Skip to main content

Feature Spec β€” Notify Me & Categories

Status: Draft Β· Author: @relbns Β· ClickUp: CU-86c1p963g

Why this doc exists

We already have the Notification Service v1 DR β€” the notifications engine. But schema doubts (flat-vs-nested, labels/translation, aliases) kept surfacing because the feature was not pinned. This spec is the umbrella: it defines the feature scope, phasing, the one canonical category model + its governance, and the surfaces that read/write it β€” so the schema and each phase's Design Doc derive from it instead of being guessed.

1. TL;DR​

Residents pick notification topics from one canonical category taxonomy, and get a daily digest of new events that match. BeWith owns the taxonomy (levels 1–2 are canonical, because we run analytics on them); organizations give aliases (their own display names) and request new categories (BeWith approves); level 3 is dynamic (org/community-defined). Everything runs behind the platform feature-flags system (already in production) so nothing is affected until we turn it on β€” and even then, per org/community/member, or super-admin-only for an internal pilot.

2. The model in one picture β€” one taxonomy, keys everywhere​

There is one new taxonomy store. Everything else holds keys that point at it.

  • notify-categories (one collection) = the definition (keys, labels, hierarchy, scope) plus each doc's embedded overrides (org/community aliases). Faceted: Main types Γ— up to 3 levels. L1+L2 canonical (BeWith-owned, analytics dimensions, closed). L3 dynamic (org/community).
  • users.notify_categories = what the resident picked (keys).
  • groups.custom_filters_keys = what the event is tagged with (keys), alongside the legacy custom_filters free strings (non-destructive).
  • Match = intersection of the two key-sets. Both reference the same canonical keys, so matching is faithful.
  • Aliases are the opposite of duplication: one canonical key, many org/community display names β€” an embedded overrides: [{scope, scopeId, label}] array on the doc, resolved from cache at display time (community > org > canonical). This collapses today's fragmentation (one concept spelled 5 ways β†’ one key + display overrides).

3. Surfaces β€” what reads/writes the taxonomy​

SurfaceRepoReads taxonomy?Writes taxonomy?LanguageAliases?Phase
Resident notify-pickerbewith-consumer-frontendreads active canonical listwrites users.notify_categories (keys)community languagedisplay-only (later)A (minimal) β†’ B (full)
Notify digest enginebackend-servicesreads notify_categories + custom_filters_keysnon/a (keys)noA (DR)
Manual send + filterorganization-dashboardreads canonical list for the pickerno (filters by notify_categories)admindisplayA
Event tagging (create/edit + AI)backend-services / PHPreads canonical listwrites groups.custom_filters_keysn/a (keys)no0 (backfill) β†’ C (AI)
Taxonomy adminorganization-dashboard / support-toolreads full treeBeWith: canonical L1/L2 Β· org: aliases + L3 + requestshe/enyesB
Analytics / insightsorganization-dashboardreads canonical L1/L2 keys as dimensionsnodisplayvia aliasB
Phase 0 mappingbackend-services / migrationreads legacy stringsbuilds categories; backfills custom_filters_keyshe/enno0

The schema below is derived from this table β€” it must serve exactly these reads/writes, nothing more.

4. Phasing​

Pivot (2026-07-19, pending Aviad): Gali asked that the category list be brand-new values, not derived from the existing categories. If approved, this obviates Phase 0's mapping β€” the taxonomy is greenfield, there is nothing to reconcile. Ariel is not committing to dropping Phase 0 until Aviad signs off, so Phase 0 stays below as conditional. Meanwhile we ship a POC of the whole mechanism using Gali's new categories, to show progress.

PhaseClickUpShipsDepends on
POC β€” end-to-end mechanismCU-86cafrhcySeed Gali's new categories in categories + minimal resident picker + tag a few events + run the digest β†’ a resident gets the right notification. Minimal UI, all behind FF, fully deletable.FF (in prod)
Phase 0 β€” category consolidation (CONDITIONAL β€” pending Aviad)CU-86cahy31fMap today's community_tags/custom_filters/preferred_categories into the canonical list + backfill groups.custom_filters_keys. Obviated if greenfield is approved.Aviad decision
Phase A β€” notify engine (Track B)CU-86cafrhcyProductionise the POC: digest engine + users.notify_categories + minimal resident picker + manual-send filter, behind FFFF (done) + a category list (greenfield or Phase 0)
Phase B β€” taxonomy & preferencesβ€”Full resident preference screen (types Γ— levels) + taxonomy admin (aliases, requests, L3) + org-dash per-user view/filter + profile + insightsPhase A
Phase C β€” AI auto-taggingβ€”Auto-tag events with canonical keys on create/edit (AI + admin override); API to PHPa category list

Key sequencing truth: the engine needs both sides to speak the same keys β€” resident notify_categories and event custom_filters_keys. Greenfield makes this cheap (seed new categories, tag events directly, no legacy mapping); Phase 0 was the expensive path (reconcile ~5,000 legacy values). The engine is identical either way β€” only where the keys come from differs, and that source is swappable without touching the engine. So the POC proves the mechanism now; the greenfield-vs-Phase-0 source decision is Aviad's and does not block it. Worst case, the POC's seeded categories are deleted and replaced with the chosen list.

5. Feature flag β€” use the platform feature-flags system (NOT a bespoke collection)​

The platform now has a full feature-flags system (live in production) β€” this supersedes the notification-feature-settings collection proposed in the DR.

  • Where: apps/authorization/src/feature-flags (collection featureFlags); managed from the support tool via internal-gateway (GET/CREATE/UPSERT/RESOLVE_FEATURE_FLAG, RESOLVE_EFFECTIVE_FEATURE_FLAGS).
  • Shape: { key, defaultEnabled, rules: [{scope, scopeId, enabled}], status, audience }.
    • scope = org | community | member (precedence member > community > org > default) β€” exactly our org/community/user gating.
    • audience = everyone | super-admin. super-admin = super-admins resolve normally, everyone else forced OFF β€” a safe internal pilot in prod. Flip to everyone to roll out.
    • status = active | archived; full audit trail.
  • How the engine uses it: define a flag key (e.g. notify.event-digest); resolve it per resident (resolve-feature-flag.helper with {memberId, communityIds, orgId, isSuperAdmin}) before dispatch. Off by default β†’ no one affected until turned on.
  • Send-time config: the FF is boolean gating only. The digest send-time (default 09:00, org timezone) is a separate small concern β€” for v1 default it; a per-org configurable send-time can live in a tiny settings doc or the platform-jobs schedule later (not on the FF, and no longer on notification-feature-settings).

6. The notify-categories schema β€” one collection (derived from Β§3)​

One collection: notify-categories (Aviad prefers fewer collections; aliases live inside it as embedded overrides, requests as status:pending docs β€” no separate stores). Small, stable reference data (~6 types Γ— ≀20 Γ— ≀20 β‰ˆ a few thousand nodes, rarely written). Levels = level + parentKey (flat nodes). Reading the tree is one find (the small active set) + build parentβ†’child in memory + cache β€” NOT a per-request $graphLookup/aggregation. (Alternative: nested storage β€” one doc per type with children embedded β€” whole tree in one read.) L3 dynamic stays flat, read on-demand per community. The key on events/users is storage-shape-independent.

FieldTypeNotes
keystring (unique)stable join id (what notify_categories / custom_filters_keys hold)
typeenuminterest Β· audience Β· district Β· department Β· emergency Β· general (Gali's Main types)
level1 | 2 | 3not every type uses all (audience/emergency/general = L1 only)
parentKeystring?ref to parent's key (null at L1)
labels{ he, en, … }display only β€” the key is language-independent, so this is deferrable (inline map, or reuse the platform getTranslation)
scopesystem | org | communityL1+L2 = system (canonical) Β· L3 = org/community (dynamic)
organizationId? / communityId?numberfor org/community scope
sourcecanonical | requestedrequested = org-asked, awaits approval
statusactive | pending | rejectedv1 shows active only
order / enabled / catalogVersiondisplay + versioning
overrides?[{ scope, scopeId, label }]embedded org/community aliases (display names); precedence community > org > canonical; sparse; resolved from cache

Aliases (org/community display-name overrides) = embedded in each category doc as overrides: [{ scope, scopeId, label }] β€” one collection, no extra store (Aviad-friendly). Resolved from cache (the taxonomy is cached anyway) β†’ no runtime lookup (Baruch's ask): the only read is the cached tree. Precedence: community > org > canonical β€” a community under an org overrides the org's alias, which overrides BeWith's canonical label. Scale: aliases are rare admin writes and tiny; 16MB is not a real risk (β‰ˆ a few thousand possible per category, β‰ͺ limit). The one theoretical cost β€” many communities writing overrides on a single hot category doc β€” is negligible at rare-write frequency; if a specific category ever suffers, split that one out later (YAGNI). Requests = a status:pending doc in the same collection (no separate store); deferred to Phase B.

7. Data model summary (all new pieces)​

StoreWhereNew?Holds
notify-categoriesMongoNEWthe taxonomy + embedded overrides (aliases) β€” ONE collection
users.notify_categoriesSQL (users)NEW column, nullableresident's chosen topic keys
groups.custom_filters_keys (+ custom_filter_groups_keys)SQL (groups)NEW columnsevent's category keys, alongside legacy custom_filters
(aliases + requests)β€”β€”folded into notify-categories (embedded overrides; requests = status:pending docs). No separate collections.
featureFlagsMongoEXISTING (in production)the gate for everything
notification-digest-runs, EVENT_DIGEST typeMongo / enumNEW (Phase A / DR)digest idempotency + campaign type

8. Governance rules (what the schema encodes)​

  • L1 + L2 = canonical, BeWith-owned (scope:system, source:canonical) β€” the analytics dimensions, a closed list. Orgs do not create them.
  • L3 = dynamic (scope:org/community) β€” orgs/residents define freely.
  • Org wants a new L1/L2 β†’ request (a status:pending doc in notify-categories, no separate collection) β†’ BeWith approves β†’ active.
  • Alias = an org's display name over a canonical key (the key + analytics stay single; only the label differs per org).
  • v1 = only-what-exists: seed the canonical list, allow L3 dynamic, residents pick from active; no org create/alias/request UI yet (that's Phase B).

9. Safety β€” no customer is affected​

  • system-categories is NOT overwritten. It still feeds admin surfaces (org-dashboard quick-insights, the communities-categories admin modal, a public /system/categories/:language, leaders-app autocomplete, assistant-ai). We build the new taxonomy in a new notify-categories collection; Phase 0 reconciles/migrates system-categories in as a deliberate, planned step later.
  • Legacy groups.custom_filters strings are kept (feeds, search, stats, PHP readers unchanged); we only add the keys columns.
  • Everything is behind the FF (default OFF; super-admin audience for the internal pilot).

10. Open decisions​

  1. Labels / translation β€” inline {he,en} vs the platform translation system. Deferrable (key is language-independent).
  2. Send-time config home β€” default 09:00 for v1; per-org configurable later (small settings doc / platform-jobs schedule).
  3. Category source β€” greenfield (Gali's new list) vs Phase 0 mapping β€” Aviad's call. POC proceeds greenfield; swappable without engine changes.

Locked: one taxonomy = notify-categories (ONE collection Β· aliases embedded as overrides[] Β· flat+cache, no runtime lookup) Β· keys on both sides Β· empty notify_categories β†’ no notifications (opt-in) Β· manual send filters by notify_categories Β· FF = the platform feature-flags system Β· don't overwrite system-categories.

11. What this changes in the existing DR​

The Notification Service v1 DR should be updated to:

  • Replace the proposed notification-feature-settings collection (Β§5/Β§6) with the platform feature-flags system (Β§5 here).
  • Add the event-side groups.custom_filters_keys column to Β§5 (it currently mentions keys only conceptually in Β§18).
  • Reference this spec as the umbrella; keep the digest engine, idempotency (notification-digest-runs), SMS cap, per-community, and dispatch reuse as-is.

The notifications engine lives in the Notification Service v1 DR. Phase 0 (CU-86cahy31f) builds the canonical list this spec depends on. This spec is the source of truth for the feature and the category model; the schema derives from Β§3–§8.