# Push Notifications - Implementation Plan

> Real-time browser alerts for high-intent leads, callbacks, and new signups

**Created:** Jan 20, 2026
**Updated:** Jan 24, 2026 (Architect investigation)
**Status:** Plan Ready - Detailed
**Effort:** 3-4 hours
**Ongoing Cost:** $0 (Web Push is free)

---

## Investigation Findings

### Current State
- **PWA Service Worker exists** (`public/sw.js`) - Uses Workbox for precaching, NO push handling yet
- **No web-push package** - Not in `package.json`
- **Callbacks table exists** - Has `scheduled_at` field in schema
- **No callback reminder job** - Needs to be created
- **Slack notifications work** - High-intent SMS and calls already send Slack alerts
- **Settings page exists** - `More.jsx` has Settings section for notification preferences

### Integration Points Identified
| Hook Point | File | Line | Current Behavior |
|------------|------|------|------------------|
| High-intent SMS | `src/routes/webhooks.js` | ~425 | Skips auto-reply, no alert sent |
| Inbound calls | `src/routes/voice.js` | ~220 | Sends Slack via `sendInboundCallNotification()` |
| New signups | `src/services/slack.js` | ~850 | Sends via `sendSignupNotification()` |
| Callback reminders | N/A | N/A | **MISSING** - needs new job |

---

## Problem

Reps miss time-sensitive events when not actively in app:
- High-intent SMS replies (lead says "yes, I'm interested")
- Overdue callbacks (scheduled follow-ups missed)
- New website signups (5-minute response = 10x conversion)
- Conversion celebrations (positive reinforcement)

**Current:** Slack notifications exist but require Slack app open and notifications enabled.

**Solution:** Browser push notifications that work even when app is backgrounded.

---

## Architecture

```
┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│   Event Trigger │────▶│  Push Service    │────▶│  Service Worker │
│  (webhook/job)  │     │  (web-push lib)  │     │  (sw.js + push) │
└─────────────────┘     └──────────────────┘     └─────────────────┘
                               │                         │
                               ▼                         ▼
                        ┌──────────────────┐     ┌─────────────────┐
                        │  push_subscriptions │  │  Browser Notif  │
                        │  (Supabase table)   │  │  (click → app)  │
                        └──────────────────┘     └─────────────────┘
```

---

## Database Schema

```sql
-- Push notification subscriptions
CREATE TABLE IF NOT EXISTS push_subscriptions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  endpoint TEXT NOT NULL,
  keys JSONB NOT NULL, -- { p256dh: string, auth: string }
  user_agent TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  last_used_at TIMESTAMPTZ,
  UNIQUE(user_id, endpoint)
);

-- Notification preferences per user
CREATE TABLE IF NOT EXISTS notification_preferences (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE,
  -- What to notify
  high_intent_sms BOOLEAN DEFAULT TRUE,
  new_leads BOOLEAN DEFAULT TRUE,
  callbacks BOOLEAN DEFAULT TRUE,
  conversions BOOLEAN DEFAULT TRUE,
  sequence_replies BOOLEAN DEFAULT TRUE,
  daily_summary BOOLEAN DEFAULT FALSE,
  -- When to notify
  quiet_hours_start TIME, -- e.g., '22:00'
  quiet_hours_end TIME,   -- e.g., '07:00'
  -- Fallback
  sms_fallback BOOLEAN DEFAULT FALSE,
  sms_fallback_phone VARCHAR(20),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Indexes
CREATE INDEX IF NOT EXISTS idx_push_subs_user ON push_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_notif_prefs_user ON notification_preferences(user_id);
```

---

## API Endpoints

| Method | Route | Purpose |
|--------|-------|---------|
| POST | `/api/notifications/subscribe` | Register push subscription |
| DELETE | `/api/notifications/unsubscribe` | Remove subscription |
| GET | `/api/notifications/preferences` | Get user preferences |
| PUT | `/api/notifications/preferences` | Update preferences |
| POST | `/api/notifications/test` | Send test notification |

---

## Backend Implementation

### 1. Push Service (`src/services/pushNotifications.js`)

```javascript
const webpush = require('web-push');
const { supabaseAdmin } = require('../config/database');

// Configure VAPID
webpush.setVapidDetails(
  'mailto:support@rateright.com.au',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

const db = supabaseAdmin;

/**
 * Send push notification to user
 */
async function sendPushNotification(userId, notification) {
  // Check user preferences
  const { data: prefs } = await db
    .from('notification_preferences')
    .select('*')
    .eq('user_id', userId)
    .single();

  // Check quiet hours (AEST)
  if (prefs && isQuietHours(prefs.quiet_hours_start, prefs.quiet_hours_end)) {
    console.log(`[Push] Skipping - quiet hours for user ${userId}`);
    return { sent: 0, reason: 'quiet_hours' };
  }

  // Check preference for this notification type
  const typeKey = notification.type; // e.g., 'high_intent_sms'
  if (prefs && prefs[typeKey] === false) {
    console.log(`[Push] Skipping - ${typeKey} disabled for user ${userId}`);
    return { sent: 0, reason: 'preference_disabled' };
  }

  // Get user's push subscriptions
  const { data: subscriptions } = await db
    .from('push_subscriptions')
    .select('*')
    .eq('user_id', userId);

  if (!subscriptions?.length) {
    console.log(`[Push] No subscriptions for user ${userId}`);
    return { sent: 0, reason: 'no_subscriptions' };
  }

  let sent = 0;
  for (const sub of subscriptions) {
    try {
      await webpush.sendNotification(
        {
          endpoint: sub.endpoint,
          keys: sub.keys
        },
        JSON.stringify(notification)
      );
      sent++;

      // Update last_used_at
      await db
        .from('push_subscriptions')
        .update({ last_used_at: new Date().toISOString() })
        .eq('id', sub.id);

    } catch (err) {
      if (err.statusCode === 410 || err.statusCode === 404) {
        // Subscription expired, remove it
        await db.from('push_subscriptions').delete().eq('id', sub.id);
        console.log(`[Push] Removed expired subscription ${sub.id}`);
      } else {
        console.error(`[Push] Failed to send:`, err.message);
      }
    }
  }

  return { sent };
}

/**
 * Check if current time is within quiet hours (AEST)
 */
function isQuietHours(start, end) {
  if (!start || !end) return false;

  const now = new Date();
  const aestOffset = 10 * 60; // AEST = UTC+10
  const aestMinutes = (now.getUTCHours() * 60 + now.getUTCMinutes() + aestOffset) % (24 * 60);

  const [startH, startM] = start.split(':').map(Number);
  const [endH, endM] = end.split(':').map(Number);

  const startMinutes = startH * 60 + startM;
  const endMinutes = endH * 60 + endM;

  // Handle overnight quiet hours (e.g., 22:00 - 07:00)
  if (startMinutes > endMinutes) {
    return aestMinutes >= startMinutes || aestMinutes < endMinutes;
  }

  return aestMinutes >= startMinutes && aestMinutes < endMinutes;
}

/**
 * Broadcast notification to all users (or specific role)
 */
async function broadcastPush(notification, role = null) {
  const query = db.from('user_xp').select('user_id');
  if (role) {
    query.eq('role', role);
  }

  const { data: users } = await query;

  let totalSent = 0;
  for (const user of (users || [])) {
    const result = await sendPushNotification(user.user_id, notification);
    totalSent += result.sent;
  }

  return { totalSent };
}

module.exports = {
  sendPushNotification,
  broadcastPush,
  isQuietHours
};
```

### 2. Notifications Routes (`src/routes/notifications.js`)

```javascript
const express = require('express');
const router = express.Router();
const webpush = require('web-push');
const { supabaseAdmin } = require('../config/database');
const { requireAuth } = require('../middleware/auth');
const { sendPushNotification } = require('../services/pushNotifications');

const db = supabaseAdmin;

// Subscribe to push notifications
router.post('/subscribe', requireAuth, async (req, res) => {
  try {
    const userId = req.user.id;
    const { subscription } = req.body;

    if (!subscription?.endpoint || !subscription?.keys) {
      return res.status(400).json({ error: 'Invalid subscription' });
    }

    // Upsert subscription
    const { error } = await db
      .from('push_subscriptions')
      .upsert({
        user_id: userId,
        endpoint: subscription.endpoint,
        keys: subscription.keys,
        user_agent: req.headers['user-agent'],
        created_at: new Date().toISOString()
      }, {
        onConflict: 'user_id,endpoint'
      });

    if (error) throw error;

    res.json({ success: true });
  } catch (err) {
    console.error('[Notifications] Subscribe error:', err);
    res.status(500).json({ error: err.message });
  }
});

// Unsubscribe from push notifications
router.delete('/unsubscribe', requireAuth, async (req, res) => {
  try {
    const userId = req.user.id;
    const { endpoint } = req.body;

    const query = db.from('push_subscriptions').delete().eq('user_id', userId);
    if (endpoint) {
      query.eq('endpoint', endpoint);
    }

    await query;
    res.json({ success: true });
  } catch (err) {
    console.error('[Notifications] Unsubscribe error:', err);
    res.status(500).json({ error: err.message });
  }
});

// Get notification preferences
router.get('/preferences', requireAuth, async (req, res) => {
  try {
    const userId = req.user.id;

    let { data: prefs } = await db
      .from('notification_preferences')
      .select('*')
      .eq('user_id', userId)
      .single();

    // Return defaults if no preferences exist
    if (!prefs) {
      prefs = {
        high_intent_sms: true,
        new_leads: true,
        callbacks: true,
        conversions: true,
        sequence_replies: true,
        daily_summary: false,
        quiet_hours_start: null,
        quiet_hours_end: null,
        sms_fallback: false,
        sms_fallback_phone: null
      };
    }

    res.json(prefs);
  } catch (err) {
    console.error('[Notifications] Get preferences error:', err);
    res.status(500).json({ error: err.message });
  }
});

// Update notification preferences
router.put('/preferences', requireAuth, async (req, res) => {
  try {
    const userId = req.user.id;
    const updates = req.body;

    const { error } = await db
      .from('notification_preferences')
      .upsert({
        user_id: userId,
        ...updates,
        updated_at: new Date().toISOString()
      }, {
        onConflict: 'user_id'
      });

    if (error) throw error;

    res.json({ success: true });
  } catch (err) {
    console.error('[Notifications] Update preferences error:', err);
    res.status(500).json({ error: err.message });
  }
});

// Send test notification
router.post('/test', requireAuth, async (req, res) => {
  try {
    const userId = req.user.id;

    const result = await sendPushNotification(userId, {
      title: '🔔 Test Notification',
      body: 'Push notifications are working!',
      tag: 'test',
      type: 'test',
      url: '/dashboard'
    });

    res.json(result);
  } catch (err) {
    console.error('[Notifications] Test error:', err);
    res.status(500).json({ error: err.message });
  }
});

// Get VAPID public key for frontend
router.get('/vapid-public-key', (req, res) => {
  res.json({ publicKey: process.env.VAPID_PUBLIC_KEY });
});

module.exports = router;
```

### 3. Callback Reminder Job (`src/jobs/callbackReminder.js`)

```javascript
/**
 * Callback Reminder Job
 * Runs every 5 minutes, sends push notifications for upcoming callbacks
 */

const { supabaseAdmin } = require('../config/database');
const { sendPushNotification } = require('../services/pushNotifications');
const { sendSlackMessage } = require('../services/slack');

const db = supabaseAdmin;

async function checkUpcomingCallbacks() {
  console.log('[CallbackReminder] Checking for upcoming callbacks...');

  // Get callbacks due in next 15 minutes that haven't been notified
  const fifteenMinutesFromNow = new Date(Date.now() + 15 * 60 * 1000).toISOString();
  const now = new Date().toISOString();

  const { data: callbacks, error } = await db
    .from('callbacks')
    .select(`
      id,
      lead_id,
      scheduled_at,
      reason,
      user_id,
      leads!inner (
        id,
        first_name,
        last_name,
        phone,
        company
      )
    `)
    .eq('status', 'pending')
    .gte('scheduled_at', now)
    .lte('scheduled_at', fifteenMinutesFromNow)
    .is('notified_at', null);

  if (error) {
    console.error('[CallbackReminder] Error fetching callbacks:', error);
    return;
  }

  console.log(`[CallbackReminder] Found ${callbacks?.length || 0} callbacks due soon`);

  for (const callback of (callbacks || [])) {
    const lead = callback.leads;
    const leadName = `${lead.first_name || ''} ${lead.last_name || ''}`.trim() || 'Unknown';
    const scheduledTime = new Date(callback.scheduled_at);
    const minutesUntil = Math.round((scheduledTime - Date.now()) / (1000 * 60));

    // Send push notification
    if (callback.user_id) {
      await sendPushNotification(callback.user_id, {
        title: `⏰ Callback in ${minutesUntil} min`,
        body: `${leadName}${callback.reason ? ` - ${callback.reason}` : ''}`,
        tag: `callback-${callback.id}`,
        type: 'callbacks',
        priority: 'high',
        vibrate: [200, 100, 200],
        url: `/leads/${lead.id}?action=call`,
        actions: [
          { action: 'call', title: '📞 Call Now' },
          { action: 'snooze', title: '⏰ +15 min' }
        ]
      });
    }

    // Mark as notified
    await db
      .from('callbacks')
      .update({ notified_at: new Date().toISOString() })
      .eq('id', callback.id);
  }

  // Also check for overdue callbacks (send alert)
  const { data: overdue } = await db
    .from('callbacks')
    .select('id, lead_id, scheduled_at, user_id')
    .eq('status', 'pending')
    .lt('scheduled_at', now)
    .limit(10);

  if (overdue?.length > 0) {
    console.log(`[CallbackReminder] ${overdue.length} overdue callbacks`);
    // Could send batch alert to Slack here
  }
}

// Schedule to run every 5 minutes
function startCallbackReminder() {
  console.log('[CallbackReminder] Starting callback reminder job (every 5 min)');

  // Run immediately
  checkUpcomingCallbacks();

  // Then every 5 minutes
  setInterval(checkUpcomingCallbacks, 5 * 60 * 1000);
}

module.exports = { startCallbackReminder, checkUpcomingCallbacks };
```

---

## Service Worker Extension

Add to `admin/src/sw-push.js` (Vite will merge with existing sw.js):

```javascript
// Push notification handler
self.addEventListener('push', (event) => {
  if (!event.data) return;

  const data = event.data.json();

  const options = {
    body: data.body,
    icon: '/icons/icon-192.png',
    badge: '/icons/badge-72.png',
    vibrate: data.vibrate || [200],
    tag: data.tag,
    renotify: true,
    requireInteraction: data.priority === 'high' || data.priority === 'critical',
    actions: data.actions || [],
    data: {
      url: data.url || '/',
      leadId: data.leadId
    }
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Notification click handler
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const action = event.action;
  const data = event.notification.data;

  if (action === 'call') {
    // Open lead page with call action
    event.waitUntil(
      clients.openWindow(data.url + '?action=call')
    );
  } else if (action === 'snooze') {
    // Could POST to snooze endpoint
    event.waitUntil(
      fetch('/api/callbacks/snooze', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ leadId: data.leadId, minutes: 15 })
      })
    );
  } else {
    // Default: open the URL
    event.waitUntil(
      clients.openWindow(data.url)
    );
  }
});
```

---

## Frontend Components

### 1. NotificationPermissionPrompt.jsx

```jsx
import { useState, useEffect } from 'react';
import { Bell, X } from 'lucide-react';
import { notificationsApi } from '../api/client';

export default function NotificationPermissionPrompt() {
  const [show, setShow] = useState(false);
  const [subscribing, setSubscribing] = useState(false);

  useEffect(() => {
    // Check if we should show the prompt
    const dismissed = localStorage.getItem('push-prompt-dismissed');
    const hasPermission = Notification.permission === 'granted';

    if (!dismissed && !hasPermission && 'Notification' in window) {
      // Delay showing to not overwhelm on first load
      setTimeout(() => setShow(true), 5000);
    }
  }, []);

  const handleEnable = async () => {
    setSubscribing(true);
    try {
      const permission = await Notification.requestPermission();

      if (permission === 'granted') {
        // Get VAPID public key
        const { publicKey } = await notificationsApi.getVapidKey();

        // Subscribe to push
        const registration = await navigator.serviceWorker.ready;
        const subscription = await registration.pushManager.subscribe({
          userVisibleOnly: true,
          applicationServerKey: urlBase64ToUint8Array(publicKey)
        });

        // Send subscription to backend
        await notificationsApi.subscribe(subscription.toJSON());

        setShow(false);
      }
    } catch (err) {
      console.error('Failed to enable notifications:', err);
    } finally {
      setSubscribing(false);
    }
  };

  const handleDismiss = () => {
    localStorage.setItem('push-prompt-dismissed', Date.now().toString());
    setShow(false);
  };

  if (!show) return null;

  return (
    <div className="fixed bottom-24 left-4 right-4 md:left-auto md:right-6 md:max-w-sm z-50">
      <div className="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-200 dark:border-slate-700 p-4">
        <button
          onClick={handleDismiss}
          className="absolute top-2 right-2 p-1 text-slate-400 hover:text-slate-600"
        >
          <X className="w-4 h-4" />
        </button>

        <div className="flex items-start gap-3">
          <div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center flex-shrink-0">
            <Bell className="w-5 h-5 text-blue-600 dark:text-blue-400" />
          </div>
          <div>
            <h3 className="font-bold text-slate-900 dark:text-white">
              Never miss a hot lead
            </h3>
            <p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
              Get instant alerts for high-intent messages, callbacks, and new signups.
            </p>
            <div className="flex gap-2 mt-3">
              <button
                onClick={handleEnable}
                disabled={subscribing}
                className="px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 disabled:opacity-50"
              >
                {subscribing ? 'Enabling...' : 'Enable Notifications'}
              </button>
              <button
                onClick={handleDismiss}
                className="px-4 py-2 text-slate-500 text-sm font-medium hover:text-slate-700"
              >
                Maybe later
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}
```

### 2. NotificationSettings.jsx (for More.jsx Settings section)

```jsx
import { useState, useEffect } from 'react';
import { Bell, Moon, Phone, TestTube } from 'lucide-react';
import { toast } from 'sonner';
import { notificationsApi } from '../api/client';

export default function NotificationSettings() {
  const [prefs, setPrefs] = useState(null);
  const [loading, setLoading] = useState(true);
  const [testing, setTesting] = useState(false);

  useEffect(() => {
    loadPreferences();
  }, []);

  const loadPreferences = async () => {
    try {
      const data = await notificationsApi.getPreferences();
      setPrefs(data);
    } catch (err) {
      console.error('Failed to load preferences:', err);
    } finally {
      setLoading(false);
    }
  };

  const updatePref = async (key, value) => {
    try {
      setPrefs(prev => ({ ...prev, [key]: value }));
      await notificationsApi.updatePreferences({ [key]: value });
    } catch (err) {
      console.error('Failed to update preference:', err);
      toast.error('Failed to save');
    }
  };

  const sendTest = async () => {
    setTesting(true);
    try {
      const result = await notificationsApi.test();
      if (result.sent > 0) {
        toast.success('Test notification sent!');
      } else {
        toast.error(result.reason || 'No subscriptions found');
      }
    } catch (err) {
      toast.error('Failed to send test');
    } finally {
      setTesting(false);
    }
  };

  if (loading) return <div className="p-4 text-slate-400">Loading...</div>;

  const toggles = [
    { key: 'high_intent_sms', label: 'High-intent messages', icon: '🔥' },
    { key: 'new_leads', label: 'New lead signups', icon: '🆕' },
    { key: 'callbacks', label: 'Callback reminders', icon: '⏰' },
    { key: 'conversions', label: 'Conversion celebrations', icon: '🎉' },
    { key: 'sequence_replies', label: 'Sequence replies', icon: '📧' },
  ];

  return (
    <div className="space-y-4">
      <h3 className="text-sm font-bold text-slate-700 dark:text-slate-300">
        Push Notifications
      </h3>

      <div className="space-y-2">
        {toggles.map(({ key, label, icon }) => (
          <label key={key} className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700 rounded-lg cursor-pointer">
            <span className="flex items-center gap-2">
              <span>{icon}</span>
              <span className="text-sm text-slate-700 dark:text-slate-200">{label}</span>
            </span>
            <input
              type="checkbox"
              checked={prefs?.[key] ?? true}
              onChange={(e) => updatePref(key, e.target.checked)}
              className="w-5 h-5 rounded accent-blue-600"
            />
          </label>
        ))}
      </div>

      <div className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700 rounded-lg">
        <Moon className="w-5 h-5 text-slate-400" />
        <div className="flex-1">
          <p className="text-sm text-slate-700 dark:text-slate-200">Quiet Hours</p>
          <p className="text-xs text-slate-400">No notifications during sleep</p>
        </div>
        <div className="flex items-center gap-2 text-sm">
          <input
            type="time"
            value={prefs?.quiet_hours_start || ''}
            onChange={(e) => updatePref('quiet_hours_start', e.target.value)}
            className="px-2 py-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded text-slate-700 dark:text-slate-200"
          />
          <span className="text-slate-400">to</span>
          <input
            type="time"
            value={prefs?.quiet_hours_end || ''}
            onChange={(e) => updatePref('quiet_hours_end', e.target.value)}
            className="px-2 py-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-600 rounded text-slate-700 dark:text-slate-200"
          />
        </div>
      </div>

      <button
        onClick={sendTest}
        disabled={testing}
        className="w-full py-2 bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200 rounded-lg text-sm font-medium hover:bg-slate-300 dark:hover:bg-slate-500 disabled:opacity-50"
      >
        {testing ? 'Sending...' : '🔔 Send Test Notification'}
      </button>
    </div>
  );
}
```

---

## Integration Hooks

### 1. High-Intent SMS (`src/routes/webhooks.js`)

Add after line ~425 (where buying signal is detected):

```javascript
// Send push notification for high intent
if (buyingSignal?.level === 'high' || buyingSignal?.level === 'medium') {
  const { sendPushNotification, broadcastPush } = require('../services/pushNotifications');

  // Get assigned user or broadcast to all
  const assignedUserId = lead?.assigned_to;
  const notification = {
    title: buyingSignal.level === 'high' ? '🔥 HIGH INTENT' : '⚡ Buying Signal',
    body: `${leadName}: "${Body.substring(0, 50)}${Body.length > 50 ? '...' : ''}"`,
    tag: `sms-${lead?.id || MessageSid}`,
    type: 'high_intent_sms',
    priority: buyingSignal.level === 'high' ? 'critical' : 'high',
    vibrate: [200, 100, 200, 100, 200],
    url: `/leads/${lead?.id}`,
    leadId: lead?.id,
    actions: [
      { action: 'call', title: '📞 Call Now' },
      { action: 'view', title: 'View' }
    ]
  };

  if (assignedUserId) {
    await sendPushNotification(assignedUserId, notification);
  } else {
    await broadcastPush(notification, 'closer');
  }
}
```

### 2. New Signup (`src/services/slack.js`)

Add to `sendSignupNotification()` function:

```javascript
// After Slack notification, also send push
const { broadcastPush } = require('./pushNotifications');
await broadcastPush({
  title: '🆕 New Signup',
  body: `${leadName} - ${trade || 'Unknown trade'} in ${location || 'Unknown'}`,
  tag: `signup-${lead.id}`,
  type: 'new_leads',
  priority: 'high',
  url: `/leads/${lead.id}`
}, 'closer');
```

---

## Environment Variables

```bash
# Generate with: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=<public_key>
VAPID_PRIVATE_KEY=<private_key>
```

---

## Implementation Steps

### Phase 1: Backend Setup (1.5 hours)
- [ ] 1.1 Install `web-push` package
- [ ] 1.2 Generate VAPID keys, add to Railway env vars
- [ ] 1.3 Create database tables (migration)
- [ ] 1.4 Create `src/services/pushNotifications.js`
- [ ] 1.5 Create `src/routes/notifications.js`
- [ ] 1.6 Register route in `src/index.js`

### Phase 2: Callback Reminder Job (30 min)
- [ ] 2.1 Create `src/jobs/callbackReminder.js`
- [ ] 2.2 Add `notified_at` column to callbacks table
- [ ] 2.3 Register job in `src/jobs/index.js`

### Phase 3: Service Worker (30 min)
- [ ] 3.1 Create `admin/src/sw-push.js` with push handlers
- [ ] 3.2 Update `vite.config.js` to include push handlers in SW
- [ ] 3.3 Test push event handling

### Phase 4: Frontend (1 hour)
- [ ] 4.1 Add `notificationsApi` to `admin/src/api/client.js`
- [ ] 4.2 Create `NotificationPermissionPrompt.jsx`
- [ ] 4.3 Create `NotificationSettings.jsx`
- [ ] 4.4 Add NotificationSettings to More.jsx Settings section
- [ ] 4.5 Add NotificationPermissionPrompt to App.jsx

### Phase 5: Integration (30 min)
- [ ] 5.1 Hook into webhooks.js (high-intent SMS)
- [ ] 5.2 Hook into slack.js (new signups)
- [ ] 5.3 Test end-to-end flow

---

## Success Metrics

- [ ] 80%+ of users enable notifications
- [ ] Response time to high-intent SMS <5 min (vs current avg)
- [ ] Callback completion rate >90%
- [ ] Zero missed hot leads due to notification failure

---

## Notes for Builder

1. **VAPID keys are sensitive** - Never commit to git
2. **Test on multiple browsers** - Chrome, Safari, Firefox have different behaviors
3. **iOS Safari limitations** - Push only works if PWA is installed on home screen
4. **Quiet hours use AEST** - All users are Australian timezone
5. **Use `supabaseAdmin`** - Push service runs without user context

## Notes for QA

1. **Test permission flow** - First-time prompt, denied, then allowed
2. **Test quiet hours** - Verify notifications blocked during quiet period
3. **Test on mobile** - iOS needs PWA install, Android should work in browser
4. **Test notification actions** - Call Now, Snooze should work
5. **Test preference toggles** - Each toggle should actually prevent that notification type
