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
+149 -15
View File
@@ -3331,10 +3331,43 @@ export const GetAdminServiceDependentOrdersResponse = zod.object({
/**
* @summary List notes the caller has sent
*/
export const ListSentNotesResponseItem = zod.record(
zod.string(),
zod.unknown(),
);
export const ListSentNotesResponseItem = zod.object({
id: zod.number(),
userId: zod.number(),
title: zod.string(),
content: zod.string(),
color: zod.string(),
isPinned: zod.boolean().optional(),
isArchived: zod.boolean().optional(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
recipients: zod.array(
zod.object({
id: zod.number(),
noteId: zod.number().optional(),
recipientUserId: zod.number(),
senderUserId: zod.number(),
status: zod.enum(["unread", "read", "replied", "archived"]),
sentAt: zod.coerce.date(),
readAt: zod.coerce.date().nullable(),
archivedAt: zod.coerce.date().nullable(),
recipient: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullable(),
displayNameEn: zod.string().nullable(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean().optional(),
}),
zod.null(),
])
.optional(),
}),
),
replyCount: zod.number(),
});
export const ListSentNotesResponse = zod.array(ListSentNotesResponseItem);
/**
@@ -3346,10 +3379,32 @@ export const ListReceivedNotesQueryParams = zod.object({
archived: zod.coerce.boolean().default(listReceivedNotesQueryArchivedDefault),
});
export const ListReceivedNotesResponseItem = zod.record(
zod.string(),
zod.unknown(),
);
export const ListReceivedNotesResponseItem = zod.object({
id: zod.number(),
title: zod.string(),
content: zod.string(),
color: zod.string(),
createdAt: zod.coerce.date(),
updatedAt: zod.coerce.date(),
sender: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullable(),
displayNameEn: zod.string().nullable(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean().optional(),
}),
zod.null(),
])
.optional(),
recipientRowId: zod.number(),
status: zod.enum(["unread", "read", "replied", "archived"]),
sentAt: zod.coerce.date(),
readAt: zod.coerce.date().nullable(),
archivedAt: zod.coerce.date().nullable(),
});
export const ListReceivedNotesResponse = zod.array(
ListReceivedNotesResponseItem,
);
@@ -3361,7 +3416,82 @@ export const GetNoteDetailParams = zod.object({
id: zod.coerce.number(),
});
export const GetNoteDetailResponse = zod.record(zod.string(), zod.unknown());
export const GetNoteDetailResponse = zod.object({
id: zod.number(),
title: zod.string(),
content: zod.string(),
color: zod.string(),
createdAt: zod.coerce.date().nullish(),
updatedAt: zod.coerce.date().nullish(),
senderUserId: zod.number(),
sender: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullable(),
displayNameEn: zod.string().nullable(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean().optional(),
}),
zod.null(),
])
.optional(),
isOwner: zod.boolean(),
isAdmin: zod.boolean(),
myStatus: zod.union([
zod.enum(["unread", "read", "replied", "archived"]),
zod.null(),
]),
recipients: zod.array(
zod.object({
id: zod.number(),
noteId: zod.number().optional(),
recipientUserId: zod.number(),
senderUserId: zod.number(),
status: zod.enum(["unread", "read", "replied", "archived"]),
sentAt: zod.coerce.date(),
readAt: zod.coerce.date().nullable(),
archivedAt: zod.coerce.date().nullable(),
recipient: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullable(),
displayNameEn: zod.string().nullable(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean().optional(),
}),
zod.null(),
])
.optional(),
}),
),
replies: zod.array(
zod.object({
id: zod.number(),
noteId: zod.number(),
senderUserId: zod.number(),
recipientUserId: zod.number(),
content: zod.string(),
createdAt: zod.coerce.date(),
sender: zod
.union([
zod.object({
id: zod.number(),
username: zod.string(),
displayNameAr: zod.string().nullable(),
displayNameEn: zod.string().nullable(),
avatarUrl: zod.string().nullish(),
isActive: zod.boolean().optional(),
}),
zod.null(),
])
.optional(),
}),
),
});
/**
* @summary Owner sends an existing note to a list of recipients
@@ -3374,7 +3504,10 @@ export const SendNoteBody = zod.object({
recipientUserIds: zod.array(zod.number()),
});
export const SendNoteResponse = zod.record(zod.string(), zod.unknown());
export const SendNoteResponse = zod.object({
success: zod.boolean(),
sent: zod.number(),
});
/**
* @summary Recipient marks their copy of a note read
@@ -3383,7 +3516,9 @@ export const MarkNoteReadParams = zod.object({
id: zod.coerce.number(),
});
export const MarkNoteReadResponse = zod.record(zod.string(), zod.unknown());
export const MarkNoteReadResponse = zod.object({
success: zod.boolean(),
});
/**
* @summary Recipient archives or unarchives their copy of a note
@@ -3396,10 +3531,9 @@ export const ArchiveReceivedNoteBody = zod.object({
archived: zod.boolean(),
});
export const ArchiveReceivedNoteResponse = zod.record(
zod.string(),
zod.unknown(),
);
export const ArchiveReceivedNoteResponse = zod.object({
success: zod.boolean(),
});
/**
* @summary Sender or recipient adds a reply to a note's thread