Address review feedback: search filter, folder name race, bulk-bar menu, OpenAPI coverage

- Exclude archived routines from company search results, matching the
  folder listing behavior
- Map unique-index violations on folder create/rename to a 409 so the
  name-conflict check is race-safe
- Render Move to... folder items directly in the BulkBar dropdown
  instead of nesting a redundant submenu
- Register the folders endpoints in the OpenAPI spec and route
  coverage test
- Renumber folders migration to 0134 after master took 0131-0133

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 4.7 committed 2026-07-06 19:59:01 +00:00
1 parent ae04bca734
commit 9be0d1fec6
5 files changed
+168 -38

No files matched your search

@@ -27,6 +27,7 @@ const apiPrefixes: Record<string, string> = {
"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",
+75
View File
@@ -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",
+2 -2
View File
@@ -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)
: [];
+33 -2
View File
@@ -31,6 +31,24 @@ function normalizeColor(color: string | null | undefined) {
return trimmed.length > 0 ? trimmed : null;
}
function isFolderNameUniqueViolation(error: unknown) {
const seen = new Set<unknown>();
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;
}
+57 -34
View File
@@ -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 (
<>
<div className="flex items-center gap-2 px-2 py-1.5">
<Search className="h-3.5 w-3.5 text-muted-foreground" />
<input
value={query}
onChange={(event) => 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"
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onMove(null)}>
<FolderSwatch color={null} />
Unfiled
{currentFolderId == null ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
{visibleFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onSelect={() => onMove(folder.id)}>
<FolderSwatch color={folder.color} />
<span className="min-w-0 flex-1 truncate">{folder.name}</span>
{currentFolderId === folder.id ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
))}
{visibleFolders.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No folders match.</div>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onCreateAndMove}>
<Plus className="h-3.5 w-3.5" />
New folder...
</DropdownMenuItem>
</>
);
}
export function MoveToMenu({
folders,
currentFolderId,
onMove,
onCreateAndMove,
}: {
folders: FolderListItem[];
currentFolderId: string | null | undefined;
onMove: (folderId: string | null) => void;
onCreateAndMove: () => void;
}) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Move to...</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<div className="flex items-center gap-2 px-2 py-1.5">
<Search className="h-3.5 w-3.5 text-muted-foreground" />
<input
value={query}
onChange={(event) => 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"
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onMove(null)}>
<FolderSwatch color={null} />
Unfiled
{currentFolderId == null ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
{visibleFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onSelect={() => onMove(folder.id)}>
<FolderSwatch color={folder.color} />
<span className="min-w-0 flex-1 truncate">{folder.name}</span>
{currentFolderId === folder.id ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
))}
{visibleFolders.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No folders match.</div>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onCreateAndMove}>
<Plus className="h-3.5 w-3.5" />
New folder...
</DropdownMenuItem>
<MoveToMenuItems
folders={folders}
currentFolderId={currentFolderId}
onMove={onMove}
onCreateAndMove={onCreateAndMove}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
@@ -567,8 +590,8 @@ export function BulkBar({
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">Move to...</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<MoveToMenu
<DropdownMenuContent align="end" className="w-56">
<MoveToMenuItems
folders={folders}
currentFolderId={undefined}
onMove={onMove}