mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip is the open source control plane people use to organize and govern AI-agent companies > - Company skills are durable resources that users browse, import, assign, and maintain over time > - A flat skill list plus tags does not provide a stable location or hierarchy for personal, company, project-imported, and bundled skills > - Folder paths need to be canonical, company-scoped, safe to move, and preserved across re-imports without changing skill IDs > - The `/skills` UI also needs traversal, breadcrumbs, move/create flows, and a dedicated My Skills namespace that work on desktop and mobile > - This pull request adds the folder data model and APIs, reserved-root lifecycle, project import behavior, and the folder-first skills experience > - The benefit is a predictable filesystem-like organization model while tags remain available for cross-cutting classification ## Linked Issues or Issue Description Refs #9619 — the reviewed folder foundation was intentionally closed and folded into this combined feature PR. Refs #9026 — earlier flat-folder attempt superseded by this integrated implementation. Refs #3281 — related skill organization proposal; this PR uses canonical persisted folders rather than deriving groups from skill keys, and does not add hidden-skill behavior. **Feature request** - **Problem:** Skills currently lack a canonical hierarchical location, making personal skills, project imports, bundled skills, and company-authored skills difficult to traverse and manage at scale. - **Proposed behavior:** Add nested company-scoped folders with stable paths, reserved My/Projects/Bundled roots, subtree queries, safe move/create operations, and a folder-first `/skills` library UI. - **Import behavior:** New project scans file skills under `projects/<project-slug>`; later imports update content without overriding a user-selected folder. - **Alternatives considered:** Tags alone remain useful for cross-cutting classification, but they do not provide canonical location, nesting, reserved namespaces, or stable import placement. - **Roadmap alignment:** Extends the completed Skills Manager and Scheduled Routines capabilities without duplicating an active roadmap item. ## What Changed - Adds `folders` persistence for routine and skill folders, nested canonical paths, parent/slug/system-key fields, migration backfills, and reapply-safe migrations `0174`–`0175` after current master migrations. - Adds company-scoped folder CRUD, cycle/depth/namespace validation, reserved My/Projects/Bundled lifecycle, item moves, subtree filtering, and folder paths on skill results. - Preserves project-import placement: first import files into the project folder, while re-import keeps user-owned placement and stable skill IDs. - Adds the `/skills` folder tree rail, tags facet, breadcrumbs, subfolder browser, move/new-folder dialog, canonical detail location, inline tag editing, and folder-aware Studio creation. - Keeps bundled skills read-only even when their source metadata is incomplete by detecting the reserved Bundled folder and hiding selection/move actions. - Extends routine folder UI and OpenAPI coverage, and adds regression tests across migrations, services, routes, tree helpers, pages, and Studio creation. ## Verification - `pnpm exec vitest run packages/db/src/nested-skill-folders-migration.test.ts server/src/__tests__/folders-routes.test.ts server/src/__tests__/folders-service.test.ts server/src/__tests__/company-skills-service.test.ts server/src/__tests__/routines-service.test.ts ui/src/components/folders/FolderControls.test.tsx ui/src/components/folders/SkillFolderTree.test.tsx ui/src/components/folders/skill-folder-tree.test.ts ui/src/pages/CompanySkills.test.tsx ui/src/pages/Routines.test.tsx ui/src/pages/SkillStudio.test.tsx ui/src/lib/company-skill-routes.test.ts ui/src/lib/skill-create.test.ts` — 13 files, 192 tests passed. - `pnpm exec vitest run ui/src/pages/CompanySkills.test.tsx ui/src/components/folders/SkillFolderTree.test.tsx` — 2 files, 20 tests passed after preserving the existing PR's bundled-skill fixes. - `pnpm -r typecheck` — passed for all workspace packages. - `pnpm test:run` — passed in an isolated CI-like environment with inherited Paperclip runtime identity and static AWS credential variables removed. - `pnpm build` — production build passed for all workspace packages. - Greptile iteration 2 — 5/5 confidence with zero unresolved threads on commit `ff2d67aa71`. - Latest-head GitHub checks — all success, neutral, or skipped; PR is mergeable with a clean merge state. - `pnpm check:token-gates` — reports nine existing `#9627` comment false positives already present on `master`; this PR introduces no new token violation. ## Risks - **Migration/backfill:** `0174` creates the foundation and `0175` adds nested/reserved semantics. Both are ordered after current master migration `0173`, are covered by numbering/safety checks, and are designed to be reapply-safe. - **Reserved namespaces:** My, Projects, and Bundled roots are service-managed. Regression coverage prevents namespace squatting, cross-company folder use, bundled writes, cycles, and excessive depth. - **Behavioral change:** Project scans choose a project folder only on initial creation; existing skills deliberately retain their current folder during refresh. - **UI scope:** The folder rail applies to the Installed library; Catalog retains the discovery-oriented category sidebar. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI `gpt-5.5` in Codex CLI, medium reasoning mode; runtime did not expose a context-window value. Used repository/file tools, terminal execution, Git/GitHub operations, test execution, and code editing. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
620 lines
24 KiB
TypeScript
620 lines
24 KiB
TypeScript
import { and, asc, eq, max, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { companySkills, folders, routines } from "@paperclipai/db";
|
|
import type {
|
|
CreateFolder,
|
|
Folder,
|
|
FolderKind,
|
|
FolderListResult,
|
|
MoveFolder,
|
|
MoveFolderItem,
|
|
UpdateFolder,
|
|
} from "@paperclipai/shared";
|
|
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
|
|
|
const MAX_FOLDER_DEPTH = 4;
|
|
const RESERVED_ROOT_SLUGS = new Set(["bundled", "my", "projects"]);
|
|
const RESERVED_CHILD_ROOT_SYSTEM_KEYS = new Set(["my", "projects"]);
|
|
|
|
type FolderRow = typeof folders.$inferSelect;
|
|
|
|
function isPostgresError(error: unknown, code: string) {
|
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
}
|
|
|
|
function normalizeName(name: string) {
|
|
return name.trim();
|
|
}
|
|
|
|
function normalizeColor(color: string | null | undefined) {
|
|
if (color === undefined) return undefined;
|
|
const trimmed = color?.trim() ?? "";
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
export function normalizeFolderSlug(value: string) {
|
|
const slug = value
|
|
.normalize("NFKD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.replace(/-+/g, "-");
|
|
return slug || "folder";
|
|
}
|
|
|
|
function buildFolderViews(rows: FolderRow[]) {
|
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
const views = new Map<string, Folder>();
|
|
const visiting = new Set<string>();
|
|
|
|
function resolve(row: FolderRow): Folder {
|
|
const existing = views.get(row.id);
|
|
if (existing) return existing;
|
|
if (visiting.has(row.id)) throw unprocessable("Folder hierarchy contains a cycle");
|
|
visiting.add(row.id);
|
|
const parent = row.parentId ? byId.get(row.parentId) : null;
|
|
if (row.parentId && !parent) throw unprocessable("Folder hierarchy contains an invalid parent");
|
|
const parentView = parent ? resolve(parent) : null;
|
|
const view: Folder = {
|
|
...row,
|
|
parentId: row.parentId ?? null,
|
|
systemKey: row.systemKey ?? null,
|
|
color: row.color ?? null,
|
|
path: parentView ? `${parentView.path}/${row.slug}` : row.slug,
|
|
depth: (parentView?.depth ?? 0) + 1,
|
|
};
|
|
visiting.delete(row.id);
|
|
views.set(row.id, view);
|
|
return view;
|
|
}
|
|
|
|
for (const row of rows) resolve(row);
|
|
return views;
|
|
}
|
|
|
|
export function folderService(db: Db, mutationLockHeld = false) {
|
|
async function withCompanyFolderLock<T>(companyId: string, operation: (lockedDb: Db) => Promise<T>) {
|
|
if (mutationLockHeld) return operation(db);
|
|
return db.transaction(async (tx) => {
|
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`paperclip:folders:${companyId}`}, 0))`);
|
|
return operation(tx as unknown as Db);
|
|
});
|
|
}
|
|
|
|
async function getRows(companyId: string, kind: FolderKind) {
|
|
return db
|
|
.select()
|
|
.from(folders)
|
|
.where(and(eq(folders.companyId, companyId), eq(folders.kind, kind)))
|
|
.orderBy(asc(folders.position), asc(folders.name), asc(folders.id));
|
|
}
|
|
|
|
async function getFolderRow(companyId: string, folderId: string) {
|
|
return db
|
|
.select()
|
|
.from(folders)
|
|
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function getFolder(companyId: string, folderId: string) {
|
|
const row = await getFolderRow(companyId, folderId);
|
|
if (!row) return null;
|
|
const views = buildFolderViews(await getRows(companyId, row.kind));
|
|
return views.get(row.id) ?? null;
|
|
}
|
|
|
|
async function assertNoSlugConflict(
|
|
companyId: string,
|
|
kind: FolderKind,
|
|
parentId: string | null,
|
|
slug: string,
|
|
excludeFolderId?: string,
|
|
) {
|
|
const existing = await db
|
|
.select({ id: folders.id })
|
|
.from(folders)
|
|
.where(and(
|
|
eq(folders.companyId, companyId),
|
|
eq(folders.kind, kind),
|
|
parentId === null ? sql`${folders.parentId} is null` : eq(folders.parentId, parentId),
|
|
eq(folders.slug, slug),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (existing && existing.id !== excludeFolderId) {
|
|
throw conflict("Folder slug already exists under this parent");
|
|
}
|
|
}
|
|
|
|
async function nextPosition(companyId: string, kind: FolderKind, parentId: string | null) {
|
|
const row = await db
|
|
.select({ value: max(folders.position) })
|
|
.from(folders)
|
|
.where(and(
|
|
eq(folders.companyId, companyId),
|
|
eq(folders.kind, kind),
|
|
parentId === null ? sql`${folders.parentId} is null` : eq(folders.parentId, parentId),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
return Number(row?.value ?? -1) + 1;
|
|
}
|
|
|
|
async function routineCounts(companyId: string) {
|
|
return db
|
|
.select({ folderId: routines.folderId, count: sql<number>`count(*)::int` })
|
|
.from(routines)
|
|
.where(eq(routines.companyId, companyId))
|
|
.groupBy(routines.folderId);
|
|
}
|
|
|
|
async function skillCounts(companyId: string) {
|
|
return db
|
|
.select({ folderId: companySkills.folderId, count: sql<number>`count(*)::int` })
|
|
.from(companySkills)
|
|
.where(eq(companySkills.companyId, companyId))
|
|
.groupBy(companySkills.folderId);
|
|
}
|
|
|
|
async function list(companyId: string, kind: FolderKind): Promise<FolderListResult> {
|
|
const [folderRows, countRows] = await Promise.all([
|
|
getRows(companyId, kind),
|
|
kind === "routine" ? routineCounts(companyId) : skillCounts(companyId),
|
|
]);
|
|
const views = buildFolderViews(folderRows);
|
|
const countsByFolderId = new Map<string | null, number>();
|
|
for (const row of countRows) countsByFolderId.set(row.folderId ?? null, Number(row.count ?? 0));
|
|
return {
|
|
kind,
|
|
folders: folderRows.map((row) => ({
|
|
...views.get(row.id)!,
|
|
itemCount: countsByFolderId.get(row.id) ?? 0,
|
|
})),
|
|
allCount: Array.from(countsByFolderId.values()).reduce((sum, count) => sum + count, 0),
|
|
unfiledCount: countsByFolderId.get(null) ?? 0,
|
|
};
|
|
}
|
|
|
|
function isReservedRootSlug(kind: FolderKind, parentId: string | null, slug: string) {
|
|
return kind === "skill" && parentId === null && RESERVED_ROOT_SLUGS.has(slug);
|
|
}
|
|
|
|
async function isBundledFolder(companyId: string, folderId: string) {
|
|
let current = await getFolder(companyId, folderId);
|
|
const visited = new Set<string>();
|
|
while (current) {
|
|
if (current.systemKey === "bundled") return true;
|
|
if (!current.parentId || visited.has(current.id)) return false;
|
|
visited.add(current.id);
|
|
current = await getFolder(companyId, current.parentId);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function assertMutableFolder(companyId: string, folder: Folder) {
|
|
if (folder.systemKey || await isBundledFolder(companyId, folder.id)) {
|
|
throw forbidden("System-managed folders cannot be changed");
|
|
}
|
|
}
|
|
|
|
async function validateParent(companyId: string, kind: FolderKind, parentId: string | null) {
|
|
if (!parentId) return null;
|
|
const parent = await getFolder(companyId, parentId);
|
|
if (!parent || parent.kind !== kind) throw notFound("Parent folder not found");
|
|
if (await isBundledFolder(companyId, parent.id)) throw forbidden("Bundled folders are read-only");
|
|
if (
|
|
parent.kind === "skill"
|
|
&& parent.parentId === null
|
|
&& (RESERVED_CHILD_ROOT_SYSTEM_KEYS.has(parent.systemKey ?? "") || RESERVED_CHILD_ROOT_SYSTEM_KEYS.has(parent.slug))
|
|
) {
|
|
throw forbidden("Reserved skill folders are system-managed");
|
|
}
|
|
return parent;
|
|
}
|
|
|
|
async function create(companyId: string, input: CreateFolder): Promise<Folder> {
|
|
if (!mutationLockHeld) {
|
|
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).create(companyId, input));
|
|
}
|
|
const parentId = input.parentId ?? null;
|
|
const parent = await validateParent(companyId, input.kind, parentId);
|
|
if ((parent?.depth ?? 0) + 1 > MAX_FOLDER_DEPTH) {
|
|
throw unprocessable(`Folder depth cannot exceed ${MAX_FOLDER_DEPTH}`);
|
|
}
|
|
const name = normalizeName(input.name);
|
|
const slug = input.slug ?? normalizeFolderSlug(name);
|
|
if (isReservedRootSlug(input.kind, parentId, slug)) {
|
|
throw forbidden("Reserved skill folders are system-managed");
|
|
}
|
|
await assertNoSlugConflict(companyId, input.kind, parentId, slug);
|
|
const position = input.position ?? await nextPosition(companyId, input.kind, parentId);
|
|
let row: FolderRow;
|
|
try {
|
|
row = await db
|
|
.insert(folders)
|
|
.values({ companyId, kind: input.kind, parentId, name, slug, color: normalizeColor(input.color) ?? null, position })
|
|
.returning()
|
|
.then((rows) => rows[0]!);
|
|
} catch (error) {
|
|
if (isPostgresError(error, "23505")) throw conflict("Folder slug already exists under this parent");
|
|
throw error;
|
|
}
|
|
return (await getFolder(companyId, row.id))!;
|
|
}
|
|
|
|
async function update(companyId: string, folderId: string, patch: UpdateFolder): Promise<Folder | null> {
|
|
if (!mutationLockHeld) {
|
|
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).update(companyId, folderId, patch));
|
|
}
|
|
const existing = await getFolder(companyId, folderId);
|
|
if (!existing) return null;
|
|
await assertMutableFolder(companyId, existing);
|
|
const name = patch.name === undefined ? existing.name : normalizeName(patch.name);
|
|
const slug = patch.slug ?? (patch.name === undefined ? existing.slug : normalizeFolderSlug(name));
|
|
if (isReservedRootSlug(existing.kind, existing.parentId, slug)) {
|
|
throw forbidden("Reserved skill folders are system-managed");
|
|
}
|
|
await assertNoSlugConflict(companyId, existing.kind, existing.parentId, slug, existing.id);
|
|
try {
|
|
await db
|
|
.update(folders)
|
|
.set({
|
|
name,
|
|
slug,
|
|
color: patch.color === undefined ? existing.color : normalizeColor(patch.color),
|
|
position: patch.position ?? existing.position,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)));
|
|
} catch (error) {
|
|
if (isPostgresError(error, "23505")) throw conflict("Folder slug already exists under this parent");
|
|
throw error;
|
|
}
|
|
return getFolder(companyId, folderId);
|
|
}
|
|
|
|
function descendantIdsFromRows(rows: FolderRow[], folderId: string) {
|
|
if (!rows.some((row) => row.id === folderId)) throw notFound("Folder not found");
|
|
const children = new Map<string, string[]>();
|
|
for (const row of rows) {
|
|
if (!row.parentId) continue;
|
|
children.set(row.parentId, [...(children.get(row.parentId) ?? []), row.id]);
|
|
}
|
|
const result = new Set([folderId]);
|
|
const queue = [folderId];
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()!;
|
|
for (const childId of children.get(current) ?? []) {
|
|
if (result.has(childId)) throw unprocessable("Folder hierarchy contains a cycle");
|
|
result.add(childId);
|
|
queue.push(childId);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function descendantIds(companyId: string, kind: FolderKind, folderId: string) {
|
|
return descendantIdsFromRows(await getRows(companyId, kind), folderId);
|
|
}
|
|
|
|
async function moveFolder(companyId: string, folderId: string, input: MoveFolder): Promise<Folder | null> {
|
|
if (!mutationLockHeld) {
|
|
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).moveFolder(companyId, folderId, input));
|
|
}
|
|
const existing = await getFolder(companyId, folderId);
|
|
if (!existing) return null;
|
|
await assertMutableFolder(companyId, existing);
|
|
const parentId = input.parentId === undefined ? existing.parentId : input.parentId;
|
|
if (parentId === existing.id) throw unprocessable("A folder cannot be its own parent");
|
|
const rows = await getRows(companyId, existing.kind);
|
|
const descendants = descendantIdsFromRows(rows, existing.id);
|
|
if (parentId && descendants.has(parentId)) throw unprocessable("A folder cannot be moved into its own subtree");
|
|
const parent = await validateParent(companyId, existing.kind, parentId);
|
|
const views = buildFolderViews(rows);
|
|
const relativeDepth = Math.max(...Array.from(descendants).map((id) => views.get(id)!.depth - existing.depth + 1));
|
|
if ((parent?.depth ?? 0) + relativeDepth > MAX_FOLDER_DEPTH) {
|
|
throw unprocessable(`Folder depth cannot exceed ${MAX_FOLDER_DEPTH}`);
|
|
}
|
|
if (isReservedRootSlug(existing.kind, parentId, existing.slug)) {
|
|
throw forbidden("Reserved skill folders are system-managed");
|
|
}
|
|
await assertNoSlugConflict(companyId, existing.kind, parentId, existing.slug, existing.id);
|
|
try {
|
|
await db
|
|
.update(folders)
|
|
.set({ parentId, position: input.position, updatedAt: new Date() })
|
|
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)));
|
|
} catch (error) {
|
|
if (isPostgresError(error, "23505")) throw conflict("Folder slug already exists under this parent");
|
|
if (isPostgresError(error, "23503")) throw conflict("Parent folder changed during move");
|
|
throw error;
|
|
}
|
|
return getFolder(companyId, folderId);
|
|
}
|
|
|
|
async function deleteFolder(companyId: string, folderId: string): Promise<Folder | null> {
|
|
if (!mutationLockHeld) {
|
|
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).deleteFolder(companyId, folderId));
|
|
}
|
|
const existing = await getFolder(companyId, folderId);
|
|
if (!existing) return null;
|
|
await assertMutableFolder(companyId, existing);
|
|
const child = await db
|
|
.select({ id: folders.id })
|
|
.from(folders)
|
|
.where(and(eq(folders.companyId, companyId), eq(folders.parentId, folderId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (child) throw conflict("Move or delete nested folders first");
|
|
try {
|
|
await db.delete(folders).where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)));
|
|
} catch (error) {
|
|
if (isPostgresError(error, "23503")) throw conflict("Move or delete nested folders first");
|
|
throw error;
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
async function validateSkillFolder(companyId: string, folderId: string, options?: { allowBundled?: boolean }) {
|
|
const folder = await getFolder(companyId, folderId);
|
|
if (!folder || folder.kind !== "skill") throw notFound("Skill folder not found");
|
|
if (!options?.allowBundled && await isBundledFolder(companyId, folder.id)) {
|
|
throw forbidden("Bundled folders are read-only");
|
|
}
|
|
return folder;
|
|
}
|
|
|
|
async function moveItem(companyId: string, input: MoveFolderItem) {
|
|
if (input.folderId) {
|
|
const target = await getFolder(companyId, input.folderId);
|
|
if (!target) throw notFound("Folder not found");
|
|
if (target.kind !== input.kind) throw unprocessable("Folder kind must match item kind");
|
|
if (await isBundledFolder(companyId, target.id)) throw forbidden("Bundled folders are read-only");
|
|
}
|
|
if (input.kind === "routine") {
|
|
const row = await db
|
|
.update(routines)
|
|
.set({ folderId: input.folderId ?? null, updatedAt: new Date() })
|
|
.where(and(eq(routines.companyId, companyId), eq(routines.id, input.itemId)))
|
|
.returning({ id: routines.id, folderId: routines.folderId })
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) throw notFound("Routine not found");
|
|
return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null };
|
|
}
|
|
const existing = await db
|
|
.select({ id: companySkills.id, folderId: companySkills.folderId })
|
|
.from(companySkills)
|
|
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, input.itemId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) throw notFound("Skill not found");
|
|
if (existing.folderId && await isBundledFolder(companyId, existing.folderId)) {
|
|
throw forbidden("Bundled skills cannot be moved");
|
|
}
|
|
const row = await db
|
|
.update(companySkills)
|
|
.set({ folderId: input.folderId ?? null, updatedAt: new Date() })
|
|
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, input.itemId)))
|
|
.returning({ id: companySkills.id, folderId: companySkills.folderId })
|
|
.then((rows) => rows[0]!);
|
|
return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null };
|
|
}
|
|
|
|
async function uniqueSiblingSlug(companyId: string, parentId: string | null, baseSlug: string, stableSuffix: string) {
|
|
const siblingSlugs = new Set(await db
|
|
.select({ slug: folders.slug })
|
|
.from(folders)
|
|
.where(and(
|
|
eq(folders.companyId, companyId),
|
|
eq(folders.kind, "skill"),
|
|
parentId === null ? sql`${folders.parentId} is null` : eq(folders.parentId, parentId),
|
|
))
|
|
.then((rows) => rows.map((row) => row.slug)));
|
|
if (!siblingSlugs.has(baseSlug)) return baseSlug;
|
|
const suffix = normalizeFolderSlug(stableSuffix).slice(0, 24);
|
|
let candidate = `${baseSlug}-${suffix}`;
|
|
let duplicateNumber = 2;
|
|
while (siblingSlugs.has(candidate)) {
|
|
candidate = `${baseSlug}-${suffix}-${duplicateNumber}`;
|
|
duplicateNumber += 1;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
async function findSystemFolder(companyId: string, systemKey: string) {
|
|
return db
|
|
.select()
|
|
.from(folders)
|
|
.where(and(
|
|
eq(folders.companyId, companyId),
|
|
eq(folders.kind, "skill"),
|
|
eq(folders.systemKey, systemKey),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function insertSystemFolder(input: {
|
|
companyId: string;
|
|
parentId: string | null;
|
|
name: string;
|
|
slug: string;
|
|
systemKey: string;
|
|
}) {
|
|
const inserted = await db
|
|
.insert(folders)
|
|
.values({
|
|
companyId: input.companyId,
|
|
kind: "skill",
|
|
parentId: input.parentId,
|
|
name: input.name,
|
|
slug: input.slug,
|
|
systemKey: input.systemKey,
|
|
position: await nextPosition(input.companyId, "skill", input.parentId),
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning({ id: folders.id })
|
|
.then((rows) => rows[0] ?? null);
|
|
if (inserted) return (await getFolder(input.companyId, inserted.id))!;
|
|
const existing = await findSystemFolder(input.companyId, input.systemKey);
|
|
return existing ? (await getFolder(input.companyId, existing.id))! : null;
|
|
}
|
|
|
|
async function ensureContainer(companyId: string, slug: "bundled" | "my" | "projects", name: string) {
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const existingSystem = await findSystemFolder(companyId, slug);
|
|
if (existingSystem) return (await getFolder(companyId, existingSystem.id))!;
|
|
const squatted = await db
|
|
.select({ id: folders.id })
|
|
.from(folders)
|
|
.where(and(
|
|
eq(folders.companyId, companyId),
|
|
eq(folders.kind, "skill"),
|
|
sql`${folders.parentId} is null`,
|
|
eq(folders.slug, slug),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (squatted) {
|
|
await db
|
|
.update(folders)
|
|
.set({ slug: await uniqueSiblingSlug(companyId, null, slug, squatted.id.slice(0, 8)), updatedAt: new Date() })
|
|
.where(and(eq(folders.companyId, companyId), eq(folders.id, squatted.id)));
|
|
}
|
|
const created = await insertSystemFolder({ companyId, parentId: null, name, slug, systemKey: slug });
|
|
if (created) return created;
|
|
}
|
|
throw conflict(`Could not create ${name} folder`);
|
|
}
|
|
|
|
async function uniqueSystemSlug(
|
|
companyId: string,
|
|
parentId: string,
|
|
baseSlug: string,
|
|
systemKey: string,
|
|
stableSuffix = systemKey.split(":").at(-1) ?? systemKey,
|
|
) {
|
|
const existingSystem = await db
|
|
.select({ id: folders.id })
|
|
.from(folders)
|
|
.where(and(eq(folders.companyId, companyId), eq(folders.kind, "skill"), eq(folders.systemKey, systemKey)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (existingSystem) return { id: existingSystem.id, slug: null };
|
|
return {
|
|
id: null,
|
|
slug: await uniqueSiblingSlug(companyId, parentId, baseSlug, stableSuffix),
|
|
};
|
|
}
|
|
|
|
async function ensureMyFolder(companyId: string, userId: string, userName: string | null, requestedSlug?: string | null): Promise<Folder> {
|
|
if (!mutationLockHeld) {
|
|
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).ensureMyFolder(companyId, userId, userName, requestedSlug));
|
|
}
|
|
const parent = await ensureContainer(companyId, "my", "My Skills");
|
|
const systemKey = `my:${userId}`;
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const resolved = await uniqueSystemSlug(companyId, parent.id, requestedSlug ?? normalizeFolderSlug(userName ?? userId), systemKey);
|
|
if (resolved.id) return (await getFolder(companyId, resolved.id))!;
|
|
const created = await insertSystemFolder({
|
|
companyId,
|
|
parentId: parent.id,
|
|
name: userName?.trim() || "My Skills",
|
|
slug: resolved.slug!,
|
|
systemKey,
|
|
});
|
|
if (created) return created;
|
|
}
|
|
throw conflict("Could not create personal skill folder");
|
|
}
|
|
|
|
async function ensureProjectFolder(companyId: string, projectId: string, projectName: string): Promise<Folder> {
|
|
if (!mutationLockHeld) {
|
|
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).ensureProjectFolder(companyId, projectId, projectName));
|
|
}
|
|
const parent = await ensureContainer(companyId, "projects", "Projects");
|
|
const systemKey = `project:${projectId}`;
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const resolved = await uniqueSystemSlug(companyId, parent.id, normalizeFolderSlug(projectName), systemKey);
|
|
if (resolved.id) return (await getFolder(companyId, resolved.id))!;
|
|
const created = await insertSystemFolder({
|
|
companyId,
|
|
parentId: parent.id,
|
|
name: projectName,
|
|
slug: resolved.slug!,
|
|
systemKey,
|
|
});
|
|
if (created) return created;
|
|
}
|
|
throw conflict("Could not create project skill folder");
|
|
}
|
|
|
|
async function ensureBundledCategory(companyId: string, category: string): Promise<Folder> {
|
|
if (!mutationLockHeld) {
|
|
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).ensureBundledCategory(companyId, category));
|
|
}
|
|
const root = await ensureContainer(companyId, "bundled", "Bundled");
|
|
const name = normalizeName(category);
|
|
const slug = normalizeFolderSlug(category);
|
|
const systemKey = `bundled:${slug}`;
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
const resolved = await uniqueSystemSlug(companyId, root.id, slug, systemKey, "bundled");
|
|
if (resolved.id) {
|
|
const existing = await getFolder(companyId, resolved.id);
|
|
if (!existing) continue;
|
|
if (existing.name === name) return existing;
|
|
await db
|
|
.update(folders)
|
|
.set({ name, updatedAt: new Date() })
|
|
.where(and(eq(folders.companyId, companyId), eq(folders.id, existing.id)));
|
|
return (await getFolder(companyId, existing.id))!;
|
|
}
|
|
const created = await insertSystemFolder({
|
|
companyId,
|
|
parentId: root.id,
|
|
name,
|
|
slug: resolved.slug!,
|
|
systemKey,
|
|
});
|
|
if (created) return created;
|
|
}
|
|
throw conflict("Could not create bundled skill folder");
|
|
}
|
|
|
|
async function pruneEmptyBundledCategories(companyId: string, retainedCategories: string[]): Promise<void> {
|
|
if (!mutationLockHeld) {
|
|
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).pruneEmptyBundledCategories(companyId, retainedCategories));
|
|
}
|
|
const root = await findSystemFolder(companyId, "bundled");
|
|
if (!root) return;
|
|
const rows = await getRows(companyId, "skill");
|
|
const retainedSystemKeys = new Set(
|
|
retainedCategories.map((category) => `bundled:${normalizeFolderSlug(category)}`),
|
|
);
|
|
const usedFolderIds = new Set(
|
|
await db
|
|
.select({ folderId: companySkills.folderId })
|
|
.from(companySkills)
|
|
.where(eq(companySkills.companyId, companyId))
|
|
.then((skills) => skills.flatMap((skill) => skill.folderId ? [skill.folderId] : [])),
|
|
);
|
|
const parentIds = new Set(rows.flatMap((row) => row.parentId ? [row.parentId] : []));
|
|
for (const row of rows) {
|
|
if (row.parentId !== root.id || !row.systemKey?.startsWith("bundled:")) continue;
|
|
if (retainedSystemKeys.has(row.systemKey) || usedFolderIds.has(row.id) || parentIds.has(row.id)) continue;
|
|
await db.delete(folders).where(and(eq(folders.companyId, companyId), eq(folders.id, row.id)));
|
|
}
|
|
}
|
|
|
|
return {
|
|
list,
|
|
create,
|
|
update,
|
|
moveFolder,
|
|
deleteFolder,
|
|
moveItem,
|
|
getFolder,
|
|
descendantIds,
|
|
validateSkillFolder,
|
|
ensureMyFolder,
|
|
ensureProjectFolder,
|
|
ensureBundledCategory,
|
|
pruneEmptyBundledCategories,
|
|
};
|
|
}
|