Task #402: Convert Notes into in-app messaging (independence fix)

Original task: turn personal Notes into in-app messaging — sender composes a
note (title/content/color), picks recipient(s), Send. Recipients get an Inbox
with sender name, color, Read/Unread badge, and inline reply. Sender sees
Sent Notes with per-recipient status. Sender's and recipient's copies must
be INDEPENDENT, with proper backend access checks (admin sees everything),
realtime updates, and full i18n + RTL.

Initial implementation review FAILED because the recipient view still read
from the sender's notes table, so sender edits/deletes mutated recipient
copies. This commit completes the fix:

- Schema (lib/db/src/schema/notes.ts): added immutable snapshot columns
  (title/content/color) on note_recipients; dropped the FK on
  note_recipients.note_id and note_replies.note_id and made them plain
  integers so recipient threads survive the sender deleting their note.
- Routes (artifacts/api-server/src/routes/notes.ts):
  - /notes/received and /notes/:id/thread now serve the recipient snapshot
    (sender/admin still see the live note).
  - /notes/:id/reply derives the owner from note_recipients.senderUserId
    so it works after sender deletion, clears archivedAt, and bumps
    status to "replied".
  - /notes/sent filters out null noteIds.
- Tests (artifacts/api-server/tests/notes-share.test.mjs): added
  snapshot-independence test (sender edit + delete must not mutate
  recipient copy; thread + reply still work after sender delete) and
  archived-reply-clears-archivedAt test. All 5 backend tests pass; the
  existing notes-inbox e2e still passes.
- Architect review: PASS (conditional only on the schema migration being
  applied, which has been pushed via `pnpm --filter @workspace/db push`).

Pre-existing executive-meetings TS errors and the failing `test` workflow
are unrelated to this task.
This commit is contained in:
riyadhafraa
2026-05-05 14:53:30 +00:00
parent c0d9e30b91
commit 6352cbf844
9 changed files with 2004 additions and 77 deletions
+495 -15
View File
@@ -1,15 +1,29 @@
import { Router, type IRouter } from "express";
import { eq, and, desc, inArray } from "drizzle-orm";
import { eq, and, desc, inArray, or, sql } from "drizzle-orm";
import { db } from "@workspace/db";
import {
notesTable,
noteLabelsTable,
noteLabelAssignmentsTable,
noteRecipientsTable,
noteRepliesTable,
usersTable,
notificationsTable,
} from "@workspace/db";
import { requireAuth } from "../middlewares/auth";
import { requireAuth, getUserRoles } from "../middlewares/auth";
const router: IRouter = Router();
async function emitToUser(userId: number, event: string, payload?: unknown) {
const { io } = await import("../index.js");
io.to(`user:${userId}`).emit(event, payload);
}
async function isAdmin(userId: number): Promise<boolean> {
const roles = await getUserRoles(userId);
return roles.includes("admin");
}
function parseIdParam(raw: unknown): { ok: true; id: number } | { ok: false; error: string } {
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) return { ok: false, error: "Invalid id" };
@@ -121,6 +135,81 @@ async function setNoteLabels(noteId: number, userId: number, labelIds: number[])
);
}
type UserSummary = {
id: number;
username: string;
displayNameAr: string | null;
displayNameEn: string | null;
};
async function loadUserSummaries(userIds: number[]): Promise<Map<number, UserSummary>> {
const unique = Array.from(new Set(userIds));
if (unique.length === 0) return new Map();
const rows = await db
.select({
id: usersTable.id,
username: usersTable.username,
displayNameAr: usersTable.displayNameAr,
displayNameEn: usersTable.displayNameEn,
})
.from(usersTable)
.where(inArray(usersTable.id, unique));
return new Map(rows.map((r) => [r.id, r]));
}
async function loadRecipientsForNote(noteId: number) {
const rows = await db
.select()
.from(noteRecipientsTable)
.where(eq(noteRecipientsTable.noteId, noteId))
.orderBy(noteRecipientsTable.id);
const userMap = await loadUserSummaries(rows.map((r) => r.recipientUserId));
return rows.map((r) => ({
id: r.id,
noteId: r.noteId,
recipientUserId: r.recipientUserId,
status: r.status,
sentAt: r.sentAt,
readAt: r.readAt,
archivedAt: r.archivedAt,
title: r.title,
content: r.content,
color: r.color,
recipient: userMap.get(r.recipientUserId) ?? null,
}));
}
async function loadRepliesForNote(noteId: number) {
const rows = await db
.select()
.from(noteRepliesTable)
.where(eq(noteRepliesTable.noteId, noteId))
.orderBy(noteRepliesTable.createdAt);
const userMap = await loadUserSummaries(rows.map((r) => r.senderUserId));
return rows.map((r) => ({
id: r.id,
noteId: r.noteId,
senderUserId: r.senderUserId,
recipientUserId: r.recipientUserId,
content: r.content,
createdAt: r.createdAt,
sender: userMap.get(r.senderUserId) ?? null,
}));
}
async function loadOne(noteId: number, userId: number) {
const [note] = await db
.select()
.from(notesTable)
.where(and(eq(notesTable.id, noteId), eq(notesTable.userId, userId)));
if (!note) return [null];
const assignments = await db
.select()
.from(noteLabelAssignmentsTable)
.where(eq(noteLabelAssignmentsTable.noteId, noteId));
return [{ ...note, labelIds: assignments.map((a) => a.labelId) }];
}
router.get("/notes", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const archived = req.query.archived === "true";
@@ -181,19 +270,6 @@ router.patch("/notes/:id", requireAuth, async (req, res): Promise<void> => {
res.json(withLabels);
});
async function loadOne(noteId: number, userId: number) {
const [note] = await db
.select()
.from(notesTable)
.where(and(eq(notesTable.id, noteId), eq(notesTable.userId, userId)));
if (!note) return [null];
const assignments = await db
.select()
.from(noteLabelAssignmentsTable)
.where(eq(noteLabelAssignmentsTable.noteId, noteId));
return [{ ...note, labelIds: assignments.map((a) => a.labelId) }];
}
router.delete("/notes/:id", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
@@ -212,6 +288,410 @@ router.delete("/notes/:id", requireAuth, async (req, res): Promise<void> => {
res.json({ success: true });
});
// ----- Sent / Received / Detail -----
/**
* GET /notes/sent — notes the current user has authored AND has at least one
* recipient row for. Each note carries its full recipient list with status.
*/
router.get("/notes/sent", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const sentNoteIdRows = await db
.selectDistinct({ noteId: noteRecipientsTable.noteId })
.from(noteRecipientsTable)
.where(eq(noteRecipientsTable.senderUserId, userId));
const ids = sentNoteIdRows
.map((r) => r.noteId)
.filter((n): n is number => typeof n === "number");
if (ids.length === 0) {
res.json([]);
return;
}
const notes = await db
.select()
.from(notesTable)
.where(and(eq(notesTable.userId, userId), inArray(notesTable.id, ids)))
.orderBy(desc(notesTable.updatedAt));
const out = [] as any[];
for (const n of notes) {
const recipients = await loadRecipientsForNote(n.id);
const replyCount = await db
.select({ c: sql<number>`count(*)::int` })
.from(noteRepliesTable)
.where(eq(noteRepliesTable.noteId, n.id));
out.push({
...n,
recipients,
replyCount: replyCount[0]?.c ?? 0,
});
}
res.json(out);
});
/**
* GET /notes/received — notes that were sent TO the current user (not archived
* by them). Each entry carries the sender summary and this user's status row.
*/
router.get("/notes/received", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const includeArchived = req.query.archived === "true";
const recipientRows = await db
.select()
.from(noteRecipientsTable)
.where(
and(
eq(noteRecipientsTable.recipientUserId, userId),
includeArchived
? eq(noteRecipientsTable.status, "archived")
: sql`${noteRecipientsTable.status} <> 'archived'`,
),
)
.orderBy(desc(noteRecipientsTable.sentAt));
if (recipientRows.length === 0) {
res.json([]);
return;
}
// Recipients always see the snapshot they were sent — independent of any
// sender-side edit/delete. The note id is exposed only as a thread routing
// key (used by /notes/:id/thread, /read, /archive, /reply); the rendered
// body comes from the recipient row's title/content/color snapshot.
const senderMap = await loadUserSummaries(recipientRows.map((r) => r.senderUserId));
const out = recipientRows.map((r) => ({
id: r.noteId ?? r.id,
title: r.title,
content: r.content,
color: r.color,
createdAt: r.sentAt,
updatedAt: r.readAt ?? r.sentAt,
sender: senderMap.get(r.senderUserId) ?? null,
recipientRowId: r.id,
status: r.status,
sentAt: r.sentAt,
readAt: r.readAt,
archivedAt: r.archivedAt,
}));
res.json(out);
});
/**
* GET /notes/:id/thread — full detail of a sent/received note: note body,
* full recipient list (sender or admin only), and full reply thread filtered
* to what this user is allowed to see (admin/sender = all; recipient = only
* their own thread with the sender).
*/
router.get("/notes/:id/thread", 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 [note] = await db.select().from(notesTable).where(eq(notesTable.id, id.id));
const [recipientRow] = await db
.select()
.from(noteRecipientsTable)
.where(
and(
eq(noteRecipientsTable.noteId, id.id),
eq(noteRecipientsTable.recipientUserId, userId),
),
);
const admin = await isAdmin(userId);
const isSender = !!note && note.userId === userId;
if (!note && !recipientRow) {
res.status(404).json({ error: "Note not found" });
return;
}
if (!admin && !isSender && !recipientRow) {
res.status(403).json({ error: "Forbidden" });
return;
}
// Recipient view reads its own immutable snapshot. Owner/admin view shows
// the live sender note (sender's editable copy).
const useSnapshot = !isSender && !admin && !!recipientRow;
const senderUserId = note?.userId ?? recipientRow!.senderUserId;
const senderMap = await loadUserSummaries([senderUserId]);
const recipients = admin || isSender ? await loadRecipientsForNote(id.id) : [];
let replies = await loadRepliesForNote(id.id);
if (!admin && !isSender && recipientRow) {
replies = replies.filter(
(r) =>
(r.senderUserId === userId && r.recipientUserId === senderUserId) ||
(r.senderUserId === senderUserId && r.recipientUserId === userId),
);
}
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,
updatedAt: useSnapshot
? recipientRow!.readAt ?? recipientRow!.sentAt
: note?.updatedAt ?? null,
senderUserId,
sender: senderMap.get(senderUserId) ?? null,
isOwner: isSender,
isAdmin: admin,
myStatus: recipientRow?.status ?? null,
recipients,
replies,
});
});
// ----- Send -----
/**
* POST /notes/:id/send — owner sends an existing note to a list of recipients.
* Self-send and dupes are filtered out. Existing recipients are silently
* skipped (idempotent).
*/
router.post("/notes/:id/send", 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 [note] = await db
.select()
.from(notesTable)
.where(and(eq(notesTable.id, id.id), eq(notesTable.userId, userId)));
if (!note) {
res.status(404).json({ error: "Note not found" });
return;
}
const raw = (req.body && req.body.recipientUserIds) || [];
if (!Array.isArray(raw)) {
res.status(400).json({ error: "Invalid recipientUserIds" });
return;
}
const ids = Array.from(
new Set(
raw
.map((x: unknown) => Number(x))
.filter((n: number) => Number.isInteger(n) && n > 0 && n !== userId),
),
);
if (ids.length === 0) {
res.status(400).json({ error: "No valid recipients" });
return;
}
const existing = await db
.select({ id: usersTable.id })
.from(usersTable)
.where(and(inArray(usersTable.id, ids), eq(usersTable.isActive, true)));
const existingIds = new Set(existing.map((u) => u.id));
const toSend = ids.filter((i) => existingIds.has(i));
if (toSend.length === 0) {
res.status(400).json({ error: "No active recipients" });
return;
}
// Snapshot the note as of right now into each recipient row so that any
// future sender edit/delete leaves delivered copies untouched.
const inserted = await db
.insert(noteRecipientsTable)
.values(
toSend.map((rid) => ({
noteId: note.id,
senderUserId: userId,
recipientUserId: rid,
title: note.title,
content: note.content,
color: note.color,
})),
)
.onConflictDoNothing({
target: [noteRecipientsTable.noteId, noteRecipientsTable.recipientUserId],
})
.returning();
// Notify each newly-added recipient via per-user socket + persistent
// notifications row so they get a chime + badge bump.
const senderMap = await loadUserSummaries([userId]);
const sender = senderMap.get(userId);
const senderName = sender?.displayNameEn ?? sender?.username ?? "Someone";
const senderNameAr = sender?.displayNameAr ?? senderName;
for (const r of inserted) {
const [notif] = await db
.insert(notificationsTable)
.values({
userId: r.recipientUserId,
titleAr: "ملاحظة جديدة",
titleEn: "New note",
bodyAr: `${senderNameAr} أرسل لك ملاحظة`,
bodyEn: `${senderName} sent you a note`,
type: "note",
})
.returning();
await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" });
await emitToUser(r.recipientUserId, "note_received", { noteId: note.id });
}
res.json({ success: true, sent: inserted.length });
});
// ----- Read / Archive (recipient-side) -----
router.post("/notes/:id/read", 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 [row] = await db
.select()
.from(noteRecipientsTable)
.where(
and(
eq(noteRecipientsTable.noteId, id.id),
eq(noteRecipientsTable.recipientUserId, userId),
),
);
if (!row) {
res.status(403).json({ error: "Forbidden" });
return;
}
// Only flip unread -> read; never overwrite "replied" downward.
if (row.status === "unread") {
await db
.update(noteRecipientsTable)
.set({ status: "read", readAt: new Date() })
.where(eq(noteRecipientsTable.id, row.id));
await emitToUser(row.senderUserId, "note_status_changed", {
noteId: id.id,
recipientUserId: userId,
status: "read",
});
}
res.json({ success: true });
});
router.post("/notes/:id/archive", 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 archived = req.body && req.body.archived !== false;
const [row] = await db
.select()
.from(noteRecipientsTable)
.where(
and(
eq(noteRecipientsTable.noteId, id.id),
eq(noteRecipientsTable.recipientUserId, userId),
),
);
if (!row) {
res.status(403).json({ error: "Forbidden" });
return;
}
await db
.update(noteRecipientsTable)
.set({
status: archived ? "archived" : (row.readAt ? "read" : "unread"),
archivedAt: archived ? new Date() : null,
})
.where(eq(noteRecipientsTable.id, row.id));
res.json({ success: true });
});
// ----- Reply -----
router.post("/notes/:id/reply", 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 content = typeof req.body?.content === "string" ? req.body.content.trim() : "";
if (content.length === 0 || content.length > 5000) {
res.status(400).json({ error: "Invalid content" });
return;
}
const [row] = await db
.select()
.from(noteRecipientsTable)
.where(
and(
eq(noteRecipientsTable.noteId, id.id),
eq(noteRecipientsTable.recipientUserId, userId),
),
);
if (!row) {
res.status(403).json({ error: "Forbidden" });
return;
}
const ownerUserId = row.senderUserId;
const [reply] = await db
.insert(noteRepliesTable)
.values({
noteId: id.id,
senderUserId: userId,
recipientUserId: ownerUserId,
content,
})
.returning();
// Replying re-activates an archived recipient row: clear archivedAt and
// bump status to "replied" so the conversation continues cleanly.
await db
.update(noteRecipientsTable)
.set({
status: "replied",
readAt: row.readAt ?? new Date(),
archivedAt: null,
})
.where(eq(noteRecipientsTable.id, row.id));
// Notify the original sender (note owner).
const senderMap = await loadUserSummaries([userId]);
const replier = senderMap.get(userId);
const replierName = replier?.displayNameEn ?? replier?.username ?? "Someone";
const replierNameAr = replier?.displayNameAr ?? replierName;
const [notif] = await db
.insert(notificationsTable)
.values({
userId: ownerUserId,
titleAr: "رد على ملاحظتك",
titleEn: "Reply to your note",
bodyAr: `${replierNameAr} رد على ملاحظتك`,
bodyEn: `${replierName} replied to your note`,
type: "note",
})
.returning();
await emitToUser(ownerUserId, "notification_created", { ...notif, type: "note" });
await emitToUser(ownerUserId, "note_replied", {
noteId: id.id,
recipientUserId: userId,
replyId: reply.id,
});
res.status(201).json({
id: reply.id,
noteId: reply.noteId,
senderUserId: reply.senderUserId,
recipientUserId: reply.recipientUserId,
content: reply.content,
createdAt: reply.createdAt,
sender: replier ?? null,
});
});
// ----- Labels (unchanged) -----
router.get("/note-labels", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const labels = await db
@@ -0,0 +1,342 @@
// API tests for the in-app messaging flow built on top of personal notes.
// Covers POST /api/notes/:id/send, GET /api/notes/sent, GET /api/notes/received,
// GET /api/notes/:id/thread, POST /api/notes/:id/read, POST /api/notes/:id/reply,
// and the access-control rules around them.
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 world",
color: "blue",
...body,
}),
});
assert.ok(
res.status === 200 || res.status === 201,
`create note expected 2xx, got ${res.status}`,
);
const json = await res.json();
createdNoteIds.push(json.id);
return 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("send → received → read → reply produces independent sender / recipient views", async () => {
const sender = await createUser("notes_sender");
const r1 = await createUser("notes_r1");
const r2 = await createUser("notes_r2");
const sCookie = await login(sender.username);
const r1Cookie = await login(r1.username);
const r2Cookie = await login(r2.username);
const note = await createNote(sCookie);
// Send to both recipients (and try to include sender's own id to confirm it
// gets filtered out).
const sendRes = await api(`/notes/${note.id}/send`, sCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [r1.id, r2.id, sender.id] }),
});
assert.equal(sendRes.status, 200);
const sendBody = await sendRes.json();
assert.equal(sendBody.success, true);
assert.equal(sendBody.sent, 2, "self-recipient must be excluded from the send");
// Sender's "Sent" view shows the note with two unread recipients.
const sentRes = await api(`/notes/sent`, sCookie);
assert.equal(sentRes.status, 200);
const sent = await sentRes.json();
const sentNote = sent.find((n) => n.id === note.id);
assert.ok(sentNote, "sent note must appear in /notes/sent");
assert.equal(sentNote.recipients.length, 2);
assert.ok(
sentNote.recipients.every((r) => r.status === "unread"),
"all recipients should start as unread",
);
// r1 sees the note in their inbox.
const r1Inbox = await (await api(`/notes/received`, r1Cookie)).json();
const r1Note = r1Inbox.find((n) => n.id === note.id);
assert.ok(r1Note, "r1 must see the note in their inbox");
assert.equal(r1Note.status, "unread");
assert.equal(r1Note.sender.id, sender.id);
// r1 marks read → status flips for that recipient row only.
const readRes = await api(`/notes/${note.id}/read`, r1Cookie, {
method: "POST",
});
assert.equal(readRes.status, 200);
const sentAfterRead = await (await api(`/notes/sent`, sCookie)).json();
const sentNote2 = sentAfterRead.find((n) => n.id === note.id);
const r1Row = sentNote2.recipients.find((r) => r.recipientUserId === r1.id);
const r2Row = sentNote2.recipients.find((r) => r.recipientUserId === r2.id);
assert.equal(r1Row.status, "read");
assert.equal(r2Row.status, "unread", "r2 must remain unread");
// r1 replies → status becomes 'replied' for r1 only, reply is visible.
const replyRes = await api(`/notes/${note.id}/reply`, r1Cookie, {
method: "POST",
body: JSON.stringify({ content: "got it, thanks" }),
});
assert.ok(
replyRes.status === 200 || replyRes.status === 201,
`reply expected 2xx, got ${replyRes.status}`,
);
const threadSender = await (
await api(`/notes/${note.id}/thread`, sCookie)
).json();
assert.equal(threadSender.isOwner, true);
assert.equal(threadSender.replies.length, 1);
assert.equal(threadSender.replies[0].content, "got it, thanks");
const r1RowAfter = threadSender.recipients.find(
(r) => r.recipientUserId === r1.id,
);
assert.equal(r1RowAfter.status, "replied");
// r2's recipient view must NOT show r1's reply (recipient sees only their
// own thread of replies).
const threadR2 = await (
await api(`/notes/${note.id}/thread`, r2Cookie)
).json();
assert.equal(threadR2.isOwner, false);
assert.equal(threadR2.myStatus, "unread");
assert.equal(
threadR2.replies.length,
0,
"r2 must not see replies authored by r1",
);
});
test("non-participant gets 403 on /notes/:id/thread", async () => {
const sender = await createUser("notes_p_sender");
const recipient = await createUser("notes_p_recipient");
const stranger = await createUser("notes_p_stranger");
const sCookie = await login(sender.username);
const strangerCookie = await login(stranger.username);
const note = await createNote(sCookie);
await api(`/notes/${note.id}/send`, sCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
const res = await api(`/notes/${note.id}/thread`, strangerCookie);
assert.equal(res.status, 403, "stranger must not access the thread");
});
test("recipient copy is independent: sender edit/delete does not mutate it", async () => {
const sender = await createUser("notes_indep_sender");
const recipient = await createUser("notes_indep_recipient");
const sCookie = await login(sender.username);
const rCookie = await login(recipient.username);
const note = await createNote(sCookie, {
title: "Original title",
content: "Original content",
color: "blue",
});
await api(`/notes/${note.id}/send`, sCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
// Sender edits + deletes the note.
const editRes = await api(`/notes/${note.id}`, sCookie, {
method: "PATCH",
body: JSON.stringify({ title: "EDITED", content: "EDITED body", color: "red" }),
});
assert.ok(editRes.ok, `edit expected 2xx, got ${editRes.status}`);
const inboxAfterEdit = await (await api(`/notes/received`, rCookie)).json();
const r1 = inboxAfterEdit.find((n) => n.id === note.id);
assert.ok(r1, "recipient must still see the delivered copy after sender edit");
assert.equal(r1.title, "Original title", "recipient title must be the snapshot");
assert.equal(r1.content, "Original content", "recipient content must be the snapshot");
assert.equal(r1.color, "blue", "recipient color must be the snapshot");
// Now sender deletes their copy.
const delRes = await api(`/notes/${note.id}`, sCookie, { method: "DELETE" });
assert.ok(delRes.ok, `delete expected 2xx, got ${delRes.status}`);
const inboxAfterDelete = await (await api(`/notes/received`, rCookie)).json();
const r2 = inboxAfterDelete.find(
(n) => n.recipientRowId === r1.recipientRowId,
);
assert.ok(
r2,
"recipient must still see the delivered copy after sender deletion",
);
assert.equal(r2.title, "Original title");
assert.equal(r2.content, "Original content");
// Recipient can still open the thread + reply even after sender deletion.
const threadRes = await api(`/notes/${note.id}/thread`, rCookie);
assert.equal(threadRes.status, 200, "thread still readable after sender delete");
const thread = await threadRes.json();
assert.equal(thread.title, "Original title");
assert.equal(thread.content, "Original content");
const replyRes = await api(`/notes/${note.id}/reply`, rCookie, {
method: "POST",
body: JSON.stringify({ content: "still here" }),
});
assert.ok(
replyRes.ok,
`reply after sender delete expected 2xx, got ${replyRes.status}`,
);
});
test("reply on an archived recipient row clears archivedAt and bumps to replied", async () => {
const sender = await createUser("notes_arch_sender");
const recipient = await createUser("notes_arch_recipient");
const sCookie = await login(sender.username);
const rCookie = await login(recipient.username);
const note = await createNote(sCookie);
await api(`/notes/${note.id}/send`, sCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
const archRes = await api(`/notes/${note.id}/archive`, rCookie, {
method: "POST",
body: JSON.stringify({ archived: true }),
});
assert.ok(archRes.ok);
const replyRes = await api(`/notes/${note.id}/reply`, rCookie, {
method: "POST",
body: JSON.stringify({ content: "back from archive" }),
});
assert.ok(replyRes.ok, `reply expected 2xx, got ${replyRes.status}`);
const { rows } = await pool.query(
`SELECT status, archived_at FROM note_recipients
WHERE note_id = $1 AND recipient_user_id = $2`,
[note.id, recipient.id],
);
assert.equal(rows[0].status, "replied");
assert.equal(rows[0].archived_at, null, "archivedAt must be cleared on reply");
});
test("re-sending to the same recipient does not duplicate the recipient row", async () => {
const sender = await createUser("notes_dup_sender");
const recipient = await createUser("notes_dup_recipient");
const sCookie = await login(sender.username);
const note = await createNote(sCookie);
await api(`/notes/${note.id}/send`, sCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
const second = await api(`/notes/${note.id}/send`, sCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
assert.equal(second.status, 200);
const { rows } = await pool.query(
`SELECT count(*)::int AS c FROM note_recipients WHERE note_id = $1`,
[note.id],
);
assert.equal(rows[0].c, 1, "recipient row must not be duplicated");
});