Task #402: Convert Notes into in-app messaging

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 backend access checks (admin sees everything),
realtime updates, toasts, an unread badge on the Notes app tile, and full
i18n + RTL.

Four review rounds were addressed in this commit:

Round 1 (independence):
- Snapshot columns (title/content/color) on note_recipients; FKs dropped
  on note_recipients.note_id and note_replies.note_id so recipient
  threads survive the sender deleting their note.
- /notes/received and /notes/:id/thread render the recipient snapshot.

Round 2:
- POST /notes/:id/reply: owner is now allowed to reply too.
- Added GET /notes/:id as alias of /notes/:id/thread.
- Added OpenAPI ops for the Notes routes; ran orval codegen.
- use-notifications-socket.ts shows bilingual toasts for note_received /
  note_replied (suppressed during socket warmup).
- home.tsx renders an unread badge on the Notes app tile.

Round 3:
- Archived tab now shows BOTH "My archived notes" and "Archived inbox".
- Sender thread view groups replies by recipient with per-conversation
  header and per-reply author + localized timestamp.
- Send dialog requires explicit AlertDialog confirmation + success toast.
- Realtime toast strings moved off hardcoded EN/AR to i18n keys.
- handleNoteDetail returns 403 (not 404) for non-participants of an
  existing conversation.
- useReplyToNote accepts recipientUserId; ThreadDialog shows a recipient
  picker for owner replies on multi-recipient notes.

Round 4 (this round):
- Added GET /notes/my as an explicit alias of GET /notes (shared
  handleListMyNotes handler).
- Removed `as any[]` cast in /notes/sent — replaced with a precise local
  SentNoteOut type derived from the query result.
- Added auth coverage tests:
  - stranger forbidden from /read, /archive, /reply, GET /notes/:id
  - recipient cannot PATCH the sender's note body
  - admin can read any note via GET /notes/:id and sees full thread
  - GET /notes/my matches GET /notes
  createUser test helper now accepts an optional role.

Note on schema migrations: this monorepo uses `drizzle-kit push` (no
migration files); `pnpm --filter @workspace/db push` was already run
when the new columns/tables landed.

Tests: 11 backend tests in notes-share.test.mjs + 1 e2e (notes-inbox)
all pass. tx-os typecheck is clean. Architect re-review: PASS.

Pre-existing executive-meetings TS errors and the failing top-level
`test` workflow are unrelated to this task.
This commit is contained in:
riyadhafraa
2026-05-05 15:14:25 +00:00
parent d1b4bb228c
commit 672cb6280c
2 changed files with 115 additions and 6 deletions
+15 -3
View File
@@ -210,12 +210,20 @@ async function loadOne(noteId: number, userId: number) {
return [{ ...note, labelIds: assignments.map((a) => a.labelId) }];
}
router.get("/notes", requireAuth, async (req, res): Promise<void> => {
async function handleListMyNotes(
req: import("express").Request,
res: import("express").Response,
): Promise<void> {
const userId = req.session.userId!;
const archived = req.query.archived === "true";
const notes = await loadNotesWithLabels(userId, archived);
res.json(notes);
});
}
// Backward-compatible: GET /notes lists the caller's personal notes.
router.get("/notes", requireAuth, handleListMyNotes);
// Explicit alias requested by the messaging spec.
router.get("/notes/my", requireAuth, handleListMyNotes);
router.post("/notes", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
@@ -312,7 +320,11 @@ router.get("/notes/sent", requireAuth, async (req, res): Promise<void> => {
.from(notesTable)
.where(and(eq(notesTable.userId, userId), inArray(notesTable.id, ids)))
.orderBy(desc(notesTable.updatedAt));
const out = [] as any[];
type SentNoteOut = (typeof notes)[number] & {
recipients: Awaited<ReturnType<typeof loadRecipientsForNote>>;
replyCount: number;
};
const out: SentNoteOut[] = [];
for (const n of notes) {
const recipients = await loadRecipientsForNote(n.id);
const replyCount = await db
+100 -3
View File
@@ -25,7 +25,7 @@ function uniq() {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
async function createUser(prefix) {
async function createUser(prefix, role = "user") {
const username = `${prefix}_${uniq()}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
@@ -36,8 +36,8 @@ async function createUser(prefix) {
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[id],
SELECT $1, id FROM roles WHERE name = $2`,
[id, role],
);
return { id, username };
}
@@ -376,6 +376,103 @@ test("GET /notes/:id is the same payload as /notes/:id/thread", async () => {
assert.equal(a.myStatus, b.myStatus);
});
test("stranger is forbidden from /read, /archive, and /reply on someone else's note", async () => {
const sender = await createUser("notes_strg_sender");
const recipient = await createUser("notes_strg_recipient");
const stranger = await createUser("notes_strg_stranger");
const sCookie = await login(sender.username);
const xCookie = 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 readRes = await api(`/notes/${note.id}/read`, xCookie, { method: "POST" });
assert.equal(readRes.status, 403, "stranger /read must be 403");
const archiveRes = await api(`/notes/${note.id}/archive`, xCookie, {
method: "POST",
body: JSON.stringify({ archived: true }),
});
assert.equal(archiveRes.status, 403, "stranger /archive must be 403");
const replyRes = await api(`/notes/${note.id}/reply`, xCookie, {
method: "POST",
body: JSON.stringify({ content: "I shouldn't be here" }),
});
assert.equal(replyRes.status, 403, "stranger /reply must be 403");
const detailRes = await api(`/notes/${note.id}`, xCookie);
assert.equal(detailRes.status, 403, "stranger GET /notes/:id must be 403");
});
test("recipient cannot PATCH the sender's note body", async () => {
const sender = await createUser("notes_patchdeny_sender");
const recipient = await createUser("notes_patchdeny_recipient");
const sCookie = await login(sender.username);
const rCookie = await login(recipient.username);
const note = await createNote(sCookie, { title: "Untouched" });
await api(`/notes/${note.id}/send`, sCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
const res = await api(`/notes/${note.id}`, rCookie, {
method: "PATCH",
body: JSON.stringify({ title: "HACKED" }),
});
assert.equal(res.status, 404, "recipient PATCH must be denied");
const { rows } = await pool.query(`SELECT title FROM notes WHERE id = $1`, [note.id]);
assert.equal(rows[0].title, "Untouched", "sender's note body must be unchanged");
});
test("admin can read any note via GET /notes/:id and sees full thread", async () => {
const sender = await createUser("notes_adm_sender");
const recipient = await createUser("notes_adm_recipient");
const adminUser = await createUser("notes_adm_admin", "admin");
const sCookie = await login(sender.username);
const rCookie = await login(recipient.username);
const aCookie = await login(adminUser.username);
const note = await createNote(sCookie, { title: "Confidential" });
await api(`/notes/${note.id}/send`, sCookie, {
method: "POST",
body: JSON.stringify({ recipientUserIds: [recipient.id] }),
});
await api(`/notes/${note.id}/reply`, rCookie, {
method: "POST",
body: JSON.stringify({ content: "recipient says hi" }),
});
const res = await api(`/notes/${note.id}`, aCookie);
assert.equal(res.status, 200, "admin must be able to read any note");
const body = await res.json();
assert.equal(body.isAdmin, true);
assert.equal(body.title, "Confidential");
assert.ok(body.recipients.length >= 1, "admin sees full recipient list");
assert.ok(
body.replies.some((r) => r.content === "recipient says hi"),
"admin sees full reply thread",
);
});
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);
await createNote(cookie, { title: "from /notes/my" });
const a = await (await api(`/notes`, cookie)).json();
const b = await (await api(`/notes/my`, cookie)).json();
assert.equal(a.length, b.length);
assert.deepEqual(
a.map((n) => n.id).sort(),
b.map((n) => n.id).sort(),
);
});
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");