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