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 purematchResidentsToEvents/intersectKeysfunctions. - Injected via the
AUDIENCE_SELECTORDI token intoNotifyDigestService; 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 }):
- Idempotency: claim a per-
(org, digestDate)run innotification-digest-runs; skip if already succeeded. - Per targeted community where the flag is enabled (Part 8): fetch events in the collection window (projecting
custom_filters_keys, tagged with theircommunityId) + candidates (approved members,enableGeneralNotifications, theirnotify_categories/notify_channels/notify_frequency), then run the selector. - Aggregate per resident across all their communities — one message per resident, events grouped by community (not one message per community).
- 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 onupdatedAt, so doc count is bounded and inactive residents auto-expire. The unbounded part is thesentEventIdsarray on an active resident's doc (its TTL never fires while digests keep refreshingupdatedAt). Prune it to a rolling window — an event whosecreated_atpredates the collection window can never re-match, so its id is dead weight and safe to drop.notification-digest-runsis already bounded (TTL 30d on a fixedcreatedAt) — 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 existingenable_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
AudienceSelectorstrategy 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.ts—saveNotificationsCampaign/enqueueCampaignBatches;receiver=MEMBERaudience; 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 onorganizations-statsreport-schedule-dispatcher.service.ts.apps/user-communities/src/schemas/members.schema.ts— membership (communityId,enableGeneralNotifications, approved).apps/user/src/entities/user.entity.ts—enable_general_notifications(consent) +notify_categories/notify_channels/notify_frequency(selector source; do NOT use the misalignedpreferred_categories).apps/groups/src/groups/groups.entity.ts—custom_filters_keys(event's notify-categories keys).- Feature flag
notify-mevia the@bewith-dev/feature-flagspackage (FLAGS.NOTIFY_ME) — see Part 8. libs/enums/notifications/notifications-campaigns-type.enum.ts—EVENT_DIGEST;libs/enums/notifications/notification-reciever.enum.ts—MEMBER.