Skip to content

Notification API

Badge updates, pings, and read state management.


Overview

On Book Pro's notification system provides:

  • Badge counts in sidebar navigation
  • Real-time pings for @mentions and announcements
  • Read/unread state tracking per user

Data Model

typescript
interface Notification {
    id: string;
    projectId: string;
    recipientId: string;       // User ID of recipient
    type: NotificationType;
    title: string;
    body: string;
    link?: string;             // Deep link to related content
    read: boolean;
    createdAt: Timestamp;
}

type NotificationType = 
    | 'mention'           // @mentioned in a post
    | 'announcement'      // Pinned announcement
    | 'deadline'          // Upcoming deadline reminder
    | 'invitation'        // Project invitation
    | 'schedule_change';  // Rehearsal time changed

Creating Notifications

Server-Side (Cloud Functions)

typescript
// functions/src/notifications.ts
import { onDocumentCreated } from 'firebase-functions/v2/firestore';

// Auto-notify on @mention
export const onMention = onDocumentCreated(
    'projects/{projectId}/posts/{postId}',
    async (event) => {
        const post = event.data?.data();
        if (!post) return;
        
        // Extract @mentions from content
        const mentions = extractMentions(post.content);
        
        for (const userId of mentions) {
            await createNotification({
                projectId: event.params.projectId,
                recipientId: userId,
                type: 'mention',
                title: `${post.authorName} mentioned you`,
                body: truncate(post.content, 100),
                link: `/feed/${event.params.postId}`,
            });
        }
    }
);

const createNotification = async (data: Omit<Notification, 'id' | 'read' | 'createdAt'>) => {
    const notification: Notification = {
        ...data,
        id: crypto.randomUUID(),
        read: false,
        createdAt: Timestamp.now(),
    };
    
    await db.collection('notifications').doc(notification.id).set(notification);
};

Client-Side (Direct Write)

typescript
import { collection, addDoc, serverTimestamp } from 'firebase/firestore';

export const sendNotification = async (
    recipientId: string,
    type: NotificationType,
    title: string,
    body: string,
    link?: string
) => {
    const projectId = useAppStore.getState().meta.projectId;
    
    await addDoc(collection(db, 'notifications'), {
        projectId,
        recipientId,
        type,
        title,
        body,
        link,
        read: false,
        createdAt: serverTimestamp(),
    });
};

Real-Time Badge Updates

Subscribe to Unread Count

typescript
import { collection, query, where, onSnapshot } from 'firebase/firestore';

export const useUnreadCount = () => {
    const [count, setCount] = useState(0);
    const userId = useAppStore((s) => s.auth.user?.uid);
    
    useEffect(() => {
        if (!userId) return;
        
        const q = query(
            collection(db, 'notifications'),
            where('recipientId', '==', userId),
            where('read', '==', false)
        );
        
        const unsubscribe = onSnapshot(q, (snapshot) => {
            setCount(snapshot.size);
        });
        
        return unsubscribe;
    }, [userId]);
    
    return count;
};

Display Badge

tsx
import { NotificationBadge } from '@/components/common';

export const SidebarNotifications = () => {
    const count = useUnreadCount();
    
    return (
        <div className="relative">
            <Icon name="notifications" />
            {count > 0 && <NotificationBadge count={count} />}
        </div>
    );
};

Read State Management

Mark Single as Read

typescript
export const markAsRead = async (notificationId: string) => {
    await updateDoc(doc(db, 'notifications', notificationId), {
        read: true,
    });
};

Mark All as Read

typescript
export const markAllAsRead = async (userId: string) => {
    const q = query(
        collection(db, 'notifications'),
        where('recipientId', '==', userId),
        where('read', '==', false)
    );
    
    const snapshot = await getDocs(q);
    const batch = writeBatch(db);
    
    snapshot.docs.forEach((doc) => {
        batch.update(doc.ref, { read: true });
    });
    
    await batch.commit();
};

Auto-Mark on View

tsx
export const NotificationItem = ({ notification }: Props) => {
    useEffect(() => {
        if (!notification.read) {
            markAsRead(notification.id);
        }
    }, [notification.id, notification.read]);
    
    return (
        <div className={notification.read ? 'opacity-60' : ''}>
            <h4>{notification.title}</h4>
            <p>{notification.body}</p>
        </div>
    );
};

Notification Types

TypeTriggerDefault Behavior
mention@mention in postBadge + list
announcementPinned post createdBadge + toast
deadline24h before deadlineBadge + list
invitationAdded to projectBadge + modal
schedule_changeEvent time modifiedBadge + toast

Store Integration

typescript
// src/store/notification-slice.ts
interface NotificationState {
    notifications: Notification[];
    unreadCount: number;
}

interface NotificationActions {
    setNotifications: (notifications: Notification[]) => void;
    markRead: (id: string) => void;
    markAllRead: () => void;
}

Best Practices

  1. Batch notifications — Don't spam users with multiple similar notifications
  2. Include deep links — Make notifications actionable
  3. Truncate preview text — Keep body under 100 characters
  4. Clean up old notifications — Auto-delete after 30 days

Last updated: January 2026