mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
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:
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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
: [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user