diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 2840dd01..da180579 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -3,6 +3,7 @@ import { eq, and, desc, inArray, or, sql } from "drizzle-orm"; import { db } from "@workspace/db"; import { notesTable, + noteFoldersTable, noteLabelsTable, noteLabelAssignmentsTable, noteRecipientsTable, @@ -54,6 +55,30 @@ interface NoteInput { isPinned?: boolean; isArchived?: boolean; labelIds?: number[]; + folderId?: number | null; +} + +function parseFolderIdInput(v: unknown): { ok: true; value: number | null } | { ok: false } { + if (v === null) return { ok: true, value: null }; + const n = Number(v); + if (!Number.isInteger(n) || n <= 0) return { ok: false }; + return { ok: true, value: n }; +} + +function parseFolderName(body: any): { ok: true; name: string } | { ok: false; error: string } { + if (!body || typeof body !== "object") return { ok: false, error: "Invalid body" }; + if (typeof body.name !== "string") return { ok: false, error: "Invalid name" }; + const name = body.name.trim(); + if (name.length === 0 || name.length > 80) return { ok: false, error: "Invalid name length" }; + return { ok: true, name }; +} + +async function userOwnsFolder(userId: number, folderId: number): Promise { + const [row] = await db + .select({ id: noteFoldersTable.id }) + .from(noteFoldersTable) + .where(and(eq(noteFoldersTable.id, folderId), eq(noteFoldersTable.userId, userId))); + return !!row; } function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInput } | { ok: false; error: string } { @@ -86,6 +111,11 @@ function parseNoteInput(body: any, isCreate: boolean): { ok: true; data: NoteInp if (ids === undefined) return { ok: false, error: "Invalid labelIds" }; data.labelIds = ids; } else if (isCreate) data.labelIds = []; + if (body.folderId !== undefined) { + const fid = parseFolderIdInput(body.folderId); + if (!fid.ok) return { ok: false, error: "Invalid folderId" }; + data.folderId = fid.value; + } return { ok: true, data }; } @@ -232,7 +262,11 @@ router.post("/notes", requireAuth, async (req, res): Promise => { res.status(400).json({ error: parsed.error }); return; } - const { labelIds = [], ...data } = parsed.data; + const { labelIds = [], folderId, ...data } = parsed.data; + if (folderId != null && !(await userOwnsFolder(userId, folderId))) { + res.status(400).json({ error: "Invalid folderId" }); + return; + } const [note] = await db .insert(notesTable) .values({ @@ -242,6 +276,7 @@ router.post("/notes", requireAuth, async (req, res): Promise => { color: data.color ?? "default", isPinned: data.isPinned ?? false, isArchived: data.isArchived ?? false, + folderId: folderId ?? null, }) .returning(); if (labelIds.length > 0) await setNoteLabels(note.id, userId, labelIds); @@ -270,6 +305,10 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise => { res.status(404).json({ error: "Note not found" }); return; } + if (data.folderId != null && !(await userOwnsFolder(userId, data.folderId))) { + res.status(400).json({ error: "Invalid folderId" }); + return; + } if (Object.keys(data).length > 0) { await db.update(notesTable).set(data).where(eq(notesTable.id, existing.id)); } @@ -855,4 +894,102 @@ router.delete("/note-labels/:id", requireAuth, async (req, res): Promise = res.json({ success: true }); }); +// ----- Folders ----- + +router.get("/note-folders", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + // Counts are scoped to the current tab (active vs archived) so the rail + // matches the visible note set. `archived` query param: "true" → archived + // notes only, otherwise active notes only. + const archived = req.query.archived === "true"; + const noteScope = and( + eq(notesTable.userId, userId), + eq(notesTable.isArchived, archived), + ); + const [folders, counts] = await Promise.all([ + db + .select() + .from(noteFoldersTable) + .where(eq(noteFoldersTable.userId, userId)) + .orderBy(noteFoldersTable.name), + db + .select({ + folderId: notesTable.folderId, + count: sql`count(*)::int`, + }) + .from(notesTable) + .where(noteScope) + .groupBy(notesTable.folderId), + ]); + const countMap = new Map(); + for (const row of counts) { + if (row.folderId != null) countMap.set(row.folderId, Number(row.count)); + } + res.json(folders.map((f) => ({ ...f, noteCount: countMap.get(f.id) ?? 0 }))); +}); + +router.post("/note-folders", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const parsed = parseFolderName(req.body); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error }); + return; + } + try { + const [folder] = await db + .insert(noteFoldersTable) + .values({ userId, name: parsed.name }) + .returning(); + res.status(201).json({ ...folder, noteCount: 0 }); + } catch (e) { + res.status(409).json({ error: "Folder already exists" }); + } +}); + +router.patch("/note-folders/:id", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const id = parseIdParam(req.params.id); + if (!id.ok) { + res.status(400).json({ error: id.error }); + return; + } + const parsed = parseFolderName(req.body); + if (!parsed.ok) { + res.status(400).json({ error: parsed.error }); + return; + } + try { + const [folder] = await db + .update(noteFoldersTable) + .set({ name: parsed.name }) + .where(and(eq(noteFoldersTable.id, id.id), eq(noteFoldersTable.userId, userId))) + .returning(); + if (!folder) { + res.status(404).json({ error: "Folder not found" }); + return; + } + res.json(folder); + } catch (e) { + res.status(409).json({ error: "Folder already exists" }); + } +}); + +router.delete("/note-folders/:id", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const id = parseIdParam(req.params.id); + if (!id.ok) { + res.status(400).json({ error: id.error }); + return; + } + const result = await db + .delete(noteFoldersTable) + .where(and(eq(noteFoldersTable.id, id.id), eq(noteFoldersTable.userId, userId))) + .returning(); + if (result.length === 0) { + res.status(404).json({ error: "Folder not found" }); + return; + } + res.json({ success: true }); +}); + export default router; diff --git a/artifacts/tx-os/src/components/notes/folders-rail.tsx b/artifacts/tx-os/src/components/notes/folders-rail.tsx new file mode 100644 index 00000000..38d934f4 --- /dev/null +++ b/artifacts/tx-os/src/components/notes/folders-rail.tsx @@ -0,0 +1,374 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Folder, + FolderOpen, + FolderPlus, + Inbox as InboxIcon, + Layers, + Pencil, + Trash2, + Check, + X, +} from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + type NoteFolder, + useCreateFolder, + useDeleteFolder, + useUpdateFolder, +} from "@/lib/notes-api"; + +export type FolderSelection = + | { kind: "all" } + | { kind: "unfiled" } + | { kind: "folder"; id: number }; + +export function selectionMatches(a: FolderSelection, b: FolderSelection): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === "folder" && b.kind === "folder") return a.id === b.id; + return true; +} + +export function selectionDropTarget(s: FolderSelection): "all" | "unfiled" | number { + if (s.kind === "folder") return s.id; + return s.kind; +} + +// A module-scoped reference to the note id currently being dragged. We use a +// plain JS variable rather than relying solely on DataTransfer.getData because +// some test runners (and the browser, with non-string MIME types) make it +// unreliable across the dragstart -> drop boundary. DataTransfer is still set +// for native drag UX (cursor + drop hint). +let DRAGGING_NOTE_ID: number | null = null; + +export function setDraggingNoteId(id: number | null) { + DRAGGING_NOTE_ID = id; +} + +export function getDraggingNoteId(): number | null { + return DRAGGING_NOTE_ID; +} + +interface Props { + folders: NoteFolder[]; + selected: FolderSelection; + onSelect: (s: FolderSelection) => void; + unfiledCount: number; + totalCount: number; + onDropNote: (target: "unfiled" | number) => void; +} + +export function FoldersRail({ + folders, + selected, + onSelect, + unfiledCount, + totalCount, + onDropNote, +}: Props) { + const { t } = useTranslation(); + const [newName, setNewName] = useState(""); + const [creating, setCreating] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(""); + const [hoverTarget, setHoverTarget] = useState(null); + const create = useCreateFolder(); + const update = useUpdateFolder(); + const del = useDeleteFolder(); + + const submitCreate = () => { + const name = newName.trim(); + if (!name) return; + create.mutate(name, { + onSuccess: () => { + setNewName(""); + setCreating(false); + }, + }); + }; + + const renderRow = ( + key: string, + { + icon, + label, + count, + isSelected, + onClick, + onDrop, + dropTargetTestId, + action, + content, + }: { + icon: React.ReactNode; + label: React.ReactNode; + count: number; + isSelected: boolean; + onClick: () => void; + onDrop?: () => void; + dropTargetTestId: string; + action?: React.ReactNode; + content?: React.ReactNode; + }, + ) => { + const isHover = hoverTarget === key; + return ( +
{ + if (!onDrop) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (hoverTarget !== key) setHoverTarget(key); + }} + onDragLeave={() => { + if (hoverTarget === key) setHoverTarget(null); + }} + onDrop={(e) => { + if (!onDrop) return; + e.preventDefault(); + setHoverTarget(null); + onDrop(); + }} + className={`group rounded-lg border transition ${ + isSelected + ? "bg-foreground text-background border-foreground" + : "border-transparent hover:bg-slate-100" + } ${isHover ? "ring-2 ring-amber-400 ring-offset-1" : ""}`} + > + {content ?? ( + + )} +
+ ); + }; + + return ( + + ); +} diff --git a/artifacts/tx-os/src/lib/notes-api.ts b/artifacts/tx-os/src/lib/notes-api.ts index 0b42247e..cd3422be 100644 --- a/artifacts/tx-os/src/lib/notes-api.ts +++ b/artifacts/tx-os/src/lib/notes-api.ts @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; export interface Note { id: number; userId: number; + folderId: number | null; title: string; content: string; color: string; @@ -20,6 +21,15 @@ export interface NoteLabel { createdAt: string; } +export interface NoteFolder { + id: number; + userId: number; + name: string; + noteCount: number; + createdAt: string; + updatedAt: string; +} + export interface UserSummary { id: number; username: string; @@ -109,6 +119,8 @@ async function req(path: string, init?: RequestInit): Promise { export const notesKey = (archived: boolean) => ["notes", { archived }] as const; export const labelsKey = ["note-labels"] as const; +export const foldersKey = (archived: boolean = false) => + ["note-folders", { archived }] as const; export const sentNotesKey = ["notes", "sent"] as const; export const receivedNotesKey = (archived: boolean) => ["notes", "received", { archived }] as const; @@ -270,6 +282,89 @@ export function useUpdateLabel() { }); } +export function useNoteFolders(archived: boolean = false) { + return useQuery({ + queryKey: foldersKey(archived), + queryFn: () => + req(`/note-folders?archived=${archived ? "true" : "false"}`), + }); +} + +export function useCreateFolder() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => + req("/note-folders", { + method: "POST", + body: JSON.stringify({ name }), + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["note-folders"] }), + }); +} + +export function useUpdateFolder() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, name }: { id: number; name: string }) => + req(`/note-folders/${id}`, { + method: "PATCH", + body: JSON.stringify({ name }), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["note-folders"] }); + qc.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export function useDeleteFolder() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + req<{ success: true }>(`/note-folders/${id}`, { method: "DELETE" }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["note-folders"] }); + qc.invalidateQueries({ queryKey: ["notes"] }); + }, + }); +} + +export function useMoveNoteToFolder() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, folderId }: { id: number; folderId: number | null }) => + req(`/notes/${id}`, { + method: "PATCH", + body: JSON.stringify({ folderId }), + }), + onMutate: async ({ id, folderId }) => { + await qc.cancelQueries({ queryKey: ["notes"] }); + const snapshots: Array<[unknown, Note[] | undefined]> = []; + const queries = qc.getQueriesData({ queryKey: ["notes"] }); + for (const [key, data] of queries) { + snapshots.push([key, data]); + if (Array.isArray(data)) { + qc.setQueryData( + key, + data.map((n) => (n.id === id ? { ...n, folderId } : n)), + ); + } + } + return { snapshots }; + }, + onError: (_err, _vars, ctx) => { + if (!ctx) return; + for (const [key, data] of ctx.snapshots) { + qc.setQueryData(key as readonly unknown[], data); + } + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: ["notes"] }); + qc.invalidateQueries({ queryKey: ["note-folders"] }); + }, + }); +} + export function useDeleteLabel() { const qc = useQueryClient(); return useMutation({ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 4cdd1a26..b4c33b41 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1039,6 +1039,18 @@ "newLabel": "تصنيف جديد", "noLabelsYet": "لا توجد تصنيفات بعد", "allLabels": "الكل", + "folders": { + "title": "المجلدات", + "new": "مجلد جديد", + "namePlaceholder": "اسم المجلد", + "all": "كل الملاحظات", + "unfiled": "بدون مجلد", + "rename": "إعادة تسمية المجلد", + "delete": "حذف المجلد", + "deleteConfirm": "حذف هذا المجلد؟ ستنتقل الملاحظات بداخله إلى \"بدون مجلد\".", + "dragHint": "اسحب ملاحظة إلى المجلد لنقلها.", + "moved": "تم نقل الملاحظة" + }, "tabs": { "active": "ملاحظاتي", "archived": "الأرشيف", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 95ca4408..d46cea68 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -940,6 +940,18 @@ "newLabel": "New label", "noLabelsYet": "No labels yet", "allLabels": "All", + "folders": { + "title": "Folders", + "new": "New folder", + "namePlaceholder": "Folder name", + "all": "All notes", + "unfiled": "Unfiled", + "rename": "Rename folder", + "delete": "Delete folder", + "deleteConfirm": "Delete this folder? Notes inside will move to Unfiled.", + "dragHint": "Drag a note onto a folder to move it.", + "moved": "Note moved" + }, "tabs": { "active": "My Notes", "archived": "Archived", diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index d33767c7..92e4a29e 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -62,6 +62,8 @@ import { useDeleteLabel, useDeleteNote, useMarkNoteRead, + useMoveNoteToFolder, + useNoteFolders, useNoteLabels, useNoteThread, useNotes, @@ -74,6 +76,12 @@ import { useUserDirectory, userDisplayName, } from "@/lib/notes-api"; +import { + FoldersRail, + type FolderSelection, + setDraggingNoteId, + getDraggingNoteId, +} from "@/components/notes/folders-rail"; import { useAuth } from "@/contexts/AuthContext"; type TabId = "active" | "received" | "sent" | "archived"; @@ -87,6 +95,9 @@ export default function NotesPage() { const [view, setView] = useState("active"); const [search, setSearch] = useState(""); const [labelFilter, setLabelFilter] = useState(null); + const [folderSelection, setFolderSelection] = useState({ + kind: "all", + }); const [editing, setEditing] = useState(null); const [labelsDialogOpen, setLabelsDialogOpen] = useState(false); const [sendForNoteId, setSendForNoteId] = useState(null); @@ -119,6 +130,9 @@ export default function NotesPage() { const { data: notes = [], isLoading: loadingNotes } = useNotes(view === "archived"); const { data: labels = [] } = useNoteLabels(); + const { data: folders = [] } = useNoteFolders(view === "archived"); + const moveNote = useMoveNoteToFolder(); + const showFolders = view === "active" || view === "archived"; const { data: receivedNotes = [], isLoading: loadingReceived } = useReceivedNotes(false); const { data: archivedReceivedNotes = [], isLoading: loadingArchivedReceived } = @@ -129,13 +143,28 @@ export default function NotesPage() { const q = search.trim().toLowerCase(); return notes.filter((n) => { if (labelFilter && !n.labelIds.includes(labelFilter)) return false; + if (folderSelection.kind === "unfiled" && n.folderId !== null) return false; + if (folderSelection.kind === "folder" && n.folderId !== folderSelection.id) + return false; if (!q) return true; return ( n.title.toLowerCase().includes(q) || n.content.toLowerCase().includes(q) ); }); - }, [notes, search, labelFilter]); + }, [notes, search, labelFilter, folderSelection]); + + const unfiledCount = notes.filter((n) => n.folderId === null).length; + + const handleDropNote = (target: "unfiled" | number) => { + const id = getDraggingNoteId(); + setDraggingNoteId(null); + if (id == null) return; + const note = notes.find((n) => n.id === id); + const nextFolderId = target === "unfiled" ? null : target; + if (note && note.folderId === nextFolderId) return; + moveNote.mutate({ id, folderId: nextFolderId }); + }; const filteredReceived = useMemo(() => { const q = search.trim().toLowerCase(); @@ -280,7 +309,18 @@ export default function NotesPage() { -
+
+ {showFolders && ( + + )} +
{view === "active" && ( <> @@ -346,6 +386,7 @@ export default function NotesPage() { onOpen={(id) => setOpenThreadId(id)} /> )} +
{editing && ( @@ -479,6 +520,16 @@ function NoteCard({ return (
{ + setDraggingNoteId(note.id); + try { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", `note:${note.id}`); + } catch {} + }} + onDragEnd={() => setDraggingNoteId(null)} className={`break-inside-avoid mb-3 rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md group ${colorBg( note.color, )}`} diff --git a/artifacts/tx-os/tests/notes-folders.spec.mjs b/artifacts/tx-os/tests/notes-folders.spec.mjs new file mode 100644 index 00000000..4bc57dd9 --- /dev/null +++ b/artifacts/tx-os/tests/notes-folders.spec.mjs @@ -0,0 +1,322 @@ +// E2E test for the user-defined Notes Folders feature: +// - Create a folder from the rail +// - Drag a note from "Unfiled" into the new folder +// - Filter by selected folder shows only notes in that folder +// - Drag the note from one folder into another +// - Rename + delete a folder; deleting moves its notes back to Unfiled +// - Verifies bilingual rendering: re-runs the rail title check in Arabic +// so the RTL label is exercised end-to-end. +import { test, expect } from "@playwright/test"; +import pg from "pg"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run this UI test"); +} + +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +const createdUserIds = []; +const createdNoteIds = []; + +function uniq() { + return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +async function createUser(prefix, displayEn) { + const username = `${prefix}_${uniq()}`; + const { rows } = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, + [username, `${username}@example.com`, TEST_PASSWORD_HASH, displayEn], + ); + const id = rows[0].id; + createdUserIds.push(id); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) + SELECT $1, id FROM roles WHERE name = 'user'`, + [id], + ); + return { id, username }; +} + +test.afterAll(async () => { + if (createdUserIds.length > 0) { + await pool.query(`DELETE FROM notes WHERE user_id = ANY($1::int[])`, [ + createdUserIds, + ]); + await pool.query( + `DELETE FROM note_folders WHERE user_id = ANY($1::int[])`, + [createdUserIds], + ); + await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [ + createdUserIds, + ]); + await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ + createdUserIds, + ]); + } + await pool.end(); +}); + +async function loginViaUi(page, username) { + await page.goto("/login"); + await page.locator("#username").fill(username); + await page.locator("#password").fill(TEST_PASSWORD); + await Promise.all([ + page.waitForURL((url) => !url.pathname.endsWith("/login"), { + timeout: 15_000, + }), + page.locator('form button[type="submit"]').click(), + ]); +} + +// Synthesize HTML5 drag-and-drop with a shared DataTransfer so React's drag +// handlers fire reliably regardless of pointer-based heuristics. +async function dragNoteToFolderTarget(page, noteId, dropTargetTestId) { + await page.evaluate( + ({ srcSel, dstSel }) => { + const src = document.querySelector(srcSel); + const dst = document.querySelector(dstSel); + if (!src || !dst) throw new Error(`drag elements missing: ${srcSel} / ${dstSel}`); + const dt = new DataTransfer(); + src.dispatchEvent( + new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer: dt }), + ); + dst.dispatchEvent( + new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer: dt }), + ); + dst.dispatchEvent( + new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer: dt }), + ); + dst.dispatchEvent( + new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: dt }), + ); + src.dispatchEvent( + new DragEvent("dragend", { bubbles: true, cancelable: true, dataTransfer: dt }), + ); + }, + { + srcSel: `[data-testid="note-card-${noteId}"]`, + dstSel: `[data-testid="${dropTargetTestId}"]`, + }, + ); +} + +async function createNoteViaApi(page, title) { + const resp = await page.request.post("/api/notes", { + data: { title, content: "body of " + title }, + }); + expect(resp.ok()).toBeTruthy(); + const note = await resp.json(); + createdNoteIds.push(note.id); + return note; +} + +test("create folder, drag note in, filter, move between folders, rename, delete", async ({ + page, +}) => { + const user = await createUser("notes_folders_e2e", "Folder Tester"); + await loginViaUi(page, user.username); + + // Seed two notes via the API so the test focuses on folder UX. + const note1 = await createNoteViaApi(page, `Folder Note 1 ${uniq()}`); + const note2 = await createNoteViaApi(page, `Folder Note 2 ${uniq()}`); + + await page.goto("/notes"); + await expect(page.getByTestId("notes-page")).toBeVisible(); + + // Folders rail is visible on the My Notes tab. + const rail = page.getByTestId("notes-folders-rail"); + await expect(rail).toBeVisible(); + await expect(page.getByTestId("folder-drop-all")).toBeVisible(); + await expect(page.getByTestId("folder-drop-unfiled")).toBeVisible(); + + // Both new notes should be unfiled. + await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^2$/); + + // Create folder "Projects". + await page.getByTestId("folder-create-open").click(); + await page.getByTestId("folder-create-input").fill("Projects"); + const createProjectsResp = page.waitForResponse( + (r) => + new URL(r.url()).pathname === "/api/note-folders" && + r.request().method() === "POST" && + r.status() >= 200 && + r.status() < 300, + ); + await page.getByTestId("folder-create-submit").click(); + const projectsResp = await createProjectsResp; + const projects = await projectsResp.json(); + + // Create folder "Personal". + await page.getByTestId("folder-create-open").click(); + await page.getByTestId("folder-create-input").fill("Personal"); + const createPersonalResp = page.waitForResponse( + (r) => + new URL(r.url()).pathname === "/api/note-folders" && + r.request().method() === "POST" && + r.status() >= 200 && + r.status() < 300, + ); + await page.getByTestId("folder-create-submit").click(); + const personalResp = await createPersonalResp; + const personal = await personalResp.json(); + + // Both folders show up with count 0. + await expect(page.getByTestId(`folder-select-${projects.id}`)).toBeVisible(); + await expect(page.getByTestId(`folder-select-${personal.id}`)).toBeVisible(); + await expect( + page.getByTestId(`folder-drop-${projects.id}-count`), + ).toHaveText(/^0$/); + + // ---- Drag note1 into Projects ---- + const movePromise1 = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/notes/${note1.id}` && + r.request().method() === "PATCH" && + r.status() >= 200 && + r.status() < 300, + ); + await dragNoteToFolderTarget(page, note1.id, `folder-drop-${projects.id}`); + await movePromise1; + + // Counts update. + await expect( + page.getByTestId(`folder-drop-${projects.id}-count`), + ).toHaveText(/^1$/); + await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^1$/); + + // ---- Filter by Projects: only note1 visible ---- + await page.getByTestId(`folder-select-${projects.id}`).click(); + await expect(page.getByTestId(`note-card-${note1.id}`)).toBeVisible(); + await expect(page.getByTestId(`note-card-${note2.id}`)).toHaveCount(0); + + // ---- Filter Unfiled: only note2 visible ---- + await page.getByTestId("folder-drop-unfiled").click(); + await expect(page.getByTestId(`note-card-${note2.id}`)).toBeVisible(); + await expect(page.getByTestId(`note-card-${note1.id}`)).toHaveCount(0); + + // ---- Drag note2 into Personal, then move it from Personal to Projects ---- + await page.getByTestId("folder-drop-all").click(); + const movePromise2 = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/notes/${note2.id}` && + r.request().method() === "PATCH" && + r.status() >= 200 && + r.status() < 300, + ); + await dragNoteToFolderTarget(page, note2.id, `folder-drop-${personal.id}`); + await movePromise2; + await expect( + page.getByTestId(`folder-drop-${personal.id}-count`), + ).toHaveText(/^1$/); + + const movePromise3 = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/notes/${note2.id}` && + r.request().method() === "PATCH" && + r.status() >= 200 && + r.status() < 300, + ); + await dragNoteToFolderTarget(page, note2.id, `folder-drop-${projects.id}`); + await movePromise3; + await expect( + page.getByTestId(`folder-drop-${projects.id}-count`), + ).toHaveText(/^2$/); + await expect( + page.getByTestId(`folder-drop-${personal.id}-count`), + ).toHaveText(/^0$/); + + // ---- Rename Projects -> "Work" ---- + await page.getByTestId(`folder-select-${projects.id}`).hover(); + await page.getByTestId(`folder-rename-${projects.id}`).click(); + const renameInput = page.getByTestId(`folder-rename-input-${projects.id}`); + await renameInput.fill("Work"); + const renamePromise = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/note-folders/${projects.id}` && + r.request().method() === "PATCH" && + r.status() >= 200 && + r.status() < 300, + ); + await page.getByTestId(`folder-rename-save-${projects.id}`).click(); + await renamePromise; + await expect(page.getByTestId(`folder-select-${projects.id}`)).toContainText( + "Work", + ); + + // ---- Move note1 INTO Personal so the folder is non-empty, then delete it + // and verify the note returns to Unfiled (FK ON DELETE SET NULL). ---- + const movePromiseToPersonal = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/notes/${note1.id}` && + r.request().method() === "PATCH" && + r.status() >= 200 && + r.status() < 300, + ); + await dragNoteToFolderTarget(page, note1.id, `folder-drop-${personal.id}`); + await movePromiseToPersonal; + await expect( + page.getByTestId(`folder-drop-${personal.id}-count`), + ).toHaveText(/^1$/); + + page.once("dialog", (d) => d.accept()); + const deletePromise = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/note-folders/${personal.id}` && + r.request().method() === "DELETE" && + r.status() >= 200 && + r.status() < 300, + ); + await page.getByTestId(`folder-select-${personal.id}`).hover(); + await page.getByTestId(`folder-delete-${personal.id}`).click(); + await deletePromise; + await expect(page.getByTestId(`folder-select-${personal.id}`)).toHaveCount(0); + + // The orphaned note should have folderId reset to null via FK SET NULL, + // so it should now appear under "Unfiled". + const allNotes = await (await page.request.get(`/api/notes`)).json(); + const note1AfterDelete = allNotes.find((n) => n.id === note1.id); + expect(note1AfterDelete).toBeDefined(); + expect(note1AfterDelete.folderId).toBeNull(); + await expect(page.getByTestId("folder-drop-unfiled-count")).toHaveText(/^1$/); + + // ---- Cross-user safety: another user's folder cannot be assigned. ---- + const otherUser = await createUser("notes_folders_other", "Other User"); + const { rows: otherFolderRows } = await pool.query( + `INSERT INTO note_folders (user_id, name) VALUES ($1, $2) RETURNING id`, + [otherUser.id, `OtherFolder_${uniq()}`], + ); + const otherFolderId = otherFolderRows[0].id; + const crossResp = await page.request.patch(`/api/notes/${note2.id}`, { + data: { folderId: otherFolderId }, + }); + expect(crossResp.status()).toBe(400); + await pool.query(`DELETE FROM note_folders WHERE id = $1`, [otherFolderId]); + + await page.getByTestId("folder-drop-all").click(); + + // ---- Bilingual smoke check: flip the user to Arabic, reload, and verify + // the rail renders RTL with the Arabic copy. ---- + await pool.query( + `UPDATE users SET preferred_language = 'ar' WHERE id = $1`, + [user.id], + ); + await page.evaluate(() => { + localStorage.setItem("tx-lang", "ar"); + }); + await page.reload(); + await expect(page.getByTestId("notes-page")).toHaveAttribute("dir", "rtl"); + await expect(page.getByTestId("notes-folders-rail")).toBeVisible(); + await expect(page.getByTestId(`folder-select-${projects.id}`)).toContainText( + "Work", + ); + await expect(page.getByTestId("folder-drop-unfiled")).toContainText( + "بدون مجلد", + ); +}); diff --git a/lib/db/src/schema/notes.ts b/lib/db/src/schema/notes.ts index 24d3d6ff..d333c623 100644 --- a/lib/db/src/schema/notes.ts +++ b/lib/db/src/schema/notes.ts @@ -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; export type NoteLabel = typeof noteLabelsTable.$inferSelect; +export const insertNoteFolderSchema = createInsertSchema(noteFoldersTable).omit({ id: true, createdAt: true, updatedAt: true }); +export type InsertNoteFolder = z.infer; +export type NoteFolder = typeof noteFoldersTable.$inferSelect; + export type NoteRecipient = typeof noteRecipientsTable.$inferSelect; export type NoteReply = typeof noteRepliesTable.$inferSelect;