main
/**
* Personal notification endpoints (`/me/notifications*`). The generated client
* inlines the paged row type, so `NotificationRow` is carved out of the list
* envelope instead of being hand-written; keep it in sync with `WsNotification`.
*/
import { unwrapApi } from '@app/lib'
import type { EnvelopePageNotificationDto, MarkNotificationsReadCmd, NotificationType } from '@app/services/api'
import Apis from '@app/services/api'
export type NotificationRow = NonNullable<NonNullable<EnvelopePageNotificationDto['data']>['items']>[number]
export const NOTIFICATIONS_PAGE_LIMIT = 50
export interface NotificationQuery {
offset?: number
unread?: boolean
type?: NotificationType
}
/**
* Unset filters are omitted rather than sent as `null`: Alova serialises a
* `null` query value to the literal string `"null"`, which fails the backend's
* `unread: Option<bool>` parse outright (400) and would silently match a branch
* or cursor named "null" where the type is a string. `false` is a real filter
* and must survive, hence the explicit `undefined` test.
*/
export async function listNotifications(query: NotificationQuery = {}): Promise<NotificationRow[]> {
const page = await unwrapApi<{ items?: NotificationRow[] }>(
Apis.notifications.list_notifications({
params: {
limit: NOTIFICATIONS_PAGE_LIMIT,
offset: query.offset ?? 0,
...(query.unread === undefined ? {} : { unread: query.unread }),
...(query.type ? { type: query.type } : {}),
},
}),
)
return page?.items ?? []
}
export const markNotificationsRead = (cmd: MarkNotificationsReadCmd) =>
unwrapApi(Apis.account.mark_notifications_read({ data: cmd }))
export const deleteNotification = (notificationId: string) =>
unwrapApi(Apis.notifications.delete_notification({ pathParams: { notification_id: notificationId } }))
/*
* ------------------------------------------------------------------------
* The inbox is a *personal* list: it holds the things that ask something of
* you (an invite, an assignment, a reminder) and leaves the conversation
* traffic where it happened. Mentions and replies are addressed to a channel
* or a ticket, and both already surface there, so keeping them here would
* turn the page into a second, noisier copy of the transcript.
* ------------------------------------------------------------------------
*/
/** Kinds the inbox hides; they still arrive as realtime toasts. */
export const INBOX_HIDDEN_TYPES: readonly NotificationType[] = ['mention', 'reply']
export function isInboxNotification(row: Pick<NotificationRow, 'notification_type'>): boolean {
return !INBOX_HIDDEN_TYPES.includes(row.notification_type)
}
/** The list the inbox renders: the server has no "not one of" filter. */
export async function listInboxNotifications(query: NotificationQuery = {}): Promise<NotificationRow[]> {
const rows = await listNotifications(query)
return rows.filter(isInboxNotification)
}
/**
* Unread total *for this page*, so the bell never promises more rows than the
* inbox shows. Capped at one page: `capped` lets the badge read "50+" instead
* of lying with a smaller number.
*/
export async function inboxUnreadCount(): Promise<{ count: number; capped: boolean }> {
const rows = await listInboxNotifications({ unread: true })
return { count: rows.length, capped: rows.length >= NOTIFICATIONS_PAGE_LIMIT }
}
/* ------------------------------------------------------------------ invites */
/**
* Accepting an invite is the same call the invite email's link makes: the
* notification carries the very code that email does, so the inbox can act on
* an invite instead of forwarding you to your mailbox.
*/
export const acceptSpaceInvite = (token: string) => unwrapApi(Apis.space.space_invite_redeem({ data: { token } }))