Skip to main content

FCM Push Notification Type System

Overview

This document describes the revamped FCM push notification system that provides type-safe, workflow-based notifications across the backend and guest-frontend applications.

Architecture

Shared Types (@adamondo/utils)

All notification types are defined in the @adamondo/utils workspace to ensure consistency between backend and frontend:

packages/utils/
├── notifications/
│ ├── types.ts # Core notification types and interfaces
│ └── builders.ts # Type-safe notification builders
└── workflow/
└── reservation.ts # Reservation workflow actions and payloads

Key Concepts

  1. Workflow-Based Notifications: Notifications are tied to specific workflow types (e.g., reservation)
  2. Action-Specific Payloads: Each workflow action has its own typed payload
  3. Type Safety: Full TypeScript support from backend to frontend
  4. FCM Compatibility: Automatic serialization/deserialization for FCM's string-only data format

Type Definitions

Base Notification

interface BaseNotification {
notificationId: string | number;
title: string;
message: string;
timestamp?: number;
}

Workflow Types

enum WorkflowType {
RESERVATION = "reservation",
// Add more workflow types as needed
}

Reservation Notification

type ReservationNotificationPayload<A extends ReservationAction> = {
workflowType: WorkflowType.RESERVATION;
action: A;
screenType: NotificationScreenType;
userType: NotificationUserType;
threadId?: number;
reservationId?: number;
actorId: string;
actorName: string;
actionPayload?: ReservationActionPayloadMap[A];
};

Complete FCM Notification

type FCMNotification<T extends WorkflowNotificationPayload> = BaseNotification &
T;

Usage

Backend: Sending Notifications

import {
buildReservationNotification,
NotificationUserType,
NotificationScreenType,
ReservationAction,
} from "@adamondo/utils";
import { sendPushNotification } from "../pushNotifications/pushNotificationRoutes";

const notification = buildReservationNotification({
action: ReservationAction.INQUIRE,
title: "New Inquiry",
message: "John has sent a new inquiry",
actorId: "user123",
actorName: "John Doe",
userType: NotificationUserType.HOST,
screenType: NotificationScreenType.TRIPS,
threadId: 456,
reservationId: 789,
});

await sendPushNotification(notification, targetUserId);

Using the Fluent API

import {
createReservationNotification,
ReservationAction,
} from "@adamondo/utils";

const notification = createReservationNotification(ReservationAction.CONFIRM)
.setTitle("Booking Confirmed")
.setMessage("Your booking has been confirmed")
.setActor("user123", "John Doe")
.setUserType(NotificationUserType.GUEST)
.setScreenType(NotificationScreenType.TRIPS)
.setThreadId(456)
.setReservationId(789)
.build();

Frontend: Receiving Notifications

In FCM Service

import { FCMNotification, fromFCMDataPayload } from '@adamondo/utils';

onForegroundMessage(listener: (payload: FCMNotification) => void) {
return WebMessaging.onMessage(this.messaging, (payload) => {
if (payload.data) {
const notification = fromFCMDataPayload(payload.data);
if (notification) {
listener(notification);
}
}
});
}

In Application Logic

import { FCMNotification, isReservationNotification } from "@adamondo/utils";

const handleNotification = (notification: FCMNotification) => {
if (isReservationNotification(notification)) {
// TypeScript knows the exact shape of the notification
console.log("Reservation action:", notification.action);
console.log("Thread ID:", notification.threadId);

// Navigate based on notification data
if (notification.threadId) {
router.push(`/inbox/${notification.threadId}`);
}
}
};

Notification Flow

Backend Workflow

Build Typed Notification (buildReservationNotification)

Convert to FCM Data Payload (toFCMDataPayload)

Send via Firebase Admin SDK

FCM Cloud Messaging

Client Receives (Web/Native)

Parse FCM Data (fromFCMDataPayload)

Handle Typed Notification

Adding New Workflow Types

To add a new workflow type (e.g., maintenance):

  1. Define the workflow actions and payloads in packages/utils/workflow/maintenance.ts:
export enum MaintenanceAction {
SCHEDULE = "schedule",
COMPLETE = "complete",
CANCEL = "cancel",
}

export type MaintenanceActionPayloadMap = {
[MaintenanceAction.SCHEDULE]: { date: string; description: string };
[MaintenanceAction.COMPLETE]: { completedBy: string };
[MaintenanceAction.CANCEL]: { reason: string };
};
  1. Add the workflow type to packages/utils/notifications/types.ts:
export enum WorkflowType {
RESERVATION = "reservation",
MAINTENANCE = "maintenance",
}

export type MaintenanceNotificationPayload<A extends MaintenanceAction> = {
workflowType: WorkflowType.MAINTENANCE;
action: A;
// ... other fields
};

export type WorkflowNotificationPayload =
| ReservationNotificationPayload
| MaintenanceNotificationPayload;
  1. Create a builder in packages/utils/notifications/builders.ts:
export class MaintenanceNotificationBuilder<A extends MaintenanceAction> {
// Similar to ReservationNotificationBuilder
}
  1. Use in backend and frontend following the same patterns as reservation notifications.

Migration from Legacy System

Before (Legacy)

// Backend
const notifyContent = {
screenType: "trips",
title: "New Inquiry",
userType: "owner",
message: "John has sent a new inquiry",
threadId: 456,
reservationId: 789,
actorId: "user123",
actorName: "John Doe",
};
sendPushNotification(notifyContent, userId);

// Frontend
const showNotification = (title: string, body: string, data?: any) => {
// Untyped data handling
};

After (New System)

// Backend
const notification = buildReservationNotification({
action: ReservationAction.INQUIRE,
title: "New Inquiry",
message: "John has sent a new inquiry",
actorId: "user123",
actorName: "John Doe",
userType: NotificationUserType.HOST,
screenType: NotificationScreenType.TRIPS,
threadId: 456,
reservationId: 789,
});
sendPushNotification(notification, userId);

// Frontend
const showNotification = (notification: FCMNotification) => {
// Fully typed notification handling
if (isReservationNotification(notification)) {
// TypeScript autocomplete and type checking
}
};

Benefits

  1. Type Safety: Catch errors at compile time, not runtime
  2. Autocomplete: IDE support for all notification fields
  3. Consistency: Same types used across backend and frontend
  4. Extensibility: Easy to add new workflow types
  5. Maintainability: Clear structure and documentation
  6. Backward Compatibility: Legacy notifications still work via fallback parsing

Testing

Backend Test

import {
buildReservationNotification,
ReservationAction,
NotificationUserType,
} from "@adamondo/utils";

test("builds reservation notification correctly", () => {
const notification = buildReservationNotification({
action: ReservationAction.INQUIRE,
title: "Test",
message: "Test message",
actorId: "user1",
actorName: "User One",
userType: NotificationUserType.HOST,
threadId: 123,
});

expect(notification.workflowType).toBe("reservation");
expect(notification.action).toBe("inquiry");
expect(notification.threadId).toBe(123);
});

Frontend Test

import { fromFCMDataPayload, isReservationNotification } from "@adamondo/utils";

test("parses FCM data payload correctly", () => {
const data = {
content: JSON.stringify({
workflowType: "reservation",
action: "inquiry",
title: "Test",
message: "Test message",
// ... other fields
}),
};

const notification = fromFCMDataPayload(data);
expect(notification).not.toBeNull();
expect(isReservationNotification(notification!)).toBe(true);
});

Troubleshooting

Notification not parsed correctly

  • Check that toFCMDataPayload is called before sending
  • Verify the content field contains the full JSON
  • Check browser/native console for parsing errors

Type errors in frontend

  • Ensure @adamondo/utils is built: yarn workspace @adamondo/utils build
  • Check that imports are from @adamondo/utils, not relative paths

Missing notification fields

  • Verify all required fields are set in the builder
  • Check that the builder's build() method is called
  • Ensure FCM data payload size limits aren't exceeded