feat(server): derive hosted-tenant issue prefixes from the company name and follow renames (#12292)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Every company has an issue prefix. It is the visible half of each task and case identifier, and a self-hosted company derives it from the name it was created with > - A hosted or managed instance does not use the create-company flow. The trusted-header auth path claims the tenant company instead > - That path minted the prefix from a hash of the stack id, and it wrote a placeholder description that nobody chose > - So a hosted company showed opaque task IDs such as `PC7F2A-14`, and a rename never changed them > - This pull request derives the prefix from the company name on that path too. It re-derives the prefix when the name changes on a managed instance, and it rewrites the stored issue and case identifiers so existing tasks follow the rename > - It also repairs each company that an earlier build claimed. The repair runs once, on the next authenticated request > - The benefit is that task IDs on a hosted instance read like the ones on a self-hosted instance, and they stay correct after a rename ## Linked Issues or Issue Description No public issue exists. The description below follows `.github/ISSUE_TEMPLATE/enhancement.yml`. **What existing behavior does this improve?** The tenant company claim in `resolveCloudTenantActor` (`server/src/middleware/auth.ts`) and the company update in `companyService.update` (`server/src/services/companies.ts`). Both decide the `issue_prefix` and the `description` of a company on a hosted or managed instance. **Subsystem affected** `server/` — REST API and orchestration services. One small hint was also added in `ui/`. **Current behavior** A self-hosted company gets its issue prefix from its name. "Acme Robotics" becomes `ACM`, and its tasks read `ACM-14`. A hosted or managed instance claims the company through the trusted-header auth path. That path wrote a different prefix: `"PC"` plus the first four hex characters of the SHA-256 of the stack id. The same path also wrote a placeholder description, `"Provisioned by ... for stack <stack id>."`. The result is a task ID such as `PC7F2A-14`. It says nothing about the company. A later rename of the company does not change it, because nothing re-derives the prefix after creation. **Proposed behavior** The claim path derives the prefix from the company name, exactly as the create-company flow does. It writes no description. On a managed instance, a rename re-derives the prefix. The stored issue and case identifiers move with it, so `ACM-14` becomes `NOR-14` when "Acme Robotics" becomes "Northwind Traders". A rename that keeps the same three-letter base keeps the current prefix, including any disambiguating suffix. A self-hosted instance is unchanged. A rename there still keeps the prefix the company was created with. Companies that an earlier build already claimed get a one-time repair on their next authenticated request. The repair re-derives the prefix from the current name, re-keys the identifiers, and clears the placeholder description. **Reason and benefit** A task ID is the primary handle for a task. People type it, paste it into chat, and read it in a URL. On a hosted instance that handle was an opaque hash, and it disagreed with the company name that the same user chose during signup. The name is the only prefix source a hosted user ever supplies, so the prefix now follows it. **Breaking changes** Yes, on hosted and managed instances only. A company rename now rewrites the stored issue and case identifiers. Links that carry an old identifier stop resolving after the rename. The company settings page states this before the user saves. The one-time repair applies the same rewrite once to companies that carry the old hash prefix. Self-hosted behavior does not change. ## What Changed - Added `server/src/services/issue-prefix.ts`. It holds the prefix helpers that used to live inside the `companyService` closure: `ISSUE_PREFIX_FALLBACK`, `deriveIssuePrefixBase`, `issuePrefixSuffixForAttempt`, and `isIssuePrefixConflict`. The companies service now imports them. - Added `pickAvailableIssuePrefix` to that module. It reads the prefixes in one base family and returns the first free candidate. A standalone `INSERT` can retry on a unique violation, because each failed statement is its own implicit transaction. A caller that already holds a transaction cannot, because the violation aborts the whole transaction. Such a caller picks first, then writes. - Added `rekeyCompanyIssueIdentifiers` to that module. It rewrites the prefix of the stored `issues.identifier` and `cases.identifier` values of one company in the caller's transaction, and it returns the two row counts. - `companyService.update` re-derives the prefix when the name changes on a managed instance, re-keys both tables in the same transaction, and writes a `company.updated` activity entry after the commit. - `resolveCloudTenantActor` claims the company with a name-derived prefix and a null description. The claim retries with the next suffix when the prefix is taken. - `resolveCloudTenantActor` also runs a one-time repair for companies that carry the old hash prefix. An exact-match fence on the update lets a concurrent rename win. The repair is idempotent, because its guards stop matching after it lands. - The rename takes a row lock on the company before it compares anything against it, and it re-keys from the prefix it reads under that lock. Only patch and environment facts gate the lock, so no stale read can steer the decision. Two overlapping updates would otherwise leave a company whose prefix disagrees with its own identifiers, in either direction: two renames, where the second re-keys from a prefix the first already moved; or a rename plus a stale form that resubmits the original name, where the second sees an unchanged name, skips re-derivation, and restores the old name on top of the first rename's prefix. Only a managed instance takes the lock, and only for an update that carries a name. - Both helpers compare an exact identifier head instead of a LIKE pattern. A stored prefix is data, so it must never be read as a pattern. - The company settings page shows a hint under the name field on a managed instance: renaming can change the task ID prefix. ## Verification Automated tests: ``` pnpm --filter @paperclipai/server exec vitest run \ src/services/issue-prefix.test.ts \ src/__tests__/companies-service.test.ts \ src/__tests__/cloud-tenant-company-provisioning.test.ts \ src/middleware/cloud-tenant-actor.test.ts \ src/__tests__/auth-session-route.test.ts \ src/__tests__/cloud-routes.test.ts \ src/__tests__/cloud-instance.test.ts \ src/__tests__/company-branding-route.test.ts \ src/__tests__/company-cloud-floor.test.ts \ src/__tests__/companies-route-cross-company-authz.test.ts \ src/__tests__/companies-route-path-guard.test.ts \ src/__tests__/company-portability.test.ts pnpm --filter @paperclipai/ui exec vitest run pnpm --filter @paperclipai/ui typecheck ``` New coverage: - `server/src/services/issue-prefix.test.ts` covers the derivation, the suffix ladder, the cause-chain walk of the unique-violation detector, and `pickAvailableIssuePrefix` against a stubbed select. - `server/src/__tests__/companies-service.test.ts` covers a managed rename against a real Postgres database: the prefix moves, both identifier tables are re-keyed, and the activity entry is written. It also covers a same-base rename, a collision that takes the suffixed candidate, a non-name patch, and a self-hosted rename that leaves the prefix alone. Two more tests drive the overlap cases: two concurrent renames of the same company, and a rename racing a stale form that resubmits the original name. Both assert that the surviving name's base matches the company prefix and that the stored identifiers sit on that prefix. - `server/src/__tests__/cloud-tenant-company-provisioning.test.ts` covers the claim path and the repair against a real Postgres database: a name-derived prefix, a null description, a suffixed prefix on collision, the full repair, a second pass that changes nothing, a description-only repair, and an operator-written description that the repair leaves alone. - `ui/src/pages/CompanySettingsRenameHint.test.tsx` covers the hint on a managed instance and its absence on a self-hosted instance. The `substring` cast in `rekeyCompanyIssueIdentifiers` is load-bearing and the database tests prove it. The driver binds the offset as text. Without the `::int` cast Postgres resolves the SQL-regex overload of `substring`, and every identifier becomes NULL. ## Risks - **Re-keying changes existing identifiers and URLs.** This is deliberate, and it happens on hosted and managed instances only. After a rename, a link that carries an old task identifier stops resolving. The settings page warns about this before the user saves. - **Identifiers inside comment text are not rewritten.** Only the `identifier` columns of `issues` and `cases` move. A task ID that someone typed into a comment, a description, or a document keeps the old prefix. - **A lost prefix race inside the rename transaction surfaces as a conflict.** The rename picks a free prefix and then writes, because a unique violation inside a transaction aborts the whole transaction. Two *different* companies renamed onto the same base at the same moment can still collide. The loser sees its PATCH fail with the unique violation. The write is retryable by the client, and the window is a single statement wide. Two renames of the *same* company no longer race: the row lock serializes them, and the second one re-keys from what the first committed. - **The rename holds a row lock.** A managed rename takes `SELECT ... FOR UPDATE` on its own company row for the rest of the transaction. It is one row, and no other path in the transaction locks a company row, so there is no lock-order cycle. A self-hosted instance and every non-rename company update never reach the lock. - **The one-time repair is best effort.** It runs inside a try/catch and logs a warning on failure, so it never blocks authentication. A failed pass is retried on the next request, because its guards still match. - No schema change and no migration. ## Model Used - Provider: Anthropic (Claude) - Model: Claude Opus, model id `claude-opus-5[1m]` - Context window: 1M - Reasoning mode: extended thinking - Capabilities used: agentic tool use through Claude Code (file edits, shell, test runs against an embedded Postgres database) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
a4f1b3c533
commit
76f7019bdf
|
|
@ -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<string, string> = {
|
||||
"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<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof companies.$inferInsert>,
|
||||
): 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<unknown>();
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
/** Read surface shared by the root client and a transaction handle. */
|
||||
export type IssuePrefixReadDb = Pick<Db | DbTransaction, "select">;
|
||||
/** Write surface shared by the root client and a transaction handle. */
|
||||
export type IssuePrefixWriteDb = Pick<Db | DbTransaction, "update">;
|
||||
|
||||
/** 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<unknown>();
|
||||
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<string | null> {
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Renaming can change this company's task ID prefix. Existing task IDs are
|
||||
renumbered and old task links stop resolving.
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
label="Description"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { CompanySettings } from "./CompanySettings";
|
||||
|
||||
const mockCompaniesApi = vi.hoisted(() => ({
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<CompanySettings />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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());
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue