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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user