@murumets-ee/notifications 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.mjs","names":["authUser"],"sources":["../src/queue-recipients.ts","../src/plugin.ts"],"sourcesContent":["/**\n * Recipient resolver for queue terminal notifications (`queue.job.dead`).\n *\n * Lists every user with the `admin` role straight from the auth `user`\n * table. Same direct-Drizzle pattern + CLAUDE.md rationale as\n * `recipients.ts` — there's no AdminClient for the auth user surface, so\n * batch reads happen against the typed `@murumets-ee/auth/schema` exports\n * instead of going round-trip through better-auth's HTTP API.\n *\n * Scoped to \"role=admin\" exactly: the toolkit's permissions model gives\n * `admin` the safety-net \"always returns true\" behaviour\n * (`packages/auth/src/permissions.ts`). Other roles can be granted\n * `system:read` etc. via the roles editor, but for v1 we treat\n * dead-letter alerts as an admin-only operator concern. A future\n * `notifications.queue.dead.recipientsRole` setting (or a custom\n * `resolveUsers` override on the queue notifier wiring) can broaden\n * this without changing the publisher API.\n */\n\nimport { user as authUser } from '@murumets-ee/auth/schema'\nimport { eq } from 'drizzle-orm'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\n\ninterface AdminIdRow {\n id: string\n}\n\n/**\n * Returns the user ids of every user with role `admin`. The\n * notification client's recipient cap (default 1000) is the hard\n * upper bound that bites if a deployment somehow has more.\n */\nexport async function listAdminUserIds(db: PostgresJsDatabase): Promise<string[]> {\n const rows = (await db\n .select({ id: authUser.id })\n .from(authUser)\n .where(eq(authUser.role, 'admin'))) as AdminIdRow[]\n return rows.map((r) => r.id)\n}\n","/**\n * Notifications plugin for `@murumets-ee/core`.\n *\n * Registers the `toolkit_notifications` table, mounts the admin API\n * routes, ships the `notifications.preferences` user-scoped settings\n * namespace, and walks every plugin's `shared.notifications` to populate\n * the type registry.\n *\n * @example\n * ```ts\n * import { defineLumiConfig } from '@murumets-ee/core'\n * import { notifications } from '@murumets-ee/notifications/plugin'\n * import { TicketingMessageCreated } from '@murumets-ee/ticketing/notifications'\n *\n * export default defineLumiConfig({\n * plugins: [\n * notifications({ types: [TicketingMessageCreated] }),\n * // ...\n * ],\n * })\n * ```\n */\n\nimport type { Plugin, PluginNotificationTypeDefinition, ToolkitApp } from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport { notificationsRoutes } from './admin.js'\nimport { inAppChannel } from './channels/in-app.js'\nimport type { NotificationChannel } from './channels/types.js'\nimport { NotificationClient, notify, setActiveNotificationClient } from './client.js'\nimport { registerNotificationType } from './define.js'\nimport { toolkitNotifications } from './notifications-table.js'\nimport { notificationPreferencesSettings, resolveEnabledChannels } from './preferences.js'\nimport { listAdminUserIds } from './queue-recipients.js'\nimport type { NotificationChannelId, NotificationPreferences, NotificationType } from './types.js'\nimport { DEFAULT_RECIPIENT_CAP } from './types.js'\n\nexport interface NotificationsPluginOptions {\n /**\n * Notification types contributed at the consumer level (i.e. defined in\n * the app, not in a plugin). Plugin-contributed types are also collected\n * via `Plugin.shared.notifications` and merged in at init time.\n */\n types?: NotificationType[]\n /**\n * Default locale for renders when the recipient has no per-user locale.\n * Defaults to `'en'`.\n */\n defaultLocale?: string\n /**\n * Maximum number of recipients a single `resolveUsers()` call may return.\n * Defaults to 1000 — bounds the blast radius of a misbehaving permission\n * resolver. See PLAN-NOTIFICATIONS §6 Q6.\n */\n recipientCap?: number\n /**\n * Per-channel options. Currently only `email` is configurable.\n *\n * The email channel auto-enables when both `@murumets-ee/mail` (with a\n * provider) and `@murumets-ee/queue` are initialised in the same app.\n * Set `email.enabled: false` to opt out even when both are available\n * (e.g. an environment that should only deliver in-app).\n */\n channels?: {\n email?: {\n /**\n * Override the default opt-in. `true` forces email enabled (throws on\n * init if mail is unavailable), `false` disables it explicitly,\n * `undefined` (default) auto-enables when mail+queue are present.\n */\n enabled?: boolean\n /**\n * From-address. Falls back to `mail().defaultFrom`. One of the two\n * MUST be configured or the email channel skips registration with\n * a warning.\n */\n from?: string\n }\n }\n}\n\nexport function notifications(options: NotificationsPluginOptions = {}): Plugin {\n const localTypes = options.types ?? []\n\n return {\n name: '@murumets-ee/notifications',\n server: {\n tables: { toolkitNotifications },\n routes: [notificationsRoutes()],\n init: async (app) => {\n if (!schemaRegistry.has('toolkit_notifications')) {\n schemaRegistry.register('toolkit_notifications', toolkitNotifications)\n }\n\n // 1) Register types — local first, then plugin contributions.\n // Walking app.plugins.all() at init time means a plugin contributing\n // types via `shared.notifications` doesn't need to be ordered before\n // notifications() in the array. (registerNotificationType is\n // idempotent — re-registering with the same id replaces.)\n for (const t of localTypes) {\n registerNotificationType(t)\n }\n for (const plugin of app.plugins.all()) {\n if (plugin.name === '@murumets-ee/notifications') continue\n const contributed = plugin.shared?.notifications\n if (!contributed) continue\n for (const def of contributed) {\n // PluginNotificationTypeDefinition is structurally widened —\n // runtime values were full `NotificationType` instances when\n // the plugin contributed them. Cast at this single boundary.\n registerNotificationType(def as unknown as NotificationType)\n }\n }\n\n // 2) Build the preferences resolver. Caches per-user settings reads\n // for a single notify() call (the resolver itself is stateless;\n // notify() does the per-call cache). v1 reads via createSettingsClient.\n const preferencesResolver = async (userId: string): Promise<NotificationPreferences> => {\n // Dynamic import to keep settings out of any non-RSC startup\n // path that might transitively load this file (jiti / tsx).\n const { createSettingsClient } = await import('@murumets-ee/settings')\n const client = createSettingsClient(notificationPreferencesSettings, {\n app,\n scopeId: userId,\n })\n const prefs = (await client.get('prefs')) as NotificationPreferences | null\n return prefs ?? {}\n }\n\n // 3) Build the channel registry. In-app is always present. Email\n // auto-enables when both @murumets-ee/mail (with provider) and\n // @murumets-ee/queue are initialised. Either dependency missing →\n // skip with a warning (in-app delivery still works).\n const channels: Record<string, NotificationChannel> = { inApp: inAppChannel }\n const emailOpt = options.channels?.email\n const wantEmail = emailOpt?.enabled !== false\n if (wantEmail) {\n await wireEmailChannel({\n app,\n channels,\n forced: emailOpt?.enabled === true,\n from: emailOpt?.from,\n defaultLocale: options.defaultLocale ?? 'en',\n })\n }\n\n // 4) Wire up the active client.\n const client = new NotificationClient({\n db: app.db.readWrite,\n logger: app.logger.child({ pkg: 'notifications' }),\n defaultLocale: options.defaultLocale ?? 'en',\n recipientCap: options.recipientCap ?? DEFAULT_RECIPIENT_CAP,\n preferencesResolver,\n channels,\n })\n setActiveNotificationClient(client)\n\n // 5) PR E of PLAN-NOTIFICATIONS — install the queue terminal\n // notifier so the worker's dead-letter transitions fan out as\n // `queue.job.dead` notifications to every admin. Wired here\n // (not from queue's own init) because: (a) queue cannot\n // statically/dynamically import notifications without forming\n // a build-graph cycle, and (b) by the time notifications' init\n // runs, queue's `shared.notifications` types are already\n // registered above in step (1) — same registry the notifier's\n // `notify(...)` call targets via the type id.\n await wireQueueTerminalNotifier({ app })\n\n app.logger.info(\n {\n types: Array.from(new Set(localTypes.map((t) => t.id))),\n channels: Object.keys(channels),\n },\n 'Notifications plugin initialized',\n )\n },\n },\n shared: {\n // Contribute the user-scope preferences namespace through the\n // existing settings infra. `@murumets-ee/settings`' init walks\n // every plugin's shared.settings; `notifications.preferences`\n // shows up as a user-scoped settings page automatically (and the\n // `MyPreferencesPage` admin shell surfaces it).\n settings: [notificationPreferencesSettings],\n // Local types are also re-published via shared.notifications so\n // downstream consumers (preferences UI deriver in PR C, audit\n // tooling) can find every type via a single uniform path.\n notifications: localTypes as unknown as PluginNotificationTypeDefinition[],\n },\n }\n}\n\n// ---------------------------------------------------------------------------\n// Email channel wiring\n// ---------------------------------------------------------------------------\n\ninterface WireEmailArgs {\n app: ToolkitApp\n channels: Record<string, NotificationChannel>\n /** True when the operator explicitly set `channels.email.enabled: true`.\n * Causes init to throw on missing mail/provider, instead of warning. */\n forced: boolean\n /** Override from-address from plugin options. Falls back to mail's defaultFrom. */\n from: string | undefined\n defaultLocale: string\n}\n\n// Liberal email regex — sanity check at init time, not RFC-strict validation.\n// Catches the common \"missing @\" typo without rejecting display-name forms\n// like `\"App\" <no-reply@app.example>` that some providers accept.\nconst FROM_ADDR_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$|<[^\\s@]+@[^\\s@]+\\.[^\\s@]+>/\n\n/**\n * Wire the email channel into `channels` if both `@murumets-ee/mail` (with\n * provider) and `@murumets-ee/queue` are initialised. All package imports\n * are dynamic so the notifications package keeps working in environments\n * that don't have either dependency installed (in-app delivery only).\n */\nasync function wireEmailChannel(args: WireEmailArgs): Promise<void> {\n const { app, channels, forced, from: fromOpt, defaultLocale } = args\n\n // 1) Mail plugin must be initialised AND must have a provider.\n let mailModule: typeof import('@murumets-ee/mail/plugin')\n try {\n mailModule = await import('@murumets-ee/mail/plugin')\n } catch (err) {\n const msg =\n 'notifications: @murumets-ee/mail not available — email channel disabled (in-app still works)'\n if (forced) throw new Error(`${msg} (cause: ${(err as Error).message})`)\n app.logger.warn({ err }, msg)\n return\n }\n\n let mailConfig: ReturnType<typeof mailModule.getMailConfig>\n try {\n mailConfig = mailModule.getMailConfig()\n } catch (err) {\n const msg =\n 'notifications: mail() plugin not in plugins array — email channel disabled (in-app still works)'\n if (forced) throw new Error(`${msg} (cause: ${(err as Error).message})`)\n app.logger.warn({ err }, msg)\n return\n }\n\n if (!mailConfig.provider) {\n const msg =\n 'notifications: mail provider not configured — email channel disabled (in-app still works)'\n if (forced) throw new Error(msg)\n app.logger.warn(msg)\n return\n }\n\n const fromAddr = fromOpt || mailConfig.defaultFrom\n if (!fromAddr) {\n const msg =\n 'notifications: no from-address — set channels.email.from or mail().defaultFrom (email channel disabled)'\n if (forced) throw new Error(msg)\n app.logger.warn(msg)\n return\n }\n\n // Sanity-check the from-address shape. A typo'd `from: 'no-reply.app.com'`\n // (missing `@`) otherwise sails past init and fails 3× per send before the\n // queue marks the job dead. Catch it once at startup instead.\n if (!FROM_ADDR_RE.test(fromAddr)) {\n const msg = `notifications: from-address \"${fromAddr}\" is not a valid email — email channel disabled`\n if (forced) throw new Error(msg)\n app.logger.warn(msg)\n return\n }\n\n // 2) Queue package — needed for QueueClient + registerJob.\n let queueClientModule: typeof import('@murumets-ee/queue/client')\n try {\n queueClientModule = await import('@murumets-ee/queue/client')\n } catch (err) {\n const msg =\n 'notifications: @murumets-ee/queue not available — email channel disabled (in-app still works)'\n if (forced) throw new Error(`${msg} (cause: ${(err as Error).message})`)\n app.logger.warn({ err }, msg)\n return\n }\n\n // The queue PACKAGE being importable doesn't mean the queue() PLUGIN is in\n // the consumer's plugins array. Without it, `toolkit_jobs` is never\n // registered/migrated and every `enqueue()` fails at runtime — degrading\n // every email send to channel-`failed` with no retry path. Probe the\n // plugins array so we can warn loudly at init instead.\n const queuePluginPresent = app.plugins.all().some((p) => p.name === '@murumets-ee/queue')\n if (!queuePluginPresent) {\n const msg =\n 'notifications: queue() plugin not in plugins array — email channel disabled (in-app still works)'\n if (forced) throw new Error(msg)\n app.logger.warn(msg)\n return\n }\n\n // 3) Build channel + handler, register both.\n const { createEmailChannel, createSendEmailHandler, notificationsSendEmailJob } = await import(\n './channels/email.js'\n )\n\n const logger = app.logger.child({ pkg: 'notifications', channel: 'email' })\n const queue = new queueClientModule.QueueClient({\n db: app.db.readWrite,\n logger,\n })\n\n channels.email = createEmailChannel({ queue, logger })\n\n queueClientModule.registerJob(\n notificationsSendEmailJob,\n createSendEmailHandler({\n db: app.db.readWrite,\n mail: mailConfig.provider,\n from: fromAddr,\n defaultLocale,\n logger,\n }),\n )\n\n app.logger.info({ from: fromAddr }, 'Notifications email channel enabled')\n}\n\n// ---------------------------------------------------------------------------\n// Queue terminal notifier wiring (PR E of PLAN-NOTIFICATIONS)\n// ---------------------------------------------------------------------------\n\n/**\n * Cascade-loop guard for the queue terminal notifier.\n *\n * The notifications package owns `notifications:send-email` (and any\n * future `notifications:*` jobs). If the mail provider is down, every\n * send-email job dies after 3 retries. Without this guard each death\n * would call `notify(QueueJobDead)` → enqueue one more send-email job\n * per admin → each dies → geometric blow-up.\n *\n * The leading-edge alerter in `@murumets-ee/queue/alerter` still emails\n * operators about send-email failures via its dedupe-window path, so\n * the failure is still surfaced — just not via the bell drawer's\n * per-admin path that would self-amplify.\n *\n * Exported for unit testing.\n */\nexport function isQueueDeadEventNotifiable(jobType: string): boolean {\n return !jobType.startsWith('notifications:')\n}\n\ninterface WireQueueNotifierArgs {\n app: ToolkitApp\n}\n\n/**\n * Install a `QueueTerminalNotifier` against `@murumets-ee/queue` so the\n * worker's `failJob` dead-letter branch fans `queue.job.dead` out to\n * every admin via the framework `notify(...)` API.\n *\n * Skipped when:\n * - The queue plugin is not in the consumer's plugins array (no worker\n * to feed; nothing to notify on).\n * - The queue package isn't installed (very unlikely once notifications\n * itself loaded — they're in the same workspace — but defensive for\n * deployments that pruned to a strict subset).\n *\n * `queue.import.completed` is NOT wired here: the queue has no\n * \"originator\" concept on the jobs table, so per-user fan-out of\n * successful imports is the importer handler's responsibility (it knows\n * the user that kicked off the import). The type ships from\n * `@murumets-ee/queue/notifications` for those handlers to import.\n */\nasync function wireQueueTerminalNotifier(args: WireQueueNotifierArgs): Promise<void> {\n const { app } = args\n\n const queuePluginPresent = app.plugins.all().some((p) => p.name === '@murumets-ee/queue')\n if (!queuePluginPresent) {\n // Plugin missing → no worker, no terminal events. Don't even try.\n return\n }\n\n let queueClientModule: typeof import('@murumets-ee/queue/client')\n try {\n queueClientModule = await import('@murumets-ee/queue/client')\n } catch (err) {\n app.logger.warn(\n { err },\n 'notifications: @murumets-ee/queue not available — queue terminal notifier disabled',\n )\n return\n }\n\n let queueNotificationsModule: typeof import('@murumets-ee/queue/notifications')\n try {\n queueNotificationsModule = await import('@murumets-ee/queue/notifications')\n } catch (err) {\n app.logger.warn(\n { err },\n 'notifications: @murumets-ee/queue/notifications subpath not available — queue terminal notifier disabled',\n )\n return\n }\n\n const { QueueJobDead, buildQueueJobDeadPayload } = queueNotificationsModule\n const db = app.db.readWrite\n const logger = app.logger.child({ pkg: 'notifications', wiring: 'queue-notifier' })\n\n queueClientModule.setActiveQueueNotifier({\n onJobDead: async (event) => {\n // Cascade-loop guard. See `isQueueDeadEventNotifiable` for the\n // rationale + regression test.\n if (!isQueueDeadEventNotifiable(event.jobType)) {\n return\n }\n\n // Resolve admins lazily on every event — admins added/removed\n // between worker start and event fire need to be reflected\n // immediately. The cap on `resolveUsers()` (default 1000) bounds\n // any pathological case.\n try {\n await notify({\n // PluginNotificationTypeDefinition is the open shape; the\n // notify() body does not narrow on TPayload, so the cast is\n // structural and safe — both ends agree on the registry id.\n type: QueueJobDead as unknown as Parameters<typeof notify>[0]['type'],\n recipients: { resolveUsers: () => listAdminUserIds(db) },\n payload: buildQueueJobDeadPayload({\n jobId: event.jobId,\n jobType: event.jobType,\n attempts: event.attempts,\n error: event.error,\n failedAt: event.failedAt,\n }),\n })\n } catch (err) {\n // The worker dispatcher already swallows + logs at warn — log\n // here too so attribution points at the wiring (notifications\n // plugin) rather than the worker dispatcher.\n logger.warn(\n { err, jobId: event.jobId, jobType: event.jobType },\n 'queue.job.dead notify() failed',\n )\n }\n },\n // No `onJobCompleted` — `queue.import.completed` is consumer-callable\n // and has no auto-fire surface here. See queue/notifications.ts.\n })\n\n app.logger.info('Notifications queue terminal notifier wired (queue.job.dead → admins)')\n}\n\n// ---------------------------------------------------------------------------\n// Re-exports\n// ---------------------------------------------------------------------------\n\nexport type { NotificationChannelId }\nexport { resolveEnabledChannels }\n"],"mappings":"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"}
@@ -0,0 +1,38 @@
1
+ import { o as NotificationChannelId, s as NotificationPreferences, u as NotificationType } from "./types-B8qKgKMj.mjs";
2
+ import { z } from "zod";
3
+ import * as _$_murumets_ee_settings0 from "@murumets-ee/settings";
4
+
5
+ //#region src/preferences.d.ts
6
+ /**
7
+ * Zod schema for the `prefs` json blob. Loose-by-design: keys are type
8
+ * ids (validated in notify()), inner record values are channel ids →
9
+ * boolean. Unknown channel ids are tolerated so a user's prefs survive
10
+ * a plugin renaming or removing a channel.
11
+ */
12
+ declare const notificationPreferencesSchema: z.ZodType<NotificationPreferences>;
13
+ /**
14
+ * Settings definition for `notifications.preferences`. Contributed via
15
+ * the notifications plugin's `shared.settings` so the existing settings
16
+ * infra handles validation, persistence, audit, and route mounting.
17
+ */
18
+ declare const notificationPreferencesSettings: _$_murumets_ee_settings0.SettingsDefinition<{
19
+ readonly prefs: {
20
+ readonly type: "json";
21
+ readonly label: "Per-type, per-channel preferences";
22
+ readonly description: "Map of notification type id → channel id → enabled. Missing entries fall back to type defaults.";
23
+ readonly default: NotificationPreferences;
24
+ readonly schema: z.ZodType<NotificationPreferences, z.ZodTypeDef, NotificationPreferences>;
25
+ };
26
+ }>;
27
+ /**
28
+ * Resolve the effective enabled-channel list for a given (user, type),
29
+ * applying user prefs over type defaults.
30
+ *
31
+ * Returns the channels declared by the type that are enabled for the
32
+ * user. Order is preserved by `Object.keys(type.channels)` for stable
33
+ * snapshot tests.
34
+ */
35
+ declare function resolveEnabledChannels(type: NotificationType, userPrefs: NotificationPreferences): NotificationChannelId[];
36
+ //#endregion
37
+ export { notificationPreferencesSettings as n, resolveEnabledChannels as r, notificationPreferencesSchema as t };
38
+ //# sourceMappingURL=preferences-BCkY2j9L.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preferences-BCkY2j9L.d.mts","names":[],"sources":["../src/preferences.ts"],"mappings":";;;;;;;;;;;cA2Ba,6BAAA,EAA+B,CAAA,CAAE,OAAA,CAAQ,uBAAA;;;;;;cAUzC,+BAAA,2BAA+B,kBAAA;EAAA;;;;sBAevB,uBAAA;IAAA;;;;;AAcrB;;;;;;iBAAgB,sBAAA,CACd,IAAA,EAAM,gBAAA,EACN,SAAA,EAAW,uBAAA,GACV,qBAAA"}
@@ -0,0 +1,2 @@
1
+ import{column as e,defineTable as t}from"@murumets-ee/db";import{user as n}from"@murumets-ee/auth/schema";import{inArray as r}from"drizzle-orm";const i=t({name:`toolkit_notifications`,columns:{id:e.uuid({primaryKey:!0,defaultRandom:!0}),recipientUserId:e.varchar({length:255,notNull:!0,pgName:`recipient_user_id`}),type:e.varchar({length:128,notNull:!0}),payload:e.jsonb({notNull:!0,default:{}}),channels:e.jsonb({notNull:!0,default:[]}),groupKey:e.varchar({length:128,pgName:`group_key`}),readAt:e.timestamp({withTimezone:!0,pgName:`read_at`}),archivedAt:e.timestamp({withTimezone:!0,pgName:`archived_at`}),createdAt:e.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`created_at`}),updatedAt:e.timestamp({notNull:!0,defaultNow:!0,withTimezone:!0,pgName:`updated_at`})},indexes:[{on:[`recipientUserId`,`readAt`,`createdAt`],name:`toolkit_notifications_recipient_read_at_created_at_idx`},{on:[`recipientUserId`,`type`,`groupKey`],name:`toolkit_notifications_recipient_type_group_key_idx`}]}),a=i.table;var o=class extends Error{constructor(){super(`notify(): recipients is required. Pass { userIds: [...] } or { resolveUsers: async () => [...] }. There is no broadcast path.`),this.name=`RecipientRequiredError`}},s=class extends Error{constructor(e,t){super(`notify(): resolveUsers() returned ${e} recipients, which exceeds the configured cap of ${t}. Use a real broadcast tool — notifications fan-out is bounded by design.`),this.attemptedSize=e,this.cap=t,this.name=`NotificationFanoutTooLargeError`}},c=class extends Error{constructor(e){super(`notify(): notification type "${e}" is not registered. Did you forget to add it to your plugin's shared.notifications?`),this.typeId=e,this.name=`UnknownNotificationTypeError`}},l=class extends Error{constructor(e,t){super(`notify(): channel "${t}" is not supported by type "${e}". Declare it on the type's channels record to enable.`),this.typeId=e,this.channelId=t,this.name=`UnsupportedChannelError`}};async function u(e,t){let n;if(`userIds`in e)n=[...e.userIds];else if(`resolveUsers`in e)n=[...await e.resolveUsers()];else throw new o;if(n.length===0)throw new o;if(n.length>t)throw new s(n.length,t);let r=new Set,i=[];for(let e of n)r.has(e)||(r.add(e),i.push(e));return i}async function d(e,t,i){if(t.length===0)return new Map;let a=await e.select({id:n.id,name:n.name,email:n.email,emailVerified:n.emailVerified}).from(n).where(r(n.id,t)),o=new Map;for(let e of a)o.set(e.id,{id:e.id,name:e.name??void 0,email:e.emailVerified&&e.email?e.email:void 0,locale:i});return o}export{c as a,a as c,o as i,u as n,l as o,s as r,i as s,d as t};
2
+ //# sourceMappingURL=recipients-DDN8AJzX.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"recipients-DDN8AJzX.mjs","names":["authUser"],"sources":["../src/notifications-table.ts","../src/errors.ts","../src/recipients.ts"],"sourcesContent":["/**\n * `toolkit_notifications` table — one row per `(recipient, notification)`.\n *\n * Per-recipient duplication is intentional — read state is the dominant\n * access pattern and the join + array-membership filter alternative is\n * orders of magnitude slower for \"user X's unread count\". See PLAN-NOTIFICATIONS §3.1.\n *\n * `payload` is structured (not pre-rendered) so locale changes / display\n * preferences don't require a backfill. `channels` tracks per-channel\n * delivery state for observability — NOT a transactional state machine.\n *\n * `groupKey` enables \"12 replies on the same ticket → one bell entry that\n * updates on each new reply\" without a digest engine: a new event whose\n * groupKey collides with an *unread* row updates that row instead of\n * inserting. Once the user reads the row, the next event inserts a fresh\n * one. See PLAN-NOTIFICATIONS §3.1, §3.3, §6 Q3.\n */\n\nimport { column, defineTable } from '@murumets-ee/db'\nimport type { ChannelDeliveryRecord } from './types.js'\n\nexport const notificationsTable = defineTable({\n name: 'toolkit_notifications',\n columns: {\n id: column.uuid({ primaryKey: true, defaultRandom: true }),\n // varchar — better-auth user ids are random strings, NOT UUIDs.\n // See CLAUDE.md \"User IDs\" rule.\n recipientUserId: column.varchar({\n length: 255,\n notNull: true,\n pgName: 'recipient_user_id',\n }),\n type: column.varchar({ length: 128, notNull: true }),\n payload: column.jsonb<Record<string, unknown>, true>({\n notNull: true,\n default: {} as Record<string, unknown>,\n }),\n channels: column.jsonb<ChannelDeliveryRecord[], true>({\n notNull: true,\n default: [] as ChannelDeliveryRecord[],\n }),\n groupKey: column.varchar({ length: 128, pgName: 'group_key' }),\n readAt: column.timestamp({ withTimezone: true, pgName: 'read_at' }),\n archivedAt: column.timestamp({ withTimezone: true, pgName: 'archived_at' }),\n createdAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'created_at',\n }),\n updatedAt: column.timestamp({\n notNull: true,\n defaultNow: true,\n withTimezone: true,\n pgName: 'updated_at',\n }),\n },\n indexes: [\n // Drives the bell drawer + unread count. NULLS FIRST puts unread rows\n // at the top of the index for the common \"list unread, newest first\"\n // query.\n {\n on: ['recipientUserId', 'readAt', 'createdAt'],\n name: 'toolkit_notifications_recipient_read_at_created_at_idx',\n },\n // Drives \"is there an unread row for this group already?\" lookup before\n // insert (group-collapse mode).\n {\n on: ['recipientUserId', 'type', 'groupKey'],\n name: 'toolkit_notifications_recipient_type_group_key_idx',\n },\n ],\n})\n\n/** Backward-compatible re-export following the queue/jobs convention. */\nexport const toolkitNotifications = notificationsTable.table\n","/**\n * Errors thrown by `@murumets-ee/notifications`.\n *\n * All extend `Error` so callers can `instanceof` discriminate. Names match\n * the class so logger output reads cleanly.\n */\n\nexport class RecipientRequiredError extends Error {\n constructor() {\n super(\n 'notify(): recipients is required. Pass { userIds: [...] } or ' +\n '{ resolveUsers: async () => [...] }. There is no broadcast path.',\n )\n this.name = 'RecipientRequiredError'\n }\n}\n\nexport class NotificationFanoutTooLargeError extends Error {\n constructor(\n public readonly attemptedSize: number,\n public readonly cap: number,\n ) {\n super(\n `notify(): resolveUsers() returned ${attemptedSize} recipients, ` +\n `which exceeds the configured cap of ${cap}. Use a real broadcast ` +\n `tool — notifications fan-out is bounded by design.`,\n )\n this.name = 'NotificationFanoutTooLargeError'\n }\n}\n\nexport class UnknownNotificationTypeError extends Error {\n constructor(public readonly typeId: string) {\n super(\n `notify(): notification type \"${typeId}\" is not registered. ` +\n `Did you forget to add it to your plugin's shared.notifications?`,\n )\n this.name = 'UnknownNotificationTypeError'\n }\n}\n\nexport class UnsupportedChannelError extends Error {\n constructor(\n public readonly typeId: string,\n public readonly channelId: string,\n ) {\n super(\n `notify(): channel \"${channelId}\" is not supported by type \"${typeId}\". ` +\n `Declare it on the type's channels record to enable.`,\n )\n this.name = 'UnsupportedChannelError'\n }\n}\n","/**\n * Recipient identity resolver — fetches the canonical name + verified email\n * for a list of user ids straight from `@murumets-ee/auth`'s `user` table.\n *\n * Direct Drizzle SELECT against the auth schema is the documented pattern\n * (see CLAUDE.md \"Database Queries\"). Going through better-auth's HTTP API\n * for batch lookup would be both slower and round-trip the same trust\n * boundary (server reading its own DB).\n *\n * v1: only verified emails are returned. Unverified-email recipients still\n * receive in-app notifications; the email channel records `skipped_unverified`\n * (see PLAN-NOTIFICATIONS §6 Q5).\n */\n\nimport { user as authUser } from '@murumets-ee/auth/schema'\nimport { inArray } from 'drizzle-orm'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { NotificationFanoutTooLargeError, RecipientRequiredError } from './errors.js'\nimport type { RecipientContext, Recipients } from './types.js'\n\n/**\n * Resolve the recipient list to a deduped array of user ids, validate\n * against the configured cap, and reject empty/missing recipient inputs.\n *\n * `defaultLocale` is used as a baseline; per-user locale lives in\n * `notifications.preferences` user-scope settings (PR C will surface a\n * preference for it). v1 returns the default for everyone.\n */\nexport async function resolveRecipientIds(recipients: Recipients, cap: number): Promise<string[]> {\n let ids: string[]\n if ('userIds' in recipients) {\n ids = [...recipients.userIds]\n } else if ('resolveUsers' in recipients) {\n ids = [...(await recipients.resolveUsers())]\n } else {\n throw new RecipientRequiredError()\n }\n if (ids.length === 0) {\n throw new RecipientRequiredError()\n }\n if (ids.length > cap) {\n throw new NotificationFanoutTooLargeError(ids.length, cap)\n }\n // Dedupe — preserve first-seen order for stable test snapshots.\n const seen = new Set<string>()\n const out: string[] = []\n for (const id of ids) {\n if (!seen.has(id)) {\n seen.add(id)\n out.push(id)\n }\n }\n return out\n}\n\n/** Subset of the auth `user` row we read for notifications. */\ninterface RecipientRow {\n id: string\n name: string | null\n email: string | null\n emailVerified: boolean | null\n}\n\n/**\n * Bulk-fetch recipient identities, returning a `Map` keyed by id. Missing\n * user ids are simply absent from the map — the caller decides whether\n * that's a hard error (publisher tried to address a deleted user) or a\n * soft skip (race between resolveUsers() and DB read).\n *\n * Email is included only if `emailVerified === true`. Unverified emails\n * become `undefined` so the email channel can take the \"skipped_unverified\"\n * branch instead of sending to a typo'd or compromised address.\n */\nexport async function fetchRecipientContexts(\n db: PostgresJsDatabase,\n userIds: string[],\n defaultLocale: string,\n): Promise<Map<string, RecipientContext>> {\n if (userIds.length === 0) return new Map()\n // The auth `user` table's typed columns are exposed via `@murumets-ee/auth/schema`'s\n // `user` export. CLAUDE.md sanctions direct Drizzle SELECT against this\n // table — there's no AdminClient for the auth user surface.\n const rows = (await db\n .select({\n id: authUser.id,\n name: authUser.name,\n email: authUser.email,\n emailVerified: authUser.emailVerified,\n })\n .from(authUser)\n .where(inArray(authUser.id, userIds))) as RecipientRow[]\n\n const out = new Map<string, RecipientContext>()\n for (const row of rows) {\n out.set(row.id, {\n id: row.id,\n name: row.name ?? undefined,\n email: row.emailVerified && row.email ? row.email : undefined,\n locale: defaultLocale,\n })\n }\n return out\n}\n"],"mappings":"gJAqBA,MAAa,EAAqB,EAAY,CAC5C,KAAM,wBACN,QAAS,CACP,GAAI,EAAO,KAAK,CAAE,WAAY,GAAM,cAAe,GAAM,CAAC,CAG1D,gBAAiB,EAAO,QAAQ,CAC9B,OAAQ,IACR,QAAS,GACT,OAAQ,oBACT,CAAC,CACF,KAAM,EAAO,QAAQ,CAAE,OAAQ,IAAK,QAAS,GAAM,CAAC,CACpD,QAAS,EAAO,MAAqC,CACnD,QAAS,GACT,QAAS,EAAE,CACZ,CAAC,CACF,SAAU,EAAO,MAAqC,CACpD,QAAS,GACT,QAAS,EAAE,CACZ,CAAC,CACF,SAAU,EAAO,QAAQ,CAAE,OAAQ,IAAK,OAAQ,YAAa,CAAC,CAC9D,OAAQ,EAAO,UAAU,CAAE,aAAc,GAAM,OAAQ,UAAW,CAAC,CACnE,WAAY,EAAO,UAAU,CAAE,aAAc,GAAM,OAAQ,cAAe,CAAC,CAC3E,UAAW,EAAO,UAAU,CAC1B,QAAS,GACT,WAAY,GACZ,aAAc,GACd,OAAQ,aACT,CAAC,CACF,UAAW,EAAO,UAAU,CAC1B,QAAS,GACT,WAAY,GACZ,aAAc,GACd,OAAQ,aACT,CAAC,CACH,CACD,QAAS,CAIP,CACE,GAAI,CAAC,kBAAmB,SAAU,YAAY,CAC9C,KAAM,yDACP,CAGD,CACE,GAAI,CAAC,kBAAmB,OAAQ,WAAW,CAC3C,KAAM,qDACP,CACF,CACF,CAAC,CAGW,EAAuB,EAAmB,MCpEvD,IAAa,EAAb,cAA4C,KAAM,CAChD,aAAc,CACZ,MACE,gIAED,CACD,KAAK,KAAO,2BAIH,EAAb,cAAqD,KAAM,CACzD,YACE,EACA,EACA,CACA,MACE,qCAAqC,EAAc,mDACV,EAAI,2EAE9C,CAPe,KAAA,cAAA,EACA,KAAA,IAAA,EAOhB,KAAK,KAAO,oCAIH,EAAb,cAAkD,KAAM,CACtD,YAAY,EAAgC,CAC1C,MACE,gCAAgC,EAAO,sFAExC,CAJyB,KAAA,OAAA,EAK1B,KAAK,KAAO,iCAIH,EAAb,cAA6C,KAAM,CACjD,YACE,EACA,EACA,CACA,MACE,sBAAsB,EAAU,8BAA8B,EAAO,wDAEtE,CANe,KAAA,OAAA,EACA,KAAA,UAAA,EAMhB,KAAK,KAAO,4BCtBhB,eAAsB,EAAoB,EAAwB,EAAgC,CAChG,IAAI,EACJ,GAAI,YAAa,EACf,EAAM,CAAC,GAAG,EAAW,QAAQ,SACpB,iBAAkB,EAC3B,EAAM,CAAC,GAAI,MAAM,EAAW,cAAc,CAAE,MAE5C,MAAM,IAAI,EAEZ,GAAI,EAAI,SAAW,EACjB,MAAM,IAAI,EAEZ,GAAI,EAAI,OAAS,EACf,MAAM,IAAI,EAAgC,EAAI,OAAQ,EAAI,CAG5D,IAAM,EAAO,IAAI,IACX,EAAgB,EAAE,CACxB,IAAK,IAAM,KAAM,EACV,EAAK,IAAI,EAAG,GACf,EAAK,IAAI,EAAG,CACZ,EAAI,KAAK,EAAG,EAGhB,OAAO,EAqBT,eAAsB,EACpB,EACA,EACA,EACwC,CACxC,GAAI,EAAQ,SAAW,EAAG,OAAO,IAAI,IAIrC,IAAM,EAAQ,MAAM,EACjB,OAAO,CACN,GAAIA,EAAS,GACb,KAAMA,EAAS,KACf,MAAOA,EAAS,MAChB,cAAeA,EAAS,cACzB,CAAC,CACD,KAAKA,EAAS,CACd,MAAM,EAAQA,EAAS,GAAI,EAAQ,CAAC,CAEjC,EAAM,IAAI,IAChB,IAAK,IAAM,KAAO,EAChB,EAAI,IAAI,EAAI,GAAI,CACd,GAAI,EAAI,GACR,KAAM,EAAI,MAAQ,IAAA,GAClB,MAAO,EAAI,eAAiB,EAAI,MAAQ,EAAI,MAAQ,IAAA,GACpD,OAAQ,EACT,CAAC,CAEJ,OAAO"}
@@ -0,0 +1,194 @@
1
+ import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+
3
+ //#region src/types.d.ts
4
+ /** Channel ids supported in v1. */
5
+ type NotificationChannelId = 'inApp' | 'email';
6
+ /** Per-channel delivery state recorded on a notification row. */
7
+ interface ChannelDeliveryRecord {
8
+ channel: NotificationChannelId;
9
+ state: ChannelDeliveryState;
10
+ at: string;
11
+ /**
12
+ * Optional one-line operator-facing detail. Used for failure reasons
13
+ * (`error: <message>`), throttle info, etc. Never carries rendered text.
14
+ */
15
+ detail?: string;
16
+ }
17
+ /**
18
+ * Per-channel delivery state.
19
+ * - `delivered` — terminal success.
20
+ * - `queued` — handed to the channel; e.g. email job enqueued, not yet sent.
21
+ * - `failed` — terminal failure. The channel module decides whether to retry.
22
+ * - `throttled` — over-cap; row inserted but no send. Audit-logged.
23
+ * - `skipped_unverified` — recipient has no verified email; channel skipped.
24
+ */
25
+ type ChannelDeliveryState = 'delivered' | 'queued' | 'failed' | 'throttled' | 'skipped_unverified';
26
+ /** Per-channel default + throttle config declared by a notification type. */
27
+ interface ChannelDefinition {
28
+ /** Default value if the user has no preference for this (type, channel). */
29
+ default: boolean;
30
+ /**
31
+ * Per-user, per-channel rate cap. Format `<count>/<window>` where window
32
+ * is `<n><s|m|h>` (e.g. `5/15m` = at most 5 per 15 minutes per user).
33
+ * Over-cap deliveries land as a `throttled` ChannelDeliveryRecord on the
34
+ * notification row (so the bell still updates) and are not enqueued.
35
+ */
36
+ throttle?: string;
37
+ }
38
+ /** Rendered content for a single channel — shape per channel id. */
39
+ interface RenderedInApp {
40
+ /** Short title (single-line). */
41
+ title: string;
42
+ /** Body text (one or two lines). May be plain text. */
43
+ body?: string;
44
+ /** Optional deep-link clicked from the bell drawer. */
45
+ href?: string;
46
+ /** Optional icon identifier (e.g. lucide icon name). */
47
+ iconName?: string;
48
+ }
49
+ interface RenderedEmail {
50
+ subject: string;
51
+ html: string;
52
+ text: string;
53
+ }
54
+ /** Mapping from channel id → rendered content shape. */
55
+ interface RenderedContent {
56
+ inApp?: RenderedInApp;
57
+ email?: RenderedEmail;
58
+ }
59
+ /**
60
+ * Recipient context passed to a render function. The auth user record is
61
+ * looked up server-side; render is pure and depends only on what's passed.
62
+ */
63
+ interface RecipientContext {
64
+ /** Better-auth user id (varchar). */
65
+ id: string;
66
+ /** Display name as known to the auth user record (may be undefined). */
67
+ name?: string | undefined;
68
+ /** Verified email address (may be undefined). */
69
+ email?: string | undefined;
70
+ /** Locale resolved per-user; falls back to the app's default locale. */
71
+ locale: string;
72
+ }
73
+ /** Render function input. */
74
+ interface RenderArgs<TPayload> {
75
+ payload: TPayload;
76
+ recipient: RecipientContext;
77
+ locale: string;
78
+ }
79
+ /**
80
+ * A notification type — produced by `defineNotificationType(...)`.
81
+ *
82
+ * NB: The `TPayload` type parameter flows from the publisher through the
83
+ * render function so payloads are checked at the call site, not at runtime.
84
+ */
85
+ interface NotificationType<TPayload extends Record<string, unknown> = Record<string, unknown>> {
86
+ /** Stable, namespaced id (e.g. `ticketing.message.created`). */
87
+ id: string;
88
+ /** Per-channel defaults + throttles. Channels not listed are unsupported. */
89
+ channels: Partial<Record<NotificationChannelId, ChannelDefinition>>;
90
+ /**
91
+ * Optional collapse key. When two unread rows would have the same
92
+ * `(recipientUserId, type, groupBy(payload))`, the existing row is
93
+ * updated (payload + updatedAt) instead of inserting a new one.
94
+ */
95
+ groupBy?: (payload: TPayload) => string;
96
+ /** Pure render — receives payload + recipient + locale, returns per-channel content. */
97
+ render: (args: RenderArgs<TPayload>) => RenderedContent;
98
+ }
99
+ /**
100
+ * Recipients shape accepted by `notify(...)`.
101
+ *
102
+ * Mirrors realtime's "scope is required" rule: there is no broadcast-to-
103
+ * everyone path. A misconfigured publisher gets a `RecipientRequiredError`,
104
+ * never a silent fan-out.
105
+ */
106
+ type Recipients = {
107
+ userIds: string[];
108
+ } | {
109
+ resolveUsers: () => Promise<string[]> | string[];
110
+ };
111
+ /** Options passed to `notify(...)`. */
112
+ interface NotifyOptions<TPayload extends Record<string, unknown>> {
113
+ type: NotificationType<TPayload>;
114
+ recipients: Recipients;
115
+ payload: TPayload;
116
+ /**
117
+ * Drizzle transaction handle from `db.transaction(async (tx) => ...)`.
118
+ * When set, every notification row INSERT/UPDATE and every email-channel
119
+ * `queue.enqueue(...)` participates in the caller's transaction. Commit →
120
+ * rows visible to the worker and to other connections. Rollback → no
121
+ * rows persist and no email job is enqueued.
122
+ *
123
+ * Closes the v1 limitation called out in PLAN-NOTIFICATIONS §3.3
124
+ * ("publisher rollback creates ghost notifications") — shipped as
125
+ * PLAN-OUTBOX Phase 5 / PR E (a.k.a. PLAN-NOTIFICATIONS PR F — the
126
+ * dual numbering is harmless: each plan numbers its own scope).
127
+ *
128
+ * Realtime publishes are intentionally NOT bound to the tx: realtime is
129
+ * ephemeral by design. A rollback case would publish to nothing-of-value
130
+ * because no row exists; in the commit case the realtime fan-out happens
131
+ * after each channel `send()` and reflects the committed state.
132
+ *
133
+ * @example
134
+ * ```ts
135
+ * await db.transaction(async (tx) => {
136
+ * const order = await orders.create({ ... }, { tx })
137
+ * await notify({
138
+ * type: OrderPlaced,
139
+ * recipients: { userIds: [order.userId] },
140
+ * payload: { orderId: order.id },
141
+ * tx,
142
+ * })
143
+ * // If THIS tx rolls back, the order row, every notification row, and
144
+ * // every queued email job all roll back together.
145
+ * })
146
+ * ```
147
+ */
148
+ tx?: PostgresJsDatabase;
149
+ }
150
+ /** Successful return from `notify(...)`. */
151
+ interface NotifyResult {
152
+ /** Newly inserted or updated notification rows, one per recipient. */
153
+ notificationIds: string[];
154
+ }
155
+ /**
156
+ * Per-user preferences shape persisted under settings namespace
157
+ * `notifications.preferences` (user-scope).
158
+ *
159
+ * `preferences[typeId][channelId]` — boolean. Missing key → fall back to
160
+ * `type.channels[channelId].default`.
161
+ */
162
+ type NotificationPreferences = Record<string, Record<string, boolean>>;
163
+ /**
164
+ * Maximum number of recipients a single `resolveUsers()` call may return.
165
+ * Bounds the blast radius of a misbehaving permission resolver. Configurable
166
+ * at plugin init time.
167
+ */
168
+ declare const DEFAULT_RECIPIENT_CAP = 1000;
169
+ /** Topics published to realtime by the in-app channel. */
170
+ declare const NOTIFICATION_TOPICS: {
171
+ readonly created: "notification.created";
172
+ readonly updated: "notification.updated";
173
+ readonly read: "notification.read";
174
+ readonly archived: "notification.archived";
175
+ };
176
+ type NotificationTopic = (typeof NOTIFICATION_TOPICS)[keyof typeof NOTIFICATION_TOPICS];
177
+ /**
178
+ * Realtime payload — kept minimal so authorization is re-checked at REST
179
+ * read. `id` and `type` are present on per-row events
180
+ * (`notification.created` / `updated` / single-row `read` / `archived`)
181
+ * and absent on bulk events (`notification.read` fired by mark-all-read,
182
+ * which has no single id to point at).
183
+ */
184
+ interface NotificationRealtimePayload {
185
+ /** Notification row id. Absent on bulk events (mark-all-read). */
186
+ id?: string | undefined;
187
+ /** Notification type id. Absent on bulk events. */
188
+ type?: string | undefined;
189
+ /** Recipient's unread count AFTER this event. Always present. */
190
+ unreadCount: number;
191
+ }
192
+ //#endregion
193
+ export { RenderedEmail as _, NOTIFICATION_TOPICS as a, NotificationRealtimePayload as c, NotifyOptions as d, NotifyResult as f, RenderedContent as g, RenderArgs as h, DEFAULT_RECIPIENT_CAP as i, NotificationTopic as l, Recipients as m, ChannelDeliveryRecord as n, NotificationChannelId as o, RecipientContext as p, ChannelDeliveryState as r, NotificationPreferences as s, ChannelDefinition as t, NotificationType as u, RenderedInApp as v };
194
+ //# sourceMappingURL=types-B8qKgKMj.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-B8qKgKMj.d.mts","names":[],"sources":["../src/types.ts"],"mappings":";;;AAcA;AAAA,KAHY,qBAAA;;UAGK,qBAAA;EACf,OAAA,EAAS,qBAAA;EACT,KAAA,EAAO,oBAAA;EACP,EAAA;EADO;;;;EAMP,MAAA;AAAA;;;;;AAmBF;;;;KARY,oBAAA;AAqBZ;AAAA,UAbiB,iBAAA;;EAEf,OAAA;EAaA;;;;;;EANA,QAAA;AAAA;;UAIe,aAAA;EAYf;EAVA,KAAA;EAYA;EAVA,IAAA;EAUI;EARJ,IAAA;EAY8B;EAV9B,QAAA;AAAA;AAAA,UAGe,aAAA;EACf,OAAA;EACA,IAAA;EACA,IAAA;AAAA;;UAIe,eAAA;EACf,KAAA,GAAQ,aAAA;EACR,KAAA,GAAQ,aAAA;AAAA;;;;;UAOO,gBAAA;EAQT;EANN,EAAA;EAUyB;EARzB,IAAA;EAU2B;EAR3B,KAAA;EAOA;EALA,MAAA;AAAA;;UAIe,UAAA;EACf,OAAA,EAAS,QAAA;EACT,SAAA,EAAW,gBAAA;EACX,MAAA;AAAA;;;;;;;UASe,gBAAA,kBACE,MAAA,oBAA0B,MAAA;EAWvB;EARpB,EAAA;EAUe;EARf,QAAA,EAAU,OAAA,CAAQ,MAAA,CAAO,qBAAA,EAAuB,iBAAA;EAQO;;;;;EAFvD,OAAA,IAAW,OAAA,EAAS,QAAA;EANpB;EAQA,MAAA,GAAS,IAAA,EAAM,UAAA,CAAW,QAAA,MAAc,eAAA;AAAA;;;;;;;;KAU9B,UAAA;EACN,OAAA;AAAA;EACA,YAAA,QAAoB,OAAA;AAAA;AAF1B;AAAA,UAKiB,aAAA,kBAA+B,MAAA;EAC9C,IAAA,EAAM,gBAAA,CAAiB,QAAA;EACvB,UAAA,EAAY,UAAA;EACZ,OAAA,EAAS,QAAA;EANL;;;;AAGN;;;;;;;;;;;;;;;;;;;;;;;;AAwCA;;;;EAJE,EAAA,GAAK,kBAAA;AAAA;;UAIU,YAAA;EAYqB;EAVpC,eAAA;AAAA;;;;;AAoBF;;;KAVY,uBAAA,GAA0B,MAAA,SAAe,MAAA;;;;;;cAOxC,qBAAA;AAUb;AAAA,cAPa,mBAAA;EAAA;;;;;KAOD,iBAAA,WAA4B,mBAAA,eAAkC,mBAAA;;;;;;;;UASzD,2BAAA;;EAEf,EAAA;;EAEA,IAAA;;EAEA,WAAA;AAAA"}
@@ -0,0 +1,2 @@
1
+ const e=1e3,t={created:`notification.created`,updated:`notification.updated`,read:`notification.read`,archived:`notification.archived`};export{t as n,e as t};
2
+ //# sourceMappingURL=types-Dy_AGX6X.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-Dy_AGX6X.mjs","names":[],"sources":["../src/types.ts"],"sourcesContent":["/**\n * Public types for `@murumets-ee/notifications`.\n *\n * Channel ids ship as a string union for v1 (`'inApp' | 'email'`) and may\n * widen as new channels are added (push, sms). Plugin authors choose which\n * channels their type supports; users choose which they receive.\n */\n\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\n\n/** Channel ids supported in v1. */\nexport type NotificationChannelId = 'inApp' | 'email'\n\n/** Per-channel delivery state recorded on a notification row. */\nexport interface ChannelDeliveryRecord {\n channel: NotificationChannelId\n state: ChannelDeliveryState\n at: string\n /**\n * Optional one-line operator-facing detail. Used for failure reasons\n * (`error: <message>`), throttle info, etc. Never carries rendered text.\n */\n detail?: string\n}\n\n/**\n * Per-channel delivery state.\n * - `delivered` — terminal success.\n * - `queued` — handed to the channel; e.g. email job enqueued, not yet sent.\n * - `failed` — terminal failure. The channel module decides whether to retry.\n * - `throttled` — over-cap; row inserted but no send. Audit-logged.\n * - `skipped_unverified` — recipient has no verified email; channel skipped.\n */\nexport type ChannelDeliveryState =\n | 'delivered'\n | 'queued'\n | 'failed'\n | 'throttled'\n | 'skipped_unverified'\n\n/** Per-channel default + throttle config declared by a notification type. */\nexport interface ChannelDefinition {\n /** Default value if the user has no preference for this (type, channel). */\n default: boolean\n /**\n * Per-user, per-channel rate cap. Format `<count>/<window>` where window\n * is `<n><s|m|h>` (e.g. `5/15m` = at most 5 per 15 minutes per user).\n * Over-cap deliveries land as a `throttled` ChannelDeliveryRecord on the\n * notification row (so the bell still updates) and are not enqueued.\n */\n throttle?: string\n}\n\n/** Rendered content for a single channel — shape per channel id. */\nexport interface RenderedInApp {\n /** Short title (single-line). */\n title: string\n /** Body text (one or two lines). May be plain text. */\n body?: string\n /** Optional deep-link clicked from the bell drawer. */\n href?: string\n /** Optional icon identifier (e.g. lucide icon name). */\n iconName?: string\n}\n\nexport interface RenderedEmail {\n subject: string\n html: string\n text: string\n}\n\n/** Mapping from channel id → rendered content shape. */\nexport interface RenderedContent {\n inApp?: RenderedInApp\n email?: RenderedEmail\n}\n\n/**\n * Recipient context passed to a render function. The auth user record is\n * looked up server-side; render is pure and depends only on what's passed.\n */\nexport interface RecipientContext {\n /** Better-auth user id (varchar). */\n id: string\n /** Display name as known to the auth user record (may be undefined). */\n name?: string | undefined\n /** Verified email address (may be undefined). */\n email?: string | undefined\n /** Locale resolved per-user; falls back to the app's default locale. */\n locale: string\n}\n\n/** Render function input. */\nexport interface RenderArgs<TPayload> {\n payload: TPayload\n recipient: RecipientContext\n locale: string\n}\n\n/**\n * A notification type — produced by `defineNotificationType(...)`.\n *\n * NB: The `TPayload` type parameter flows from the publisher through the\n * render function so payloads are checked at the call site, not at runtime.\n */\nexport interface NotificationType<\n TPayload extends Record<string, unknown> = Record<string, unknown>,\n> {\n /** Stable, namespaced id (e.g. `ticketing.message.created`). */\n id: string\n /** Per-channel defaults + throttles. Channels not listed are unsupported. */\n channels: Partial<Record<NotificationChannelId, ChannelDefinition>>\n /**\n * Optional collapse key. When two unread rows would have the same\n * `(recipientUserId, type, groupBy(payload))`, the existing row is\n * updated (payload + updatedAt) instead of inserting a new one.\n */\n groupBy?: (payload: TPayload) => string\n /** Pure render — receives payload + recipient + locale, returns per-channel content. */\n render: (args: RenderArgs<TPayload>) => RenderedContent\n}\n\n/**\n * Recipients shape accepted by `notify(...)`.\n *\n * Mirrors realtime's \"scope is required\" rule: there is no broadcast-to-\n * everyone path. A misconfigured publisher gets a `RecipientRequiredError`,\n * never a silent fan-out.\n */\nexport type Recipients =\n | { userIds: string[] }\n | { resolveUsers: () => Promise<string[]> | string[] }\n\n/** Options passed to `notify(...)`. */\nexport interface NotifyOptions<TPayload extends Record<string, unknown>> {\n type: NotificationType<TPayload>\n recipients: Recipients\n payload: TPayload\n /**\n * Drizzle transaction handle from `db.transaction(async (tx) => ...)`.\n * When set, every notification row INSERT/UPDATE and every email-channel\n * `queue.enqueue(...)` participates in the caller's transaction. Commit →\n * rows visible to the worker and to other connections. Rollback → no\n * rows persist and no email job is enqueued.\n *\n * Closes the v1 limitation called out in PLAN-NOTIFICATIONS §3.3\n * (\"publisher rollback creates ghost notifications\") — shipped as\n * PLAN-OUTBOX Phase 5 / PR E (a.k.a. PLAN-NOTIFICATIONS PR F — the\n * dual numbering is harmless: each plan numbers its own scope).\n *\n * Realtime publishes are intentionally NOT bound to the tx: realtime is\n * ephemeral by design. A rollback case would publish to nothing-of-value\n * because no row exists; in the commit case the realtime fan-out happens\n * after each channel `send()` and reflects the committed state.\n *\n * @example\n * ```ts\n * await db.transaction(async (tx) => {\n * const order = await orders.create({ ... }, { tx })\n * await notify({\n * type: OrderPlaced,\n * recipients: { userIds: [order.userId] },\n * payload: { orderId: order.id },\n * tx,\n * })\n * // If THIS tx rolls back, the order row, every notification row, and\n * // every queued email job all roll back together.\n * })\n * ```\n */\n tx?: PostgresJsDatabase\n}\n\n/** Successful return from `notify(...)`. */\nexport interface NotifyResult {\n /** Newly inserted or updated notification rows, one per recipient. */\n notificationIds: string[]\n}\n\n/**\n * Per-user preferences shape persisted under settings namespace\n * `notifications.preferences` (user-scope).\n *\n * `preferences[typeId][channelId]` — boolean. Missing key → fall back to\n * `type.channels[channelId].default`.\n */\nexport type NotificationPreferences = Record<string, Record<string, boolean>>\n\n/**\n * Maximum number of recipients a single `resolveUsers()` call may return.\n * Bounds the blast radius of a misbehaving permission resolver. Configurable\n * at plugin init time.\n */\nexport const DEFAULT_RECIPIENT_CAP = 1000\n\n/** Topics published to realtime by the in-app channel. */\nexport const NOTIFICATION_TOPICS = {\n created: 'notification.created',\n updated: 'notification.updated',\n read: 'notification.read',\n archived: 'notification.archived',\n} as const\n\nexport type NotificationTopic = (typeof NOTIFICATION_TOPICS)[keyof typeof NOTIFICATION_TOPICS]\n\n/**\n * Realtime payload — kept minimal so authorization is re-checked at REST\n * read. `id` and `type` are present on per-row events\n * (`notification.created` / `updated` / single-row `read` / `archived`)\n * and absent on bulk events (`notification.read` fired by mark-all-read,\n * which has no single id to point at).\n */\nexport interface NotificationRealtimePayload {\n /** Notification row id. Absent on bulk events (mark-all-read). */\n id?: string | undefined\n /** Notification type id. Absent on bulk events. */\n type?: string | undefined\n /** Recipient's unread count AFTER this event. Always present. */\n unreadCount: number\n}\n"],"mappings":"AAiMA,MAAa,EAAwB,IAGxB,EAAsB,CACjC,QAAS,uBACT,QAAS,uBACT,KAAM,oBACN,SAAU,wBACX"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@murumets-ee/notifications",
3
+ "version": "0.12.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.mts",
9
+ "import": "./dist/index.mjs"
10
+ },
11
+ "./define": {
12
+ "types": "./dist/define.d.mts",
13
+ "import": "./dist/define.mjs"
14
+ },
15
+ "./plugin": {
16
+ "types": "./dist/plugin.d.mts",
17
+ "import": "./dist/plugin.mjs"
18
+ },
19
+ "./admin": {
20
+ "types": "./dist/admin.d.mts",
21
+ "import": "./dist/admin.mjs"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "drizzle-orm": "^0.45.2",
29
+ "zod": "^3.24.1",
30
+ "@murumets-ee/auth": "0.12.0",
31
+ "@murumets-ee/core": "0.12.0",
32
+ "@murumets-ee/db": "0.12.0",
33
+ "@murumets-ee/logging": "0.12.0",
34
+ "@murumets-ee/mail": "0.12.0",
35
+ "@murumets-ee/queue": "0.12.0",
36
+ "@murumets-ee/settings": "0.12.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^20.19.39",
40
+ "tsdown": "^0.21.10",
41
+ "typescript": "^5.7.3",
42
+ "vitest": "^2.1.8"
43
+ },
44
+ "typeCoverage": {
45
+ "atLeast": 99
46
+ },
47
+ "scripts": {
48
+ "build": "tsdown",
49
+ "dev": "tsdown --watch",
50
+ "test": "vitest",
51
+ "test:integration": "vitest run --config vitest.integration.config.ts"
52
+ }
53
+ }