Skip to main content

Firebase Cloud Messaging (FCM) Push Notifications

Overview

Tatu uses Firebase Cloud Messaging (FCM) to send native push notifications to mobile devices (iOS and Android). This system works alongside our existing in-app notification system to provide a complete notification experience.

Architecture

Two-Layer System

  1. In-App Notifications (Firestore-based)
    • Stores notification data and history
    • Powers the in-app notification center UI
    • Polled every 5 minutes when app is open
    • Source of truth for notification state
  2. Push Notifications (FCM-based)
    • Wakes up devices when app is closed/backgrounded
    • Shows native OS notifications
    • Triggers app to open and fetch full notification data
    • Delivery mechanism only (not source of truth)

How It Works

Event Occurs (e.g., new booking)

createNotification() called

    ┌────┴────┐
    ↓         ↓
Firestore   FCM Push
Document    Notification
Created     Sent
    ↓         ↓
In-App      Device
Center      Wakes Up
    ↓         ↓
User sees   User taps
in app      notification

        App opens to
        booking detail

Implementation

1. User Model

FCM tokens are stored in the user document:
// types/models/users.ts
export interface BaseUserInfo {
  // ... other fields ...
  fcmTokens?: {
    [deviceId: string]: {
      token: string;
      platform: "ios" | "android" | "web";
      deviceId: string;
      createdAt: number;
      lastUsed: number;
    };
  };
}
Why a map?
  • Users can have multiple devices (iPhone, iPad, Android tablet)
  • Easy to update individual device tokens
  • Simple to remove tokens for specific devices
  • Allows per-device tracking and cleanup

2. Backend Implementation

Creating Notifications

The createNotification function automatically sends FCM push notifications:
// firebase/server/notifications.ts
import { sendPushNotification } from "./fcm";

export async function createNotification<T extends NotificationTypeEnum>(
  { userId, type, data }: { userId: string; type: T; data: NotificationDataMap[T] }
) {
  // 1. Create Firestore notification document
  const notificationId = await createFirestoreNotification(...);

  // 2. Send FCM push notification (non-blocking)
  sendPushNotification(userId, type, data, notificationId).catch(console.error);

  return notificationId;
}
Key Points:
  • FCM sending is non-blocking and won’t fail notification creation
  • Firestore write happens first (source of truth)
  • FCM failures are logged but don’t throw errors

FCM Utilities

sendPushNotification() - Send to one user
await sendPushNotification(
  userId,
  NotificationTypeEnum.NEW_BOOKING_REQUEST,
  { bookingId: "123", clientName: "John Doe" },
  notificationId,
);
sendPushNotificationToMultipleUsers() - Bulk send
await sendPushNotificationToMultipleUsers(
  userIds,
  NotificationTypeEnum.NEW_FEATURE,
  (userId) => ({ featureName: "Dark Mode" }),
  (userId) => notificationIdMap[userId],
);
buildNotificationPayload() - Convert notification to FCM format
const payload = buildNotificationPayload(
  NotificationTypeEnum.BOOKING_CONFIRMED,
  { bookingId: "123", artistName: "Jane Smith" },
);
// Returns: { title: "Booking Confirmed", body: "Jane Smith confirmed your booking", ... }

3. API Endpoints

Register FCM Token

POST /api/notifications/register-fcm-token
Body: {
  token: string,
  deviceId: string,
  platform: "ios" | "android" | "web"
}

Remove FCM Token

POST / api / notifications / remove - fcm - token;
Body: {
  deviceId: string;
}

Update Token Usage

POST / api / notifications / update - fcm - token - usage;
Body: {
  deviceId: string;
}

4. Frontend/Mobile Implementation

React Hooks

import { useRegisterFCMToken } from "@/hooks/notifications/useFCMTokenRegistration";

// Manual registration
const { mutate: registerToken } = useRegisterFCMToken();

registerToken({
  token: "fcm_token_from_device",
  deviceId: "unique_device_id",
  platform: "ios",
});

Auto-Registration Hook

import { useFCMTokenAutoRegistration } from "@/hooks/notifications/useFCMTokenRegistration";

// In your React Native app
function App() {
  const [fcmToken, setFcmToken] = useState(null);
  const [deviceId, setDeviceId] = useState(null);

  // Auto-registers and manages token lifecycle
  const { isRegistered, isRegistering, error } = useFCMTokenAutoRegistration(
    fcmToken,
    deviceId,
    Platform.OS,
  );

  // ... rest of app
}

Mobile App Integration

React Native (Expo)

import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
import { useRegisterFCMToken } from '@/hooks/notifications/useFCMTokenRegistration';

// 1. Request permissions
async function registerForPushNotifications() {
  if (!Device.isDevice) {
    console.log('Must use physical device for push notifications');
    return null;
  }

  // Check/request permissions
  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    console.log('Permission not granted for push notifications');
    return null;
  }

  // Get token
  const token = (await Notifications.getExpoPushTokenAsync()).data;

  // For Android, set notification channel
  if (Platform.OS === 'android') {
    Notifications.setNotificationChannelAsync('default', {
      name: 'default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#FF231F7C',
    });
  }

  return token;
}

// 2. Use in your app
function App() {
  const { mutate: registerToken } = useRegisterFCMToken();

  useEffect(() => {
    registerForPushNotifications().then(token => {
      if (token) {
        registerToken({
          token,
          deviceId: Device.deviceId,
          platform: Platform.OS,
        });
      }
    });
  }, []);

  // 3. Handle notification taps
  useEffect(() => {
    const subscription = Notifications.addNotificationResponseReceivedListener(response => {
      const data = response.notification.request.content.data;

      // Navigate based on notification data
      if (data.screen === 'booking' && data.bookingId) {
        navigation.navigate('BookingDetail', { id: data.bookingId });
      }
    });

    return () => subscription.remove();
  }, []);

  return <YourApp />;
}

iOS Configuration

  1. Enable Push Notifications Capability
    • In Xcode, select your target
    • Go to “Signing & Capabilities”
    • Add “Push Notifications” capability
  2. Configure APNs
    • Create APNs key in Apple Developer Console
    • Upload to Firebase Console (Project Settings → Cloud Messaging → iOS)

Android Configuration

  1. google-services.json
    • Download from Firebase Console
    • Place in android/app/ directory
  2. Notification Channels (Android 8.0+)
    • Already configured in the code above
    • Can customize per notification type if needed

Notification Payload Structure

What’s Sent via FCM

{
  "notification": {
    "title": "New Booking Request",
    "body": "John Doe sent you a booking request"
  },
  "data": {
    "notificationId": "firestore_notification_id",
    "type": "NEW_BOOKING_REQUEST",
    "bookingId": "123",
    "clientName": "John Doe",
    "screen": "booking"
  },
  "android": {
    "priority": "high",
    "notification": {
      "channelId": "default",
      "sound": "default"
    }
  },
  "apns": {
    "payload": {
      "aps": {
        "sound": "default",
        "badge": 1
      }
    }
  }
}

Notification Types

All notification types from NotificationTypeEnum are supported:
  • Booking: NEW_BOOKING_REQUEST, BOOKING_CONFIRMED, BOOKING_CANCELLED, etc.
  • Session: SESSION_CONFIRMED, SESSION_COMPLETED, etc.
  • System: NEW_FEATURE, etc.
Each type has a custom title and body defined in buildNotificationPayload.ts.

Token Management

Token Lifecycle

  1. Registration - When user logs in or app starts
  2. Updates - When token changes (rare but possible)
  3. Removal - When user logs out or revokes permissions
  4. Cleanup - Invalid tokens automatically removed on send failure

Invalid Token Cleanup

When FCM returns errors (expired/invalid tokens), they’re automatically removed:
// In sendPushNotification.ts
if (response.failureCount > 0) {
  const failedTokens = /* ... extract failed tokens ... */;
  await cleanupInvalidTokens(userId, failedTokens);
}

Manual Cleanup

You can also manually remove tokens:
const { mutate: removeToken } = useRemoveFCMToken();

// On logout
function handleLogout() {
  removeToken(deviceId);
  // ... rest of logout logic
}

Testing

Local Testing (Development)

  1. Use Firebase Console
    • Go to Cloud Messaging
    • Use “Send test message”
    • Enter your device token
    • Test different notification types
  2. Use Postman/cURL
    curl -X POST http://localhost:3000/api/notifications/register-fcm-token \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer YOUR_TOKEN" \
      -d '{
        "token": "device_fcm_token",
        "deviceId": "test_device_123",
        "platform": "ios"
      }'
    
  3. Trigger Real Notifications
    • Create a test booking
    • Notification will automatically trigger
    • Check device for push notification

Production Testing

  • Test on physical devices (simulators have limitations)
  • Test with app in foreground, background, and closed
  • Test notification tapping and deep linking
  • Test with multiple devices per user
  • Test token cleanup on logout

Troubleshooting

Notifications Not Received

  1. Check token registration
    // Log token after registration
    console.log("FCM Token registered:", token);
    
  2. Verify token in Firestore
    • Check user document has fcmTokens field
    • Verify token value matches device
  3. Check Firebase Console logs
    • Go to Cloud Functions logs
    • Look for “FCM notification sent” messages
    • Check for errors
  4. Platform-specific issues
    • iOS: Verify APNs certificate is uploaded
    • Android: Check google-services.json is correct
    • Both: Ensure permissions are granted

Tokens Keep Getting Removed

  • Check if tokens are valid (not expired)
  • Verify device has stable internet connection
  • Check Firebase project configuration

Deep Linking Not Working

  • Verify notification data includes correct fields
  • Check navigation logic in notification handler
  • Ensure screens exist in navigation stack

Best Practices

Do’s ✅

  • Always register tokens on app start
  • Remove tokens on logout
  • Handle notification taps for deep linking
  • Test on physical devices
  • Log FCM operations for debugging
  • Use non-blocking FCM sends (current implementation)

Don’ts ❌

  • Don’t block notification creation on FCM failures
  • Don’t store tokens in frontend state only
  • Don’t forget to clean up invalid tokens
  • Don’t send sensitive data in FCM payload (use notification ID instead)
  • Don’t rely on FCM for notification history (use Firestore)

Future Enhancements

  1. Badge Count Management
    • Track unread notification count
    • Update badge number dynamically
    • Sync across devices
  2. Rich Notifications
    • Include images (tattoo photos)
    • Action buttons (Accept/Decline)
    • Custom notification sounds
  3. Notification Preferences
    • Per-notification-type toggles
    • Quiet hours settings
    • Platform-specific preferences
  4. Analytics
    • Track notification delivery rates
    • Measure tap-through rates
    • Monitor token lifecycle

Security Considerations

  • FCM tokens are stored in user documents (backend only)
  • Tokens are NOT exposed in public user profiles
  • Invalid tokens are automatically cleaned up
  • Each device has a unique token
  • Tokens can be revoked by removing from user document

Resources