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
+100 -14
View File
@@ -1049,6 +1049,106 @@ export interface UserDependentNoteItem {
updatedAt: string;
}
export type NoteRecipientStatus =
(typeof NoteRecipientStatus)[keyof typeof NoteRecipientStatus];
export const NoteRecipientStatus = {
unread: "unread",
read: "read",
replied: "replied",
archived: "archived",
} as const;
export interface NoteUserSummary {
id: number;
username: string;
/** @nullable */
displayNameAr: string | null;
/** @nullable */
displayNameEn: string | null;
/** @nullable */
avatarUrl?: string | null;
isActive?: boolean;
}
export interface NoteRecipientSummary {
id: number;
noteId?: number;
recipientUserId: number;
senderUserId: number;
status: NoteRecipientStatus;
sentAt: string;
/** @nullable */
readAt: string | null;
/** @nullable */
archivedAt: string | null;
recipient?: NoteUserSummary | null;
}
export interface SentNote {
id: number;
userId: number;
title: string;
content: string;
color: string;
isPinned?: boolean;
isArchived?: boolean;
createdAt: string;
updatedAt: string;
recipients: NoteRecipientSummary[];
replyCount: number;
}
export interface ReceivedNote {
id: number;
title: string;
content: string;
color: string;
createdAt: string;
updatedAt: string;
sender?: NoteUserSummary | null;
recipientRowId: number;
status: NoteRecipientStatus;
sentAt: string;
/** @nullable */
readAt: string | null;
/** @nullable */
archivedAt: string | null;
}
export interface NoteReply {
id: number;
noteId: number;
senderUserId: number;
recipientUserId: number;
content: string;
createdAt: string;
sender?: NoteUserSummary | null;
}
export interface NoteThread {
id: number;
title: string;
content: string;
color: string;
/** @nullable */
createdAt?: string | null;
/** @nullable */
updatedAt?: string | null;
senderUserId: number;
sender?: NoteUserSummary | null;
isOwner: boolean;
isAdmin: boolean;
myStatus: NoteRecipientStatus | null;
recipients: NoteRecipientSummary[];
replies: NoteReply[];
}
export interface SendNoteResult {
success: boolean;
sent: number;
}
export interface UserDependentNotesPage {
items: UserDependentNoteItem[];
totalCount: number;
@@ -1601,38 +1701,24 @@ export type GetAdminServiceDependentOrdersParams = {
offset?: number;
};
export type ListSentNotes200Item = { [key: string]: unknown };
export type ListReceivedNotesParams = {
archived?: boolean;
};
export type ListReceivedNotes200Item = { [key: string]: unknown };
export type GetNoteDetail200 = { [key: string]: unknown };
export type SendNoteBody = {
recipientUserIds: number[];
};
export type SendNote200 = { [key: string]: unknown };
export type MarkNoteRead200 = { [key: string]: unknown };
export type ArchiveReceivedNoteBody = {
archived: boolean;
};
export type ArchiveReceivedNote200 = { [key: string]: unknown };
export type ReplyToNoteBody = {
content: string;
/** Required when the note owner replies and there is more than one recipient. */
recipientUserId?: number;
};
export type ReplyToNote201 = { [key: string]: unknown };
export type GetAdminUserDependentNotesParams = {
/**
* @minimum 1
+22 -27
View File
@@ -33,7 +33,6 @@ import type {
AppPermissionsImpact,
AppPermissionsImpactBody,
AppSettings,
ArchiveReceivedNote200,
ArchiveReceivedNoteBody,
AuditLogList,
AuthUser,
@@ -69,7 +68,6 @@ import type {
GetAdminUserDependentOrdersParams,
GetAppPermissionAuditParams,
GetGroupPermissionAuditParams,
GetNoteDetail200,
GetRolePermissionAuditParams,
GetUserPermissionAuditParams,
Group,
@@ -80,19 +78,18 @@ import type {
LeaveConversationBody,
ListAuditLogsParams,
ListGroupsParams,
ListReceivedNotes200Item,
ListReceivedNotesParams,
ListSentNotes200Item,
ListUsersParams,
LoginBody,
MarkNoteRead200,
MessageWithSender,
NoteReply,
NoteThread,
Notification,
Permission,
PermissionAuditList,
ReceivedNote,
RegisterBody,
ReplaceRolePermissionsBody,
ReplyToNote201,
ReplyToNoteBody,
RequestUploadUrlBody,
RequestUploadUrlResponse,
@@ -104,8 +101,9 @@ import type {
RolePermissionsImpactBody,
RoleUsage,
SendMessageBody,
SendNote200,
SendNoteBody,
SendNoteResult,
SentNote,
Service,
ServiceCategory,
ServiceDeletionConflict,
@@ -8272,8 +8270,8 @@ export const getListSentNotesUrl = () => {
export const listSentNotes = async (
options?: RequestInit,
): Promise<ListSentNotes200Item[]> => {
return customFetch<ListSentNotes200Item[]>(getListSentNotesUrl(), {
): Promise<SentNote[]> => {
return customFetch<SentNote[]>(getListSentNotesUrl(), {
...options,
method: "GET",
});
@@ -8360,14 +8358,11 @@ export const getListReceivedNotesUrl = (params?: ListReceivedNotesParams) => {
export const listReceivedNotes = async (
params?: ListReceivedNotesParams,
options?: RequestInit,
): Promise<ListReceivedNotes200Item[]> => {
return customFetch<ListReceivedNotes200Item[]>(
getListReceivedNotesUrl(params),
{
...options,
method: "GET",
},
);
): Promise<ReceivedNote[]> => {
return customFetch<ReceivedNote[]>(getListReceivedNotesUrl(params), {
...options,
method: "GET",
});
};
export const getListReceivedNotesQueryKey = (
@@ -8448,8 +8443,8 @@ export const getGetNoteDetailUrl = (id: number) => {
export const getNoteDetail = async (
id: number,
options?: RequestInit,
): Promise<GetNoteDetail200> => {
return customFetch<GetNoteDetail200>(getGetNoteDetailUrl(id), {
): Promise<NoteThread> => {
return customFetch<NoteThread>(getGetNoteDetailUrl(id), {
...options,
method: "GET",
});
@@ -8536,8 +8531,8 @@ export const sendNote = async (
id: number,
sendNoteBody: SendNoteBody,
options?: RequestInit,
): Promise<SendNote200> => {
return customFetch<SendNote200>(getSendNoteUrl(id), {
): Promise<SendNoteResult> => {
return customFetch<SendNoteResult>(getSendNoteUrl(id), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
@@ -8622,8 +8617,8 @@ export const getMarkNoteReadUrl = (id: number) => {
export const markNoteRead = async (
id: number,
options?: RequestInit,
): Promise<MarkNoteRead200> => {
return customFetch<MarkNoteRead200>(getMarkNoteReadUrl(id), {
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getMarkNoteReadUrl(id), {
...options,
method: "POST",
});
@@ -8707,8 +8702,8 @@ export const archiveReceivedNote = async (
id: number,
archiveReceivedNoteBody: ArchiveReceivedNoteBody,
options?: RequestInit,
): Promise<ArchiveReceivedNote200> => {
return customFetch<ArchiveReceivedNote200>(getArchiveReceivedNoteUrl(id), {
): Promise<SuccessResponse> => {
return customFetch<SuccessResponse>(getArchiveReceivedNoteUrl(id), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
@@ -8794,8 +8789,8 @@ export const replyToNote = async (
id: number,
replyToNoteBody: ReplyToNoteBody,
options?: RequestInit,
): Promise<ReplyToNote201> => {
return customFetch<ReplyToNote201>(getReplyToNoteUrl(id), {
): Promise<NoteReply> => {
return customFetch<NoteReply>(getReplyToNoteUrl(id), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },