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