Varslingsplattform
Push-notifikasjoner i Impulse AI leveres gjennom en lagdelt plattform som skiller forretningslogikk (producers) fra transportlaget (delivery engine). Denne guiden dekker hele arkitekturen, fra AI-generering til APNS-levering.
Målgruppe: utviklere som skal legge til nye varslingstyper, feilsøke leveringsproblemer, eller forstå kategori-modellen.
Arkitekturoversikt
heading.anchorLabelPRODUCERS PLATFORM LAYER(eier HVA og NÅR) (eier HVORDAN)
┌──────────────┐│ Reminder │ Cron hourly│ (reminder) │ Template-basert└──────┬───────┘ │┌──────┴───────┐ ┌─────────────────────────────────┐│ Notification │──►│ Delivery Engine ││ Bus │ │ ││ │ │ 1. Permission gate ││ enqueue │ │ 2. Silence gate ││ Notification│ │ 3. Category opt-in ││ () │ │ 4. Rate-limit gate │└──────┬───────┘ │ 5. Write notification_messages │ │ │ 6. Send APNS │┌──────┴───────┐ │ 7. Record stats + events ││ Lifecycle │ └─────────────────────────────────┘│ (lifecycle) ││ ASC webhook │└──────────────┘Nøkkelprinsipp: Producers kaller enqueueNotification() med ferdig body. De vet ingenting om APNS, rate-limits, eller DB-skriving. Delivery engine vet ingenting om AI, subscriptions, eller kampanjer.
Kategorier
heading.anchorLabelKategorier er utvidbarhetsaksen. Ny varslingstype = ny kategori i registeret + ny producer. Bus og engine er uberørt.
| Kategori | Formål | Bruker-toggle | AI | Rate-limit |
|---|---|---|---|---|
reminder | Stille nikk — template-basert re-engagement med 4-stegs eskalering | Ja | Nei | 1/uke |
lifecycle | Kontovarsler — abonnement, betaling | Nei (alltid på) | Nei | 5/uke |
campaign | Nyheter og tilbud — manuelt fra Console | Ja | Nei | 1/uke |
Global cap: Maks 4 varsler per uke per bruker, på tvers av alle kategorier (unntatt lifecycle som ikke teller mot cap).
Kategori-registeret
heading.anchorLabelapps/api/src/notifications/categories.tsEnkelt TypeScript-objekt med all konfigurasjon per kategori: navn (i18n), ikon, rate-limits, prioritet, AI-flagg. Brukes av bus, engine, rate-limiter, og iOS-innstillinger.
Notification Bus
heading.anchorLabelapps/api/src/notifications/bus.tsEneste interface mellom producers og plattformen. Én funksjon:
enqueueNotification({ userId, category, body, title?, scheduledFor?, deeplinkUrl?, metadata?, priority?, sourceEntityId?, languageCode?})Bussen validerer kategori + body, beregner delay fra scheduledFor, og legger jobben på BullMQ-køen notification-deliver.
Delivery Engine
heading.anchorLabelapps/api/src/notifications/delivery-engine.tsBullMQ-worker med gate-kjede:
- Permission —
ios_permission_status = 'granted'+apns_tokenfinnes - Silence —
silence_untilikke aktiv - Category opt-in — kategori aktivert i brukerens innstillinger (hoppes over for
lifecycle) - Rate-limit — per-kategori + global ukentlig cap (Redis)
- Write — INSERT i
notification_messagesmed kategori, tittel, deeplink, metadata - Send — APNS push med
categoryIdentifier(trigger iOS action buttons) - Record — oppdater
lastSentAt, logg event, inkrementer stats
APNS 410 (invalid token) → automatisk revokering av permission + token-nullstilling.
Producers
heading.anchorLabelReminder Producer
heading.anchorLabelapps/api/src/notifications/producers/reminder-producer.tsTemplate-basert “stille nikk” re-engagement. Kjører som hourly cron (24x/dag). Ingen AI-generering — rene maler.
4-stegs eskalering basert på dager siden siste aktivitet (impulse eller entry):
| Steg | Terskel | Tone | Innhold |
|---|---|---|---|
| 1 | 3 dager | Varm | 3 roterende varianter (A/B/C) — anerkjenner aktivitet uten å avsløre innhold |
| 2 | 7 dager | Varm | ”Impulse AI is here when you need it” |
| 3 | 14 dager | Respektfull | ”Capture an impulse when you’re ready” |
| 4 | 21 dager | Siste | ”We’re here. One tap away.” |
| — | 28+ dager | Stillhet | Ingen flere varsler |
Uke 1-varianter (roterer basert på daysSinceActivity % 3):
- A: “You captured something important. How did it go?”
- B: “Have you checked in with yourself today?”
- C: “Your impulse is waiting. Take a moment.”
For hver kjøring:
- Finn brukere der lokal tid er i leveringsvinduet (alle dager 08-21)
- Sjekk inaktivitet (ingen impulser/entries siste 3 dager)
- Bestem eskalerings-steg basert på dager siden siste aktivitet
- Velg template fra i18n-katalog via
t()/tVar() enqueueNotification({ category: 'reminder', body, deeplinkUrl })
Dedup: Maks 1 reminder per ISO-uke per bruker.
Tidssone-støtte: Brukerens timezone (IANA, f.eks. Europe/Oslo) lagres i user_notification_preferences. iOS sender den ved hver app-foreground via APNS token sync.
Templates: reminder-templates.ts — engelsk (via i18n-katalog), varm kollegial tone. Ingen “we miss you”, ingen utropstegn, ingen em dash. Se Server-side i18n for språksystemet.
Lifecycle Producer
heading.anchorLabelapps/api/src/notifications/producers/lifecycle-producer.tsEvent-drevet (ingen cron). Hooks inn i eksisterende App Store Server Notifications V2 handlers:
| ASC Event | Push-body |
|---|---|
DID_FAIL_TO_RENEW | ”We couldn’t renew your subscription. Update your payment info.” |
EXPIRED | ”Your subscription has expired. You can renew anytime.” |
GRACE_PERIOD_EXPIRED | ”Your subscription is about to expire. Update payment to avoid interruption.” |
| Revalidation cron (3d før) | “Your {tierName} expires {expiryDate}.” |
Bruk: notifyLifecycleEvent(userId, 'subscription_expired') — én linje i eksisterende webhook-handler.
Rate Limiting
heading.anchorLabelapps/api/src/notifications/category-rate-limiter.tsTo nivåer, begge Redis-baserte:
| Nivå | Redis-key | Cap |
|---|---|---|
| Per-kategori | notif:v2:cat:{userId}:{categoryId}:{weekKey} | Fra CategoryConfig.maxPerWeek |
| Global | notif:v2:global:{userId}:{weekKey} | 7/uke |
lifecycle teller ikke mot global cap (countsTowardGlobalCap: false).
TTL: 8 dager. Graceful degradation: Redis utilgjengelig → fail open.
Action Buttons (iOS)
heading.anchorLabeliosApp/Services/NotificationActionRegistry.swiftStatisk registry med action-definisjoner per kategori. Registreres ved app-launch, respons via generisk deeplink-handler.
| Kategori | Action | Deeplink |
|---|---|---|
reminder | ”Åpne impulsen” | im://home?impulseId={id} |
lifecycle | ”Forny abonnement” | im://profile/subscription |
Ny action = én entry i registry + én lokalisert streng. Null endring i delegate-kode.
Foreground-håndtering
heading.anchorLabelAppDelegate implementerer UNUserNotificationCenterDelegate.willPresent:
lifecycle→ vises som banner + lyd (viktig)reminder→ svelges (bruker er i appen)
Push-format
heading.anchorLabelStandard APNS alert — ingen Service Extension nødvendig:
{ "aps": { "alert": { "title": "Impulsspeil", "body": "For 2 dager siden skrev du '...' — hva kjenner du nå?" }, "category": "mirror" }, "source": "notification", "messageId": "uuid", "deeplinkUrl": "im://home?source=notification"}category-feltet i APNS bestemmer hvilke action buttons som vises.
Database
heading.anchorLabelTabeller
heading.anchorLabel| Tabell | Formål |
|---|---|
user_notification_preferences | Samtykke, kategorier (JSONB), timezone, silence |
notification_messages | Meldingsartifakt — body, tittel, deeplink, batch-ref |
notification_events | Append-only observabilitetslog |
Kategorier i DB
heading.anchorLabeluser_notification_preferences.categories er en JSONB-map:
{ "reminder": { "enabled": true, "lastSentAt": "2026-04-17T09:00:00Z" }, "lifecycle": { "enabled": true, "lastSentAt": null }, "campaign": { "enabled": false, "lastSentAt": null }}Dev-endpoint sikkerhet
heading.anchorLabelDev-endpoints (/dev/ping, /dev/test-consent, /dev/trigger-v2, /dev/reset) er beskyttet av én mekanisme:
DEV_API_TOKEN env var — når den ikke er satt, returnerer endpointene 404 Not Found. Ingen NODE_ENV-sjekk. Tokenet er sikkerhetsgrensen.
| Miljø | DEV_API_TOKEN | Dev-endpoints |
|---|---|---|
| Lokal dev | Satt (Doppler dev) | Tilgjengelig |
| Staging | Satt (Doppler stg) | Tilgjengelig |
| Produksjon | Ikke satt | 404 — usynlige |
Auth-bypass: X-Dev-Token header erstatter JWT-autentisering. Fungerer i alle miljøer der DEV_API_TOKEN er konfigurert. Implementert i apps/api/src/middleware/auth.ts.
Guard: apps/api/src/routes/notifications/dev-guard.ts — rejectIfNotDev(), brukt av alle dev-endpoints.
Test og verifisering
heading.anchorLabelFire dev-endpoints (krever DEV_API_TOKEN i env):
POST /api/v1/notifications/dev/ping
heading.anchorLabelRen APNS transport-test. Sender test-push direkte til brukerens device, utenom bus/engine.
Verifiserer: APNS-credentials, token registrert, iOS permission, Apple aksepterer push.
POST /api/v1/notifications/dev/test-consent
heading.anchorLabelDiagnostikk av brukerens samtykke-tilstand per kategori. Ingen sideeffekter.
Verifiserer: Permission-status, kategori-toggles, rate-limits, timezone, silence.
POST /api/v1/notifications/dev/trigger-v2
heading.anchorLabelTrigger via bus → delivery engine (produksjonsvei).
{ "category": "mirror", "body": "Test-melding", "mode": "deliver" }Moduser: deliver (send nå), generate (AI uten send), both.
Testflyt
heading.anchorLabel# 1. Sjekk APNS-koblingcurl -X POST .../dev/ping -H "X-Dev-Token: $TOKEN"
# 2. Sjekk samtykkecurl -X POST .../dev/test-consent -H "X-Dev-Token: $TOKEN"
# 3. Test leveringcurl -X POST .../dev/trigger-v2 -H "X-Dev-Token: $TOKEN" \ -d '{ "category": "lifecycle", "body": "Test kontovarsling" }'
# 4. Nullstill og gjentacurl -X POST .../dev/reset -H "X-Dev-Token: $TOKEN"iOS-innstillinger
heading.anchorLabelVarslinger├── [Stille uke-kort] (hvis aktiv)├── Kategorier│ ├── 🔔 Påminnelser [toggle]│ ├── 📢 Nyheter [toggle]│ └── 👤 Kontovarsler (alltid aktiv)├── Siste varsler (meldingshistorikk)Filstruktur
heading.anchorLabelapps/api/src/├── i18n/ # Server-side i18n system│ ├── index.ts # t(), tVar(), resolveLocale()│ ├── locales.ts # 16 locale-koder (ServerLocale type)│ └── messages/│ ├── notifications.ts # Push-varsel strenger (EN-only katalog)│ └── emails.ts # Transaksjonell e-post (fremtidig)│├── notifications/│ ├── categories.ts # Kategori-register│ ├── bus.ts # enqueueNotification()│ ├── delivery-engine.ts # BullMQ worker (gates → APNS)│ ├── category-rate-limiter.ts # Per-kategori + global cap│ └── producers/│ ├── reminder-producer.ts # Stille nikk (hourly, template-basert)│ ├── reminder-templates.ts # 4-stegs eskalering via t() katalog│ ├── lifecycle-producer.ts # ASC-events (deterministisk)│ └── lifecycle-templates.ts # Lifecycle via tVar() katalog
apps/api/src/routes/notifications/├── dev-ping.ts # APNS transport-test├── dev-consent.ts # Samtykke-diagnostikk└── dev-trigger-v2.ts # Bus → engine trigger
iosApp/Services/├── NotificationService.swift # APNS-registrering + timezone├── NotificationActionRegistry.swift # Action button-register└── AppDelegate.swift # Foreground + action delegateNotification Privacy Rule
heading.anchorLabelPush-varsler er en offentlig overflate (låseskjerm, varslingssenteret, widgets). Aldri inkluder brukerinnhold i push-tekst:
- Ingen impulse-titler eller beskrivelser
- Ingen session-innhold eller refleksjoner
- Ingen sjekk-inn-svar
- Ingen AI-genererte analyse-sammendrag
- Ingen personlige områdenavn
Varsler skal invitere uten å avsløre. Alle som ser brukerens låseskjerm skal ikke lære noe om deres indre prosess.
Legge til ny varslingstype
heading.anchorLabel- Legg til strenger i
i18n/messages/notifications.ts(EN-only,{placeholder}for variabler) - Legg til kategori i
categories.ts(id, navn, rate-limit, prioritet) - Skriv producer — cron, event-driven, eller manuell trigger
- Kall
enqueueNotification()med kategori + body viat()/tVar() - Legg til action (valgfritt) i
NotificationActionRegistry.swift
Delivery engine, bus, rate-limiter, og iOS settings UI tilpasser seg automatisk. Se Server-side i18n for fullstendig i18n-dokumentasjon.
Designdokument
heading.anchorLabelFullstendig arkitekturbeslutninger og v3-roadmap: docs/design/notification-platform-architecture.md