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