diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 2fd8a7e0..28a2948b 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -417,31 +417,57 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp const admin = await isAdmin(userId); const isSender = !!note && note.userId === userId; - // Authorization contract: any non-participant gets 403 (not 404), as long - // as someone is participating in the conversation. Only return 404 when - // there is no trace of the note at all (no live row, no recipient rows). + // Authorization contract: + // - admin can read any note that has any trace (live row OR recipient + // snapshots), even if the sender deleted their own copy; + // - non-participants get 403 when the conversation exists but they + // are not part of it; + // - 404 only when there is no trace of the note at all. + const [anyRecipientRow] = + !note && !recipientRow + ? await db + .select({ id: noteRecipientsTable.id }) + .from(noteRecipientsTable) + .where(eq(noteRecipientsTable.noteId, id.id)) + .limit(1) + : [null]; + if (!note && !recipientRow) { - const [otherRow] = await db - .select({ id: noteRecipientsTable.id }) - .from(noteRecipientsTable) - .where(eq(noteRecipientsTable.noteId, id.id)) - .limit(1); - if (otherRow) { + if (admin && anyRecipientRow) { + // fall through — admin reads from recipient snapshots below + } else if (anyRecipientRow) { res.status(403).json({ error: "Forbidden" }); return; + } else { + res.status(404).json({ error: "Note not found" }); + return; } - res.status(404).json({ error: "Note not found" }); - return; - } - if (!admin && !isSender && !recipientRow) { + } else if (!admin && !isSender && !recipientRow) { res.status(403).json({ error: "Forbidden" }); return; } + // For admin reading a deleted note (no live row, no recipient row of + // their own), fall back to ANY recipient snapshot so we can still render + // the conversation. + let adminFallbackRow: typeof recipientRow | null = null; + if (admin && !note && !recipientRow) { + const [r] = await db + .select() + .from(noteRecipientsTable) + .where(eq(noteRecipientsTable.noteId, id.id)) + .orderBy(noteRecipientsTable.id) + .limit(1); + adminFallbackRow = r ?? null; + } + const snapshotRow = recipientRow ?? adminFallbackRow; + // Recipient view reads its own immutable snapshot. Owner/admin view shows - // the live sender note (sender's editable copy). + // the live sender note when present; admin falls back to a snapshot when + // the live note has been deleted. const useSnapshot = !isSender && !admin && !!recipientRow; - const senderUserId = note?.userId ?? recipientRow!.senderUserId; + const senderUserId = + note?.userId ?? recipientRow?.senderUserId ?? snapshotRow?.senderUserId ?? 0; const senderMap = await loadUserSummaries([senderUserId]); const recipients = admin || isSender ? await loadRecipientsForNote(id.id) : []; @@ -455,15 +481,21 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp ); } + const titleFallback = snapshotRow?.title ?? note?.title ?? ""; + const contentFallback = snapshotRow?.content ?? note?.content ?? ""; + const colorFallback = snapshotRow?.color ?? note?.color ?? "default"; + res.json({ id: id.id, - title: useSnapshot ? recipientRow!.title : note?.title ?? "", - content: useSnapshot ? recipientRow!.content : note?.content ?? "", - color: useSnapshot ? recipientRow!.color : note?.color ?? "default", - createdAt: useSnapshot ? recipientRow!.sentAt : note?.createdAt ?? null, + title: useSnapshot ? recipientRow!.title : note?.title ?? titleFallback, + content: useSnapshot ? recipientRow!.content : note?.content ?? contentFallback, + color: useSnapshot ? recipientRow!.color : note?.color ?? colorFallback, + createdAt: useSnapshot + ? recipientRow!.sentAt + : note?.createdAt ?? snapshotRow?.sentAt ?? null, updatedAt: useSnapshot ? recipientRow!.readAt ?? recipientRow!.sentAt - : note?.updatedAt ?? null, + : note?.updatedAt ?? snapshotRow?.sentAt ?? null, senderUserId, sender: senderMap.get(senderUserId) ?? null, isOwner: isSender, diff --git a/artifacts/api-server/tests/notes-share.test.mjs b/artifacts/api-server/tests/notes-share.test.mjs index 11e14f90..abc58193 100644 --- a/artifacts/api-server/tests/notes-share.test.mjs +++ b/artifacts/api-server/tests/notes-share.test.mjs @@ -460,6 +460,29 @@ test("admin can read any note via GET /notes/:id and sees full thread", async () ); }); +test("admin can read a note whose sender deleted their copy (recipient snapshot remains)", async () => { + const sender = await createUser("notes_admdel_sender"); + const recipient = await createUser("notes_admdel_recipient"); + const adminUser = await createUser("notes_admdel_admin", "admin"); + const sCookie = await login(sender.username); + const aCookie = await login(adminUser.username); + + const note = await createNote(sCookie, { title: "WillDelete" }); + await api(`/notes/${note.id}/send`, sCookie, { + method: "POST", + body: JSON.stringify({ recipientUserIds: [recipient.id] }), + }); + // Sender deletes the live note row; recipient snapshot must remain. + await pool.query(`DELETE FROM notes WHERE id = $1`, [note.id]); + + const res = await api(`/notes/${note.id}`, aCookie); + assert.equal(res.status, 200, "admin must still read the thread after sender delete"); + const body = await res.json(); + assert.equal(body.isAdmin, true); + assert.equal(body.title, "WillDelete", "admin sees recipient snapshot title"); + assert.ok(body.recipients.length >= 1, "admin sees recipient list"); +}); + test("GET /notes/my returns the same payload as GET /notes (alias)", async () => { const owner = await createUser("notes_my_owner"); const cookie = await login(owner.username);