Files
PaperClipAI/server/src/services/folders.ts
T
DottaandPaperclip 52aea90263 feat: organize skills with nested folders and My Skills (#9633)
## 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>
2026-07-16 15:50:45 -05:00

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,
};
}