diff --git a/server/src/__tests__/cloud-tenant-company-provisioning.test.ts b/server/src/__tests__/cloud-tenant-company-provisioning.test.ts new file mode 100644 index 0000000000..c57dde6d63 --- /dev/null +++ b/server/src/__tests__/cloud-tenant-company-provisioning.test.ts @@ -0,0 +1,335 @@ +import { createHash } from "node:crypto"; +import { afterAll, afterEach, beforeEach, beforeAll, describe, expect, it } from "vitest"; +import { and, eq } from "drizzle-orm"; +import { + activityLog, + authUsers, + cases, + companies, + companyMemberships, + createDb, + instanceUserRoles, + issues, + principalPermissionGrants, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { cloudActorHeaderSourceFromHeaders, resolveCloudTenantActor } from "../middleware/auth.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres tenant provisioning tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +const SERVER_TOKEN = "test-server-token"; + +function tenantHeaders(input: { stackId: string; userId: string; companyName?: string }) { + const headers: Record = { + "x-paperclip-cloud-tenant-token": SERVER_TOKEN, + "x-paperclip-cloud-user-id": input.userId, + "x-paperclip-cloud-user-email": `${input.userId}@example.com`, + "x-paperclip-cloud-stack-id": input.stackId, + "x-paperclip-cloud-stack-role": "owner", + }; + if (input.companyName) headers["x-paperclip-cloud-paperclip-company-name"] = input.companyName; + return cloudActorHeaderSourceFromHeaders(headers); +} + +/** + * The prefix a pre-name-derivation build wrote. Pinned here on purpose: the + * repair detects legacy rows by this exact value, so it must keep matching + * what those builds produced. + */ +function legacyProvisionedPrefix(stackId: string) { + return `PC${createHash("sha256").update(stackId).digest("hex").slice(0, 4).toUpperCase()}`; +} + +function legacyProvisionedDescription(stackId: string) { + return `Provisioned by Paperclip Cloud for stack ${stackId}.`; +} + +describeEmbeddedPostgres("cloud tenant company provisioning", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tenant-provisioning-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + beforeEach(() => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = SERVER_TOKEN; + }); + + afterEach(async () => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + await db.delete(activityLog); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(cases); + await db.delete(issues); + await db.delete(companies); + await db.delete(instanceUserRoles); + await db.delete(authUsers); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function readCompany(companyId: string) { + return db + .select({ + name: companies.name, + issuePrefix: companies.issuePrefix, + description: companies.description, + }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + } + + async function readIdentifiers(companyId: string) { + const issueRows = await db + .select({ identifier: issues.identifier }) + .from(issues) + .where(eq(issues.companyId, companyId)); + const caseRows = await db + .select({ identifier: cases.identifier }) + .from(cases) + .where(eq(cases.companyId, companyId)); + return { + issues: issueRows.map((row) => row.identifier).sort(), + cases: caseRows.map((row) => row.identifier).sort(), + }; + } + + describe("claim time", () => { + it("derives the issue prefix from the company name and writes no placeholder description", async () => { + const actor = await resolveCloudTenantActor( + db, + tenantHeaders({ stackId: "stack-claim-1", userId: "user-claim-1", companyName: "Acme Robotics" }), + ); + + const companyId = actor!.companyIds![0]!; + await expect(readCompany(companyId)).resolves.toEqual({ + name: "Acme Robotics", + issuePrefix: "ACM", + description: null, + }); + }); + + it("derives the prefix from the humanized stack slug when no company name header is sent", async () => { + const actor = await resolveCloudTenantActor( + db, + tenantHeaders({ stackId: "paperclip-stack-borealis", userId: "user-claim-2" }), + ); + + const companyId = actor!.companyIds![0]!; + await expect(readCompany(companyId)).resolves.toMatchObject({ + name: "Borealis", + issuePrefix: "BOR", + description: null, + }); + }); + + it("suffixes the derived prefix when another company already holds it", async () => { + await db.insert(companies).values({ name: "Acme Holdings", issuePrefix: "ACM" }); + + const actor = await resolveCloudTenantActor( + db, + tenantHeaders({ stackId: "stack-claim-3", userId: "user-claim-3", companyName: "Acme Robotics" }), + ); + + const companyId = actor!.companyIds![0]!; + await expect(readCompany(companyId)).resolves.toMatchObject({ + name: "Acme Robotics", + issuePrefix: "ACMA", + }); + }); + }); + + describe("legacy provisioning repair", () => { + /** + * Claims the company through the normal path, then rewrites it into the + * shape a pre-name-derivation build left behind. + */ + async function seedLegacyCompany(input: { + stackId: string; + claimUserId: string; + companyName: string; + issuePrefix?: string; + description?: string | null; + }) { + const actor = await resolveCloudTenantActor( + db, + tenantHeaders({ + stackId: input.stackId, + userId: input.claimUserId, + companyName: input.companyName, + }), + ); + const companyId = actor!.companyIds![0]!; + const issuePrefix = input.issuePrefix ?? legacyProvisionedPrefix(input.stackId); + await db + .update(companies) + .set({ + issuePrefix, + description: input.description === undefined + ? legacyProvisionedDescription(input.stackId) + : input.description, + }) + .where(eq(companies.id, companyId)); + await db.insert(issues).values([ + { companyId, title: "First", issueNumber: 1, identifier: `${issuePrefix}-1` }, + { companyId, title: "Second", issueNumber: 20, identifier: `${issuePrefix}-20` }, + ]); + await db.insert(cases).values({ + companyId, + caseNumber: 4, + identifier: `${issuePrefix}-C4`, + caseType: "decision", + title: "A case", + }); + return { companyId, issuePrefix }; + } + + it("re-derives the prefix, clears the placeholder, and re-keys both identifier tables", async () => { + const stackId = "stack-repair-1"; + const { companyId, issuePrefix: legacyPrefix } = await seedLegacyCompany({ + stackId, + claimUserId: "user-repair-seed-1", + companyName: "Acme Robotics", + }); + + await resolveCloudTenantActor( + db, + tenantHeaders({ stackId, userId: "user-repair-1", companyName: "Acme Robotics" }), + ); + + await expect(readCompany(companyId)).resolves.toEqual({ + name: "Acme Robotics", + issuePrefix: "ACM", + description: null, + }); + await expect(readIdentifiers(companyId)).resolves.toEqual({ + issues: ["ACM-1", "ACM-20"], + cases: ["ACM-C4"], + }); + + const logged = await db + .select({ details: activityLog.details }) + .from(activityLog) + .where(and( + eq(activityLog.companyId, companyId), + eq(activityLog.action, "company.updated"), + )); + expect(logged).toHaveLength(1); + expect(logged[0]).toMatchObject({ + details: { + source: "cloud_tenant_auth", + reason: "legacy_provision_defaults_repair", + previousIssuePrefix: legacyPrefix, + issuePrefix: "ACM", + descriptionCleared: true, + issuesRekeyed: 2, + casesRekeyed: 1, + }, + }); + }); + + it("is a no-op on a second pass", async () => { + const stackId = "stack-repair-2"; + const { companyId } = await seedLegacyCompany({ + stackId, + claimUserId: "user-repair-seed-2", + companyName: "Acme Robotics", + }); + + await resolveCloudTenantActor( + db, + tenantHeaders({ stackId, userId: "user-repair-2a", companyName: "Acme Robotics" }), + ); + const afterFirstPass = await readCompany(companyId); + + await resolveCloudTenantActor( + db, + tenantHeaders({ stackId, userId: "user-repair-2b", companyName: "Acme Robotics" }), + ); + + await expect(readCompany(companyId)).resolves.toEqual(afterFirstPass); + await expect(readIdentifiers(companyId)).resolves.toEqual({ + issues: ["ACM-1", "ACM-20"], + cases: ["ACM-C4"], + }); + // The repair logged once, on the pass that actually changed the row. + const logged = await db + .select({ id: activityLog.id }) + .from(activityLog) + .where(and( + eq(activityLog.companyId, companyId), + eq(activityLog.action, "company.updated"), + )); + expect(logged).toHaveLength(1); + }); + + it("clears only the placeholder description when the prefix is not the legacy hash", async () => { + const stackId = "stack-repair-3"; + const { companyId } = await seedLegacyCompany({ + stackId, + claimUserId: "user-repair-seed-3", + companyName: "Acme Robotics", + // A prefix the operator already re-derived or chose. + issuePrefix: "ZEN", + }); + + await resolveCloudTenantActor( + db, + tenantHeaders({ stackId, userId: "user-repair-3", companyName: "Acme Robotics" }), + ); + + await expect(readCompany(companyId)).resolves.toEqual({ + name: "Acme Robotics", + issuePrefix: "ZEN", + description: null, + }); + // Nothing was re-keyed, so the identifiers keep the prefix they had. + await expect(readIdentifiers(companyId)).resolves.toEqual({ + issues: ["ZEN-1", "ZEN-20"], + cases: ["ZEN-C4"], + }); + const logged = await db + .select({ id: activityLog.id }) + .from(activityLog) + .where(eq(activityLog.companyId, companyId)); + expect(logged).toHaveLength(0); + }); + + it("leaves an operator-written description alone while it re-derives the prefix", async () => { + const stackId = "stack-repair-4"; + const { companyId } = await seedLegacyCompany({ + stackId, + claimUserId: "user-repair-seed-4", + companyName: "Acme Robotics", + description: "We build robots.", + }); + + await resolveCloudTenantActor( + db, + tenantHeaders({ stackId, userId: "user-repair-4", companyName: "Acme Robotics" }), + ); + + await expect(readCompany(companyId)).resolves.toEqual({ + name: "Acme Robotics", + issuePrefix: "ACM", + description: "We build robots.", + }); + }); + }); +}); diff --git a/server/src/__tests__/companies-service.test.ts b/server/src/__tests__/companies-service.test.ts index d834695484..40ccfc6d18 100644 --- a/server/src/__tests__/companies-service.test.ts +++ b/server/src/__tests__/companies-service.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { and, eq } from "drizzle-orm"; import { activityLog, @@ -7,6 +7,7 @@ import { agents, agentWakeupRequests, builtInManagedResources, + cases, companies, companySkillVersions, companySkills, @@ -14,6 +15,7 @@ import { createDb, heartbeatRunEvents, heartbeatRuns, + issues, principalPermissionGrants, routines, routineTriggers, @@ -23,6 +25,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { companyService } from "../services/companies.js"; +import { deriveIssuePrefixBase } from "../services/issue-prefix.js"; import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js"; import { builtInAgentService, reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js"; @@ -58,6 +61,8 @@ describeEmbeddedPostgres("companyService", () => { await db.delete(agents); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); + await db.delete(cases); + await db.delete(issues); await db.delete(companies); }); @@ -877,4 +882,229 @@ describeEmbeddedPostgres("companyService", () => { await expect(svc.getById("")).resolves.toBeNull(); }); + describe("issue prefix re-derivation on rename", () => { + const TEST_ACTOR = { + actorType: "user" as const, + actorId: "test-user", + agentId: null, + runId: null, + }; + + beforeEach(() => { + // The tenant server token is the managed-instance signal the prefix + // re-derivation gates on. + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; + }); + + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + }); + + async function seedCompanyWithWork(name: string, issuePrefix: string) { + const [company] = await db + .insert(companies) + .values({ name, issuePrefix }) + .returning({ id: companies.id }); + const companyId = company!.id; + await db.insert(issues).values([ + { companyId, title: "First", issueNumber: 1, identifier: `${issuePrefix}-1` }, + { companyId, title: "Second", issueNumber: 12, identifier: `${issuePrefix}-12` }, + // A pre-identifier issue must survive the re-key untouched. + { companyId, title: "Unnumbered", issueNumber: null, identifier: null }, + ]); + await db.insert(cases).values({ + companyId, + caseNumber: 3, + identifier: `${issuePrefix}-C3`, + caseType: "decision", + title: "A case", + }); + return companyId; + } + + // Nulls last, so an issue that never got an identifier stays visible in + // the assertion instead of sorting unpredictably. + function sortIdentifiers(values: (string | null)[]) { + return [...values].sort((a, b) => { + if (a === null) return b === null ? 0 : 1; + if (b === null) return -1; + return a.localeCompare(b); + }); + } + + async function readIdentifiers(companyId: string) { + const issueRows = await db + .select({ identifier: issues.identifier }) + .from(issues) + .where(eq(issues.companyId, companyId)); + const caseRows = await db + .select({ identifier: cases.identifier }) + .from(cases) + .where(eq(cases.companyId, companyId)); + return { + issues: sortIdentifiers(issueRows.map((row) => row.identifier)), + cases: sortIdentifiers(caseRows.map((row) => row.identifier)), + }; + } + + it("re-derives the prefix and re-keys both identifier tables on a managed instance", async () => { + const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); + + const updated = await companyService(db).update( + companyId, + { name: "Northwind Traders" }, + TEST_ACTOR, + ); + + expect(updated).toMatchObject({ name: "Northwind Traders", issuePrefix: "NOR" }); + await expect(readIdentifiers(companyId)).resolves.toEqual({ + issues: ["NOR-1", "NOR-12", null], + cases: ["NOR-C3"], + }); + + const logged = await db + .select({ details: activityLog.details }) + .from(activityLog) + .where(and( + eq(activityLog.companyId, companyId), + eq(activityLog.action, "company.updated"), + )); + expect(logged).toHaveLength(1); + expect(logged[0]).toMatchObject({ + details: { + source: "company_rename", + reason: "issue_prefix_rederived", + previousIssuePrefix: "ACM", + issuePrefix: "NOR", + issuesRekeyed: 2, + casesRekeyed: 1, + }, + }); + }); + + it("suffixes the candidate when another company already holds the derived base", async () => { + await db.insert(companies).values({ name: "Northwind Holdings", issuePrefix: "NOR" }); + const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); + + const updated = await companyService(db).update( + companyId, + { name: "Northwind Traders" }, + TEST_ACTOR, + ); + + expect(updated?.issuePrefix).toBe("NORA"); + await expect(readIdentifiers(companyId)).resolves.toEqual({ + issues: ["NORA-1", "NORA-12", null], + cases: ["NORA-C3"], + }); + }); + + it("keeps the prefix when the rename derives the same base", async () => { + const companyId = await seedCompanyWithWork("Acme Robotics", "ACMA"); + + const updated = await companyService(db).update( + companyId, + { name: "Acme Robotics International" }, + TEST_ACTOR, + ); + + // The suffixed prefix is kept: the base did not move, so nothing needs + // to be re-keyed and no disambiguating suffix is lost. + expect(updated?.issuePrefix).toBe("ACMA"); + await expect(readIdentifiers(companyId)).resolves.toEqual({ + issues: ["ACMA-1", "ACMA-12", null], + cases: ["ACMA-C3"], + }); + const logged = await db + .select({ id: activityLog.id }) + .from(activityLog) + .where(eq(activityLog.companyId, companyId)); + expect(logged).toHaveLength(0); + }); + + it("keeps identifiers and the company prefix together under concurrent renames", async () => { + const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); + const svc = companyService(db); + + // Both renames read the company before either commits. Without a row + // lock the loser re-keys from the prefix it read, finds nothing left to + // move, and strands the identifiers on the winner's prefix while the + // company row carries its own. + await Promise.all([ + svc.update(companyId, { name: "Northwind Traders" }, TEST_ACTOR), + svc.update(companyId, { name: "Zenith Freight" }, TEST_ACTOR), + ]); + + const [company] = await db + .select({ issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)); + const prefix = company!.issuePrefix; + expect(["NOR", "ZEN"]).toContain(prefix); + await expect(readIdentifiers(companyId)).resolves.toEqual({ + issues: [`${prefix}-1`, `${prefix}-12`, null], + cases: [`${prefix}-C3`], + }); + }); + + it("keeps the prefix consistent when a stale form resubmits the old name during a rename", async () => { + const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); + const svc = companyService(db); + + // The second caller submits the name it loaded before the rename. Judged + // against its own stale read that name looks unchanged, so a pre-lock + // comparison would skip re-derivation and restore "Acme Robotics" on top + // of the rename's prefix. + await Promise.all([ + svc.update(companyId, { name: "Northwind Traders" }, TEST_ACTOR), + svc.update(companyId, { name: "Acme Robotics" }, TEST_ACTOR), + ]); + + const [company] = await db + .select({ name: companies.name, issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)); + // Whichever update commits last, the prefix derives from the name that + // survived, and the identifiers sit on that prefix. + expect(company!.issuePrefix.startsWith(deriveIssuePrefixBase(company!.name))).toBe(true); + await expect(readIdentifiers(companyId)).resolves.toEqual({ + issues: [`${company!.issuePrefix}-1`, `${company!.issuePrefix}-12`, null], + cases: [`${company!.issuePrefix}-C3`], + }); + }); + + it("leaves the prefix alone when only non-name fields change", async () => { + const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); + + const updated = await companyService(db).update( + companyId, + { description: "Now with rockets" }, + TEST_ACTOR, + ); + + expect(updated?.issuePrefix).toBe("ACM"); + await expect(readIdentifiers(companyId)).resolves.toEqual({ + issues: ["ACM-1", "ACM-12", null], + cases: ["ACM-C3"], + }); + }); + + it("never touches the prefix on a self-hosted instance", async () => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + const companyId = await seedCompanyWithWork("Acme Robotics", "ACM"); + + const updated = await companyService(db).update( + companyId, + { name: "Northwind Traders" }, + TEST_ACTOR, + ); + + expect(updated).toMatchObject({ name: "Northwind Traders", issuePrefix: "ACM" }); + await expect(readIdentifiers(companyId)).resolves.toEqual({ + issues: ["ACM-1", "ACM-12", null], + cases: ["ACM-C3"], + }); + }); + }); + }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index c073f14406..2f79859147 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -12,6 +12,14 @@ import { heartbeatRuns, instanceUserRoles, } from "@paperclipai/db"; +import { + MAX_ISSUE_PREFIX_ATTEMPTS, + deriveIssuePrefixBase, + isIssuePrefixConflict, + issuePrefixSuffixForAttempt, + pickAvailableIssuePrefix, + rekeyCompanyIssueIdentifiers, +} from "../services/issue-prefix.js"; import { verifyLocalAgentJwt } from "../agent-auth-jwt.js"; import { isUuidLike, normalizeAgentApiKeyScope, type DeploymentMode } from "@paperclipai/shared"; import type { BetterAuthSessionResult } from "../auth/better-auth.js"; @@ -554,19 +562,7 @@ export async function resolveCloudTenantActor( .delete(instanceUserRoles) .where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin"))); - if (shouldSync) await db - .insert(companies) - .values({ - id: companyId, - name: companyName, - description: `Provisioned by Paperclip Cloud for stack ${stackId}.`, - status: "active", - issuePrefix: issuePrefixForCloudStack(stackId), - updatedAt: now, - }) - .onConflictDoNothing({ - target: companies.id, - }); + if (shouldSync) await insertCloudTenantCompany(db, { companyId, companyName, now }); if (shouldSync && paperclipCompanyName) { await repairCloudTenantCompanyName(db, { @@ -577,6 +573,13 @@ export async function resolveCloudTenantActor( }); } + // Runs after the name repair so the prefix derives from the repaired name. + // The helper self-gates on the legacy markers, so it is a no-op once the + // company has been repaired or was claimed by a current build. + if (shouldSync) { + await repairCloudTenantCompanyProvisionDefaults(db, { companyId, stackId, now }); + } + effectiveMembership = shouldSync ? await db .insert(companyMemberships) .values({ @@ -786,11 +789,153 @@ async function repairCloudTenantCompanyName( } } -function issuePrefixForCloudStack(stackId: string): string { +/** + * Claims the tenant company row for this stack. + * + * The prefix derives from the company name, exactly as it does for a + * self-hosted company. Each attempt is a standalone INSERT, so a failed + * attempt is its own implicit transaction and cannot poison a surrounding + * one. `onConflictDoNothing` only absorbs the `companies.id` conflict — a + * prefix that another company already holds still raises `23505`, so the loop + * moves on to the next suffix. + */ +async function insertCloudTenantCompany( + db: Db, + input: { companyId: string; companyName: string; now: Date }, +): Promise { + const base = deriveIssuePrefixBase(input.companyName); + for (let attempt = 1; attempt <= MAX_ISSUE_PREFIX_ATTEMPTS; attempt += 1) { + try { + await db + .insert(companies) + .values({ + id: input.companyId, + name: input.companyName, + description: null, + status: "active", + issuePrefix: `${base}${issuePrefixSuffixForAttempt(attempt)}`, + updatedAt: input.now, + }) + .onConflictDoNothing({ + target: companies.id, + }); + return; + } catch (error) { + if (!isIssuePrefixConflict(error)) throw error; + } + } + throw new Error("Unable to allocate a unique issue prefix for the tenant company"); +} + +/** + * The issue prefix that pre-name-derivation builds gave a tenant company. + * + * This derivation survives only as the detector for the one-time repair + * below. Nothing mints a prefix this way any more. + */ +function legacyProvisionedIssuePrefix(stackId: string): string { const hash = createHash("sha256").update(stackId).digest("hex").slice(0, 4).toUpperCase(); return `PC${hash}`; } +/** The placeholder description that pre-name-derivation builds wrote. */ +const LEGACY_PROVISIONED_DESCRIPTION_PREFIX = "Provisioned by Paperclip Cloud for stack "; + +/** + * One-time repair for companies claimed by a pre-name-derivation build. + * + * Those companies carry an opaque hash prefix and a placeholder description + * that the operator never chose. Re-derive the prefix from the company's + * current name, re-key the stored issue and case identifiers onto it, and drop + * the placeholder. Both guards stop matching once the repair lands, so a later + * pass is a no-op. + */ +async function repairCloudTenantCompanyProvisionDefaults( + db: Db, + input: { companyId: string; stackId: string; now: Date }, +): Promise { + try { + const existing = await db + .select({ + name: companies.name, + issuePrefix: companies.issuePrefix, + description: companies.description, + }) + .from(companies) + .where(eq(companies.id, input.companyId)) + .then((rows) => rows[0]); + if (!existing) return; + + const legacyPrefix = legacyProvisionedIssuePrefix(input.stackId); + const legacyDescription = existing.description?.startsWith(LEGACY_PROVISIONED_DESCRIPTION_PREFIX) + ? existing.description + : null; + if (existing.issuePrefix !== legacyPrefix) { + if (!legacyDescription) return; + // The prefix was already re-derived, so only the placeholder is left. + await db + .update(companies) + .set({ description: null, updatedAt: input.now }) + .where(and( + eq(companies.id, input.companyId), + eq(companies.description, legacyDescription), + )); + return; + } + + await db.transaction(async (tx) => { + const candidate = await pickAvailableIssuePrefix(tx, deriveIssuePrefixBase(existing.name)); + if (!candidate || candidate === legacyPrefix) return; + + const [updated] = await tx + .update(companies) + .set({ + issuePrefix: candidate, + ...(legacyDescription ? { description: null } : {}), + updatedAt: input.now, + }) + .where(and( + eq(companies.id, input.companyId), + // A user may rename the company between the read above and this + // repair. Match the exact observed legacy prefix so that concurrent + // genuine renames always win. + eq(companies.issuePrefix, legacyPrefix), + )) + .returning({ id: companies.id }); + if (!updated) return; + + const rekeyed = await rekeyCompanyIssueIdentifiers(tx, { + companyId: input.companyId, + fromPrefix: legacyPrefix, + toPrefix: candidate, + }); + + await tx.insert(activityLog).values({ + companyId: input.companyId, + actorType: "system", + actorId: "cloud-tenant-auth", + action: "company.updated", + entityType: "company", + entityId: input.companyId, + details: { + source: "cloud_tenant_auth", + reason: "legacy_provision_defaults_repair", + previousIssuePrefix: legacyPrefix, + issuePrefix: candidate, + descriptionCleared: legacyDescription !== null, + issuesRekeyed: rekeyed.issues, + casesRekeyed: rekeyed.cases, + }, + }); + }); + } catch (err) { + logger.warn( + { err, companyId: input.companyId }, + "Failed to repair legacy tenant company provisioning defaults", + ); + } +} + export function requireBoard(req: Express.Request) { return req.actor.type === "board"; } diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index 2105650a1c..4a42f636f7 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -34,6 +34,15 @@ import { routines, } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; +import { isCloudManagedInstance } from "./cloud-instance.js"; +import { + MAX_ISSUE_PREFIX_ATTEMPTS, + deriveIssuePrefixBase, + isIssuePrefixConflict, + issuePrefixSuffixForAttempt, + pickAvailableIssuePrefix, + rekeyCompanyIssueIdentifiers, +} from "./issue-prefix.js"; import { environmentService } from "./environments.js"; import { heartbeatService } from "./heartbeat.js"; import { logActivity } from "./activity-log.js"; @@ -56,7 +65,6 @@ const SYSTEM_COMPANY_ACTOR: CompanyActivityActor = { }; export function companyService(db: Db) { - const ISSUE_PREFIX_FALLBACK = "CMP"; const environmentsSvc = environmentService(db); const heartbeat = heartbeatService(db); const builtInAgents = builtInAgentService(db); @@ -209,36 +217,69 @@ export function companyService(db: Db) { .leftJoin(companyLogos, eq(companyLogos.companyId, companies.id)); } - function deriveIssuePrefixBase(name: string) { - const normalized = name.toUpperCase().replace(/[^A-Z]/g, ""); - return normalized.slice(0, 3) || ISSUE_PREFIX_FALLBACK; - } + /** + * Decides whether a rename must move the company onto a new issue prefix, and + * returns the exact prefix pair to re-key. + * + * Self-hosted companies pick their prefix from the name at creation and keep + * it, so a rename leaves the prefix alone. On a hosted/managed instance the + * company is provisioned for the operator, so the name is the only prefix + * source the operator ever chose — a rename re-derives it. Returns null when + * the current prefix is already correct or when the suffix space is + * exhausted. + */ + async function resolveRenamedIssuePrefix( + tx: CompanyTx, + companyId: string, + companyPatch: Partial, + ): Promise<{ fromPrefix: string; toPrefix: string } | null> { + // Only patch and environment facts gate the lock. Every comparison against + // the company's own name or prefix happens below, under the lock. + // An explicit prefix in the patch is the caller's decision; never override it. + if (companyPatch.issuePrefix !== undefined) return null; + const nextName = companyPatch.name; + if (typeof nextName !== "string" || nextName.trim().length === 0) return null; + if (!isCloudManagedInstance()) return null; - function suffixForAttempt(attempt: number) { - if (attempt <= 1) return ""; - return "A".repeat(attempt - 1); - } + // Lock the company row before comparing anything against it. Two concurrent + // updates would otherwise each decide from the row they read before either + // committed, and both ways of getting that wrong end with a company whose + // prefix disagrees with its own identifiers: + // + // - Two renames: the second re-keys from the prefix it read, finds the + // identifiers the first already moved, and leaves them on the first + // rename's prefix while the row carries the second one's. + // - A rename plus a stale form that resubmits the original name: the + // second sees a name equal to the one it read, skips re-derivation, and + // restores the old name on top of the first rename's prefix. + // + // Reading the row under the lock makes the second transaction decide from + // what the first actually committed. Only a managed instance takes this + // lock, and only for an update that carries a name. + const locked = await tx + .select({ name: companies.name, issuePrefix: companies.issuePrefix }) + .from(companies) + .where(eq(companies.id, companyId)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!locked || nextName === locked.name) return null; - function isIssuePrefixConflict(error: unknown) { - const seen = new Set(); - let current = error; - while (typeof current === "object" && current !== null && !seen.has(current)) { - seen.add(current); - const maybe = current as { code?: string; constraint?: string; constraint_name?: string; cause?: unknown }; - const constraint = maybe.constraint ?? maybe.constraint_name; - if (maybe.code === "23505" && constraint === "companies_issue_prefix_idx") { - return true; - } - current = maybe.cause; - } - return false; + const nextBase = deriveIssuePrefixBase(nextName); + // A rename that keeps the same base keeps the current prefix, including + // any disambiguating suffix it was allocated. + if (nextBase === deriveIssuePrefixBase(locked.name)) return null; + if (nextBase === locked.issuePrefix) return null; + + const candidate = await pickAvailableIssuePrefix(tx, nextBase); + if (!candidate || candidate === locked.issuePrefix) return null; + return { fromPrefix: locked.issuePrefix, toPrefix: candidate }; } async function createCompanyWithUniquePrefix(data: typeof companies.$inferInsert) { const base = deriveIssuePrefixBase(data.name); let suffix = 1; - while (suffix < 10000) { - const candidate = `${base}${suffixForAttempt(suffix)}`; + while (suffix <= MAX_ISSUE_PREFIX_ATTEMPTS) { + const candidate = `${base}${issuePrefixSuffixForAttempt(suffix)}`; try { const rows = await db .insert(companies) @@ -313,14 +354,40 @@ export function companyService(db: Db) { } } + const renamedPrefix = await resolveRenamedIssuePrefix(tx, id, companyPatch); + const updated = await tx .update(companies) - .set({ ...companyPatch, updatedAt: new Date() }) + .set({ + ...companyPatch, + ...(renamedPrefix ? { issuePrefix: renamedPrefix.toPrefix } : {}), + updatedAt: new Date(), + }) .where(eq(companies.id, id)) .returning() .then((rows) => rows[0] ?? null); if (!updated) return null; + let issuePrefixRederived: { + previousIssuePrefix: string; + issuePrefix: string; + issuesRekeyed: number; + casesRekeyed: number; + } | null = null; + if (renamedPrefix) { + const rekeyed = await rekeyCompanyIssueIdentifiers(tx, { + companyId: id, + fromPrefix: renamedPrefix.fromPrefix, + toPrefix: renamedPrefix.toPrefix, + }); + issuePrefixRederived = { + previousIssuePrefix: renamedPrefix.fromPrefix, + issuePrefix: renamedPrefix.toPrefix, + issuesRekeyed: rekeyed.issues, + casesRekeyed: rekeyed.cases, + }; + } + let agentsRestored = 0; if (willReactivate) { const restoredRows = await tx @@ -376,9 +443,27 @@ export function companyService(db: Db) { company: enrichCompany(hydrated), reactivated: shouldLogReactivation ? { agentsRestored } : null, archiveCascade, + issuePrefixRederived, }; }); if (!result) return null; + if (result.issuePrefixRederived) { + await logActivity(db, { + companyId: id, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId ?? null, + runId: actor.runId ?? null, + action: "company.updated", + entityType: "company", + entityId: id, + details: { + source: "company_rename", + reason: "issue_prefix_rederived", + ...result.issuePrefixRederived, + }, + }); + } if (result.reactivated) { await logActivity(db, { companyId: id, diff --git a/server/src/services/issue-prefix.test.ts b/server/src/services/issue-prefix.test.ts new file mode 100644 index 0000000000..b9e05c0e19 --- /dev/null +++ b/server/src/services/issue-prefix.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { + ISSUE_PREFIX_FALLBACK, + MAX_ISSUE_PREFIX_ATTEMPTS, + deriveIssuePrefixBase, + isIssuePrefixConflict, + issuePrefixSuffixForAttempt, + pickAvailableIssuePrefix, + type IssuePrefixReadDb, +} from "./issue-prefix.js"; + +/** + * Minimal select stub shaped like the one chain pickAvailableIssuePrefix uses: + * select(...).from(companies).where(condition).then(...). + */ +function stubPrefixReadDb(takenPrefixes: string[]) { + const conditions: unknown[] = []; + const db = { + select: () => ({ + from: () => ({ + where: (condition: unknown) => { + conditions.push(condition); + return { + then: (resolve: (rows: unknown) => unknown) => + Promise.resolve(takenPrefixes.map((issuePrefix) => ({ issuePrefix }))).then(resolve), + }; + }, + }), + }), + } as unknown as IssuePrefixReadDb; + return { db, conditions }; +} + +describe("deriveIssuePrefixBase", () => { + it("takes the first three letters of the name, uppercased", () => { + expect(deriveIssuePrefixBase("Acme Robotics")).toBe("ACM"); + expect(deriveIssuePrefixBase("northwind")).toBe("NOR"); + }); + + it("ignores digits, punctuation, and whitespace", () => { + expect(deriveIssuePrefixBase("3 M-Labs")).toBe("MLA"); + expect(deriveIssuePrefixBase(" a b c d ")).toBe("ABC"); + }); + + it("keeps a short name short rather than padding it", () => { + expect(deriveIssuePrefixBase("Hi")).toBe("HI"); + }); + + it("falls back when the name has no letters at all", () => { + expect(deriveIssuePrefixBase("2026 // 42")).toBe(ISSUE_PREFIX_FALLBACK); + expect(deriveIssuePrefixBase("")).toBe(ISSUE_PREFIX_FALLBACK); + }); + + it("never emits a LIKE metacharacter, so the base is safe to interpolate", () => { + for (const name of ["100%", "a_b_c", "back\\slash", "何か"]) { + expect(deriveIssuePrefixBase(name)).toMatch(/^[A-Z]{1,3}$/); + } + }); +}); + +describe("issuePrefixSuffixForAttempt", () => { + it("leaves the first attempt unsuffixed and grows by one A per retry", () => { + expect(issuePrefixSuffixForAttempt(1)).toBe(""); + expect(issuePrefixSuffixForAttempt(2)).toBe("A"); + expect(issuePrefixSuffixForAttempt(3)).toBe("AA"); + }); + + it("treats a non-positive attempt as the first attempt", () => { + expect(issuePrefixSuffixForAttempt(0)).toBe(""); + expect(issuePrefixSuffixForAttempt(-5)).toBe(""); + }); +}); + +describe("isIssuePrefixConflict", () => { + it("matches the raw driver error", () => { + expect(isIssuePrefixConflict({ code: "23505", constraint: "companies_issue_prefix_idx" })).toBe(true); + }); + + it("walks the cause chain Drizzle wraps the driver error in", () => { + const wrapped = new Error("Failed query") as Error & { cause?: unknown }; + wrapped.cause = { code: "23505", constraint_name: "companies_issue_prefix_idx" }; + expect(isIssuePrefixConflict(wrapped)).toBe(true); + }); + + it("ignores a unique violation on a different constraint", () => { + expect(isIssuePrefixConflict({ code: "23505", constraint: "issues_identifier_idx" })).toBe(false); + }); + + it("ignores a different error code on the same constraint", () => { + expect(isIssuePrefixConflict({ code: "23503", constraint: "companies_issue_prefix_idx" })).toBe(false); + }); + + it("terminates on a self-referential cause chain", () => { + const cyclic: { code: string; cause?: unknown } = { code: "42P01" }; + cyclic.cause = cyclic; + expect(isIssuePrefixConflict(cyclic)).toBe(false); + }); + + it("ignores values that carry no error shape", () => { + expect(isIssuePrefixConflict(null)).toBe(false); + expect(isIssuePrefixConflict("23505")).toBe(false); + }); +}); + +describe("pickAvailableIssuePrefix", () => { + it("returns the bare base when nothing holds it", async () => { + const { db, conditions } = stubPrefixReadDb([]); + await expect(pickAvailableIssuePrefix(db, "ACM")).resolves.toBe("ACM"); + // The read narrows to the base's own suffix family, not the whole table, + // and it compares an exact head rather than a LIKE pattern, so a base is + // never interpreted as one. + expect(conditions).toHaveLength(1); + const query = new PgDialect().sqlToQuery(conditions[0] as SQL); + expect(query.sql).toBe('left("companies"."issue_prefix", $1::int) = $2'); + expect(query.params).toEqual([3, "ACM"]); + }); + + it("compares against a base that carries LIKE metacharacters without treating it as a pattern", async () => { + const { db, conditions } = stubPrefixReadDb([]); + await expect(pickAvailableIssuePrefix(db, "A%_")).resolves.toBe("A%_"); + expect(new PgDialect().sqlToQuery(conditions[0] as SQL).params).toEqual([3, "A%_"]); + }); + + it("skips every prefix already taken in the same family", async () => { + const { db } = stubPrefixReadDb(["ACM", "ACMA", "ACMB"]); + // ACMB belongs to a different naming scheme, so it does not block ACMAA. + await expect(pickAvailableIssuePrefix(db, "ACM")).resolves.toBe("ACMAA"); + }); + + it("ignores rows from an unrelated family the pattern happened to return", async () => { + const { db } = stubPrefixReadDb(["ACMEX"]); + await expect(pickAvailableIssuePrefix(db, "ACM")).resolves.toBe("ACM"); + }); + + it("returns null when the whole suffix space is taken", async () => { + const exhausted = Array.from( + { length: MAX_ISSUE_PREFIX_ATTEMPTS }, + (_, index) => `AC${"A".repeat(index)}`, + ); + const { db } = stubPrefixReadDb(exhausted); + await expect(pickAvailableIssuePrefix(db, "AC")).resolves.toBeNull(); + }); +}); diff --git a/server/src/services/issue-prefix.ts b/server/src/services/issue-prefix.ts new file mode 100644 index 0000000000..5e31f9bd7f --- /dev/null +++ b/server/src/services/issue-prefix.ts @@ -0,0 +1,143 @@ +import { and, eq, sql } from "drizzle-orm"; +import type { PgColumn } from "drizzle-orm/pg-core"; +import type { Db } from "@paperclipai/db"; +import { cases, companies, issues } from "@paperclipai/db"; + +type DbTransaction = Parameters[0]>[0]; + +/** Read surface shared by the root client and a transaction handle. */ +export type IssuePrefixReadDb = Pick; +/** Write surface shared by the root client and a transaction handle. */ +export type IssuePrefixWriteDb = Pick; + +/** Prefix used when a company name has no letters to derive from. */ +export const ISSUE_PREFIX_FALLBACK = "CMP"; + +/** + * Upper bound on suffix attempts. The suffix is a run of "A" characters, so + * the loop is bounded to keep a pathological data set from spinning forever. + */ +export const MAX_ISSUE_PREFIX_ATTEMPTS = 10_000; + +/** + * The letters a company name contributes to its issue prefix. + * + * The result is always `[A-Z]{1,3}`, which matters for + * {@link pickAvailableIssuePrefix}: the base is interpolated into a LIKE + * pattern, and letters carry no LIKE metacharacters. + */ +export function deriveIssuePrefixBase(name: string) { + const normalized = name.toUpperCase().replace(/[^A-Z]/g, ""); + return normalized.slice(0, 3) || ISSUE_PREFIX_FALLBACK; +} + +/** Disambiguating suffix for the nth allocation attempt: "", "A", "AA", ... */ +export function issuePrefixSuffixForAttempt(attempt: number) { + if (attempt <= 1) return ""; + return "A".repeat(attempt - 1); +} + +/** + * Detects the issue-prefix unique violation through Drizzle's wrapper errors. + * + * Drizzle re-throws driver errors inside a `DrizzleQueryError`, so the real + * `23505` sits somewhere down the `.cause` chain. The `seen` set guards + * against a self-referential chain. + */ +export function isIssuePrefixConflict(error: unknown) { + const seen = new Set(); + let current = error; + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const maybe = current as { code?: string; constraint?: string; constraint_name?: string; cause?: unknown }; + const constraint = maybe.constraint ?? maybe.constraint_name; + if (maybe.code === "23505" && constraint === "companies_issue_prefix_idx") { + return true; + } + current = maybe.cause; + } + return false; +} + +/** + * Returns the first free `${base}${suffix}` prefix, or null when the whole + * suffix space is taken. + * + * A standalone INSERT can simply retry on the unique violation, because each + * failed statement is its own implicit transaction. Inside an explicit + * transaction it cannot: a unique violation aborts the whole transaction, so + * every later statement fails with "current transaction is aborted". Callers + * that already hold a transaction must therefore pick first and then write. + * That leaves a small race — another company can claim the same prefix between + * this read and the caller's UPDATE — and the loser sees the unique violation + * surface from its own statement. The write is client-retryable, and the + * collision only happens when two companies are renamed onto the same base at + * the same moment. + * + * Prefixes are read from the companies table alone. An orphan identifier left + * behind by a partially applied re-key would not be visible here, and the + * caller would see the identifier unique index reject the write instead. + */ +export async function pickAvailableIssuePrefix( + database: IssuePrefixReadDb, + base: string, +): Promise { + const taken = new Set( + await database + .select({ issuePrefix: companies.issuePrefix }) + .from(companies) + // An exact head comparison rather than LIKE, so a base is never read as a + // pattern. `deriveIssuePrefixBase` cannot produce a LIKE metacharacter, + // but this helper does not get to assume its caller used it. + .where(sql`left(${companies.issuePrefix}, ${base.length}::int) = ${base}`) + .then((rows) => rows.map((row) => row.issuePrefix)), + ); + for (let attempt = 1; attempt <= MAX_ISSUE_PREFIX_ATTEMPTS; attempt += 1) { + const candidate = `${base}${issuePrefixSuffixForAttempt(attempt)}`; + if (!taken.has(candidate)) return candidate; + } + return null; +} + +/** + * Rewrites the stored issue and case identifiers of one company onto a new + * prefix. + * + * Issue identifiers mint as `${prefix}-${issueNumber}` and case identifiers as + * `${prefix}-C${caseNumber}`, so both start with `${prefix}-` and one head + * comparison covers both tables. Only the prefix is replaced; the separator and + * the number after it are preserved. The statements run in the caller's + * transaction so the company row and its identifiers move together. + */ +export async function rekeyCompanyIssueIdentifiers( + tx: IssuePrefixWriteDb, + input: { companyId: string; fromPrefix: string; toPrefix: string }, +): Promise<{ issues: number; cases: number }> { + const { companyId, fromPrefix, toPrefix } = input; + if (fromPrefix === toPrefix) return { issues: 0, cases: 0 }; + + const legacyHead = `${fromPrefix}-`; + // An exact head comparison rather than LIKE, so a stored prefix is never read + // as a pattern. + const matchesLegacyHead = (identifier: PgColumn) => + sql`left(${identifier}, ${legacyHead.length}::int) = ${legacyHead}`; + // 1-based index of the "-" that follows the prefix, so the tail keeps its + // separator and number. The `::int` cast is load-bearing: the driver binds + // the parameter as text, and `substring(text from text)` is the SQL-regex + // overload, which returns NULL for a non-pattern argument. + const tailStart = sql`${fromPrefix.length + 1}::int`; + + const rekeyedIssues = await tx + .update(issues) + .set({ identifier: sql`${toPrefix} || substring(${issues.identifier} from ${tailStart})` }) + .where(and(eq(issues.companyId, companyId), matchesLegacyHead(issues.identifier))) + .returning({ id: issues.id }); + + const rekeyedCases = await tx + .update(cases) + .set({ identifier: sql`${toPrefix} || substring(${cases.identifier} from ${tailStart})` }) + .where(and(eq(cases.companyId, companyId), matchesLegacyHead(cases.identifier))) + .returning({ id: cases.id }); + + return { issues: rekeyedIssues.length, cases: rekeyedCases.length }; +} diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index d78cc89c21..1b0284b6ed 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -8,6 +8,7 @@ import { } from "@paperclipai/shared"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCloudInstance } from "../hooks/useCloudInstance"; import { companiesApi } from "../api/companies"; import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; @@ -39,6 +40,9 @@ export function CompanySettings() { } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); + // Managed instances derive the task ID prefix from the company name, so a + // rename here also renumbers the existing task IDs. + const isCloudManaged = Boolean(useCloudInstance()); // General settings local state const [companyName, setCompanyName] = useState(""); const [description, setDescription] = useState(""); @@ -214,6 +218,12 @@ export function CompanySettings() { value={companyName} onChange={(e) => setCompanyName(e.target.value)} /> + {isCloudManaged && ( +

+ Renaming can change this company's task ID prefix. Existing task IDs are + renumbered and old task links stop resolving. +

+ )} ({ + update: vi.fn(), + archive: vi.fn(), +})); + +const mockAssetsApi = vi.hoisted(() => ({ + uploadCompanyLogo: vi.fn(), +})); + +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn()); + +const SELECTED_COMPANY = { + id: "company-1", + name: "Acme Robotics", + description: null, + status: "active", + issuePrefix: "ACM", + brandColor: null, + logoUrl: null, + attachmentMaxBytes: null, + requireBoardApprovalForNewAgents: false, + interactionResolverGovernance: {}, +}; + +vi.mock("../api/companies", () => ({ companiesApi: mockCompaniesApi })); +vi.mock("../api/assets", () => ({ assetsApi: mockAssetsApi })); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [SELECTED_COMPANY], + selectedCompany: SELECTED_COMPANY, + selectedCompanyId: SELECTED_COMPANY.id, + setSelectedCompanyId: mockSetSelectedCompanyId, + }), +})); + +// Both panels below the name field own their own queries and are not part of +// what this test covers. +vi.mock("../components/InteractionGovernancePanel", () => ({ + InteractionGovernancePanel: () => null, + applyGovernanceChange: (governance: unknown) => governance, +})); + +vi.mock("./InstanceGeneralSettings", () => ({ + InstanceGeneralSettings: () => null, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const CLOUD_HEALTH = { + status: "ok" as const, + cloud: { + managed: true as const, + managedBy: "paperclip-cloud" as const, + stackSlug: "acme-labs", + cloudBaseUrl: "https://cloud.example.test", + }, +}; + +const SELF_HOSTED_HEALTH = { status: "ok" as const, cloud: null }; + +const RENAME_HINT = + "Renaming can change this company's task ID prefix. Existing task IDs are renumbered and old task links stop resolving."; + +describe("CompanySettings rename hint", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + function render(health: unknown) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + // CloudAccessGate owns the health fetch in the app; seeding the cache is how + // useCloudInstance sees a managed instance under test. + queryClient.setQueryData(queryKeys.health, health); + const root = createRoot(container); + flushSync(() => { + root.render( + + + + + , + ); + }); + return root; + } + + function hintText() { + return Array.from(container.querySelectorAll("p")).find( + (element) => element.textContent?.trim() === RENAME_HINT, + ); + } + + it("warns that a rename re-keys task IDs on a managed instance", () => { + const root = render(CLOUD_HEALTH); + expect(hintText()).toBeDefined(); + flushSync(() => root.unmount()); + }); + + it("stays silent on a self-hosted instance, where a rename keeps the prefix", () => { + const root = render(SELF_HOSTED_HEALTH); + expect(hintText()).toBeUndefined(); + flushSync(() => root.unmount()); + }); +});