Task #512: Per-recipient Delete in Notes Inbox

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.
This commit is contained in:
riyadhafraa
2026-05-12 11:14:33 +00:00
parent 6878cdfe7c
commit 0988585c65
7 changed files with 818 additions and 14 deletions
+93
View File
@@ -691,6 +691,99 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
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<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(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<void> => {
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<number>();
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<number>();
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<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
@@ -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");
});
+30
View File
@@ -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({
+9
View File
@@ -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": "لا توجد ملاحظات في الأرشيف",
+9
View File
@@ -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",
+213 -14
View File
@@ -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<Set<number>>(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() {
<Archive size={14} />
{t("notes.bulkArchive", "Archive")}
</button>
{/* 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. */}
<button
type="button"
onClick={() => setBulkDeleteInboxOpen(true)}
disabled={
selectedInboxIds.size === 0 ||
bulkArchiveInbox.isPending ||
bulkDeleteInbox.isPending
}
data-testid="inbox-bulk-delete"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 size={14} />
{t("notes.bulkDelete", "Delete")}
</button>
</div>
</div>
)}
@@ -1206,6 +1234,95 @@ export default function NotesPage() {
</AlertDialogContent>
</AlertDialog>
{/* 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. */}
<AlertDialog
open={bulkDeleteInboxOpen}
onOpenChange={(open) => {
if (!bulkDeleteInbox.isPending) setBulkDeleteInboxOpen(open);
}}
>
<AlertDialogContent data-testid="inbox-bulk-delete-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t(
selectedInboxIds.size === 1
? "notes.bulkDeleteInboxConfirmTitle_one"
: "notes.bulkDeleteInboxConfirmTitle",
{ n: selectedInboxIds.size },
)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"notes.bulkDeleteInboxConfirmBody",
"This removes the notes from your inbox and archive. The sender keeps their copy.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkDeleteInbox.isPending}>
{t("notes.bulkCancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="inbox-bulk-delete-confirm-button"
disabled={bulkDeleteInbox.isPending}
className="bg-rose-600 hover:bg-rose-700 text-white"
onClick={async (e) => {
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")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<LabelsDialog
open={labelsDialogOpen}
onClose={() => 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 (
<>
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent
className={`max-w-lg max-h-[85vh] flex flex-col ${thread ? colorBg(thread.color) : ""}`}
@@ -2666,19 +2789,36 @@ function ThreadDialog({
/>
<div className="flex items-center justify-between">
{!thread.isOwner ? (
<Button
size="sm"
variant="ghost"
onClick={() => {
archive.mutate(
{ id: noteId, archived: true },
{ onSuccess: onClose },
);
}}
>
<Archive size={14} className="me-1" />
{t("notes.archive", "Archive")}
</Button>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
data-testid="thread-archive"
onClick={() => {
archive.mutate(
{ id: noteId, archived: true },
{ onSuccess: onClose },
);
}}
>
<Archive size={14} className="me-1" />
{t("notes.archive", "Archive")}
</Button>
{/* 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. */}
<Button
size="sm"
variant="ghost"
data-testid="thread-delete"
className="text-rose-600 hover:text-rose-700 hover:bg-rose-50"
onClick={() => setSingleDeleteOpen(true)}
>
<Trash2 size={14} className="me-1" />
{t("notes.delete", "Delete")}
</Button>
</div>
) : (
<span />
)}
@@ -2704,6 +2844,65 @@ function ThreadDialog({
)}
</DialogContent>
</Dialog>
{/* Task #512: single-note delete confirm rendered as a sibling so
it overlays cleanly above the thread dialog. */}
<AlertDialog
open={singleDeleteOpen}
onOpenChange={(open) => {
if (!deleteReceived.isPending) setSingleDeleteOpen(open);
}}
>
<AlertDialogContent data-testid="thread-delete-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("notes.deleteInboxConfirmTitle", "Delete this note?")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"notes.bulkDeleteInboxConfirmBody",
"This removes the notes from your inbox and archive. The sender keeps their copy.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteReceived.isPending}>
{t("notes.bulkCancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="thread-delete-confirm-button"
disabled={deleteReceived.isPending}
className="bg-rose-600 hover:bg-rose-700 text-white"
onClick={async (e) => {
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")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -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);
});