From 46abc31740f6df3ff968a946b05aff1ad01dd07b Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 16 Sep 2026 13:50:26 +0100 Subject: [PATCH 1/4] feat(notifications): let realtime broadcasts wake a device and supersede themselves The Firebase driver sent `data: { event, payload }` and nothing else. With no `android.priority`, a data message goes out at normal priority, which Doze and App Standby are free to hold until the next maintenance window -- exactly the state a device is in when something time-critical needs to reach it. The typed messaging slice also made this unreachable from outside: there was no parameter to pass a priority through, so it could not be worked around downstream. Delivery options `.priority()`, `.ttl()` and `.collapseKey()` on the realtime builder, carried to the driver through a new optional `delivery` argument on `RealtimeDriver`. Each hint converts to the unit its platform expects -- Android TTL in milliseconds, `apns-expiration` as an absolute epoch second, Web Push in seconds. Defaults can be set under `drivers.realtime.delivery` or per transport, and raw `android` / `apns` / `webpush` blocks pass through for anything unmapped. Pusher accepts and ignores them; it holds an open connection, so there is no dozing radio to wake. Priority reaches APNs only once the message is a visible push, which the caller declares through the `apns` escape hatch. Apple requires a background push -- what a data-only message becomes on iOS -- to be priority 5 or 1, so deriving 10 there would send APNs something it is documented to reject. Supersession `.tag()` gives a notification a stable identity so a later one replaces it rather than stacking beside it, and `.retract()` withdraws one outright. Retractions are never persisted. `tag` is deliberately not wired to `collapseKey`: a tag is an unbounded client-side identity, while FCM keeps only four collapse keys per device and silently evicts the rest. `@arkstack/realtime` gains `supersede()`, applied by the React and Vue bindings and exported for hand-rolled lists. Custom transports `driverFactory` supplies a `RealtimeDriver` of your own, mirroring the client's existing `transportFactory`; `broadcast()` now accepts payloads that are not notifications. Together these let an application reach a transport this package does not ship -- APNs or PushKit, say -- without forking it. Additive throughout: an unhinted broadcast produces a byte-identical message, and a driver written against the old three-parameter contract still compiles. --- docs/api.md | 11 +- docs/guide/notifications.md | 59 ++++ .../src/Contracts/RealtimeDriver.ts | 9 +- .../src/drivers/RealtimeNotification.ts | 146 ++++++++- .../realtime/FirebaseRealtimeDriver.ts | 170 +++++++++- .../drivers/realtime/PusherRealtimeDriver.ts | 20 +- packages/notifications/src/types.ts | 91 ++++- packages/notifications/tests/realtime.test.ts | 310 +++++++++++++++++- packages/realtime/src/index.ts | 1 + packages/realtime/src/react/index.ts | 13 +- packages/realtime/src/supersede.ts | 51 +++ packages/realtime/src/types.ts | 8 + packages/realtime/src/vue/index.ts | 9 +- packages/realtime/tests/supersede.test.ts | 101 ++++++ 14 files changed, 964 insertions(+), 35 deletions(-) create mode 100644 packages/realtime/src/supersede.ts create mode 100644 packages/realtime/tests/supersede.test.ts diff --git a/docs/api.md b/docs/api.md index 9c851d99..793dba7b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -222,7 +222,7 @@ Both middlewares expect an `Authorization: Bearer ` header. - `Notification.mail(options?)` / `Notification.email(options?)` — create a mail notification driver. - `Notification.sms(options?)` — create an SMS notification driver. - `Notification.db()` — create a database notification driver. -- `Notification.realtime(options?)` — create a realtime (Pusher/Firebase) broadcast driver. +- `Notification.realtime(options?)` — create a realtime (Pusher/Firebase) broadcast driver. Pass `driverFactory` to supply your own `RealtimeDriver` instead; `broadcast()` accepts non-notification payloads too. - `Notification.channel(channel?, options?)` — create a driver from a channel or `notifications.default_driver`. - `new Notification(channel, options?).prepare(recipient, data?)` — prepare a driver using a user-like recipient or direct address. @@ -236,6 +236,12 @@ Broadcasts a notification to connected clients over Pusher Channels or Firebase - `.channel(name | names[])` / `.event(name)` — override the channel(s) and the client event name (default `notification`). An array fans out to multiple Pusher channels, or is treated as Firebase device tokens (multicast, chunked to 500, returning `invalidTokens` to prune). - `.type(type)` / `.action(text, link)` / `.meta(data)` — build the payload. - `.store(enabled?)` — also persist the notification (requires a `User` recipient) so clients can load history. +- `.priority(level?)` — `high` (default) exempts the message from Android Doze / App Standby, which otherwise defer a normal-priority FCM data message. Always maps to Android and Web Push, and to APNs once the message is a visible push; see [the iOS note](/guide/notifications#delivery-options). +- `.ttl(seconds)` — how long the message stays worth delivering (FCM's own default is four weeks). `0` means now-or-never. Converted per platform. +- `.collapseKey(key)` — supersede an earlier undelivered message with the same key instead of stacking beside it. +- `.delivery(options)` — merge raw delivery options, including per-platform `android` / `apns` / `webpush` escape hatches. +- `.tag(tag)` — stable identity for what the notification is about; a later notification with the same tag supersedes it on the client. Independent of `collapseKey` (queue-level, four per device). +- `.retract(enabled?)` — send an instruction to remove the notification carrying this tag instead of one to display. Never persisted. Prefer superseding with replacement content where there is any. - `.send(message, subject?, recipient?, data?)` — broadcast; resolves to `{ channel, event, payload, stored? }`. ### Notification Config @@ -244,7 +250,7 @@ Broadcasts a notification to connected clients over Pusher Channels or Firebase - `notifications.drivers.mail.transport` — mail transport name, usually `smtp`. - `notifications.drivers.sms.transport` — SMS transport name, `africastalking` or `twilio`. - `notifications.drivers.db.table` — database notifications table name. -- `notifications.drivers.realtime` — realtime `transport` (`pusher` | `firebase`), `channel_prefix`, `event`, and default `store`. +- `notifications.drivers.realtime` — realtime `transport` (`pusher` | `firebase`), `channel_prefix`, `event`, default `store`, default `delivery` (`priority`, `ttl`, `collapseKey`), and `driverFactory` to supply a transport of your own. - `notifications.transports.smtp` — SMTP connection options. - `notifications.transports.africastalking` — AfricasTalking credentials. - `notifications.transports.twilio` — Twilio credentials. @@ -260,6 +266,7 @@ The framework-neutral client for consuming realtime notifications, with React an - `createRealtime(config)` — create a `RealtimeClient` (config: `transport`, `event`, `channelPrefix`, `pusher`/`firebase`, or a custom `transportFactory`). - `client.subscribe(channel, handler)` / `client.forUser(userId, handler)` — subscribe; returns an unsubscribe function. - `client.channelFor(userId)` — the per-user channel name; `client.disconnect()` — tear down the connection. +- `supersede(list, incoming, limit?)` — fold a notification into a list, replacing a same-tag entry in place, removing what a retraction names, and prepending otherwise. Applied for you by both bindings. - `@arkstack/realtime/react` — `useNotifications(client, channel, { limit? })` → `{ notifications, latest, clear }`. - `@arkstack/realtime/vue` — `useNotifications(client, channel, { limit? })` → `{ notifications, latest, clear, stop }`. diff --git a/docs/guide/notifications.md b/docs/guide/notifications.md index 03186a08..95ffa5e5 100644 --- a/docs/guide/notifications.md +++ b/docs/guide/notifications.md @@ -175,6 +175,57 @@ await Notification.realtime({ transport: 'firebase' }) .send('You have a new message'); ``` +### Delivery options + +FCM sends a data message at normal priority, and Android's Doze and App Standby may hold it until the next maintenance window. `.priority('high')` exempts it, and pairs naturally with a short TTL — FCM's own default is four weeks: + +```ts +await Notification.realtime({ transport: 'firebase' }) + .channel(user.deviceTokens) + .priority('high') + .ttl(60) + .collapseKey(`signin:${attemptId}`) // a retry replaces the prompt it repeats + .send('Approve sign-in from Lagos?'); +``` + +Pass TTL in seconds; each platform's unit is converted for you. Set defaults under `drivers.realtime.delivery`, override per send, and reach anything unmapped through `.delivery({ android, apns, webpush })`. Pusher ignores all of it — it pushes over a connection the client already holds open. + +On iOS this maps to APNs only for a visible push (`apns-push-type: alert`), since Apple requires background pushes to be low priority. Ringing or full-screen alerts need PushKit, which FCM cannot address at all. + +### Superseding a notification + +`.tag()` gives a notification a stable identity, so a later one with the same tag replaces it on the client instead of stacking beside it: + +```ts +// asked +await Notification.realtime({ transport: 'firebase' }) + .channel(tokens) + .tag(`signin:${attemptId}`) + .collapseKey(`signin:${attemptId}`) + .priority('high') + .ttl(60) + .send('Approve sign-in from Lagos?'); + +// answered elsewhere +await Notification.realtime({ transport: 'firebase' }) + .channel(tokens) + .tag(`signin:${attemptId}`) + .collapseKey(`signin:${attemptId}`) + .send('Sign-in approved'); +``` + +Tag by the thing it is about (`order:42`), not the message. `tag` supersedes what was **displayed**; `collapseKey` supersedes what is still **queued** — set both when you want both. They stay separate because FCM allows only four collapse keys per device, so deriving one per tag would starve that budget. + +`.retract()` removes a notification with nothing in its place. Prefer a replacement where the outcome has content of its own: a retraction must travel as a silent push, which platforms throttle hardest. Retractions are never stored, and retracting does not touch a row already written by `.store()`. + +The React and Vue bindings apply this for you; for your own list, use the same reducer: + +```ts +import { supersede } from '@arkstack/realtime'; + +client.subscribe(channel, (n) => setItems((items) => supersede(items, n, 50))); +``` + Configure the transport in `src/config/notifications.ts` (`drivers.realtime`) and its credentials under `transports.pusher` / `transports.firebase`. The `pusher` / `firebase-admin` SDKs are optional, install only the one you use: ```sh @@ -182,6 +233,14 @@ pnpm add pusher # Pusher transport pnpm add firebase-admin # Firebase transport ``` +**Bringing your own transport.** `driverFactory` replaces the built-ins with anything implementing `RealtimeDriver` — a backend this package does not ship, or a fake in a test. Set it per send, or under `drivers.realtime` for the whole app; it takes precedence over `transport`. Do connection setup lazily inside the driver, as the bundled ones do. + +```ts +Notification.realtime({ driverFactory: () => new MyApnsDriver() }); +``` + +`broadcast()` also accepts payloads that are not notifications, so an application can push its own event shapes over the same channel and pick them up with the client's `listen()`. + **Firebase credentials** can be provided two ways. Point `admin_sdk_path` (`FIREBASE_ADMINSDK`, default `firebase-adminsdk.json`, resolved from the project root) at a downloaded service-account JSON file; if that file is absent, the driver falls back to the inline `project_id` / `client_email` / `private_key` values (`FIREBASE_PROJECT_ID` / `FIREBASE_CLIENT_EMAIL` / `FIREBASE_PRIVATE_KEY`). `app_name` (`FIREBASE_APP_NAME`, default your `APP_NAME`) names the Firebase Admin app instance so repeated broadcasts reuse it. ### Consuming on the client diff --git a/packages/notifications/src/Contracts/RealtimeDriver.ts b/packages/notifications/src/Contracts/RealtimeDriver.ts index 184d98d6..a8856638 100644 --- a/packages/notifications/src/Contracts/RealtimeDriver.ts +++ b/packages/notifications/src/Contracts/RealtimeDriver.ts @@ -1,4 +1,4 @@ -import type { RealtimeDriverName, RealtimeNotificationPayload } from '../types' +import type { RealtimeBroadcastPayload, RealtimeDeliveryOptions, RealtimeDriverName } from '../types' import type { FirebaseRealtimeDriver } from '../drivers/realtime/FirebaseRealtimeDriver' import type { PusherRealtimeDriver } from '../drivers/realtime/PusherRealtimeDriver' @@ -10,12 +10,17 @@ import type { PusherRealtimeDriver } from '../drivers/realtime/PusherRealtimeDri * `channel` may be an array: for Pusher it fans out to multiple channels, and * for Firebase it is treated as a list of device registration tokens delivered * via a multicast send. + * + * `delivery` carries transport-level hints — priority, TTL, collapse key. It is + * optional so existing implementations stay source-compatible, and transports + * without a notion of priority (Pusher) are free to ignore it. */ export interface RealtimeDriver { broadcast( channel: string | string[], event: string, - payload: RealtimeNotificationPayload, + payload: RealtimeBroadcastPayload, + delivery?: RealtimeDeliveryOptions, ): Promise auth( socketId: string, diff --git a/packages/notifications/src/drivers/RealtimeNotification.ts b/packages/notifications/src/drivers/RealtimeNotification.ts index fb777e95..f6237487 100644 --- a/packages/notifications/src/drivers/RealtimeNotification.ts +++ b/packages/notifications/src/drivers/RealtimeNotification.ts @@ -4,6 +4,7 @@ import type { NotificationData, NotificationRecipient, RealtimeBroadcastResult, + RealtimeDeliveryOptions, RealtimeDriverName, RealtimeDriverOptions, RealtimeNotificationPayload, @@ -12,7 +13,7 @@ import type { import { FirebaseRealtimeDriver } from './realtime/FirebaseRealtimeDriver' import { NotificationContract } from '../Contracts/NotificationContract' import { PusherRealtimeDriver } from './realtime/PusherRealtimeDriver' -import type { RealtimeNotificationDriver } from '../Contracts/RealtimeDriver' +import type { RealtimeDriver, RealtimeNotificationDriver } from '../Contracts/RealtimeDriver' import type { User } from '@app/models/User' import { UserNotificationCenter } from '../UserNotificationCenter' import { configure } from '../config' @@ -26,8 +27,10 @@ import { randomUUID } from 'node:crypto' */ export class RealtimeNotification extends NotificationContract { - /** - * The underlying transport; assignable so tests can inject a fake. + /** + * The underlying transport. Assignable, and configurable up front through + * `driverFactory`, so a transport this package does not ship — or a fake in a + * test — can stand in for the built-ins. */ driver: RealtimeNotificationDriver private user?: User @@ -35,6 +38,9 @@ export class RealtimeNotification private eventName: string private channelPrefix: string private shouldStore: boolean + private tagName?: string + private isRetraction = false + private deliveryOptions: RealtimeDeliveryOptions private payload: Partial = {} constructor(options: RealtimeDriverOptions = {}) { @@ -45,6 +51,8 @@ export class RealtimeNotification channel_prefix?: string event?: string store?: boolean + delivery?: RealtimeDeliveryOptions + driverFactory?: () => RealtimeDriver } const transport = options.transport ?? driverConfig?.transport ?? 'pusher' const transportConfig = configure(`transports.${transport}` as never, {}) as Record @@ -53,10 +61,17 @@ export class RealtimeNotification this.eventName = options.event ?? driverConfig?.event ?? 'notification' this.channelPrefix = driverConfig?.channel_prefix ?? 'user.' this.shouldStore = options.store ?? driverConfig?.store ?? false + this.deliveryOptions = { ...driverConfig?.delivery, ...options.delivery } - this.driver = (transport === 'firebase' - ? new FirebaseRealtimeDriver({ ...transportConfig, ...options.firebase }) - : new PusherRealtimeDriver({ ...transportConfig, ...options.pusher }) + // A supplied driver wins over `transport`: naming one of the built-ins is + // meaningless when the caller is bringing their own. + const factory = options.driverFactory ?? driverConfig?.driverFactory + + this.driver = (factory + ? factory() + : transport === 'firebase' + ? new FirebaseRealtimeDriver({ ...transportConfig, ...options.firebase }) + : new PusherRealtimeDriver({ ...transportConfig, ...options.pusher }) ) as RealtimeNotificationDriver } @@ -170,6 +185,112 @@ export class RealtimeNotification return this } + /** + * How hard the transport should work to deliver this message. + * + * FCM sends a data message at normal priority by default, and Doze and App + * Standby may hold a normal-priority message until the next maintenance + * window — which is precisely the state a phone is in when something needs to + * wake it. `high` is what exempts the message. + * + * FCM budgets high-priority sends, so reserve it for messages the user is + * actually waiting on. Pusher ignores this. + * + * @param priority + * @returns + */ + priority(priority: 'normal' | 'high' = 'high'): this { + this.deliveryOptions.priority = priority + + return this + } + + /** + * How long, in seconds, this message is still worth delivering. + * + * FCM keeps a message for four weeks by default. Anything time-critical wants + * far less — a signal that arrives after the moment has passed is worse than + * one that never arrives — so pair a short TTL with `priority('high')`. `0` + * means deliver now or drop it. + * + * @param seconds + * @returns + */ + ttl(seconds: number): this { + this.deliveryOptions.ttl = seconds + + return this + } + + /** + * Supersede an earlier undelivered message instead of stacking beside it. + * Key it by the thing being signalled — an approval id, say — so a repeat replaces + * the message it repeats. + * + * @param key + * @returns + */ + collapseKey(key: string): this { + this.deliveryOptions.collapseKey = key + + return this + } + + /** + * Merge raw delivery options, including the per-platform `android` / `apns` / + * `webpush` escape hatches for anything the helpers above do not cover. + * + * @param delivery + * @returns + */ + delivery(delivery: RealtimeDeliveryOptions): this { + this.deliveryOptions = { ...this.deliveryOptions, ...delivery } + + return this + } + + /** + * Give this notification a stable identity, so a later one can supersede it. + * + * Tag by the thing the notification is *about* — `order:42`, `incident:7` — + * not by the message. A client that already showed a notification with this + * tag replaces it in place instead of stacking a second one beside it. + * + * This is a client-side identity and is deliberately **not** wired to + * `collapseKey`: a collapse key is a transport queue slot, and FCM keeps only + * four per device, so auto-deriving one per tag would quietly evict others. + * When you want both — supersede what is queued *and* what was displayed — + * set both to the same value. + * + * @param tag + * @returns + */ + tag(tag: string): this { + this.tagName = tag + + return this + } + + /** + * Send an instruction to remove the notification carrying this tag, rather + * than a notification to display. Nothing is persisted, even under `store()`. + * + * Reach for this only when there is genuinely nothing left to say. Where the + * outcome has its own content — an approval answered elsewhere, a check that + * went from failing to passing — supersede it by sending that content under + * the same `tag()`. A replacement travels as an ordinary notification, while a + * retraction has nothing to display and so must travel silently, which is + * exactly what Doze, App Standby and iOS throttle hardest. + * + * @param retract + * @returns + */ + retract(retract = true): this { + this.isRetraction = retract + + return this + } + private resolveChannel(): string | string[] { if (this.channelName !== undefined) { return this.channelName @@ -192,7 +313,8 @@ export class RealtimeNotification * @returns */ async send( - message: string, + // Optional so a retraction, which displays nothing, can `send()` bare. + message: string = '', subject?: string, _recipient?: NotificationRecipient, data?: NotificationData, @@ -211,7 +333,9 @@ export class RealtimeNotification // Opt-in persistence gives the payload a real id + timestamps and lets the // client load history alongside the live broadcast. - const stored = this.shouldStore && this.user + // A retraction withdraws a notification; storing one as a notification in + // its own right would leave the history saying the opposite of the truth. + const stored = this.shouldStore && this.user && !this.isRetraction ? await UserNotificationCenter.create(this.user, base) : undefined @@ -223,12 +347,16 @@ export class RealtimeNotification actionText: base.actionText ?? null, actionLink: base.actionLink ?? null, meta: base.meta ?? null, + // Both are omitted rather than nulled when unused, so an untagged + // broadcast stays byte-for-byte what it has always been on the wire. + ...(this.tagName !== undefined ? { tag: this.tagName } : {}), + ...(this.isRetraction ? { retracted: true } : {}), read_at: stored?.readAt ? new Date(stored.readAt).toISOString() : null, created_at: stored?.createdAt ? new Date(stored.createdAt).toISOString() : new Date().toISOString(), } if (channel && channel.length > 0) - await this.driver.broadcast(channel, this.eventName, payload) + await this.driver.broadcast(channel, this.eventName, payload, this.deliveryOptions) return { channel, event: this.eventName, payload, stored } } diff --git a/packages/notifications/src/drivers/realtime/FirebaseRealtimeDriver.ts b/packages/notifications/src/drivers/realtime/FirebaseRealtimeDriver.ts index 7d813fdb..089b36ad 100644 --- a/packages/notifications/src/drivers/realtime/FirebaseRealtimeDriver.ts +++ b/packages/notifications/src/drivers/realtime/FirebaseRealtimeDriver.ts @@ -1,4 +1,4 @@ -import type { FirebaseTransportConfig, RealtimeNotificationPayload } from '../../types' +import type { FirebaseTransportConfig, RealtimeBroadcastPayload, RealtimeDeliveryOptions } from '../../types' import { existsSync, readFileSync } from 'node:fs' import type { RealtimeDriver } from '../../Contracts/RealtimeDriver' @@ -11,9 +11,23 @@ interface FirebaseSendResponse { error?: { code?: string } } +/** + * The platform blocks FCM accepts alongside `data`. Typed as open records on + * purpose — the admin SDK's own config types are far richer than this driver + * models, and keeping these loose lets a caller pass anything FCM supports + * without this slice having to track the SDK version for version. + */ +interface FirebaseMessageOverrides { + android?: Record + apns?: Record + webpush?: Record +} + interface FirebaseMessaging { - send(message: { topic: string, data: Record }): Promise - sendEachForMulticast(message: { tokens: string[], data: Record }): Promise<{ + send(message: { topic: string, data: Record } & FirebaseMessageOverrides): Promise + sendEachForMulticast( + message: { tokens: string[], data: Record } & FirebaseMessageOverrides, + ): Promise<{ successCount: number failureCount: number responses: FirebaseSendResponse[] @@ -102,19 +116,156 @@ export class FirebaseRealtimeDriver implements RealtimeDriver { return this.messagingPromise } - async broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload) { + async broadcast( + channel: string | string[], + event: string, + payload: RealtimeBroadcastPayload, + delivery?: RealtimeDeliveryOptions, + ) { const messaging = await this.messaging() const data = { event, payload: JSON.stringify(payload) } + const overrides = this.overridesFor(delivery) // An array of channels = device registration tokens → multicast. if (Array.isArray(channel)) { - return await this.multicast(messaging, channel, data) + return await this.multicast(messaging, channel, data, overrides) } // FCM topic names allow only `[a-zA-Z0-9-_.~%]`. const topic = channel.replace(/[^a-zA-Z0-9-_.~%]/g, '_') - return await messaging.send({ topic, data }) + return await messaging.send({ topic, data, ...overrides }) + } + + /** + * Translate transport-agnostic delivery hints into FCM's platform blocks. + * + * Per-send options win over the transport defaults, and the raw `android` / + * `apns` / `webpush` escape hatches are merged last so a caller can always + * override what this derives. + * + * `priority` always maps to Android and Web Push. Whether it maps to APNs + * depends on the message rather than on the platform: Apple requires a + * background push to be priority 5 or 1, and a data-only message — what this + * driver sends by default — arrives on iOS as exactly that. So the header is + * derived only once the message is demonstrably a *visible* push, which a + * caller declares through the `apns` escape hatch. Guessing otherwise would + * mean sending APNs something it is documented to reject. + * + * @param delivery + * @returns + */ + private overridesFor(delivery?: RealtimeDeliveryOptions): FirebaseMessageOverrides { + const base = this.options.delivery ?? {} + const merged: RealtimeDeliveryOptions = { + ...base, + ...delivery, + android: { ...base.android, ...delivery?.android }, + apns: { ...base.apns, ...delivery?.apns }, + webpush: { ...base.webpush, ...delivery?.webpush }, + } + + const android: Record = {} + const apnsHeaders: Record = {} + const webpushHeaders: Record = {} + + if (merged.priority) { + android.priority = merged.priority + webpushHeaders.Urgency = merged.priority + + // APNs takes a priority only where one is legal — see `isVisiblePush`. + if (this.isVisiblePush(merged.apns)) { + apnsHeaders['apns-priority'] = merged.priority === 'high' ? '10' : '5' + } + } + + if (merged.collapseKey) { + android.collapseKey = merged.collapseKey + apnsHeaders['apns-collapse-id'] = merged.collapseKey + } + + if (typeof merged.ttl === 'number') { + // The admin SDK takes an Android TTL as a duration in milliseconds … + android.ttl = merged.ttl * 1000 + // … while apns-expiration is an absolute UNIX second, and 0 means + // now-or-never rather than "expires at the epoch". + apnsHeaders['apns-expiration'] = merged.ttl === 0 + ? '0' + : String(Math.floor(Date.now() / 1000) + merged.ttl) + // Web Push TTL is a duration in seconds. + webpushHeaders.TTL = String(merged.ttl) + } + + return { + // Android carries its settings flat on the block … + ...this.flatBlock('android', android, merged.android), + // … while APNs and Web Push nest theirs under `headers`. + ...this.headerBlock('apns', apnsHeaders, merged.apns), + ...this.headerBlock('webpush', webpushHeaders, merged.webpush), + } + } + + /** + * Whether the caller has built a push iOS will actually display. + * + * Apple allows `apns-priority: 10` only on a push that shows the user + * something; a background push must be 5 or 1. A data-only message is a + * background push, so this is false for the default this driver sends, and + * true once a caller has declared an alert through the `apns` escape hatch — + * by push type, or by supplying an alert payload. + * + * @param apns + * @returns + */ + private isVisiblePush(apns: Record | undefined): boolean { + const headers = apns?.headers as Record | undefined + const aps = (apns?.payload as { aps?: Record } | undefined)?.aps + + return headers?.['apns-push-type'] === 'alert' || aps?.alert !== undefined + } + + /** + * Fold a caller's raw override over the derived settings, dropping the block + * entirely when neither contributed anything. + * + * @param key + * @param derived + * @param override + * @returns + */ + private flatBlock( + key: 'android', + derived: Record, + override: Record | undefined, + ): FirebaseMessageOverrides { + const block = { ...derived, ...override } + + return Object.keys(block).length > 0 ? { [key]: block } : {} + } + + /** + * As `flatBlock`, but for the platforms whose settings live under `headers` — + * merged key by key, so an override adding one header keeps the rest. + * + * @param key + * @param derived + * @param override + * @returns + */ + private headerBlock( + key: 'apns' | 'webpush', + derived: Record, + override: Record | undefined, + ): FirebaseMessageOverrides { + const { headers: overrideHeaders, ...rest } = (override ?? {}) as { headers?: Record } + const headers = { ...derived, ...overrideHeaders } + const block: Record = { ...rest } + + if (Object.keys(headers).length > 0) { + block.headers = headers + } + + return Object.keys(block).length > 0 ? { [key]: block } : {} } /** @@ -162,12 +313,17 @@ export class FirebaseRealtimeDriver implements RealtimeDriver { messaging: FirebaseMessaging, tokens: string[], data: Record, + overrides: FirebaseMessageOverrides = {}, ): Promise { const result: FirebaseMulticastResult = { successCount: 0, failureCount: 0, invalidTokens: [] } for (let i = 0; i < tokens.length; i += MULTICAST_LIMIT) { const batch = tokens.slice(i, i + MULTICAST_LIMIT) - const response = await messaging.sendEachForMulticast({ tokens: batch, data }) + const response = await messaging.sendEachForMulticast({ + tokens: batch, + data, + ...overrides + }) result.successCount += response.successCount result.failureCount += response.failureCount diff --git a/packages/notifications/src/drivers/realtime/PusherRealtimeDriver.ts b/packages/notifications/src/drivers/realtime/PusherRealtimeDriver.ts index 49fc1cb7..f032bb4f 100644 --- a/packages/notifications/src/drivers/realtime/PusherRealtimeDriver.ts +++ b/packages/notifications/src/drivers/realtime/PusherRealtimeDriver.ts @@ -1,4 +1,4 @@ -import type { PusherTransportConfig, RealtimeNotificationPayload } from '../../types' +import type { PusherTransportConfig, RealtimeBroadcastPayload, RealtimeDeliveryOptions } from '../../types' import { RequestException, env } from '@arkstack/common' import type { RealtimeDriver } from '../../Contracts/RealtimeDriver' @@ -64,7 +64,23 @@ export class PusherRealtimeDriver implements RealtimeDriver { return this.clientPromise } - async broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload) { + /** + * `delivery` is accepted for contract parity and ignored: Pusher pushes over a + * connection the client already holds open, so there is no dozing radio to + * wake and no per-message priority, TTL or collapse key to set. + * + * @param channel + * @param event + * @param payload + * @param _delivery + * @returns + */ + async broadcast( + channel: string | string[], + event: string, + payload: RealtimeBroadcastPayload, + _delivery?: RealtimeDeliveryOptions, + ) { const client = await this.client() // Pusher's `trigger` fans out to multiple channels when given an array. diff --git a/packages/notifications/src/types.ts b/packages/notifications/src/types.ts index 508e15d0..0d78bff9 100644 --- a/packages/notifications/src/types.ts +++ b/packages/notifications/src/types.ts @@ -3,6 +3,7 @@ import type { TransportOptions, Transporter } from 'nodemailer' import type { Logger } from 'nodemailer/lib/shared' import MailMessage from 'nodemailer/lib/mailer/mail-message' import type { MergedConfig } from '@arkstack/common' +import type { RealtimeDriver } from './Contracts/RealtimeDriver' import type { UserNotification } from '@app/models/UserNotification' export type NotificationRecipient = string | string[] @@ -85,6 +86,54 @@ export type FirebaseTransportConfig = { private_key?: string app_name?: string admin_sdk_path?: string + /** Delivery options applied to every send on this transport. */ + delivery?: RealtimeDeliveryOptions +} + +/** + * Per-send delivery hints for push transports. + * + * FCM defaults a data message to normal priority, which Doze and App Standby are + * free to defer until the next maintenance window — exactly the state a phone is + * in when something time-critical — an approval someone is waiting on, an alert + * about to go stale — needs to wake it. + * `priority: 'high'` is what exempts the message. + * + * Only the Firebase transport acts on these; Pusher holds an open connection and + * has no equivalent, so it ignores them. + */ +export type RealtimeDeliveryOptions = { + /** + * `high` wakes a dozing device (FCM `android.priority`, APNs priority 10). + * Defaults to `normal`. Budgeted by FCM — reserve it for messages a user is + * actually waiting on. + */ + priority?: 'normal' | 'high' + /** + * How long, in **seconds**, the message stays worth delivering. FCM's own + * default is four weeks; anything time-critical wants far less, so a stale + * signal expires instead of surfacing long after the moment it described has + * passed. `0` asks for now-or-never. + */ + ttl?: number + /** + * Supersede an undelivered message with the same key rather than stacking a + * second one — e.g. the id of whatever is being signalled, so a retry replaces + * the message it repeats. + */ + collapseKey?: string + /** + * Merged over the derived `android` block; escape hatch for anything unmapped. + */ + android?: Record + /** + * Merged over the derived `apns` block; escape hatch for anything unmapped. + */ + apns?: Record + /** + * Merged over the derived `webpush` block; escape hatch for anything unmapped. + */ + webpush?: Record } export type RealtimeDriverOptions = { @@ -95,6 +144,17 @@ export type RealtimeDriverOptions RealtimeDriver pusher?: PusherTransportConfig firebase?: FirebaseTransportConfig } @@ -108,10 +168,35 @@ export type RealtimeNotificationPayload = { actionText?: string | null actionLink?: string | null meta?: NotificationData | null + /** + * Stable identity for what this notification is *about* — an order, an + * incident, an approval. A later notification carrying the same tag supersedes + * this one rather than stacking beside it, so a client can replace what it + * already showed. Unbounded and client-side; unrelated to the transport's + * `collapseKey`, which is a queue slot with a much smaller budget. + */ + tag?: string | null + /** + * Marks this message as an instruction to *remove* the notification carrying + * `tag`, rather than as a notification to display. Absent means display. + * + * Prefer superseding with replacement content where there is any: a retraction + * has nothing to show, so it must travel as a silent push, and silent pushes + * are the ones platforms throttle hardest. + */ + retracted?: boolean read_at: string | null created_at: string } +/** + * What a transport will carry. Notifications are the common case and what the + * builder produces, but a channel is just a channel — an application can push its + * own event shapes over the same one and pick them up with the client's + * `listen()`, which has always accepted arbitrary events. + */ +export type RealtimeBroadcastPayload = RealtimeNotificationPayload | Record + /** The result of a realtime broadcast (plus the stored record when `store` is on). */ export type RealtimeBroadcastResult = { channel: string | string[] @@ -159,13 +244,17 @@ export interface NotificationConfig { table: string } realtime?: { - transport: RealtimeDriverName + transport?: RealtimeDriverName /** Prefix for the per-user channel/topic (default `user.`). */ channel_prefix?: string /** Event name clients subscribe to (default `notification`). */ event?: string /** Persist broadcasts to the database by default. */ store?: boolean + /** Delivery hints applied to every broadcast unless overridden per-send. */ + delivery?: RealtimeDeliveryOptions + /** Build the driver yourself for every realtime notification. */ + driverFactory?: () => RealtimeDriver } } transports: { diff --git a/packages/notifications/tests/realtime.test.ts b/packages/notifications/tests/realtime.test.ts index 7e2bfd7b..34d1157e 100644 --- a/packages/notifications/tests/realtime.test.ts +++ b/packages/notifications/tests/realtime.test.ts @@ -1,14 +1,14 @@ -import { FirebaseRealtimeDriver, Notification, RealtimeNotification, UserNotificationCenter } from '../src' +import { FirebaseRealtimeDriver, Notification, PusherRealtimeDriver, RealtimeNotification, UserNotificationCenter } from '../src' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RealtimeDriver } from '../src' /** A fake transport that records what it was asked to broadcast. */ const fakeDriver = () => { - const calls: Array<{ channel: string, event: string, payload: any }> = [] + const calls: Array<{ channel: string, event: string, payload: any, delivery?: any }> = [] const driver: RealtimeDriver = { - broadcast: vi.fn(async (channel, event, payload) => { - calls.push({ channel, event, payload }) + broadcast: vi.fn(async (channel, event, payload, delivery) => { + calls.push({ channel, event, payload, delivery }) return { ok: true } }), @@ -173,3 +173,305 @@ describe('FirebaseRealtimeDriver multicast', () => { expect(result.successCount).toBe(500) }) }) + +describe('realtime delivery options', () => { + /** Inject a fake `firebase-admin` messaging into the lazy promise. */ + const withMessaging = (messaging: unknown, options = {}) => { + const driver = new FirebaseRealtimeDriver(options); + (driver as any).messagingPromise = Promise.resolve(messaging) + + return driver + } + + const payload = () => ({ + id: '1', type: null, title: 'T', description: 'D', read_at: null, created_at: '2026-01-01T00:00:00.000Z', + }) + + const sent = async (delivery: any, options = {}) => { + const send = vi.fn(async (_message: Record) => 'msg-id') + const driver = withMessaging({ send, sendEachForMulticast: vi.fn() }, options) + + await driver.broadcast('user.7', 'notification', payload(), delivery) + + return send.mock.calls[0][0] + } + + it('forwards the builder\'s delivery hints to the transport', async () => { + const { driver, calls } = fakeDriver() + const rt = withDriver(Notification.realtime(), driver) + + await rt.recipient(user).priority('high').ttl(45).collapseKey('call-42').send('Incoming call') + + expect(calls[0].delivery).toEqual({ priority: 'high', ttl: 45, collapseKey: 'call-42' }) + }) + + it('defaults priority() to high, since that is the only reason to call it', async () => { + const { driver, calls } = fakeDriver() + const rt = withDriver(Notification.realtime(), driver) + + await rt.recipient(user).priority().send('Incoming call') + + expect(calls[0].delivery).toEqual({ priority: 'high' }) + }) + + it('sends nothing but data when no delivery hints are given', async () => { + const message = await sent(undefined) + + // Back-compat: an unhinted broadcast is byte-for-byte what it always was. + expect(message).toEqual({ topic: 'user.7', data: { event: 'notification', payload: expect.any(String) } }) + expect(message).not.toHaveProperty('android') + expect(message).not.toHaveProperty('apns') + expect(message).not.toHaveProperty('webpush') + }) + + it('maps high priority onto Android and Web Push', async () => { + const message = await sent({ priority: 'high' }) + + expect(message.android).toEqual({ priority: 'high' }) + expect(message.webpush).toEqual({ headers: { Urgency: 'high' } }) + }) + + it('leaves APNs priority alone on a data-only push, which iOS treats as background', async () => { + const message = await sent({ priority: 'high' }) + + // Apple requires a background push to be priority 5 or 1, so deriving 10 + // here would send APNs something it is documented to reject. + expect(message).not.toHaveProperty('apns') + }) + + it('derives an APNs priority once the caller declares a visible push', async () => { + const message = await sent({ + priority: 'high', + apns: { headers: { 'apns-push-type': 'alert' } }, + }) + + expect(message.apns.headers).toEqual({ 'apns-push-type': 'alert', 'apns-priority': '10' }) + }) + + it('recognises a visible push from an alert payload alone', async () => { + const message = await sent({ + priority: 'high', + apns: { payload: { aps: { alert: { title: 'Approve sign-in' } } } }, + }) + + expect(message.apns.headers).toEqual({ 'apns-priority': '10' }) + }) + + it('maps normal priority to 5 on a visible push', async () => { + const message = await sent({ + priority: 'normal', + apns: { headers: { 'apns-push-type': 'alert' } }, + }) + + expect(message.apns.headers).toEqual({ 'apns-push-type': 'alert', 'apns-priority': '5' }) + }) + + it('does not derive a priority for a push the caller declared as background', async () => { + const message = await sent({ + priority: 'high', + apns: { headers: { 'apns-push-type': 'background' } }, + }) + + expect(message.apns.headers).toEqual({ 'apns-push-type': 'background' }) + }) + + it('still lets the caller set an APNs priority the rule would not derive', async () => { + const message = await sent({ + priority: 'high', + apns: { headers: { 'apns-priority': '5' } }, + }) + + expect(message.apns.headers).toEqual({ 'apns-priority': '5' }) + }) + + it('converts a TTL to each platform\'s own unit', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + try { + const message = await sent({ ttl: 45 }) + + // Android takes a duration in milliseconds. + expect(message.android).toEqual({ ttl: 45_000 }) + // APNs takes an absolute UNIX second: 1767225600 is the epoch second + // for 2026-01-01T00:00:00Z. + expect(message.apns).toEqual({ headers: { 'apns-expiration': String(1767225600 + 45) } }) + // Web Push takes a duration in seconds. + expect(message.webpush).toEqual({ headers: { TTL: '45' } }) + } finally { + vi.useRealTimers() + } + }) + + it('treats a zero TTL as now-or-never rather than as the epoch', async () => { + const message = await sent({ ttl: 0 }) + + expect(message.android).toEqual({ ttl: 0 }) + expect(message.apns).toEqual({ headers: { 'apns-expiration': '0' } }) + }) + + it('carries a collapse key to both Android and APNs', async () => { + const message = await sent({ collapseKey: 'call-42' }) + + expect(message.android).toEqual({ collapseKey: 'call-42' }) + expect(message.apns).toEqual({ headers: { 'apns-collapse-id': 'call-42' } }) + }) + + it('lets the raw escape hatches override what it derives', async () => { + const message = await sent({ + priority: 'high', + collapseKey: 'call-42', + android: { priority: 'normal', restrictedPackageName: 'com.example' }, + apns: { headers: { 'apns-push-type': 'voip' }, payload: { aps: { 'content-available': 1 } } }, + }) + + // The override replaces `priority` and adds its own key; the derived + // `collapseKey` it said nothing about survives underneath. + expect(message.android).toEqual({ + priority: 'normal', collapseKey: 'call-42', restrictedPackageName: 'com.example', + }) + expect(message.apns).toEqual({ + headers: { 'apns-collapse-id': 'call-42', 'apns-push-type': 'voip' }, + payload: { aps: { 'content-available': 1 } }, + }) + }) + + it('merges transport defaults under the per-send options', async () => { + const message = await sent({ ttl: 30 }, { delivery: { priority: 'high', ttl: 600 } }) + + expect(message.android).toEqual({ priority: 'high', ttl: 30_000 }) + }) + + it('applies delivery options to every multicast batch', async () => { + const sendEachForMulticast = vi.fn(async ({ tokens }: { tokens: string[] }) => ({ + successCount: tokens.length, failureCount: 0, responses: tokens.map(() => ({ success: true })), + })) + const driver = withMessaging({ send: vi.fn(), sendEachForMulticast }) + const tokens = Array.from({ length: 501 }, (_, i) => `t${i}`) + + await driver.broadcast(tokens, 'notification', payload(), { priority: 'high' }) + + expect(sendEachForMulticast).toHaveBeenCalledTimes(2) + for (const [message] of sendEachForMulticast.mock.calls) { + expect((message as any).android).toEqual({ priority: 'high' }) + } + }) + + it('ignores delivery options on Pusher without throwing', async () => { + const trigger = vi.fn(async () => ({ ok: true })) + const driver = new PusherRealtimeDriver(); + (driver as any).clientPromise = Promise.resolve({ trigger }) + + await driver.broadcast('user.7', 'notification', payload(), { priority: 'high' }) + + expect(trigger).toHaveBeenCalledWith('user.7', 'notification', expect.objectContaining({ id: '1' })) + }) +}) + +describe('notification supersession', () => { + it('carries a tag on the payload so a client can replace what it showed', async () => { + const { driver, calls } = fakeDriver() + const rt = withDriver(Notification.realtime(), driver) + + await rt.recipient(user).tag('call:42').send('Incoming call') + + expect(calls[0].payload.tag).toBe('call:42') + expect(calls[0].payload.retracted).toBeUndefined() + }) + + it('omits both fields entirely when untagged', async () => { + const { driver, calls } = fakeDriver() + const rt = withDriver(Notification.realtime(), driver) + + await rt.recipient(user).send('Hello') + + // Untagged broadcasts stay byte-for-byte what they were on the wire. + expect(calls[0].payload).not.toHaveProperty('tag') + expect(calls[0].payload).not.toHaveProperty('retracted') + }) + + it('marks a retraction and lets it send with nothing to display', async () => { + const { driver, calls } = fakeDriver() + const rt = withDriver(Notification.realtime(), driver) + + await rt.recipient(user).tag('call:42').retract().send() + + expect(calls[0].payload.tag).toBe('call:42') + expect(calls[0].payload.retracted).toBe(true) + expect(calls[0].payload.description).toBe('') + }) + + it('does not leave a retraction behind in stored history', async () => { + const create = vi.spyOn(UserNotificationCenter, 'create') + const { driver, calls } = fakeDriver() + const rt = withDriver(Notification.realtime(), driver) + + const result = await rt.recipient(user).tag('call:42').store().retract().send() + + // Storing a withdrawal as a notification would make history say the + // opposite of what happened. + expect(create).not.toHaveBeenCalled() + expect(result.stored).toBeUndefined() + expect(calls[0].payload.retracted).toBe(true) + }) + + it('leaves tag and collapseKey independent, since their budgets differ', async () => { + const { driver, calls } = fakeDriver() + const rt = withDriver(Notification.realtime(), driver) + + await rt.recipient(user).tag('call:42').send('Incoming call') + + // FCM keeps only four collapse keys per device, so a tag must never + // quietly become one. + expect(calls[0].delivery?.collapseKey).toBeUndefined() + }) +}) + +describe('custom realtime drivers', () => { + it('broadcasts through a driver supplied by the caller', async () => { + const { driver, calls } = fakeDriver() + + const rt = Notification.realtime({ driverFactory: () => driver }) + await rt.recipient(user).send('Hello') + + expect(calls).toHaveLength(1) + expect(calls[0].channel).toBe('user.7') + }) + + it('ignores `transport` when a driver is supplied, rather than building both', async () => { + const { driver, calls } = fakeDriver() + const factory = vi.fn(() => driver) + + const rt = Notification.realtime({ transport: 'firebase', driverFactory: factory }) + await rt.recipient(user).send('Hello') + + expect(factory).toHaveBeenCalledTimes(1) + expect(rt.driver).toBe(driver) + expect(calls).toHaveLength(1) + }) + + it('still builds a built-in when no driver is supplied', async () => { + expect(Notification.realtime({ transport: 'firebase' }).driver) + .toBeInstanceOf(FirebaseRealtimeDriver) + expect(Notification.realtime({ transport: 'pusher' }).driver) + .toBeInstanceOf(PusherRealtimeDriver) + }) + + it('carries a payload that is not shaped like a notification', async () => { + const calls: Array<{ event: string, payload: any }> = [] + const driver: RealtimeDriver = { + broadcast: vi.fn(async (_channel, event, payload) => { + calls.push({ event, payload }) + }), + auth: vi.fn(async () => undefined), + registerAuthRoute: vi.fn(async () => undefined), + } + + // A channel is just a channel: an application can push its own event + // shapes over it and pick them up with the client's `listen()`. + await driver.broadcast('user.7', 'call.invite', { callId: 'c-1', room: 'r-9', expiresAt: 1767225600 }) + + expect(calls[0].event).toBe('call.invite') + expect(calls[0].payload).toEqual({ callId: 'c-1', room: 'r-9', expiresAt: 1767225600 }) + }) +}) diff --git a/packages/realtime/src/index.ts b/packages/realtime/src/index.ts index 16f27e0e..ad278ed4 100644 --- a/packages/realtime/src/index.ts +++ b/packages/realtime/src/index.ts @@ -1,4 +1,5 @@ export { RealtimeClient, createRealtime } from './RealtimeClient' export { createPusherTransport } from './transports/pusher' export { createFirebaseTransport } from './transports/firebase' +export { supersede } from './supersede' export type * from './types' diff --git a/packages/realtime/src/react/index.ts b/packages/realtime/src/react/index.ts index 43bff730..57908e9e 100644 --- a/packages/realtime/src/react/index.ts +++ b/packages/realtime/src/react/index.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import type { RealtimeClient } from '../RealtimeClient' import type { RealtimeNotification } from '../types' +import { supersede } from '../supersede' export interface UseNotificationsOptions { /** Cap the number of retained notifications (newest kept). Default: unbounded. */ @@ -19,6 +20,10 @@ export interface UseNotificationsResult { * notifications (newest first) into state. Automatically unsubscribes on unmount * or when `client`/`channel` change. * + * Tagged notifications supersede rather than accumulate: one replaces an earlier + * notification with the same tag in place, and a retraction removes it. See + * {@link supersede}. + * * @param client A {@link RealtimeClient} (from `createRealtime`). * @param channel The channel to subscribe to, e.g. `client.channelFor(user.id)`. * @param options `limit` caps how many notifications are retained. @@ -35,11 +40,9 @@ export function useNotifications ( let unsubscribe: (() => void) | undefined let cancelled = false - const push = (notification: RealtimeNotification) => setNotifications((prev) => { - const next = [notification, ...prev] - - return limit ? next.slice(0, limit) : next - }) + const push = (notification: RealtimeNotification) => setNotifications( + (prev) => supersede(prev, notification, limit), + ) client.subscribe(channel, push) .then((off) => { diff --git a/packages/realtime/src/supersede.ts b/packages/realtime/src/supersede.ts new file mode 100644 index 00000000..f2e0ec38 --- /dev/null +++ b/packages/realtime/src/supersede.ts @@ -0,0 +1,51 @@ +import type { RealtimeNotification } from './types' + +/** + * Fold an incoming notification into a list, honouring tag supersession. + * + * A notification carrying a `tag` names the thing it is about rather than the + * moment it was sent, so a later notification with the same tag is understood as + * a newer account of the same thing: + * + * - **Tagged, already present** — replaces the existing entry *in place*. The + * entry keeps its position so a list does not reshuffle under someone reading + * it; only its content changes. + * - **Tagged, not present** — prepended, like any other notification. + * - **Retracted** — removes the entry it names and contributes nothing itself. A + * retraction for something never seen is a no-op, which is the common case when + * a client connects after the fact. + * - **Untagged** — prepended. Untagged notifications never supersede anything. + * + * Pure and list-in/list-out, so it suits any state container; the React and Vue + * bindings are thin wrappers over it. + * + * @param list The notifications held so far, newest first. + * @param incoming The notification just received. + * @param limit Caps the retained list (newest kept) when growing it. + */ +export const supersede = ( + list: RealtimeNotification[], + incoming: RealtimeNotification, + limit?: number, +): RealtimeNotification[] => { + const { tag, retracted } = incoming + + if (retracted) { + return tag ? list.filter((notification) => notification.tag !== tag) : list + } + + if (tag) { + const index = list.findIndex((notification) => notification.tag === tag) + + if (index !== -1) { + const next = list.slice() + next[index] = incoming + + return next + } + } + + const next = [incoming, ...list] + + return limit ? next.slice(0, limit) : next +} diff --git a/packages/realtime/src/types.ts b/packages/realtime/src/types.ts index 4f9f557b..a239d0dd 100644 --- a/packages/realtime/src/types.ts +++ b/packages/realtime/src/types.ts @@ -11,6 +11,14 @@ export interface RealtimeNotification { actionText?: string | null actionLink?: string | null meta?: Record | null + /** + * Stable identity for what this notification is about. A later notification + * with the same tag supersedes this one rather than stacking beside it — see + * {@link supersede}, which the React and Vue bindings apply for you. + */ + tag?: string | null + /** Removes the notification carrying `tag` instead of displaying anything. */ + retracted?: boolean read_at: string | null created_at: string } diff --git a/packages/realtime/src/vue/index.ts b/packages/realtime/src/vue/index.ts index 6aa8d8e1..39e637c2 100644 --- a/packages/realtime/src/vue/index.ts +++ b/packages/realtime/src/vue/index.ts @@ -3,6 +3,7 @@ import { computed, onScopeDispose, ref } from 'vue' import type { ComputedRef, Ref } from 'vue' import type { RealtimeClient } from '../RealtimeClient' import type { RealtimeNotification } from '../types' +import { supersede } from '../supersede' export interface UseNotificationsOptions { /** Cap the number of retained notifications (newest kept). Default: unbounded. */ @@ -21,6 +22,10 @@ export interface UseNotificationsResult { * notifications (newest first) into a ref. Automatically unsubscribes when the * scope is disposed (component unmount). * + * Tagged notifications supersede rather than accumulate: one replaces an earlier + * notification with the same tag in place, and a retraction removes it. See + * {@link supersede}. + * * @param client A {@link RealtimeClient} (from `createRealtime`). * @param channel The channel to subscribe to, e.g. `client.channelFor(user.id)`. * @param options `limit` caps how many notifications are retained. @@ -37,9 +42,7 @@ export function useNotifications ( let cancelled = false const push = (notification: RealtimeNotification) => { - const next = [notification, ...notifications.value] - - notifications.value = limit ? next.slice(0, limit) : next + notifications.value = supersede(notifications.value, notification, limit) } client.subscribe(channel, push) diff --git a/packages/realtime/tests/supersede.test.ts b/packages/realtime/tests/supersede.test.ts new file mode 100644 index 00000000..b6b6f122 --- /dev/null +++ b/packages/realtime/tests/supersede.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' + +import type { RealtimeNotification } from '../src' +import { supersede } from '../src' + +const notification = ( + overrides: Partial = {}, +): RealtimeNotification => ({ + id: overrides.id ?? '1', + type: null, + title: 'Incoming call', + description: 'Ada is calling', + read_at: null, + created_at: '2026-01-01T00:00:00.000Z', + ...overrides, +}) + +describe('supersede', () => { + it('prepends an untagged notification, newest first', () => { + const first = notification({ id: '1' }) + const second = notification({ id: '2' }) + + expect(supersede([first], second)).toEqual([second, first]) + }) + + it('never lets an untagged notification supersede anything', () => { + const existing = notification({ id: '1', tag: 'call:42' }) + const incoming = notification({ id: '2' }) + + expect(supersede([existing], incoming)).toEqual([incoming, existing]) + }) + + it('replaces a tagged notification in place, holding its position', () => { + const newest = notification({ id: '3' }) + const ring = notification({ id: '1', tag: 'call:42' }) + const missed = notification({ id: '2', tag: 'call:42', title: 'Missed call' }) + + const result = supersede([newest, ring], missed) + + // Position is held so the list does not reshuffle under a reader. + expect(result).toEqual([newest, missed]) + expect(result).toHaveLength(2) + }) + + it('prepends a tagged notification the list has not seen', () => { + const existing = notification({ id: '1', tag: 'order:7' }) + const incoming = notification({ id: '2', tag: 'call:42' }) + + expect(supersede([existing], incoming)).toEqual([incoming, existing]) + }) + + it('removes what a retraction names and shows nothing of its own', () => { + const other = notification({ id: '1', tag: 'order:7' }) + const ring = notification({ id: '2', tag: 'call:42' }) + + const result = supersede([other, ring], notification({ id: '3', tag: 'call:42', retracted: true })) + + expect(result).toEqual([other]) + }) + + it('ignores a retraction for something it never saw', () => { + const existing = notification({ id: '1', tag: 'order:7' }) + + const result = supersede([existing], notification({ id: '2', tag: 'call:42', retracted: true })) + + // Common whenever a client connects after the fact. + expect(result).toEqual([existing]) + }) + + it('drops a retraction that names nothing at all', () => { + const existing = notification({ id: '1' }) + + expect(supersede([existing], notification({ id: '2', retracted: true }))).toEqual([existing]) + }) + + it('applies the limit when growing the list', () => { + const existing = [notification({ id: '1' }), notification({ id: '2' })] + + expect(supersede(existing, notification({ id: '3' }), 2)).toEqual([ + notification({ id: '3' }), notification({ id: '1' }), + ]) + }) + + it('does not drop entries to the limit when replacing in place', () => { + const existing = [notification({ id: '1', tag: 'a' }), notification({ id: '2', tag: 'b' })] + const replacement = notification({ id: '3', tag: 'b', title: 'Updated' }) + + // A replacement does not grow the list, so the limit has nothing to trim. + expect(supersede(existing, replacement, 2)).toEqual([existing[0], replacement]) + }) + + it('leaves the input list untouched', () => { + const existing = [notification({ id: '1', tag: 'call:42' })] + const snapshot = [...existing] + + supersede(existing, notification({ id: '2', tag: 'call:42' })) + supersede(existing, notification({ id: '3', tag: 'call:42', retracted: true })) + + expect(existing).toEqual(snapshot) + }) +}) From b0c2eb388f2f9f492de3628e0d5ec479400e88f0 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 16 Sep 2026 13:52:05 +0100 Subject: [PATCH 2/4] chore(notifications): type getModel against a UserNotification constructor - notifications: add `CUserNotification`, a constructor type for the abstract `UserNotification` model, and resolve `getModel` against it. - jobs, h3 template: import ordering, method spacing, and a narrower `H3Middleware` import path. --- packages/jobs/src/Job.ts | 19 +++++++++---------- .../src/Contracts/UserNotification.ts | 8 ++++++++ .../src/UserNotificationCenter.ts | 5 +++-- templates/h3/src/core/app.ts | 11 +++++++---- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/jobs/src/Job.ts b/packages/jobs/src/Job.ts index 147f9580..2d69166d 100644 --- a/packages/jobs/src/Job.ts +++ b/packages/jobs/src/Job.ts @@ -1,7 +1,6 @@ -import type { Queueable } from '@arkstack/queue' - import { JobRegistry } from './JobRegistry' import { PendingDispatch } from './PendingDispatch' +import type { Queueable } from '@arkstack/queue' /** * The base class for dispatchable jobs. @@ -41,32 +40,32 @@ export abstract class Job implements Queueable { /** * Perform the work for this job. Implemented by subclasses. */ - abstract handle (): unknown | Promise + abstract handle(): unknown | Promise /** * Serialize the job's state for storage. Defaults to a shallow copy of the * instance's own properties. Override for custom serialization. */ - serialize (): Record { - return { ...this } + serialize(): Record { + return { ...this } as never } /** Send this job to the given connection. */ - onConnection (connection: string): this { + onConnection(connection: string): this { this.connection = connection return this } /** Send this job to the given queue. */ - onQueue (queue: string): this { + onQueue(queue: string): this { this.queue = queue return this } /** Delay the job by a number of seconds. */ - withDelay (seconds: number): this { + withDelay(seconds: number): this { this.delay = seconds return this @@ -76,14 +75,14 @@ export abstract class Job implements Queueable { * Create a pending dispatch for this job class with the given constructor * arguments. Await it (or chain `onQueue`/`onConnection`/`withDelay`) to send. */ - static dispatch (this: new (...args: any[]) => T, ...args: any[]): PendingDispatch { + static dispatch(this: new (...args: any[]) => T, ...args: any[]): PendingDispatch { return new PendingDispatch(new this(...args)) } /** * Dispatch immediately on the synchronous connection, running the job inline. */ - static dispatchSync (this: new (...args: any[]) => T, ...args: any[]): PendingDispatch { + static dispatchSync(this: new (...args: any[]) => T, ...args: any[]): PendingDispatch { return new PendingDispatch(new this(...args)).onConnection('sync') } } diff --git a/packages/notifications/src/Contracts/UserNotification.ts b/packages/notifications/src/Contracts/UserNotification.ts index 5ce31c99..81f9cd78 100644 --- a/packages/notifications/src/Contracts/UserNotification.ts +++ b/packages/notifications/src/Contracts/UserNotification.ts @@ -20,3 +20,11 @@ export abstract class UserNotification extends Model { meta: 'json', } as const } + +export type CUserNotification = + Omit & + { new(...args: any[]): UserNotification; prototype: UserNotification } + +export type Concrete = + Omit & + { new(...args: any[]): Abstract; prototype: Abstract } \ No newline at end of file diff --git a/packages/notifications/src/UserNotificationCenter.ts b/packages/notifications/src/UserNotificationCenter.ts index 415f681d..885e03c9 100644 --- a/packages/notifications/src/UserNotificationCenter.ts +++ b/packages/notifications/src/UserNotificationCenter.ts @@ -1,15 +1,15 @@ import type { ArkormCollection, LengthAwarePaginator } from 'arkormx' +import type { CUserNotification, UserNotification } from '@app/models/UserNotification' import type { DbNotificationPayload } from './types' import { Notification } from './Notification' import type { User } from '@app/models/User' -import type { UserNotification } from '@app/models/UserNotification' import { UserPushToken } from './Contracts/UserPushToken' import { getModel } from '@arkstack/common' export class UserNotificationCenter { private static async getModel() { - return await getModel('UserNotification') + return getModel('UserNotification') } /** @@ -72,6 +72,7 @@ export class UserNotificationCenter { * @param user */ static async unreadForUser(user: User): Promise> + /** * Fetch all the users unread messages with a lenght aware paginator instance * diff --git a/templates/h3/src/core/app.ts b/templates/h3/src/core/app.ts index 330aeaa4..61cfa681 100644 --- a/templates/h3/src/core/app.ts +++ b/templates/h3/src/core/app.ts @@ -1,6 +1,8 @@ -import { Arkstack, ArkstackRouterContract, ArkstackRouteListOptions } from '@arkstack/contract' -import { H3Driver, type H3Middleware } from '@arkstack/driver-h3' +import { Arkstack, ArkstackRouteListOptions, ArkstackRouterContract } from '@arkstack/contract' + import { H3 } from 'h3' +import { H3Driver } from '@arkstack/driver-h3' +import { H3Middleware } from '@arkstack/driver-h3/types' import { Router } from '@arkstack/driver-h3' export default class Application extends Arkstack { @@ -29,7 +31,7 @@ export default class Application extends Arkstack { * * @returns */ - getRouter (): ArkstackRouterContract { + getRouter(): ArkstackRouterContract { return { bind: (app: H3) => Router.bind(app), list: (options: ArkstackRouteListOptions = {}, app?: H3) => Router.list(options, app ?? this.app), @@ -43,11 +45,12 @@ export default class Application extends Arkstack { * @param port The numeric port to run the server on * @param defer Set to true to skip server startup */ - public async boot (port: number, defer = false) { + public async boot(port: number, defer = false) { // Load public assets await this.driver.mountPublicAssets(this.app, 'public') // Apply all middleware + // @ts-expect-error config cannot be discovered outside app scaffolds await this.driver.applyMiddleware(this.app, config('middleware') as never) // Bind the router From 6c467de3855962bc221cb54ff9bf00bfcb629446 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 16 Sep 2026 13:58:47 +0100 Subject: [PATCH 3/4] fix(notifications): correct type import and adjust UserNotification model retrieval --- packages/notifications/src/drivers/DbNotification.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/notifications/src/drivers/DbNotification.ts b/packages/notifications/src/drivers/DbNotification.ts index 6d57e196..cf53d8bb 100644 --- a/packages/notifications/src/drivers/DbNotification.ts +++ b/packages/notifications/src/drivers/DbNotification.ts @@ -1,8 +1,8 @@ +import type { CUserNotification, UserNotification } from '@app/models/UserNotification' import type { DbNotificationPayload, NotificationData, NotificationRecipient } from '../types' import { NotificationContract } from '../Contracts/NotificationContract' import type { User } from '@app/models/User' -import type { UserNotification } from '@app/models/UserNotification' import { UserNotificationCenter } from '../UserNotificationCenter' import { getModel } from '@arkstack/common' import { interpolate } from '../utils/template' @@ -61,7 +61,7 @@ export class DbNotification extends NotificationContract { } async create(user: User, payload: DbNotificationPayload) { - await getModel('UserNotification') + getModel('UserNotification') if (this.realtime) { return await UserNotificationCenter.send(user, payload) as never From d83bed84c0e64b9219924a438d296ce59ea21246 Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Wed, 16 Sep 2026 14:06:57 +0100 Subject: [PATCH 4/4] refactor(auth): update getModel usage with Concrete type for better type safety --- packages/auth/src/Auth.ts | 62 +++++++++---------- packages/auth/src/AuthSession.ts | 8 +-- packages/auth/tests/fixtures/auth.ts | 6 +- packages/common/package.json | 5 ++ packages/common/src/types.ts | 8 ++- .../src/Contracts/UserNotification.ts | 10 +-- .../src/UserNotificationCenter.ts | 6 +- .../src/drivers/DbNotification.ts | 6 +- 8 files changed, 57 insertions(+), 54 deletions(-) diff --git a/packages/auth/src/Auth.ts b/packages/auth/src/Auth.ts index 0382b668..732bff0f 100644 --- a/packages/auth/src/Auth.ts +++ b/packages/auth/src/Auth.ts @@ -1,4 +1,4 @@ -import { Hash, appKey, env, getModel } from '@arkstack/common' +import { Concrete, Hash, appKey, env, getModel } from '@arkstack/common' import { JWTPayload, SignJWT, jwtVerify } from 'jose' import { AuthContract } from './Contracts/AuthContract' @@ -33,7 +33,7 @@ export class Auth extends AuthContract { * @param secret The secret key used for signing and verifying JWTs. * @returns A new instance of the Auth class. */ - static make (secret?: string) { + static make(secret?: string) { return new Auth(secret) } @@ -43,7 +43,7 @@ export class Auth extends AuthContract { * @param req The HTTP request instance to be set. * @returns The Auth class itself for method chaining. */ - static setRequest (req: Request | RequestSource) { + static setRequest(req: Request | RequestSource) { this.req = Request.from(req) return this @@ -55,7 +55,7 @@ export class Auth extends AuthContract { * @param req The HTTP request instance to be set. * @returns The Auth instance itself for method chaining. */ - setRequest (req: Request | RequestSource) { + setRequest(req: Request | RequestSource) { Auth.req ??= Request.from(req) return this @@ -67,7 +67,7 @@ export class Auth extends AuthContract { * * @returns The current HTTP request instance or undefined if not set. */ - getRequest (): Request | undefined { + getRequest(): Request | undefined { return Auth.req } @@ -76,7 +76,7 @@ export class Auth extends AuthContract { * * @returns The currently authenticated user or null if not authenticated. */ - user (): User | null { + user(): User | null { return this.#user } @@ -87,8 +87,8 @@ export class Auth extends AuthContract { * @param password The password of the user. * @returns A boolean indicating whether the credentials are valid. */ - async verify (email: string, password: string): Promise { - const user = await (await getModel('User')).query().where({ email }).first() + async verify(email: string, password: string): Promise { + const user = await (getModel>('User')).query().where({ email }).first() return !!user && await Hash.verify(password, user.password) } @@ -100,8 +100,8 @@ export class Auth extends AuthContract { * @param password * @returns */ - async attempt (email: string, password: string): Promise { - const user = await (await getModel('User')).query().where({ email }).first() + async attempt(email: string, password: string): Promise { + const user = await (getModel>('User')).query().where({ email }).first() if (!user) { throw new AuthenticationException('User account not found', { req: Auth.req, status: 422, errors: { email: ['No account found for this email address'] } }) @@ -125,7 +125,7 @@ export class Auth extends AuthContract { * @param password * @returns */ - async login (email: string, password: string): Promise { + async login(email: string, password: string): Promise { const user = await this.attempt(email, password) return await this.create(user) @@ -140,7 +140,7 @@ export class Auth extends AuthContract { * @param expiresIn * @returns */ - async createTemporaryToken (user: User, purpose: string, expiresIn: string = '10m'): Promise { + async createTemporaryToken(user: User, purpose: string, expiresIn: string = '10m'): Promise { return await this.createJWT({ sub: user.id.toString(), email: user.email, @@ -156,7 +156,7 @@ export class Auth extends AuthContract { * @param purpose * @returns */ - async authorizeTemporaryToken (token: string, purpose: string): Promise { + async authorizeTemporaryToken(token: string, purpose: string): Promise { const payload = await this.verifyJWT(token) if (!payload || payload.purpose !== purpose || !payload.sub) { @@ -166,7 +166,7 @@ export class Auth extends AuthContract { ) } - const user = await (await getModel('User')).query().find(payload.sub) + const user = await (getModel>('User')).query().find(payload.sub) if (!user) { throw new AuthenticationException( @@ -186,21 +186,21 @@ export class Auth extends AuthContract { * @param token * @returns */ - async logout (token?: string | PersonalAccessToken): Promise { + async logout(token?: string | PersonalAccessToken): Promise { if (!this.#user && !token) { return } if (token) { if (typeof token === 'string') { - const TokenModel = await getModel('PersonalAccessToken') + const TokenModel = getModel>('PersonalAccessToken') await TokenModel.query().where({ token }).delete() } else { await token.delete() } } else { - const TokenModel = await getModel('PersonalAccessToken') + const TokenModel = getModel>('PersonalAccessToken') await TokenModel.query().where({ userId: this.#user!.id }).delete() } @@ -217,7 +217,7 @@ export class Auth extends AuthContract { * * @returns */ - async check (): Promise { + async check(): Promise { return !!this.#user } @@ -226,7 +226,7 @@ export class Auth extends AuthContract { * * @returns */ - session () { + session() { return new AuthSession(this) } @@ -236,7 +236,7 @@ export class Auth extends AuthContract { * @param user * @returns */ - async create (user: User): Promise { + async create(user: User): Promise { const payload: JWTPayload = { sub: user.id.toString(), email: user.email, @@ -261,8 +261,8 @@ export class Auth extends AuthContract { * @param token The new bearer token to persist. * @param deviceInfo The current request's device information. */ - private async upsertDeviceToken (user: User, token: string, deviceInfo: Record | null) { - const TokenModel = await getModel('PersonalAccessToken') + private async upsertDeviceToken(user: User, token: string, deviceInfo: Record | null) { + const TokenModel = getModel>('PersonalAccessToken') const deviceKey = SessionDevice.getUniqueKey(deviceInfo) const payload = { abilities: [], @@ -322,7 +322,7 @@ export class Auth extends AuthContract { * @param token * @returns */ - async authorizeToken (token: string): Promise { + async authorizeToken(token: string): Promise { const payload = await this.verifyJWT(token) if (!payload) { @@ -332,7 +332,7 @@ export class Auth extends AuthContract { ) } - const TokenModel = await getModel('PersonalAccessToken') + const TokenModel = getModel>('PersonalAccessToken') const pat = await TokenModel.query().where({ token }).first() if (!pat) { @@ -342,7 +342,7 @@ export class Auth extends AuthContract { ) } - const user = await (await getModel('User')).query().find(payload.sub!) + const user = await (getModel>('User')).query().find(payload.sub!) if (!user) { throw new AuthenticationException( @@ -368,7 +368,7 @@ export class Auth extends AuthContract { * @param payload * @returns */ - private async createJWT (payload: JWTPayload, expiresIn: string = env('JWT_EXPIRES_IN', '1h')): Promise { + private async createJWT(payload: JWTPayload, expiresIn: string = env('JWT_EXPIRES_IN', '1h')): Promise { const jwt = await new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() @@ -384,7 +384,7 @@ export class Auth extends AuthContract { * @param token * @returns */ - private async verifyJWT (token: string): Promise { + private async verifyJWT(token: string): Promise { try { const { payload } = await jwtVerify(token, new TextEncoder().encode(this.getSecret())) @@ -394,13 +394,13 @@ export class Auth extends AuthContract { } } - private getSecret (): string { + private getSecret(): string { // Explicitly configured secret wins, then the unified APP_KEY with a // backward-compatible fallback to the legacy JWT_SECRET variable. return this.configuredSecret ?? appKey('JWT_SECRET') ?? 'default_secret' } - private setAuthenticated (user: User, token?: string) { + private setAuthenticated(user: User, token?: string) { this.#user = user Auth.req?.setAuthentication(this, user, token) } @@ -412,7 +412,7 @@ export class Auth extends AuthContract { * @param pat The personal access token to update. * @returns A promise that resolves when the update is complete. */ - private async touchSession (pat: PersonalAccessToken) { + private async touchSession(pat: PersonalAccessToken) { const now = new Date() const currentDeviceInfo = SessionDevice.fromRequest(Auth.req) const shouldUpdateLastUsedAt = !pat.lastUsedAt || (now.getTime() - pat.lastUsedAt.getTime()) > 5 * 60 * 1000 @@ -438,7 +438,7 @@ export class Auth extends AuthContract { payload.name = currentDisplayName } - const TokenModel = await getModel('PersonalAccessToken') + const TokenModel = getModel>('PersonalAccessToken') await TokenModel.query().where({ id: pat.id }).update(payload) diff --git a/packages/auth/src/AuthSession.ts b/packages/auth/src/AuthSession.ts index f2c65181..e34dfe0f 100644 --- a/packages/auth/src/AuthSession.ts +++ b/packages/auth/src/AuthSession.ts @@ -1,7 +1,7 @@ import { AuthContract } from './Contracts/AuthContract' import type { PersonalAccessToken } from '@app/models/PersonalAccessToken' import { Session as HttpSession } from '@arkstack/http' -import { getModel } from '@arkstack/common' +import { Concrete, getModel } from '@arkstack/common' /** * Represents an authenticated user session. @@ -18,7 +18,7 @@ export class AuthSession extends HttpSession { * * @returns */ - override async destroy () { + override async destroy() { const pat = await this.token() if (pat) { @@ -35,7 +35,7 @@ export class AuthSession extends HttpSession { * * @returns */ - async token (): Promise { + async token(): Promise { if (!this.auth.getRequest()) { return null } @@ -46,7 +46,7 @@ export class AuthSession extends HttpSession { return null } - const Model = await getModel('PersonalAccessToken') + const Model = getModel>('PersonalAccessToken') return await Model.query().where({ token }).first() } diff --git a/packages/auth/tests/fixtures/auth.ts b/packages/auth/tests/fixtures/auth.ts index d9e74efc..0481a4ce 100644 --- a/packages/auth/tests/fixtures/auth.ts +++ b/packages/auth/tests/fixtures/auth.ts @@ -1,7 +1,7 @@ +import { Concrete, getModel } from '@arkstack/common' import { PersonalAccessToken, User } from '../../src' import { SignJWT } from 'jose' -import { getModel } from '@arkstack/common' import { randomUUID } from 'node:crypto' export const authSecret = 'test-secret' @@ -10,7 +10,7 @@ const users: User[] = [] const personalAccessTokens: PersonalAccessToken[] = [] export const createAuthUser = async (attributes: Partial> = {}) => { - const user = await (await getModel('User')).query().create({ + const user = await (getModel>('User')).query().create({ email: attributes.email ?? `auth-test-${randomUUID()}@example.com`, name: attributes.name ?? 'Auth Test', password: attributes.password ?? 'password', @@ -30,7 +30,7 @@ export const createAuthToken = async (subject: string | number) => await new Sig .sign(new TextEncoder().encode(authSecret)) export const createPersonalAccessToken = async (userId: string | number, token: string) => { - const personalAccessToken = await (await getModel('PersonalAccessToken')).query().create({ + const personalAccessToken = await (getModel>('PersonalAccessToken')).query().create({ abilities: [], lastUsedAt: new Date(), name: 'Test device', diff --git a/packages/common/package.json b/packages/common/package.json index e553bf7d..50b8ac13 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -64,5 +64,10 @@ "arkormx": { "optional": true } + }, + "inlinedDependencies": { + "clear-router": "2.9.3", + "dayjs": "1.11.20", + "kanun": "1.2.0" } } diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 2a420f33..39dd3296 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -362,4 +362,10 @@ export interface PublishConfirmation { choice: Choice['value'], stub: string ) => string | Promise -} \ No newline at end of file +} + +export type Concrete any> = + Omit & { + new(...args: ConstructorParameters): InstanceType + prototype: InstanceType + } \ No newline at end of file diff --git a/packages/notifications/src/Contracts/UserNotification.ts b/packages/notifications/src/Contracts/UserNotification.ts index 81f9cd78..10ea8f76 100644 --- a/packages/notifications/src/Contracts/UserNotification.ts +++ b/packages/notifications/src/Contracts/UserNotification.ts @@ -19,12 +19,4 @@ export abstract class UserNotification extends Model { protected casts = { meta: 'json', } as const -} - -export type CUserNotification = - Omit & - { new(...args: any[]): UserNotification; prototype: UserNotification } - -export type Concrete = - Omit & - { new(...args: any[]): Abstract; prototype: Abstract } \ No newline at end of file +} \ No newline at end of file diff --git a/packages/notifications/src/UserNotificationCenter.ts b/packages/notifications/src/UserNotificationCenter.ts index 885e03c9..85198380 100644 --- a/packages/notifications/src/UserNotificationCenter.ts +++ b/packages/notifications/src/UserNotificationCenter.ts @@ -1,15 +1,15 @@ import type { ArkormCollection, LengthAwarePaginator } from 'arkormx' -import type { CUserNotification, UserNotification } from '@app/models/UserNotification' +import { Concrete, getModel } from '@arkstack/common' import type { DbNotificationPayload } from './types' import { Notification } from './Notification' import type { User } from '@app/models/User' +import type { UserNotification } from '@app/models/UserNotification' import { UserPushToken } from './Contracts/UserPushToken' -import { getModel } from '@arkstack/common' export class UserNotificationCenter { private static async getModel() { - return getModel('UserNotification') + return getModel>('UserNotification') } /** diff --git a/packages/notifications/src/drivers/DbNotification.ts b/packages/notifications/src/drivers/DbNotification.ts index cf53d8bb..8f8bfc6c 100644 --- a/packages/notifications/src/drivers/DbNotification.ts +++ b/packages/notifications/src/drivers/DbNotification.ts @@ -1,10 +1,10 @@ -import type { CUserNotification, UserNotification } from '@app/models/UserNotification' +import { Concrete, getModel } from '@arkstack/common' import type { DbNotificationPayload, NotificationData, NotificationRecipient } from '../types' import { NotificationContract } from '../Contracts/NotificationContract' import type { User } from '@app/models/User' +import type { UserNotification } from '@app/models/UserNotification' import { UserNotificationCenter } from '../UserNotificationCenter' -import { getModel } from '@arkstack/common' import { interpolate } from '../utils/template' export class DbNotification extends NotificationContract { @@ -61,7 +61,7 @@ export class DbNotification extends NotificationContract { } async create(user: User, payload: DbNotificationPayload) { - getModel('UserNotification') + getModel>('UserNotification') if (this.realtime) { return await UserNotificationCenter.send(user, payload) as never