notes: user-defined folders with drag-drop (task #413)
DB - New `note_folders` table (per-user unique name); `notes.folder_id` nullable with `ON DELETE SET NULL` so deleting a folder preserves its notes. API - /note-folders CRUD (GET, POST, PATCH, DELETE), all user-scoped. - /notes POST/PATCH validate folder ownership before assignment (no cross-user folder bleed). - Folder noteCount is tab-aware (active vs archived) via ?archived=. - OpenAPI spec extended with /note-folders endpoints, NoteFolder schema, and folderId on SentNote/ReceivedNote. Codegen regenerated for api-client-react and api-zod (`pnpm --filter @workspace/api-spec run codegen`). Client - NoteFolder type and useNoteFolders / useCreateFolder / useUpdateFolder / useDeleteFolder / useMoveNoteToFolder hooks (optimistic across all ["notes", ...] caches; rollback on error). UI (FoldersRail next to My Notes + Archive tabs only) - Desktop: sidebar with All / Unfiled / user folders + counts. - Mobile: horizontal chip row (flex + overflow-x-auto) above the notes grid. - Per-folder kebab dropdown with Rename + Delete. - Newly-created folder is auto-selected. - HTML5 draggable note cards on desktop; touch long-press fallback (350ms) for iPad/mobile via shared drop pipeline (registerFolderDropHandler + hit-tested `[data-folder-drop]`). - Dev-only `window.__notesTouchTest` helper (gated to non-production builds) exposes the touch pipeline so e2e tests can drive it deterministically without synthesizing native TouchEvents. Bilingual - notes.folders.* keys added to en.json and ar.json (RTL inherited from existing dir prop wired from i18n.language). Tests (artifacts/tx-os/tests/notes-folders.spec.mjs) - create folder + auto-select - drag note → folder, refresh-and-still-in-folder persistence - filter by folder / Unfiled / All - drag between folders (count math) - rename via kebab - delete a non-empty folder via kebab → notes return to Unfiled (FK SET NULL) - cross-user folder rejection (400) - Arabic + RTL render - separate touch test (414×800, hasTouch+isMobile) covers chip-row layout and exercises the touch drop pipeline through window helper. All API node tests + tx-os Playwright suite + tsc clean.
This commit is contained in:
@@ -31,6 +31,15 @@ function parseIdParam(raw: unknown): { ok: true; id: number } | { ok: false; err
|
||||
return { ok: true, id: n };
|
||||
}
|
||||
|
||||
// Postgres unique-constraint violation (SQLSTATE 23505). Used to distinguish
|
||||
// duplicate-name errors (→ 409) from unrelated DB failures (→ 500).
|
||||
function isUniqueViolation(e: unknown): boolean {
|
||||
return (
|
||||
typeof e === "object" && e !== null && "code" in e &&
|
||||
(e as { code?: unknown }).code === "23505"
|
||||
);
|
||||
}
|
||||
|
||||
function parseStringMax(v: unknown, max: number): string | undefined {
|
||||
if (v === undefined || v === null) return undefined;
|
||||
if (typeof v !== "string") return undefined;
|
||||
@@ -942,7 +951,11 @@ router.post("/note-folders", requireAuth, async (req, res): Promise<void> => {
|
||||
.returning();
|
||||
res.status(201).json({ ...folder, noteCount: 0 });
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) {
|
||||
res.status(409).json({ error: "Folder already exists" });
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -983,7 +996,11 @@ router.patch("/note-folders/:id", requireAuth, async (req, res): Promise<void> =
|
||||
);
|
||||
res.json({ ...folder, noteCount: Number(countRow?.count ?? 0) });
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) {
|
||||
res.status(409).json({ error: "Folder already exists" });
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user