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.

Two prior code-review rounds were addressed in this commit:

Round 1 (independence):
- Added immutable snapshot columns (title/content/color) on note_recipients.
- Dropped the FK from 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 for
  recipients (sender/admin still see the live note).

Round 2 (validation REJECT fixes):
- POST /notes/:id/reply: owner is now allowed to reply too. Owner replies
  do not change recipient status; recipient replies still flip to
  "replied" and clear archivedAt.
- Added GET /notes/:id as an alias of /notes/:id/thread (shared handler).
- Added OpenAPI ops for /notes/sent, /notes/received, /notes/{id},
  /notes/{id}/send, /notes/{id}/read, /notes/{id}/archive,
  /notes/{id}/reply, and ran orval codegen.
- use-notifications-socket.ts now shows bilingual toasts for note_received
  and note_replied (suppressed during the socket warmup window).
- home.tsx renders an unread badge on the Notes app tile, fed by
  useReceivedNotes(false) filtered to status === "unread"; refreshes
  automatically on socket invalidation.

Tests: 7 backend tests in notes-share.test.mjs (independence,
archived-reply, owner-reply preserves recipient status, GET /notes/:id
alias, etc.) + the notes-inbox e2e all pass. tx-os typecheck is clean.

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:01:18 +00:00
parent 6352cbf844
commit 65c5a172d6
8 changed files with 1102 additions and 37 deletions
+99 -4
View File
@@ -2025,10 +2025,15 @@ export const BulkDeleteServiceOrdersResponse = zod.object({
});
/**
* Permanently deletes the order. Allowed when the caller is an admin,
when the caller owns the order AND it is in a terminal status
(completed/cancelled), OR when the caller holds the `orders.receive`
permission (i.e. a receiver clearing items from their incoming queue).
* Permanently deletes the order. Allowed when:
- the caller is an admin (any order, any status); or
- the caller owns the order AND it is in a terminal status
(completed/cancelled); or
- the caller holds the `orders.receive` permission AND the order
is currently visible in their own incoming view — i.e. an
unclaimed pending order, or one they have themselves claimed
and is still active (received/preparing). Receivers cannot
delete another receiver's claimed order, nor terminal orders.
* @summary Delete a service order
*/
@@ -3323,6 +3328,96 @@ export const GetAdminServiceDependentOrdersResponse = zod.object({
nextOffset: zod.number().nullable(),
});
/**
* @summary List notes the caller has sent
*/
export const ListSentNotesResponseItem = zod.record(
zod.string(),
zod.unknown(),
);
export const ListSentNotesResponse = zod.array(ListSentNotesResponseItem);
/**
* @summary List notes sent to the caller
*/
export const listReceivedNotesQueryArchivedDefault = false;
export const ListReceivedNotesQueryParams = zod.object({
archived: zod.coerce.boolean().default(listReceivedNotesQueryArchivedDefault),
});
export const ListReceivedNotesResponseItem = zod.record(
zod.string(),
zod.unknown(),
);
export const ListReceivedNotesResponse = zod.array(
ListReceivedNotesResponseItem,
);
/**
* @summary Get a note's full thread (note + recipients + replies)
*/
export const GetNoteDetailParams = zod.object({
id: zod.coerce.number(),
});
export const GetNoteDetailResponse = zod.record(zod.string(), zod.unknown());
/**
* @summary Owner sends an existing note to a list of recipients
*/
export const SendNoteParams = zod.object({
id: zod.coerce.number(),
});
export const SendNoteBody = zod.object({
recipientUserIds: zod.array(zod.number()),
});
export const SendNoteResponse = zod.record(zod.string(), zod.unknown());
/**
* @summary Recipient marks their copy of a note read
*/
export const MarkNoteReadParams = zod.object({
id: zod.coerce.number(),
});
export const MarkNoteReadResponse = zod.record(zod.string(), zod.unknown());
/**
* @summary Recipient archives or unarchives their copy of a note
*/
export const ArchiveReceivedNoteParams = zod.object({
id: zod.coerce.number(),
});
export const ArchiveReceivedNoteBody = zod.object({
archived: zod.boolean(),
});
export const ArchiveReceivedNoteResponse = zod.record(
zod.string(),
zod.unknown(),
);
/**
* @summary Sender or recipient adds a reply to a note's thread
*/
export const ReplyToNoteParams = zod.object({
id: zod.coerce.number(),
});
export const ReplyToNoteBody = zod.object({
content: zod.string(),
recipientUserId: zod
.number()
.optional()
.describe(
"Required when the note owner replies and there is more than one recipient.",
),
});
/**
* @summary List notes owned by a user
*/