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:
@@ -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");
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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": "لا توجد ملاحظات في الأرشيف",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user