diff --git a/server/src/__tests__/project-shortname-resolution.test.ts b/server/src/__tests__/project-shortname-resolution.test.ts new file mode 100644 index 00000000..5b0ab728 --- /dev/null +++ b/server/src/__tests__/project-shortname-resolution.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { resolveProjectNameForUniqueShortname } from "../services/projects.ts"; + +describe("resolveProjectNameForUniqueShortname", () => { + it("keeps name when shortname is not used", () => { + const resolved = resolveProjectNameForUniqueShortname("Platform", [ + { id: "p1", name: "Growth" }, + ]); + expect(resolved).toBe("Platform"); + }); + + it("appends numeric suffix when shortname collides", () => { + const resolved = resolveProjectNameForUniqueShortname("Growth Team", [ + { id: "p1", name: "growth-team" }, + ]); + expect(resolved).toBe("Growth Team 2"); + }); + + it("increments suffix until unique", () => { + const resolved = resolveProjectNameForUniqueShortname("Growth Team", [ + { id: "p1", name: "growth-team" }, + { id: "p2", name: "growth-team-2" }, + ]); + expect(resolved).toBe("Growth Team 3"); + }); + + it("ignores excluded project id", () => { + const resolved = resolveProjectNameForUniqueShortname( + "Growth Team", + [ + { id: "p1", name: "growth-team" }, + { id: "p2", name: "platform" }, + ], + { excludeProjectId: "p1" }, + ); + expect(resolved).toBe("Growth Team"); + }); + + it("keeps non-normalizable names unchanged", () => { + const resolved = resolveProjectNameForUniqueShortname("!!!", [ + { id: "p1", name: "growth" }, + ]); + expect(resolved).toBe("!!!"); + }); +}); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index cc59063c..afe54ffc 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -87,6 +87,14 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record { diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 3ff3b53b..54d5cd82 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -31,6 +31,15 @@ interface ProjectWithGoals extends ProjectRow { primaryWorkspace: ProjectWorkspace | null; } +interface ProjectShortnameRow { + id: string; + name: string; +} + +interface ResolveProjectNameOptions { + excludeProjectId?: string | null; +} + /** Batch-load goal refs for a set of projects. */ async function attachGoals(db: Db, rows: ProjectRow[]): Promise { if (rows.length === 0) return []; @@ -192,6 +201,34 @@ function deriveWorkspaceName(input: { return "Workspace"; } +export function resolveProjectNameForUniqueShortname( + requestedName: string, + existingProjects: ProjectShortnameRow[], + options?: ResolveProjectNameOptions, +): string { + const requestedShortname = normalizeProjectUrlKey(requestedName); + if (!requestedShortname) return requestedName; + + const usedShortnames = new Set( + existingProjects + .filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId)) + .map((project) => normalizeProjectUrlKey(project.name)) + .filter((value): value is string => value !== null), + ); + if (!usedShortnames.has(requestedShortname)) return requestedName; + + for (let suffix = 2; suffix < 10_000; suffix += 1) { + const candidateName = `${requestedName} ${suffix}`; + const candidateShortname = normalizeProjectUrlKey(candidateName); + if (candidateShortname && !usedShortnames.has(candidateShortname)) { + return candidateName; + } + } + + // Fallback guard for pathological naming collisions. + return `${requestedName} ${Date.now()}`; +} + async function ensureSinglePrimaryWorkspace( dbOrTx: any, input: { @@ -271,6 +308,12 @@ export function projectService(db: Db) { projectData.color = nextColor; } + const existingProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.companyId, companyId)); + projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects); + // Also write goalId to the legacy column (first goal or null) const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null; @@ -295,6 +338,26 @@ export function projectService(db: Db) { ): Promise => { const { goalIds: inputGoalIds, ...projectData } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); + const existingProject = await db + .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) + .from(projects) + .where(eq(projects.id, id)) + .then((rows) => rows[0] ?? null); + if (!existingProject) return null; + + if (projectData.name !== undefined) { + const existingShortname = normalizeProjectUrlKey(existingProject.name); + const nextShortname = normalizeProjectUrlKey(projectData.name); + if (existingShortname !== nextShortname) { + const existingProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.companyId, existingProject.companyId)); + projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, { + excludeProjectId: id, + }); + } + } // Keep legacy goalId column in sync const updates: Partial = {