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:50:39 +00:00
parent 0bcfb9017d
commit 6826b475b2
4 changed files with 587 additions and 0 deletions
+109
View File
@@ -2905,6 +2905,96 @@ paths:
application/json:
schema: { $ref: "#/components/schemas/NoteReply" }
# ----- Note folders (user-defined) -----
/note-folders:
get:
operationId: listNoteFolders
tags: [notes]
summary: List the caller's note folders with per-tab note counts
parameters:
- in: query
name: archived
required: false
description: Tab scope for noteCount (false = My Notes, true = Archive)
schema: { type: boolean, default: false }
responses:
"200":
description: Folders owned by the caller
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/NoteFolder" }
post:
operationId: createNoteFolder
tags: [notes]
summary: Create a new note folder owned by the caller
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string, minLength: 1, maxLength: 80 }
responses:
"201":
description: Folder created
content:
application/json:
schema: { $ref: "#/components/schemas/NoteFolder" }
"400":
description: Invalid name or duplicate per-user folder name
content:
application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" }
/note-folders/{id}:
patch:
operationId: updateNoteFolder
tags: [notes]
summary: Rename a note folder
parameters:
- in: path
name: id
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string, minLength: 1, maxLength: 80 }
responses:
"200":
description: Folder updated
content:
application/json:
schema: { $ref: "#/components/schemas/NoteFolder" }
"404":
description: Folder not found or not owned by caller
content:
application/json:
schema: { $ref: "#/components/schemas/ErrorResponse" }
delete:
operationId: deleteNoteFolder
tags: [notes]
summary: Delete a note folder. Notes inside are preserved (folderId set to null).
parameters:
- in: path
name: id
required: true
schema: { type: integer }
responses:
"200":
description: Folder deleted
content:
application/json:
schema: { $ref: "#/components/schemas/SuccessResponse" }
/admin/users/{id}/dependents/notes:
get:
operationId: getAdminUserDependentNotes
@@ -4900,6 +4990,19 @@ components:
required:
[id, recipientUserId, status, sentAt, readAt, archivedAt]
NoteFolder:
type: object
properties:
id: { type: integer }
userId: { type: integer }
name: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
noteCount:
type: integer
description: Number of notes in this folder, scoped to the requested tab (active vs archived).
required: [id, userId, name, createdAt, updatedAt, noteCount]
SentNote:
type: object
properties:
@@ -4910,6 +5013,9 @@ components:
color: { type: string }
isPinned: { type: boolean }
isArchived: { type: boolean }
folderId:
type: ["integer", "null"]
description: Owning folder id, or null for Unfiled.
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
recipients:
@@ -4926,6 +5032,9 @@ components:
title: { type: string }
content: { type: string }
color: { type: string }
folderId:
type: ["integer", "null"]
description: Owning folder id on the recipient's side, or null for Unfiled.
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
sender: