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