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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user