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 6 (this round):
- OpenAPI: replaced all `additionalProperties: true` schemas in /notes
  paths with concrete component refs — NoteRecipientStatus (enum),
  NoteUserSummary, NoteRecipientSummary, SentNote, ReceivedNote,
  NoteReply, NoteThread, SendNoteResult. Re-ran orval; api-zod and
  api-client-react regenerated cleanly.
- Trimmed narrative comments in artifacts/api-server/src/routes/notes.ts
  (handleNoteDetail, /sent, /received, /send, /read, /reply).
- Schema FK + enum policy: kept noteId as plain integer (no FK) on
  note_recipients/note_replies — this is required so recipient
  snapshots survive sender deletion. Status remains a varchar but is
  constrained at the API boundary by the new NoteRecipientStatus enum
  schema and TS string-literal union on both server and client.
- Migrations: this monorepo uses `drizzle-kit push` exclusively
  (lib/db has no migrations directory; drizzle.config.ts is push-only;
  package.json defines only push/push-force scripts). No migration
  files committed by design.

Round 5: admin authorization fix
- handleNoteDetail: admin bypass is now evaluated BEFORE the
  "non-participant 403" branch. When the sender has deleted the live
  note row but recipient snapshots remain, an admin GET /notes/:id now
  returns 200 with thread payload assembled from a fallback recipient
  snapshot (instead of 403).
- New regression test: "admin can read a note whose sender deleted
  their copy (recipient snapshot remains)".

Round 4:
- 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:26:23 +00:00
parent 73c9953675
commit 5b13fe16f5
5 changed files with 413 additions and 108 deletions
+8 -45
View File
@@ -298,10 +298,6 @@ router.delete("/notes/:id", requireAuth, async (req, res): Promise<void> => {
// ----- 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
@@ -340,10 +336,6 @@ router.get("/notes/sent", requireAuth, async (req, res): Promise<void> => {
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";
@@ -364,10 +356,7 @@ router.get("/notes/received", requireAuth, async (req, res): Promise<void> => {
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.
// Recipients render their immutable snapshot, not the live note row.
const senderMap = await loadUserSummaries(recipientRows.map((r) => r.senderUserId));
const out = recipientRows.map((r) => ({
id: r.noteId ?? r.id,
@@ -392,10 +381,7 @@ router.get("/notes/received", requireAuth, async (req, res): Promise<void> => {
* to what this user is allowed to see (admin/sender = all; recipient = only
* their own thread with the sender).
*/
/**
* GET /notes/:id — public alias of /notes/:id/thread per the task spec.
* Returns the same payload (note + recipients + replies, scoped to caller).
*/
// GET /notes/:id — alias of /notes/:id/thread.
async function handleNoteDetail(req: import("express").Request, res: import("express").Response): Promise<void> {
const userId = req.session.userId!;
const id = parseIdParam(req.params.id);
@@ -417,12 +403,7 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp
const admin = await isAdmin(userId);
const isSender = !!note && note.userId === userId;
// 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.
// Admin: 200 if any trace exists. Non-participant: 403. No trace: 404.
const [anyRecipientRow] =
!note && !recipientRow
? await db
@@ -447,9 +428,7 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp
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.
// Admin reading a deleted note: fall back to any recipient snapshot.
let adminFallbackRow: typeof recipientRow | null = null;
if (admin && !note && !recipientRow) {
const [r] = await db
@@ -462,9 +441,7 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp
}
const snapshotRow = recipientRow ?? adminFallbackRow;
// Recipient view reads its own immutable snapshot. Owner/admin view shows
// the live sender note when present; admin falls back to a snapshot when
// the live note has been deleted.
// Recipients render their snapshot; sender/admin render the live note.
const useSnapshot = !isSender && !admin && !!recipientRow;
const senderUserId =
note?.userId ?? recipientRow?.senderUserId ?? snapshotRow?.senderUserId ?? 0;
@@ -512,7 +489,7 @@ router.get("/notes/:id", requireAuth, handleNoteDetail);
// ----- Send -----
/**
* POST /notes/:id/send — owner sends an existing note to a list of recipients.
* POST /notes/:id/send — owner sends note to recipients.
* Self-send and dupes are filtered out. Existing recipients are silently
* skipped (idempotent).
*/
@@ -577,8 +554,6 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
})
.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";
@@ -624,7 +599,6 @@ router.post("/notes/:id/read", requireAuth, async (req, res): Promise<void> => {
res.status(403).json({ error: "Forbidden" });
return;
}
// Only flip unread -> read; never overwrite "replied" downward.
if (row.status === "unread") {
await db
.update(noteRecipientsTable)
@@ -684,9 +658,6 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
res.status(400).json({ error: "Invalid content" });
return;
}
// The note owner is allowed to reply too (per task spec). Look up the
// current note (may be null if owner deleted it) AND any recipient row for
// the caller; resolve role from whichever exists.
const [liveNote] = await db
.select()
.from(notesTable)
@@ -706,11 +677,6 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
return;
}
// Resolve the conversation's owner (the original sender) and the "other
// party" this specific reply is addressed to. For a recipient reply that's
// the owner. For an owner reply we need a target recipient — body may pass
// `recipientUserId` to scope; if absent and there is exactly one recipient
// row, default to it; otherwise reject.
let ownerUserId: number;
let otherPartyUserId: number;
let targetRow: typeof callerRecipientRow | null = null;
@@ -750,10 +716,8 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
})
.returning();
// Recipient replies flip the recipient row to "replied" and clear archivedAt
// so the conversation re-surfaces. Owner replies do NOT change recipient
// status (per task spec) — the recipient sees a new reply but their own
// read/unread/replied state is preserved.
// Recipient replies bump status to "replied" + un-archive. Owner replies
// leave recipient status untouched.
if (!isOwner) {
await db
.update(noteRecipientsTable)
@@ -765,7 +729,6 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
.where(eq(noteRecipientsTable.id, targetRow.id));
}
// Notify the other party (owner-on-recipient-reply, or recipient-on-owner-reply).
const senderMap = await loadUserSummaries([userId]);
const replier = senderMap.get(userId);
const replierName = replier?.displayNameEn ?? replier?.username ?? "Someone";