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