Push Notification Microservice¶
A dedicated PHP microservice that owns FCM token storage and Firebase Cloud Messaging delivery for the entire HNS Ticketing platform. Replaces the current split where Drupal and the ticketing backend each maintain their own token store and call FCM directly.
Why this exists¶
Today push handling is fragmented:
| Sender | Token store | Sends FCM |
|---|---|---|
Drupal (hns) |
user__field_fcm_tokens (per-user field) |
Yes, directly via kreait/firebase-php |
| Ticketing backend | push_tokens table |
Yes, directly via kreait/firebase-php |
| Mobile app | — | Registers tokens with Drupal only |
Consequences:
- Backend's
push_tokenstable is effectively orphaned. The mobile app never callsPOST /push-tokens, so notifications fired fromMatchControllerandSubquotaServicefind no devices for most users. - Anonymous users don't fit cleanly anywhere. Drupal supports
uid=0rows, the backend requiresuser_id UUID NOT NULL. - Two services share one Firebase project with no shared state — divergence is already happening and will worsen as new senders appear.
The microservice consolidates FCM into a single, device-first store, reachable from any sender via NATS.
High-level architecture¶
┌──────────────────────┐ ┌──────────────────────┐
│ Drupal (hns) │ │ Ticketing backend │
│ Notification entity │ │ MatchController, │
│ + scheduling UI │ │ SubquotaService,... │
└──────────┬───────────┘ └───────────┬──────────┘
│ NATS publish │ NATS publish
│ subject: push.send / │ subject: push.send
│ push.broadcast │
└──────────────┬──────────────┘
▼
┌────────────────┐
│ NATS JetStream│
└────────┬───────┘
│
┌────────┴───────┐
│ nats-consumer │ (Go, in hns-ticketing-eventbus)
│ subscription: │ wraps payload in envelope,
│ push-events │ POSTs with Bearer token
└────────┬───────┘
│ POST /webhooks/push.send
│ POST /webhooks/push.broadcast
│ Authorization: Bearer ...
▼
┌──────────────────────────────────────┐
│ Push Microservice (PHP) │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Inbound HTTP │ │
│ │ Device API (mobile, Drupal): │ │
│ │ POST /devices │ │
│ │ PATCH /devices/{device_id} │ │
│ │ DELETE /devices/{device_id} │ │
│ │ Webhook receivers (eventbus): │ │
│ │ POST /webhooks/push.send │ │
│ │ POST /webhooks/push. │ │
│ │ broadcast │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Postgres │ │
│ │ devices │ │
│ │ push_logs │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Driver: firebase | ntfy │ │
│ └────────┬────────────────┬───────┘ │
└───────────┼────────────────┼─────────┘
▼ ▼
Firebase FCM ntfy (dev)
│
▼
Mobile devices
(Drupal-auth users,
ticketing users,
anonymous)
┌─────────────────────┐
│ Mobile app │
│ - Generates │
│ device_id │
│ - Registers token │──► POST /devices (microservice)
│ - Rebinds subject │ PATCH /devices/{id}
│ on login/logout │
└─────────────────────┘
Stack¶
| Component | Choice | Rationale |
|---|---|---|
| Language | PHP 8.4 | Reuse kreait/firebase-php and basis-company/nats already known to the team |
| Framework | Symfony 8 (or Slim if minimal footprint preferred) | Match operational patterns of backend |
| Storage | PostgreSQL 16 | One service = one database; same migration tooling as backend |
| Cache/queue | None (NATS handles queueing) | — |
| FCM client | kreait/firebase-php ^7.24 |
Battle-tested in current PushService |
| NATS client | basis-company/nats ^1.2 |
Same library used by EmailService |
| Dev viewer | ntfy (relocated from backend) | Local-only; toggled by PUSH_DRIVER=ntfy |
Data model¶
devices table¶
The token store is device-first, not user-first. A subject_id is optional and rebindable.
| Column | Type | Notes |
|---|---|---|
id |
UUID v7 | Primary key |
device_id |
VARCHAR(128) | Stable client-generated ID (Expo install ID); unique |
fcm_token |
VARCHAR(500) | Current FCM registration token; rotates over time |
subject_id |
VARCHAR(128) NULL | Polymorphic subject reference, e.g. drupal_uid:123, ticketing_user:<uuid>. NULL = unauthenticated |
platform |
VARCHAR(16) | ios / android / unknown |
app_version |
VARCHAR(32) NULL | Reported by client at registration |
locale |
VARCHAR(8) NULL | e.g. hr, en |
is_active |
BOOLEAN | Auto-deactivated on FCM UNREGISTERED / 404 |
last_seen_at |
TIMESTAMPTZ NULL | Updated on registration / token refresh |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
Indexes:
| Index | Purpose |
|---|---|
idx_devices_device_id (unique) |
Lookup by stable device ID |
idx_devices_subject_id |
Send-to-subject queries |
idx_devices_is_active |
Broadcast filtering |
idx_devices_fcm_token |
Cross-user token migration / dedup |
push_logs table¶
Mirrors backend's current PushLog schema for audit continuity.
| Column | Type | Notes |
|---|---|---|
id |
UUID v7 | |
device_id |
UUID NULL | FK to devices.id (NULL for broadcasts where many devices were targeted) |
subject_id |
VARCHAR(128) NULL | Same polymorphic format |
audience |
VARCHAR(32) NULL | subject / registered / all for broadcasts |
notification_type |
VARCHAR(100) | e.g. match_cancelled, subquota_delegation |
title |
VARCHAR(255) | |
body |
TEXT | |
data_json |
JSONB NULL | Deep-link payload |
priority |
VARCHAR(16) | normal / high |
fcm_message_id |
VARCHAR(255) NULL | Set by Firebase on success |
status |
VARCHAR(32) | queued / sent / failed |
error_message |
TEXT NULL | |
sent_at |
TIMESTAMPTZ NULL | |
created_at |
TIMESTAMPTZ |
Audience semantics¶
Renamed from Drupal's current taxonomy for clarity. anonymous is renamed to all because the agreed semantics is "every device including subjects", not "only unauthenticated devices".
| Audience | Filter | Use case |
|---|---|---|
subject:{id} |
subject_id = X AND is_active |
Per-user notifications (ticket events, quota changes) |
registered |
subject_id IS NOT NULL AND is_active |
All authenticated devices |
all |
is_active |
Platform-wide broadcasts (match announcements) |
unauthenticated |
subject_id IS NULL AND is_active |
Reserved for future use; not currently invoked |
Drupal adapter mapping: when Drupal publishes a notification with
target_audience='anonymous'(legacy meaning "everyone"), it must publishaudience: "all"to NATS, notunauthenticated.
NATS subject contracts¶
All subjects use JetStream for at-least-once delivery. Producers (Drupal, ticketing backend) publish to NATS; the existing nats-consumer Go process in hns-ticketing-eventbus has a push-events subscription that wraps the payload in the standard envelope and POSTs to the push microservice webhook endpoint with a Bearer token. See Event Bus for the consumer mechanics and Stripe Webhook Router for an analogous destination microservice.
push.send¶
Single-target send. Either subject_id or device_id must be set.
{
"type": "match_cancelled",
"subject_id": "ticketing_user:550e8400-e29b-41d4-a716-446655440000",
"device_id": null,
"title": "Utakmica otkazana",
"body": "Hrvatska – Italija premještena na 2026-06-15.",
"data": {
"match_id": "...",
"deep_link": "match:550e8400-..."
},
"priority": "high",
"correlation_id": "match-cancel-2026-..."
}
Required: type, title, body, exactly one of subject_id / device_id.
Optional: data, priority (default normal), correlation_id.
push.broadcast¶
Multi-device send via audience filter.
{
"type": "promo_announcement",
"audience": "all",
"title": "Pretprodaja kreće sutra",
"body": "...",
"data": { "deep_link": "promo:summer-2026" },
"priority": "normal",
"schedule_at": null,
"correlation_id": "promo-summer-2026"
}
Required: type, audience, title, body.
Optional: data, priority, schedule_at (ISO 8601; if set, microservice queues for later delivery), correlation_id.
push.token.invalidated¶
Emitted by the microservice when FCM reports a token is dead. Other services can subscribe if they care (e.g. backend may want to mark a user as "no longer reachable by push" for support workflows).
{
"device_id": "abc123-install-id",
"subject_id": "drupal_uid:42",
"reason": "UNREGISTERED",
"deactivated_at": "2026-05-01T14:32:11Z"
}
REST API¶
The microservice exposes two surfaces, both Bearer-token authenticated:
- Device API — called directly by clients (mobile app, Drupal proxy during transition). Manages device registration / rebind / deactivation.
- Webhook receivers — called by the eventbus
nats-consumerto deliverpush.send/push.broadcastevents. The Bearer token here is the consumer↔destination shared secret pattern documented in the Stripe Webhook Router doc.
POST /devices¶
Register a new device or update an existing one's fcm_token.
Request:
{
"device_id": "abc123-install-id",
"fcm_token": "fGc...long-fcm-token...",
"platform": "ios",
"subject_id": "drupal_uid:42",
"app_version": "1.4.2",
"locale": "hr"
}
Behavior:
- If device_id exists: update fcm_token, platform, app_version, locale, last_seen_at, set is_active=true.
- If device_id is new: insert.
- If a different device row already holds this fcm_token: deactivate the old row (token rotated to a new install / device wipe).
Response: 200 OK with the persisted device record.
PATCH /devices/{device_id}¶
Rebind subject on login/logout.
Request:
{
"subject_id": "ticketing_user:550e8400-e29b-41d4-a716-446655440000"
}
Or to clear (logout):
{
"subject_id": null
}
Response: 200 OK with updated record. 404 if device not found.
DELETE /devices/{device_id}¶
Soft-deactivate. Sets is_active=false. Response: 204 No Content.
POST /webhooks/push.send¶
Receives events from the eventbus nats-consumer. The body is the eventbus envelope wrapping the original push.send payload (see Event Bus for envelope shape). The microservice unwraps, resolves devices for the target subject/device, and dispatches to the active driver (Firebase or ntfy). Idempotency is enforced via the envelope's message ID — duplicates within retention window are no-op'd.
Response: 200 OK on accepted (delivery is async; immediate FCM errors are logged but the response succeeds so the consumer does not retry indefinitely). 401 on bad Bearer.
POST /webhooks/push.broadcast¶
Same envelope shape as push.send. Resolves the audience filter against the devices table and dispatches in batches of 500 (FCM multicast limit).
POST /devices/_drupal-legacy (transition only)¶
Accepts the old Drupal-shape payload to keep pre-cutover mobile clients working. Removed once telemetry shows no traffic.
Request:
{
"device_token": "fGc...",
"uid": 42
}
Behavior: synthesize device_id = sha256(uid + ':' + device_token), map uid=0 to subject_id=NULL, otherwise subject_id="drupal_uid:" + uid. Then forward internally to POST /devices logic.
Configuration¶
| Env var | Default | Purpose |
|---|---|---|
PUSH_DRIVER |
firebase |
firebase (production) / ntfy (dev) |
FIREBASE_CREDENTIALS_PATH |
— | Path to service account JSON |
NTFY_URL |
http://hns-push-ntfy:80 |
Dev-only viewer |
NATS_URL |
nats://hns-nats:4222 |
NATS connection |
NATS_STREAM |
push |
JetStream name |
DATABASE_URL |
— | Postgres DSN |
DEVICE_API_TOKEN_MOBILE |
— | Bearer token for mobile app POST/PATCH/DELETE /devices |
DEVICE_API_TOKEN_DRUPAL |
— | Bearer token for Drupal proxy calls during transition |
WEBHOOK_TOKEN |
— | Bearer token shared with eventbus nats-consumer for /webhooks/push.* |
The microservice uses the same Firebase service account currently shared by Drupal and the backend. This is a configuration migration, not a Firebase project migration.
Migration: Drupal → microservice¶
Source¶
-- Drupal MariaDB
SELECT entity_id AS uid, field_fcm_tokens_value AS fcm_token, delta
FROM user__field_fcm_tokens;
Target¶
-- Microservice Postgres
INSERT INTO devices (id, device_id, fcm_token, subject_id, platform, is_active, created_at, updated_at)
VALUES (...);
Field mapping¶
| Source | Target | Transform |
|---|---|---|
entity_id |
subject_id |
uid > 0 → 'drupal_uid:' + uid; uid = 0 → NULL |
field_fcm_tokens_value |
fcm_token |
Verbatim |
| — | device_id |
sha256(uid + ':' + fcm_token) (synthesized; idempotent for re-runs) |
| — | platform |
'unknown' (Drupal does not store this) |
| — | is_active |
TRUE |
| — | created_at / updated_at |
Migration timestamp |
Gotchas¶
- Synthesized device IDs are not stable across reinstall. Acceptable trade-off — Drupal never had real device IDs anyway, and the next FCM token refresh from the new mobile app version will replace the synthesized record with a real one.
- Duplicate
fcm_tokens across users. Drupal'sSaveFcmTokensalready moved tokens to the latest requesting user. Replicate that during migration: keep the latest byentity_id(ordelta), drop older. - Backend's
push_tokenstable. Likely empty per the audit. If it has rows, run a parallel migration withsubject_id = 'ticketing_user:' + user_id. Usedevice_idfrompush_tokens.device_iddirectly (it's already a stable client ID in that schema). - Run order: migrate after Drupal switches to NATS publish but before mobile app cuts over to the microservice. Otherwise the migration window has writes hitting two stores.
Cutover plan¶
The mobile app currently registers with Drupal at POST /167ef06c.../fcmTokens/save. Old app versions will remain in the wild after the new version ships. Strategy: Drupal's endpoint becomes a thin proxy to keep them working until traffic dies.
Sequence¶
- Microservice live in production, Firebase credentials configured,
firebasedriver active. Endpoints reachable from internal network only. - Drupal
FirebaseCloudMessagingService::send()rewritten to publish to NATSpush.send/push.broadcastinstead of calling FCM directly. ECK notification entity, scheduling, editorial UI all preserved unchanged. - Drupal
SaveFcmTokensREST resource rewritten as a thin proxy: receives the legacy{device_token, uid}payload and forwards to microservice'sPOST /devices/_drupal-legacy. Same path, same response shape — pre-existing app versions continue working. - Run migration script (Drupal
user__field_fcm_tokens→ microservicedevices). - Backend cleanup PR: delete
PushService,PushTokenController,PushToken,PushLog,kreait/firebase-php,ntfyservice. Replace caller sites with thinNotificationPublisherthat publishes to NATS. Backend stops sending FCM directly. - New mobile release ships, calls
POST /devicesdirectly with properdevice_idandsubject_id. Both endpoints alive in parallel. - Telemetry: log
_drupal-legacyroute hit count. Set retirement criteria (e.g.< 100 req/day for 14 days, or 6 months elapsed, whichever comes first). - Retire: Drupal endpoint returns
410 Gone,_drupal-legacyroute deleted from microservice,user__field_fcm_tokensfield dropped from Drupal.
Timeline estimate¶
| Step | Effort |
|---|---|
| Microservice scaffold (storage + REST + NATS subscriber + Firebase) | ~1 week |
| Drupal NATS publish + proxy rewrite | ~1-2 days |
Backend cleanup + NotificationPublisher |
~2-3 days |
| Migration script + dry-run on prod data | ~1-2 days |
| Mobile changes | ~1-2 days code + iOS store review (~1 week) |
Critical path is mobile store review. Steps 1-4 can ship before mobile is ready; step 6 gates on store approval.
Mobile app changes¶
In hns-mobile-app:
src/services/network/api.ts:865— repointsaveFCMToken()- Old:
POST {DRUPAL_BASE}/167ef06c.../fcmTokens/savewith{device_token, uid} -
New:
POST {PUSH_BASE}/deviceswith full device record includingdevice_id,platform,subject_id,app_version,locale -
Generate stable
device_idat install time - Use
expo-applicationgetAndroidId()/getIosIdForVendorAsync() -
Persist locally; never regenerate on the same install
-
Login/logout rebind in
src/context/UserContext.tsx - On login (after
userDetailsresolves):PATCH /devices/{device_id}with newsubject_id -
On logout:
PATCH /devices/{device_id}withsubject_id: null -
Notification reception — no change.
getMessaging().onMessage(),onNotificationOpenedApp(), deep-link parsing inNotificationHandler.tsxall keep working. Microservice produces FCM payloads in the same shape as Drupal does today.
Observability¶
The microservice ships logs to the existing Loki stack (see grafana-logging.md):
| Channel | Content |
|---|---|
audit |
One entry per send attempt: audit_type=push_delivery, device_id, subject_id, notification_type, status, fcm_message_id |
app |
Errors, NATS connection issues, FCM errors |
access |
HTTP request logs |
A new audit dashboard panel in the HNS — Audit Trail Grafana dashboard:
- Push deliveries by status (sent / failed)
- Push deliveries by notification_type
- Token invalidation rate
Open items¶
- Inbound auth model: shared bearer tokens are pragmatic but rotate-friendly auth (mTLS, signed JWTs from each caller) may be desirable later. Defer until traffic patterns are clear.
- Scheduled broadcasts: Drupal currently uses its own queue worker for future-dated notifications. The microservice needs an equivalent. Options: a) Drupal keeps the schedule and only publishes to NATS at fire time (simplest), b) microservice owns scheduling (cleaner long-term but duplicates Drupal's existing logic). Recommend (a) initially.
unauthenticatedaudience: defined in the schema but not currently emitted by anyone. Leave reserved.- Backend caller refactor:
MatchControllerandSubquotaServicepush call sites currently embed title/body strings inline. Consider extracting to aPushTemplateregistry parallel toEmailTemplatefor consistency. Out of scope for this microservice cutover; track as follow-up.
Eventbus subscription¶
Adding the push microservice as an eventbus destination requires a new subscription file in hns-ticketing-eventbus/consumer/config.d/ (e.g. push-events.yml). It subscribes to push.> and forwards to the microservice's /webhooks/push.send and /webhooks/push.broadcast endpoints, mirroring the mailer-auth-events pattern. See Event Bus for the subscription file format.
Related documents¶
- Event Bus — NATS subject conventions, JetStream config, consumer mechanics
- Stripe Webhook Router — Reference pattern for a downstream microservice consuming via webhook
- Audit Logging Infrastructure — Loki/Grafana stack for observability
- Microservices Strategy — How this service fits the broader microservices roadmap