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:
riyadhafraa
2026-05-06 07:57:57 +00:00
parent 1889407565
commit f562749214
+19 -2
View File
@@ -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) {
res.status(409).json({ error: "Folder already exists" });
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) {
res.status(409).json({ error: "Folder already exists" });
if (isUniqueViolation(e)) {
res.status(409).json({ error: "Folder already exists" });
return;
}
throw e;
}
});