Design Doc β Notification Service v1 (event-publish daily digest)
Status: Draft Β· Author: @relbns Β· ClickUp: CU-86c1p963g (task CU-86cafrhcy) Β· Aviad-approved 2026-07-02
This doc is self-contained: a fresh session (or engineer) should be able to implement v1 from it without re-researching the codebase. All current-state facts, file-level integration points, and locked decisions are inline. This is Track B β a visible prod feature that ships in parallel to, and decoupled from, the big category-consolidation effort (Phase 0, task CU-86cahy31f). The two meet only at the audience-selector (Β§4).
1. TL;DRβ
When a new event is created, residents who are interested should hear about it. v1 does this as a once-a-day digest: a scheduled job collects the day's new events per community, matches them to each resident's existing interests, and sends one message per resident listing the relevant new events, over the existing Email/SMS/WhatsApp channels. It is gated by the platform feature-flags system (live in prod; scope org/community/member, super-admin audience for a safe internal pilot) and scheduled via platform-jobs (daily 09:00, org timezone, + manual run), and reuses almost the entire existing notification pipeline. Consent is already given at registration (users.enable_general_notifications), so v1 needs no new consent screen. Matching is on keys from the new notify-categories taxonomy (see the Feature Spec): the resident's users.notify_categories β© the event's groups.custom_filters_keys. The audience-selector stays pluggable.
Why a daily digest (not real-time per event): at our scale (IL 1.25M users, communities publishing many events/day) real-time per-event fan-out means many paid SMS/WhatsApp messages + notification fatigue. One digest/day per resident is cheaper, calmer, and simpler. (Aviad-approved.)
2. Context & current state (so you don't have to re-research)β
The parent problem. The platform has categories on events and interests on residents, but they are not connected to notifications. Two facts you must know:
- Categories are community-defined references β but writes aren't enforced. Each community defines its category pool in
community_tags(SQL, per-community:category,name,order) β this is the source of truth. Events reference it:groups.custom_filters(string[]) holds values selected from that pool;groups.custom_filter_groups({header: string[]}) pairs a free-text header with an array of values from the pool. Residents' interests live inusers.preferred_categories(string[], global to the user at org level, not per-community). The intent is select-from-pool, but there is no enforcement: the DTOs are@IsObject()only (no enum/FK), and the CSV-import and integration-provider write paths insert values and lazily UPSERT them into the pool if missing β so the pool is grown from writes, not gated. That is what produces cross-community fragmentation: prod (read-only, 2026-06-30) shows IL 256K events / 1.25M users / 5,003 distinctcustom_filtersvalues β the same concept spelled several ways across communities (e.g. three spellings of one age-group); USA 136K / 72K / 302 values. A separate curated Mongo taxonomy (system-categories) exists but feeds only the AI-search path and is not connected tocommunity_tags. Fixing this β movingcommunity_tags/tag_groupsto Mongo canonical keys, switching refs to keys, and enforcing writes β is Phase 0's job, not v1's. - v1 matches on a dedicated
notify_categoriescolumn, NOTpreferred_categories. Gali (2026-07-05) confirmedusers.preferred_categoriesis not aligned with the communities' category pools β it's a separate, org-global, unmanaged vocabulary. So v1 does not use it. Instead v1 reads a new dedicatedusers.notify_categoriescolumn (keys the resident chose) and intersects it with the event'sgroups.custom_filters_keys. Both hold keys from the newnotify-categoriestaxonomy (see the Feature Spec) β greenfield categories for the POC, or Phase 0's mapping if Aviad approves it. Matching is faithful (keyed, no wrong sends); coverage depends on residents having setnotify_categoriesand on events being tagged with keys. v1 ships behind the flag, measured.
Consent vs topic-choice. Consent to receive messages already exists β users.enable_general_notifications (SQL, apps/user/src/entities/user.entity.ts, default on); v1 relies on it, no new consent screen. Which topics a resident wants is the new users.notify_categories column (Β§2.2). Empty notify_categories β no notifications (opt-in; decided). Populating it = a resident preference surface; for the POC, a minimal picker + PUT /users/self/notify-categories, behind the FF (the full screen is a later phase). See the Feature Spec.
No push yet. Channels today are Email (SES), SMS (Vonage/Nexmo), WhatsApp (Meta). No FCM/APNs/Expo. Push is a later addition.
Feature flags today = a global build-time constant (libs/constants/feature-flags.const.ts, e.g. USE_NEW_ROLES_AND_PERMISSIONS). There is no per-org/community/user scoping. v1 introduces a scoped flag (Β§6).
3. Approach & key decisionsβ
| Decision | Why | Alternative (rejected) |
|---|---|---|
| Pluggable audience-selector | Decouples v1 from the category mess: the engine takes "audience-selector + channels"; v1's selector uses existing data. When Phase 0 lands, swap the selector to canonical keys β no engine change. | Wait for Phase 0 β blocks a visible feature for months. |
| Daily digest at 09:00, per community | Scale + paid-channel cost + fatigue: one message/day per resident. Per-community, no org aggregation in v1 β a resident in N communities may get N digests. | Real-time per-event β many paid messages, fatigue, higher load. |
Schedule via platform-jobs | Reuse Tal's cron runner: one global CRON job + a fan-out handler; manual "run now" = a ONCE schedule. Send-time (09:00) configurable per org from support-tool. | One job-schedule entry per org β scales poorly. |
Consent = existing enable_general_notifications | Registration already = consent (Aviad). No new consent screen for v1. | Build a new consent screen now β unneeded for v1. |
| Reuse the existing notification pipeline | campaign already supports an arbitrary audience via receiver=MEMBER; SQS + dispatchers exist. New code is small. | Build a new dispatch path β wasteful. |
Dedicated notification-digest-runs for idempotency | platform-jobs dedupes only at the execution level, not per (community,date)/resident. A runs collection + resident cursor gives real dedup + resumability. | Rely on platform-jobs alone β double-sends residents on retry. |
Gate = platform featureFlags (in prod) | Reuse the existing system: scope org/community/member, super-admin audience for a safe internal prod pilot β everyone. No bespoke flag collection. | The proposed notification-feature-settings β superseded by the platform FF. |
Match notify_categories β© custom_filters_keys (both = notify-categories keys) | preferred_categories is misaligned with the community pools (Gali), so v1 matches keys from the new notify-categories taxonomy β resident keys β© event keys. | Reuse preferred_categories β misaligned, mis-targets. |
4. Architectureβ
v1 is a daily batch, not a per-event reaction. Almost everything downstream of the selector already exists.
- Trigger. v1 runs on
platform-jobs(Tal's cron runner): one global CRON schedule (hourly tick) + a fan-out handler that, per org, checks whether local time crossed the configured send-time (default 09:00, org timezone) and fires that org's per-community digests. Manual "run now" = insert aONCEschedule (nextRunAt=now) via a small support-tool-triggered endpoint. platform-jobs' atomic claim prevents double-run across pods; business-level dedup is thenotification-digest-runscollection (Β§5/Β§10). ("New event" = created since the last digest run for that community, deduped by eventId. RedisRESOURCE_UPDATEDfromapps/groups/src/groups/groups.service.tsis a later real-time hook option; v1 stays daily to control cost/fatigue.) - Selector v1 (recipient resolver). For each community with new events: resolve members from Mongo
memberswhereenableGeneralNotifications = trueand approved, paginated; for each, keep them if theirusers.notify_categoriesintersects the union of the day's events'custom_filters_keysfor that community (both sides arenotify-categorieskeys). (Notpreferred_categoriesβ misaligned per Gali.) - Digest composer. Per matched resident, build one message listing the matched new events (with date/time/location). Cap for SMS (Β§7).
- Dispatch (reuse). Hand
receiversIds(user ids) toNotificationsCampaignsService.saveNotificationsCampaign({ campaignType: EVENT_DIGEST, receiver: MEMBER, ... }); it batches to SQS (100/batch) and the sender dispatches per channel. AddEVENT_DIGESTtoNotificationsCampaignsTypeEnum.
5. Data modelβ
v1 adds one small Mongo collection (notification-digest-runs) + two SQL columns (users.notify_categories, groups.custom_filters_keys) + the EVENT_DIGEST campaign type. It reuses the platform featureFlags for gating and reads the notify-categories taxonomy (defined in the Feature Spec). No other SQL schema change.
Feature flag = the platform featureFlags system (EXISTING, in prod). This supersedes the notification-feature-settings collection this DR originally proposed. Gate v1 with a flag key (e.g. notify.event-digest): apps/authorization/src/feature-flags (collection featureFlags), rules: [{scope: org|community|member, scopeId, enabled}], precedence member > community > org > default, audience: super-admin for an internal prod pilot β everyone to roll out. Resolve per resident via libs/helpers/feature-flags/resolve-feature-flag.helper before dispatch. Managed from the support tool (internal-gateway resolve endpoints).
Send-time (default 09:00, org timezone) is a separate small concern β the FF is boolean gating only: for v1 default it; a per-org configurable send-time can live in a tiny settings doc or the platform-jobs schedule later.
notification-digest-runs (Mongo β NEW). Business-level idempotency (platform-jobs dedupes only at the execution level). One record per (organizationId, communityId, digestDate) with a unique index, plus a resident cursor so a crashed/retried run resumes instead of re-sending: { organizationId, communityId, digestDate, status: PENDING|PROCESSING|SUCCEEDED|PARTIAL|FAILED, cursor?: lastResidentId, sentCount, processedAt?, error? }. Add a TTL to bound growth. Reuse the atomic-claim mechanism from scheduled-event-notifications via a shared helper (reuse without coupling).
users.notify_categories (SQL β NEW column). The resident's chosen topic keys, string[] (JSON), nullable. The selector's source (not preferred_categories, misaligned per Gali). Keys come from the notify-categories taxonomy. Empty β no notifications (opt-in). Populated via a resident picker (POC: minimal, behind FF). Additive/nullable β no breaking change.
groups.custom_filters_keys (+ custom_filter_groups_keys) (SQL β NEW columns). The event's category keys, string[] (JSON), alongside the legacy custom_filters free strings (non-destructive β feeds/search/stats/PHP keep reading the strings). Populated on event create/edit (POC: tag a few events by hand; later: admin/AI). This is the event side of the match.
Deferred (NOT v1): a richer notification_preferences (per-category on/off + per-channel) β a later enhancement. v1 uses enable_general_notifications (consent) + the new notify_categories (topics).
6. Feature flag & rolloutβ
- Gate = the platform
featureFlagssystem (EXISTING, in prod). Flag keynotify.event-digest, scope org / community / member (precedence member>community>org>default),audience: super-adminfor a safe internal pilot in prod βeveryoneto roll out. Resolve per resident before dispatch. (This replaces thenotification-feature-settingscollection originally proposed here.) - Rollout: start
audience: super-admin(internal pilot) β enable per pilot community via a rule β widen toeveryone. Reversible. - Managed from the support tool (internal-gateway). Send-time is separate (Β§5).
7. Digest composition & SMS sizeβ
- One message per resident listing the day's matched new events: title + date/time/location, plus a "see all" link.
- SMS cap = 3 events. Hebrew is Unicode (70 chars single / 67 per concatenated segment); 3 events β ~180 chars β ~3 segments. Cap long titles (~30 chars). Email/WhatsApp carry the full list (no cap).
- "See all" link = the existing community page URL with interest-filter query params β already supported, so no new page/endpoint; pass the resident's matched categories as URL params. Short-linked via the existing
createShortUrl(). - Compose per channel (SMS = capped/short; Email/WhatsApp = richer). Note: no SMS length handling exists in our code today (Vonage sends
type:'unicode'and the provider segments + bills) β the cap is ours to enforce.
8. Consumer / admin surfaceβ
- Resident: for the POC, a minimal picker (
/profile/notificationsin the consumer app, behind the FF) that writesusers.notify_categoriesviaPUT /users/self/notify-categories; the list =notify-categories. The full preference screen (types Γ 3 levels) = a later phase. See the Feature Spec. - Manual send + category filter (org-dashboard): filters recipients by
notify_categories(decided β consistent with the digest;preferredCategoriesis dropped as misaligned). Add anotifyCategories?field toOrganizationUsersFilterByInterfaceand mirror the existingpreferredCategoriesJSON_OVERLAPSfilter (apps/user/src/queries/organization-users-detailed.query.ts) ontouser.notify_categories. FE: a multi-select ofnotify-categoriesin the send dialog (CreateInstantMessage.tsx) fillingfilters.notifyCategories. Scope v1: multi-select, OR semantics.
9. Recipient resolution (the selector v1) β scaleβ
- Resolve members from Mongo
members(apps/user-communities/src/schemas/members.schema.ts) bycommunityId+enableGeneralNotifications+ approved, paginated (existingmembers-crudpagination; index oncommunityId + enableGeneralNotifications). - For each candidate, intersect their
users.notify_categories(the new dedicated column) with the community's day-events categories. v1 does exact/normalized match; the vocabulary is Phase 0's canonical list, so this is the swappable point β canonical keys. (Notpreferred_categoriesβ Gali confirmed it is misaligned with the community pools.) - Resolve and send per community (one digest run per community; no cross-community aggregation in v1). Batch matched user ids to SQS (100/batch). A large community is fine: paginated resolve + batched send.
10. Failure modes & idempotencyβ
| Scenario | Handling |
|---|---|
| Job re-runs / retries same day | notification-digest-runs unique on (org, community, digestDate) + resident cursor β resume, never re-send. platform-jobs' atomic claim additionally prevents double-run across pods. |
| Event edited after creation | v1 keys off "new that day"; edits don't re-trigger (no per-event trigger in v1) |
| A dispatch fails | reuse existing campaign retry/DLQ; record status |
Resident opted out (enable_general_notifications = false) | excluded at resolution |
| Flag disabled for scope | no send; logged |
| No matched events for a resident | no message (don't send empty digests) |
11. Observabilityβ
- Per run: counts of communities processed, candidates, matched residents, messages queued per channel, failures β unique log messages + context (
organizationId,communityId,date). - Metrics/alerts on the daily job success + dispatch failures; watch cost per channel (SMS segments).
12. Breaking changesβ
None. v1 is additive: a new job + orchestrator + one Mongo collection (notification-digest-runs) + two new nullable SQL columns (users.notify_categories, groups.custom_filters_keys, both alongside existing columns) + a new campaign type + the notify.event-digest feature flag. No change to existing event/notification schemas or reader contracts (legacy custom_filters untouched).
13. Testingβ
- Unit: the selector (interest β© event-tags), digest composition + SMS cap, flag evaluation.
- Integration: daily job β campaign β SQS (mocked dispatch); flag scoping; idempotency (re-run no double-send).
- e2e / local run: bring the stack up locally, seed a community with members + interests + a new event, run the job, assert one digest with the right events on the right channels.
- Data fidelity: run against a prod-replica sample (read-only) to sanity-check match volumes before enabling.
14. Task breakdown (PRs β minimise count)β
| PR | Repo | Scope |
|---|---|---|
| PR-1 | backend-services (+ support-tool) | Define the notify.event-digest feature flag (platform featureFlags) + the two SQL columns (users.notify_categories, groups.custom_filters_keys) + seed the greenfield notify-categories collection + PUT /users/self/notify-categories endpoint. |
| PR-2 | backend-services | platform-jobs global CRON + fan-out handler (09:00 / org tz) + ONCE manual-trigger; notify-digest orchestrator + selector v1 (members Γ consent Γ notify_categories β© custom_filters_keys, per community) + digest composer + SMS cap (3) + notification-digest-runs idempotency + EVENT_DIGEST type; wire to existing campaignβSQSβchannels; tests |
| PR-3 | bewith-consumer-frontend | minimal resident picker (/profile/notifications, behind FF) β notify_categories |
| PR-4 | organization-dashboard + backend-services | notifyCategories recipient filter (mirror the JSON_OVERLAPS query) + a notify-categories multi-select on the manual send (fills filters.notifyCategories) |
Full resident preference screen (types Γ 3 levels), taxonomy admin, and AI auto-tagging are later phases. Category source (greenfield vs Phase 0) is swappable without engine change (Aviad's call).
15. Risks & alternativesβ
| Risk | Mitigation |
|---|---|
| Low coverage/recall (interests β event tags) | it's coverage not accuracy; ship behind flag + measure; Phase 0 (canonical keys + aliases) is the fix |
| SMS cost / fatigue | daily digest + SMS cap (top-N + link) |
| Fan-out at scale | paginated members resolve + SQS batching; per-community |
| Double-send | digest-run idempotency (one per resident per day) |
Alternatives considered: real-time per-event (rejected β cost/fatigue); wait for Phase 0 (rejected β blocks a visible feature); build per-category consent now (deferred β consent already exists).
16. Resolved decisions (updated 2026-07-19)β
| # | Question | Decision |
|---|---|---|
| 1 | SMS cap + "see all" link | Cap 3 events + link = community page URL with interest-filter query params. Β§7 |
| 2 | Audience | Community members only. Per-community digest, no org aggregation. Β§4/Β§9 |
| 3 | Digest timing | 09:00 (org tz), daily + manual run via platform-jobs. Send-time default 09:00 for v1 (configurable later). Β§4 |
| 4 | Manual-send filter | Filters by notify_categories (new notifyCategories filter mirroring the JSON_OVERLAPS query); preferredCategories dropped. Β§8 |
| 5 | Digest idempotency | notification-digest-runs (unique (org,community,date) + resident cursor). Β§5/Β§10 |
| 6 | Selector source | resident notify_categories β© event custom_filters_keys, both = notify-categories keys (not preferred_categories). Β§2/Β§5 |
| 7 | Feature flag | The platform featureFlags system (in prod; scope org/community/member, super-admin pilot) β replaces notification-feature-settings. Β§5/Β§6 |
| 8 | Empty notify_categories | No notifications (opt-in). Β§2 |
| 9 | Category source | Greenfield notify-categories for the POC; Phase 0 mapping if Aviad approves β swappable without engine change. Β§2 |
Still open: rollout cadence (super-admin pilot β per-community β everyone); the full resident preference screen (later phase); category source (greenfield vs Phase 0) = Aviad's call.
17. Definition of Doneβ
- The
notify.event-digestfeature flag gates per org/community/member (super-admin pilot verified on & off). - Daily job resolves the right audience (members Γ consent Γ
notify_categoriesβ©event-tags), idempotent (no double-send). - One digest per resident with the day's matched events; SMS capped correctly; per-channel composition.
- Reuses campaignβSQSβdispatchers;
EVENT_DIGESTtype added. - Manual org-dash send has a working category filter.
- Unit + integration + local-run green; lint/typecheck/build green; no secrets; CU linked.
- Behind the flag; additive (no breaking changes); observability in place.
18. Decoupling from Phase 0 (the selector interface)β
The only coupling point to the category consolidation is the recipient resolver's matching step. Define it behind an interface (e.g. AudienceSelector.resolve(event, communityMembers) -> userIds). v1's implementation matches the resident's notify_categories β© the event's custom_filters_keys, both = keys from the notify-categories taxonomy (Feature Spec). The vocabulary source is swappable β greenfield for the POC, or Phase 0's mapping β with no change to the selector, job, digest, flag, or dispatch. This is how Track B ships now and converges with the category work.
19. Key files (integration points β cite these when building)β
apps/groups/src/groups/groups.service.tsβ event create; emits RedisRESOURCE_UPDATEDonRESOURCES_CHANNEL(real-time hook option; v1 uses the daily job instead).apps/notifications/src/notifications-campaigns/notifications-campaigns.service.tsβsaveNotificationsCampaign/enqueueCampaignBatches;receiver=MEMBERsupports an arbitrary user-id 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 to mirror).apps/platform-jobs/*β Tal's cron runner (the digest scheduler). Key:src/persistence/schemas/job-schedule.schema.ts(jobKey, scheduleType CRON/ONCE, cronExpression, timezone, nextRunAt, payload),src/runner/runner.service.ts+src/runner/job-claim.service.ts(atomic claim),src/jobs/jobs-registry.service.ts(register the digest handler). Model the fan-out onapps/organizations-stats/src/report-schedule/report-schedule-dispatcher.service.ts.apps/user-communities/src/schemas/members.schema.tsβ community membership (communityId,enableGeneralNotifications, approved);members-crudpagination.apps/user/src/entities/user.entity.tsβenable_general_notifications(consent) + the newnotify_categoriescolumn (selector source;preferred_categoriesexists but is misaligned β do NOT use it for the digest).apps/groups/src/groups/groups.entity.tsβcustom_filters,custom_filter_groups(legacy strings) + the newcustom_filters_keyscolumn (event'snotify-categorieskeys).apps/communities/src/community-tags/community-tags.entity.tsβcommunity_tags(per-community category pool = SOT) +tag_groups(grouping headers); the pool thatcustom_filters/preferred_categoriesreference. Writes are not validated against it (lazy UPSERT) β Phase 0 adds keys + enforcement.apps/authorization/src/feature-flags/*β the platform feature-flags system (collectionfeatureFlags); gate v1 with a flag key. Resolve vialibs/helpers/feature-flags/resolve-feature-flag.helper. Managed from support-tool (internal-gatewayfeature-flagscontrollers).- Feature Spec β Notify Me & Categories β the
notify-categoriestaxonomy + governance this DR consumes. libs/enums/notifications/notifications-campaigns-type.enum.tsβ addEVENT_DIGEST.libs/enums/notifications/notification-reciever.enum.tsβMEMBERreceiver type.
Track A (category consolidation / Phase 0, CU-86cahy31f) runs in parallel and meets this DR only at Β§18. Human-facing summary artifact for the DDR lives outside the repo; this doc is the implementable source of truth.