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.
Two prior code-review rounds were addressed in this commit:
Round 1 (independence):
- Added immutable snapshot columns (title/content/color) on note_recipients.
- Dropped the FK from 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 for
recipients (sender/admin still see the live note).
Round 2 (validation REJECT fixes):
- POST /notes/:id/reply: owner is now allowed to reply too. Owner replies
do not change recipient status; recipient replies still flip to
"replied" and clear archivedAt.
- Added GET /notes/:id as an alias of /notes/:id/thread (shared handler).
- Added OpenAPI ops for /notes/sent, /notes/received, /notes/{id},
/notes/{id}/send, /notes/{id}/read, /notes/{id}/archive,
/notes/{id}/reply, and ran orval codegen.
- use-notifications-socket.ts now shows bilingual toasts for note_received
and note_replied (suppressed during the socket warmup window).
- home.tsx renders an unread badge on the Notes app tile, fed by
useReceivedNotes(false) filtered to status === "unread"; refreshes
automatically on socket invalidation.
Tests: 7 backend tests in notes-share.test.mjs (independence,
archived-reply, owner-reply preserves recipient status, GET /notes/:id
alias, etc.) + the notes-inbox e2e all pass. tx-os typecheck is clean.
Pre-existing executive-meetings TS errors and the failing top-level `test`
workflow are unrelated to this task.
This commit is contained in:
@@ -380,7 +380,11 @@ 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).
|
||||
*/
|
||||
router.get("/notes/:id/thread", requireAuth, async (req, res): Promise<void> => {
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> =>
|
||||
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<void> =>
|
||||
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<void> =>
|
||||
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<void> =>
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className={`icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
|
||||
<div className={`relative icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
|
||||
<Icon size={30} color="#FFFFFF" />
|
||||
{badgeCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[18px] h-[18px] px-1 flex items-center justify-center font-bold tabular-nums shadow">
|
||||
{badgeCount > 9 ? "9+" : badgeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
|
||||
{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}
|
||||
>
|
||||
<AppIconContent app={app} />
|
||||
<AppIconContent app={app} badgeCount={badgeCount} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ListSentNotes200Item[]> => {
|
||||
return customFetch<ListSentNotes200Item[]>(getListSentNotesUrl(), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getListSentNotesQueryKey = () => {
|
||||
return [`/api/notes/sent`] as const;
|
||||
};
|
||||
|
||||
export const getListSentNotesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listSentNotes>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSentNotes>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListSentNotesQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listSentNotes>>> = ({
|
||||
signal,
|
||||
}) => listSentNotes({ signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSentNotes>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListSentNotesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listSentNotes>>
|
||||
>;
|
||||
export type ListSentNotesQueryError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary List notes the caller has sent
|
||||
*/
|
||||
|
||||
export function useListSentNotes<
|
||||
TData = Awaited<ReturnType<typeof listSentNotes>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSentNotes>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListSentNotesQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
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<ListReceivedNotes200Item[]> => {
|
||||
return customFetch<ListReceivedNotes200Item[]>(
|
||||
getListReceivedNotesUrl(params),
|
||||
{
|
||||
...options,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getListReceivedNotesQueryKey = (
|
||||
params?: ListReceivedNotesParams,
|
||||
) => {
|
||||
return [`/api/notes/received`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListReceivedNotesQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listReceivedNotes>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(
|
||||
params?: ListReceivedNotesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listReceivedNotes>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListReceivedNotesQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listReceivedNotes>>
|
||||
> = ({ signal }) => listReceivedNotes(params, { signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listReceivedNotes>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListReceivedNotesQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listReceivedNotes>>
|
||||
>;
|
||||
export type ListReceivedNotesQueryError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary List notes sent to the caller
|
||||
*/
|
||||
|
||||
export function useListReceivedNotes<
|
||||
TData = Awaited<ReturnType<typeof listReceivedNotes>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(
|
||||
params?: ListReceivedNotesParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listReceivedNotes>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListReceivedNotesQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
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<GetNoteDetail200> => {
|
||||
return customFetch<GetNoteDetail200>(getGetNoteDetailUrl(id), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetNoteDetailQueryKey = (id: number) => {
|
||||
return [`/api/notes/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetNoteDetailQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getNoteDetail>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNoteDetail>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetNoteDetailQueryKey(id);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getNoteDetail>>> = ({
|
||||
signal,
|
||||
}) => getNoteDetail(id, { signal, ...requestOptions });
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNoteDetail>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetNoteDetailQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getNoteDetail>>
|
||||
>;
|
||||
export type GetNoteDetailQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Get a note's full thread (note + recipients + replies)
|
||||
*/
|
||||
|
||||
export function useGetNoteDetail<
|
||||
TData = Awaited<ReturnType<typeof getNoteDetail>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
id: number,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNoteDetail>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetNoteDetailQueryOptions(id, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
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<SendNote200> => {
|
||||
return customFetch<SendNote200>(getSendNoteUrl(id), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(sendNoteBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getSendNoteMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof sendNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<SendNoteBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof sendNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<SendNoteBody> },
|
||||
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<ReturnType<typeof sendNote>>,
|
||||
{ id: number; data: BodyType<SendNoteBody> }
|
||||
> = (props) => {
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return sendNote(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type SendNoteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof sendNote>>
|
||||
>;
|
||||
export type SendNoteMutationBody = BodyType<SendNoteBody>;
|
||||
export type SendNoteMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Owner sends an existing note to a list of recipients
|
||||
*/
|
||||
export const useSendNote = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof sendNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<SendNoteBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof sendNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<SendNoteBody> },
|
||||
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<MarkNoteRead200> => {
|
||||
return customFetch<MarkNoteRead200>(getMarkNoteReadUrl(id), {
|
||||
...options,
|
||||
method: "POST",
|
||||
});
|
||||
};
|
||||
|
||||
export const getMarkNoteReadMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof markNoteRead>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof markNoteRead>>,
|
||||
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<ReturnType<typeof markNoteRead>>,
|
||||
{ id: number }
|
||||
> = (props) => {
|
||||
const { id } = props ?? {};
|
||||
|
||||
return markNoteRead(id, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type MarkNoteReadMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof markNoteRead>>
|
||||
>;
|
||||
|
||||
export type MarkNoteReadMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Recipient marks their copy of a note read
|
||||
*/
|
||||
export const useMarkNoteRead = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof markNoteRead>>,
|
||||
TError,
|
||||
{ id: number },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof markNoteRead>>,
|
||||
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<ArchiveReceivedNote200> => {
|
||||
return customFetch<ArchiveReceivedNote200>(getArchiveReceivedNoteUrl(id), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(archiveReceivedNoteBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getArchiveReceivedNoteMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof archiveReceivedNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<ArchiveReceivedNoteBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof archiveReceivedNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<ArchiveReceivedNoteBody> },
|
||||
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<ReturnType<typeof archiveReceivedNote>>,
|
||||
{ id: number; data: BodyType<ArchiveReceivedNoteBody> }
|
||||
> = (props) => {
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return archiveReceivedNote(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ArchiveReceivedNoteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof archiveReceivedNote>>
|
||||
>;
|
||||
export type ArchiveReceivedNoteMutationBody = BodyType<ArchiveReceivedNoteBody>;
|
||||
export type ArchiveReceivedNoteMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Recipient archives or unarchives their copy of a note
|
||||
*/
|
||||
export const useArchiveReceivedNote = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof archiveReceivedNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<ArchiveReceivedNoteBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof archiveReceivedNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<ArchiveReceivedNoteBody> },
|
||||
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<ReplyToNote201> => {
|
||||
return customFetch<ReplyToNote201>(getReplyToNoteUrl(id), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(replyToNoteBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getReplyToNoteMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof replyToNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<ReplyToNoteBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof replyToNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<ReplyToNoteBody> },
|
||||
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<ReturnType<typeof replyToNote>>,
|
||||
{ id: number; data: BodyType<ReplyToNoteBody> }
|
||||
> = (props) => {
|
||||
const { id, data } = props ?? {};
|
||||
|
||||
return replyToNote(id, data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type ReplyToNoteMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof replyToNote>>
|
||||
>;
|
||||
export type ReplyToNoteMutationBody = BodyType<ReplyToNoteBody>;
|
||||
export type ReplyToNoteMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Sender or recipient adds a reply to a note's thread
|
||||
*/
|
||||
export const useReplyToNote = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof replyToNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<ReplyToNoteBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof replyToNote>>,
|
||||
TError,
|
||||
{ id: number; data: BodyType<ReplyToNoteBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getReplyToNoteMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary List notes owned by a user
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user