/** * 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['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` 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 { 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): 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 { 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 } }))