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:
riyadhafraa
2026-05-05 15:01:18 +00:00
parent 6352cbf844
commit 65c5a172d6
8 changed files with 1102 additions and 37 deletions
@@ -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
+627 -4
View File
@@ -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
*/
+155
View File
@@ -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
+99 -4
View File
@@ -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
*/