From 0988585c65f8a919642d8d6e9523a421fa642693 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 12 May 2026 11:14:33 +0000 Subject: [PATCH] Task #512: Per-recipient Delete in Notes Inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a recipient-scoped delete that's distinct from Archive: a recipient can remove a note from their own inbox without touching the underlying note or other recipients' rows. Backend (artifacts/api-server/src/routes/notes.ts): - DELETE /notes/received/:id — recipient-only; 404 if no row. - POST /notes/received/bulk-delete — body {ids:number[]}, max 500, single SQL DELETE, returns {ok, notFound} for partial-success UI. - Both registered before DELETE /notes/:id so Express matches /received first. Frontend (artifacts/tx-os): - New hooks useDeleteReceivedNote / useBulkDeleteReceivedNotes in src/lib/notes-api.ts; both invalidate notes + folders queries. - Inbox bulk bar gets a Delete button (rose) next to Archive plus a confirm AlertDialog with all/none/partial toasts (src/pages/notes.tsx). - ThreadDialog gets a per-row Delete next to Archive plus a single confirm dialog that closes the thread on success. - AR + EN locale strings added for all new copy. Tests: - artifacts/api-server/tests/notes-inbox-delete.test.mjs — recipient delete, non-recipient/sender 404, bulk mix of valid+missing ids, and DB-level checks that the note + other recipients survive. - artifacts/tx-os/tests/notes-inbox-bulk-delete.spec.mjs — Playwright flow seeds three received notes, bulk-deletes two from the inbox, then deletes the third via ThreadDialog per-row. --- artifacts/api-server/src/routes/notes.ts | 93 +++++++ .../tests/notes-inbox-delete.test.mjs | 255 ++++++++++++++++++ artifacts/tx-os/src/lib/notes-api.ts | 30 +++ artifacts/tx-os/src/locales/ar.json | 9 + artifacts/tx-os/src/locales/en.json | 9 + artifacts/tx-os/src/pages/notes.tsx | 227 +++++++++++++++- .../tests/notes-inbox-bulk-delete.spec.mjs | 209 ++++++++++++++ 7 files changed, 818 insertions(+), 14 deletions(-) create mode 100644 artifacts/api-server/tests/notes-inbox-delete.test.mjs create mode 100644 artifacts/tx-os/tests/notes-inbox-bulk-delete.spec.mjs diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 1ba20584..85d97e39 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -691,6 +691,99 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise => { res.json(withLabels); }); +// Task #512: per-recipient delete. The recipient does not own the +// underlying note, so a regular DELETE /notes/:id (which is owner-only) +// can't help them clear an unwanted incoming note. This endpoint +// removes ONLY the caller's own `note_recipients` row, leaving the +// note, the sender's copy, and every other recipient untouched. The +// caller must be the recipient — we don't honor requests from anyone +// else, even admins, because the row represents a personal inbox +// entry. +// +// Registered BEFORE the catch-all `DELETE /notes/:id` further down +// because Express matches in registration order; otherwise +// `parseIdParam("received")` would 400 the request. +router.delete( + "/notes/received/: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(noteRecipientsTable) + .where( + and( + eq(noteRecipientsTable.noteId, id.id), + eq(noteRecipientsTable.recipientUserId, userId), + ), + ) + .returning({ id: noteRecipientsTable.id }); + if (result.length === 0) { + res.status(404).json({ error: "Not found" }); + return; + } + res.json({ success: true }); + }, +); + +// Task #512: bulk variant. Accepts up to BULK_RECEIVED_DELETE_MAX ids +// in a single request and reports per-id success/notFound so the UI +// can render a precise toast even on partial misses (e.g. a note the +// sender just hard-deleted between page load and click). One SQL +// DELETE handles the whole batch — much cheaper than N round-trips. +const BULK_RECEIVED_DELETE_MAX = 500; +router.post( + "/notes/received/bulk-delete", + requireAuth, + async (req, res): Promise => { + const userId = req.session.userId!; + const raw = (req.body && (req.body as { ids?: unknown }).ids) ?? null; + if (!Array.isArray(raw)) { + res.status(400).json({ error: "ids must be an array" }); + return; + } + const ids: number[] = []; + const seen = new Set(); + for (const x of raw) { + const n = Number(x); + if (Number.isInteger(n) && n > 0 && !seen.has(n)) { + seen.add(n); + ids.push(n); + } + } + if (ids.length === 0) { + res.json({ ok: [], notFound: [] }); + return; + } + if (ids.length > BULK_RECEIVED_DELETE_MAX) { + res.status(400).json({ + error: `Too many ids (max ${BULK_RECEIVED_DELETE_MAX})`, + }); + return; + } + const deleted = await db + .delete(noteRecipientsTable) + .where( + and( + inArray(noteRecipientsTable.noteId, ids), + eq(noteRecipientsTable.recipientUserId, userId), + ), + ) + .returning({ noteId: noteRecipientsTable.noteId }); + const okSet = new Set(); + for (const r of deleted) { + if (typeof r.noteId === "number") okSet.add(r.noteId); + } + const ok = ids.filter((i) => okSet.has(i)); + const notFound = ids.filter((i) => !okSet.has(i)); + res.json({ ok, notFound }); + }, +); + router.delete("/notes/:id", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; const id = parseIdParam(req.params.id); diff --git a/artifacts/api-server/tests/notes-inbox-delete.test.mjs b/artifacts/api-server/tests/notes-inbox-delete.test.mjs new file mode 100644 index 00000000..c66f7d11 --- /dev/null +++ b/artifacts/api-server/tests/notes-inbox-delete.test.mjs @@ -0,0 +1,255 @@ +// Task #512: per-recipient inbox delete + bulk delete. +// A recipient can detach their note_recipients row (DELETE +// /notes/received/:id and bulk variant) without touching the +// underlying note or other recipients' rows. Non-recipients get 404. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +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) { + 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, prefix], + ); + 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 }; +} + +async function login(username) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password: TEST_PASSWORD }), + }); + assert.equal(res.status, 200, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + const sid = setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); + assert.ok(sid, "expected connect.sid cookie"); + return sid; +} + +async function api(path, cookie, init = {}) { + return fetch(`${API_BASE}/api${path}`, { + ...init, + headers: { + Cookie: cookie, + "Content-Type": "application/json", + ...(init.headers ?? {}), + }, + }); +} + +async function createNote(cookie, body) { + const res = await api("/notes", cookie, { + method: "POST", + body: JSON.stringify({ + title: "Hi", + content: "hello", + color: "blue", + ...body, + }), + }); + assert.ok(res.ok, `create note expected 2xx, got ${res.status}`); + const json = await res.json(); + createdNoteIds.push(json.id); + return json; +} + +async function sendTo(cookie, noteId, recipientUserIds) { + const res = await api(`/notes/${noteId}/send`, cookie, { + method: "POST", + body: JSON.stringify({ recipientUserIds }), + }); + assert.ok(res.ok, `send expected 2xx, got ${res.status}`); + return res.json(); +} + +after(async () => { + if (createdNoteIds.length > 0) { + await pool.query( + `DELETE FROM note_replies WHERE note_id = ANY($1::int[])`, + [createdNoteIds], + ); + await pool.query( + `DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`, + [createdNoteIds], + ); + await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [ + createdNoteIds, + ]); + } + if (createdUserIds.length > 0) { + await pool.query( + `DELETE FROM notifications 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(); +}); + +test("recipient can DELETE /notes/received/:id; row is gone, note + other recipients survive", async () => { + const sender = await createUser("ndel_s"); + const r1 = await createUser("ndel_r1"); + const r2 = await createUser("ndel_r2"); + const sCookie = await login(sender.username); + const r1Cookie = await login(r1.username); + const r2Cookie = await login(r2.username); + + const note = await createNote(sCookie); + await sendTo(sCookie, note.id, [r1.id, r2.id]); + + // r1 deletes their inbox row. + const delRes = await api(`/notes/received/${note.id}`, r1Cookie, { + method: "DELETE", + }); + assert.ok(delRes.ok, `delete expected 2xx, got ${delRes.status}`); + + // r1's inbox no longer shows the note. + const r1Inbox = await (await api(`/notes/received`, r1Cookie)).json(); + assert.ok( + !r1Inbox.find((n) => n.id === note.id), + "r1 must no longer see the note in their inbox", + ); + + // The note row + r2's recipient row are intact. + const { rows: noteRows } = await pool.query( + `SELECT id FROM notes WHERE id = $1`, + [note.id], + ); + assert.equal(noteRows.length, 1, "note row must still exist"); + const { rows: r2Rows } = await pool.query( + `SELECT id FROM note_recipients WHERE note_id = $1 AND recipient_user_id = $2`, + [note.id, r2.id], + ); + assert.equal(r2Rows.length, 1, "r2 recipient row must be intact"); + + // r2 still sees the note in their inbox. + const r2Inbox = await (await api(`/notes/received`, r2Cookie)).json(); + assert.ok( + r2Inbox.find((n) => n.id === note.id), + "r2 must still see the note", + ); +}); + +test("non-recipient gets 404 on DELETE /notes/received/:id (does not leak)", async () => { + const sender = await createUser("ndel_nrs"); + const recipient = await createUser("ndel_nrr"); + const stranger = await createUser("ndel_nrx"); + const sCookie = await login(sender.username); + const xCookie = await login(stranger.username); + + const note = await createNote(sCookie); + await sendTo(sCookie, note.id, [recipient.id]); + + // Stranger has no recipient row → 404. + const res = await api(`/notes/received/${note.id}`, xCookie, { + method: "DELETE", + }); + assert.equal(res.status, 404, "stranger delete must be 404"); + + // Sender (owner, but not a recipient) also has no recipient row → 404. + const res2 = await api(`/notes/received/${note.id}`, sCookie, { + method: "DELETE", + }); + assert.equal(res2.status, 404, "sender-as-non-recipient must be 404"); + + // Recipient row still intact. + const { rows } = await pool.query( + `SELECT id FROM note_recipients WHERE note_id = $1 AND recipient_user_id = $2`, + [note.id, recipient.id], + ); + assert.equal(rows.length, 1, "recipient row must remain after stranger 404"); +}); + +test("bulk-delete removes only the caller's rows; mix of valid + missing is reported via notFound", async () => { + const sender = await createUser("ndel_bs"); + const r1 = await createUser("ndel_br1"); + const other = await createUser("ndel_bo"); + const sCookie = await login(sender.username); + const r1Cookie = await login(r1.username); + + const noteA = await createNote(sCookie); + const noteB = await createNote(sCookie); + const noteC = await createNote(sCookie); // r1 is NOT a recipient + await sendTo(sCookie, noteA.id, [r1.id, other.id]); + await sendTo(sCookie, noteB.id, [r1.id]); + await sendTo(sCookie, noteC.id, [other.id]); + + const res = await api(`/notes/received/bulk-delete`, r1Cookie, { + method: "POST", + body: JSON.stringify({ ids: [noteA.id, noteB.id, noteC.id, 999999999] }), + }); + assert.ok(res.ok, `bulk-delete expected 2xx, got ${res.status}`); + const body = await res.json(); + assert.deepEqual( + [...body.ok].sort((a, b) => a - b), + [noteA.id, noteB.id].sort((a, b) => a - b), + "must report A and B as successfully deleted", + ); + assert.deepEqual( + [...body.notFound].sort((a, b) => a - b), + [noteC.id, 999999999].sort((a, b) => a - b), + "non-recipient + missing ids must be reported", + ); + + // r1 inbox no longer has A or B. + const r1Inbox = await (await api(`/notes/received`, r1Cookie)).json(); + assert.ok( + !r1Inbox.find((n) => n.id === noteA.id), + "noteA must be gone from r1 inbox", + ); + assert.ok( + !r1Inbox.find((n) => n.id === noteB.id), + "noteB must be gone from r1 inbox", + ); + + // Other recipient on noteA still has their row. + const { rows: otherRows } = await pool.query( + `SELECT id FROM note_recipients WHERE note_id = $1 AND recipient_user_id = $2`, + [noteA.id, other.id], + ); + assert.equal(otherRows.length, 1, "other recipient on noteA must survive"); + + // Underlying notes still exist (we only delete recipient rows). + const { rows: notes } = await pool.query( + `SELECT id FROM notes WHERE id = ANY($1::int[])`, + [[noteA.id, noteB.id, noteC.id]], + ); + assert.equal(notes.length, 3, "all underlying notes must remain"); +}); diff --git a/artifacts/tx-os/src/lib/notes-api.ts b/artifacts/tx-os/src/lib/notes-api.ts index dc0425dd..051a427e 100644 --- a/artifacts/tx-os/src/lib/notes-api.ts +++ b/artifacts/tx-os/src/lib/notes-api.ts @@ -471,6 +471,36 @@ export function useBulkArchiveReceivedNotes() { }); } +// Task #512: per-recipient delete. Removes only the caller's own +// recipient row — sender, note, and other recipients are untouched. +// Invalidates `["notes"]` so both the inbox and the archived view +// refresh immediately. +export function useDeleteReceivedNote() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + req<{ success: true }>(`/notes/received/${id}`, { method: "DELETE" }), + onSettled: () => invalidateNotesAndFolders(qc), + }); +} + +// Task #512: bulk variant. One POST handles the whole batch on the +// server (single SQL DELETE) and reports per-id ok/notFound so the +// UI can render a precise toast even on partial misses. Cache is +// invalidated once at the end so all surfaces (inbox, archived, +// unread badges) refresh. +export function useBulkDeleteReceivedNotes() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (ids: number[]) => + req<{ ok: number[]; notFound: number[] }>( + `/notes/received/bulk-delete`, + { method: "POST", body: JSON.stringify({ ids }) }, + ), + onSettled: () => invalidateNotesAndFolders(qc), + }); +} + export function useReplyToNote() { const qc = useQueryClient(); return useMutation({ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 8a2ffcab..d54c1ace 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -999,6 +999,15 @@ "bulkArchivedAll_one": "تمت أرشفة ملاحظة واحدة", "bulkArchivePartial": "تمت أرشفة {{ok}} من {{total}}، فشل {{failed}}", "bulkArchiveNone": "تعذّر أرشفة الملاحظات المحددة", + "bulkDeleteInboxConfirmTitle": "حذف {{n}} ملاحظة من الوارد؟", + "bulkDeleteInboxConfirmTitle_one": "حذف ملاحظة واحدة من الوارد؟", + "bulkDeleteInboxConfirmBody": "سيتم حذف الملاحظات من الوارد والأرشيف لديك. تبقى لدى المرسل نسخته.", + "bulkDeletedInboxAll": "تم حذف {{n}} ملاحظة من الوارد", + "bulkDeletedInboxAll_one": "تم حذف ملاحظة واحدة من الوارد", + "bulkDeleteInboxPartial": "تم حذف {{ok}} من {{total}}، و{{failed}} لم تعد موجودة", + "bulkDeleteInboxNone": "تعذّر حذف الملاحظات المحددة", + "deleteInboxConfirmTitle": "حذف هذه الملاحظة من الوارد؟", + "deletedInboxOne": "تم حذف الملاحظة من الوارد", "moreActions": "خيارات أخرى", "empty": "ستظهر ملاحظاتك هنا", "emptyArchived": "لا توجد ملاحظات في الأرشيف", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index b258c074..9503c901 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -907,6 +907,15 @@ "bulkArchivedAll_one": "Archived 1 note", "bulkArchivePartial": "Archived {{ok}} of {{total}}; {{failed}} failed", "bulkArchiveNone": "Could not archive the selected notes", + "bulkDeleteInboxConfirmTitle": "Delete {{n}} inbox notes?", + "bulkDeleteInboxConfirmTitle_one": "Delete 1 inbox note?", + "bulkDeleteInboxConfirmBody": "This removes the notes from your inbox and archive. The sender keeps their copy.", + "bulkDeletedInboxAll": "Deleted {{n}} notes from your inbox", + "bulkDeletedInboxAll_one": "Deleted 1 note from your inbox", + "bulkDeleteInboxPartial": "Deleted {{ok}} of {{total}}; {{failed}} were already gone", + "bulkDeleteInboxNone": "Could not delete the selected notes", + "deleteInboxConfirmTitle": "Delete this note from your inbox?", + "deletedInboxOne": "Note removed from your inbox", "moreActions": "More actions", "empty": "Your notes will appear here", "emptyArchived": "No archived notes", diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index 54d9c205..77ff76b7 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -81,6 +81,8 @@ import { colorAccent, useArchiveReceivedNote, useBulkArchiveReceivedNotes, + useDeleteReceivedNote, + useBulkDeleteReceivedNotes, useCreateLabel, useCreateNote, useDeleteLabel, @@ -232,6 +234,12 @@ export default function NotesPage() { const [selectedInboxIds, setSelectedInboxIds] = useState>(new Set()); const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false); const bulkArchiveInbox = useBulkArchiveReceivedNotes(); + // Task #512: bulk-delete for the Notes Inbox. Distinct from archive + // (which moves to the Archived tab) — this permanently removes the + // caller's own recipient rows so the notes disappear from both + // Inbox and Archived. The sender's copy is untouched. + const [bulkDeleteInboxOpen, setBulkDeleteInboxOpen] = useState(false); + const bulkDeleteInbox = useBulkDeleteReceivedNotes(); const toggleInboxSelected = useCallback((id: number) => { setSelectedInboxIds((prev) => { const next = new Set(prev); @@ -244,6 +252,7 @@ export default function NotesPage() { setInboxSelectionMode(false); setSelectedInboxIds(new Set()); setBulkArchiveOpen(false); + setBulkDeleteInboxOpen(false); }, []); useEffect(() => { if (view !== "received" && inboxSelectionMode) { @@ -921,7 +930,9 @@ export default function NotesPage() { type="button" onClick={() => setBulkArchiveOpen(true)} disabled={ - selectedInboxIds.size === 0 || bulkArchiveInbox.isPending + selectedInboxIds.size === 0 || + bulkArchiveInbox.isPending || + bulkDeleteInbox.isPending } data-testid="inbox-bulk-archive" className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-slate-900 text-white hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" @@ -929,6 +940,23 @@ export default function NotesPage() { {t("notes.bulkArchive", "Archive")} + {/* Task #512: bulk Delete sits next to Archive. It's + destructive and user-scoped — copy in the confirm + dialog clarifies the sender keeps their copy. */} + )} @@ -1206,6 +1234,95 @@ export default function NotesPage() { + {/* Task #512: confirm dialog for bulk-deleting inbox notes. The + copy makes clear it removes them from BOTH inbox + archive + and that the sender keeps their copy. Single POST to the + bulk endpoint; reports per-id ok/notFound. */} + { + if (!bulkDeleteInbox.isPending) setBulkDeleteInboxOpen(open); + }} + > + + + + {t( + selectedInboxIds.size === 1 + ? "notes.bulkDeleteInboxConfirmTitle_one" + : "notes.bulkDeleteInboxConfirmTitle", + { n: selectedInboxIds.size }, + )} + + + {t( + "notes.bulkDeleteInboxConfirmBody", + "This removes the notes from your inbox and archive. The sender keeps their copy.", + )} + + + + + {t("notes.bulkCancel", "Cancel")} + + { + e.preventDefault(); + const ids = Array.from(selectedInboxIds); + if (ids.length === 0) return; + try { + const res = await bulkDeleteInbox.mutateAsync(ids); + if (res.notFound.length === 0) { + toast({ + title: t( + res.ok.length === 1 + ? "notes.bulkDeletedInboxAll_one" + : "notes.bulkDeletedInboxAll", + { n: res.ok.length }, + ), + }); + } else if (res.ok.length === 0) { + toast({ + title: t( + "notes.bulkDeleteInboxNone", + "Could not delete the selected notes", + ), + variant: "destructive", + }); + } else { + toast({ + title: t("notes.bulkDeleteInboxPartial", { + ok: res.ok.length, + total: ids.length, + failed: res.notFound.length, + }), + variant: "destructive", + }); + } + } catch { + toast({ + title: t( + "notes.bulkDeleteInboxNone", + "Could not delete the selected notes", + ), + variant: "destructive", + }); + } finally { + exitInboxSelection(); + } + }} + > + {bulkDeleteInbox.isPending + ? t("notes.bulkDeleting", "Deleting...") + : t("notes.bulkDelete", "Delete")} + + + + + setLabelsDialogOpen(false)} @@ -2452,6 +2569,11 @@ function ThreadDialog({ const markRead = useMarkNoteRead(); const reply = useReplyToNote(); const archive = useArchiveReceivedNote(); + // Task #512: per-row delete from the open thread. Lives next to the + // existing Archive button so single-note delete doesn't require + // entering bulk-selection mode. + const deleteReceived = useDeleteReceivedNote(); + const [singleDeleteOpen, setSingleDeleteOpen] = useState(false); // Task #438: collaborative checklist toggle scoped to the open thread. const toggleChecklistItem = useToggleChecklistItem(); const [replyText, setReplyText] = useState(""); @@ -2512,6 +2634,7 @@ function ThreadDialog({ }; return ( + <> !o && onClose()}>
{!thread.isOwner ? ( - +
+ + {/* Task #512: per-row Delete sits next to Archive + in the thread footer. Confirms inline before + calling DELETE /notes/received/:id so users + don't lose a note by accident. */} + +
) : ( )} @@ -2704,6 +2844,65 @@ function ThreadDialog({ )}
+ {/* Task #512: single-note delete confirm rendered as a sibling so + it overlays cleanly above the thread dialog. */} + { + if (!deleteReceived.isPending) setSingleDeleteOpen(open); + }} + > + + + + {t("notes.deleteInboxConfirmTitle", "Delete this note?")} + + + {t( + "notes.bulkDeleteInboxConfirmBody", + "This removes the notes from your inbox and archive. The sender keeps their copy.", + )} + + + + + {t("notes.bulkCancel", "Cancel")} + + { + e.preventDefault(); + try { + await deleteReceived.mutateAsync(noteId); + toast({ + title: t( + "notes.deletedInboxOne", + "Note removed from your inbox", + ), + }); + setSingleDeleteOpen(false); + onClose(); + } catch { + toast({ + title: t( + "notes.bulkDeleteInboxNone", + "Could not delete the selected notes", + ), + variant: "destructive", + }); + } + }} + > + {deleteReceived.isPending + ? t("notes.bulkDeleting", "Deleting...") + : t("notes.delete", "Delete")} + + + + + ); } diff --git a/artifacts/tx-os/tests/notes-inbox-bulk-delete.spec.mjs b/artifacts/tx-os/tests/notes-inbox-bulk-delete.spec.mjs new file mode 100644 index 00000000..6becad70 --- /dev/null +++ b/artifacts/tx-os/tests/notes-inbox-bulk-delete.spec.mjs @@ -0,0 +1,209 @@ +// Task #512 e2e: per-recipient delete on the Inbox tab. +// Seeds three received notes for a recipient, opens Notes (Inbox is +// the default view), enters bulk-select mode, picks two cards, +// confirms the bulk delete, and verifies that the deleted recipient +// rows are gone from the inbox while the unselected one remains. +// Then the third row is removed via the per-row Delete in +// ThreadDialog. Direct-DB seed mirrors notes-sent-bulk-delete.spec.mjs. +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, displayEn }; +} + +async function createNote(userId, title, content) { + const { rows } = await pool.query( + `INSERT INTO notes (user_id, title, content, color, kind) + VALUES ($1, $2, $3, 'yellow', 'text') RETURNING id, title, content, color, kind`, + [userId, title, content], + ); + const note = rows[0]; + createdNoteIds.push(note.id); + return note; +} + +async function addRecipient(note, senderId, recipientId) { + await pool.query( + `INSERT INTO note_recipients + (note_id, sender_user_id, recipient_user_id, title, content, color, kind, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'unread')`, + [note.id, senderId, recipientId, note.title, note.content, note.color, note.kind], + ); +} + +test.afterAll(async () => { + if (createdNoteIds.length > 0) { + await pool.query( + `DELETE FROM note_replies WHERE note_id = ANY($1::int[])`, + [createdNoteIds], + ); + await pool.query( + `DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`, + [createdNoteIds], + ); + await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [ + createdNoteIds, + ]); + } + if (createdUserIds.length > 0) { + await pool.query( + `DELETE FROM notes WHERE user_id = ANY($1::int[])`, + [createdUserIds], + ); + await pool.query( + `DELETE FROM notifications 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(), + ]); +} + +test("Inbox tab: bulk delete two notes, third deleted via thread per-row", async ({ + page, +}) => { + const sender = await createUser("inbox_del_sender", "Sender Del"); + const recipient = await createUser("inbox_del_recipient", "Recipient Del"); + + // Seed three received notes for the recipient. + const tag = uniq(); + const noteA = await createNote(sender.id, `Inbox A ${tag}`, "alpha"); + const noteB = await createNote(sender.id, `Inbox B ${tag}`, "beta"); + const noteC = await createNote(sender.id, `Inbox C ${tag}`, "gamma"); + await addRecipient(noteA, sender.id, recipient.id); + await addRecipient(noteB, sender.id, recipient.id); + await addRecipient(noteC, sender.id, recipient.id); + + await loginViaUi(page, recipient.username); + + // Switch to Inbox view. + await page.goto("/notes"); + await expect(page.getByTestId("notes-page")).toBeVisible(); + await page.getByTestId("notes-overflow-menu-trigger").click(); + await page.getByTestId("notes-overflow-received").click(); + + const cardA = page.getByTestId(`received-note-card-${noteA.id}`); + const cardB = page.getByTestId(`received-note-card-${noteB.id}`); + const cardC = page.getByTestId(`received-note-card-${noteC.id}`); + await expect(cardA).toBeVisible(); + await expect(cardB).toBeVisible(); + await expect(cardC).toBeVisible(); + + // Enter bulk-select; pick A + B (leave C). + await page.getByTestId("inbox-bulk-select-toggle").click(); + await expect(page.getByTestId("inbox-bulk-bar")).toBeVisible(); + await cardA.click(); + await cardB.click(); + await expect(page.getByTestId("inbox-bulk-count")).toContainText("2"); + + // Confirm bulk delete. + await page.getByTestId("inbox-bulk-delete").click(); + await expect(page.getByTestId("inbox-bulk-delete-confirm")).toBeVisible(); + + const bulkDeleted = page.waitForResponse( + (r) => + new URL(r.url()).pathname === "/api/notes/received/bulk-delete" && + r.request().method() === "POST" && + r.status() >= 200 && + r.status() < 300, + { timeout: 15_000 }, + ); + await page.getByTestId("inbox-bulk-delete-confirm-button").click(); + await bulkDeleted; + + await expect(cardA).toBeHidden(); + await expect(cardB).toBeHidden(); + await expect(cardC).toBeVisible(); + + // The recipient rows for A + B are gone but the underlying notes remain + // (sender still owns them). + const { rows: recRows } = await pool.query( + `SELECT note_id FROM note_recipients + WHERE note_id = ANY($1::int[]) AND recipient_user_id = $2`, + [[noteA.id, noteB.id, noteC.id], recipient.id], + ); + const remaining = recRows.map((r) => r.note_id); + expect(remaining).toEqual([noteC.id]); + const { rows: noteRows } = await pool.query( + `SELECT id FROM notes WHERE id = ANY($1::int[])`, + [[noteA.id, noteB.id, noteC.id]], + ); + expect(noteRows.length).toEqual(3); + + // Per-row delete: open C in ThreadDialog and use the Delete button. + await cardC.click(); + await expect(page.getByTestId("note-thread-dialog")).toBeVisible(); + await page.getByTestId("thread-delete").click(); + await expect(page.getByTestId("thread-delete-confirm")).toBeVisible(); + + const singleDeleted = page.waitForResponse( + (r) => + new URL(r.url()).pathname === `/api/notes/received/${noteC.id}` && + r.request().method() === "DELETE" && + r.status() >= 200 && + r.status() < 300, + { timeout: 15_000 }, + ); + await page.getByTestId("thread-delete-confirm-button").click(); + await singleDeleted; + + await expect(page.getByTestId("note-thread-dialog")).toBeHidden(); + await expect(cardC).toBeHidden(); + + const { rows: recRows2 } = await pool.query( + `SELECT note_id FROM note_recipients + WHERE note_id = $1 AND recipient_user_id = $2`, + [noteC.id, recipient.id], + ); + expect(recRows2.length).toEqual(0); +});