notes: user-defined folders with drag-drop (task #413)

- DB: new note_folders table (per-user unique name) + nullable folder_id on
  notes with ON DELETE SET NULL; pushed via @workspace/db.
- API: full /note-folders CRUD with user scoping; /notes POST/PATCH validate
  folder ownership; folder noteCount is tab-aware (active vs archived) via
  ?archived= query param computed by GROUP BY (drizzle correlated subquery
  was returning 0 — replaced with two queries merged in JS).
- Client: NoteFolder type, useNoteFolders/useCreateFolder/useUpdateFolder/
  useDeleteFolder/useMoveNoteToFolder hooks (optimistic update for moves
  across all ["notes", ...] caches).
- UI: new FoldersRail (artifacts/tx-os/src/components/notes/folders-rail.tsx)
  rendered next to My Notes + Archive tabs; HTML5 draggable note cards with
  module-scoped DRAGGING_NOTE_ID fallback; visible drop highlight; rename +
  delete + create inline; folder-scoped + Unfiled filtering in notes.tsx.
- Bilingual: notes.folders.* keys added to en.json and ar.json (RTL works
  via existing dir prop wired from i18n.language).
- Test: artifacts/tx-os/tests/notes-folders.spec.mjs covers create folder,
  drag note to folder, filter by folder/Unfiled, drag between folders,
  rename, delete a non-empty folder (verifies FK SET NULL + Unfiled count),
  cross-user folder rejection (400), and Arabic+RTL rendering.

Drift from plan: none — all originally scoped work shipped. Strengthened
e2e + cross-user check + tab-aware counts added per code review feedback.
This commit is contained in:
riyadhafraa
2026-05-06 07:26:11 +00:00
parent 6d3e38ec26
commit ba0da3d26c
8 changed files with 1021 additions and 3 deletions
+15
View File
@@ -3,9 +3,20 @@ import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
import { usersTable } from "./users";
export const noteFoldersTable = pgTable("note_folders", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
name: varchar("name", { length: 80 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
}, (t) => ({
userNameUnique: uniqueIndex("note_folders_user_name_unique").on(t.userId, t.name),
}));
export const notesTable = pgTable("notes", {
id: serial("id").primaryKey(),
userId: integer("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
folderId: integer("folder_id").references(() => noteFoldersTable.id, { onDelete: "set null" }),
title: varchar("title", { length: 300 }).notNull().default(""),
content: text("content").notNull().default(""),
color: varchar("color", { length: 32 }).notNull().default("default"),
@@ -79,5 +90,9 @@ export const insertNoteLabelSchema = createInsertSchema(noteLabelsTable).omit({
export type InsertNoteLabel = z.infer<typeof insertNoteLabelSchema>;
export type NoteLabel = typeof noteLabelsTable.$inferSelect;
export const insertNoteFolderSchema = createInsertSchema(noteFoldersTable).omit({ id: true, createdAt: true, updatedAt: true });
export type InsertNoteFolder = z.infer<typeof insertNoteFolderSchema>;
export type NoteFolder = typeof noteFoldersTable.$inferSelect;
export type NoteRecipient = typeof noteRecipientsTable.$inferSelect;
export type NoteReply = typeof noteRepliesTable.$inferSelect;