Feature Spec β Notify Me & Categories
Status: Draft Β· Author: @relbns Β· ClickUp: CU-86c1p963g
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 embeddedoverrides(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 legacycustom_filtersfree 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 embeddedoverrides: [{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β
| Surface | Repo | Reads taxonomy? | Writes taxonomy? | Language | Aliases? | Phase |
|---|---|---|---|---|---|---|
| Resident notify-picker | bewith-consumer-frontend | reads active canonical list | writes users.notify_categories (keys) | community language | display-only (later) | A (minimal) β B (full) |
| Notify digest engine | backend-services | reads notify_categories + custom_filters_keys | no | n/a (keys) | no | A (DR) |
| Manual send + filter | organization-dashboard | reads canonical list for the picker | no (filters by notify_categories) | admin | display | A |
| Event tagging (create/edit + AI) | backend-services / PHP | reads canonical list | writes groups.custom_filters_keys | n/a (keys) | no | 0 (backfill) β C (AI) |
| Taxonomy admin | organization-dashboard / support-tool | reads full tree | BeWith: canonical L1/L2 Β· org: aliases + L3 + requests | he/en | yes | B |
| Analytics / insights | organization-dashboard | reads canonical L1/L2 keys as dimensions | no | display | via alias | B |
| Phase 0 mapping | backend-services / migration | reads legacy strings | builds categories; backfills custom_filters_keys | he/en | no | 0 |
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.
| Phase | ClickUp | Ships | Depends on |
|---|---|---|---|
| POC β end-to-end mechanism | CU-86cafrhcy | Seed 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-86cahy31f | Map 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-86cafrhcy | Productionise the POC: digest engine + users.notify_categories + minimal resident picker + manual-send filter, behind FF | FF (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 + insights | Phase A |
| Phase C β AI auto-tagging | β | Auto-tag events with canonical keys on create/edit (AI + admin override); API to PHP | a 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(collectionfeatureFlags); 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 toeveryoneto roll out. - status =
active | archived; full audit trail.
- scope =
- How the engine uses it: define a flag key (e.g.
notify.event-digest); resolve it per resident (resolve-feature-flag.helperwith{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 onnotification-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.
| Field | Type | Notes |
|---|---|---|
key | string (unique) | stable join id (what notify_categories / custom_filters_keys hold) |
type | enum | interest Β· audience Β· district Β· department Β· emergency Β· general (Gali's Main types) |
level | 1 | 2 | 3 | not every type uses all (audience/emergency/general = L1 only) |
parentKey | string? | 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) |
scope | system | org | community | L1+L2 = system (canonical) Β· L3 = org/community (dynamic) |
organizationId? / communityId? | number | for org/community scope |
source | canonical | requested | requested = org-asked, awaits approval |
status | active | pending | rejected | v1 shows active only |
order / enabled / catalogVersion | display + 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)β
| Store | Where | New? | Holds |
|---|---|---|---|
notify-categories | Mongo | NEW | the taxonomy + embedded overrides (aliases) β ONE collection |
users.notify_categories | SQL (users) | NEW column, nullable | resident's chosen topic keys |
groups.custom_filters_keys (+ custom_filter_groups_keys) | SQL (groups) | NEW columns | event's category keys, alongside legacy custom_filters |
| (aliases + requests) | β | β | folded into notify-categories (embedded overrides; requests = status:pending docs). No separate collections. |
featureFlags | Mongo | EXISTING (in production) | the gate for everything |
notification-digest-runs, EVENT_DIGEST type | Mongo / enum | NEW (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:pendingdoc innotify-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-categoriesis 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 newnotify-categoriescollection; Phase 0 reconciles/migratessystem-categoriesin as a deliberate, planned step later.- Legacy
groups.custom_filtersstrings are kept (feeds, search, stats, PHP readers unchanged); we only add the keys columns. - Everything is behind the FF (default OFF;
super-adminaudience for the internal pilot).
10. Open decisionsβ
- Labels / translation β inline
{he,en}vs the platform translation system. Deferrable (key is language-independent). - Send-time config home β default
09:00for v1; per-org configurable later (small settings doc / platform-jobs schedule). - 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-settingscollection (Β§5/Β§6) with the platform feature-flags system (Β§5 here). - Add the event-side
groups.custom_filters_keyscolumn 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.