feat: deduplicate project shortnames on create and update
Ensure unique URL-safe shortnames by appending numeric suffixes when collisions occur. Applied during project creation, update, and company import flows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
45
server/src/__tests__/project-shortname-resolution.test.ts
Normal file
45
server/src/__tests__/project-shortname-resolution.test.ts
Normal file
@@ -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("!!!");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,6 +87,14 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
|||||||
{ path: ["method"], value: "POST" },
|
{ path: ["method"], value: "POST" },
|
||||||
{ path: ["timeoutSec"], value: 30 },
|
{ path: ["timeoutSec"], value: 30 },
|
||||||
],
|
],
|
||||||
|
openclaw_gateway: [
|
||||||
|
{ path: ["timeoutSec"], value: 120 },
|
||||||
|
{ path: ["waitTimeoutMs"], value: 120000 },
|
||||||
|
{ path: ["sessionKeyStrategy"], value: "fixed" },
|
||||||
|
{ path: ["sessionKey"], value: "paperclip" },
|
||||||
|
{ path: ["role"], value: "operator" },
|
||||||
|
{ path: ["scopes"], value: ["operator.admin"] },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ interface ProjectWithGoals extends ProjectRow {
|
|||||||
primaryWorkspace: ProjectWorkspace | null;
|
primaryWorkspace: ProjectWorkspace | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectShortnameRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolveProjectNameOptions {
|
||||||
|
excludeProjectId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Batch-load goal refs for a set of projects. */
|
/** Batch-load goal refs for a set of projects. */
|
||||||
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
|
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
@@ -192,6 +201,34 @@ function deriveWorkspaceName(input: {
|
|||||||
return "Workspace";
|
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(
|
async function ensureSinglePrimaryWorkspace(
|
||||||
dbOrTx: any,
|
dbOrTx: any,
|
||||||
input: {
|
input: {
|
||||||
@@ -271,6 +308,12 @@ export function projectService(db: Db) {
|
|||||||
projectData.color = nextColor;
|
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)
|
// Also write goalId to the legacy column (first goal or null)
|
||||||
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
||||||
|
|
||||||
@@ -295,6 +338,26 @@ export function projectService(db: Db) {
|
|||||||
): Promise<ProjectWithGoals | null> => {
|
): Promise<ProjectWithGoals | null> => {
|
||||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
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
|
// Keep legacy goalId column in sync
|
||||||
const updates: Partial<typeof projects.$inferInsert> = {
|
const updates: Partial<typeof projects.$inferInsert> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user