drop-push-notifications-spec

Drop Push Notification Delivery Service — Implementation Spec

Project: Drop (Fintech Payment App) Task: MC #1196 Date: 2026-02-17 Author: John (AI Director)


1. Executive Summary

Drop currently has notification stubs (database table, UI, demo-mode service) but no actual push delivery infrastructure. This spec defines a production-ready push notification system covering:

MVP Recommendation: Web Push (PWA) — Drop currently has web app only, mobile apps planned for future. Start with Web Push API + service workers, add FCM/APNs when React Native mobile apps ship.


2. Architecture Overview

2.1 Unified Push Service Layer

File: src/lib/push.ts (NEW — replaces stub at src/lib/services/notifications.ts)

Provider-agnostic push notification abstraction. All push sending goes through this module.

Interface:

// src/lib/push.ts

export interface DeviceToken {
  id: string;
  userId: string;
  platform: 'web' | 'android' | 'ios';
  token: string;           // For FCM/APNs, this is the device token
  endpoint?: string;       // For Web Push, this is the subscription endpoint
  keys?: {                 // For Web Push only
    p256dh: string;
    auth: string;
  };
  createdAt: string;
  lastUsedAt: string;
}

export interface NotificationPayload {
  userId: string;
  type: NotificationType;
  title: string;
  body: string;
  data?: Record<string, unknown>;
  priority?: 'high' | 'normal' | 'low';
  category?: string;       // For iOS notification categories
  badge?: number;          // Badge count (iOS/Android)
  sound?: string;          // Sound file name
}

export interface DeliveryResult {
  success: boolean;
  messageId?: string;
  platform: 'web' | 'android' | 'ios';
  error?: string;
}

// Core send function
export async function sendPushNotification(
  payload: NotificationPayload
): Promise<DeliveryResult[]>;

// Device token management
export async function registerDeviceToken(
  userId: string,
  platform: 'web' | 'android' | 'ios',
  token: string,
  keys?: { p256dh: string; auth: string; endpoint: string }
): Promise<{ success: boolean; error?: string }>;

export async function unregisterDeviceToken(
  userId: string,
  deviceId: string
): Promise<{ success: boolean }>;

export async function refreshDeviceToken(
  oldToken: string,
  newToken: string
): Promise<{ success: boolean }>;

// Preference check
export async function canSendNotification(
  userId: string,
  type: NotificationType
): Promise<boolean>;

2.2 Notification Type Taxonomy

Security Principle: Transactional notifications = mandatory (no opt-out). Promotional = explicit consent required (Norwegian markedsføringsloven).

export type NotificationType =
  // TRANSACTIONAL (mandatory, no opt-out)
  | 'transfer_sent'           // Money sent from user's account
  | 'transfer_received'       // Money received into user's account
  | 'transfer_failed'         // Transfer failed (bank rejected)
  | 'login_alert'             // New device/location login
  | 'otp_code'                // OTP code for 2FA (future)
  | 'password_changed'        // Password changed (security alert)
  | 'bankid_linked'           // BankID linked to account
  | 'bankid_unlinked'         // BankID unlinked (security alert)
  | 'account_locked'          // Account locked due to suspicious activity
  | 'kyc_approved'            // KYC verification approved
  | 'kyc_rejected'            // KYC verification rejected

  // ACCOUNT (opt-in, enabled by default)
  | 'transaction_summary'     // Daily/weekly transaction summary
  | 'low_balance'             // Bank account balance below threshold
  | 'rate_update'             // Exchange rate update for pending transfer

  // PROMOTIONAL (opt-in, disabled by default, GDPR consent required)
  | 'referral'                // Referral program
  | 'new_feature'             // New feature announcement
  | 'special_offer';          // Special offers/promotions

export const NOTIFICATION_CATEGORIES = {
  transactional: [
    'transfer_sent',
    'transfer_received',
    'transfer_failed',
    'login_alert',
    'otp_code',
    'password_changed',
    'bankid_linked',
    'bankid_unlinked',
    'account_locked',
    'kyc_approved',
    'kyc_rejected',
  ],
  account: [
    'transaction_summary',
    'low_balance',
    'rate_update',
  ],
  promotional: [
    'referral',
    'new_feature',
    'special_offer',
  ],
} as const;

export const NOTIFICATION_PRIORITY = {
  transfer_sent: 'high',
  transfer_received: 'high',
  transfer_failed: 'high',
  login_alert: 'high',
  otp_code: 'high',
  password_changed: 'high',
  bankid_unlinked: 'high',
  account_locked: 'high',
  kyc_approved: 'normal',
  kyc_rejected: 'normal',
  bankid_linked: 'normal',
  transaction_summary: 'normal',
  low_balance: 'normal',
  rate_update: 'low',
  referral: 'low',
  new_feature: 'low',
  special_offer: 'low',
} as const;

Norwegian Marketing Law Compliance:


2.3 Platform-Specific Implementations

A. Web Push API (PRIMARY — MVP)

Tech Stack:

VAPID (Voluntary Application Server Identification):

Client-Side Flow:

  1. User visits Drop PWA
  2. Service worker registers (/sw.js)
  3. User grants notification permission (browser prompt)
  4. Client subscribes to push: registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY })
  5. Client sends subscription object to server: POST /api/notifications/register-device
  6. Server stores subscription in device_tokens table

Server-Side Flow:

  1. Transaction completes → create notification in notifications table
  2. Fetch user's Web Push subscriptions from device_tokens WHERE platform='web'
  3. For each subscription:
    • Use web-push library to send notification
    • webpush.sendNotification(subscription, JSON.stringify(payload))
  4. Log delivery result in notification_log

Service Worker (public/sw.js):

// Listen for push events
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};
  const { title, body, icon, badge, data: customData } = data;

  const options = {
    body: body,
    icon: icon || '/icon-192.png',
    badge: badge || '/badge-72.png',
    data: customData,
    vibrate: [200, 100, 200],
    tag: data.type || 'default',
    requireInteraction: data.priority === 'high',
  };

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

// Handle notification click
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const urlToOpen = event.notification.data?.url || '/dashboard';

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
      // If Drop is already open, focus it
      for (const client of clientList) {
        if (client.url.includes(urlToOpen) && 'focus' in client) {
          return client.focus();
        }
      }
      // Otherwise, open new window
      if (clients.openWindow) {
        return clients.openWindow(urlToOpen);
      }
    })
  );
});

Dependencies:

{
  "dependencies": {
    "web-push": "^3.6.7"
  }
}

Env Vars:

# Web Push (VAPID keys)
VAPID_PUBLIC_KEY=BN...  # Public key (also exposed to client via /api/vapid-public-key)
VAPID_PRIVATE_KEY=...   # Private key (server-side only)
VAPID_SUBJECT=mailto:support@getdrop.no

B. Firebase Cloud Messaging (FUTURE — Android)

When: When React Native Android app ships.

Tech Stack:

Setup:

  1. Create Firebase project at console.firebase.google.com
  2. Add Android app to Firebase project (package name: no.getdrop.app)
  3. Download google-services.json, place in React Native Android project
  4. Download service account key JSON for server
  5. Set env var: FIREBASE_SERVICE_ACCOUNT_KEY (base64-encoded JSON)

Client-Side Flow (React Native):

// React Native app startup
import messaging from '@react-native-firebase/messaging';

async function registerForPushNotifications() {
  const authStatus = await messaging().requestPermission();
  if (authStatus === messaging.AuthorizationStatus.AUTHORIZED) {
    const token = await messaging().getToken();
    // Send to server: POST /api/notifications/register-device
    await fetch('/api/notifications/register-device', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ platform: 'android', token }),
    });
  }
}

// Handle foreground messages
messaging().onMessage(async (remoteMessage) => {
  // Show in-app notification UI
});

// Handle background messages (background handler in index.js)
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
  console.log('Background message:', remoteMessage);
});

// Handle token refresh
messaging().onTokenRefresh((token) => {
  // Update server with new token
});

Server-Side Flow:

import admin from 'firebase-admin';

// Initialize Firebase Admin (once on startup)
const serviceAccountKey = JSON.parse(
  Buffer.from(process.env.FIREBASE_SERVICE_ACCOUNT_KEY!, 'base64').toString('utf-8')
);

admin.initializeApp({
  credential: admin.credential.cert(serviceAccountKey),
});

// Send notification
async function sendFCMNotification(token: string, payload: NotificationPayload) {
  const message = {
    token: token,
    notification: {
      title: payload.title,
      body: payload.body,
    },
    data: payload.data || {},
    android: {
      priority: payload.priority === 'high' ? 'high' : 'normal',
      notification: {
        sound: payload.sound || 'default',
        badge: payload.badge,
      },
    },
  };

  const response = await admin.messaging().send(message);
  return response; // message ID
}

Dependencies:

{
  "dependencies": {
    "firebase-admin": "^12.0.0"
  }
}

Env Vars:

FIREBASE_SERVICE_ACCOUNT_KEY=base64-encoded-json

Cost: FREE — FCM has no quota limits or pricing.


C. Apple Push Notification service (FUTURE — iOS)

When: When React Native iOS app ships.

Tech Stack:

Setup:

  1. Create iOS app in Apple Developer account
  2. Create Push Notification certificate or Auth Key (.p8 file)
  3. Download .p8 file, note Key ID and Team ID
  4. Set env vars: APNS_KEY_ID, APNS_TEAM_ID, APNS_KEY_PATH (or APNS_KEY_CONTENT as base64)

Server-Side Flow:

import apn from 'apn';

// Initialize APNs provider
const apnProvider = new apn.Provider({
  token: {
    key: process.env.APNS_KEY_CONTENT!, // .p8 file content
    keyId: process.env.APNS_KEY_ID!,
    teamId: process.env.APNS_TEAM_ID!,
  },
  production: process.env.NODE_ENV === 'production',
});

// Send notification
async function sendAPNsNotification(deviceToken: string, payload: NotificationPayload) {
  const notification = new apn.Notification();
  notification.alert = {
    title: payload.title,
    body: payload.body,
  };
  notification.badge = payload.badge;
  notification.sound = payload.sound || 'default';
  notification.category = payload.category;
  notification.priority = payload.priority === 'high' ? 10 : 5;
  notification.payload = payload.data || {};
  notification.topic = 'no.getdrop.app'; // iOS bundle ID

  const result = await apnProvider.send(notification, deviceToken);
  return result; // Array of { device, status }
}

Dependencies:

{
  "dependencies": {
    "apn": "^2.2.0"
  }
}

Env Vars:

APNS_KEY_ID=ABC123XYZ
APNS_TEAM_ID=DEF456UVW
APNS_KEY_CONTENT=base64-encoded-p8-file

Cost: FREE — APNs has no quota or pricing.


3. Database Schema

3.1 New Tables

device_tokens

Stores push notification device tokens/subscriptions.

SQLite:

CREATE TABLE IF NOT EXISTS device_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  platform TEXT NOT NULL CHECK(platform IN ('web','android','ios')),
  token TEXT,                    -- FCM/APNs device token (NULL for Web Push)
  endpoint TEXT,                 -- Web Push endpoint URL (NULL for FCM/APNs)
  p256dh_key TEXT,               -- Web Push p256dh key (NULL for FCM/APNs)
  auth_key TEXT,                 -- Web Push auth key (NULL for FCM/APNs)
  user_agent TEXT,               -- Browser/device info
  created_at TEXT DEFAULT (datetime('now')),
  last_used_at TEXT DEFAULT (datetime('now')),
  active INTEGER DEFAULT 1       -- 0 = deactivated (stale/unregistered)
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_device_tokens_token ON device_tokens(token) WHERE token IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_tokens_endpoint ON device_tokens(endpoint) WHERE endpoint IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_device_tokens_user ON device_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_device_tokens_platform ON device_tokens(platform);
CREATE INDEX IF NOT EXISTS idx_device_tokens_active ON device_tokens(active);

PostgreSQL:

CREATE TABLE IF NOT EXISTS device_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  platform TEXT NOT NULL CHECK(platform IN ('web','android','ios')),
  token TEXT,
  endpoint TEXT,
  p256dh_key TEXT,
  auth_key TEXT,
  user_agent TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  active INTEGER DEFAULT 1
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_device_tokens_token ON device_tokens(token) WHERE token IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_tokens_endpoint ON device_tokens(endpoint) WHERE endpoint IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_device_tokens_user ON device_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_device_tokens_platform ON device_tokens(platform);
CREATE INDEX IF NOT EXISTS idx_device_tokens_active ON device_tokens(active);

Deduplication: Same device registering multiple times → UPSERT on token or endpoint (updates last_used_at, reactivates if inactive).

Stale Token Cleanup: Cron job (daily) marks tokens as inactive if last_used_at > 90 days OR if delivery fails with "token invalid" error.


notification_queue

Queue for deferred/retry delivery (future — MVP sends immediately).

SQLite:

CREATE TABLE IF NOT EXISTS notification_queue (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  data TEXT,                     -- JSON string
  priority TEXT DEFAULT 'normal' CHECK(priority IN ('high','normal','low')),
  status TEXT DEFAULT 'pending' CHECK(status IN ('pending','sent','failed','cancelled')),
  attempts INTEGER DEFAULT 0,
  max_attempts INTEGER DEFAULT 3,
  next_retry_at TEXT,            -- ISO timestamp
  created_at TEXT DEFAULT (datetime('now')),
  sent_at TEXT,
  error TEXT
);

CREATE INDEX IF NOT EXISTS idx_queue_user ON notification_queue(user_id);
CREATE INDEX IF NOT EXISTS idx_queue_status ON notification_queue(status);
CREATE INDEX IF NOT EXISTS idx_queue_next_retry ON notification_queue(next_retry_at) WHERE status = 'pending';

PostgreSQL:

CREATE TABLE IF NOT EXISTS notification_queue (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  data TEXT,
  priority TEXT DEFAULT 'normal' CHECK(priority IN ('high','normal','low')),
  status TEXT DEFAULT 'pending' CHECK(status IN ('pending','sent','failed','cancelled')),
  attempts INTEGER DEFAULT 0,
  max_attempts INTEGER DEFAULT 3,
  next_retry_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  sent_at TIMESTAMP,
  error TEXT
);

CREATE INDEX IF NOT EXISTS idx_queue_user ON notification_queue(user_id);
CREATE INDEX IF NOT EXISTS idx_queue_status ON notification_queue(status);
CREATE INDEX IF NOT EXISTS idx_queue_next_retry ON notification_queue(next_retry_at) WHERE status = 'pending';

MVP Note: Queue table created but not used initially. MVP sends notifications synchronously. Post-MVP: add background worker that processes queue with retry logic (exponential backoff).


notification_log

Audit log of all push notification deliveries.

SQLite:

CREATE TABLE IF NOT EXISTS notification_log (
  id TEXT PRIMARY KEY,
  notification_id TEXT REFERENCES notifications(id),  -- NULL for push-only (no in-app)
  user_id TEXT NOT NULL REFERENCES users(id),
  device_token_id TEXT REFERENCES device_tokens(id),
  platform TEXT NOT NULL CHECK(platform IN ('web','android','ios')),
  type TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('sent','failed','skipped')),
  message_id TEXT,               -- Provider message ID (FCM/APNs/Web Push)
  error TEXT,
  sent_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_notif_log_user ON notification_log(user_id);
CREATE INDEX IF NOT EXISTS idx_notif_log_status ON notification_log(status);
CREATE INDEX IF NOT EXISTS idx_notif_log_sent_at ON notification_log(sent_at);
CREATE INDEX IF NOT EXISTS idx_notif_log_platform ON notification_log(platform);

PostgreSQL:

CREATE TABLE IF NOT EXISTS notification_log (
  id TEXT PRIMARY KEY,
  notification_id TEXT REFERENCES notifications(id),
  user_id TEXT NOT NULL REFERENCES users(id),
  device_token_id TEXT REFERENCES device_tokens(id),
  platform TEXT NOT NULL CHECK(platform IN ('web','android','ios')),
  type TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('sent','failed','skipped')),
  message_id TEXT,
  error TEXT,
  sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_notif_log_user ON notification_log(user_id);
CREATE INDEX IF NOT EXISTS idx_notif_log_status ON notification_log(status);
CREATE INDEX IF NOT EXISTS idx_notif_log_sent_at ON notification_log(sent_at);
CREATE INDEX IF NOT EXISTS idx_notif_log_platform ON notification_log(platform);

Retention: Keep indefinitely for audit trail (fintech compliance). Monitoring queries:

-- Delivery rate last 24h
SELECT
  platform,
  COUNT(*) as total,
  SUM(CASE WHEN status='sent' THEN 1 ELSE 0 END) as sent,
  SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) as failed
FROM notification_log
WHERE sent_at > datetime('now', '-1 day')
GROUP BY platform;

-- Failure reasons
SELECT error, COUNT(*) as count
FROM notification_log
WHERE status='failed' AND sent_at > datetime('now', '-7 days')
GROUP BY error
ORDER BY count DESC
LIMIT 10;

notification_preferences

User preferences for notification types (opt-in/opt-out, quiet hours).

SQLite:

CREATE TABLE IF NOT EXISTS notification_preferences (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  category TEXT NOT NULL CHECK(category IN ('transactional','account','promotional')),
  enabled INTEGER DEFAULT 1,
  quiet_hours_start TEXT,        -- HH:MM format (e.g., "22:00")
  quiet_hours_end TEXT,          -- HH:MM format (e.g., "08:00")
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now'))
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_notif_pref_user_cat ON notification_preferences(user_id, category);

PostgreSQL:

CREATE TABLE IF NOT EXISTS notification_preferences (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  category TEXT NOT NULL CHECK(category IN ('transactional','account','promotional')),
  enabled INTEGER DEFAULT 1,
  quiet_hours_start TEXT,
  quiet_hours_end TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_notif_pref_user_cat ON notification_preferences(user_id, category);

Default behavior:

Quiet hours logic:


3.2 Schema Changes to Existing Tables

notifications (existing table — ADD columns)

Additions:

-- SQLite migration
ALTER TABLE notifications ADD COLUMN notification_type TEXT;
ALTER TABLE notifications ADD COLUMN priority TEXT DEFAULT 'normal' CHECK(priority IN ('high','normal','low'));
ALTER TABLE notifications ADD COLUMN data TEXT;  -- JSON string with extra context

PostgreSQL migration:

ALTER TABLE notifications ADD COLUMN notification_type TEXT;
ALTER TABLE notifications ADD COLUMN priority TEXT DEFAULT 'normal';
ALTER TABLE notifications ADD COLUMN data TEXT;

ALTER TABLE notifications ADD CONSTRAINT notifications_priority_check
  CHECK (priority IN ('high','normal','low'));

Purpose: Existing notifications table stores in-app notifications. New columns allow linking in-app notifications to push notifications (same notification shows in both Notifications screen AND push).


4. API Endpoints

4.1 POST /api/notifications/register-device

Purpose: Register device token for push notifications.

Auth: Required (bearer token).

Request:

{
  "platform": "web",
  "token": "fcm-token-or-apns-token",  // For FCM/APNs
  "subscription": {                     // For Web Push only
    "endpoint": "https://fcm.googleapis.com/...",
    "keys": {
      "p256dh": "base64-encoded-p256dh",
      "auth": "base64-encoded-auth"
    }
  },
  "userAgent": "Mozilla/5.0 ..."
}

Response (200):

{
  "data": {
    "deviceId": "dtk_abc123",
    "registered": true
  }
}

Errors:

Logic:

  1. Validate platform ('web', 'android', 'ios')
  2. For Web Push: validate subscription object (endpoint, keys.p256dh, keys.auth)
  3. For FCM/APNs: validate token format
  4. Check if token/endpoint already exists:
    • If exists → update last_used_at, set active=1, return existing ID
    • If not exists → insert new row
  5. Return deviceId

File: src/app/api/notifications/register-device/route.ts (NEW)


4.2 DELETE /api/notifications/devices/:deviceId

Purpose: Unregister device token (user logs out or revokes permission).

Auth: Required.

Response (200):

{
  "data": { "success": true }
}

Logic:

  1. Verify device belongs to authenticated user
  2. Set active=0 (soft delete — keep for audit trail)

File: src/app/api/notifications/devices/[deviceId]/route.ts (NEW)


4.3 GET /api/notifications/preferences

Purpose: Get user's notification preferences.

Auth: Required.

Response (200):

{
  "data": {
    "transactional": {
      "enabled": true,
      "canDisable": false
    },
    "account": {
      "enabled": true,
      "canDisable": true
    },
    "promotional": {
      "enabled": false,
      "canDisable": true
    },
    "quietHours": {
      "enabled": false,
      "start": null,
      "end": null
    }
  }
}

Logic:

  1. Fetch rows from notification_preferences WHERE user_id = ?
  2. If no rows exist → create defaults (transactional=1, account=1, promotional=0)
  3. Return preferences

File: src/app/api/notifications/preferences/route.ts (NEW, GET handler)


4.4 PUT /api/notifications/preferences

Purpose: Update user's notification preferences.

Auth: Required.

Request:

{
  "account": { "enabled": true },
  "promotional": { "enabled": false },
  "quietHours": {
    "enabled": true,
    "start": "22:00",
    "end": "08:00"
  }
}

Response (200):

{
  "data": { "updated": true }
}

Errors:

Logic:

  1. Validate categories (cannot disable transactional)
  2. Validate quiet hours format (HH:MM, 00:00-23:59)
  3. UPSERT preferences into notification_preferences table
  4. Update updated_at = now()

File: src/app/api/notifications/preferences/route.ts (NEW, PUT handler)


4.5 GET /api/notifications/history

Purpose: Get user's push notification delivery history (debugging/audit).

Auth: Required.

Query params:

Response (200):

{
  "data": [
    {
      "id": "nlog_abc123",
      "type": "transfer_received",
      "title": "Du mottok penger",
      "platform": "web",
      "status": "sent",
      "sentAt": "2026-02-17T14:30:00Z"
    }
  ],
  "pagination": {
    "limit": 50,
    "offset": 0,
    "total": 123
  }
}

Logic:

  1. Query notification_log WHERE user_id = ? ORDER BY sent_at DESC LIMIT ? OFFSET ?
  2. Count total rows for pagination
  3. Return list

File: src/app/api/notifications/history/route.ts (NEW)


4.6 GET /api/vapid-public-key

Purpose: Expose VAPID public key to client for Web Push subscription.

Auth: Not required (public endpoint).

Response (200):

{
  "publicKey": "BN4GvZtEZiZuqaasbD-..."
}

File: src/app/api/vapid-public-key/route.ts (NEW)


5. Integration Points

5.1 Transaction Complete (Remittance + QR Payment)

Files to modify:

After transaction status = 'completed':

import { sendPushNotification } from '@/lib/push';
import { randomId } from '@/lib/utils';

// Create in-app notification (existing table)
const notificationId = randomId('ntf');
await run(
  `INSERT INTO notifications (id, user_id, type, title, body, notification_type, priority, data)
   VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
  [
    notificationId,
    userId,
    'transaction_complete',
    'Transaksjon fullført',
    `${amount} ${currency} sendt til ${recipientName}`,
    'transfer_sent',
    'high',
    JSON.stringify({ transactionId: txId, amount, currency }),
  ]
);

// Send push notification
await sendPushNotification({
  userId: userId,
  type: 'transfer_sent',
  title: 'Transaksjon fullført',
  body: `${amount} ${currency} sendt til ${recipientName}`,
  priority: 'high',
  data: {
    transactionId: txId,
    amount,
    currency,
    url: `/dashboard/transactions/${txId}`,
  },
});

// If recipient is a Drop user, notify them
if (recipientUserId) {
  const recipientNotifId = randomId('ntf');
  await run(
    `INSERT INTO notifications (id, user_id, type, title, body, notification_type, priority, data)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
    [
      recipientNotifId,
      recipientUserId,
      'transaction_complete',
      'Du mottok penger',
      `${amount} ${currency} fra ${senderName}`,
      'transfer_received',
      'high',
      JSON.stringify({ transactionId: txId, amount, currency }),
    ]
  );

  await sendPushNotification({
    userId: recipientUserId,
    type: 'transfer_received',
    title: 'Du mottok penger',
    body: `${amount} ${currency} fra ${senderName}`,
    priority: 'high',
    data: {
      transactionId: txId,
      amount,
      currency,
      url: `/dashboard/transactions/${txId}`,
    },
  });
}

5.2 Login Alert (New Device)

File to modify: src/app/api/auth/login/route.ts

After successful login:

import crypto from 'crypto';
import { sendPushNotification } from '@/lib/push';

const ip = getClientIp(request);
const userAgent = request.headers.get('user-agent') || 'Unknown';

// Generate device fingerprint
const deviceFingerprint = crypto.createHash('sha256')
  .update(`${ip}:${userAgent}`)
  .digest('hex');

// Check if device is new
const existingDevice = await getOne<{ id: string }>(
  "SELECT id FROM sessions WHERE user_id = ? AND device_fingerprint = ?",
  [userId, deviceFingerprint]
);

if (!existingDevice) {
  // New device → send login alert
  const notificationId = randomId('ntf');
  await run(
    `INSERT INTO notifications (id, user_id, type, title, body, notification_type, priority, data)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
    [
      notificationId,
      userId,
      'security',
      'Ny pålogging oppdaget',
      `Pålogging fra ${userAgent.slice(0, 50)}`,
      'login_alert',
      'high',
      JSON.stringify({ ip, userAgent }),
    ]
  );

  await sendPushNotification({
    userId: userId,
    type: 'login_alert',
    title: 'Ny pålogging oppdaget',
    body: `Pålogging fra ${userAgent.slice(0, 50)}`,
    priority: 'high',
    data: {
      ip,
      userAgent,
      url: '/profile/security',
    },
  });
}

// Add device_fingerprint to session insert (schema change needed)
await run(
  `INSERT INTO sessions (id, user_id, token_hash, expires_at, device_fingerprint)
   VALUES (?, ?, ?, ?, ?)`,
  [sessionId, userId, tokenHash, expiresAt, deviceFingerprint]
);

Schema change:

-- Add to sessions table
ALTER TABLE sessions ADD COLUMN device_fingerprint TEXT;
CREATE INDEX IF NOT EXISTS idx_sessions_device ON sessions(device_fingerprint);

5.3 Account Events (KYC, BankID, Password Change)

Files to modify:

Pattern (same for all):

// After event (e.g., password changed)
await sendPushNotification({
  userId: userId,
  type: 'password_changed',
  title: 'Passord endret',
  body: 'Passordet ditt ble nettopp endret. Hvis dette ikke var deg, kontakt support umiddelbart.',
  priority: 'high',
  data: {
    timestamp: new Date().toISOString(),
    url: '/profile/security',
  },
});

6. User Preference Management UI

6.1 Notification Settings Screen

File to create: src/app/profile/notifications/page.tsx (MODIFY existing if exists)

UI Components:

  1. Permission Status — Shows if browser/OS notifications enabled
    • If not enabled → show "Enable Notifications" button → triggers browser permission prompt
  2. Category Toggles:
    • Transactional: Always ON (disabled toggle, grayed out, "Required for security")
    • Account: Toggle (ON by default)
    • Promotional: Toggle (OFF by default, show consent text)
  3. Quiet Hours:
    • Toggle "Enable Quiet Hours"
    • Time pickers: Start time (default 22:00), End time (default 08:00)
    • Note: "Transactional notifications (security alerts, payments) will still be sent"
  4. Device List:
    • Shows registered devices (platform, last used)
    • "Remove" button per device

Client-Side Logic:

// Check if push notifications supported
if ('serviceWorker' in navigator && 'PushManager' in window) {
  // Check current permission
  const permission = Notification.permission;
  if (permission === 'default') {
    // Show "Enable Notifications" button
  } else if (permission === 'granted') {
    // Subscribe to push
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
    });

    // Send to server
    await fetch('/api/notifications/register-device', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        platform: 'web',
        subscription: subscription.toJSON(),
        userAgent: navigator.userAgent,
      }),
    });
  } else {
    // Permission denied → show "Notifications blocked" message
  }
}
<div>
  <Toggle
    checked={promotionalEnabled}
    onChange={async (enabled) => {
      if (enabled) {
        // Show consent dialog
        const confirmed = await showConsentDialog(
          "Markedsføringsvarsler",
          "Jeg samtykker til å motta tilbud, anbefalinger og produktoppdateringer fra Drop. " +
          "Jeg kan trekke tilbake samtykket når som helst i innstillinger."
        );
        if (confirmed) {
          await updatePreferences({ promotional: { enabled: true } });
        }
      } else {
        await updatePreferences({ promotional: { enabled: false } });
      }
    }}
  />
  <label>Tilbud og kampanjer</label>
  <p className="text-xs text-gray-500">
    Motta tilbud, anbefalinger og produktoppdateringer (krever samtykke)
  </p>
</div>

7. Norwegian Marketing Law Compliance

7.1 Markedsføringsloven §15 (Marketing Consent)

Law: Promotional electronic messages (email, SMS, push) require EXPLICIT prior consent from recipient.

Drop Implementation:

Audit trail:

-- Query: Show consent history for user
SELECT
  user_id,
  category,
  enabled,
  created_at,
  updated_at
FROM notification_preferences
WHERE user_id = ? AND category = 'promotional';

7.2 GDPR Compliance

Art. 6(1)(a) — Consent as legal basis:

Art. 7 — Conditions for consent:

Art. 17 — Right to erasure:


8. Security Considerations

8.1 No Sensitive Data in Push Payload

Risk: Push notifications travel through third-party servers (FCM, APNs, Web Push service). Payload can be logged/intercepted.

Mitigation:

Example (GOOD):

{
  "title": "Ny transaksjon",
  "body": "Du mottok kr 2 500,00",
  "data": {
    "transactionId": "tx_abc123",
    "url": "/dashboard/transactions/tx_abc123"
  }
}

Example (BAD):

{
  "title": "Ny transaksjon",
  "body": "Du mottok kr 2 500,00 fra John Doe (john@example.com) til konto 1234.56.78901",
  "data": { ... }
}

8.2 Token Encryption at Rest

Risk: Device tokens stored in plain text in DB → if DB compromised, attacker can send spam push notifications to all users.

Mitigation (Post-MVP):

MVP: Store in plain text (same risk level as session tokens — if DB is compromised, session tokens are also exposed). Add encryption in Phase 2.


8.3 Rate Limiting

Risk: Attacker with stolen session token spams push notifications to user or all users.

Mitigation:

Implementation:

import { checkRateLimit } from '@/lib/rate-limit';

async function sendPushNotification(payload: NotificationPayload) {
  const hour = new Date().toISOString().slice(0, 13); // "2026-02-17T14"
  const rateLimitKey = `push:${payload.userId}:${hour}`;

  const allowed = await checkRateLimit(rateLimitKey, 100, 3600); // 100 per hour
  if (!allowed) {
    console.warn(`[Push] Rate limit exceeded for user ${payload.userId}`);
    return [{ success: false, error: 'Rate limit exceeded', platform: 'web' }];
  }

  // Send notification...
}

9. Monitoring & Alerting

9.1 Delivery Metrics

Dashboard queries (daily cron or on-demand):

-- Delivery rate by platform (last 24h)
SELECT
  platform,
  COUNT(*) as total,
  ROUND(AVG(CASE WHEN status='sent' THEN 1.0 ELSE 0.0 END) * 100, 2) as success_rate
FROM notification_log
WHERE sent_at > datetime('now', '-1 day')
GROUP BY platform;

-- Opt-out rate by category
SELECT
  category,
  COUNT(*) as total_users,
  SUM(CASE WHEN enabled=0 THEN 1 ELSE 0 END) as opted_out,
  ROUND(AVG(CASE WHEN enabled=0 THEN 1.0 ELSE 0.0 END) * 100, 2) as opt_out_rate
FROM notification_preferences
GROUP BY category;

-- Top failure reasons
SELECT
  error,
  COUNT(*) as count,
  platform
FROM notification_log
WHERE status='failed' AND sent_at > datetime('now', '-7 days')
GROUP BY error, platform
ORDER BY count DESC
LIMIT 10;

9.2 Alerts (Slack/Email)

Trigger conditions:

Implementation (future):

// In src/lib/alerts.ts (from drop-supporting-systems-plan.md)
await sendSlackAlert({
  severity: 'high',
  message: `Push notification delivery rate dropped to ${rate}% (platform: ${platform})`,
  link: 'http://localhost:3030/monitoring/push',
});

10. Cost Analysis

Platform Setup Cost Operating Cost Free Tier Paid Tier
Web Push 0 kr 0 kr Unlimited (browser-managed) N/A
FCM (Android) 0 kr 0 kr Unlimited N/A
APNs (iOS) 794 kr/year (Apple Developer) 0 kr Unlimited N/A

Total Annual Cost: 794 kr (Apple Developer membership only).

Infrastructure cost: Minimal — push notification sending is lightweight (HTTP requests), no message queue or worker needed for MVP.

Post-MVP (if >100k push/day): Consider message queue (BullMQ + Redis, ~200 kr/month on Railway/Fly.io).


11. Implementation Plan

Phase 1: Web Push (MVP) — 3 days

Day 1: Backend Infrastructure (6h)

Day 2: Frontend Integration (6h)

Day 3: Integration + Testing (6h)

Total: 18 hours (3 days @ 6h/day)


Phase 2: FCM (Android) — 1 day (FUTURE)

When: React Native Android app ready for testing.

Tasks:


Phase 3: APNs (iOS) — 1 day (FUTURE)

When: React Native iOS app ready for testing.

Tasks:


Phase 4: Advanced Features — 2 days (FUTURE)

Features:


12. Testing Strategy

12.1 Unit Tests

Test files to create:

Coverage:


12.2 Integration Tests

Test files to create:

Scenarios:

  1. Register device → send notification → verify log entry
    • POST /api/notifications/register-device with Web Push subscription
    • Trigger transaction → verify notification_log entry created with status='sent'
  2. Opt-out → verify notification skipped
    • Update preferences: account notifications OFF
    • Trigger transaction → verify notification NOT sent (status='skipped' in log)
  3. Login alert → new device
    • Login from new User-Agent → verify login alert notification sent
  4. Promotional consent → verify GDPR compliance
    • Enable promotional notifications → verify notification_preferences.updated_at updated

12.3 Manual Testing Checklist


13. Success Metrics

Week 1 (Post-Deployment)

Month 1

Quarter 1


14. Rollout Strategy

Staging (1 week)

Production (Gradual)


15. Acceptance Criteria

Push Service Layer:

Database:

API Endpoints:

Integration:

Compliance:

UI:


16. Dependencies

Add to package.json:

{
  "dependencies": {
    "web-push": "^3.6.7"
  }
}

Future (when mobile apps ship):

{
  "dependencies": {
    "firebase-admin": "^12.0.0",
    "apn": "^2.2.0"
  }
}

Install:

cd ~/ALAI/products/Drop/src/drop-app
npm install web-push

17. Env Vars

Add to .env.example:

# --- Push Notifications ---

# Web Push (VAPID keys)
# Generate: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=BN...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:support@getdrop.no

# Firebase Cloud Messaging (future - Android)
# FIREBASE_SERVICE_ACCOUNT_KEY=base64-encoded-json

# Apple Push Notification service (future - iOS)
# APNS_KEY_ID=ABC123XYZ
# APNS_TEAM_ID=DEF456UVW
# APNS_KEY_CONTENT=base64-encoded-p8-file

Generate VAPID keys:

npx web-push generate-vapid-keys
# Outputs:
# Public Key: BN4GvZtEZiZuqaasbD-...
# Private Key: ...

18. File List

Files to CREATE:

src/lib/push.ts                                     # Push notification service layer
src/app/api/notifications/register-device/route.ts  # Device registration endpoint
src/app/api/notifications/devices/[deviceId]/route.ts # Device unregister endpoint
src/app/api/notifications/preferences/route.ts      # Preferences GET/PUT endpoints
src/app/api/notifications/history/route.ts          # Notification history endpoint
src/app/api/vapid-public-key/route.ts               # VAPID public key endpoint
public/sw.js                                        # Service worker (push event listener)
tests/unit/push.test.ts                             # Unit tests for push service
tests/integration/push-flow.test.ts                 # Integration tests

Files to MODIFY:

src/lib/db.ts                                       # Add device_tokens, notification_queue, notification_log, notification_preferences tables
src/lib/db.ts                                       # Add notification_type, priority, data columns to notifications table
src/lib/db.ts                                       # Add device_fingerprint column to sessions table
src/app/api/transactions/remittance/route.ts        # Add push notification on completion
src/app/api/transactions/qr-payment/route.ts        # Add push notification on completion
src/app/api/auth/login/route.ts                     # Add login alert for new devices
src/app/profile/notifications/page.tsx              # Modify to add preference toggles
src/app/layout.tsx                                  # Register service worker
.env.example                                        # Add VAPID_* env vars
package.json                                        # Add web-push dependency
src/lib/services/notifications.ts                   # DELETE (replaced by src/lib/push.ts)

Total: 9 new files, 11 modified files.


END OF SPEC


Revision #3
Created 2026-02-18 08:44:46 UTC by John
Updated 2026-05-24 20:00:47 UTC by John