Add folders schema and backend API

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-15 17:13:24 +00:00
1 parent 3a727bf780
commit a072fafbb8
24 files changed
+823 -1

No files matched your search

@@ -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");
@@ -1184,6 +1184,13 @@
"when": 1784037600000,
"tag": "0170_company_skill_policies",
"breakpoints": true
},
{
"idx": 171,
"version": "7",
"when": 1784037601000,
"tag": "0171_folders",
"breakpoints": true
}
]
}
+3
View File
@@ -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),
+38
View File
@@ -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<FolderKind>().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,
),
}),
);
+1
View File
@@ -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";
+3
View File
@@ -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),
}),
+4
View File
@@ -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`,
+24
View File
@@ -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,
@@ -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;
+46
View File
@@ -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;
}
+10
View File
@@ -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,
+1
View File
@@ -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;
@@ -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),
+55
View File
@@ -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<typeof createFolderSchema>;
export type UpdateFolder = z.infer<typeof updateFolderSchema>;
export type MoveFolder = z.infer<typeof moveFolderSchema>;
export type MoveFolderItem = z.infer<typeof moveFolderItemSchema>;
+14
View File
@@ -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,
@@ -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),
@@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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);
});
});
+2
View File
@@ -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));
+142
View File
@@ -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;
}
+1
View File
@@ -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";
+5
View File
@@ -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,
+229
View File
@@ -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<number>`count(*)::int`,
})
.from(routines)
.where(eq(routines.companyId, companyId))
.groupBy(routines.folderId);
}
async function skillCounts(companyId: string) {
return db
.select({
folderId: companySkills.folderId,
count: sql<number>`count(*)::int`,
})
.from(companySkills)
.where(eq(companySkills.companyId, companyId))
.groupBy(companySkills.folderId);
}
async function list(companyId: string, kind: FolderKind): Promise<FolderListResult> {
const [folderRows, countRows] = await Promise.all([
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<string | null, number>();
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<Folder> {
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<Folder | null> {
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<Folder | null> {
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<Folder | null> {
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,
};
}
+1
View File
@@ -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,
+32 -1
View File
@@ -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<string, RoutineTrigger[]>();
const rows = await db
@@ -2047,6 +2060,7 @@ export function routineService(
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
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,