diff --git a/packages/db/src/migrations/0174_folders.sql b/packages/db/src/migrations/0174_folders.sql new file mode 100644 index 0000000000..82abc7155f --- /dev/null +++ b/packages/db/src/migrations/0174_folders.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS "folders" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "kind" text NOT NULL, + "name" text NOT NULL, + "color" text, + "position" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "folder_id" uuid; +--> statement-breakpoint +ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "folder_id" uuid; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'folders_company_id_companies_id_fk' + ) THEN + ALTER TABLE "folders" ADD CONSTRAINT "folders_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'company_skills_folder_id_folders_id_fk' + ) THEN + ALTER TABLE "company_skills" ADD CONSTRAINT "company_skills_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routines_folder_id_folders_id_fk' + ) THEN + ALTER TABLE "routines" ADD CONSTRAINT "routines_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "folders_company_kind_position_idx" ON "folders" USING btree ("company_id","kind","position","name"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_name_uq" ON "folders" USING btree ("company_id","kind","name"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skills_company_folder_idx" ON "company_skills" USING btree ("company_id","folder_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routines_company_folder_idx" ON "routines" USING btree ("company_id","folder_id"); diff --git a/packages/db/src/migrations/0175_nested_skill_folders.sql b/packages/db/src/migrations/0175_nested_skill_folders.sql new file mode 100644 index 0000000000..7e4c8dee40 --- /dev/null +++ b/packages/db/src/migrations/0175_nested_skill_folders.sql @@ -0,0 +1,232 @@ +ALTER TABLE "folders" ADD COLUMN IF NOT EXISTS "parent_id" uuid; +--> statement-breakpoint +ALTER TABLE "folders" ADD COLUMN IF NOT EXISTS "slug" text; +--> statement-breakpoint +ALTER TABLE "folders" ADD COLUMN IF NOT EXISTS "system_key" text; +--> statement-breakpoint +WITH normalized AS ( + SELECT + "id", + COALESCE( + NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER("name"), '[^a-z0-9]+', '-', 'g')), ''), + 'folder' + ) AS base_slug, + ROW_NUMBER() OVER ( + PARTITION BY "company_id", "kind", COALESCE( + NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER("name"), '[^a-z0-9]+', '-', 'g')), ''), + 'folder' + ) + ORDER BY "id" + ) AS duplicate_number + FROM "folders" + WHERE "slug" IS NULL +) +UPDATE "folders" AS folder +SET "slug" = CASE + WHEN normalized.duplicate_number = 1 THEN normalized.base_slug + ELSE normalized.base_slug || '-' || LEFT(folder."id"::text, 8) +END +FROM normalized +WHERE folder."id" = normalized."id"; +--> statement-breakpoint +ALTER TABLE "folders" ALTER COLUMN "slug" SET NOT NULL; +--> statement-breakpoint +DROP INDEX IF EXISTS "folders_company_kind_name_uq"; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM "pg_constraint" WHERE "conname" = 'folders_parent_id_folders_id_fk' + ) THEN + ALTER TABLE "folders" ADD CONSTRAINT "folders_parent_id_folders_id_fk" + FOREIGN KEY ("parent_id") REFERENCES "public"."folders"("id") ON DELETE restrict ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +ALTER TABLE "folders" DROP CONSTRAINT IF EXISTS "folders_company_kind_parent_slug_uq"; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_root_slug_uq" + ON "folders" USING btree ("company_id", "kind", "slug") + WHERE "parent_id" IS NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_parent_slug_uq" + ON "folders" USING btree ("company_id", "kind", "parent_id", "slug") + WHERE "parent_id" IS NOT NULL; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_system_key_uq" + ON "folders" USING btree ("company_id", "kind", "system_key") + WHERE "system_key" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "folders_company_kind_parent_position_idx" + ON "folders" USING btree ("company_id", "kind", "parent_id", "position", "name"); +--> statement-breakpoint +UPDATE "folders" AS folder +SET "slug" = 'bundled-' || LEFT(REPLACE(folder."id"::text, '-', ''), 8), "updated_at" = now() +WHERE folder."kind" = 'skill' + AND folder."parent_id" IS NULL + AND folder."slug" = 'bundled' + AND folder."system_key" IS NULL + AND EXISTS ( + SELECT 1 + FROM "company_skills" AS skill + WHERE skill."company_id" = folder."company_id" + AND skill."folder_id" IS NULL + AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled') + ); +--> statement-breakpoint +INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position") +SELECT DISTINCT skill."company_id", 'skill', NULL::uuid, 'Bundled', 'bundled', 'bundled', 0 +FROM "company_skills" AS skill +WHERE skill."folder_id" IS NULL + AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled') + AND NOT EXISTS ( + SELECT 1 FROM "folders" AS folder + WHERE folder."company_id" = skill."company_id" + AND folder."kind" = 'skill' + AND folder."system_key" = 'bundled' + ) +ON CONFLICT DO NOTHING; +--> statement-breakpoint +WITH bundled_categories AS ( + SELECT DISTINCT ON (skill."company_id", category.slug) + skill."company_id", + category.name, + category.slug + FROM "company_skills" AS skill + CROSS JOIN LATERAL ( + SELECT + COALESCE(NULLIF(SPLIT_PART(skill."key", '/', 3), ''), 'other') AS name, + COALESCE( + NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(COALESCE(NULLIF(SPLIT_PART(skill."key", '/', 3), ''), 'other')), '[^a-z0-9]+', '-', 'g')), ''), + 'other' + ) AS slug + ) AS category + WHERE skill."folder_id" IS NULL + AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled') + ORDER BY skill."company_id", category.slug, category.name +) +INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position") +SELECT category."company_id", 'skill', root."id", category."name", category."slug", 'bundled:' || category."slug", 0 +FROM bundled_categories AS category +JOIN "folders" AS root + ON root."company_id" = category."company_id" + AND root."kind" = 'skill' + AND root."system_key" = 'bundled' +ON CONFLICT DO NOTHING; +--> statement-breakpoint +UPDATE "company_skills" AS skill +SET "folder_id" = category_folder."id", "updated_at" = now() +FROM "folders" AS root +JOIN "folders" AS category_folder + ON category_folder."company_id" = root."company_id" + AND category_folder."kind" = 'skill' + AND category_folder."parent_id" = root."id" +WHERE skill."company_id" = root."company_id" + AND root."kind" = 'skill' + AND root."system_key" = 'bundled' + AND skill."folder_id" IS NULL + AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled') + AND category_folder."system_key" = 'bundled:' || COALESCE( + NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(COALESCE(NULLIF(SPLIT_PART(skill."key", '/', 3), ''), 'other')), '[^a-z0-9]+', '-', 'g')), ''), + 'other' + ); +--> statement-breakpoint +UPDATE "folders" AS folder +SET "slug" = 'projects-' || LEFT(REPLACE(folder."id"::text, '-', ''), 8), "updated_at" = now() +WHERE folder."kind" = 'skill' + AND folder."parent_id" IS NULL + AND folder."slug" = 'projects' + AND folder."system_key" IS NULL + AND EXISTS ( + SELECT 1 + FROM "company_skills" AS skill + WHERE skill."company_id" = folder."company_id" + AND skill."folder_id" IS NULL + AND skill."metadata"->>'sourceKind' = 'project_scan' + AND skill."metadata"->>'projectId' IS NOT NULL + ); +--> statement-breakpoint +INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position") +SELECT DISTINCT skill."company_id", 'skill', NULL::uuid, 'Projects', 'projects', 'projects', 1 +FROM "company_skills" AS skill +WHERE skill."folder_id" IS NULL + AND skill."metadata"->>'sourceKind' = 'project_scan' + AND skill."metadata"->>'projectId' IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM "folders" AS folder + WHERE folder."company_id" = skill."company_id" + AND folder."kind" = 'skill' + AND folder."system_key" = 'projects' + ) +ON CONFLICT DO NOTHING; +--> statement-breakpoint +WITH project_sources AS ( + SELECT DISTINCT ON (skill."company_id", skill."metadata"->>'projectId') + skill."company_id", + skill."metadata"->>'projectId' AS project_id, + COALESCE(project."name", NULLIF(skill."metadata"->>'projectName', ''), 'Project') AS project_name + FROM "company_skills" AS skill + LEFT JOIN "projects" AS project + ON project."id"::text = skill."metadata"->>'projectId' + AND project."company_id" = skill."company_id" + WHERE skill."folder_id" IS NULL + AND skill."metadata"->>'sourceKind' = 'project_scan' + AND skill."metadata"->>'projectId' IS NOT NULL + ORDER BY skill."company_id", skill."metadata"->>'projectId', skill."id" +), project_folders AS ( + SELECT + source."company_id", + source.project_id, + source.project_name, + container."id" AS parent_id, + COALESCE( + NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(source.project_name), '[^a-z0-9]+', '-', 'g')), ''), + 'project' + ) AS base_slug + FROM project_sources AS source + JOIN "folders" AS container + ON container."company_id" = source."company_id" + AND container."kind" = 'skill' + AND container."system_key" = 'projects' +), ranked_project_folders AS ( + SELECT + source.*, + ROW_NUMBER() OVER ( + PARTITION BY source."company_id", source.parent_id, source.base_slug + ORDER BY source.project_id + ) AS duplicate_number + FROM project_folders AS source +) +INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position") +SELECT + source."company_id", + 'skill', + source.parent_id, + source.project_name, + CASE + WHEN sibling."id" IS NULL AND source.duplicate_number = 1 THEN source.base_slug + ELSE source.base_slug || '-' || LEFT(source.project_id, 8) + END, + 'project:' || source.project_id, + 0 +FROM ranked_project_folders AS source +LEFT JOIN "folders" AS sibling + ON sibling."company_id" = source."company_id" + AND sibling."kind" = 'skill' + AND sibling."parent_id" = source.parent_id + AND sibling."slug" = source.base_slug +WHERE NOT EXISTS ( + SELECT 1 FROM "folders" AS existing + WHERE existing."company_id" = source."company_id" + AND existing."kind" = 'skill' + AND existing."system_key" = 'project:' || source.project_id +) +ON CONFLICT DO NOTHING; +--> statement-breakpoint +UPDATE "company_skills" AS skill +SET "folder_id" = project_folder."id", "updated_at" = now() +FROM "folders" AS project_folder +WHERE project_folder."company_id" = skill."company_id" + AND project_folder."kind" = 'skill' + AND project_folder."system_key" = 'project:' || (skill."metadata"->>'projectId') + AND skill."folder_id" IS NULL + AND skill."metadata"->>'sourceKind' = 'project_scan'; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 9f778c178b..0e0c76170c 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1205,6 +1205,20 @@ "when": 1784210753027, "tag": "0173_inbox_policy_agent_cleanup", "breakpoints": true + }, + { + "idx": 174, + "version": "7", + "when": 1784210754027, + "tag": "0174_folders", + "breakpoints": true + }, + { + "idx": 175, + "version": "7", + "when": 1784210755027, + "tag": "0175_nested_skill_folders", + "breakpoints": true } ] } diff --git a/packages/db/src/nested-skill-folders-migration.test.ts b/packages/db/src/nested-skill-folders-migration.test.ts new file mode 100644 index 0000000000..64ba1cd904 --- /dev/null +++ b/packages/db/src/nested-skill-folders-migration.test.ts @@ -0,0 +1,123 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import postgres from "postgres"; +import { applyPendingMigrations } from "./client.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./test-embedded-postgres.js"; + +const MIGRATION_FILE = "0175_nested_skill_folders.sql"; +const cleanups: Array<() => Promise> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function migrationHash() { + const content = await fs.promises.readFile(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8"); + return createHash("sha256").update(content).digest("hex"); +} + +describeEmbeddedPostgres("nested skill folders migration", () => { + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); + }); + + it("backfills existing folders, bundled skills, and project scan skills", async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-nested-folders-migration-"); + cleanups.push(database.cleanup); + const sql = postgres(database.connectionString, { max: 1 }); + cleanups.push(async () => sql.end()); + + await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`; + await sql`DROP INDEX IF EXISTS "folders_company_kind_parent_position_idx"`; + await sql`DROP INDEX IF EXISTS "folders_company_kind_system_key_uq"`; + await sql`DROP INDEX IF EXISTS "folders_company_kind_root_slug_uq"`; + await sql`DROP INDEX IF EXISTS "folders_company_kind_parent_slug_uq"`; + await sql`ALTER TABLE "folders" DROP CONSTRAINT IF EXISTS "folders_company_kind_parent_slug_uq"`; + await sql`ALTER TABLE "folders" DROP CONSTRAINT IF EXISTS "folders_parent_id_folders_id_fk"`; + await sql`ALTER TABLE "folders" DROP COLUMN IF EXISTS "system_key"`; + await sql`ALTER TABLE "folders" DROP COLUMN IF EXISTS "slug"`; + await sql`ALTER TABLE "folders" DROP COLUMN IF EXISTS "parent_id"`; + await sql`CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_name_uq" ON "folders" ("company_id", "kind", "name")`; + + const companyId = randomUUID(); + const projectId = randomUUID(); + const existingFolderId = randomUUID(); + const squattedBundledId = randomUUID(); + const squattedProjectsId = randomUUID(); + const bundledSkillId = randomUUID(); + const projectSkillId = randomUUID(); + const unfiledSkillId = randomUUID(); + await sql` + INSERT INTO "companies" ("id", "name", "issue_prefix") + VALUES (${companyId}, 'Paperclip', 'PAP') + `; + await sql` + INSERT INTO "projects" ("id", "company_id", "name") + VALUES (${projectId}, ${companyId}, 'Agent Platform') + `; + await sql` + INSERT INTO "folders" ("id", "company_id", "kind", "name", "position") + VALUES + (${existingFolderId}, ${companyId}, 'skill', 'Team Notes', 0), + (${squattedBundledId}, ${companyId}, 'skill', 'Bundled', 1), + (${squattedProjectsId}, ${companyId}, 'skill', 'Projects', 2) + `; + await sql` + INSERT INTO "company_skills" ("id", "company_id", "key", "slug", "name", "markdown", "metadata") + VALUES + (${bundledSkillId}, ${companyId}, 'paperclipai/bundled/software-development/review', 'review', 'Review', '# Review', '{"sourceKind":"paperclip_bundled"}'::jsonb), + (${projectSkillId}, ${companyId}, 'company/project-skill', 'project-skill', 'Project Skill', '# Project', ${sql.json({ sourceKind: "project_scan", projectId, projectName: "Agent Platform" })}), + (${unfiledSkillId}, ${companyId}, 'company/unfiled', 'unfiled', 'Unfiled', '# Unfiled', '{}'::jsonb) + `; + + await applyPendingMigrations(database.connectionString); + + const folderRows = await sql<{ + id: string; + parent_id: string | null; + slug: string; + system_key: string | null; + }[]>` + SELECT "id", "parent_id", "slug", "system_key" + FROM "folders" + WHERE "company_id" = ${companyId} + ORDER BY "slug" + `; + expect(folderRows).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: existingFolderId, slug: "team-notes", parent_id: null }), + expect.objectContaining({ id: squattedBundledId, slug: `bundled-${squattedBundledId.replace(/-/g, "").slice(0, 8)}`, system_key: null }), + expect.objectContaining({ id: squattedProjectsId, slug: `projects-${squattedProjectsId.replace(/-/g, "").slice(0, 8)}`, system_key: null }), + expect.objectContaining({ slug: "bundled", system_key: "bundled", parent_id: null }), + expect.objectContaining({ slug: "software-development", system_key: "bundled:software-development" }), + expect.objectContaining({ slug: "projects", system_key: "projects", parent_id: null }), + expect.objectContaining({ slug: "agent-platform", system_key: `project:${projectId}` }), + ])); + + const skills = await sql<{ id: string; folder_id: string | null; folder_slug: string | null }[]>` + SELECT skill."id", skill."folder_id", folder."slug" AS folder_slug + FROM "company_skills" AS skill + LEFT JOIN "folders" AS folder ON folder."id" = skill."folder_id" + WHERE skill."company_id" = ${companyId} + ORDER BY skill."id" + `; + expect(skills.find((skill) => skill.id === bundledSkillId)).toMatchObject({ folder_slug: "software-development" }); + expect(skills.find((skill) => skill.id === projectSkillId)).toMatchObject({ folder_slug: "agent-platform" }); + expect(skills.find((skill) => skill.id === unfiledSkillId)).toMatchObject({ folder_id: null, folder_slug: null }); + + const indexes = await sql<{ indexname: string; indexdef: string }[]>` + SELECT "indexname", "indexdef" + FROM "pg_indexes" + WHERE "tablename" = 'folders' + AND "indexname" IN ('folders_company_kind_root_slug_uq', 'folders_company_kind_parent_slug_uq') + ORDER BY "indexname" + `; + expect(indexes).toHaveLength(2); + expect(indexes.map((index) => index.indexdef).join("\n")).not.toContain("NULLS NOT DISTINCT"); + expect(indexes.find((index) => index.indexname === "folders_company_kind_root_slug_uq")?.indexdef) + .toContain("WHERE (parent_id IS NULL)"); + expect(indexes.find((index) => index.indexname === "folders_company_kind_parent_slug_uq")?.indexdef) + .toContain("WHERE (parent_id IS NOT NULL)"); + }, 30_000); +}); diff --git a/packages/db/src/schema/company_skills.ts b/packages/db/src/schema/company_skills.ts index 7e6ad7d1d5..b1fd42d2af 100644 --- a/packages/db/src/schema/company_skills.ts +++ b/packages/db/src/schema/company_skills.ts @@ -13,12 +13,14 @@ import type { CompanySkillFileInventoryEntry, CompanySkillSharingScope } from "@ import { agents } from "./agents.js"; import { companies } from "./companies.js"; import { issues } from "./issues.js"; +import { folders } from "./folders.js"; export const companySkills = pgTable( "company_skills", { id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id), + folderId: uuid("folder_id").references(() => folders.id, { onDelete: "set null" }), key: text("key").notNull(), slug: text("slug").notNull(), name: text("name").notNull(), @@ -51,6 +53,7 @@ export const companySkills = pgTable( (table) => ({ companyKeyUniqueIdx: uniqueIndex("company_skills_company_key_idx").on(table.companyId, table.key), companyNameIdx: index("company_skills_company_name_idx").on(table.companyId, table.name), + companyFolderIdx: index("company_skills_company_folder_idx").on(table.companyId, table.folderId), companyCategoriesIdx: index("company_skills_company_categories_idx").using("gin", table.categories), companySharingScopeIdx: index("company_skills_company_sharing_scope_idx").on(table.companyId, table.sharingScope), companyCurrentVersionIdx: index("company_skills_company_current_version_idx").on(table.companyId, table.currentVersionId), diff --git a/packages/db/src/schema/folders.ts b/packages/db/src/schema/folders.ts new file mode 100644 index 0000000000..c186ad759a --- /dev/null +++ b/packages/db/src/schema/folders.ts @@ -0,0 +1,54 @@ +import { + type AnyPgColumn, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { companies } from "./companies.js"; +import type { FolderKind } from "@paperclipai/shared"; + +export const folders = pgTable( + "folders", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + kind: text("kind").$type().notNull(), + parentId: uuid("parent_id").references((): AnyPgColumn => folders.id, { onDelete: "restrict" }), + name: text("name").notNull(), + slug: text("slug").notNull(), + systemKey: text("system_key"), + color: text("color"), + position: integer("position").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyKindPositionIdx: index("folders_company_kind_position_idx").on( + table.companyId, + table.kind, + table.position, + table.name, + ), + companyKindRootSlugUniqueIdx: uniqueIndex("folders_company_kind_root_slug_uq") + .on(table.companyId, table.kind, table.slug) + .where(sql`${table.parentId} is null`), + companyKindParentSlugUniqueIdx: uniqueIndex("folders_company_kind_parent_slug_uq") + .on(table.companyId, table.kind, table.parentId, table.slug) + .where(sql`${table.parentId} is not null`), + companyKindSystemKeyUniqueIdx: uniqueIndex("folders_company_kind_system_key_uq") + .on(table.companyId, table.kind, table.systemKey) + .where(sql`${table.systemKey} is not null`), + companyKindParentPositionIdx: index("folders_company_kind_parent_position_idx").on( + table.companyId, + table.kind, + table.parentId, + table.position, + table.name, + ), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index ab9b93281f..2d03e463dc 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -35,6 +35,7 @@ export { workspaceOperations } from "./workspace_operations.js"; export { workspaceRuntimeServices } from "./workspace_runtime_services.js"; export { projectGoals } from "./project_goals.js"; export { goals } from "./goals.js"; +export { folders } from "./folders.js"; export { issues } from "./issues.js"; export { issueWatchdogs } from "./issue_watchdogs.js"; export { issuePlanDecompositions } from "./issue_plan_decompositions.js"; diff --git a/packages/db/src/schema/routines.ts b/packages/db/src/schema/routines.ts index ba65082c16..5b171f07e5 100644 --- a/packages/db/src/schema/routines.ts +++ b/packages/db/src/schema/routines.ts @@ -17,6 +17,7 @@ import { issues } from "./issues.js"; import { projects } from "./projects.js"; import { goals } from "./goals.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; +import { folders } from "./folders.js"; import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared"; export const routines = pgTable( @@ -25,6 +26,7 @@ export const routines = pgTable( id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }), + folderId: uuid("folder_id").references(() => folders.id, { onDelete: "set null" }), goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }), parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }), title: text("title").notNull(), @@ -56,6 +58,7 @@ export const routines = pgTable( companyStatusIdx: index("routines_company_status_idx").on(table.companyId, table.status), companyAssigneeIdx: index("routines_company_assignee_idx").on(table.companyId, table.assigneeAgentId), companyProjectIdx: index("routines_company_project_idx").on(table.companyId, table.projectId), + companyFolderIdx: index("routines_company_folder_idx").on(table.companyId, table.folderId), companyResponsibleUserIdx: index("routines_company_responsible_user_idx").on(table.companyId, table.responsibleUserId), companyOriginIdx: index("routines_company_origin_idx").on(table.companyId, table.originKind, table.originId), }), diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 1635ceb3d1..d8ff3bf3dd 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -3,6 +3,10 @@ export const API_PREFIX = "/api"; export const API = { health: `${API_PREFIX}/health`, companies: `${API_PREFIX}/companies`, + companyFolders: `${API_PREFIX}/companies/:companyId/folders`, + companyFolder: `${API_PREFIX}/companies/:companyId/folders/:folderId`, + companyFolderMove: `${API_PREFIX}/companies/:companyId/folders/:folderId/move`, + companyFolderItemMove: `${API_PREFIX}/companies/:companyId/folders/items/move`, agents: `${API_PREFIX}/agents`, projects: `${API_PREFIX}/projects`, environments: `${API_PREFIX}/environments`, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cf991c13f3..93947c96fc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2101,12 +2101,40 @@ export type { } from "./environment-support.js"; export type { AdapterRegistryEntry } from "./types/adapter-registry.js"; +export type { + FolderKind, + Folder, + FolderListItem, + FolderListResult, + CreateFolderRequest, + UpdateFolderRequest, + MoveFolderRequest, + MoveFolderItemRequest, + EnsureMySkillFolderRequest, +} from "./types/folder.js"; export { adapterRegistryEntrySchema, adapterRegistrySchema, type AdapterRegistryEntryParsed, } from "./validators/adapter-registry.js"; +export { + folderKindSchema, + folderSlugSchema, + folderSchema, + folderListItemSchema, + folderListResultSchema, + createFolderSchema, + updateFolderSchema, + moveFolderSchema, + moveFolderItemSchema, + ensureMySkillFolderSchema, + type CreateFolder, + type UpdateFolder, + type MoveFolder, + type MoveFolderItem, + type EnsureMySkillFolder, +} from "./validators/folder.js"; export { environmentCustomImageTemplateKindSchema, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 29ea6e3ddc..6e195f0e29 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -34,6 +34,8 @@ export interface CompanySkillVersionFileInventoryEntry extends CompanySkillFileI export interface CompanySkill { id: string; companyId: string; + folderId?: string | null; + folderPath?: string | null; key: string; slug: string; name: string; @@ -67,6 +69,8 @@ export interface CompanySkill { export interface CompanySkillListItem { id: string; companyId: string; + folderId?: string | null; + folderPath?: string | null; key: string; slug: string; name: string; @@ -140,6 +144,8 @@ export interface CompanySkillListQuery { categories?: string[]; scope?: CompanySkillSharingScope; include?: CompanySkillListInclude[]; + folderId?: string; + includeSubtree?: boolean; } export interface CompanySkillCategoryCount { @@ -374,6 +380,7 @@ export interface CompanySkillProjectScanResult { } export interface CompanySkillCreateRequest { + folderId?: string | null; name: string; slug?: string | null; description?: string | null; diff --git a/packages/shared/src/types/folder.ts b/packages/shared/src/types/folder.ts new file mode 100644 index 0000000000..451856a672 --- /dev/null +++ b/packages/shared/src/types/folder.ts @@ -0,0 +1,59 @@ +export type FolderKind = "routine" | "skill"; + +export interface Folder { + id: string; + companyId: string; + kind: FolderKind; + parentId: string | null; + name: string; + slug: string; + systemKey: string | null; + path: string; + depth: number; + color: string | null; + position: number; + createdAt: Date; + updatedAt: Date; +} + +export interface FolderListItem extends Folder { + itemCount: number; +} + +export interface FolderListResult { + kind: FolderKind; + folders: FolderListItem[]; + allCount: number; + unfiledCount: number; +} + +export interface CreateFolderRequest { + kind: FolderKind; + parentId?: string | null; + name: string; + slug?: string | null; + color?: string | null; + position?: number | null; +} + +export interface UpdateFolderRequest { + name?: string; + slug?: string; + color?: string | null; + position?: number; +} + +export interface MoveFolderRequest { + parentId?: string | null; + position: number; +} + +export interface EnsureMySkillFolderRequest { + slug?: string | null; +} + +export interface MoveFolderItemRequest { + kind: FolderKind; + itemId: string; + folderId?: string | null; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 74537a5a33..da48529ed3 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -163,6 +163,17 @@ export type { CompanySkillInstallCatalogRequest, CompanySkillInstallCatalogResult, } from "./company-skill.js"; +export type { + FolderKind, + Folder, + FolderListItem, + FolderListResult, + CreateFolderRequest, + UpdateFolderRequest, + MoveFolderRequest, + MoveFolderItemRequest, + EnsureMySkillFolderRequest, +} from "./folder.js"; export type { CatalogTeamKind, CatalogTeamTrustLevel, diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index 571d7492ef..bab97b6d4e 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -71,6 +71,7 @@ export interface Routine { id: string; companyId: string; projectId: string | null; + folderId?: string | null; goalId: string | null; parentIssueId: string | null; title: string; diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index f458efaf3c..ac3a61fd1a 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -20,6 +20,8 @@ export const companySkillVersionFileInventoryEntrySchema = companySkillFileInven export const companySkillSchema = z.object({ id: z.string().uuid(), companyId: z.string().uuid(), + folderId: z.string().uuid().nullable().optional(), + folderPath: z.string().nullable().optional(), key: z.string().min(1), slug: z.string().min(1), name: z.string().min(1), @@ -106,6 +108,8 @@ export const companySkillListQuerySchema = z.object({ categories: z.array(z.string().min(1)).optional(), scope: companySkillSharingScopeSchema.optional(), include: z.array(companySkillListIncludeSchema).optional(), + folderId: z.string().uuid().optional(), + includeSubtree: z.boolean().optional(), }); export const companySkillCategoryCountSchema = z.object({ @@ -320,6 +324,7 @@ export const companySkillProjectScanResultSchema = z.object({ }); export const companySkillCreateSchema = z.object({ + folderId: z.string().uuid().nullable().optional(), name: z.string().min(1), slug: z.string().min(1).nullable().optional(), description: z.string().nullable().optional(), diff --git a/packages/shared/src/validators/folder.ts b/packages/shared/src/validators/folder.ts new file mode 100644 index 0000000000..4f3e231029 --- /dev/null +++ b/packages/shared/src/validators/folder.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; + +export const folderKindSchema = z.enum(["routine", "skill"]); +export const folderSlugSchema = z.string().trim().min(1).max(120).regex( + /^[a-z0-9]+(?:-[a-z0-9]+)*$/, + "Folder slug must contain only lowercase letters, numbers, and single hyphens", +); + +export const folderSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + kind: folderKindSchema, + parentId: z.string().uuid().nullable(), + name: z.string().min(1), + slug: folderSlugSchema, + systemKey: z.string().nullable(), + path: z.string().min(1), + depth: z.number().int().min(1), + color: z.string().nullable(), + position: z.number().int(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +export const folderListItemSchema = folderSchema.extend({ + itemCount: z.number().int().nonnegative(), +}); + +export const folderListResultSchema = z.object({ + kind: folderKindSchema, + folders: z.array(folderListItemSchema), + allCount: z.number().int().nonnegative(), + unfiledCount: z.number().int().nonnegative(), +}); + +export const createFolderSchema = z.object({ + kind: folderKindSchema, + parentId: z.string().uuid().optional().nullable(), + name: z.string().trim().min(1).max(120), + slug: folderSlugSchema.optional().nullable(), + color: z.string().trim().min(1).max(80).optional().nullable(), + position: z.number().int().min(0).optional().nullable(), +}); + +export const updateFolderSchema = z.object({ + name: z.string().trim().min(1).max(120).optional(), + slug: folderSlugSchema.optional(), + color: z.string().trim().min(1).max(80).optional().nullable(), + position: z.number().int().min(0).optional(), +}).refine((value) => Object.keys(value).length > 0, { + message: "At least one folder field is required", +}); + +export const moveFolderSchema = z.object({ + parentId: z.string().uuid().optional().nullable(), + position: z.number().int().min(0), +}); + +export const ensureMySkillFolderSchema = z.object({ + slug: folderSlugSchema.optional().nullable(), +}).default({}); + +export const moveFolderItemSchema = z.object({ + kind: folderKindSchema, + itemId: z.string().uuid(), + folderId: z.string().uuid().optional().nullable(), +}); + +export type CreateFolder = z.infer; +export type UpdateFolder = z.infer; +export type MoveFolder = z.infer; +export type MoveFolderItem = z.infer; +export type EnsureMySkillFolder = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 58853af7a9..3594b922e5 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -194,6 +194,23 @@ export { type CompanySkillInstallUpdate, type CompanySkillReset, } from "./company-skill.js"; +export { + folderKindSchema, + folderSlugSchema, + folderSchema, + folderListItemSchema, + folderListResultSchema, + createFolderSchema, + updateFolderSchema, + moveFolderSchema, + moveFolderItemSchema, + ensureMySkillFolderSchema, + type CreateFolder, + type UpdateFolder, + type MoveFolder, + type MoveFolderItem, + type EnsureMySkillFolder, +} from "./folder.js"; export { catalogTeamKindSchema, catalogTeamTrustLevelSchema, diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index 56629340fd..f0e4a92851 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -61,6 +61,7 @@ export const routineVariableSchema = z.object({ export const createRoutineSchema = z.object({ projectId: z.string().uuid().optional().nullable(), + folderId: z.string().uuid().optional().nullable(), goalId: z.string().uuid().optional().nullable(), parentIssueId: z.string().uuid().optional().nullable(), title: z.string().trim().min(1).max(200), @@ -85,6 +86,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({ id: z.string().uuid(), companyId: z.string().uuid(), projectId: z.string().uuid().nullable(), + folderId: z.string().uuid().nullable().optional(), goalId: z.string().uuid().nullable(), parentIssueId: z.string().uuid().nullable(), title: z.string().trim().min(1).max(200), diff --git a/server/src/__tests__/company-skills-catalog-service.test.ts b/server/src/__tests__/company-skills-catalog-service.test.ts index c2fbe60176..b2b6b54902 100644 --- a/server/src/__tests__/company-skills-catalog-service.test.ts +++ b/server/src/__tests__/company-skills-catalog-service.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { promises as fs } from "node:fs"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { and, eq } from "drizzle-orm"; -import { companies, companySkills, createDb } from "@paperclipai/db"; +import { companies, companySkills, createDb, folders } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -132,6 +132,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => { afterEach(async () => { await db.delete(companySkills); + await db.delete(folders); await db.delete(companies); await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); cleanupDirs.clear(); @@ -154,6 +155,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => { expect(result.action).toBe("created"); expect(result.skill).toMatchObject({ companyId, + folderId: expect.any(String), key: sampleCatalogSkill.key, slug: sampleCatalogSkill.slug, sourceType: "catalog", @@ -182,6 +184,37 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => { packageName: "@paperclipai/skills-catalog", packageVersion: "0.3.1", }); + const folder = await db + .select() + .from(folders) + .where(eq(folders.id, result.skill.folderId!)) + .then((rows) => rows[0]); + expect(folder).toMatchObject({ + name: "Software Development", + slug: "software-development", + systemKey: "bundled:software-development", + }); + }); + + it("repairs an existing unfiled Paperclip catalog skill during inventory refresh", async () => { + const companyId = await createCompany(); + const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id }); + await db + .update(companySkills) + .set({ folderId: null }) + .where(eq(companySkills.id, installed.skill.id)); + + const listed = await svc.list(companyId); + const repaired = listed.find((skill) => skill.id === installed.skill.id); + const folder = repaired?.folderId + ? await db.select().from(folders).where(eq(folders.id, repaired.folderId)).then((rows) => rows[0]) + : null; + + expect(repaired?.folderId).toEqual(expect.any(String)); + expect(folder).toMatchObject({ + name: "Software Development", + systemKey: "bundled:software-development", + }); }); it("materializes catalog asset files without UTF-8 rewriting", async () => { diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index 2d6035146a..6df5bab4e6 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -11,6 +11,7 @@ import { companySkillVersions, companySkills, createDb, + folders, projects, projectWorkspaces, } from "@paperclipai/db"; @@ -19,6 +20,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { companySkillService } from "../services/company-skills.ts"; +import { folderService } from "../services/folders.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -63,6 +65,7 @@ describeEmbeddedPostgres("companySkillService.list", () => { await db.delete(companySkills); await db.delete(projectWorkspaces); await db.delete(projects); + await db.delete(folders); await db.delete(companies); await db.delete(authUsers); await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); @@ -310,6 +313,13 @@ describeEmbeddedPostgres("companySkillService.list", () => { const bundledSkill = initialList.find((skill) => skill.key.startsWith("paperclipai/paperclip/")); expect(bundledSkill).toBeDefined(); if (!bundledSkill) throw new Error("Expected bundled Paperclip skills fixture"); + const bundledFolder = bundledSkill.folderId + ? await db.select().from(folders).where(eq(folders.id, bundledSkill.folderId)).then((rows) => rows[0]) + : null; + expect(bundledFolder).toMatchObject({ + name: "Paperclip Core", + systemKey: "bundled:paperclip-core", + }); const preservedUpdatedAt = new Date("2026-01-01T00:00:00.000Z"); await db @@ -323,6 +333,34 @@ describeEmbeddedPostgres("companySkillService.list", () => { expect(refreshedSkill?.updatedAt.toISOString()).toBe(preservedUpdatedAt.toISOString()); }); + it("repairs a squatted bundled root during bundled-skill list refresh", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + const [squatted] = await db.insert(folders).values({ + companyId, + kind: "skill", + parentId: null, + name: "User Bundled", + slug: "bundled", + position: 0, + }).returning(); + + const listed = await svc.list(companyId); + const folderRows = await db.select().from(folders).where(eq(folders.companyId, companyId)); + const bundledRoot = folderRows.find((folder) => folder.systemKey === "bundled"); + const repairedSquat = folderRows.find((folder) => folder.id === squatted!.id); + + expect(listed.some((skill) => skill.key.startsWith("paperclipai/paperclip/"))).toBe(true); + expect(bundledRoot).toMatchObject({ slug: "bundled", parentId: null, systemKey: "bundled" }); + expect(repairedSquat).toMatchObject({ name: "User Bundled", systemKey: null }); + expect(repairedSquat?.slug).toMatch(/^bundled-[a-f0-9]{8}$/); + }); + it("does not retouch bundled skills with stale missing-source metadata during list refresh", async () => { const companyId = randomUUID(); await db.insert(companies).values({ @@ -686,6 +724,97 @@ describeEmbeddedPostgres("companySkillService.list", () => { await expect(svc.categoryCounts(companyId)).resolves.toEqual([]); }); + it("filters by folder subtree, keeps search global, and returns canonical folder paths", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + const folderSvc = folderService(db); + const engineering = await folderSvc.create(companyId, { kind: "skill", name: "Engineering" }); + const reviews = await folderSvc.create(companyId, { kind: "skill", parentId: engineering.id, name: "Reviews" }); + const operations = await folderSvc.create(companyId, { kind: "skill", name: "Operations" }); + + const reviewDir = await createManagedSkillDir(companyId, "review-"); + const deployDir = await createManagedSkillDir(companyId, "deploy-"); + await fs.writeFile(path.join(reviewDir, "SKILL.md"), "# Review\n", "utf8"); + await fs.writeFile(path.join(deployDir, "SKILL.md"), "# Deploy\n", "utf8"); + await db.insert(companySkills).values([ + { + companyId, + folderId: reviews.id, + key: `company/${companyId}/review`, + slug: "review", + name: "Review", + markdown: "# Review", + sourceType: "local_path", + sourceLocator: reviewDir, + categories: ["engineering"], + }, + { + companyId, + folderId: operations.id, + key: `company/${companyId}/deploy`, + slug: "deploy", + name: "Deploy", + markdown: "# Deploy", + sourceType: "local_path", + sourceLocator: deployDir, + categories: ["operations"], + }, + ]); + + await expect(svc.list(companyId, { + folderId: engineering.id, + includeSubtree: true, + categories: ["engineering"], + })).resolves.toEqual([ + expect.objectContaining({ name: "Review", folderPath: "engineering/reviews" }), + ]); + await expect(svc.list(companyId, { folderId: engineering.id })).resolves.toEqual([]); + await expect(svc.list(companyId, { folderId: engineering.id, q: "deploy" })).resolves.toEqual([ + expect.objectContaining({ name: "Deploy", folderPath: "operations" }), + ]); + const review = (await svc.list(companyId)).find((skill) => skill.name === "Review"); + await expect(svc.getById(companyId, review!.id)).resolves.toMatchObject({ + name: "Review", + folderPath: "engineering/reviews", + }); + }); + + it("creates skills in same-company folders and rejects cross-company folder references", async () => { + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + await db.insert(companies).values([ + { + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + { + id: otherCompanyId, + name: "Other", + issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }, + ]); + const folderSvc = folderService(db); + const folder = await folderSvc.create(companyId, { kind: "skill", name: "Personal" }); + const otherFolder = await folderSvc.create(otherCompanyId, { kind: "skill", name: "Private" }); + + await expect(svc.createLocalSkill(companyId, { + name: "Filed Skill", + folderId: folder.id, + })).resolves.toMatchObject({ folderId: folder.id }); + await expect(svc.createLocalSkill(companyId, { + name: "Cross Company Skill", + folderId: otherFolder.id, + })).rejects.toMatchObject({ status: 404, message: "Skill folder not found" }); + }); + it("resolves detail by unique skill slug for Studio deep links", async () => { const companyId = randomUUID(); await db.insert(companies).values({ @@ -2327,4 +2456,60 @@ describeEmbeddedPostgres("companySkillService.list", () => { const persisted = await db.select().from(companySkills).where(eq(companySkills.companyId, companyId)); expect(persisted.filter((skill) => skill.metadata?.sourceKind === "project_scan")).toEqual([]); }); + + it("files new project imports without moving them back on re-import", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + const folderSvc = folderService(db); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-project-folder-")); + cleanupDirs.add(workspaceDir); + const skillDir = path.join(workspaceDir, "skills", "project-skill"); + const skillFile = path.join(skillDir, "SKILL.md"); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile(skillFile, "---\nname: Project Skill\n---\n\nInitial content.\n", "utf8"); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" }); + await db.insert(projectWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + name: "Primary", + cwd: workspaceDir, + isPrimary: true, + }); + + const firstImport = await svc.scanProjectWorkspaces(companyId, { projectIds: [projectId] }); + + expect(firstImport.imported).toHaveLength(1); + const importedSkill = firstImport.imported[0]!; + const projectFolder = await folderSvc.getFolder(companyId, importedSkill.folderId!); + expect(projectFolder).toMatchObject({ + path: "projects/skills-project", + systemKey: `project:${projectId}`, + }); + + const personalFolder = await folderSvc.create(companyId, { kind: "skill", name: "Personal" }); + await folderSvc.moveItem(companyId, { + kind: "skill", + itemId: importedSkill.id, + folderId: personalFolder.id, + }); + await fs.writeFile(skillFile, "---\nname: Project Skill\n---\n\nUpdated content.\n", "utf8"); + + const reimport = await svc.scanProjectWorkspaces(companyId, { projectIds: [projectId] }); + + expect(reimport.updated).toHaveLength(1); + expect(reimport.updated[0]).toMatchObject({ + id: importedSkill.id, + folderId: personalFolder.id, + markdown: expect.stringContaining("Updated content."), + }); + }); + }); diff --git a/server/src/__tests__/folders-routes.test.ts b/server/src/__tests__/folders-routes.test.ts new file mode 100644 index 0000000000..f0eff113f0 --- /dev/null +++ b/server/src/__tests__/folders-routes.test.ts @@ -0,0 +1,74 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFolderService = vi.hoisted(() => ({ + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + moveFolder: vi.fn(), + moveItem: vi.fn(), + deleteFolder: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + folderService: () => mockFolderService, + logActivity: mockLogActivity, +})); + +async function createApp() { + vi.resetModules(); + const [{ errorHandler }, { folderRoutes }] = await Promise.all([ + import("../middleware/index.js") as Promise, + import("../routes/folders.js") as Promise, + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", folderRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("folder routes", () => { + beforeEach(() => { + for (const mock of Object.values(mockFolderService)) mock.mockReset(); + mockLogActivity.mockReset(); + }); + + it("routes item moves to the item move handler before the folder reorder route", async () => { + mockFolderService.moveItem.mockResolvedValue({ + kind: "routine", + itemId: "11111111-1111-4111-8111-111111111111", + folderId: "22222222-2222-4222-8222-222222222222", + }); + + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/folders/items/move") + .send({ + kind: "routine", + itemId: "11111111-1111-4111-8111-111111111111", + folderId: "22222222-2222-4222-8222-222222222222", + }); + + expect(res.status).toBe(200); + expect(mockFolderService.moveItem).toHaveBeenCalledWith("company-1", { + kind: "routine", + itemId: "11111111-1111-4111-8111-111111111111", + folderId: "22222222-2222-4222-8222-222222222222", + }); + expect(mockFolderService.moveFolder).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/folders-service.test.ts b/server/src/__tests__/folders-service.test.ts new file mode 100644 index 0000000000..a141f9965d --- /dev/null +++ b/server/src/__tests__/folders-service.test.ts @@ -0,0 +1,447 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq, sql } from "drizzle-orm"; +import { + folderSlugSchema, +} from "@paperclipai/shared"; +import { + companies, + companySkills, + createDb, + folders, + routines, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { folderService } from "../services/folders.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("folder service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-folders-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(companySkills); + await db.delete(routines); + await db.delete(folders); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + defaultResponsibleUserId: "responsible-user", + }); + return companyId; + } + + async function seedRoutine(companyId: string, title: string, folderId?: string | null) { + const [routine] = await db + .insert(routines) + .values({ + companyId, + title, + folderId: folderId ?? null, + responsibleUserId: "responsible-user", + }) + .returning(); + return routine!; + } + + async function seedSkill(companyId: string, slug: string, folderId?: string | null) { + const [skill] = await db + .insert(companySkills) + .values({ + companyId, + folderId: folderId ?? null, + key: `company/${companyId}/${slug}`, + slug, + name: slug, + markdown: `# ${slug}`, + }) + .returning(); + return skill!; + } + + it("creates, updates, reorders, and lists routine folders with counts", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + + const reporting = await svc.create(companyId, { + kind: "routine", + name: "Reporting", + color: "green", + }); + const cleanup = await svc.create(companyId, { + kind: "routine", + name: "Cleanup", + color: null, + }); + await seedRoutine(companyId, "Filed", reporting.id); + await seedRoutine(companyId, "Unfiled"); + + const renamed = await svc.update(companyId, cleanup.id, { name: "Ops", color: "cyan" }); + expect(renamed).toMatchObject({ id: cleanup.id, name: "Ops", color: "cyan" }); + const cleared = await svc.update(companyId, cleanup.id, { color: null }); + expect(cleared).toMatchObject({ id: cleanup.id, color: null }); + + const movedFolder = await svc.moveFolder(companyId, reporting.id, { position: 10 }); + expect(movedFolder).toMatchObject({ id: reporting.id, position: 10 }); + + const listed = await svc.list(companyId, "routine"); + expect(listed.allCount).toBe(2); + expect(listed.unfiledCount).toBe(1); + expect(listed.folders).toEqual([ + expect.objectContaining({ id: cleanup.id, name: "Ops", itemCount: 0 }), + expect.objectContaining({ id: reporting.id, name: "Reporting", itemCount: 1 }), + ]); + }); + + it("moves routines and skills to folders and back to virtual Unfiled", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const routineFolder = await svc.create(companyId, { kind: "routine", name: "Reports" }); + const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" }); + const routine = await seedRoutine(companyId, "Daily report"); + const skill = await seedSkill(companyId, "review"); + + await expect(svc.moveItem(companyId, { + kind: "routine", + itemId: routine.id, + folderId: routineFolder.id, + })).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: routineFolder.id }); + await expect(svc.moveItem(companyId, { + kind: "skill", + itemId: skill.id, + folderId: skillFolder.id, + })).resolves.toEqual({ kind: "skill", itemId: skill.id, folderId: skillFolder.id }); + + await expect(svc.moveItem(companyId, { + kind: "routine", + itemId: routine.id, + folderId: null, + })).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: null }); + + const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id)); + const [updatedSkill] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id)); + expect(updatedRoutine?.folderId).toBeNull(); + expect(updatedSkill?.folderId).toBe(skillFolder.id); + }); + + it("rejects moving an item into a folder of the wrong kind", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" }); + const routine = await seedRoutine(companyId, "Daily report"); + + await expect(svc.moveItem(companyId, { + kind: "routine", + itemId: routine.id, + folderId: skillFolder.id, + })).rejects.toMatchObject({ + status: 422, + message: "Folder kind must match item kind", + }); + }); + + it("deletes folders without deleting contents by moving items to Unfiled", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const folder = await svc.create(companyId, { kind: "routine", name: "Reports" }); + const routine = await seedRoutine(companyId, "Daily report", folder.id); + + const deleted = await svc.deleteFolder(companyId, folder.id); + expect(deleted).toMatchObject({ id: folder.id, name: "Reports" }); + + const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id)); + expect(updatedRoutine?.folderId).toBeNull(); + expect(await db.select().from(folders).where(eq(folders.id, folder.id))).toHaveLength(0); + }); + + it("computes canonical paths and updates descendant paths after rename and move", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const root = await svc.create(companyId, { kind: "skill", name: "Engineering" }); + const child = await svc.create(companyId, { kind: "skill", parentId: root.id, name: "Code Review" }); + const destination = await svc.create(companyId, { kind: "skill", name: "Operations" }); + + expect(child).toMatchObject({ path: "engineering/code-review", depth: 2 }); + await svc.update(companyId, root.id, { name: "Product Engineering" }); + expect(await svc.getFolder(companyId, child.id)).toMatchObject({ + path: "product-engineering/code-review", + depth: 2, + }); + + await svc.moveFolder(companyId, child.id, { parentId: destination.id, position: 0 }); + expect(await svc.getFolder(companyId, child.id)).toMatchObject({ + parentId: destination.id, + path: "operations/code-review", + depth: 2, + }); + }); + + it("rejects invalid slugs, cycles, and folders deeper than four levels", async () => { + expect(folderSlugSchema.safeParse("../escape").success).toBe(false); + expect(folderSlugSchema.safeParse("Valid Slug").success).toBe(false); + expect(folderSlugSchema.safeParse("valid-slug-2").success).toBe(true); + + const companyId = await seedCompany(); + const svc = folderService(db); + const level1 = await svc.create(companyId, { kind: "skill", name: "Level 1" }); + const level2 = await svc.create(companyId, { kind: "skill", parentId: level1.id, name: "Level 2" }); + const level3 = await svc.create(companyId, { kind: "skill", parentId: level2.id, name: "Level 3" }); + const level4 = await svc.create(companyId, { kind: "skill", parentId: level3.id, name: "Level 4" }); + + await expect(svc.create(companyId, { + kind: "skill", + parentId: level4.id, + name: "Level 5", + })).rejects.toMatchObject({ status: 422, message: "Folder depth cannot exceed 4" }); + await expect(svc.moveFolder(companyId, level1.id, { + parentId: level3.id, + position: 0, + })).rejects.toMatchObject({ status: 422, message: "A folder cannot be moved into its own subtree" }); + }); + + it("creates stable personal roots and protects bundled folders", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"); + const repeated = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"); + const bundled = await svc.ensureBundledCategory(companyId, "software-development"); + + expect(repeated.id).toBe(personal.id); + expect(personal).toMatchObject({ systemKey: "my:user-1", path: "my/ada-lovelace", depth: 2 }); + expect(bundled.path).toBe("bundled/software-development"); + await expect(svc.create(companyId, { + kind: "skill", + parentId: bundled.id, + name: "Nested", + })).rejects.toMatchObject({ status: 403, message: "Bundled folders are read-only" }); + await expect(svc.update(companyId, bundled.id, { name: "Changed" })).rejects.toMatchObject({ status: 403 }); + }); + + it("heals legacy bundled category names without changing folder identity", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const legacy = await svc.ensureBundledCategory(companyId, "software-development"); + + const reconciled = await svc.ensureBundledCategory(companyId, "Software Development"); + + expect(reconciled).toMatchObject({ + id: legacy.id, + name: "Software Development", + path: "bundled/software-development", + systemKey: "bundled:software-development", + }); + }); + + it("creates reserved folders idempotently under concurrent requests", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + + const [personalA, personalB] = await Promise.all([ + svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"), + svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"), + ]); + const [projectA, projectB] = await Promise.all([ + svc.ensureProjectFolder(companyId, "project-1", "Core App"), + svc.ensureProjectFolder(companyId, "project-1", "Core App"), + ]); + const [bundledA, bundledB] = await Promise.all([ + svc.ensureBundledCategory(companyId, "software-development"), + svc.ensureBundledCategory(companyId, "software-development"), + ]); + + expect(personalA.id).toBe(personalB.id); + expect(projectA.id).toBe(projectB.id); + expect(bundledA.id).toBe(bundledB.id); + }); + + it("reserves system skill roots from manual create, update, and move", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + + for (const slug of ["bundled", "my", "projects"]) { + await expect(svc.create(companyId, { + kind: "skill", + name: slug, + slug, + })).rejects.toMatchObject({ status: 403, message: "Reserved skill folders are system-managed" }); + } + + const editable = await svc.create(companyId, { kind: "skill", name: "Editable" }); + await expect(svc.update(companyId, editable.id, { slug: "bundled" })).rejects.toMatchObject({ status: 403 }); + + const parent = await svc.create(companyId, { kind: "skill", name: "Parent" }); + const nestedReserved = await svc.create(companyId, { kind: "skill", parentId: parent.id, name: "Projects" }); + await expect(svc.moveFolder(companyId, nestedReserved.id, { parentId: null, position: 0 })).rejects.toMatchObject({ + status: 403, + message: "Reserved skill folders are system-managed", + }); + }); + + it("allows only system helpers to create children under personal and project roots", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"); + const project = await svc.ensureProjectFolder(companyId, "project-1", "Core App"); + const myRoot = await svc.getFolder(companyId, personal.parentId!); + const projectsRoot = await svc.getFolder(companyId, project.parentId!); + const movable = await svc.create(companyId, { kind: "skill", name: "Movable" }); + + expect(myRoot?.systemKey).toBe("my"); + expect(projectsRoot?.systemKey).toBe("projects"); + await expect(svc.create(companyId, { + kind: "skill", + parentId: myRoot!.id, + name: "Spoofed User", + })).rejects.toMatchObject({ status: 403 }); + await expect(svc.moveFolder(companyId, movable.id, { + parentId: projectsRoot!.id, + position: 0, + })).rejects.toMatchObject({ status: 403 }); + }); + + it("moves squatted roots aside instead of adopting them as system containers", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const [squattedMy, squattedProjects] = await db.insert(folders).values([ + { companyId, kind: "skill", parentId: null, name: "Attacker My", slug: "my", position: 0 }, + { companyId, kind: "skill", parentId: null, name: "Attacker Projects", slug: "projects", position: 1 }, + ]).returning(); + + const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"); + const project = await svc.ensureProjectFolder(companyId, "project-1", "Core App"); + const myRoot = await svc.getFolder(companyId, personal.parentId!); + const projectsRoot = await svc.getFolder(companyId, project.parentId!); + const repairedMy = await svc.getFolder(companyId, squattedMy!.id); + const repairedProjects = await svc.getFolder(companyId, squattedProjects!.id); + + expect(myRoot).toMatchObject({ slug: "my", systemKey: "my" }); + expect(projectsRoot).toMatchObject({ slug: "projects", systemKey: "projects" }); + expect(repairedMy).toMatchObject({ name: "Attacker My", systemKey: null }); + expect(repairedMy?.slug).toMatch(/^my-[a-f0-9]{8}$/); + expect(repairedProjects).toMatchObject({ name: "Attacker Projects", systemKey: null }); + expect(repairedProjects?.slug).toMatch(/^projects-[a-f0-9]{8}$/); + }); + + it("suffixes system children when legacy rows squat personal and project slugs", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const initialPersonal = await svc.ensureMyFolder(companyId, "seed-user", "Seed User"); + const initialProject = await svc.ensureProjectFolder(companyId, "seed-project", "Seed Project"); + const myRootId = initialPersonal.parentId!; + const projectsRootId = initialProject.parentId!; + await db.insert(folders).values([ + { companyId, kind: "skill", parentId: myRootId, name: "Ada Squat", slug: "ada-lovelace", position: 1 }, + { companyId, kind: "skill", parentId: projectsRootId, name: "Core Squat", slug: "core-app", position: 1 }, + ]); + + const personal = await svc.ensureMyFolder(companyId, "user-12345678", "Ada Lovelace"); + const project = await svc.ensureProjectFolder(companyId, "project-12345678", "Core App"); + + expect(personal).toMatchObject({ path: "my/ada-lovelace-user-12345678", systemKey: "my:user-12345678" }); + expect(project).toMatchObject({ path: "projects/core-app-project-12345678", systemKey: "project:project-12345678" }); + }); + + it("does not adopt a legacy category row under the bundled root", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const initialCategory = await svc.ensureBundledCategory(companyId, "initial"); + const bundledRootId = initialCategory.parentId!; + const [squatted] = await db.insert(folders).values({ + companyId, + kind: "skill", + parentId: bundledRootId, + name: "User Software Development", + slug: "software-development", + position: 1, + }).returning(); + + const category = await svc.ensureBundledCategory(companyId, "software-development"); + + expect(category).toMatchObject({ + path: "bundled/software-development-bundled", + systemKey: "bundled:software-development", + }); + expect(await svc.getFolder(companyId, squatted!.id)).toMatchObject({ + path: "bundled/software-development", + systemKey: null, + }); + }); + + it("serializes concurrent system folder ensures", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + await db.insert(folders).values({ + companyId, + kind: "skill", + name: "Squatted My", + slug: "my", + position: 0, + }); + + const personalFolders = await Promise.all( + Array.from({ length: 8 }, () => svc.ensureMyFolder(companyId, "user-123", "Ada Lovelace")), + ); + + expect(new Set(personalFolders.map((folder) => folder.id)).size).toBe(1); + const rows = await db.select().from(folders).where(eq(folders.companyId, companyId)); + expect(rows.filter((row) => row.systemKey === "my")).toHaveLength(1); + expect(rows.filter((row) => row.systemKey === "my:user-123")).toHaveLength(1); + expect(rows.find((row) => row.systemKey === null)).toMatchObject({ name: "Squatted My" }); + }); + + it("rechecks nested folders after waiting for the company mutation lock", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const parent = await svc.create(companyId, { kind: "routine", name: "Parent" }); + let releaseLock!: () => void; + let markLockAcquired!: () => void; + const lockAcquired = new Promise((resolve) => { markLockAcquired = resolve; }); + const holdLock = new Promise((resolve) => { releaseLock = resolve; }); + const lockKey = `paperclip:folders:${companyId}`; + const blocker = db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`); + markLockAcquired(); + await holdLock; + }); + await lockAcquired; + + const deletion = svc.deleteFolder(companyId, parent.id); + await db.insert(folders).values({ + companyId, + kind: "routine", + parentId: parent.id, + name: "Child", + slug: "child", + position: 0, + }); + releaseLock(); + await blocker; + + await expect(deletion).rejects.toMatchObject({ + status: 409, + message: "Move or delete nested folders first", + }); + await expect(svc.getFolder(companyId, parent.id)).resolves.toMatchObject({ id: parent.id }); + }); +}); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 290b6ed6a1..33b3e1b218 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -30,6 +30,7 @@ const apiPrefixes: Record = { "environments.ts": "/api", "execution-workspaces.ts": "/api", "file-resources.ts": "/api", + "folders.ts": "/api", "goals.ts": "/api", "health.ts": "/api/health", "inbox-dismissals.ts": "/api", @@ -181,6 +182,10 @@ describe("openapi routes", () => { name: { type: "string" }, }, }); + expect(res.body.paths["/api/companies/{companyId}/folders"].post.responses["201"]).toBeDefined(); + expect(res.body.paths["/api/companies/{companyId}/folders/items/move"].post.summary).toBe( + "Move an item into or out of a folder", + ); expect(JSON.stringify(res.body.paths["/api/tool-gateway/tools"].get)).not.toContain("sessionToken"); expect(JSON.stringify(res.body.paths["/api/tool-gateway/tools/call"].post)).not.toContain("sessionToken"); }); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 9dccbabc0c..0cd39660f7 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -12,6 +12,7 @@ import { documentRevisions, documents, executionWorkspaces, + folders, heartbeatRuns, instanceSettings, issueInboxArchives, @@ -68,6 +69,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { await db.delete(routineRuns); await db.delete(routineTriggers); await db.delete(routines); + await db.delete(folders); await db.delete(routineDocuments); await db.delete(documents); await db.delete(documentRevisions); @@ -271,6 +273,39 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(allRoutines.map((entry) => entry.id)).toEqual(expect.arrayContaining([routine.id, otherRoutine.id])); }); + it("does not reveal folders owned by another company", async () => { + const { companyId, agentId, projectId, svc } = await seedFixture(); + const otherCompanyId = randomUUID(); + await db.insert(companies).values({ + id: otherCompanyId, + name: "Other company", + issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + defaultResponsibleUserId: randomUUID(), + requireBoardApprovalForNewAgents: false, + }); + const [otherFolder] = await db.insert(folders).values({ + companyId: otherCompanyId, + kind: "routine", + name: "Private folder", + slug: "private-folder", + position: 0, + }).returning(); + + await expect(svc.create(companyId, { + projectId, + folderId: otherFolder!.id, + goalId: null, + parentIssueId: null, + title: "cross-company folder probe", + description: null, + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }, {})).rejects.toMatchObject({ status: 404, message: "Folder not found" }); + }); + it("defaults activity gates to always at company scope", async () => { const { routine } = await seedFixture(); diff --git a/server/src/app.ts b/server/src/app.ts index 68663296a5..1af6fb930b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -16,6 +16,7 @@ import { companyRoutes } from "./routes/companies.js"; import { companySkillRoutes } from "./routes/company-skills.js"; import { companySkillPolicyRoutes } from "./routes/company-skill-policy.js"; import { builtInAgentRoutes } from "./routes/built-in-agents.js"; +import { folderRoutes } from "./routes/folders.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; @@ -235,6 +236,7 @@ export async function createApp( api.use(openApiRoutes()); api.use("/companies", companyRoutes(db, opts.storageService)); api.use(llmRoutes(db)); + api.use(folderRoutes(db)); api.use(companySkillRoutes(db)); api.use(companySkillPolicyRoutes(db)); api.use(builtInAgentRoutes(db)); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 065f7b0464..7094d3a9a8 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -38,7 +38,7 @@ import { listCatalogSkillsOrEmpty, readCatalogSkillFile, } from "../services/skills-catalog.js"; -import { forbidden, unauthorized } from "../errors.js"; +import { badRequest, forbidden, unauthorized } from "../errors.js"; import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js"; import { getTelemetryClient } from "../telemetry.js"; import { @@ -115,6 +115,14 @@ export function companySkillRoutes(db: Db) { return undefined; } + function optionalQueryBoolean(value: unknown) { + const parsed = firstQueryString(value); + if (parsed === undefined) return undefined; + if (parsed === "true") return true; + if (parsed === "false") return false; + throw badRequest("Boolean query parameters must be true or false"); + } + function queryStringArray(value: unknown): string[] { if (typeof value === "string") return [value]; if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string"); @@ -305,12 +313,17 @@ export function companySkillRoutes(db: Db) { ...queryStringArray(req.query.category), ...queryStringArray(req.query.categories), ...queryStringArray(req.query["categories[]"]), + ...queryStringArray(req.query.tag), + ...queryStringArray(req.query.tags), + ...queryStringArray(req.query["tags[]"]), ], scope: firstQueryString(req.query.scope), include: [ ...queryStringArray(req.query.include), ...queryStringArray(req.query["include[]"]), ], + folderId: firstQueryString(req.query.folderId), + includeSubtree: optionalQueryBoolean(req.query.includeSubtree), })); res.json(result); }); diff --git a/server/src/routes/folders.ts b/server/src/routes/folders.ts new file mode 100644 index 0000000000..ad4859c166 --- /dev/null +++ b/server/src/routes/folders.ts @@ -0,0 +1,169 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { + createFolderSchema, + ensureMySkillFolderSchema, + folderKindSchema, + moveFolderItemSchema, + moveFolderSchema, + updateFolderSchema, +} from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { badRequest, forbidden } from "../errors.js"; +import { folderService, logActivity } from "../services/index.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; + +export function folderRoutes(db: Db) { + const router = Router(); + const svc = folderService(db); + + function parseKind(value: unknown) { + const result = folderKindSchema.safeParse(value); + if (!result.success) throw badRequest("Folder kind query parameter is required"); + return result.data; + } + + router.get("/companies/:companyId/folders", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await svc.list(companyId, parseKind(req.query.kind))); + }); + + router.post("/companies/:companyId/folders", validate(createFolderSchema), async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const created = await svc.create(companyId, req.body); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.created", + entityType: "folder", + entityId: created.id, + details: { kind: created.kind, name: created.name, path: created.path, parentId: created.parentId, position: created.position }, + }); + res.status(201).json(created); + }); + + router.post( + "/companies/:companyId/folders/ensure-my", + validate(ensureMySkillFolderSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + if (req.actor.type !== "board" || !req.actor.userId) { + throw forbidden("A signed-in board user is required to create a personal skill folder"); + } + const folder = await svc.ensureMyFolder(companyId, req.actor.userId, req.actor.userName ?? null, req.body.slug); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.personal_ensured", + entityType: "folder", + entityId: folder.id, + details: { path: folder.path, systemKey: folder.systemKey }, + }); + res.json(folder); + }, + ); + + router.patch("/companies/:companyId/folders/:folderId", validate(updateFolderSchema), async (req, res) => { + const companyId = req.params.companyId as string; + const folderId = req.params.folderId as string; + assertCompanyAccess(req, companyId); + const updated = await svc.update(companyId, folderId, req.body); + if (!updated) { + res.status(404).json({ error: "Folder not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.updated", + entityType: "folder", + entityId: updated.id, + details: { kind: updated.kind, name: updated.name, path: updated.path, position: updated.position }, + }); + res.json(updated); + }); + + router.post("/companies/:companyId/folders/items/move", validate(moveFolderItemSchema), async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const moved = await svc.moveItem(companyId, req.body); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.item_moved", + entityType: req.body.kind === "routine" ? "routine" : "company_skill", + entityId: moved.itemId, + details: { kind: moved.kind, folderId: moved.folderId }, + }); + res.json(moved); + }); + + router.post("/companies/:companyId/folders/:folderId/move", validate(moveFolderSchema), async (req, res) => { + const companyId = req.params.companyId as string; + const folderId = req.params.folderId as string; + assertCompanyAccess(req, companyId); + const updated = await svc.moveFolder(companyId, folderId, req.body); + if (!updated) { + res.status(404).json({ error: "Folder not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.moved", + entityType: "folder", + entityId: updated.id, + details: { kind: updated.kind, parentId: updated.parentId, path: updated.path, position: updated.position }, + }); + res.json(updated); + }); + + router.delete("/companies/:companyId/folders/:folderId", async (req, res) => { + const companyId = req.params.companyId as string; + const folderId = req.params.folderId as string; + assertCompanyAccess(req, companyId); + const deleted = await svc.deleteFolder(companyId, folderId); + if (!deleted) { + res.status(404).json({ error: "Folder not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.deleted", + entityType: "folder", + entityId: deleted.id, + details: { kind: deleted.kind, name: deleted.name }, + }); + res.json({ deleted }); + }); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 5c04a1c104..08b28f8897 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -3,6 +3,7 @@ export { companyRoutes } from "./companies.js"; export { companySkillRoutes } from "./company-skills.js"; export { companySkillPolicyRoutes } from "./company-skill-policy.js"; export { builtInAgentRoutes } from "./built-in-agents.js"; +export { folderRoutes } from "./folders.js"; export { teamsCatalogRoutes } from "./teams-catalog.js"; export { agentRoutes } from "./agents.js"; export { projectRoutes } from "./projects.js"; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 90bf5c2086..dc50b8095e 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -47,6 +47,13 @@ import { updateRoutineTriggerSchema, rotateRoutineTriggerSecretSchema, runRoutineSchema, + // Folders + createFolderSchema, + ensureMySkillFolderSchema, + folderKindSchema, + moveFolderItemSchema, + moveFolderSchema, + updateFolderSchema, // Goal createGoalSchema, updateGoalSchema, @@ -862,6 +869,8 @@ const CREATED_OPERATIONS = new Set([ "POST /api/companies/{companyId}/projects", "POST /api/projects/{id}/workspaces", "POST /api/companies/{companyId}/routines", + "POST /api/companies/{companyId}/folders", + "POST /api/companies/{companyId}/folders/ensure-my", "POST /api/routines/{id}/triggers", "POST /api/companies/{companyId}/secrets", "POST /api/companies/{companyId}/user-secret-definitions", @@ -5168,6 +5177,61 @@ for (const route of [ }); } +registerCurrentRoute({ + method: "get", + path: "/api/companies/{companyId}/folders", + tags: ["folders"], + summary: "List folders for a company item kind", + query: z.object({ kind: folderKindSchema }), +}); + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/folders", + tags: ["folders"], + summary: "Create a folder", + body: createFolderSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/folders/ensure-my", + tags: ["folders"], + summary: "Ensure the current user's personal skill folder exists", + body: ensureMySkillFolderSchema, +}); + +registerCurrentRoute({ + method: "patch", + path: "/api/companies/{companyId}/folders/{folderId}", + tags: ["folders"], + summary: "Update a folder", + body: updateFolderSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/folders/items/move", + tags: ["folders"], + summary: "Move an item into or out of a folder", + body: moveFolderItemSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/folders/{folderId}/move", + tags: ["folders"], + summary: "Move or reorder a folder", + body: moveFolderSchema, +}); + +registerCurrentRoute({ + method: "delete", + path: "/api/companies/{companyId}/folders/{folderId}", + tags: ["folders"], + summary: "Delete a folder", +}); + registerCurrentRoute({ method: "get", path: "/api/issues/{id}/cost-summary", diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index da6049b256..033f291ebf 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -97,6 +97,7 @@ import { issueDocumentSelect, mapIssueDocumentRow } from "./documents.js"; import { toIssueWorkProduct } from "./work-products.js"; import { projectService } from "./projects.js"; import { normalizePortablePath } from "./portable-path.js"; +import { folderService } from "./folders.js"; import { copyCatalogSkillFile, getCatalogPackageMetadata, @@ -119,6 +120,7 @@ type CompanySkillListDbRow = Pick< CompanySkillRow, | "id" | "companyId" + | "folderId" | "key" | "slug" | "name" @@ -151,6 +153,8 @@ type CompanySkillListRow = Pick< CompanySkill, | "id" | "companyId" + | "folderId" + | "folderPath" | "key" | "slug" | "name" @@ -213,6 +217,7 @@ type ImportedSkill = { type ImportedSkillPersistValues = Pick< CompanySkill, | "companyId" + | "folderId" | "key" | "slug" | "name" @@ -376,6 +381,7 @@ function selectCompanySkillColumns() { return { id: companySkills.id, companyId: companySkills.companyId, + folderId: companySkills.folderId, key: companySkills.key, slug: companySkills.slug, name: companySkills.name, @@ -1208,6 +1214,26 @@ function isPaperclipBundledSkillKey(key: string) { return key.startsWith("paperclipai/paperclip/"); } +function paperclipBundledFolderCategory(key: string, metadata?: unknown) { + const keyParts = key.split("/"); + if (keyParts[0] === "paperclipai" && keyParts[1] === "bundled" && keyParts[2]) { + return keyParts[2]; + } + if (isPaperclipBundledSkillKey(key)) return "paperclip-core"; + if (isPlainRecord(metadata) && asString(metadata.sourceKind) === "paperclip_bundled") { + return "paperclip-core"; + } + return null; +} + +function bundledFolderLabel(category: string) { + return category + .split(/[-_\s]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + function stripDerivedPaperclipBundledMetadata(key: string, metadata: unknown): unknown { if (metadata === null || metadata === undefined) return {}; const comparable = stableJsonComparable(metadata); @@ -1239,6 +1265,7 @@ function importedSkillPersistValuesMatchExisting( values: ImportedSkillPersistValues, ) { return existing.companyId === values.companyId + && existing.folderId === values.folderId && existing.key === values.key && existing.slug === values.slug && existing.name === values.name @@ -2643,6 +2670,8 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: return { id: skill.id, companyId: skill.companyId, + folderId: skill.folderId, + folderPath: skill.folderPath ?? null, key: skill.key, slug: skill.slug, name: skill.name, @@ -2743,6 +2772,7 @@ async function listLastEditorsBySkillId( } export function companySkillService(db: Db) { + const folderSvc = folderService(db); const agents = agentService(db); const projects = projectService(db); @@ -2798,6 +2828,39 @@ export function companySkillService(db: Db) { return []; } + async function reconcilePaperclipSkillFolders(companyId: string) { + const shippedSkills = await db + .select({ + id: companySkills.id, + key: companySkills.key, + folderId: companySkills.folderId, + metadata: companySkills.metadata, + }) + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .then((rows) => rows.flatMap((skill) => { + const category = paperclipBundledFolderCategory(skill.key, skill.metadata); + return category ? [{ ...skill, category }] : []; + })); + const foldersByCategory = new Map>>(); + for (const skill of shippedSkills) { + let folder = foldersByCategory.get(skill.category); + if (!folder) { + folder = await folderSvc.ensureBundledCategory(companyId, bundledFolderLabel(skill.category)); + foldersByCategory.set(skill.category, folder); + } + if (skill.folderId === folder.id) continue; + await db + .update(companySkills) + .set({ folderId: folder.id, updatedAt: new Date() }) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, skill.id))); + } + await folderSvc.pruneEmptyBundledCategories( + companyId, + Array.from(foldersByCategory.keys(), bundledFolderLabel), + ); + } + async function reconcileLocalPathSkillSources(companyId: string) { const rows = await db .select({ @@ -2889,6 +2952,7 @@ export function companySkillService(db: Db) { throw notFound("Company not found"); } await ensureBundledSkills(companyId); + await reconcilePaperclipSkillFolders(companyId); await reconcileLocalPathSkillSources(companyId); })(); @@ -2904,10 +2968,11 @@ export function companySkillService(db: Db) { async function list(companyId: string, query: CompanySkillListQuery = {}): Promise { await ensureSkillInventoryCurrent(companyId); - const rows = await db + const [dbRows, folderListing] = await Promise.all([db .select({ id: companySkills.id, companyId: companySkills.companyId, + folderId: companySkills.folderId, key: companySkills.key, slug: companySkills.slug, name: companySkills.name, @@ -2939,7 +3004,21 @@ export function companySkillService(db: Db) { .from(companySkills) .where(eq(companySkills.companyId, companyId)) .orderBy(asc(companySkills.name), asc(companySkills.key)) - .then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))); + .then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))), + folderSvc.list(companyId, "skill"), + ]); + const folderPaths = new Map(folderListing.folders.map((folder) => [folder.id, folder.path])); + const rows = dbRows.map((skill) => ({ + ...skill, + folderPath: skill.folderId ? folderPaths.get(skill.folderId) ?? null : null, + })); + let selectedFolderIds: Set | null = null; + if (query.folderId) { + await folderSvc.validateSkillFolder(companyId, query.folderId, { allowBundled: true }); + selectedFolderIds = query.includeSubtree + ? await folderSvc.descendantIds(companyId, "skill", query.folderId) + : new Set([query.folderId]); + } const agentRows = await agents.list(companyId); const q = query.q?.trim().toLowerCase() ?? ""; const categories = new Set( @@ -2950,6 +3029,7 @@ export function companySkillService(db: Db) { ); const filtered = rows.filter((skill) => { if (query.scope && skill.sharingScope !== query.scope) return false; + if (!q && selectedFolderIds && (!skill.folderId || !selectedFolderIds.has(skill.folderId))) return false; if (categories.size > 0 && !skill.categories.some((category) => categories.has(categoryLookupKey(category)))) return false; if (q) { const haystack = [ @@ -3032,7 +3112,7 @@ export function companySkillService(db: Db) { .from(companySkills) .where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, id))) .then((rows) => rows[0] ?? null); - return row ? toCompanySkill(row) : null; + return row ? enrichFolderPath(companyId, toCompanySkill(row)) : null; } async function getByKey(companyId: string, key: string) { @@ -3041,7 +3121,7 @@ export function companySkillService(db: Db) { .from(companySkills) .where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, key))) .then((rows) => rows[0] ?? null); - return row ? toCompanySkill(row) : null; + return row ? enrichFolderPath(companyId, toCompanySkill(row)) : null; } async function getBySlugIfUnique(companyId: string, slug: string) { @@ -3049,7 +3129,13 @@ export function companySkillService(db: Db) { .select(selectCompanySkillColumns()) .from(companySkills) .where(and(eq(companySkills.companyId, companyId), eq(companySkills.slug, slug))); - return rows.length === 1 ? toCompanySkill(rows[0]!) : null; + return rows.length === 1 ? enrichFolderPath(companyId, toCompanySkill(rows[0]!)) : null; + } + + async function enrichFolderPath(companyId: string, skill: CompanySkill): Promise { + if (!skill.folderId) return { ...skill, folderPath: null }; + const folder = await folderSvc.getFolder(companyId, skill.folderId); + return { ...skill, folderPath: folder?.kind === "skill" ? folder.path : null }; } async function getByRouteRef(companyId: string, ref: string) { @@ -3927,6 +4013,7 @@ export function companySkillService(db: Db) { input: CompanySkillCreateRequest, actor: SkillActor | null = null, ): Promise { + if (input.folderId) await folderSvc.validateSkillFolder(companyId, input.folderId); const slug = normalizeSkillSlug(input.slug ?? input.name) ?? "skill"; const key = `company/${companyId}/${slug}`; const existing = await getByKey(companyId, key); @@ -4013,6 +4100,7 @@ export function companySkillService(db: Db) { authorName: normalizeStoreText(input.authorName, 200) ?? forkSource?.authorName ?? created.authorName, homepageUrl: normalizeStoreText(input.homepageUrl, 2000) ?? forkSource?.homepageUrl ?? created.homepageUrl, categories: input.categories ? normalizeCategoryList(input.categories) : forkSource?.categories ?? created.categories, + folderId: input.folderId ?? null, sharingScope, forkedFromSkillId: forkSource?.id ?? null, forkedFromCompanyId: forkSource?.companyId ?? null, @@ -4557,6 +4645,15 @@ export function companySkillService(db: Db) { existingSkillId: existingBySource.id, reason: "This skill is already installed from the same path.", }); + if (mode === "preview" || !selected) continue; + const persisted = (await upsertImportedSkills(companyId, [{ + ...nextSkill, + key: existingBySource.key, + slug: existingBySource.slug, + }]))[0]; + if (!persisted) continue; + updated.push(persisted); + upsertAcceptedSkill(persisted); continue; } @@ -5050,6 +5147,10 @@ export function companySkillService(db: Db) { } const markdown = await fs.readFile(path.join(originSnapshotLocator, catalogSkill.entrypoint), "utf8"); const metadata = buildCatalogSkillMetadata(catalogSkill, existingByKey, originSnapshotLocator); + const bundledCategory = paperclipBundledFolderCategory(catalogSkill.key, metadata); + const bundledFolder = bundledCategory + ? await folderSvc.ensureBundledCategory(companyId, bundledFolderLabel(bundledCategory)) + : null; const parsed = parseFrontmatterMarkdown(markdown); const storeMetadata = readSkillStoreMetadata(parsed.frontmatter, { ...metadata, @@ -5057,6 +5158,7 @@ export function companySkillService(db: Db) { }); const values = { companyId, + folderId: bundledFolder?.id ?? existingByKey?.folderId ?? null, key: catalogSkill.key, slug, name: catalogSkill.name, @@ -5446,8 +5548,18 @@ export function companySkillService(db: Db) { }; const parsed = parseFrontmatterMarkdown(skill.markdown); const storeMetadata = readSkillStoreMetadata(parsed.frontmatter, metadata); + const bundledCategory = paperclipBundledFolderCategory(skill.key, incomingMeta); + const bundledFolder = bundledCategory + ? await folderSvc.ensureBundledCategory(companyId, bundledFolderLabel(bundledCategory)) + : null; + const projectId = asString(incomingMeta.projectId); + const projectName = asString(incomingMeta.projectName); + const projectFolder = !existing && incomingKind === "project_scan" && projectId && projectName + ? await folderSvc.ensureProjectFolder(companyId, projectId, projectName) + : null; const values: ImportedSkillPersistValues = { companyId, + folderId: bundledFolder?.id ?? projectFolder?.id ?? existing?.folderId ?? null, key: skill.key, slug: skill.slug, name: skill.name, diff --git a/server/src/services/folders.ts b/server/src/services/folders.ts new file mode 100644 index 0000000000..b16f6ac5c6 --- /dev/null +++ b/server/src/services/folders.ts @@ -0,0 +1,619 @@ +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(); + const visiting = new Set(); + + 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(companyId: string, operation: (lockedDb: Db) => Promise) { + 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`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`count(*)::int` }) + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .groupBy(companySkills.folderId); + } + + async function list(companyId: string, kind: FolderKind): Promise { + const [folderRows, countRows] = await Promise.all([ + getRows(companyId, kind), + kind === "routine" ? routineCounts(companyId) : skillCounts(companyId), + ]); + const views = buildFolderViews(folderRows); + const countsByFolderId = new Map(); + 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(); + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index b7745b470f..4a9977cfdd 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -5,6 +5,7 @@ export { companySearchExtractService } from "./company-search-extract.js"; export { feedbackService } from "./feedback.js"; export { companySkillService } from "./company-skills.js"; export { companySkillPolicyService, normalizeSkillPolicySourceType } from "./company-skill-policy.js"; +export { folderService } from "./folders.js"; export { agentService, deduplicateAgentName } from "./agents.js"; export { builtInAgentService, diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index e64da63290..6152ba10cc 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -12,6 +12,7 @@ import { documentRevisions, documents, executionWorkspaces, + folders, goals, heartbeatRuns, issueInboxArchives, @@ -974,6 +975,17 @@ export function routineService( if (parentIssue.companyId !== companyId) throw unprocessable("Parent issue must belong to same company"); } + async function assertRoutineFolder(companyId: string, folderId: string | null | undefined) { + if (!folderId) return; + const folder = await db + .select({ id: folders.id, kind: folders.kind }) + .from(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.id, folderId))) + .then((rows) => rows[0] ?? null); + if (!folder) throw notFound("Folder not found"); + if (folder.kind !== "routine") throw unprocessable("Folder kind must match routine"); + } + async function listTriggersForRoutineIds(companyId: string, routineIds: string[]) { if (routineIds.length === 0) return new Map(); const rows = await db @@ -2066,6 +2078,7 @@ export function routineService( create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise => { await assertProject(companyId, input.projectId ?? null); + await assertRoutineFolder(companyId, input.folderId ?? null); await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" }); if (input.goalId) await assertGoal(companyId, input.goalId); if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); @@ -2092,6 +2105,7 @@ export function routineService( .values({ companyId, projectId: input.projectId ?? null, + folderId: input.folderId ?? null, goalId: input.goalId ?? null, parentIssueId: input.parentIssueId ?? null, title: input.title, @@ -2130,6 +2144,7 @@ export function routineService( const existing = await getRoutineById(id); if (!existing) return null; const nextProjectId = patch.projectId === undefined ? existing.projectId : patch.projectId; + const nextFolderId = patch.folderId === undefined ? existing.folderId : patch.folderId; const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId; const nextTitle = patch.title ?? existing.title; const nextDescription = patch.description === undefined ? existing.description : patch.description; @@ -2153,6 +2168,7 @@ export function routineService( patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables), ); if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId); + if (patch.folderId !== undefined) await assertRoutineFolder(existing.companyId, nextFolderId); if (patch.assigneeAgentId !== undefined || patch.status === "active") { await assertAssignableAgent(db, existing.companyId, nextAssigneeAgentId, { kind: "routine" }); } @@ -2202,6 +2218,7 @@ export function routineService( const candidate: RoutineRow = { ...locked, projectId: nextProjectId, + folderId: nextFolderId, goalId: patch.goalId === undefined ? locked.goalId : patch.goalId, parentIssueId: patch.parentIssueId === undefined ? locked.parentIssueId : patch.parentIssueId, title: nextTitle, @@ -2218,8 +2235,20 @@ export function routineService( updatedByUserId: actor.userId ?? null, }; + const folderChanged = patch.folderId !== undefined && locked.folderId !== candidate.folderId; if (locked.latestRevisionId && routineCurrentFieldsMatch(locked, candidate)) { - return locked; + if (!folderChanged) return locked; + const [updated] = await txDb + .update(routines) + .set({ + folderId: candidate.folderId, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + updatedAt: new Date(), + }) + .where(eq(routines.id, id)) + .returning(); + return updated ?? locked; } const nextSnapshot = await buildRoutineRevisionSnapshot(txDb, candidate); @@ -2252,6 +2281,7 @@ export function routineService( .update(routines) .set({ projectId: candidate.projectId, + folderId: candidate.folderId, goalId: candidate.goalId, parentIssueId: candidate.parentIssueId, title: candidate.title, diff --git a/ui/src/api/folders.ts b/ui/src/api/folders.ts new file mode 100644 index 0000000000..97bab9d34c --- /dev/null +++ b/ui/src/api/folders.ts @@ -0,0 +1,39 @@ +import type { + CreateFolderRequest, + EnsureMySkillFolderRequest, + Folder, + FolderKind, + FolderListResult, + MoveFolderItemRequest, + MoveFolderRequest, + UpdateFolderRequest, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export const foldersApi = { + list: (companyId: string, kind: FolderKind) => + api.get(`/companies/${encodeURIComponent(companyId)}/folders?kind=${kind}`), + create: (companyId: string, payload: CreateFolderRequest) => + api.post(`/companies/${encodeURIComponent(companyId)}/folders`, payload), + ensureMy: (companyId: string, payload: EnsureMySkillFolderRequest = {}) => + api.post(`/companies/${encodeURIComponent(companyId)}/folders/ensure-my`, payload), + update: (companyId: string, folderId: string, payload: UpdateFolderRequest) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`, + payload, + ), + moveFolder: (companyId: string, folderId: string, payload: MoveFolderRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}/move`, + payload, + ), + moveItem: (companyId: string, payload: MoveFolderItemRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/folders/items/move`, + payload, + ), + delete: (companyId: string, folderId: string) => + api.delete<{ deleted: Folder }>( + `/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`, + ), +}; diff --git a/ui/src/components/RoutineList.tsx b/ui/src/components/RoutineList.tsx index f2cf986cc5..29604651c1 100644 --- a/ui/src/components/RoutineList.tsx +++ b/ui/src/components/RoutineList.tsx @@ -64,6 +64,10 @@ export function RoutineListRow({ disableToggle = false, hideArchiveAction = false, divider = true, + selected = false, + selectMode = false, + extraMenuItems, + onSelectChange, onRunNow, onToggleEnabled, onToggleArchived, @@ -83,6 +87,10 @@ export function RoutineListRow({ hideArchiveAction?: boolean; /** Render a bottom divider between consecutive rows. Off when the group is its own card. */ divider?: boolean; + selected?: boolean; + selectMode?: boolean; + extraMenuItems?: ReactNode; + onSelectChange?: (routine: TRoutine, selected: boolean) => void; onRunNow: (routine: TRoutine) => void; onToggleEnabled: (routine: TRoutine, enabled: boolean) => void; onToggleArchived?: (routine: TRoutine) => void; @@ -102,6 +110,23 @@ export function RoutineListRow({ divider ? " border-b border-border last:border-b-0" : "" }`} > + {selectMode ? ( +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + onSelectChange?.(routine, event.target.checked)} + /> +
+ ) : null}
{routine.title} @@ -178,6 +203,12 @@ export function RoutineListRow({ > {runningRoutineId === routine.id ? "Running..." : "Run now"} + {extraMenuItems ? ( + <> + + {extraMenuItems} + + ) : null} onToggleEnabled(routine, enabled)} diff --git a/ui/src/components/folders/FolderControls.test.tsx b/ui/src/components/folders/FolderControls.test.tsx new file mode 100644 index 0000000000..d68324b2cd --- /dev/null +++ b/ui/src/components/folders/FolderControls.test.tsx @@ -0,0 +1,480 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { FolderListResult } from "@paperclipai/shared"; +import { + AllUnfiledBanner, + BulkBar, + DeleteFolderDialog, + FolderFormDialog, + FolderRail, + MobileFolderSheet, + MoveToMenu, + folderSearchValue, + normalizeFolderSelection, +} from "./FolderControls"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +if (!globalThis.PointerEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).PointerEvent = MouseEvent; +} +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => undefined; +} + +function act(callback: () => void | Promise) { + let result: void | Promise | undefined; + flushSync(() => { + result = callback(); + }); + return result; +} + +const folderResult: FolderListResult = { + kind: "routine", + allCount: 4, + unfiledCount: 1, + folders: [ + { + id: "folder-reporting", + companyId: "company-1", + kind: "routine", + parentId: null, + name: "Reporting", + slug: "reporting", + systemKey: null, + path: "reporting", + depth: 1, + color: "indigo", + position: 0, + itemCount: 3, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-01T00:00:00.000Z"), + }, + ], +}; + +const skillFolderResult: FolderListResult = { + kind: "skill", + allCount: 8, + unfiledCount: 1, + folders: [ + { + ...folderResult.folders[0]!, + id: "my", + kind: "skill", + name: "my", + slug: "my", + systemKey: "my", + path: "my", + itemCount: 1, + }, + { + ...folderResult.folders[0]!, + id: "engineering", + kind: "skill", + name: "Engineering", + slug: "engineering", + path: "engineering", + itemCount: 3, + }, + { + ...folderResult.folders[0]!, + id: "code-review", + kind: "skill", + parentId: "engineering", + name: "Code Review", + slug: "code-review", + path: "engineering/code-review", + depth: 2, + itemCount: 2, + }, + { + ...folderResult.folders[0]!, + id: "projects", + kind: "skill", + name: "projects", + slug: "projects", + systemKey: "projects", + path: "projects", + itemCount: 2, + }, + { + ...folderResult.folders[0]!, + id: "bundled", + kind: "skill", + name: "bundled", + slug: "bundled", + systemKey: "bundled", + path: "bundled", + itemCount: 1, + }, + ], +}; + +describe("FolderControls", () => { + let container: HTMLDivElement; + let root: Root | null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container.remove(); + document.body.innerHTML = ""; + }); + + it("normalizes URL selection values for folder persistence", () => { + expect(normalizeFolderSelection(null)).toBe("all"); + expect(normalizeFolderSelection("unfiled")).toBe("unfiled"); + expect(normalizeFolderSelection("folder-reporting")).toBe("folder-reporting"); + expect(folderSearchValue("all")).toBe(""); + expect(folderSearchValue("unfiled")).toBe("unfiled"); + expect(folderSearchValue("folder-reporting")).toBe("folder-reporting"); + }); + + it("renders All, user folders, and Unfiled with counts and selection callbacks", () => { + const onSelect = vi.fn(); + root = createRoot(container); + + act(() => { + root?.render( + , + ); + }); + + expect(container.textContent).toContain("All routines"); + expect(container.textContent).toContain("Reporting"); + expect(container.textContent).toContain("Unfiled"); + expect(container.textContent).toContain("4"); + expect(container.textContent).toContain("3"); + expect(container.textContent).toContain("1"); + + const reportingButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Reporting"), + ); + expect(reportingButton).toBeTruthy(); + + act(() => { + reportingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onSelect).toHaveBeenCalledWith("folder-reporting"); + }); + + it("marks the active row with aria-current, including virtual Unfiled", () => { + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + const current = container.querySelector('[aria-current="page"]'); + expect(current?.textContent).toContain("Unfiled"); + }); + + it("renames a folder inline via double-click and Enter", () => { + const onRename = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + const nameButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Reporting"), + ); + act(() => { + nameButton?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true })); + }); + + const input = container.querySelector("input"); + expect(input).toBeTruthy(); + expect(input?.value).toBe("Reporting"); + + act(() => { + if (!input) return; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "Monthly reports"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + act(() => { + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + expect(onRename).toHaveBeenCalledWith(folderResult.folders[0], "Monthly reports"); + }); + + it("moves via MoveToMenu: Unfiled, a folder, and new-folder chaining", () => { + const onMove = vi.fn(); + const onCreateAndMove = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + + + + + + + + , + ); + }); + + const subTrigger = Array.from(document.querySelectorAll("[data-radix-collection-item]")).find( + (element) => element.textContent?.includes("Move to"), + ); + expect(subTrigger).toBeTruthy(); + act(() => { + subTrigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + }); + + const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]')); + const unfiledItem = menuItems.find((element) => element.textContent?.includes("Unfiled")); + const folderItem = menuItems.find((element) => element.textContent?.includes("Reporting")); + const newFolderItem = menuItems.find((element) => element.textContent?.includes("New folder")); + expect(unfiledItem).toBeTruthy(); + expect(folderItem).toBeTruthy(); + expect(newFolderItem).toBeTruthy(); + + act(() => { + unfiledItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onMove).toHaveBeenCalledWith(null); + + act(() => { + folderItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onMove).toHaveBeenCalledWith("folder-reporting"); + + act(() => { + newFolderItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onCreateAndMove).toHaveBeenCalled(); + }); + + it("creates a folder through FolderFormDialog and disables submit on empty name", () => { + const onSubmit = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + const submit = Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent === "Create folder", + ) as HTMLButtonElement | undefined; + expect(submit).toBeTruthy(); + expect(submit?.disabled).toBe(true); + + const nameInput = document.querySelector("#folder-name"); + act(() => { + if (!nameInput) return; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(nameInput, "Reporting"); + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + + expect(submit?.disabled).toBe(false); + act(() => { + submit?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onSubmit).toHaveBeenCalledWith({ name: "Reporting", color: expect.any(String) }); + }); + + it("states the forgiving delete behavior and confirms", () => { + const onConfirm = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + expect(document.body.textContent).toContain("3 routines in this folder won't be deleted"); + expect(document.body.textContent).toContain("They'll move to Unfiled"); + + const confirmButton = Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent === "Delete folder", + ); + act(() => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onConfirm).toHaveBeenCalled(); + }); + + it("selects a folder from the mobile sheet and dismisses", () => { + const onSelect = vi.fn(); + const onOpenChange = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + expect(document.body.textContent).toContain("All routines"); + expect(document.body.textContent).toContain("Unfiled"); + + const reportingRow = Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Reporting"), + ); + act(() => { + reportingRow?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onSelect).toHaveBeenCalledWith("folder-reporting"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("preserves skill root grouping and child hierarchy in the mobile sheet", () => { + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + const body = document.body.textContent ?? ""; + expect(body.indexOf("My Skills")).toBeLessThan(body.indexOf("Company")); + expect(body.indexOf("Company")).toBeLessThan(body.indexOf("Engineering")); + expect(body.indexOf("Engineering")).toBeLessThan(body.indexOf("Code Review")); + expect(body.indexOf("Code Review")).toBeLessThan(body.indexOf("Projects")); + expect(body.indexOf("Projects")).toBeLessThan(body.indexOf("Bundled")); + expect(document.querySelector('[data-folder-id="engineering"] > .pl-3 [data-folder-id="code-review"]')).not.toBeNull(); + }); + + it("persists AllUnfiledBanner dismissal across mounts", () => { + const storageKey = "paperclip:test-folder-nudge"; + window.localStorage.removeItem(storageKey); + const onCreateFolder = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + expect(container.textContent).toContain("Create your first folder"); + + const dismissButton = container.querySelector( + 'button[aria-label="Dismiss folder suggestion"]', + ); + act(() => { + dismissButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(container.textContent).not.toContain("Create your first folder"); + expect(window.localStorage.getItem(storageKey)).toBe("1"); + + act(() => { + root?.unmount(); + }); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + expect(container.textContent).not.toContain("Create your first folder"); + window.localStorage.removeItem(storageKey); + }); +}); diff --git a/ui/src/components/folders/FolderControls.tsx b/ui/src/components/folders/FolderControls.tsx new file mode 100644 index 0000000000..2262769fc5 --- /dev/null +++ b/ui/src/components/folders/FolderControls.tsx @@ -0,0 +1,776 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Check, + ChevronDown, + Folder as FolderIcon, + MoreHorizontal, + Plus, + Search, + Trash2, + X, +} from "lucide-react"; +import type { FolderKind, FolderListItem, FolderListResult } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; +import { + reservedRootLabel, + treeFromResult, + type FolderTreeNode, +} from "./skill-folder-tree"; + +export type FolderSelection = "all" | "unfiled" | string; + +export const FOLDER_COLORS = [ + "indigo", + "violet", + "emerald", + "cyan", + "amber", + "slate", +]; + +const FOLDER_COLOR_VALUES: Record<(typeof FOLDER_COLORS)[number], string> = { + indigo: "var(--folder-color-indigo)", + violet: "var(--folder-color-violet)", + emerald: "var(--folder-color-emerald)", + cyan: "var(--folder-color-cyan)", + amber: "var(--folder-color-amber)", + slate: "var(--folder-color-slate)", +}; + +export function normalizeFolderSelection(value: string | null | undefined): FolderSelection { + if (!value) return "all"; + if (value === "unfiled") return "unfiled"; + return value; +} + +export function folderSearchValue(selection: FolderSelection): string { + return selection === "all" ? "" : selection === "unfiled" ? "unfiled" : selection; +} + +export function selectedFolderFromList( + folders: FolderListItem[], + selection: FolderSelection, +): FolderListItem | null { + if (selection === "all" || selection === "unfiled") return null; + return folders.find((folder) => folder.id === selection) ?? null; +} + +export function FolderSwatch({ + color, + className, +}: { + color: string | null | undefined; + className?: string; +}) { + const backgroundColor = color + ? (FOLDER_COLOR_VALUES[color] ?? color) + : "var(--folder-color-slate)"; + return ( +
- + ); } @@ -887,6 +1039,29 @@ export function DiscoveryGrid({ onScan, scanPending, scanStatus, + folderResult, + folderSelection = "all", + foldersLoading = false, + selectMode = false, + selectedSkillIds = [], + onFolderSelect, + onCreateFolder, + onRenameFolder, + onEditFolder, + onMoveFolder, + onDeleteFolder, + onToggleSelectMode, + onSelectCard, + onMoveCard, + onCreateFolderAndMoveCard, + onMoveSelected, + onCreateFolderAndMoveSelected, + onClearSelected, + onOpenMobileFolders, + onCreateFolderIn, + onEnsureMyFolder, + onOpenMoveCard, + folderNudgeStorageKey, }: { tab: DiscoveryTab; tabCounts: Record; @@ -911,6 +1086,33 @@ export function DiscoveryGrid({ onScan: () => void; scanPending: boolean; scanStatus: string | null; + folderResult?: FolderListResult | null; + folderSelection?: FolderSelection; + foldersLoading?: boolean; + selectMode?: boolean; + selectedSkillIds?: string[]; + onFolderSelect?: (selection: FolderSelection) => void; + onCreateFolder?: () => void; + onRenameFolder?: (folder: FolderListItem, name: string) => void; + onEditFolder?: (folder: FolderListItem) => void; + onMoveFolder?: (folder: FolderListItem, destination: "my" | "company") => void; + onDeleteFolder?: (folder: FolderListItem) => void; + onToggleSelectMode?: () => void; + onSelectCard?: (card: DiscoveryCard, selected: boolean) => void; + onMoveCard?: (card: DiscoveryCard, folderId: string | null) => void; + onCreateFolderAndMoveCard?: (card: DiscoveryCard) => void; + onMoveSelected?: (folderId: string | null) => void; + onCreateFolderAndMoveSelected?: () => void; + onClearSelected?: () => void; + onOpenMobileFolders?: () => void; + /** Create a folder under `parentId` (null = top level), used by the tree rail. */ + onCreateFolderIn?: (parentId: string | null) => void; + /** Provision the caller's personal "My Skills" root on demand. */ + onEnsureMyFolder?: () => void; + /** Open the rich move-to-folder dialog for a single card. */ + onOpenMoveCard?: (card: DiscoveryCard) => void; + /** When set and no folders exist yet, show the dismissible all-unfiled nudge (ux-spec §6.3). */ + folderNudgeStorageKey?: string; }) { // Source filter (github / skills.sh / local / …) lives in the grid so it // narrows whatever the parent already filtered by tab/category/search (PAP-10907 E). @@ -930,15 +1132,40 @@ export function DiscoveryGrid({ [cards, sourceBadgeFilter], ); const sourceFilterActive = sourceBadgeFilter !== "all"; + const folderActionsReady = Boolean( + onCreateFolderIn && onRenameFolder && onEditFolder && onMoveFolder && onDeleteFolder, + ); + // The nested folder tree owns the left rail whenever folders (reserved roots + // or user folders) exist for the installed view. + const showFolderRail = Boolean(folderResult && folderResult.folders.length > 0 && onFolderSelect && folderActionsReady); return ( // On desktop the store is bounded to the viewport so the category sidebar // and the results pane each scroll independently (PAP-10907). Mobile keeps // the natural page flow.
+ {showFolderRail ? ( +
+ +
+ ) : null} {/* Secondary category sidebar — the main app nav collapses to a rail while this is present (handled in Layout). */} -
@@ -950,7 +1193,56 @@ export function Routines() { ) : null} {activeTab === "routines" ? ( -
+
+ {showFolderRail ? ( + openCreateFolder()} + onRename={(folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } })} + onEdit={(folder) => { + setFolderDialogTarget(folder); + setFolderDialogOpen(true); + }} + onDelete={setDeleteFolderTarget} + /> + ) : null} +
+ {routineViewState.groupBy === "folder" && hasRoutineFolders ? ( +
+ {folderSelection === "all" ? : ( +
+ + {folderSelection === "unfiled" ? "Unfiled" : activeFolder?.name ?? "Folder"} + {sortedRoutines.length} routine{sortedRoutines.length === 1 ? "" : "s"} +
+ )} +
+ ) : null} + {routineViewState.groupBy === "folder" && !hasRoutineFolders && !foldersLoading && visibleRoutines.length > 0 ? ( + openCreateFolder()} + /> + ) : null} + {selectMode ? ( + void moveSelectedRoutines(folderId)} + onCreateAndMove={() => openCreateFolder(selectedRoutineIds)} + onClear={() => setSelectedRoutineIds([])} + onDone={() => { + setSelectMode(false); + setSelectedRoutineIds([]); + }} + /> + ) : null} {visibleRoutines.length === 0 ? (
+ ) : sortedRoutines.length === 0 ? ( +
+ + {folderSelection !== "all" ? ( +
+ +
+ ) : null} +
) : (
{routineSections.map((group) => { @@ -996,6 +1303,37 @@ export function Routines() { onRunNow={handleRunNow} onToggleEnabled={handleToggleEnabled} onToggleArchived={handleToggleArchived} + selectMode={selectMode} + selected={selectedRoutineIds.includes(routine.id)} + onSelectChange={(selectedRoutine, selected) => { + setSelectedRoutineIds((current) => + selected + ? Array.from(new Set([...current, selectedRoutine.id])) + : current.filter((id) => id !== selectedRoutine.id) + ); + }} + extraMenuItems={ + { + const previousFolderId = routine.folderId ?? null; + moveRoutineToFolder.mutate({ itemId: routine.id, folderId }); + pushToast({ + title: "Routine moved", + body: folderId + ? `Moved "${routine.title}" to ${routineFolders?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.` + : `Moved "${routine.title}" to Unfiled.`, + tone: "success", + action: { + label: "Undo", + onClick: () => moveRoutineToFolder.mutate({ itemId: routine.id, folderId: previousFolderId }), + }, + }); + }} + onCreateAndMove={() => openCreateFolder([routine.id])} + /> + } /> ))} @@ -1004,9 +1342,44 @@ export function Routines() { })}
)} +
) : null} + { + if (folderDialogTarget) updateFolder.mutate({ folderId: folderDialogTarget.id, payload }); + else createFolder.mutate(payload); + }} + /> + { + if (!open) setDeleteFolderTarget(null); + }} + onConfirm={() => { + if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id); + }} + /> + openCreateFolder()} + /> + { @@ -1028,3 +1401,13 @@ export function Routines() {
); } + +function FolderIconHeader({ label, count }: { label: string; count: number }) { + return ( +
+ + {label} + {count} routine{count === 1 ? "" : "s"} +
+ ); +} diff --git a/ui/src/pages/SkillStudio.test.tsx b/ui/src/pages/SkillStudio.test.tsx index 3ed0cb3afd..a8afa4b62f 100644 --- a/ui/src/pages/SkillStudio.test.tsx +++ b/ui/src/pages/SkillStudio.test.tsx @@ -368,6 +368,26 @@ describe("SkillStudio create mode", () => { await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/skills/studio/created-skill")); }); + it("forwards the folderId query param so the new skill is filed there (PAP-14086)", async () => { + routeState.search = "?folderId=folder-my-skills"; + + const node = await renderStudio(); + + await waitFor(() => expect(node.querySelector("#skill-name")).toBeTruthy()); + await inputValue(node.querySelector("#skill-name") as HTMLInputElement, "Code Review"); + await click(buttonsNamed(node, "Create skill")[0] as HTMLButtonElement); + + await waitFor(() => expect(mockCompanySkillsApi.create).toHaveBeenCalled()); + + expect(mockCompanySkillsApi.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + name: "Code Review", + folderId: "folder-my-skills", + }), + ); + }); + it("keeps category commas and spaces editable while creating a skill", async () => { const node = await renderStudio(); diff --git a/ui/src/pages/SkillStudio.tsx b/ui/src/pages/SkillStudio.tsx index c77f5d72c4..953d43a4ee 100644 --- a/ui/src/pages/SkillStudio.tsx +++ b/ui/src/pages/SkillStudio.tsx @@ -277,6 +277,10 @@ export function SkillStudio() { const companyId = selectedCompanyId ?? ""; const isCreateMode = location.pathname.replace(/\/+$/, "").endsWith("/skills/studio/new"); const forkFromSkillId = isCreateMode ? searchParams.get("forkFrom")?.trim() || null : null; + // New skills created from a folder context (e.g. My Skills) carry their + // destination folder through this query param; without it the created skill + // silently lands in Unfiled (PAP-14086). + const newSkillFolderId = isCreateMode ? searchParams.get("folderId")?.trim() || null : null; const skillsQuery = useQuery({ queryKey: queryKeys.companySkills.list(companyId), @@ -333,6 +337,7 @@ export function SkillStudio() { skills={skillsQuery.data ?? []} skillsLoading={skillsQuery.isLoading} forkFromSkillId={forkFromSkillId} + folderId={newSkillFolderId} forkSkill={forkDetailQuery.data ?? null} forkLoading={forkDetailQuery.isLoading} forkError={forkDetailQuery.isError} @@ -373,6 +378,7 @@ function StudioCreateMode({ skills, skillsLoading, forkFromSkillId, + folderId, forkSkill, forkLoading, forkError, @@ -382,6 +388,7 @@ function StudioCreateMode({ skills: CompanySkillListItem[]; skillsLoading: boolean; forkFromSkillId: string | null; + folderId: string | null; forkSkill: CompanySkillDetail | null; forkLoading: boolean; forkError: boolean; @@ -403,6 +410,7 @@ function StudioCreateMode({ (forkSkill ? buildForkSkillDraft(forkSkill) : buildBlankSkillDraft()), - [forkSkill], - ); + const initialDraft = useMemo(() => { + const base = forkSkill ? buildForkSkillDraft(forkSkill) : buildBlankSkillDraft(); + // An explicit folder context from the URL wins over a fork source's folder + // so the new skill is filed where the user launched creation (PAP-14086). + return folderId ? { ...base, folderId } : base; + }, [forkSkill, folderId]); const [draft, setDraft] = useState(initialDraft); const [slugDirty, setSlugDirty] = useState(initialDraft.slug.trim().length > 0); const [categoryDraft, setCategoryDraft] = useState(initialDraft.categories.join(", "));