From 6e49010940d63466d4c51319a1a60dfbf44c31a4 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Wed, 6 May 2026 08:05:12 +0000 Subject: [PATCH] notes: user-defined folders with drag-drop (task #413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/generated/api.schemas.ts | 23 +++++++++++++++-- lib/api-client-react/src/generated/api.ts | 15 +++++------ lib/api-spec/openapi.yaml | 25 ++++++++++++++++++- lib/api-zod/src/generated/api.ts | 22 +++++++++++++--- 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 10953ed8..517dcb10 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -1090,6 +1090,27 @@ export interface NoteRecipientSummary { recipient?: NoteUserSummary | null; } +/** + * A note owned by the caller (My Notes / Archive view). + */ +export interface MyNote { + id: number; + userId: number; + title: string; + content: string; + color: string; + isPinned: boolean; + isArchived: boolean; + /** + * Owning folder id, or null for Unfiled. + * @nullable + */ + folderId: number | null; + createdAt: string; + updatedAt: string; + labelIds: number[]; +} + export interface NoteFolder { id: number; userId: number; @@ -1730,8 +1751,6 @@ export type ListMyNotesAliasParams = { archived?: boolean; }; -export type ListMyNotesAlias200Item = { [key: string]: unknown }; - export type ListReceivedNotesParams = { archived?: boolean; }; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index 810a5eee..a332c32c 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -79,13 +79,13 @@ import type { LeaveConversationBody, ListAuditLogsParams, ListGroupsParams, - ListMyNotesAlias200Item, ListMyNotesAliasParams, ListNoteFoldersParams, ListReceivedNotesParams, ListUsersParams, LoginBody, MessageWithSender, + MyNote, NoteFolder, NoteReply, NoteThread, @@ -8290,14 +8290,11 @@ export const getListMyNotesAliasUrl = (params?: ListMyNotesAliasParams) => { export const listMyNotesAlias = async ( params?: ListMyNotesAliasParams, options?: RequestInit, -): Promise => { - return customFetch( - getListMyNotesAliasUrl(params), - { - ...options, - method: "GET", - }, - ); +): Promise => { + return customFetch(getListMyNotesAliasUrl(params), { + ...options, + method: "GET", + }); }; export const getListMyNotesAliasQueryKey = ( diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index d3e03ea8..f911437c 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -2750,7 +2750,7 @@ paths: application/json: schema: type: array - items: { type: object, additionalProperties: true } + items: { $ref: "#/components/schemas/MyNote" } /notes/sent: get: operationId: listSentNotes @@ -5005,6 +5005,29 @@ components: required: [id, recipientUserId, status, sentAt, readAt, archivedAt] + MyNote: + type: object + description: A note owned by the caller (My Notes / Archive view). + properties: + id: { type: integer } + userId: { type: integer } + title: { type: string } + content: { type: string } + 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 } + labelIds: + type: array + items: { type: integer } + required: + [id, userId, title, content, color, isPinned, isArchived, folderId, + createdAt, updatedAt, labelIds] + NoteFolder: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index c36b216a..fb55e7df 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -3414,10 +3414,24 @@ export const ListMyNotesAliasQueryParams = zod.object({ archived: zod.coerce.boolean().default(listMyNotesAliasQueryArchivedDefault), }); -export const ListMyNotesAliasResponseItem = zod.record( - zod.string(), - zod.unknown(), -); +export const ListMyNotesAliasResponseItem = zod + .object({ + id: zod.number(), + userId: zod.number(), + title: zod.string(), + content: zod.string(), + color: zod.string(), + isPinned: zod.boolean(), + isArchived: zod.boolean(), + folderId: zod + .number() + .nullable() + .describe("Owning folder id, or null for Unfiled."), + createdAt: zod.coerce.date(), + updatedAt: zod.coerce.date(), + labelIds: zod.array(zod.number()), + }) + .describe("A note owned by the caller (My Notes \/ Archive view)."); export const ListMyNotesAliasResponse = zod.array(ListMyNotesAliasResponseItem); /**