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
+134 -7
View File
@@ -2745,7 +2745,7 @@ paths:
application/json:
schema:
type: array
items: { type: object, additionalProperties: true }
items: { $ref: "#/components/schemas/SentNote" }
/notes/received:
get:
operationId: listReceivedNotes
@@ -2763,7 +2763,7 @@ paths:
application/json:
schema:
type: array
items: { type: object, additionalProperties: true }
items: { $ref: "#/components/schemas/ReceivedNote" }
/notes/{id}:
get:
operationId: getNoteDetail
@@ -2779,7 +2779,7 @@ paths:
description: Note thread, scoped to caller (sender, recipient, or admin)
content:
application/json:
schema: { type: object, additionalProperties: true }
schema: { $ref: "#/components/schemas/NoteThread" }
"403":
description: Not a participant
content:
@@ -2816,7 +2816,7 @@ paths:
description: Sent (idempotent on existing recipients)
content:
application/json:
schema: { type: object, additionalProperties: true }
schema: { $ref: "#/components/schemas/SendNoteResult" }
/notes/{id}/read:
post:
operationId: markNoteRead
@@ -2832,7 +2832,7 @@ paths:
description: Marked read
content:
application/json:
schema: { type: object, additionalProperties: true }
schema: { $ref: "#/components/schemas/SuccessResponse" }
/notes/{id}/archive:
post:
operationId: archiveReceivedNote
@@ -2857,7 +2857,7 @@ paths:
description: Archive state updated
content:
application/json:
schema: { type: object, additionalProperties: true }
schema: { $ref: "#/components/schemas/SuccessResponse" }
/notes/{id}/reply:
post:
operationId: replyToNote
@@ -2885,7 +2885,7 @@ paths:
description: Reply created
content:
application/json:
schema: { type: object, additionalProperties: true }
schema: { $ref: "#/components/schemas/NoteReply" }
/admin/users/{id}/dependents/notes:
get:
@@ -4835,6 +4835,133 @@ components:
updatedAt: { type: string, format: date-time }
required: [id, title, isPinned, isArchived, updatedAt]
NoteRecipientStatus:
type: string
enum: [unread, read, replied, archived]
NoteUserSummary:
type: object
properties:
id: { type: integer }
username: { type: string }
displayNameAr: { type: ["string", "null"] }
displayNameEn: { type: ["string", "null"] }
avatarUrl: { type: ["string", "null"] }
isActive: { type: boolean }
required: [id, username, displayNameAr, displayNameEn]
NoteRecipientSummary:
type: object
properties:
id: { type: integer }
noteId: { type: integer }
recipientUserId: { type: integer }
senderUserId: { type: integer }
status: { $ref: "#/components/schemas/NoteRecipientStatus" }
sentAt: { type: string, format: date-time }
readAt: { type: ["string", "null"], format: date-time }
archivedAt: { type: ["string", "null"], format: date-time }
recipient:
oneOf:
- $ref: "#/components/schemas/NoteUserSummary"
- type: "null"
required:
[id, recipientUserId, senderUserId, status, sentAt, readAt, archivedAt]
SentNote:
type: object
properties:
id: { type: integer }
userId: { type: integer }
title: { type: string }
content: { type: string }
color: { type: string }
isPinned: { type: boolean }
isArchived: { type: boolean }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
recipients:
type: array
items: { $ref: "#/components/schemas/NoteRecipientSummary" }
replyCount: { type: integer }
required:
[id, userId, title, content, color, createdAt, updatedAt, recipients, replyCount]
ReceivedNote:
type: object
properties:
id: { type: integer }
title: { type: string }
content: { type: string }
color: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
sender:
oneOf:
- $ref: "#/components/schemas/NoteUserSummary"
- type: "null"
recipientRowId: { type: integer }
status: { $ref: "#/components/schemas/NoteRecipientStatus" }
sentAt: { type: string, format: date-time }
readAt: { type: ["string", "null"], format: date-time }
archivedAt: { type: ["string", "null"], format: date-time }
required:
[id, title, content, color, createdAt, updatedAt, recipientRowId, status,
sentAt, readAt, archivedAt]
NoteReply:
type: object
properties:
id: { type: integer }
noteId: { type: integer }
senderUserId: { type: integer }
recipientUserId: { type: integer }
content: { type: string }
createdAt: { type: string, format: date-time }
sender:
oneOf:
- $ref: "#/components/schemas/NoteUserSummary"
- type: "null"
required:
[id, noteId, senderUserId, recipientUserId, content, createdAt]
NoteThread:
type: object
properties:
id: { type: integer }
title: { type: string }
content: { type: string }
color: { type: string }
createdAt: { type: ["string", "null"], format: date-time }
updatedAt: { type: ["string", "null"], format: date-time }
senderUserId: { type: integer }
sender:
oneOf:
- $ref: "#/components/schemas/NoteUserSummary"
- type: "null"
isOwner: { type: boolean }
isAdmin: { type: boolean }
myStatus:
oneOf:
- $ref: "#/components/schemas/NoteRecipientStatus"
- type: "null"
recipients:
type: array
items: { $ref: "#/components/schemas/NoteRecipientSummary" }
replies:
type: array
items: { $ref: "#/components/schemas/NoteReply" }
required:
[id, title, content, color, senderUserId, isOwner, isAdmin, myStatus,
recipients, replies]
SendNoteResult:
type: object
properties:
success: { type: boolean }
sent: { type: integer }
required: [success, sent]
UserDependentNotesPage:
type: object
properties: