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
+8 -45
View File
@@ -298,10 +298,6 @@ router.delete("/notes/:id", requireAuth, async (req, res): Promise<void> => {
// ----- 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<void> => {
const userId = req.session.userId!;
const sentNoteIdRows = await db
@@ -340,10 +336,6 @@ router.get("/notes/sent", requireAuth, async (req, res): Promise<void> => {
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<void> => {
const userId = req.session.userId!;
const includeArchived = req.query.archived === "true";
@@ -364,10 +356,7 @@ router.get("/notes/received", requireAuth, async (req, res): Promise<void> => {
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<void> => {
* 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<void> {
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<void> => {
})
.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<void> => {
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<void> =>
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<void> =>
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<void> =>
})
.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<void> =>
.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";
+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 },
+134 -7
View File
@@ -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:
+149 -15
View File
@@ -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