Skip to main content

Design Doc β€” Notification Service v1 (event-publish daily digest)

Status: Draft Β· Author: @relbns Β· ClickUp: CU-86c1p963g (task CU-86cafrhcy) Β· Aviad-approved 2026-07-02

Read this first

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:

  1. 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 in users.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 distinct custom_filters values β€” 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 to community_tags. Fixing this β€” moving community_tags/tag_groups to Mongo canonical keys, switching refs to keys, and enforcing writes β€” is Phase 0's job, not v1's.
  2. v1 matches on a dedicated notify_categories column, NOT preferred_categories. Gali (2026-07-05) confirmed users.preferred_categories is 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 dedicated users.notify_categories column (keys the resident chose) and intersects it with the event's groups.custom_filters_keys. Both hold keys from the new notify-categories taxonomy (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 set notify_categories and 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​

DecisionWhyAlternative (rejected)
Pluggable audience-selectorDecouples 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 communityScale + 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-jobsReuse 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_notificationsRegistration already = consent (Aviad). No new consent screen for v1.Build a new consent screen now β€” unneeded for v1.
Reuse the existing notification pipelinecampaign 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 idempotencyplatform-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 a ONCE schedule (nextRunAt=now) via a small support-tool-triggered endpoint. platform-jobs' atomic claim prevents double-run across pods; business-level dedup is the notification-digest-runs collection (Β§5/Β§10). ("New event" = created since the last digest run for that community, deduped by eventId. Redis RESOURCE_UPDATED from apps/groups/src/groups/groups.service.ts is 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 members where enableGeneralNotifications = true and approved, paginated; for each, keep them if their users.notify_categories intersects the union of the day's events' custom_filters_keys for that community (both sides are notify-categories keys). (Not preferred_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) to NotificationsCampaignsService.saveNotificationsCampaign({ campaignType: EVENT_DIGEST, receiver: MEMBER, ... }); it batches to SQS (100/batch) and the sender dispatches per channel. Add EVENT_DIGEST to NotificationsCampaignsTypeEnum.

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 featureFlags system (EXISTING, in prod). Flag key notify.event-digest, scope org / community / member (precedence member>community>org>default), audience: super-admin for a safe internal pilot in prod β†’ everyone to roll out. Resolve per resident before dispatch. (This replaces the notification-feature-settings collection originally proposed here.)
  • Rollout: start audience: super-admin (internal pilot) β†’ enable per pilot community via a rule β†’ widen to everyone. 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/notifications in the consumer app, behind the FF) that writes users.notify_categories via PUT /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; preferredCategories is dropped as misaligned). Add a notifyCategories? field to OrganizationUsersFilterByInterface and mirror the existing preferredCategories JSON_OVERLAPS filter (apps/user/src/queries/organization-users-detailed.query.ts) onto user.notify_categories. FE: a multi-select of notify-categories in the send dialog (CreateInstantMessage.tsx) filling filters.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) by communityId + enableGeneralNotifications + approved, paginated (existing members-crud pagination; index on communityId + 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. (Not preferred_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​

ScenarioHandling
Job re-runs / retries same daynotification-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 creationv1 keys off "new that day"; edits don't re-trigger (no per-event trigger in v1)
A dispatch failsreuse existing campaign retry/DLQ; record status
Resident opted out (enable_general_notifications = false)excluded at resolution
Flag disabled for scopeno send; logged
No matched events for a residentno 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)​

PRRepoScope
PR-1backend-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-2backend-servicesplatform-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-3bewith-consumer-frontendminimal resident picker (/profile/notifications, behind FF) β†’ notify_categories
PR-4organization-dashboard + backend-servicesnotifyCategories 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​

RiskMitigation
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 / fatiguedaily digest + SMS cap (top-N + link)
Fan-out at scalepaginated members resolve + SQS batching; per-community
Double-senddigest-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)​

#QuestionDecision
1SMS cap + "see all" linkCap 3 events + link = community page URL with interest-filter query params. Β§7
2AudienceCommunity members only. Per-community digest, no org aggregation. Β§4/Β§9
3Digest timing09:00 (org tz), daily + manual run via platform-jobs. Send-time default 09:00 for v1 (configurable later). Β§4
4Manual-send filterFilters by notify_categories (new notifyCategories filter mirroring the JSON_OVERLAPS query); preferredCategories dropped. Β§8
5Digest idempotencynotification-digest-runs (unique (org,community,date) + resident cursor). Β§5/Β§10
6Selector sourceresident notify_categories ∩ event custom_filters_keys, both = notify-categories keys (not preferred_categories). §2/§5
7Feature flagThe platform featureFlags system (in prod; scope org/community/member, super-admin pilot) β€” replaces notification-feature-settings. Β§5/Β§6
8Empty notify_categoriesNo notifications (opt-in). Β§2
9Category sourceGreenfield 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-digest feature 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_DIGEST type 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 Redis RESOURCE_UPDATED on RESOURCES_CHANNEL (real-time hook option; v1 uses the daily job instead).
  • apps/notifications/src/notifications-campaigns/notifications-campaigns.service.ts β€” saveNotificationsCampaign / enqueueCampaignBatches; receiver=MEMBER supports 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 on apps/organizations-stats/src/report-schedule/report-schedule-dispatcher.service.ts.
  • apps/user-communities/src/schemas/members.schema.ts β€” community membership (communityId, enableGeneralNotifications, approved); members-crud pagination.
  • apps/user/src/entities/user.entity.ts β€” enable_general_notifications (consent) + the new notify_categories column (selector source; preferred_categories exists 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 new custom_filters_keys column (event's notify-categories keys).
  • apps/communities/src/community-tags/community-tags.entity.ts β€” community_tags (per-community category pool = SOT) + tag_groups (grouping headers); the pool that custom_filters / preferred_categories reference. Writes are not validated against it (lazy UPSERT) β€” Phase 0 adds keys + enforcement.
  • apps/authorization/src/feature-flags/* β€” the platform feature-flags system (collection featureFlags); gate v1 with a flag key. Resolve via libs/helpers/feature-flags/resolve-feature-flag.helper. Managed from support-tool (internal-gateway feature-flags controllers).
  • Feature Spec β€” Notify Me & Categories β€” the notify-categories taxonomy + governance this DR consumes.
  • libs/enums/notifications/notifications-campaigns-type.enum.ts β€” add EVENT_DIGEST.
  • libs/enums/notifications/notification-reciever.enum.ts β€” MEMBER receiver 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.