From a072fafbb84e15fb262b96c89ec6e7254f985296 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 5 Jul 2026 11:23:39 +0000 Subject: [PATCH] Add folders schema and backend API Co-Authored-By: Paperclip --- packages/db/src/migrations/0171_folders.sql | 28 +++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/company_skills.ts | 3 + packages/db/src/schema/folders.ts | 38 +++ packages/db/src/schema/index.ts | 1 + packages/db/src/schema/routines.ts | 3 + packages/shared/src/api.ts | 4 + packages/shared/src/index.ts | 24 ++ packages/shared/src/types/company-skill.ts | 2 + packages/shared/src/types/folder.ts | 46 ++++ packages/shared/src/types/index.ts | 10 + packages/shared/src/types/routine.ts | 1 + .../shared/src/validators/company-skill.ts | 1 + packages/shared/src/validators/folder.ts | 55 +++++ packages/shared/src/validators/index.ts | 14 ++ packages/shared/src/validators/routine.ts | 2 + server/src/__tests__/folders-service.test.ts | 172 +++++++++++++ server/src/app.ts | 2 + server/src/routes/folders.ts | 142 +++++++++++ server/src/routes/index.ts | 1 + server/src/services/company-skills.ts | 5 + server/src/services/folders.ts | 229 ++++++++++++++++++ server/src/services/index.ts | 1 + server/src/services/routines.ts | 33 ++- 24 files changed, 823 insertions(+), 1 deletion(-) create mode 100644 packages/db/src/migrations/0171_folders.sql create mode 100644 packages/db/src/schema/folders.ts create mode 100644 packages/shared/src/types/folder.ts create mode 100644 packages/shared/src/validators/folder.ts create mode 100644 server/src/__tests__/folders-service.test.ts create mode 100644 server/src/routes/folders.ts create mode 100644 server/src/services/folders.ts diff --git a/packages/db/src/migrations/0171_folders.sql b/packages/db/src/migrations/0171_folders.sql new file mode 100644 index 00000000000..8d1c94b8b13 --- /dev/null +++ b/packages/db/src/migrations/0171_folders.sql @@ -0,0 +1,28 @@ +CREATE TABLE "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 "folder_id" uuid; +--> statement-breakpoint +ALTER TABLE "routines" ADD COLUMN "folder_id" uuid; +--> statement-breakpoint +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; +--> statement-breakpoint +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; +--> statement-breakpoint +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; +--> statement-breakpoint +CREATE INDEX "folders_company_kind_position_idx" ON "folders" USING btree ("company_id","kind","position","name"); +--> statement-breakpoint +CREATE UNIQUE INDEX "folders_company_kind_name_uq" ON "folders" USING btree ("company_id","kind","name"); +--> statement-breakpoint +CREATE INDEX "company_skills_company_folder_idx" ON "company_skills" USING btree ("company_id","folder_id"); +--> statement-breakpoint +CREATE INDEX "routines_company_folder_idx" ON "routines" USING btree ("company_id","folder_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 80f6e4628cc..4eb1918cde5 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1184,6 +1184,13 @@ "when": 1784037600000, "tag": "0170_company_skill_policies", "breakpoints": true + }, + { + "idx": 171, + "version": "7", + "when": 1784037601000, + "tag": "0171_folders", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_skills.ts b/packages/db/src/schema/company_skills.ts index 7e6ad7d1d5f..b1fd42d2afb 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 00000000000..862ced00f7e --- /dev/null +++ b/packages/db/src/schema/folders.ts @@ -0,0 +1,38 @@ +import { + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +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(), + name: text("name").notNull(), + 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, + ), + companyKindNameUniqueIdx: uniqueIndex("folders_company_kind_name_uq").on( + table.companyId, + table.kind, + table.name, + ), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index c84a2ab120e..2604d1fa7c2 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 ba65082c162..5b171f07e58 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 1635ceb3d16..d8ff3bf3ddc 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 587c429582e..a635bc5d70c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2079,12 +2079,36 @@ export type { } from "./environment-support.js"; export type { AdapterRegistryEntry } from "./types/adapter-registry.js"; +export type { + FolderKind, + Folder, + FolderListItem, + FolderListResult, + CreateFolderRequest, + UpdateFolderRequest, + MoveFolderRequest, + MoveFolderItemRequest, +} from "./types/folder.js"; export { adapterRegistryEntrySchema, adapterRegistrySchema, type AdapterRegistryEntryParsed, } from "./validators/adapter-registry.js"; +export { + folderKindSchema, + folderSchema, + folderListItemSchema, + folderListResultSchema, + createFolderSchema, + updateFolderSchema, + moveFolderSchema, + moveFolderItemSchema, + type CreateFolder, + type UpdateFolder, + type MoveFolder, + type MoveFolderItem, +} 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 c255c1f67fe..8def89ecf73 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -34,6 +34,7 @@ export interface CompanySkillVersionFileInventoryEntry extends CompanySkillFileI export interface CompanySkill { id: string; companyId: string; + folderId?: string | null; key: string; slug: string; name: string; @@ -67,6 +68,7 @@ export interface CompanySkill { export interface CompanySkillListItem { id: string; companyId: string; + folderId?: string | null; key: string; slug: string; name: string; diff --git a/packages/shared/src/types/folder.ts b/packages/shared/src/types/folder.ts new file mode 100644 index 00000000000..b5150d84c9b --- /dev/null +++ b/packages/shared/src/types/folder.ts @@ -0,0 +1,46 @@ +export type FolderKind = "routine" | "skill"; + +export interface Folder { + id: string; + companyId: string; + kind: FolderKind; + name: string; + 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; + name: string; + color?: string | null; + position?: number | null; +} + +export interface UpdateFolderRequest { + name?: string; + color?: string | null; + position?: number; +} + +export interface MoveFolderRequest { + position: number; +} + +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 d7aab5b78bd..4ccd1d834f5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -161,6 +161,16 @@ export type { CompanySkillInstallCatalogRequest, CompanySkillInstallCatalogResult, } from "./company-skill.js"; +export type { + FolderKind, + Folder, + FolderListItem, + FolderListResult, + CreateFolderRequest, + UpdateFolderRequest, + MoveFolderRequest, + MoveFolderItemRequest, +} from "./folder.js"; export type { CatalogTeamKind, CatalogTeamTrustLevel, diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index 571d7492ef4..bab97b6d4e8 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 acf7cb46646..4caa5a7fc5a 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -20,6 +20,7 @@ export const companySkillVersionFileInventoryEntrySchema = companySkillFileInven export const companySkillSchema = z.object({ id: z.string().uuid(), companyId: z.string().uuid(), + folderId: z.string().uuid().nullable().optional(), key: z.string().min(1), slug: z.string().min(1), name: z.string().min(1), diff --git a/packages/shared/src/validators/folder.ts b/packages/shared/src/validators/folder.ts new file mode 100644 index 00000000000..f425ccfde54 --- /dev/null +++ b/packages/shared/src/validators/folder.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export const folderKindSchema = z.enum(["routine", "skill"]); + +export const folderSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + kind: folderKindSchema, + name: z.string().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, + name: z.string().trim().min(1).max(120), + 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(), + 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({ + position: z.number().int().min(0), +}); + +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; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index d3ccb2aa70f..ee3ed008eb7 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -194,6 +194,20 @@ export { type CompanySkillInstallUpdate, type CompanySkillReset, } from "./company-skill.js"; +export { + folderKindSchema, + folderSchema, + folderListItemSchema, + folderListResultSchema, + createFolderSchema, + updateFolderSchema, + moveFolderSchema, + moveFolderItemSchema, + type CreateFolder, + type UpdateFolder, + type MoveFolder, + type MoveFolderItem, +} from "./folder.js"; export { catalogTeamKindSchema, catalogTeamTrustLevelSchema, diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index 56629340fd5..f0e4a92851b 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__/folders-service.test.ts b/server/src/__tests__/folders-service.test.ts new file mode 100644 index 00000000000..adeecbbd95c --- /dev/null +++ b/server/src/__tests__/folders-service.test.ts @@ -0,0 +1,172 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +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 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); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 68663296a5b..1af6fb930b9 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/folders.ts b/server/src/routes/folders.ts new file mode 100644 index 00000000000..40ee0acb24a --- /dev/null +++ b/server/src/routes/folders.ts @@ -0,0 +1,142 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { + createFolderSchema, + folderKindSchema, + moveFolderItemSchema, + moveFolderSchema, + updateFolderSchema, +} from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { badRequest } 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, position: created.position }, + }); + res.status(201).json(created); + }); + + 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, position: updated.position }, + }); + res.json(updated); + }); + + 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, 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.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 5c04a1c1046..08b28f8897c 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/services/company-skills.ts b/server/src/services/company-skills.ts index 4ea9d885540..dc0b3e4eba8 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -118,6 +118,7 @@ type CompanySkillListDbRow = Pick< CompanySkillRow, | "id" | "companyId" + | "folderId" | "key" | "slug" | "name" @@ -150,6 +151,7 @@ type CompanySkillListRow = Pick< CompanySkill, | "id" | "companyId" + | "folderId" | "key" | "slug" | "name" @@ -375,6 +377,7 @@ function selectCompanySkillColumns() { return { id: companySkills.id, companyId: companySkills.companyId, + folderId: companySkills.folderId, key: companySkills.key, slug: companySkills.slug, name: companySkills.name, @@ -2518,6 +2521,7 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: return { id: skill.id, companyId: skill.companyId, + folderId: skill.folderId, key: skill.key, slug: skill.slug, name: skill.name, @@ -2783,6 +2787,7 @@ export function companySkillService(db: Db) { .select({ id: companySkills.id, companyId: companySkills.companyId, + folderId: companySkills.folderId, key: companySkills.key, slug: companySkills.slug, name: companySkills.name, diff --git a/server/src/services/folders.ts b/server/src/services/folders.ts new file mode 100644 index 00000000000..7f12a2c1490 --- /dev/null +++ b/server/src/services/folders.ts @@ -0,0 +1,229 @@ +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, notFound, unprocessable } from "../errors.js"; + +type FolderRow = typeof folders.$inferSelect; + +function mapFolder(row: FolderRow): Folder { + return { + ...row, + color: row.color ?? null, + }; +} + +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 folderService(db: Db) { + async function getFolder(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 assertNoNameConflict(companyId: string, kind: FolderKind, name: string, excludeFolderId?: string) { + const existing = await db + .select({ id: folders.id }) + .from(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.kind, kind), eq(folders.name, name))) + .then((rows) => rows[0] ?? null); + if (existing && existing.id !== excludeFolderId) { + throw conflict("Folder name already exists for this kind"); + } + } + + async function nextPosition(companyId: string, kind: FolderKind) { + const row = await db + .select({ value: max(folders.position) }) + .from(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.kind, kind))) + .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([ + db + .select() + .from(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.kind, kind))) + .orderBy(asc(folders.position), asc(folders.name), asc(folders.id)), + kind === "routine" ? routineCounts(companyId) : skillCounts(companyId), + ]); + + const countsByFolderId = new Map(); + for (const row of countRows) { + countsByFolderId.set(row.folderId ?? null, Number(row.count ?? 0)); + } + + return { + kind, + folders: folderRows.map((row) => ({ + ...mapFolder(row), + itemCount: countsByFolderId.get(row.id) ?? 0, + })), + allCount: Array.from(countsByFolderId.values()).reduce((sum, count) => sum + count, 0), + unfiledCount: countsByFolderId.get(null) ?? 0, + }; + } + + async function create(companyId: string, input: CreateFolder): Promise { + const name = normalizeName(input.name); + await assertNoNameConflict(companyId, input.kind, name); + const position = input.position ?? await nextPosition(companyId, input.kind); + const row = await db + .insert(folders) + .values({ + companyId, + kind: input.kind, + name, + color: normalizeColor(input.color) ?? null, + position, + }) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Failed to create folder"); + return mapFolder(row); + } + + async function update(companyId: string, folderId: string, patch: UpdateFolder): Promise { + const existing = await getFolder(companyId, folderId); + if (!existing) return null; + const nextName = patch.name === undefined ? existing.name : normalizeName(patch.name); + if (nextName !== existing.name) { + await assertNoNameConflict(companyId, existing.kind, nextName, folderId); + } + const row = await db + .update(folders) + .set({ + name: nextName, + 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))) + .returning() + .then((rows) => rows[0] ?? null); + return row ? mapFolder(row) : null; + } + + async function moveFolder(companyId: string, folderId: string, input: MoveFolder): Promise { + const row = await db + .update(folders) + .set({ position: input.position, updatedAt: new Date() }) + .where(and(eq(folders.companyId, companyId), eq(folders.id, folderId))) + .returning() + .then((rows) => rows[0] ?? null); + return row ? mapFolder(row) : null; + } + + async function deleteFolder(companyId: string, folderId: string): Promise { + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const folder = await txDb + .select() + .from(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.id, folderId))) + .then((rows) => rows[0] ?? null); + if (!folder) return null; + + if (folder.kind === "routine") { + await txDb + .update(routines) + .set({ folderId: null, updatedAt: new Date() }) + .where(and(eq(routines.companyId, companyId), eq(routines.folderId, folderId))); + } else { + await txDb + .update(companySkills) + .set({ folderId: null, updatedAt: new Date() }) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.folderId, folderId))); + } + + await txDb + .delete(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.id, folderId))); + return mapFolder(folder); + }); + } + + async function assertTargetFolder(companyId: string, kind: FolderKind, folderId: string | null | undefined) { + if (!folderId) return null; + const folder = await getFolder(companyId, folderId); + if (!folder) throw notFound("Folder not found"); + if (folder.kind !== kind) throw unprocessable("Folder kind must match item kind"); + return folder; + } + + async function moveItem(companyId: string, input: MoveFolderItem) { + const targetFolder = await assertTargetFolder(companyId, input.kind, input.folderId ?? null); + if (input.kind === "routine") { + const row = await db + .update(routines) + .set({ folderId: targetFolder?.id ?? 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 row = await db + .update(companySkills) + .set({ folderId: targetFolder?.id ?? 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] ?? null); + if (!row) throw notFound("Skill not found"); + return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null }; + } + + return { + list, + create, + update, + moveFolder, + deleteFolder, + moveItem, + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index bce71849299..61ba18d0d98 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -4,6 +4,7 @@ export { companySearchService } from "./company-search.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 4808decfcc7..6a54a51930c 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, @@ -958,6 +959,18 @@ 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, companyId: folders.companyId, kind: folders.kind }) + .from(folders) + .where(eq(folders.id, folderId)) + .then((rows) => rows[0] ?? null); + if (!folder) throw notFound("Folder not found"); + if (folder.companyId !== companyId) throw unprocessable("Folder must belong to same company"); + 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 @@ -2047,6 +2060,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); @@ -2073,6 +2087,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, @@ -2111,6 +2126,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; @@ -2134,6 +2150,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" }); } @@ -2183,6 +2200,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, @@ -2199,8 +2217,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); @@ -2233,6 +2263,7 @@ export function routineService( .update(routines) .set({ projectId: candidate.projectId, + folderId: candidate.folderId, goalId: candidate.goalId, parentIssueId: candidate.parentIssueId, title: candidate.title,