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
- Workflow-Based Notifications: Notifications are tied to specific workflow types (e.g.,
reservation) - Action-Specific Payloads: Each workflow action has its own typed payload
- Type Safety: Full TypeScript support from backend to frontend
- 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
Using the Builder (Recommended)
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):
- 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 };
};
- 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;
- Create a builder in
packages/utils/notifications/builders.ts:
export class MaintenanceNotificationBuilder<A extends MaintenanceAction> {
// Similar to ReservationNotificationBuilder
}
- 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
- Type Safety: Catch errors at compile time, not runtime
- Autocomplete: IDE support for all notification fields
- Consistency: Same types used across backend and frontend
- Extensibility: Easy to add new workflow types
- Maintainability: Clear structure and documentation
- 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
toFCMDataPayloadis called before sending - Verify the
contentfield contains the full JSON - Check browser/native console for parsing errors
Type errors in frontend
- Ensure
@adamondo/utilsis 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