diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index cfaee122..6ea79ceb 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -380,7 +380,11 @@ 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). */ -router.get("/notes/:id/thread", requireAuth, async (req, res): Promise => { +/** + * GET /notes/:id — public alias of /notes/:id/thread per the task spec. + * Returns the same payload (note + recipients + replies, scoped to caller). + */ +async function handleNoteDetail(req: import("express").Request, res: import("express").Response): Promise { const userId = req.session.userId!; const id = parseIdParam(req.params.id); if (!id.ok) { @@ -444,7 +448,10 @@ router.get("/notes/:id/thread", requireAuth, async (req, res): Promise => recipients, replies, }); -}); +} + +router.get("/notes/:id/thread", requireAuth, handleNoteDetail); +router.get("/notes/:id", requireAuth, handleNoteDetail); // ----- Send ----- @@ -621,7 +628,14 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => res.status(400).json({ error: "Invalid content" }); return; } - const [row] = await db + // 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) + .where(eq(notesTable.id, id.id)); + const [callerRecipientRow] = await db .select() .from(noteRecipientsTable) .where( @@ -630,33 +644,72 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => eq(noteRecipientsTable.recipientUserId, userId), ), ); - if (!row) { + const isOwner = !!liveNote && liveNote.userId === userId; + if (!isOwner && !callerRecipientRow) { res.status(403).json({ error: "Forbidden" }); return; } - const ownerUserId = row.senderUserId; + + // 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; + if (isOwner) { + ownerUserId = userId; + const requestedTarget = + typeof req.body?.recipientUserId === "number" + ? req.body.recipientUserId + : null; + const allRows = await db + .select() + .from(noteRecipientsTable) + .where(eq(noteRecipientsTable.noteId, id.id)); + if (requestedTarget !== null) { + targetRow = allRows.find((r) => r.recipientUserId === requestedTarget) ?? null; + } else if (allRows.length === 1) { + targetRow = allRows[0]; + } + if (!targetRow) { + res.status(400).json({ error: "recipientUserId required" }); + return; + } + otherPartyUserId = targetRow.recipientUserId; + } else { + targetRow = callerRecipientRow!; + ownerUserId = callerRecipientRow!.senderUserId; + otherPartyUserId = ownerUserId; + } + const [reply] = await db .insert(noteRepliesTable) .values({ noteId: id.id, senderUserId: userId, - recipientUserId: ownerUserId, + recipientUserId: otherPartyUserId, content, }) .returning(); - // Replying re-activates an archived recipient row: clear archivedAt and - // bump status to "replied" so the conversation continues cleanly. - await db - .update(noteRecipientsTable) - .set({ - status: "replied", - readAt: row.readAt ?? new Date(), - archivedAt: null, - }) - .where(eq(noteRecipientsTable.id, row.id)); + // 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. + if (!isOwner) { + await db + .update(noteRecipientsTable) + .set({ + status: "replied", + readAt: targetRow.readAt ?? new Date(), + archivedAt: null, + }) + .where(eq(noteRecipientsTable.id, targetRow.id)); + } - // Notify the original sender (note owner). + // 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"; @@ -664,18 +717,18 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => const [notif] = await db .insert(notificationsTable) .values({ - userId: ownerUserId, - titleAr: "رد على ملاحظتك", - titleEn: "Reply to your note", - bodyAr: `${replierNameAr} رد على ملاحظتك`, - bodyEn: `${replierName} replied to your note`, + userId: otherPartyUserId, + titleAr: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك", + titleEn: isOwner ? "New reply on a note" : "Reply to your note", + bodyAr: `${replierNameAr} رد على ملاحظة`, + bodyEn: `${replierName} replied on a note`, type: "note", }) .returning(); - await emitToUser(ownerUserId, "notification_created", { ...notif, type: "note" }); - await emitToUser(ownerUserId, "note_replied", { + await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" }); + await emitToUser(otherPartyUserId, "note_replied", { noteId: id.id, - recipientUserId: userId, + recipientUserId: isOwner ? otherPartyUserId : userId, replyId: reply.id, }); diff --git a/artifacts/api-server/tests/notes-share.test.mjs b/artifacts/api-server/tests/notes-share.test.mjs index 3402c8df..bca4e0a9 100644 --- a/artifacts/api-server/tests/notes-share.test.mjs +++ b/artifacts/api-server/tests/notes-share.test.mjs @@ -318,6 +318,64 @@ test("reply on an archived recipient row clears archivedAt and bumps to replied" assert.equal(rows[0].archived_at, null, "archivedAt must be cleared on reply"); }); +test("owner can reply on their own note; recipient status is preserved", async () => { + const sender = await createUser("notes_oreply_sender"); + const recipient = await createUser("notes_oreply_recipient"); + const sCookie = await login(sender.username); + const rCookie = await login(recipient.username); + + const note = await createNote(sCookie); + await api(`/notes/${note.id}/send`, sCookie, { + method: "POST", + body: JSON.stringify({ recipientUserIds: [recipient.id] }), + }); + + // Recipient leaves the note unread, then the owner replies. Owner reply + // must succeed (not 403) and must NOT flip the recipient's status. + const ownerReply = await api(`/notes/${note.id}/reply`, sCookie, { + method: "POST", + body: JSON.stringify({ content: "owner says hi" }), + }); + assert.equal( + ownerReply.status, + 201, + `owner reply expected 201, got ${ownerReply.status}`, + ); + + const { rows } = await pool.query( + `SELECT status FROM note_recipients + WHERE note_id = $1 AND recipient_user_id = $2`, + [note.id, recipient.id], + ); + assert.equal(rows[0].status, "unread", "owner reply must not flip recipient status"); + + // Recipient sees the owner reply in their thread. + const thread = await (await api(`/notes/${note.id}/thread`, rCookie)).json(); + assert.ok( + thread.replies.some((r) => r.content === "owner says hi"), + "recipient must see the owner's reply in the thread", + ); +}); + +test("GET /notes/:id is the same payload as /notes/:id/thread", async () => { + const sender = await createUser("notes_alias_sender"); + const recipient = await createUser("notes_alias_recipient"); + const sCookie = await login(sender.username); + const rCookie = await login(recipient.username); + const note = await createNote(sCookie); + await api(`/notes/${note.id}/send`, sCookie, { + method: "POST", + body: JSON.stringify({ recipientUserIds: [recipient.id] }), + }); + const a = await (await api(`/notes/${note.id}`, rCookie)).json(); + const b = await (await api(`/notes/${note.id}/thread`, rCookie)).json(); + assert.equal(a.id, b.id); + assert.equal(a.title, b.title); + assert.equal(a.content, b.content); + assert.equal(a.color, b.color); + assert.equal(a.myStatus, b.myStatus); +}); + test("re-sending to the same recipient does not duplicate the recipient row", async () => { const sender = await createUser("notes_dup_sender"); const recipient = await createUser("notes_dup_recipient"); diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index bb0bd42e..cdc595f4 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -16,6 +16,8 @@ import { } from "@workspace/api-client-react"; import { useAuth } from "@/contexts/AuthContext"; import { notificationPlayer } from "@/lib/notification-sounds"; +import { toast } from "@/hooks/use-toast"; +import i18n from "@/i18n"; const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); @@ -103,12 +105,31 @@ export function useNotificationsSocket() { }); }); + // Realtime note events: invalidate notes queries AND surface a toast so + // the recipient (or sender, on a reply) sees an immediate, dismissible + // confirmation regardless of which page they're on. socket.on("note_received", () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); + if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return; + const ar = i18n.language === "ar"; + toast({ + title: ar ? "ملاحظة جديدة" : "New note received", + description: ar + ? "تحقق من بريد الملاحظات الوارد لقراءتها." + : "Check your Notes inbox to read it.", + }); }); socket.on("note_replied", () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); + if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return; + const ar = i18n.language === "ar"; + toast({ + title: ar ? "رد جديد على ملاحظة" : "New reply on a note", + description: ar + ? "افتح الملاحظة لمتابعة المحادثة." + : "Open the note to continue the conversation.", + }); }); socket.on("note_status_changed", () => { diff --git a/artifacts/tx-os/src/pages/home.tsx b/artifacts/tx-os/src/pages/home.tsx index ed4bb66d..69681707 100644 --- a/artifacts/tx-os/src/pages/home.tsx +++ b/artifacts/tx-os/src/pages/home.tsx @@ -19,6 +19,7 @@ import { } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/contexts/AuthContext"; +import { useReceivedNotes } from "@/lib/notes-api"; import { Bell, Inbox, @@ -94,7 +95,13 @@ function gradientForColor(color: string): string { return "icon-tile-blue"; } -function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconName: string; color: string } }) { +function AppIconContent({ + app, + badgeCount = 0, +}: { + app: { nameAr: string; nameEn: string; iconName: string; color: string }; + badgeCount?: number; +}) { const { i18n: i18nInstance } = useTranslation(); const lang = i18nInstance.language; const name = lang === "ar" ? app.nameAr : app.nameEn; @@ -103,8 +110,13 @@ function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconNa return ( <> -
+
+ {badgeCount > 0 && ( + + {badgeCount > 9 ? "9+" : badgeCount} + + )}
{name} @@ -113,7 +125,15 @@ function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconNa ); } -function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) { +function SortableAppIcon({ + app, + onClick, + badgeCount = 0, +}: { + app: App; + onClick: () => void; + badgeCount?: number; +}) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `app-${app.id}` }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), @@ -132,7 +152,7 @@ function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) { {...attributes} {...listeners} > - + ); } @@ -409,6 +429,13 @@ export default function HomePage() { const pendingOrdersCount = incomingOrders?.filter((o) => o.status === "pending" && !o.assignedTo).length ?? 0; + // Unread Notes badge — drives the count shown on the Notes app tile (and + // dock entry, if present). We reuse the existing /notes/received hook so + // socket invalidations on note_received automatically refresh this. + const { data: receivedNotes } = useReceivedNotes(false); + const unreadNotesCount = + receivedNotes?.filter((n) => n.status === "unread").length ?? 0; + const logout = useLogout(); const updateLanguage = useUpdateLanguage(); @@ -611,6 +638,7 @@ export default function HomePage() { key={app.id} app={app} onClick={() => openApp(app)} + badgeCount={app.slug === "notes" ? unreadNotesCount : 0} /> ); })} diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index c06c018d..13f8cdde 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -1601,6 +1601,38 @@ 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 a285e763..c45c77a7 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -33,6 +33,8 @@ import type { AppPermissionsImpact, AppPermissionsImpactBody, AppSettings, + ArchiveReceivedNote200, + ArchiveReceivedNoteBody, AuditLogList, AuthUser, BulkDeleteServiceOrdersBody, @@ -67,6 +69,7 @@ import type { GetAdminUserDependentOrdersParams, GetAppPermissionAuditParams, GetGroupPermissionAuditParams, + GetNoteDetail200, GetRolePermissionAuditParams, GetUserPermissionAuditParams, Group, @@ -77,14 +80,20 @@ import type { LeaveConversationBody, ListAuditLogsParams, ListGroupsParams, + ListReceivedNotes200Item, + ListReceivedNotesParams, + ListSentNotes200Item, ListUsersParams, LoginBody, + MarkNoteRead200, MessageWithSender, Notification, Permission, PermissionAuditList, RegisterBody, ReplaceRolePermissionsBody, + ReplyToNote201, + ReplyToNoteBody, RequestUploadUrlBody, RequestUploadUrlResponse, ResetPasswordBody, @@ -95,6 +104,8 @@ import type { RolePermissionsImpactBody, RoleUsage, SendMessageBody, + SendNote200, + SendNoteBody, Service, ServiceCategory, ServiceDeletionConflict, @@ -5059,10 +5070,15 @@ export const useBulkDeleteServiceOrders = < }; /** - * Permanently deletes the order. Allowed when the caller is an admin, -when the caller owns the order AND it is in a terminal status -(completed/cancelled), OR when the caller holds the `orders.receive` -permission (i.e. a receiver clearing items from their incoming queue). + * Permanently deletes the order. Allowed when: + - the caller is an admin (any order, any status); or + - the caller owns the order AND it is in a terminal status + (completed/cancelled); or + - the caller holds the `orders.receive` permission AND the order + is currently visible in their own incoming view — i.e. an + unclaimed pending order, or one they have themselves claimed + and is still active (received/preparing). Receivers cannot + delete another receiver's claimed order, nor terminal orders. * @summary Delete a service order */ @@ -8247,6 +8263,613 @@ export function useGetAdminServiceDependentOrders< return { ...query, queryKey: queryOptions.queryKey }; } +/** + * @summary List notes the caller has sent + */ +export const getListSentNotesUrl = () => { + return `/api/notes/sent`; +}; + +export const listSentNotes = async ( + options?: RequestInit, +): Promise => { + return customFetch(getListSentNotesUrl(), { + ...options, + method: "GET", + }); +}; + +export const getListSentNotesQueryKey = () => { + return [`/api/notes/sent`] as const; +}; + +export const getListSentNotesQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListSentNotesQueryKey(); + + const queryFn: QueryFunction>> = ({ + signal, + }) => listSentNotes({ signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListSentNotesQueryResult = NonNullable< + Awaited> +>; +export type ListSentNotesQueryError = ErrorType; + +/** + * @summary List notes the caller has sent + */ + +export function useListSentNotes< + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; +}): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListSentNotesQueryOptions(options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary List notes sent to the caller + */ +export const getListReceivedNotesUrl = (params?: ListReceivedNotesParams) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + if (value !== undefined) { + normalizedParams.append(key, value === null ? "null" : value.toString()); + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 + ? `/api/notes/received?${stringifiedParams}` + : `/api/notes/received`; +}; + +export const listReceivedNotes = async ( + params?: ListReceivedNotesParams, + options?: RequestInit, +): Promise => { + return customFetch( + getListReceivedNotesUrl(params), + { + ...options, + method: "GET", + }, + ); +}; + +export const getListReceivedNotesQueryKey = ( + params?: ListReceivedNotesParams, +) => { + return [`/api/notes/received`, ...(params ? [params] : [])] as const; +}; + +export const getListReceivedNotesQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + params?: ListReceivedNotesParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getListReceivedNotesQueryKey(params); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => listReceivedNotes(params, { signal, ...requestOptions }); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type ListReceivedNotesQueryResult = NonNullable< + Awaited> +>; +export type ListReceivedNotesQueryError = ErrorType; + +/** + * @summary List notes sent to the caller + */ + +export function useListReceivedNotes< + TData = Awaited>, + TError = ErrorType, +>( + params?: ListReceivedNotesParams, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getListReceivedNotesQueryOptions(params, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Get a note's full thread (note + recipients + replies) + */ +export const getGetNoteDetailUrl = (id: number) => { + return `/api/notes/${id}`; +}; + +export const getNoteDetail = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getGetNoteDetailUrl(id), { + ...options, + method: "GET", + }); +}; + +export const getGetNoteDetailQueryKey = (id: number) => { + return [`/api/notes/${id}`] as const; +}; + +export const getGetNoteDetailQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>( + id: number, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetNoteDetailQueryKey(id); + + const queryFn: QueryFunction>> = ({ + signal, + }) => getNoteDetail(id, { signal, ...requestOptions }); + + return { + queryKey, + queryFn, + enabled: !!id, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: QueryKey }; +}; + +export type GetNoteDetailQueryResult = NonNullable< + Awaited> +>; +export type GetNoteDetailQueryError = ErrorType; + +/** + * @summary Get a note's full thread (note + recipients + replies) + */ + +export function useGetNoteDetail< + TData = Awaited>, + TError = ErrorType, +>( + id: number, + options?: { + query?: UseQueryOptions< + Awaited>, + TError, + TData + >; + request?: SecondParameter; + }, +): UseQueryResult & { queryKey: QueryKey } { + const queryOptions = getGetNoteDetailQueryOptions(id, options); + + const query = useQuery(queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * @summary Owner sends an existing note to a list of recipients + */ +export const getSendNoteUrl = (id: number) => { + return `/api/notes/${id}/send`; +}; + +export const sendNote = async ( + id: number, + sendNoteBody: SendNoteBody, + options?: RequestInit, +): Promise => { + return customFetch(getSendNoteUrl(id), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(sendNoteBody), + }); +}; + +export const getSendNoteMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["sendNote"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return sendNote(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type SendNoteMutationResult = NonNullable< + Awaited> +>; +export type SendNoteMutationBody = BodyType; +export type SendNoteMutationError = ErrorType; + +/** + * @summary Owner sends an existing note to a list of recipients + */ +export const useSendNote = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getSendNoteMutationOptions(options)); +}; + +/** + * @summary Recipient marks their copy of a note read + */ +export const getMarkNoteReadUrl = (id: number) => { + return `/api/notes/${id}/read`; +}; + +export const markNoteRead = async ( + id: number, + options?: RequestInit, +): Promise => { + return customFetch(getMarkNoteReadUrl(id), { + ...options, + method: "POST", + }); +}; + +export const getMarkNoteReadMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext +> => { + const mutationKey = ["markNoteRead"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number } + > = (props) => { + const { id } = props ?? {}; + + return markNoteRead(id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type MarkNoteReadMutationResult = NonNullable< + Awaited> +>; + +export type MarkNoteReadMutationError = ErrorType; + +/** + * @summary Recipient marks their copy of a note read + */ +export const useMarkNoteRead = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number }, + TContext +> => { + return useMutation(getMarkNoteReadMutationOptions(options)); +}; + +/** + * @summary Recipient archives or unarchives their copy of a note + */ +export const getArchiveReceivedNoteUrl = (id: number) => { + return `/api/notes/${id}/archive`; +}; + +export const archiveReceivedNote = async ( + id: number, + archiveReceivedNoteBody: ArchiveReceivedNoteBody, + options?: RequestInit, +): Promise => { + return customFetch(getArchiveReceivedNoteUrl(id), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(archiveReceivedNoteBody), + }); +}; + +export const getArchiveReceivedNoteMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["archiveReceivedNote"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return archiveReceivedNote(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ArchiveReceivedNoteMutationResult = NonNullable< + Awaited> +>; +export type ArchiveReceivedNoteMutationBody = BodyType; +export type ArchiveReceivedNoteMutationError = ErrorType; + +/** + * @summary Recipient archives or unarchives their copy of a note + */ +export const useArchiveReceivedNote = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getArchiveReceivedNoteMutationOptions(options)); +}; + +/** + * @summary Sender or recipient adds a reply to a note's thread + */ +export const getReplyToNoteUrl = (id: number) => { + return `/api/notes/${id}/reply`; +}; + +export const replyToNote = async ( + id: number, + replyToNoteBody: ReplyToNoteBody, + options?: RequestInit, +): Promise => { + return customFetch(getReplyToNoteUrl(id), { + ...options, + method: "POST", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(replyToNoteBody), + }); +}; + +export const getReplyToNoteMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + const mutationKey = ["replyToNote"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { id: number; data: BodyType } + > = (props) => { + const { id, data } = props ?? {}; + + return replyToNote(id, data, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ReplyToNoteMutationResult = NonNullable< + Awaited> +>; +export type ReplyToNoteMutationBody = BodyType; +export type ReplyToNoteMutationError = ErrorType; + +/** + * @summary Sender or recipient adds a reply to a note's thread + */ +export const useReplyToNote = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext + >; + request?: SecondParameter; +}): UseMutationResult< + Awaited>, + TError, + { id: number; data: BodyType }, + TContext +> => { + return useMutation(getReplyToNoteMutationOptions(options)); +}; + /** * @summary List notes owned by a user */ diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index fb655807..2400a342 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -2732,6 +2732,161 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + # ----- Notes (in-app messaging) ----- + /notes/sent: + get: + operationId: listSentNotes + tags: [notes] + summary: List notes the caller has sent + responses: + "200": + description: Sent notes with per-recipient status + content: + application/json: + schema: + type: array + items: { type: object, additionalProperties: true } + /notes/received: + get: + operationId: listReceivedNotes + tags: [notes] + summary: List notes sent to the caller + parameters: + - in: query + name: archived + required: false + schema: { type: boolean, default: false } + responses: + "200": + description: Received notes with sender + status + content: + application/json: + schema: + type: array + items: { type: object, additionalProperties: true } + /notes/{id}: + get: + operationId: getNoteDetail + tags: [notes] + summary: Get a note's full thread (note + recipients + replies) + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: Note thread, scoped to caller (sender, recipient, or admin) + content: + application/json: + schema: { type: object, additionalProperties: true } + "403": + description: Not a participant + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + "404": + description: Note not found + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + /notes/{id}/send: + post: + operationId: sendNote + tags: [notes] + summary: Owner sends an existing note to a list of recipients + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recipientUserIds] + properties: + recipientUserIds: + type: array + items: { type: integer } + responses: + "200": + description: Sent (idempotent on existing recipients) + content: + application/json: + schema: { type: object, additionalProperties: true } + /notes/{id}/read: + post: + operationId: markNoteRead + tags: [notes] + summary: Recipient marks their copy of a note read + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + "200": + description: Marked read + content: + application/json: + schema: { type: object, additionalProperties: true } + /notes/{id}/archive: + post: + operationId: archiveReceivedNote + tags: [notes] + summary: Recipient archives or unarchives their copy of a note + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [archived] + properties: + archived: { type: boolean } + responses: + "200": + description: Archive state updated + content: + application/json: + schema: { type: object, additionalProperties: true } + /notes/{id}/reply: + post: + operationId: replyToNote + tags: [notes] + summary: Sender or recipient adds a reply to a note's thread + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [content] + properties: + content: { type: string } + recipientUserId: + type: integer + description: Required when the note owner replies and there is more than one recipient. + responses: + "201": + description: Reply created + content: + application/json: + schema: { type: object, additionalProperties: true } + /admin/users/{id}/dependents/notes: get: operationId: getAdminUserDependentNotes diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 7b24702a..d41b3423 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -2025,10 +2025,15 @@ export const BulkDeleteServiceOrdersResponse = zod.object({ }); /** - * Permanently deletes the order. Allowed when the caller is an admin, -when the caller owns the order AND it is in a terminal status -(completed/cancelled), OR when the caller holds the `orders.receive` -permission (i.e. a receiver clearing items from their incoming queue). + * Permanently deletes the order. Allowed when: + - the caller is an admin (any order, any status); or + - the caller owns the order AND it is in a terminal status + (completed/cancelled); or + - the caller holds the `orders.receive` permission AND the order + is currently visible in their own incoming view — i.e. an + unclaimed pending order, or one they have themselves claimed + and is still active (received/preparing). Receivers cannot + delete another receiver's claimed order, nor terminal orders. * @summary Delete a service order */ @@ -3323,6 +3328,96 @@ export const GetAdminServiceDependentOrdersResponse = zod.object({ nextOffset: zod.number().nullable(), }); +/** + * @summary List notes the caller has sent + */ +export const ListSentNotesResponseItem = zod.record( + zod.string(), + zod.unknown(), +); +export const ListSentNotesResponse = zod.array(ListSentNotesResponseItem); + +/** + * @summary List notes sent to the caller + */ +export const listReceivedNotesQueryArchivedDefault = false; + +export const ListReceivedNotesQueryParams = zod.object({ + archived: zod.coerce.boolean().default(listReceivedNotesQueryArchivedDefault), +}); + +export const ListReceivedNotesResponseItem = zod.record( + zod.string(), + zod.unknown(), +); +export const ListReceivedNotesResponse = zod.array( + ListReceivedNotesResponseItem, +); + +/** + * @summary Get a note's full thread (note + recipients + replies) + */ +export const GetNoteDetailParams = zod.object({ + id: zod.coerce.number(), +}); + +export const GetNoteDetailResponse = zod.record(zod.string(), zod.unknown()); + +/** + * @summary Owner sends an existing note to a list of recipients + */ +export const SendNoteParams = zod.object({ + id: zod.coerce.number(), +}); + +export const SendNoteBody = zod.object({ + recipientUserIds: zod.array(zod.number()), +}); + +export const SendNoteResponse = zod.record(zod.string(), zod.unknown()); + +/** + * @summary Recipient marks their copy of a note read + */ +export const MarkNoteReadParams = zod.object({ + id: zod.coerce.number(), +}); + +export const MarkNoteReadResponse = zod.record(zod.string(), zod.unknown()); + +/** + * @summary Recipient archives or unarchives their copy of a note + */ +export const ArchiveReceivedNoteParams = zod.object({ + id: zod.coerce.number(), +}); + +export const ArchiveReceivedNoteBody = zod.object({ + archived: zod.boolean(), +}); + +export const ArchiveReceivedNoteResponse = zod.record( + zod.string(), + zod.unknown(), +); + +/** + * @summary Sender or recipient adds a reply to a note's thread + */ +export const ReplyToNoteParams = zod.object({ + id: zod.coerce.number(), +}); + +export const ReplyToNoteBody = zod.object({ + content: zod.string(), + recipientUserId: zod + .number() + .optional() + .describe( + "Required when the note owner replies and there is more than one recipient.", + ), +}); + /** * @summary List notes owned by a user */