@classytic/arc-notifications 0.8.0 → 0.10.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.
package/llms.txt ADDED
@@ -0,0 +1,29 @@
1
+ # @classytic/arc-notifications
2
+ > Domain events and civil dates to multi-channel notifications on arc. Single entry `@classytic/arc-notifications`. Skill: skills/arc-notifications/SKILL.md
3
+
4
+ | Need | Use |
5
+ |---|---|
6
+ | Event to notification | `NotifyTrigger` in `notificationsModule({ notifyOn })` |
7
+ | Dispatch one trigger | `dispatchTrigger` |
8
+ | Triggers owned by a bridge module | `notifyTriggerHandlers` |
9
+ | Admin-editable trigger settings | `toNotifyTriggers`, `createCachedTriggerOverrideStore`, `resolveEffectiveSettings` |
10
+ | Reminders relative to a record date | `createDateRuleEngine` |
11
+ | Civil date math | `addCivilDays`, `civilDaysBetween`, `isCivilDateString`, `dueOccurrence`, `dateRuleWindow` |
12
+ | Validate a rule schedule | `validateDateRuleSchedule` |
13
+ | Fired-once ledger | `DateRuleLedger`, `createMemoryDateRuleLedger` |
14
+ | In-app copy with {var} | `renderInApp`, `InAppTemplate` |
15
+ | Feed row plus live push | `InAppChannel` |
16
+ | Realtime-only push | `SseChannel` |
17
+ | Feed REST | `inAppFeedResource` |
18
+ | Unread badge cache | `createUnreadCountCache` |
19
+ | Reconnect catch-up | `createInAppBackfill` |
20
+ | Canonical list envelope | `feedListResult` |
21
+ | markRead / markAllRead verbs | `FEED_ACTION_VERBS` |
22
+ | Draftable templates | `createDbFirstResolver`, `templatesResource` |
23
+ | Delivery evidence per rule | `createRepositoryDeliveryLog` |
24
+ | Crash-safe bulk send | `notificationsJob` |
25
+ | Channel names for an admin UI | `createNotificationChannelCatalog` |
26
+ | Source-channel gating | `evaluateSourceChannel` |
27
+ | Deterministic time in tests | `NotificationClock`, `systemNotificationClock` |
28
+
29
+ Persistence for the ports (mongoose models, resources, suppression): @spinekit/core/notifications, skill spine-core-notifications.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc-notifications",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Notifications module for @classytic/arc — domain events → multi-channel notifications via @classytic/notifications, with DB-draftable templates, repo-backed delivery log, and a templates resource",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,7 +13,9 @@
13
13
  "types": "./dist/index.d.mts",
14
14
  "sideEffects": false,
15
15
  "files": [
16
- "dist"
16
+ "dist",
17
+ "skills",
18
+ "llms.txt"
17
19
  ],
18
20
  "publishConfig": {
19
21
  "access": "public"
@@ -29,10 +31,10 @@
29
31
  "prepublishOnly": "node ../../scripts/prepublish-gate.mjs"
30
32
  },
31
33
  "peerDependencies": {
32
- "@classytic/arc": ">=2.40.0",
33
- "@classytic/notifications": ">=2.5.0",
34
- "@classytic/primitives": ">=0.27.0",
35
- "@classytic/repo-core": ">=0.27.1"
34
+ "@classytic/arc": ">=2.46.0",
35
+ "@classytic/notifications": ">=2.7.0",
36
+ "@classytic/primitives": ">=0.34.0",
37
+ "@classytic/repo-core": ">=0.32.0"
36
38
  },
37
39
  "keywords": [
38
40
  "arc",
@@ -0,0 +1,129 @@
1
+ ---
2
+ name: arc-notifications
3
+ description: Compose notifications on arc with @classytic/arc-notifications. Event triggers, admin-editable trigger overrides, date-relative reminder rules, DB-draftable templates, in-app feed with realtime push and reconnect backfill, delivery log by rule. Use for notify, notification, reminder, bell, feed, SSE, delivery log, trigger, template.
4
+ metadata:
5
+ minVersion: "0.9.0"
6
+ ---
7
+
8
+ # @classytic/arc-notifications
9
+
10
+ Domain events and civil dates to multi-channel notifications over `@classytic/notifications`. Single entry, no subpaths: import everything from `@classytic/arc-notifications`. Persistence stays in the host (mongokit repositories); this package owns the mechanism and the ports.
11
+
12
+ ## Do not hand-roll
13
+
14
+ | Need | Use |
15
+ |---|---|
16
+ | Event to notification mapping | `NotifyTrigger` in `notificationsModule({ notifyOn })` |
17
+ | Dispatch one trigger from a worker or test | `dispatchTrigger(service, trigger, event)` |
18
+ | Triggers owned by a bridge module | `notifyTriggerHandlers({ name, service, triggers })` |
19
+ | Admin-editable enabled / recipients / channels / copy | `toNotifyTriggers`, `createCachedTriggerOverrideStore`, `resolveEffectiveSettings` |
20
+ | Reminders before / on / after a record date | `createDateRuleEngine` |
21
+ | Civil date math (no instants, no zones) | `addCivilDays`, `civilDaysBetween`, `isCivilDateString`, `dueOccurrence`, `dateRuleWindow` |
22
+ | Validate a rule schedule | `validateDateRuleSchedule` |
23
+ | Fired-once ledger for a sweep | `DateRuleLedger` (host unique index), `createMemoryDateRuleLedger` (tests) |
24
+ | `{var}` in-app copy | `renderInApp`, `InAppTemplate` |
25
+ | Feed row plus live push in one send | `InAppChannel({ repository, port, unreadCache })` |
26
+ | Realtime-only push | `SseChannel` |
27
+ | Feed REST (list, unread-count, read, delete) | `inAppFeedResource` |
28
+ | Unread badge cache | `createUnreadCountCache` |
29
+ | Reconnect catch-up | `createInAppBackfill` |
30
+ | Canonical list envelope for the feed | `feedListResult`, `listEnvelope: "offset"` |
31
+ | markRead / markAllRead action verbs | `FEED_ACTION_VERBS`, `actions: true` |
32
+ | Runtime-draftable templates | `createDbFirstResolver`, `templatesResource` |
33
+ | Durable delivery evidence, per rule | `createRepositoryDeliveryLog`, `listByRule` |
34
+ | Crash-safe bulk send | `notificationsJob` |
35
+ | Channel names for an admin UI | `createNotificationChannelCatalog` |
36
+ | Source-channel gating (web vs till) | `evaluateSourceChannel` |
37
+ | Deterministic time in tests | `NotificationClock`, `systemNotificationClock` |
38
+
39
+ ## Golden path
40
+
41
+ Event trigger with in-app copy, one send per recipient:
42
+
43
+ ```ts
44
+ import { notificationsModule, type NotifyTrigger } from "@classytic/arc-notifications";
45
+
46
+ type Assigned = { taskId: string; assigneeId: string; orgId: string };
47
+ const assigned: NotifyTrigger = {
48
+ event: "task.assigned",
49
+ recipient: (e) => {
50
+ const p = e.payload as Assigned;
51
+ return { id: p.assigneeId, metadata: { organizationId: p.orgId } };
52
+ },
53
+ inApp: {
54
+ title: "New task",
55
+ message: "Task {taskId} is yours",
56
+ entityType: "task",
57
+ entityId: "{taskId}",
58
+ },
59
+ };
60
+ notificationsModule({ channels, notifyOn: [assigned], deliveryLog });
61
+ ```
62
+
63
+ Persist and push with one channel, badge cache shared with the resource:
64
+
65
+ ```ts
66
+ import {
67
+ InAppChannel,
68
+ createUnreadCountCache,
69
+ inAppFeedResource,
70
+ } from "@classytic/arc-notifications";
71
+
72
+ const unreadCache = createUnreadCountCache();
73
+ const channel = new InAppChannel({ repository: feed, port, unreadCache });
74
+ const resource = inAppFeedResource({
75
+ repository: feed,
76
+ resolveScope,
77
+ permissions,
78
+ port,
79
+ unreadCache,
80
+ listEnvelope: "offset",
81
+ actions: true,
82
+ });
83
+ ```
84
+
85
+ SSE reconnect: call on every stream open, pass the client cursor:
86
+
87
+ ```ts
88
+ const backfill = createInAppBackfill({ repository: feed, port });
89
+ backfill.onConnect(userId, orgId, { since: req.headers["last-event-id"] as string | undefined });
90
+ ```
91
+
92
+ Delivery log with a rule column:
93
+
94
+ ```ts
95
+ const log = createRepositoryDeliveryLog(repo, {
96
+ ruleId: (p) => /^rule\.(\w+)$/.exec(p.event)?.[1],
97
+ });
98
+ const rows = await log.listByRule(ruleId, { status: "failed", limit: 50 });
99
+ ```
100
+
101
+ More: date rules `references/date-rules.md`, feed ports and backfill `references/feed.md`, trigger overrides and templates `references/triggers-templates.md`, delivery log and suppression `references/delivery-log.md`.
102
+
103
+ ## Gotchas
104
+
105
+ - `InAppFeedRepository.claimUndelivered` is required by `createInAppBackfill`; it throws at construction without it. `listSince` is optional and only used when `since` is passed.
106
+ - One `unreadCache` instance must be shared by `InAppChannel` and `inAppFeedResource`, else new rows ride the TTL.
107
+ - The channel calls `port.push` once per recipient and event and never publishes on a bus. If your port forwards onto an event bus, give each frame its own correlation id.
108
+ - `recipient.id` is the user and `recipient.metadata.organizationId` the tenant; without both the in-app channel skips (not fails).
109
+ - Fan-out idempotency keys get a per-recipient suffix automatically; set `idempotencyKey` on the trigger, not per recipient.
110
+ - A date source returns OPEN items only; resolved items stop appearing, that is how a repeating reminder ends.
111
+ - Date rules take a civil date string; the host picks "today" in its own zone.
112
+ - `ruleId` defaults to `payload.metadata.ruleId`; pass a resolver when it rides in the event name.
113
+ - `feedListResult` and `listEnvelope: "offset"` are the repo-core `OffsetPaginationResult`; the default `{ data, pagination }` is the port's own page.
114
+ - Realtime is best effort on top of the durable feed; never fail a request on a push error.
115
+
116
+ ## Never
117
+
118
+ - Hand-roll a feed repository fan-out, unread counter, SSE replay, reminder cron per record, or delivery-log model: the ports above exist.
119
+ - Read `node_modules` for the API; the barrel is the whole surface.
120
+ - Deep-import `@classytic/arc-notifications/*`.
121
+ - Store reminder state per record when the ledger gives once-per-occurrence.
122
+
123
+ ## References
124
+
125
+ - `references/date-rules.md`: read when building reminders (sources, schedule, audience, ledger, sweep, preview, catalog, validate).
126
+ - `references/feed.md`: read when wiring the in-app feed, realtime port, backfill, badge cache.
127
+ - `references/triggers-templates.md`: read when adding admin-editable triggers, templates, channels.
128
+ - `references/delivery-log.md`: read when recording or querying deliveries.
129
+ - Spine persistence for these ports: skill `spine-core-notifications`.
@@ -0,0 +1,55 @@
1
+ # Date-relative rules
2
+
3
+ `createDateRuleEngine` evaluates reminders before / on / after a date a host record carries, in ONE periodic sweep. A rule is data; the host owns rule persistence and the admin screen.
4
+
5
+ ## Shapes
6
+
7
+ | Type | Fields |
8
+ |---|---|
9
+ | `DateRule` | `id` (stable, part of the ledger key), `enabled?`, `source`, `dateField`, `schedule`, `audience` (host type, passed to `resolveAudience`), `channels?`, `template?`, `inApp?`, `priority?`, `organizationId?` |
10
+ | `DateRuleSchedule` | `offsetDays` (negative = before the date), `repeatEveryDays?`, `until?: { occurrences?, offsetDays? }` |
11
+ | `DateRuleSource` | `key`, `label?`, `dateFields[{ key, label? }]`, `variables?`, `find({ dateField, from?, to, organizationId? })` returning OPEN items |
12
+ | `DateRuleItem` | `id`, `date` (`YYYY-MM-DD`), `data` (template vars), `subject?` (`{ sourceModel, sourceId }`), `organizationId?` |
13
+ | `DateRuleLedger` | `claim(key, info): Promise<boolean>` (true = first), `release(key)` |
14
+
15
+ ## Engine
16
+
17
+ ```ts
18
+ import { createDateRuleEngine, createMemoryDateRuleLedger } from "@classytic/arc-notifications";
19
+
20
+ const engine = createDateRuleEngine({
21
+ sources: [
22
+ {
23
+ key: "invoice",
24
+ dateFields: [{ key: "dueOn" }],
25
+ variables: ["number"],
26
+ find: ({ dateField, from, to }) => invoices.openWithDateBetween(dateField, from, to),
27
+ },
28
+ ],
29
+ rules: () => ruleRepo.enabledDateRules(),
30
+ resolveAudience: (audience, { item }) => directory.recipients(audience, item),
31
+ service: () => app.notifications!,
32
+ ledger: createMemoryDateRuleLedger(), // tests; production: unique-index ledger
33
+ });
34
+ const result = await engine.sweep("2026-10-22"); // { date, rules, due, fired, duplicates, noRecipients, failed }
35
+ ```
36
+
37
+ | Method | Use |
38
+ |---|---|
39
+ | `sweep(today)` | run daily from a scheduled job; safe to re-run, replicas and restarts do not double send |
40
+ | `preview(rule, today)` | admin "who would be told today"; no claims, no sends |
41
+ | `catalog()` | rule editor: sources, date fields, variables |
42
+ | `validate({ source, dateField, schedule })` | message list on rule save; empty = valid |
43
+
44
+ Options: `ledger` (default in-memory, lost on restart), `graceDays` (how long after a missed day an occurrence still fires), `data(rule, item)` (extra template vars), `onError(err, { ruleId, itemId? })`.
45
+
46
+ ## Ledger
47
+
48
+ Key is `dateRuleOccurrenceKey(ruleId, itemId, on)`. The claim happens BEFORE dispatch and is released on failure, so the next sweep retries. Production ledger = a collection with a unique index on the key (and a TTL on its creation time).
49
+
50
+ ## Rules of thumb
51
+
52
+ - Dates are civil (`YYYY-MM-DD`); compute "today" in the operator's zone before calling `sweep`.
53
+ - Pure helpers for tests and UIs: `dueOccurrence`, `dateRuleWindow`, `validateDateRuleSchedule`, `addCivilDays`, `civilDaysBetween`, `isCivilDateString`.
54
+ - Dispatch reuses `dispatchTrigger`: in-app copy is rendered once, idempotency keys are per recipient, `subject` lands on the delivery log.
55
+ - Return only open items from `find`; never filter "already reminded" in the source, the ledger does that.
@@ -0,0 +1,33 @@
1
+ # Delivery log
2
+
3
+ `createRepositoryDeliveryLog(repository, options)` returns a `RepositoryDeliveryLog`: the kernel `DeliveryLog` (`record`, `query`, `get`) plus `listByRule`. It works over any arc `RepositoryLike` (mongokit, sqlitekit, prismakit).
4
+
5
+ | Option | Default | Use |
6
+ |---|---|---|
7
+ | `defaultLimit` | 100 | cap for `query` and `listByRule` |
8
+ | `clock` | real time | source of `timestamp` |
9
+ | `ruleId(payload)` | `payload.metadata.ruleId` | which authored rule sent it |
10
+ | `extraFields(payload, dispatch)` | none | host-typed columns stored on `DeliveryLogDoc.extraFields` |
11
+
12
+ ```ts
13
+ const log = createRepositoryDeliveryLog<{ attempt: number }>(repo, {
14
+ ruleId: (p) => /^rule\.(\w+)$/.exec(p.event)?.[1],
15
+ extraFields: (_p, d) => ({ attempt: d.sent + d.failed }),
16
+ });
17
+ await log.listByRule(ruleId, { status: "failed", limit: 50 }); // newest first, RuleDeliveryLogEntry[]
18
+ ```
19
+
20
+ `DeliveryLogByRuleQuery`: `limit`, `status`, `after`, `before`.
21
+
22
+ ## Stored document (`DeliveryLogDoc`)
23
+
24
+ `timestamp`, `event`, `recipientId?`, `recipientEmail?`, `channels`, `results` (per-channel `SendResult`, keep `providerMessageId`), `status` (`delivered | partial | failed | skipped`), `duration`, `metadata?`, `organizationId` (`null` = company-wide), `subject?`, `ruleId?`, `extraFields?`.
25
+
26
+ ## Store requirements
27
+
28
+ - Indexes: `(ruleId, timestamp -1)`, `(subject.sourceModel, subject.sourceId, timestamp -1)`, `(organizationId, timestamp -1)`, `results.providerMessageId` (sparse, not unique), and a TTL on `timestamp` for retention.
29
+ - Nothing edits or deletes rows by hand; retention is the TTL.
30
+
31
+ ## Suppression (persistence side)
32
+
33
+ Suppression is a send-path guard, not part of this package: one row per (channel, canonical recipient), checked before the connector resolves credentials, failing OPEN on a store error, with an optional expiry for a temporary suppression. `@spinekit/core/notifications` ships it (`createSuppressionService`); see skill `spine-core-notifications`.
@@ -0,0 +1,55 @@
1
+ # In-app feed, realtime, backfill
2
+
3
+ The host (or `@spinekit/core/notifications`) owns persistence. The package owns the port, channel, resource and helpers.
4
+
5
+ ## Port: `InAppFeedRepository`
6
+
7
+ | Method | Required | Notes |
8
+ |---|---|---|
9
+ | `create(row)` | yes | called by `InAppChannel`; returns `{ id }` |
10
+ | `listForUser(org, user, { page, limit, unreadOnly, type })` | yes | returns `{ data, pagination: { page, limit, total, totalPages } }` |
11
+ | `countUnread(org, user)` | yes | badge |
12
+ | `markRead(org, user, id)` | yes | `null` when missing or not theirs |
13
+ | `markAllRead(org, user)` | yes | modified count |
14
+ | `claimUndelivered(org, user, limit)` | for backfill | ATOMIC fetch-and-mark-delivered |
15
+ | `listSince(org, user, since, limit)` | optional | rows created after `since` (Date), oldest first, no claim |
16
+ | `markDelivered(org, user, id)` | optional | stamp a live-pushed row |
17
+ | `delete(org, user, id)`, `deleteRead(org, user)` | optional | mounts `DELETE /:id`, `DELETE /read` |
18
+
19
+ Delivered is not read: delivered = a realtime client received it (backfill axis), read = the user acted (badge axis).
20
+
21
+ ## Resource
22
+
23
+ ```ts
24
+ inAppFeedResource({
25
+ repository,
26
+ resolveScope,
27
+ permissions,
28
+ port,
29
+ unreadCache, // read-sync to other tabs, cached badge
30
+ listEnvelope: "offset", // GET / answers OffsetPaginationResult
31
+ actions: true, // POST /:id/action markRead, POST /action markAllRead
32
+ prefix: "/notifications",
33
+ maxLimit: 50,
34
+ });
35
+ ```
36
+
37
+ Routes: `GET /`, `GET /unread-count`, `PATCH /:id/read`, `PATCH /read-all`, `DELETE /read`, `DELETE /:id`. `FEED_ACTION_VERBS` is `{ markRead, markAllRead }`.
38
+
39
+ ## Reconnect
40
+
41
+ ```ts
42
+ const backfill = createInAppBackfill({ repository: feed, port, debounceMs: 2000, limit: 100 });
43
+ backfill.onConnect(userId, orgId); // claim what was never delivered
44
+ backfill.onConnect(userId, orgId, { since: lastEventId }); // plus replay the gap via listSince
45
+ ```
46
+
47
+ One bulk event (`notification:backfill`) per connect burst; tabs opening together coalesce to the earliest cursor; a row is never in the event twice. Call `close()` on shutdown.
48
+
49
+ ## Clock
50
+
51
+ `createUnreadCountCache({ clock })` takes a `NotificationClock` (`{ now(): Date }`). Persistence stamps (created / read / delivered) take the same clock in the host repository.
52
+
53
+ ## Transport stays host-owned
54
+
55
+ `RealtimePushPort.push(userId, orgId, event, data)` is the only seam: SSE hub, WebSocket hub or an event bus behind it. The stream endpoint, heartbeats and connection registry are not in this package. If the port publishes onto an event bus, use a distinct correlation id per frame.
@@ -0,0 +1,36 @@
1
+ # Triggers, overrides, templates, channels
2
+
3
+ ## `NotifyTrigger` (runtime shape)
4
+
5
+ `{ event, template?, channels?, recipient? | audience?, inApp?, priority?, data?, subject?, idempotencyKey?, enabled? }`
6
+
7
+ - `recipient(event)` returns `Recipient | Recipient[] | null`; `audience(event)` plus `recipientResolver` on the module resolves a host audience instead.
8
+ - `data(event)` defaults to the payload object. `inApp` renders once per event with `{var}` placeholders.
9
+ - `subject(event)` stamps `{ sourceModel, sourceId }` on every delivery row.
10
+ - Fan-out: one `service.send` per recipient, key `<base>:<recipient id | email | index>`.
11
+
12
+ ## Admin-editable triggers
13
+
14
+ Author with `NotificationTrigger` (`event`, `template { title, message, link?, entityType? }`, `recipients` roles, `audienceType`, `priority`, extract). Convert with `toNotifyTriggers(triggers, { overrides, entityIdVars?, onDecision? })`; with `overrides` each trigger becomes one entry per channel, gated on stored settings.
15
+
16
+ | Need | API |
17
+ |---|---|
18
+ | Per-org override rows with TTL cache | `createCachedTriggerOverrideStore(load, { ttlMs })` (`TriggerOverrideStore.forOrganization`) |
19
+ | Effective settings of a trigger | `resolveEffectiveSettings` |
20
+ | Gate by where the event came from | `evaluateSourceChannel`, `NotificationExtractResult.sourceChannel` |
21
+ | Registry introspection | `getTriggersByEvent`, `getRegisteredEvents`, `getRecipientMatrix` |
22
+ | Channel names for the editor | `createNotificationChannelCatalog(connectorChannels)` (always includes `IN_APP_CHANNEL`) |
23
+
24
+ Spine ships the mongoose model and admin resource for overrides; see skill `spine-core-notifications`.
25
+
26
+ ## Templates
27
+
28
+ `createDbFirstResolver({ store, fallback, cacheTtlMs, escape, onError })`: an active DB row wins, the code template is the safety net. `templatesResource({ repository, permissions, defaults, resolver })` is the drafting surface (CRUD, `/defaults`, `/preview`); pass the same `resolver` so a save invalidates its cache.
29
+
30
+ ## Module and queue
31
+
32
+ ```ts
33
+ notificationsModule({ channels, notifyOn, recipientResolver, deliveryLog, idempotency, queue });
34
+ ```
35
+
36
+ `queue: (f) => notificationsJob({ concurrency: 5 }).toQueueAdapter(f.jobs)` routes sends through arc's jobs plugin for crash-safe bulk sends. Provider channels (email, SMS, WhatsApp) come from `@classytic/notifications` or `@spinekit/integrations/channel`; this package adds only `InAppChannel` and `SseChannel`.