diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 97d7750a693..5e44a2e4007 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -27,6 +27,7 @@ const apiPrefixes: Record = { "environments.ts": "/api", "execution-workspaces.ts": "/api", "file-resources.ts": "/api", + "folders.ts": "/api", "goals.ts": "/api", "health.ts": "/api/health", "inbox-dismissals.ts": "/api", diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index a0a2bacd59a..44088f91b43 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -38,6 +38,11 @@ import { updateCompanyBrandingSchema, companyArtifactsQuerySchema, companyArtifactsResponseSchema, + // Folder + createFolderSchema, + updateFolderSchema, + moveFolderSchema, + moveFolderItemSchema, // Routine createRoutineSchema, updateRoutineSchema, @@ -755,6 +760,7 @@ const CREATED_OPERATIONS = new Set([ "POST /api/admin/users/{userId}/promote-instance-admin", "POST /api/plugins/install", "POST /api/instance/database-backups", + "POST /api/companies/{companyId}/folders", ]); const ACCEPTED_OPERATIONS = new Set([ @@ -2134,6 +2140,75 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/folders", + tags: ["folders"], + summary: "List folders of a kind with item counts", + request: { + params: z.object({ companyId: z.string() }), + query: z.object({ kind: z.enum(["routine", "skill"]) }), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/folders", + tags: ["folders"], + summary: "Create a folder", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(createFolderSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/folders/{folderId}", + tags: ["folders"], + summary: "Rename or restyle a folder", + request: { + params: z.object({ companyId: z.string(), folderId: z.string() }), + body: jsonBody(updateFolderSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/folders/{folderId}", + tags: ["folders"], + summary: "Delete a folder, moving its items to Unfiled", + request: { params: z.object({ companyId: z.string(), folderId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/folders/items/move", + tags: ["folders"], + summary: "Move a routine or skill into a folder", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(moveFolderItemSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/folders/{folderId}/move", + tags: ["folders"], + summary: "Reorder a folder", + request: { + params: z.object({ companyId: z.string(), folderId: z.string() }), + body: jsonBody(moveFolderSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + registry.registerPath({ method: "post", path: "/api/routine-triggers/{id}/rotate-secret", diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 3f49a7500a0..fd9435f864a 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, ne, sql } from "drizzle-orm"; import type { SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, companies, companySkills, folders, issues, projects, routines } from "@paperclipai/db"; @@ -848,7 +848,7 @@ export function companySearchService(db: Db) { eq(folders.kind, "routine"), ), ) - .where(and(eq(routines.companyId, companyId), routineCondition)) + .where(and(eq(routines.companyId, companyId), ne(routines.status, "archived"), routineCondition)) .orderBy(desc(routines.updatedAt), desc(routines.id)) .limit(fetchLimit) : []; diff --git a/server/src/services/folders.ts b/server/src/services/folders.ts index 52c5c5824de..7421d6eff26 100644 --- a/server/src/services/folders.ts +++ b/server/src/services/folders.ts @@ -31,6 +31,24 @@ function normalizeColor(color: string | null | undefined) { return trimmed.length > 0 ? trimmed : null; } +function isFolderNameUniqueViolation(error: unknown) { + const seen = new Set(); + let current = error; + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const maybe = current as { + code?: string; + constraint?: string; + constraint_name?: string; + cause?: unknown; + }; + const constraint = maybe.constraint ?? maybe.constraint_name; + if (maybe.code === "23505" && constraint === "folders_company_kind_name_uq") return true; + current = maybe.cause; + } + return false; +} + export function folderService(db: Db) { async function getFolder(companyId: string, folderId: string) { return db @@ -122,7 +140,14 @@ export function folderService(db: Db) { position, }) .returning() - .then((rows) => rows[0] ?? null); + .then((rows) => rows[0] ?? null) + .catch((error: unknown) => { + // assertNoNameConflict is racy; the unique index is the backstop. + if (isFolderNameUniqueViolation(error)) { + throw conflict("Folder name already exists for this kind"); + } + throw error; + }); if (!row) throw notFound("Failed to create folder"); return mapFolder(row); } @@ -144,7 +169,13 @@ export function folderService(db: Db) { }) .where(and(eq(folders.companyId, companyId), eq(folders.id, folderId))) .returning() - .then((rows) => rows[0] ?? null); + .then((rows) => rows[0] ?? null) + .catch((error: unknown) => { + if (isFolderNameUniqueViolation(error)) { + throw conflict("Folder name already exists for this kind"); + } + throw error; + }); return row ? mapFolder(row) : null; } diff --git a/ui/src/components/folders/FolderControls.tsx b/ui/src/components/folders/FolderControls.tsx index 4360f9c1ea6..b74d108024a 100644 --- a/ui/src/components/folders/FolderControls.tsx +++ b/ui/src/components/folders/FolderControls.tsx @@ -355,7 +355,7 @@ export function MobileFolderSheet({ ); } -export function MoveToMenu({ +export function MoveToMenuItems({ folders, currentFolderId, onMove, @@ -373,41 +373,64 @@ export function MoveToMenu({ return folders.filter((folder) => folder.name.toLowerCase().includes(lowered)); }, [folders, query]); + return ( + <> +
+ + setQuery(event.target.value)} + onKeyDown={(event) => event.stopPropagation()} + placeholder="Search folders" + className="h-7 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+ + onMove(null)}> + + Unfiled + {currentFolderId == null ? : null} + + {visibleFolders.map((folder) => ( + onMove(folder.id)}> + + {folder.name} + {currentFolderId === folder.id ? : null} + + ))} + {visibleFolders.length === 0 ? ( +
No folders match.
+ ) : null} + + + + New folder... + + + ); +} + +export function MoveToMenu({ + folders, + currentFolderId, + onMove, + onCreateAndMove, +}: { + folders: FolderListItem[]; + currentFolderId: string | null | undefined; + onMove: (folderId: string | null) => void; + onCreateAndMove: () => void; +}) { return ( Move to... -
- - setQuery(event.target.value)} - onKeyDown={(event) => event.stopPropagation()} - placeholder="Search folders" - className="h-7 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" - /> -
- - onMove(null)}> - - Unfiled - {currentFolderId == null ? : null} - - {visibleFolders.map((folder) => ( - onMove(folder.id)}> - - {folder.name} - {currentFolderId === folder.id ? : null} - - ))} - {visibleFolders.length === 0 ? ( -
No folders match.
- ) : null} - - - - New folder... - +
); @@ -567,8 +590,8 @@ export function BulkBar({ - - +