Skip to content

Notifications and FID push — frontend integration guide

STATUS: AUTHORITATIVE FOR THE BACKEND CONTRACT. The inbox and push-registration API below are implemented. The Web integration is the approved client design. The Expo iOS/Android section is a proposal and proof-spike brief: approve its native approach before implementing it.

Last revised: 2026-08-25. Amend this file in place when either contract changes.

This guide has two jobs:

  1. give every frontend the exact notification-center and registration API;
  2. hand the web/Expo teams a safe starting proposal to refine against their own router, session, data-cache, storage, and build setup.

1. Mental model

domain event
  ├─ durable inbox row ── GET /notifications ── notification center
  ├─ email (when configured)
  └─ advisory push ────── FCM ──────────────── toast / OS notification / deep-link
  • The inbox is canonical and durable until its recipient deletes a row. Push and email are advisory delivery channels, not alternate sources of truth.
  • Push registration is per app/browser installation and authenticated SIS identity. It is not a user preference or profile field.
  • A push tap identifies a semantic destination using targetType + data. kind remains available for event-specific branches. The destination must be loaded fresh from the API.
  • A missing, edited, withdrawn, or unauthorized destination is a normal stale notification outcome. Show a harmless message and fall back to the inbox.
  • Push permission denied, unsupported, or temporarily broken must never disable the inbox or email.
  • Marking an inbox row read is cosmetic. It does not justify attendance, alter a disciplinary note, or perform any other domain action.

2. Shared API conventions

All routes below are relative to /api/v1. They are auth-only and self-scoped: the caller can see and mutate only their own notification rows and registrations. There is no notification permission/scope for the frontend to request.

The web app should use the existing authenticated API client with credentials included and its normal refresh-on-401 behavior. The JWT strategy can also read an access token from Authorization: Bearer <token>, but that fact alone is not a complete native login/refresh contract; validate the mobile session design before relying on it.

Successful PUT and DELETE registration responses are 204 No Content. Do not attempt to parse JSON from them.

Errors use the repository envelope:

interface ApiError {
  statusCode: number;
  code: string;
  messages: string[];
  field?: string;
  data?: unknown;
  timestamp: string;
  path: string;
}

Important cases:

  • 401: session missing or expired; use the shared auth flow.
  • 400 VALIDATION_FAILED: invalid query or registration body field.
  • 400 BAD_REQUEST: malformed notification UUID.
  • 404 NOTIFICATION_NOT_FOUND: notification missing or owned by somebody else. Do not distinguish those cases in the UI.
  • 403 CSRF_ORIGIN_REJECTED: in production, unsafe methods are protected by a global origin check. This matters for the Expo proposal in §9.3.

3. Inbox API

Method Path Success
GET /notifications 200 Paginated<InboxNotification>
GET /notifications/unread-count 200 { count: number }
PATCH /notifications/:id/read 200 InboxNotification
POST /notifications/read-all 200 { updated: number }
DELETE /notifications/:id 204 No Content

3.1 Types

type AttendancePayload = {
  eventId: string;
  studentId: string;
  studentName: string;
  date: string; // YYYY-MM-DD school calendar date
  eventType: 'ABSENT' | 'LATE_ENTRY';
};

type DisciplinePayload = {
  noteId: string;
  studentId: string;
  studentName: string;
  date: string; // YYYY-MM-DD school calendar date
  changeType: 'CREATED' | 'UPDATED';
};

type TimetablePublishedPayload = {
  timetableId: string;
  academicYearId: string;
  timetableName: string;
  effectiveFrom: string; // YYYY-MM-DD
  revisionNumber: number;
  changeType: 'PUBLISHED' | 'REPUBLISHED';
  departmentIds: string[]; // sorted, recipient-relevant
  teacherId?: string; // present for teacher involvement
  studentIds: string[]; // sorted linked children relevant to this recipient
};

type TeacherCourseAssignedPayload = {
  academicYearId: string;
  teacherId: string;
  effectiveFrom: string; // YYYY-MM-DD
  assignmentCount: number;
  // All singular fields are present only when assignmentCount === 1.
  subjectGroupId?: string;
  subjectGroupName?: string;
  curriculumSubjectId?: string;
  subjectName?: string;
  gradeId?: string;
  gradeName?: string;
  departmentId?: string;
};

type RosterChangedPayload = {
  academicYearId: string;
  departmentId: string;
  cohortType: 'HOMEROOM' | 'SUBJECT_GROUP';
  cohortId: string;
  cohortName: string;
  gradeId: string;
  curriculumSubjectId?: string; // subject-group changes only
  changeType: 'ADDED' | 'REMOVED';
  effectiveFrom: string; // YYYY-MM-DD
  studentCount: number;
  // Both singular fields are present only when studentCount === 1.
  studentId?: string;
  studentName?: string;
};

type CurriculumSelectionReminderPayload = {
  academicYearId: string;
  referentId: string;
  studentIds: string[];
  departmentIds: string[];
  selectionWindowIds: string[];
  milestoneDate: string; // YYYY-MM-DD
  deadline: string; // YYYY-MM-DD
  reason:
    | 'WINDOW_OPENED'
    | 'TWO_WEEKS_BEFORE_DEADLINE'
    | 'ONE_WEEK_BEFORE_DEADLINE'
    | 'DEADLINE_DAY';
};

type ProfileCompletionReminderPayload = {
  academicYearId: string;
  referentId: string;
  referentHasActionableMissingInformation: boolean;
  studentIdsWithActionableMissingInformation: string[];
  departmentIds: string[];
  milestoneDate: string; // YYYY-MM-DD
  schoolStartDate?: string; // YYYY-MM-DD; absent for OVERDUE
  deadline: string; // YYYY-MM-DD
  reason:
    | 'TWO_WEEKS_BEFORE_SCHOOL'
    | 'ONE_WEEK_BEFORE_SCHOOL'
    | 'SCHOOL_STARTED'
    | 'OVERDUE';
};

type NotificationKind =
  | 'attendance.event'
  | 'attendance.reminder'
  | 'discipline.note'
  | 'timetable.published'
  | 'subject_group.teacher_assigned'
  | 'roster.changed'
  | 'curriculum.selection_reminder'
  | 'profile.completion_reminder';

type NotificationTargetType =
  | 'attendance.justification'
  | 'discipline.note'
  | 'timetable'
  | 'teacher.courses'
  | 'cohort.roster'
  | 'curriculum.selection'
  | 'profile.completeness';

type NotificationTargetByKind = {
  'attendance.event': 'attendance.justification';
  'attendance.reminder': 'attendance.justification';
  'discipline.note': 'discipline.note';
  'timetable.published': 'timetable';
  'subject_group.teacher_assigned': 'teacher.courses';
  'roster.changed': 'cohort.roster';
  'curriculum.selection_reminder': 'curriculum.selection';
  'profile.completion_reminder': 'profile.completeness';
};

type InboxNotification<
  K extends NotificationKind,
  P,
> = {
  id: string; // UUID
  kind: K;
  targetType: NotificationTargetByKind[K];
  payload: P;
  createdAt: string; // ISO date-time
  readAt: string | null; // ISO date-time
};

type KnownInboxNotification =
  | InboxNotification<'attendance.event', AttendancePayload>
  | InboxNotification<'attendance.reminder', AttendancePayload>
  | InboxNotification<'discipline.note', DisciplinePayload>
  | InboxNotification<'timetable.published', TimetablePublishedPayload>
  | InboxNotification<
      'subject_group.teacher_assigned',
      TeacherCourseAssignedPayload
    >
  | InboxNotification<'roster.changed', RosterChangedPayload>
  | InboxNotification<
      'curriculum.selection_reminder',
      CurriculumSelectionReminderPayload
    >
  | InboxNotification<
      'profile.completion_reminder',
      ProfileCompletionReminderPayload
    >;

type Paginated<T> = {
  data: T[];
  meta: {
    total: number;
    page: number; // 1-based
    limit: number;
    totalPages: number;
  };
};

Swagger publishes named NotificationKind and NotificationTargetType enums. Treat server data as untrusted at runtime: validate kind, targetType, their approved pairing, and the kind-specific payload before resolving a destination. A future backend can add either discriminator before an older deployed client knows it, so retain an unknown/mismatched fallback even if generated TypeScript types currently look exhaustive.

Every payload date (date, effectiveFrom, milestoneDate, deadline, and schoolStartDate) is a calendar date, not an instant. Do not parse one as UTC midnight with new Date(value); that can render the previous day in western time zones. Format it with the app's date-only utility. createdAt and readAt are real timestamps.

3.2 List the caller inbox

GET /api/v1/notifications?page=1&limit=20&unread=true

Query parameters:

Name Default Contract
page 1 Integer, minimum 1
limit 20 Integer, 1..100
unread omitted Literal, case-sensitive true or false

The response is Paginated<KnownInboxNotification> after runtime validation, ordered newest first. Omit unread for all rows; unread=false does not mean “read only,” it means the same unfiltered list. A missing or empty value is treated as omitted. Any non-empty value other than the exact strings true and false is rejected with 400.

Example:

{
  "data": [
    {
      "id": "63842f74-1aa6-4ad1-ab64-02341e021fb4",
      "kind": "attendance.event",
      "targetType": "attendance.justification",
      "payload": {
        "eventId": "a53ed02d-f708-4a5d-bcd8-d74c48401927",
        "studentId": "35283080-ef6b-4511-83b1-a0efe9535af0",
        "studentName": "Maria Rossi",
        "date": "2026-09-10",
        "eventType": "ABSENT"
      },
      "createdAt": "2026-09-10T08:42:15.000Z",
      "readAt": null
    }
  ],
  "meta": {
    "total": 1,
    "page": 1,
    "limit": 20,
    "totalPages": 1
  }
}

3.3 Read the badge count

GET /api/v1/notifications/unread-count
{ "count": 3 }

Recommended synchronization:

  • fetch on authenticated app startup;
  • poll every 30–60 seconds while the app is visible;
  • refetch when the app/window returns to the foreground;
  • refetch after a foreground push;
  • update or invalidate it after mark-one/read-all/delete mutations.

There is no WebSocket or SSE notification stream in this version.

3.4 Mark one row read

PATCH /api/v1/notifications/63842f74-1aa6-4ad1-ab64-02341e021fb4/read

The response is the updated KnownInboxNotification. The operation is idempotent: repeating it preserves the original readAt. Optimistic UI is reasonable if the list row and badge are rolled back or invalidated on failure.

3.5 Mark the whole inbox read

POST /api/v1/notifications/read-all
{ "updated": 3 }

updated counts rows that changed from unread to read. Invalidate all inbox queries and set/refetch the unread count after success.

3.6 Delete one row

DELETE /api/v1/notifications/63842f74-1aa6-4ad1-ab64-02341e021fb4

Every authenticated role may permanently remove its own inbox row. Success is 204 with no response body. A missing row and another user's row both return 404 NOTIFICATION_NOT_FOUND; do not distinguish them in the UI. Remove the row from every cached inbox query and update or refetch the unread count after success.

4. Rendering and navigation

Keep two exhaustive frontend registries with different responsibilities:

  • a presentation registry keyed by kind for localized inbox copy, icon, and tone;
  • a destination registry keyed by targetType for application navigation.
type NotificationPresentation = {
  title: string;
  body: string;
};

function presentNotification(
  notification: KnownInboxNotification,
  locale: string,
): NotificationPresentation;

type DestinationResolver = (
  notification: KnownInboxNotification,
) => NotificationDestination | null;

const destinationByTargetType = {
  'attendance.justification': resolveAttendanceDestination,
  'discipline.note': resolveDisciplineDestination,
  timetable: resolveTimetableDestination,
  'teacher.courses': resolveTeacherCoursesDestination,
  'cohort.roster': resolveCohortRosterDestination,
  'curriculum.selection': resolveCurriculumSelectionDestination,
  'profile.completeness': resolveProfileCompletenessDestination,
} satisfies Record<NotificationTargetType, DestinationResolver>;

The backend prescribes target families, not actual destinations. A resolver may use kind, payload cardinality, active role/view, and current feature availability. The complete mapping and stable load context are:

Kind Target type Meaning Branch Stable load context
attendance.event attendance.justification Absence/late entry recorded eventType eventId, studentId, date
attendance.reminder attendance.justification Recorded event still needs justification eventType eventId, studentId, date
discipline.note discipline.note Note created or updated changeType noteId, studentId, date
timetable.published timetable First publication or republication changeType timetableId, academicYearId, effectiveFrom, recipient-relevant teacherId?, studentIds[], departmentIds[]
subject_group.teacher_assigned teacher.courses Teacher assigned to one or more new courses assignmentCount Always teacherId, academicYearId, effectiveFrom; for one course also subjectGroupId, curriculumSubjectId, gradeId, departmentId
roster.changed cohort.roster Student(s) entered or left a class/course cohortType + changeType + studentCount cohortId, academicYearId, departmentId, gradeId, curriculumSubjectId?, effectiveFrom; for one student also studentId
curriculum.selection_reminder curriculum.selection An actionable curriculum-choice milestone reason referentId, academicYearId, studentIds[], departmentIds[], selectionWindowIds[], milestoneDate, deadline
profile.completion_reminder profile.completeness An actionable information-completion milestone reason referentId, academicYearId, actionable-profile ids/flag, departmentIds[], milestoneDate, schoolStartDate?, deadline

For assignment batches, { teacherId, academicYearId } identifies the reloadable course list. For roster batches, { cohortType, cohortId } identifies the reloadable roster. Commands can contain up to hundreds of leaf rows, so bulk payloads deliberately use the aggregate identity plus count instead of an unbounded ID array that could exceed FCM's data limit. Singular events retain every leaf ID needed to load that exact record.

Requirements:

  • Make payload.date prominent because notifications may refer to a past school day.
  • For future-dated domain changes, render effectiveFrom; for automatic reminders, use milestoneDate and the supplied deadline/start context.
  • Never expect disciplinary note text in the payload. Fetch the live detail after navigation.
  • Never expect missing profile field names in a completion reminder. The authenticated destination must load the current actionable state.
  • Route selection remains a frontend decision. Until a target has a resolver, render the notification normally and let a click open the notification center rather than guessing a route.
  • Unknown/malformed kinds, unknown targets, and invalid kind-target pairings render a generic “New notification” row and open the notification center. They must not crash the list.
  • Author route construction centrally; do not scatter URL strings across the service worker, push listener, and inbox components.
  • On an inbox-row click, mark that row read and navigate. On a push click, only navigate/refetch: push data does not contain the inbox row ID, so it cannot mark the corresponding row directly.

5. Push envelope delivered by the backend

The backend sends an FCM notification plus a string-only data block:

type PushEnvelope = {
  notification?: {
    title?: string;
    body?: string;
  };
  data?: Record<string, string>;
};

Examples of data:

{
  "kind": "attendance.event",
  "targetType": "attendance.justification",
  "eventId": "a53ed02d-f708-4a5d-bcd8-d74c48401927",
  "studentId": "35283080-ef6b-4511-83b1-a0efe9535af0",
  "studentName": "Maria Rossi",
  "date": "2026-09-10",
  "eventType": "ABSENT"
}
{
  "kind": "discipline.note",
  "targetType": "discipline.note",
  "noteId": "9c79a0df-ed65-451f-a6bd-e20831960eb9",
  "studentId": "35283080-ef6b-4511-83b1-a0efe9535af0",
  "studentName": "Maria Rossi",
  "date": "2026-09-10",
  "changeType": "UPDATED"
}
{
  "kind": "timetable.published",
  "targetType": "timetable",
  "timetableId": "4f490f8d-e989-43b5-b2cf-da2b9da61bbc",
  "academicYearId": "94b9ab9e-c740-4225-8726-994dc5a1f459",
  "timetableName": "Orario generale",
  "effectiveFrom": "2026-09-10",
  "revisionNumber": "2",
  "changeType": "REPUBLISHED",
  "departmentIds": "[\"47f2012d-f8e0-43f8-a8bd-14ad4e46078e\"]",
  "studentIds": "[\"35283080-ef6b-4511-83b1-a0efe9535af0\"]"
}

FCM data values are always strings. Scalar numbers and booleans use their ordinary string form ("2", "true"); array fields are JSON-encoded strings and must be parsed according to the kind schema. The same arrays remain real JSON arrays in inbox payloads. An optional field that is absent from the inbox payload is also absent from FCM data.

kind and targetType are engine-owned application keys and are never payload fields. New pushes always carry both. A notification already delivered before this contract may lack targetType; do not guess a route for it. Use the existing kind parser only to render safe copy, then open the notification center.

The backend owns push title/body in the school's primary language; the frontend owns inbox rendering in the current UI locale. Do not treat push copy as durable content.

6. Push-registration API

The backend accepts a Firebase Installation ID registered with FCM, not an FCM registration token, APNs device token, or Expo push token.

Method Path Body Success
PUT /notifications/push-registrations { installationId, platform } 204
DELETE /notifications/push-registrations { installationId } 204, idempotent

Register or refresh

PUT /api/v1/notifications/push-registrations
Content-Type: application/json

{
  "installationId": "firebase-installation-id",
  "platform": "WEB"
}

platform is exactly WEB, ANDROID, or IOS. installationId must be a non-empty string of at most 255 characters. Success is 204.

The operation creates, refreshes, or reassigns that FID to the authenticated caller inside the current tenant. Call it whenever the platform's registered callback fires, even when the FID appears unchanged; this refreshes backend lastSeenAt.

Unregister

DELETE /api/v1/notifications/push-registrations
Content-Type: application/json

{ "installationId": "firebase-installation-id" }

Success is always 204. The operation is idempotent and removes only a row owned by the authenticated caller. Never put an FID in a path, query string, analytics event, error report, or log.

7. Shared client lifecycle

Build one platform-independent orchestrator and thin web/native adapters. The orchestrator owns the backend calls, owner binding, serialization, retries, and identity checks. The adapter owns Firebase and OS APIs.

A useful seam to refine in the frontend design is:

type PushPlatform = 'WEB' | 'ANDROID' | 'IOS';

type PushAdapterHandlers = {
  onRegistered: (fid: string) => void;
  onUnregistered: (fid: string) => void;
  onForegroundMessage: (message: unknown) => void;
  onNotificationResponse: (message: unknown) => void;
};

interface PushInstallationAdapter {
  readonly platform: PushPlatform;
  isSupported(): Promise<boolean>;
  requestPermission(): Promise<'granted' | 'denied' | 'dismissed'>;
  subscribe(handlers: PushAdapterHandlers): () => void;
  register(): Promise<void>; // FID arrives asynchronously via onRegistered
  unregister(): Promise<void>;
  deleteInstallation(): Promise<void>;
}

The exact interface is not prescribed. These invariants are:

  • Persist only minimal local binding metadata, versioned for migrations: { version, tenantId, userId, installationId, platform, rotationRequired? }.
  • Serialize lifecycle work. FID callbacks, session changes, React remounts, and logout must not race backend PUT/DELETE calls.
  • Before every backend request, re-check that the captured auth generation is still current. A late callback from a previous login must not bind an old FID to the next user.
  • Never upload the same local Firebase installation across an SIS {tenantId,userId} change. Rotate it first.
  • A raw value from a Firebase Installations getId() call is not sufficient: the Messaging SDK must register the installation with FCM and report it via the registered callback before this backend can target it.
  • Registration failures are non-blocking for the inbox and session. Retry with bounded exponential backoff while the same identity is still active.
  • Do not log FIDs or person-bearing push payloads.

Same identity / normal startup

  1. Attach registered, unregistered, foreground-message, and response/tap listeners exactly once.
  2. Confirm platform support and already-granted permission.
  3. Call the platform register operation.
  4. When onRegistered(fid) fires, DELETE a different previously bound FID best-effort, then PUT the current FID.
  5. Persist the binding only after the PUT succeeds.

Identity change

If the stored owner differs from the current {tenantId,userId}, or local ownership cannot be proven:

  1. unregister the app instance from FCM;
  2. delete the Firebase Installation;
  3. clear the old binding only after local rotation succeeds;
  4. register a new installation for the current identity;
  5. wait for its registered callback, then PUT it to the backend.

Do not rely on backend reassignment as the identity-switch mechanism. Rotation prevents a shared browser/device installation from silently following a user or tenant switch.

Logout

Run cleanup while the old SIS session is still valid:

  1. backend DELETE the bound FID;
  2. unregister the app instance from FCM;
  3. delete the Firebase Installation;
  4. clear local binding metadata;
  5. perform SIS auth logout.

Unregister callbacks can duplicate step 1; DELETE is intentionally idempotent. Do not trap the user in a logged-in session if provider cleanup is offline. Instead, retain a rotationRequired marker and ensure local unregister/delete finishes on the next start before any new identity is registered. The frontend design must decide how it communicates/retries this privacy-sensitive degraded state.

8. Web implementation proposal — approved direction

Prerequisites:

  • Firebase JS SDK 12.14 or newer;
  • HTTPS, except the browser's localhost development exception;
  • the project's Firebase web configuration;
  • the Web Push VAPID public key;
  • a registered messaging service worker.

This is FID-only. Do not call Messaging getToken() or deleteToken(), and do not send registration tokens to this backend.

For now initialize Firebase Messaging only. Other Firebase product SDKs may internally interact with the deprecated token path; re-verify the current SDK behavior before adding another Firebase product to this app.

Skeleton, to adapt to the frontend's modules and state model:

import { initializeApp } from 'firebase/app';
import { deleteInstallations, getInstallations } from 'firebase/installations';
import {
  getMessaging,
  isSupported,
  onMessage,
  onRegistered,
  onUnregistered,
  register,
  unregister,
} from 'firebase/messaging';

const app = initializeApp(firebaseConfig);

if (await isSupported()) {
  const messaging = getMessaging(app);

  const stopRegistered = onRegistered(messaging, (fid) => {
    void lifecycle.enqueueRegistered(fid);
  });
  const stopUnregistered = onUnregistered(messaging, (fid) => {
    void lifecycle.enqueueUnregistered(fid);
  });
  const stopMessage = onMessage(messaging, (message) => {
    lifecycle.handleForegroundMessage(message);
  });

  await register(messaging, {
    vapidKey: firebaseWebPushPublicKey,
    serviceWorkerRegistration,
  });

  // Logout/identity rotation, after backend DELETE:
  await unregister(messaging);
  await deleteInstallations(getInstallations(app));

  // Call the three stop functions when the singleton is truly disposed.
}

register() resolves without returning the FID; the FID arrives asynchronously through onRegistered. Attach listeners before calling it. Firebase can call onRegistered after manual registration, FID rotation, or a browser pushsubscriptionchange, so the callback must remain active for the session.

Implementation notes:

  • Initialize this integration as an app/session singleton. React Strict Mode or route remounts must not create duplicate listeners and competing lifecycle operations.
  • Request notification permission only after a clear user gesture and value explanation. Do not prompt automatically on the login screen.
  • Foreground onMessage: show an in-app banner/toast and invalidate the unread count/inbox as appropriate.
  • Background/closed: let the service worker handle focus/open/navigation. Make sure custom service-worker display logic does not duplicate an automatically displayed FCM notification.
  • Pass only a validated internal destination to clients.openWindow; never turn arbitrary push data into an external URL.
  • Unknown kind/target, an invalid pairing or payload, or a stale destination opens the notification center.

Official references: Web FCM setup and FID registration, Messaging JS API, receiving messages on Web, and Firebase Installations JS API.

9. Expo iOS/Android — proposal to brainstorm

9.1 Compatibility decision

Do not implement against the names alone; the identifiers are different:

Client value/API What it returns Compatible with this backend?
Notifications.getExpoPushTokenAsync() Expo Push Service token No
Notifications.getDevicePushTokenAsync() Native FCM token or APNs token No
Firebase Messaging FID registered callback FID registered as an FCM target Yes
Firebase Installations getId() alone Raw Firebase Installation ID Not yet — Messaging must register it with FCM

Never put an Expo/APNs/FCM registration token into installationId. It may fit the DTO but the backend sends through Firebase Admin's FID target field, so delivery will fail and the identifier semantics would be dishonest.

Use an Expo development build, with a small local Expo native module (or a verified library exposing the same APIs) as the FID adapter:

shared notification lifecycle orchestrator
  ├─ web adapter: Firebase JS Messaging
  └─ Expo adapter
       ├─ TypeScript event/method facade
       ├─ Android: FirebaseMessaging FID lifecycle bridge
       └─ iOS: Firebase Messaging FID lifecycle bridge

Expo Go is not the target runtime for this integration. A native module and remote notification testing require a development build. A third-party package is acceptable only after verifying that its installed versions expose the new FID register/registered/unregistered lifecycle on both platforms; a token-only API is not equivalent.

Proposed Android bridge responsibilities:

  • include the Firebase Android app configuration;
  • enable FID messaging with firebase_messaging_installation_id_enabled=true in AndroidManifest.xml;
  • extend/integrate FirebaseMessagingService and forward onRegistered(installationId) plus unregistration to JavaScript;
  • expose manual FirebaseMessaging.getInstance().register() if auto-init is disabled until consent;
  • expose FCM unregister plus Firebase Installation deletion for identity rotation/logout;
  • forward foreground receipt and notification responses without conflicting with the service registered by expo-notifications.

Proposed iOS bridge responsibilities:

  • include the Firebase Apple app configuration and configure APNs credentials, Push Notifications capability, and remote-notification background mode;
  • set FirebaseMessagingInstallationIdEnabled to YES;
  • forward MessagingDelegate.messaging(_:didReceiveRegistration:) and messaging(_:didUnregister:) to JavaScript;
  • expose Messaging.register(), Messaging.unregister(), and Firebase Installation deletion;
  • preserve any existing notification-center/delegate forwarding used by Expo.

expo-notifications may still be useful for permission UX, foreground receipt, and tap listeners, but only if the spike proves it composes cleanly with the Firebase Messaging delegate/service on both platforms. Do not assume both SDKs can own the native callback surface without explicit configuration.

Official references: Expo direct FCM/APNs tokens, Expo push FAQ, Expo development builds, Expo local native modules, Android FCM FID setup, and Apple Firebase Messaging API.

9.3 Native API/security dependency

This backend globally checks Origin/Referer on production POST, PUT, PATCH, and DELETE requests. Native HTTP clients normally do not participate in browser origin semantics, so an Expo request can receive 403 CSRF_ORIGIN_REJECTED even with a valid bearer token.

Do not make the app impersonate the web origin. Before native implementation, agree a backend auth/CSRF design that can recognize a genuinely bearer-authenticated native request and safely bypass or replace the browser-only origin defense. Also verify mobile login, refresh, secure token storage, and logout. This is a small backend/security design iteration, not part of the current notification endpoint contract.

9.4 Alternative: change the backend transport contract

If the mobile team deliberately chooses Expo Push Service tokens or native FCM/APNs registration tokens, stop and design a backend iteration. It would need an explicit identifier/provider model, sender adapters, provider-specific error and prune behavior, migration, API/Swagger changes, and privacy tests.

Do not overload the existing FID column or endpoint semantics to avoid that work. The current backend is correctly FID-only.

10. Expo proof spike acceptance criteria

Complete this spike before committing to the native adapter:

  • One Expo development build installs on a physical Android device and one on a physical iPhone.
  • Each platform reports the FID registered by Firebase Messaging, not an Expo/APNs/FCM registration token.
  • PUT with ANDROID/IOS succeeds through the agreed production-like auth and CSRF path.
  • A backend FID-targeted notification arrives foreground, background, and from a terminated app; no duplicate OS notification appears.
  • A tap opens the intended internal destination; unknown/stale data falls back safely to the notification center.
  • FID rotation invokes the callback and refreshes the backend binding.
  • Logout deletes the backend registration before auth disappears, unregisters from FCM, and deletes the local Firebase Installation.
  • Logging/error capture contains neither FID nor person-bearing payload data.
  • expo-notifications and the Firebase delegate/service coexist without lost or duplicated callbacks.
  • The result records exact Expo SDK, Firebase iOS/Android SDK, build-plugin, and library versions. Re-run this compatibility check when upgrading them.

11. Cross-platform test matrix

At minimum cover:

Scenario Expected result
Push unsupported Inbox works; no registration attempt
Permission default Explain value; prompt only after user action
Permission denied/dismissed Inbox works; no prompt loop
Same user restarts app Register callback PUT refreshes same binding
FID rotates Old binding DELETE best-effort; new FID PUT
Same-device user switch Old Firebase installation destroyed before new registration
Tenant switch Same rotation rule; no cross-tenant delivery
Logout online Backend DELETE → FCM unregister → installation delete → auth logout
Logout offline/provider failure Logout allowed; rotation required before next registration
Foreground push One in-app presentation; inbox/count refetched
Background/terminated push One OS notification; tap routes correctly
Unknown kind/target/bad pairing Generic presentation; notification center fallback
Every known target type Exactly one central destination resolver or explicit inbox fallback
Destination deleted/forbidden Friendly stale message; no data leak
Duplicate delivery/callback Idempotent UI/backend behavior
Mark read/read all races Server result/refetch restores correct badge

Use a real supported browser and physical mobile devices for the release gate; simulator/emulator success alone is not sufficient evidence for push delivery.

12. Rollout order

  1. Technical account creates the Firebase project/apps, Web VAPID key, APNs integration, and platform configuration files. Never commit server-account credentials.
  2. Backend stores the service-account secret and keeps PUSH_TRANSPORT=log.
  3. FE ships the inbox independently; it works before push is enabled.
  4. FE ships the web lifecycle/listeners over HTTPS and runs the browser smoke.
  5. Backend switches a non-production environment to PUSH_TRANSPORT=fcm.
  6. Complete and approve the Expo proof spike plus native auth/CSRF design.
  7. Ship mobile adapters, run the cross-platform matrix, then promote.

Deferred backend work: preferences, per-user channel/locale settings, registration staleness policy, delivery reports, platform-specific FCM message configuration, inbox retention, and multi-account installation aggregation.

13. Handoff prompt for the frontend team

Paste the following into a new coding-agent chat from the frontend repository, then fill in the bracketed context:

We need to design notification inbox and FID-based push registration for this
frontend. The authoritative backend/frontend handoff is:
[paste or link docs/fe-guides/2026-08-04-notifications-FE-guide.md]

Repository/runtime context:
- Targets: [web / Expo iOS / Expo Android]
- Framework and versions: [...]
- Expo SDK and workflow (managed/prebuild/dev build): [...]
- Auth/session and secure storage: [...]
- API client/cache library: [...]
- Router/deep-link setup: [...]
- Existing service worker or notification libraries: [...]
- Firebase/Expo packages and exact versions: [...]

First inspect the repository and installed dependency APIs. Do not implement
yet. Produce a design that:
1. maps the inbox endpoints to queries, cache updates, badge polling, rendering
   by `kind`, destination resolution by `targetType`, validation of their
   pairing, and unknown-contract fallback;
2. proposes a shared lifecycle orchestrator with web and Expo adapters;
3. proves that every uploaded identifier is an FCM-registered Firebase
   Installation ID—not an Expo token, APNs token, or FCM registration token;
4. handles FID callbacks, rotation, identity/tenant changes, logout ordering,
   offline cleanup, late callbacks, and duplicate mounts without races;
5. designs foreground/background/terminated receipt and tap behavior without
   duplicate notifications;
6. audits Expo native-module feasibility and the production native auth/CSRF
   blocker described in the guide;
7. gives a file-by-file plan, dependency/version risks, test matrix, and open
   decisions.

Non-negotiables: inbox remains canonical; push is advisory; presentation is
keyed by `kind`; navigation is keyed centrally by `targetType`; no backend
value is treated as an external URL; unknown/mismatched contracts fall back to
the notification center; never log or place an FID in a URL; discipline
payloads contain no note text; never upload a token to the FID endpoint; rotate
the Firebase installation across SIS identities. Call out contradictions
before choosing a design, and wait for approval before coding.

Backend contract pointers for reviewers:

  • src/notifications/notifications.controller.ts
  • src/notifications/push-registrations.controller.ts
  • src/notifications/dto/notification-response.dto.ts
  • src/notifications/notification.interfaces.ts
  • src/notifications/notification-target.registry.ts
  • src/notifications/notification-push.registry.ts
  • docs/23-notifications.md