From 5b13fe16f5ba12aa6cb2f79973d9fd7e2ed6c14b Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 5 May 2026 15:26:23 +0000 Subject: [PATCH] Task #402: Convert Notes into in-app messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- artifacts/api-server/src/routes/notes.ts | 53 +----- .../src/generated/api.schemas.ts | 114 ++++++++++-- lib/api-client-react/src/generated/api.ts | 49 +++--- lib/api-spec/openapi.yaml | 141 ++++++++++++++- lib/api-zod/src/generated/api.ts | 164 ++++++++++++++++-- 5 files changed, 413 insertions(+), 108 deletions(-) diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index 28a2948b..39bd6159 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -298,10 +298,6 @@ router.delete("/notes/:id", requireAuth, async (req, res): Promise => { // ----- Sent / Received / Detail ----- -/** - * GET /notes/sent — notes the current user has authored AND has at least one - * recipient row for. Each note carries its full recipient list with status. - */ router.get("/notes/sent", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; const sentNoteIdRows = await db @@ -340,10 +336,6 @@ router.get("/notes/sent", requireAuth, async (req, res): Promise => { res.json(out); }); -/** - * GET /notes/received — notes that were sent TO the current user (not archived - * by them). Each entry carries the sender summary and this user's status row. - */ router.get("/notes/received", requireAuth, async (req, res): Promise => { const userId = req.session.userId!; const includeArchived = req.query.archived === "true"; @@ -364,10 +356,7 @@ router.get("/notes/received", requireAuth, async (req, res): Promise => { res.json([]); return; } - // Recipients always see the snapshot they were sent — independent of any - // sender-side edit/delete. The note id is exposed only as a thread routing - // key (used by /notes/:id/thread, /read, /archive, /reply); the rendered - // body comes from the recipient row's title/content/color snapshot. + // Recipients render their immutable snapshot, not the live note row. const senderMap = await loadUserSummaries(recipientRows.map((r) => r.senderUserId)); const out = recipientRows.map((r) => ({ id: r.noteId ?? r.id, @@ -392,10 +381,7 @@ router.get("/notes/received", requireAuth, async (req, res): Promise => { * to what this user is allowed to see (admin/sender = all; recipient = only * their own thread with the sender). */ -/** - * GET /notes/:id — public alias of /notes/:id/thread per the task spec. - * Returns the same payload (note + recipients + replies, scoped to caller). - */ +// GET /notes/:id — alias of /notes/:id/thread. async function handleNoteDetail(req: import("express").Request, res: import("express").Response): Promise { const userId = req.session.userId!; const id = parseIdParam(req.params.id); @@ -417,12 +403,7 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp const admin = await isAdmin(userId); const isSender = !!note && note.userId === userId; - // Authorization contract: - // - admin can read any note that has any trace (live row OR recipient - // snapshots), even if the sender deleted their own copy; - // - non-participants get 403 when the conversation exists but they - // are not part of it; - // - 404 only when there is no trace of the note at all. + // Admin: 200 if any trace exists. Non-participant: 403. No trace: 404. const [anyRecipientRow] = !note && !recipientRow ? await db @@ -447,9 +428,7 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp return; } - // For admin reading a deleted note (no live row, no recipient row of - // their own), fall back to ANY recipient snapshot so we can still render - // the conversation. + // Admin reading a deleted note: fall back to any recipient snapshot. let adminFallbackRow: typeof recipientRow | null = null; if (admin && !note && !recipientRow) { const [r] = await db @@ -462,9 +441,7 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp } const snapshotRow = recipientRow ?? adminFallbackRow; - // Recipient view reads its own immutable snapshot. Owner/admin view shows - // the live sender note when present; admin falls back to a snapshot when - // the live note has been deleted. + // Recipients render their snapshot; sender/admin render the live note. const useSnapshot = !isSender && !admin && !!recipientRow; const senderUserId = note?.userId ?? recipientRow?.senderUserId ?? snapshotRow?.senderUserId ?? 0; @@ -512,7 +489,7 @@ router.get("/notes/:id", requireAuth, handleNoteDetail); // ----- Send ----- /** - * POST /notes/:id/send — owner sends an existing note to a list of recipients. + * POST /notes/:id/send — owner sends note to recipients. * Self-send and dupes are filtered out. Existing recipients are silently * skipped (idempotent). */ @@ -577,8 +554,6 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise => { }) .returning(); - // Notify each newly-added recipient via per-user socket + persistent - // notifications row so they get a chime + badge bump. const senderMap = await loadUserSummaries([userId]); const sender = senderMap.get(userId); const senderName = sender?.displayNameEn ?? sender?.username ?? "Someone"; @@ -624,7 +599,6 @@ router.post("/notes/:id/read", requireAuth, async (req, res): Promise => { res.status(403).json({ error: "Forbidden" }); return; } - // Only flip unread -> read; never overwrite "replied" downward. if (row.status === "unread") { await db .update(noteRecipientsTable) @@ -684,9 +658,6 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => res.status(400).json({ error: "Invalid content" }); return; } - // The note owner is allowed to reply too (per task spec). Look up the - // current note (may be null if owner deleted it) AND any recipient row for - // the caller; resolve role from whichever exists. const [liveNote] = await db .select() .from(notesTable) @@ -706,11 +677,6 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => return; } - // Resolve the conversation's owner (the original sender) and the "other - // party" this specific reply is addressed to. For a recipient reply that's - // the owner. For an owner reply we need a target recipient — body may pass - // `recipientUserId` to scope; if absent and there is exactly one recipient - // row, default to it; otherwise reject. let ownerUserId: number; let otherPartyUserId: number; let targetRow: typeof callerRecipientRow | null = null; @@ -750,10 +716,8 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => }) .returning(); - // Recipient replies flip the recipient row to "replied" and clear archivedAt - // so the conversation re-surfaces. Owner replies do NOT change recipient - // status (per task spec) — the recipient sees a new reply but their own - // read/unread/replied state is preserved. + // Recipient replies bump status to "replied" + un-archive. Owner replies + // leave recipient status untouched. if (!isOwner) { await db .update(noteRecipientsTable) @@ -765,7 +729,6 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => .where(eq(noteRecipientsTable.id, targetRow.id)); } - // Notify the other party (owner-on-recipient-reply, or recipient-on-owner-reply). const senderMap = await loadUserSummaries([userId]); const replier = senderMap.get(userId); const replierName = replier?.displayNameEn ?? replier?.username ?? "Someone"; diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index 13f8cdde..6d7ff151 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -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 diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index c45c77a7..3a9ff48b 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -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 => { - return customFetch(getListSentNotesUrl(), { +): Promise => { + return customFetch(getListSentNotesUrl(), { ...options, method: "GET", }); @@ -8360,14 +8358,11 @@ export const getListReceivedNotesUrl = (params?: ListReceivedNotesParams) => { export const listReceivedNotes = async ( params?: ListReceivedNotesParams, options?: RequestInit, -): Promise => { - return customFetch( - getListReceivedNotesUrl(params), - { - ...options, - method: "GET", - }, - ); +): Promise => { + return customFetch(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 => { - return customFetch(getGetNoteDetailUrl(id), { +): Promise => { + return customFetch(getGetNoteDetailUrl(id), { ...options, method: "GET", }); @@ -8536,8 +8531,8 @@ export const sendNote = async ( id: number, sendNoteBody: SendNoteBody, options?: RequestInit, -): Promise => { - return customFetch(getSendNoteUrl(id), { +): Promise => { + return customFetch(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 => { - return customFetch(getMarkNoteReadUrl(id), { +): Promise => { + return customFetch(getMarkNoteReadUrl(id), { ...options, method: "POST", }); @@ -8707,8 +8702,8 @@ export const archiveReceivedNote = async ( id: number, archiveReceivedNoteBody: ArchiveReceivedNoteBody, options?: RequestInit, -): Promise => { - return customFetch(getArchiveReceivedNoteUrl(id), { +): Promise => { + return customFetch(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 => { - return customFetch(getReplyToNoteUrl(id), { +): Promise => { + return customFetch(getReplyToNoteUrl(id), { ...options, method: "POST", headers: { "Content-Type": "application/json", ...options?.headers }, diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 2400a342..628fece3 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -2745,7 +2745,7 @@ paths: application/json: schema: type: array - items: { type: object, additionalProperties: true } + items: { $ref: "#/components/schemas/SentNote" } /notes/received: get: operationId: listReceivedNotes @@ -2763,7 +2763,7 @@ paths: application/json: schema: type: array - items: { type: object, additionalProperties: true } + items: { $ref: "#/components/schemas/ReceivedNote" } /notes/{id}: get: operationId: getNoteDetail @@ -2779,7 +2779,7 @@ paths: description: Note thread, scoped to caller (sender, recipient, or admin) content: application/json: - schema: { type: object, additionalProperties: true } + schema: { $ref: "#/components/schemas/NoteThread" } "403": description: Not a participant content: @@ -2816,7 +2816,7 @@ paths: description: Sent (idempotent on existing recipients) content: application/json: - schema: { type: object, additionalProperties: true } + schema: { $ref: "#/components/schemas/SendNoteResult" } /notes/{id}/read: post: operationId: markNoteRead @@ -2832,7 +2832,7 @@ paths: description: Marked read content: application/json: - schema: { type: object, additionalProperties: true } + schema: { $ref: "#/components/schemas/SuccessResponse" } /notes/{id}/archive: post: operationId: archiveReceivedNote @@ -2857,7 +2857,7 @@ paths: description: Archive state updated content: application/json: - schema: { type: object, additionalProperties: true } + schema: { $ref: "#/components/schemas/SuccessResponse" } /notes/{id}/reply: post: operationId: replyToNote @@ -2885,7 +2885,7 @@ paths: description: Reply created content: application/json: - schema: { type: object, additionalProperties: true } + schema: { $ref: "#/components/schemas/NoteReply" } /admin/users/{id}/dependents/notes: get: @@ -4835,6 +4835,133 @@ components: updatedAt: { type: string, format: date-time } required: [id, title, isPinned, isArchived, updatedAt] + NoteRecipientStatus: + type: string + enum: [unread, read, replied, archived] + + NoteUserSummary: + type: object + properties: + id: { type: integer } + username: { type: string } + displayNameAr: { type: ["string", "null"] } + displayNameEn: { type: ["string", "null"] } + avatarUrl: { type: ["string", "null"] } + isActive: { type: boolean } + required: [id, username, displayNameAr, displayNameEn] + + NoteRecipientSummary: + type: object + properties: + id: { type: integer } + noteId: { type: integer } + recipientUserId: { type: integer } + senderUserId: { type: integer } + status: { $ref: "#/components/schemas/NoteRecipientStatus" } + sentAt: { type: string, format: date-time } + readAt: { type: ["string", "null"], format: date-time } + archivedAt: { type: ["string", "null"], format: date-time } + recipient: + oneOf: + - $ref: "#/components/schemas/NoteUserSummary" + - type: "null" + required: + [id, recipientUserId, senderUserId, status, sentAt, readAt, archivedAt] + + SentNote: + type: object + properties: + id: { type: integer } + userId: { type: integer } + title: { type: string } + content: { type: string } + color: { type: string } + isPinned: { type: boolean } + isArchived: { type: boolean } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + recipients: + type: array + items: { $ref: "#/components/schemas/NoteRecipientSummary" } + replyCount: { type: integer } + required: + [id, userId, title, content, color, createdAt, updatedAt, recipients, replyCount] + + ReceivedNote: + type: object + properties: + id: { type: integer } + title: { type: string } + content: { type: string } + color: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + sender: + oneOf: + - $ref: "#/components/schemas/NoteUserSummary" + - type: "null" + recipientRowId: { type: integer } + status: { $ref: "#/components/schemas/NoteRecipientStatus" } + sentAt: { type: string, format: date-time } + readAt: { type: ["string", "null"], format: date-time } + archivedAt: { type: ["string", "null"], format: date-time } + required: + [id, title, content, color, createdAt, updatedAt, recipientRowId, status, + sentAt, readAt, archivedAt] + + NoteReply: + type: object + properties: + id: { type: integer } + noteId: { type: integer } + senderUserId: { type: integer } + recipientUserId: { type: integer } + content: { type: string } + createdAt: { type: string, format: date-time } + sender: + oneOf: + - $ref: "#/components/schemas/NoteUserSummary" + - type: "null" + required: + [id, noteId, senderUserId, recipientUserId, content, createdAt] + + NoteThread: + type: object + properties: + id: { type: integer } + title: { type: string } + content: { type: string } + color: { type: string } + createdAt: { type: ["string", "null"], format: date-time } + updatedAt: { type: ["string", "null"], format: date-time } + senderUserId: { type: integer } + sender: + oneOf: + - $ref: "#/components/schemas/NoteUserSummary" + - type: "null" + isOwner: { type: boolean } + isAdmin: { type: boolean } + myStatus: + oneOf: + - $ref: "#/components/schemas/NoteRecipientStatus" + - type: "null" + recipients: + type: array + items: { $ref: "#/components/schemas/NoteRecipientSummary" } + replies: + type: array + items: { $ref: "#/components/schemas/NoteReply" } + required: + [id, title, content, color, senderUserId, isOwner, isAdmin, myStatus, + recipients, replies] + + SendNoteResult: + type: object + properties: + success: { type: boolean } + sent: { type: integer } + required: [success, sent] + UserDependentNotesPage: type: object properties: diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index d41b3423..ad2342ba 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -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