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:
Dotta
2026-03-07 08:59:34 -06:00
parent a498c268c5
commit 10cccc07cd
3 changed files with 116 additions and 0 deletions

View 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("!!!");
});
});

View File

@@ -87,6 +87,14 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
{ path: ["method"], value: "POST" },
{ 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> {

View File

@@ -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<ProjectWithGoals[]> {
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<ProjectWithGoals | null> => {
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<typeof projects.$inferInsert> = {