Add folders schema and backend API

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-05 11:23:39 +00:00
1 parent ad961227f5
commit ef86752e46
24 files changed
+823 -1

No files matched your search

@@ -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
@@ -13,6 +13,7 @@ import { applyTrustProxy, parseTrustProxyEnv } from "./middleware/trust-proxy.js
import { healthRoutes } from "./routes/health.js";
import { companyRoutes } from "./routes/companies.js";
import { companySkillRoutes } from "./routes/company-skills.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";
@@ -222,6 +223,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(teamsCatalogRoutes(db));
api.use(agentRoutes(db, { pluginWorkerManager: workerManager }));
+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
@@ -1,6 +1,7 @@
export { healthRoutes } from "./health.js";
export { companyRoutes } from "./companies.js";
export { companySkillRoutes } from "./company-skills.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
@@ -71,6 +71,7 @@ type CompanySkillListDbRow = Pick<
CompanySkillRow,
| "id"
| "companyId"
| "folderId"
| "key"
| "slug"
| "name"
@@ -103,6 +104,7 @@ type CompanySkillListRow = Pick<
CompanySkill,
| "id"
| "companyId"
| "folderId"
| "key"
| "slug"
| "name"
@@ -294,6 +296,7 @@ function selectCompanySkillColumns() {
return {
id: companySkills.id,
companyId: companySkills.companyId,
folderId: companySkills.folderId,
key: companySkills.key,
slug: companySkills.slug,
name: companySkills.name,
@@ -2087,6 +2090,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,
@@ -2265,6 +2269,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
@@ -3,6 +3,7 @@ export { companyArtifactsService } from "./company-artifacts.js";
export { companySearchService } from "./company-search.js";
export { feedbackService } from "./feedback.js";
export { companySkillService } from "./company-skills.js";
export { folderService } from "./folders.js";
export { agentService, deduplicateAgentName } from "./agents.js";
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
export { assetService } from "./assets.js";
+32 -1
View File
@@ -11,6 +11,7 @@ import {
documentRevisions,
documents,
executionWorkspaces,
folders,
goals,
heartbeatRuns,
issueInboxArchives,
@@ -941,6 +942,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
@@ -1894,6 +1907,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);
@@ -1920,6 +1934,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,
@@ -1958,6 +1973,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;
@@ -1981,6 +1997,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" });
}
@@ -2030,6 +2047,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,
@@ -2046,8 +2064,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);
@@ -2080,6 +2110,7 @@ export function routineService(
.update(routines)
.set({
projectId: candidate.projectId,
folderId: candidate.folderId,
goalId: candidate.goalId,
parentIssueId: candidate.parentIssueId,
title: candidate.title,