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
+138 -1
View File
@@ -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<boolean> {
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<void> => {
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<void> => {
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<void> => {
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<void> =
res.json({ success: true });
});
// ----- Folders -----
router.get("/note-folders", requireAuth, async (req, res): Promise<void> => {
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<number>`count(*)::int`,
})
.from(notesTable)
.where(noteScope)
.groupBy(notesTable.folderId),
]);
const countMap = new Map<number, number>();
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<void> => {
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<void> => {
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<void> => {
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;