Skip to main content

Part 4 — Matching & Delivery Engine

The core part of Notify-Me (CU-86c1p963g), and the scope of the first PR. The agnostic mechanism: it consumes tags from Part 2 and preferences from Part 3 via Part 1's keys, and knows nothing about where any of them came from. It is independent of the taxonomy admin — it reads only two string columns.

TL;DR

The engine's entire contract with the outside world is two string[] columns + one injected selector: groups.custom_filters_keys (event tags), users.notify_categories (resident prefs), and an AudienceSelector. v1 selector = tag set-intersection, batched into a daily org-level digest delivered through the platform's existing campaign/channel pipeline. The selector is a swap point: emergency severity-threshold matching, Phase-0 canonical-key matching, or hierarchical (parent-implies-children) matching are alternative strategies plugged into the same seam without touching the orchestrator or delivery.

The pluggable selector — the seam

  • Interface AudienceSelectorInterface.selectRecipients(candidates, events): MatchedResidentInterface[] (libs/interfaces/notifications/audience-selector.interface.ts).
  • v1 strategy TagIntersectionSelector (apps/notifications/src/notify-digest/audience-selector/tag-intersection.selector.ts) wraps the pure matchResidentsToEvents / intersectKeys functions.
  • Injected via the AUDIENCE_SELECTOR DI token into NotifyDigestService; the orchestrator depends only on the interface. Swapping the strategy changes recipients without touching orchestration or delivery.

This is what makes the mechanism agnostic: the taxonomy source, the tagging method, and the onboarding UX are all independent — the engine sees only keys + a strategy.

Orchestrator — org-level

apps/notifications/src/notify-digest/notify-digest.service.ts runDigestNow({ organizationId, communityIds }):

  1. Idempotency: claim a per-(org, digestDate) run in notification-digest-runs; skip if already succeeded.
  2. Per targeted community where the flag is enabled (Part 8): fetch events in the collection window (projecting custom_filters_keys, tagged with their communityId) + candidates (approved members, enableGeneralNotifications, their notify_categories/notify_channels/notify_frequency), then run the selector.
  3. Aggregate per resident across all their communities — one message per resident, events grouped by community (not one message per community).
  4. Dedup + dispatch (below); finalise the run.

Delivery — rides the existing pipeline (not a parallel one)

Both this digest and master's EventNotifications (EVENT_TRIGGER) dispatch through the same saveNotificationsCampaign → SQS → notification-builder → channel providers (SES / SMS / WhatsApp) → resident. Notify-me only adds a campaignType: EVENT_DIGEST and an orchestrator. Nothing about the send path is new.

Convergence (recommended, later): notify-me's interest-matching is the same abstraction as master's EventNotificationRecipientResolver, which today is role-based only. Making interest-matching a recipient strategy gives one audience mechanism across immediate + digest.

Event dedup + the collection window

  • Each event is sent to a given resident once. The per-resident digest-link doc (Part 6) carries sentEventIds; already-sent events are excluded before dispatch, and the newly-sent ids are recorded.
  • The collection window widens the day boundary by a 12h margin so an event created near local midnight is not missed by a UTC day boundary; dedup guarantees the margin never causes a duplicate.
  • Retention (follow-up, CU-86cb0nea3): the link doc is one-per-resident (unique (org, userId)) with a 60-day TTL on updatedAt, so doc count is bounded and inactive residents auto-expire. The unbounded part is the sentEventIds array on an active resident's doc (its TTL never fires while digests keep refreshing updatedAt). Prune it to a rolling window — an event whose created_at predates the collection window can never re-match, so its id is dead weight and safe to drop. notification-digest-runs is already bounded (TTL 30d on a fixed createdAt) — no action there.

Channels & frequency

  • Dispatch is filtered by the resident's notify_channels (default [email, sms]) — a resident who chose email-only receives no SMS.
  • notify_frequency (daily / weekly) gates inclusion in a given run; consent stays the existing enable_general_notifications.

Dispatcher

The digest is org-level, but the platform's send quota (dispatcher package) is per-community. v1 draws the dispatcher from the resident's first matched community. See the open question below.

v1 policies

  • SMS cap = 3 events + a "see all" link (Part 6 — a dedicated Option-B endpoint, not the calendar's label-space filter).
  • Audience = org-level aggregation over the resident's communities (one message/day). This is a deliberate step beyond community-level.
  • Timing = 09:00 org-tz daily + manual trigger, via Tal's platform-jobs (Part 7).
  • Idempotency = notification-digest-runs, keyed (org, digestDate).

Emergency (immediate) — a second selector strategy (deferred)

Gali's spec has two cadences: immediate (emergencies) and digest (everything else). v1 scopes to digest-only (Aviad-approved). The immediate path is a distinct matching mode — severity threshold (resident threshold ≤ event severity), not exact set-intersection — delivered regardless of digest frequency. It slots in as a second AudienceSelectorInterface strategy + an EVENT_TRIGGER-style immediate dispatch, demonstrating the seam's value.

Acceptance criteria

  • A resident tagged for an interest, opted-in, receives exactly one digest message for the run covering their matched events across all their communities; a resident with no matching prefs is excluded.
  • A resident who chose email-only gets an email and no SMS.
  • A second run over the same events (within the margin) produces no duplicate message (dedup).
  • The run is idempotent per (org, date).
  • Swapping the AudienceSelector strategy changes recipients with no change to the orchestrator or delivery.

Open questions

  • Org-level dispatcher/quota: should there be an org-level send quota, or is drawing from the resident's first community acceptable?
  • Template convergence: moving digest copy onto an admin-editable, WABA-capable template (Part 5) vs the bespoke composer.
  • Collection window semantics (calendar day vs rolling) and the exact org-tz handling.

Key files (integration points)

  • apps/notifications/src/notify-digest/ — the engine (service, controller, matching, composer, schemas, audience-selector).
  • apps/notifications/src/notifications-campaigns/notifications-campaigns.service.tssaveNotificationsCampaign / enqueueCampaignBatches; receiver=MEMBER audience; SQS batches of 100.
  • apps/notifications/src/scheduled-event-notifications/scheduled-event-notifications.service.ts — existing event-notification job pattern (recipient resolution + claim/idempotency reference).
  • apps/platform-jobs/* — the cron runner (digest scheduler): job-schedule.schema.ts, runner.service.ts + job-claim.service.ts (atomic claim), jobs-registry.service.ts. Model the fan-out on organizations-stats report-schedule-dispatcher.service.ts.
  • apps/user-communities/src/schemas/members.schema.ts — membership (communityId, enableGeneralNotifications, approved).
  • apps/user/src/entities/user.entity.tsenable_general_notifications (consent) + notify_categories / notify_channels / notify_frequency (selector source; do NOT use the misaligned preferred_categories).
  • apps/groups/src/groups/groups.entity.tscustom_filters_keys (event's notify-categories keys).
  • Feature flag notify-me via the @bewith-dev/feature-flags package (FLAGS.NOTIFY_ME) — see Part 8.
  • libs/enums/notifications/notifications-campaigns-type.enum.tsEVENT_DIGEST; libs/enums/notifications/notification-reciever.enum.tsMEMBER.