Skip to content

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:

  1. Backend's push_tokens table is effectively orphaned. The mobile app never calls POST /push-tokens, so notifications fired from MatchController and SubquotaService find no devices for most users.
  2. Anonymous users don't fit cleanly anywhere. Drupal supports uid=0 rows, the backend requires user_id UUID NOT NULL.
  3. 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 publish audience: "all" to NATS, not unauthenticated.


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-consumer to deliver push.send / push.broadcast events. 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 = 0NULL
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

  1. 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.
  2. Duplicate fcm_tokens across users. Drupal's SaveFcmTokens already moved tokens to the latest requesting user. Replicate that during migration: keep the latest by entity_id (or delta), drop older.
  3. Backend's push_tokens table. Likely empty per the audit. If it has rows, run a parallel migration with subject_id = 'ticketing_user:' + user_id. Use device_id from push_tokens.device_id directly (it's already a stable client ID in that schema).
  4. 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

  1. Microservice live in production, Firebase credentials configured, firebase driver active. Endpoints reachable from internal network only.
  2. Drupal FirebaseCloudMessagingService::send() rewritten to publish to NATS push.send / push.broadcast instead of calling FCM directly. ECK notification entity, scheduling, editorial UI all preserved unchanged.
  3. Drupal SaveFcmTokens REST resource rewritten as a thin proxy: receives the legacy {device_token, uid} payload and forwards to microservice's POST /devices/_drupal-legacy. Same path, same response shape — pre-existing app versions continue working.
  4. Run migration script (Drupal user__field_fcm_tokens → microservice devices).
  5. Backend cleanup PR: delete PushService, PushTokenController, PushToken, PushLog, kreait/firebase-php, ntfy service. Replace caller sites with thin NotificationPublisher that publishes to NATS. Backend stops sending FCM directly.
  6. New mobile release ships, calls POST /devices directly with proper device_id and subject_id. Both endpoints alive in parallel.
  7. Telemetry: log _drupal-legacy route hit count. Set retirement criteria (e.g. < 100 req/day for 14 days, or 6 months elapsed, whichever comes first).
  8. Retire: Drupal endpoint returns 410 Gone, _drupal-legacy route deleted from microservice, user__field_fcm_tokens field 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:

  1. src/services/network/api.ts:865 — repoint saveFCMToken()
  2. Old: POST {DRUPAL_BASE}/167ef06c.../fcmTokens/save with {device_token, uid}
  3. New: POST {PUSH_BASE}/devices with full device record including device_id, platform, subject_id, app_version, locale

  4. Generate stable device_id at install time

  5. Use expo-application getAndroidId() / getIosIdForVendorAsync()
  6. Persist locally; never regenerate on the same install

  7. Login/logout rebind in src/context/UserContext.tsx

  8. On login (after userDetails resolves): PATCH /devices/{device_id} with new subject_id
  9. On logout: PATCH /devices/{device_id} with subject_id: null

  10. Notification reception — no change. getMessaging().onMessage(), onNotificationOpenedApp(), deep-link parsing in NotificationHandler.tsx all 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

  1. 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.
  2. 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.
  3. unauthenticated audience: defined in the schema but not currently emitted by anyone. Leave reserved.
  4. Backend caller refactor: MatchController and SubquotaService push call sites currently embed title/body strings inline. Consider extracting to a PushTemplate registry parallel to EmailTemplate for 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.