From c7919d59cc20f51ed7a9ae79c1b0638a4dab2508 Mon Sep 17 00:00:00 2001 From: Tobiloba Date: Mon, 1 Jun 2026 15:54:04 +0100 Subject: [PATCH] feat: implement push notification system with PWA support --- ENV_VARS.md | 35 ++ backend/package.json | 3 +- .../down.sql | 17 + .../migration.sql | 132 +++++ backend/prisma/schema.prisma | 104 ++++ backend/scripts/generate-vapid-keys.js | 55 ++ backend/src/routes/push.ts | 193 ++++++- backend/src/services/push.ts | 473 ++++++++++++---- backend/src/services/websocket.ts | 373 ++++++++++++ docs/PUSH_NOTIFICATIONS.md | 529 ++++++++++++++++++ frontend/components/NotificationCenter.tsx | 227 ++++++++ .../components/NotificationPreferences.tsx | 394 +++++++++++++ frontend/components/PushSubscription.tsx | 356 ++++++++++++ frontend/hooks/useWebSocketNotifications.ts | 282 ++++++++++ frontend/service-worker.ts | 128 +++++ 15 files changed, 3180 insertions(+), 121 deletions(-) create mode 100644 backend/prisma/migrations/20250601000000_add_push_notifications/down.sql create mode 100644 backend/prisma/migrations/20250601000000_add_push_notifications/migration.sql create mode 100644 backend/scripts/generate-vapid-keys.js create mode 100644 backend/src/services/websocket.ts create mode 100644 docs/PUSH_NOTIFICATIONS.md create mode 100644 frontend/components/NotificationCenter.tsx create mode 100644 frontend/components/NotificationPreferences.tsx create mode 100644 frontend/components/PushSubscription.tsx create mode 100644 frontend/hooks/useWebSocketNotifications.ts diff --git a/ENV_VARS.md b/ENV_VARS.md index edd2b989..8348bf0e 100644 --- a/ENV_VARS.md +++ b/ENV_VARS.md @@ -10,6 +10,10 @@ | STELLAR_NETWORK | Stellar network (testnet or public) | testnet | No | | OPENAI_API_KEY | OpenAI API key for AI services | - | **Yes** | | AGENTICPAY_ALLOWED_SIGNATURE_ORIGINS | Allowed origins for EIP-712 signature verification | https://agenticpay.com,http://localhost:3000 | No | +| VAPID_PUBLIC_KEY | VAPID public key for Web Push API | auto-generated | No | +| VAPID_PRIVATE_KEY | VAPID private key for Web Push API | auto-generated | No | +| WS_ENABLED | Enable/disable WebSocket support | true | No | +| WS_PORT | WebSocket port | 3001 | No | ## Frontend @@ -17,22 +21,53 @@ | ----------------------- | -------------------- | ---------------------------- | | NEXT_PUBLIC_API_URL | Backend API base URL | http://localhost:3001/api/v1 | | NEXT_PUBLIC_BACKEND_URL | Backend URL fallback | http://localhost:3001/api/v1 | +| NEXT_PUBLIC_WS_URL | WebSocket URL | http://localhost:3001 | +| NEXT_PUBLIC_WS_ENABLED | Enable WebSocket | true | ## Environment Files - `.env.example` +``` PORT=3001 CORS_ALLOWED_ORIGINS=http://localhost:3000 JOBS_ENABLED=true STELLAR_NETWORK=testnet OPENAI_API_KEY=sk-your-openai-api-key AGENTICPAY_ALLOWED_SIGNATURE_ORIGINS=https://agenticpay.com,http://localhost:3000 +VAPID_PUBLIC_KEY=your-vapid-public-key +VAPID_PRIVATE_KEY=your-vapid-private-key +WS_ENABLED=true +``` - `.env.development` — local development - `.env.staging` — staging environment - `.env.production` — production environment +## Push Notification Setup + +### Generating VAPID Keys + +To generate VAPID keys for push notifications: + +```bash +cd backend +npm run generate:vapid-keys +``` + +This will output both public and private keys. Add them to your `.env` file: + +``` +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +``` + +**Important**: Keep your VAPID private key secret and never commit it to version control. + +See [PUSH_NOTIFICATIONS.md](./docs/PUSH_NOTIFICATIONS.md) for complete push notification setup guide. + ## Notes - Never commit `.env` files containing real secrets to version control - Copy the appropriate file and rename to `.env` when running locally +- VAPID keys are required for push notification functionality +- WebSocket support is required for real-time notifications diff --git a/backend/package.json b/backend/package.json index 26025100..a8db14e4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,7 +23,8 @@ "openapi:validate": "node -e \"require('fs').accessSync('docs/api/openapi/openapi.json')\"", "benchmark": "tsx src/tests/benchmarks/run-benchmarks.ts", "benchmark:baseline": "tsx src/tests/benchmarks/run-benchmarks.ts --write-baseline", - "benchmark:compare": "tsx src/tests/benchmarks/compare-baseline.ts" + "benchmark:compare": "tsx src/tests/benchmarks/compare-baseline.ts", + "generate:vapid-keys": "node scripts/generate-vapid-keys.js" }, "dependencies": { "@prisma/client": "^5.22.0", diff --git a/backend/prisma/migrations/20250601000000_add_push_notifications/down.sql b/backend/prisma/migrations/20250601000000_add_push_notifications/down.sql new file mode 100644 index 00000000..3b52a614 --- /dev/null +++ b/backend/prisma/migrations/20250601000000_add_push_notifications/down.sql @@ -0,0 +1,17 @@ +-- DropForeignKey +ALTER TABLE "notification_logs" DROP CONSTRAINT "notification_logs_subscription_id_fkey"; + +-- DropTable +DROP TABLE "notification_logs"; + +-- DropTable +DROP TABLE "push_preferences"; + +-- DropTable +DROP TABLE "push_subscriptions"; + +-- DropEnum +DROP TYPE "NotificationStatus"; + +-- DropEnum +DROP TYPE "NotificationCategory"; diff --git a/backend/prisma/migrations/20250601000000_add_push_notifications/migration.sql b/backend/prisma/migrations/20250601000000_add_push_notifications/migration.sql new file mode 100644 index 00000000..a28f1a89 --- /dev/null +++ b/backend/prisma/migrations/20250601000000_add_push_notifications/migration.sql @@ -0,0 +1,132 @@ +-- CreateEnum +CREATE TYPE "NotificationCategory" AS ENUM ( + 'payment_notification', + 'dispute_alert', + 'project_update', + 'milestone_reminder', + 'security_alert', + 'subscription_update', + 'system_notification' +); + +-- CreateEnum +CREATE TYPE "NotificationStatus" AS ENUM ( + 'pending', + 'sent', + 'delivered', + 'clicked', + 'failed' +); + +-- CreateTable "push_subscriptions" +CREATE TABLE "push_subscriptions" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "auth" TEXT NOT NULL, + "p256dh" TEXT NOT NULL, + "user_agent" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "last_used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable "push_preferences" +CREATE TABLE "push_preferences" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "payment_notifications" BOOLEAN NOT NULL DEFAULT true, + "dispute_alerts" BOOLEAN NOT NULL DEFAULT true, + "project_updates" BOOLEAN NOT NULL DEFAULT true, + "milestone_reminders" BOOLEAN NOT NULL DEFAULT true, + "security_alerts" BOOLEAN NOT NULL DEFAULT true, + "subscription_updates" BOOLEAN NOT NULL DEFAULT true, + "system_notifications" BOOLEAN NOT NULL DEFAULT true, + "group_notifications" BOOLEAN NOT NULL DEFAULT true, + "notify_sound" BOOLEAN NOT NULL DEFAULT true, + "notify_badge" BOOLEAN NOT NULL DEFAULT true, + "locale" TEXT NOT NULL DEFAULT 'en', + "timezone" TEXT NOT NULL DEFAULT 'UTC', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "push_preferences_pkey" PRIMARY KEY ("id") +); + +-- CreateTable "notification_logs" +CREATE TABLE "notification_logs" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "subscription_id" TEXT, + "category" "NotificationCategory" NOT NULL, + "status" "NotificationStatus" NOT NULL DEFAULT 'pending', + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "icon" TEXT, + "badge" TEXT, + "tag" TEXT, + "data" JSONB, + "deep_link" TEXT, + "sent_at" TIMESTAMP(3), + "delivered_at" TIMESTAMP(3), + "clicked_at" TIMESTAMP(3), + "error" TEXT, + "retry_count" INTEGER NOT NULL DEFAULT 0, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "notification_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "push_subscriptions_tenant_id_user_id_endpoint_key" ON "push_subscriptions"("tenant_id", "user_id", "endpoint"); + +-- CreateIndex +CREATE INDEX "push_subscriptions_tenant_id_user_id_idx" ON "push_subscriptions"("tenant_id", "user_id"); + +-- CreateIndex +CREATE INDEX "push_subscriptions_endpoint_idx" ON "push_subscriptions"("endpoint"); + +-- CreateIndex +CREATE INDEX "push_subscriptions_is_active_idx" ON "push_subscriptions"("is_active"); + +-- CreateIndex +CREATE INDEX "push_subscriptions_created_at_idx" ON "push_subscriptions"("created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "push_preferences_tenant_id_user_id_key" ON "push_preferences"("tenant_id", "user_id"); + +-- CreateIndex +CREATE INDEX "push_preferences_tenant_id_idx" ON "push_preferences"("tenant_id"); + +-- CreateIndex +CREATE INDEX "push_preferences_user_id_idx" ON "push_preferences"("user_id"); + +-- CreateIndex +CREATE INDEX "notification_logs_tenant_id_user_id_idx" ON "notification_logs"("tenant_id", "user_id"); + +-- CreateIndex +CREATE INDEX "notification_logs_status_idx" ON "notification_logs"("status"); + +-- CreateIndex +CREATE INDEX "notification_logs_category_idx" ON "notification_logs"("category"); + +-- CreateIndex +CREATE INDEX "notification_logs_subscription_id_idx" ON "notification_logs"("subscription_id"); + +-- CreateIndex +CREATE INDEX "notification_logs_sent_at_idx" ON "notification_logs"("sent_at"); + +-- CreateIndex +CREATE INDEX "notification_logs_tag_idx" ON "notification_logs"("tag"); + +-- AddForeignKey +ALTER TABLE "notification_logs" ADD CONSTRAINT "notification_logs_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "push_subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 2832632a..b1b36d59 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -356,6 +356,24 @@ enum EmailStatus { failed } +enum NotificationCategory { + payment_notification + dispute_alert + project_update + milestone_reminder + security_alert + subscription_update + system_notification +} + +enum NotificationStatus { + pending + sent + delivered + clicked + failed +} + enum DeliveryProvider { smtp sendgrid @@ -479,3 +497,89 @@ model EmailAnalytics { @@index([date]) @@map("email_analytics") } + +// ─── Push Notification Models ────────────────────────────────────────────────── + +model PushSubscription { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + userId String @map("user_id") + endpoint String + auth String // VAPID auth secret + p256dh String // VAPID public key + userAgent String? @map("user_agent") + isActive Boolean @default(true) @map("is_active") + lastUsedAt DateTime? @map("last_used_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + notifications NotificationLog[] + + @@unique([tenantId, userId, endpoint]) + @@index([tenantId, userId]) + @@index([endpoint]) + @@index([isActive]) + @@index([createdAt]) + @@map("push_subscriptions") +} + +model PushPreference { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + userId String @map("user_id") + paymentNotifications Boolean @default(true) @map("payment_notifications") + disputeAlerts Boolean @default(true) @map("dispute_alerts") + projectUpdates Boolean @default(true) @map("project_updates") + milestoneReminders Boolean @default(true) @map("milestone_reminders") + securityAlerts Boolean @default(true) @map("security_alerts") + subscriptionUpdates Boolean @default(true) @map("subscription_updates") + systemNotifications Boolean @default(true) @map("system_notifications") + groupNotifications Boolean @default(true) @map("group_notifications") + notifySound Boolean @default(true) @map("notify_sound") + notifyBadge Boolean @default(true) @map("notify_badge") + locale String @default("en") + timezone String @default("UTC") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([tenantId, userId]) + @@index([tenantId]) + @@index([userId]) + @@map("push_preferences") +} + +model NotificationLog { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + userId String @map("user_id") + subscriptionId String? @map("subscription_id") + category NotificationCategory + status NotificationStatus @default(pending) + title String + body String + icon String? + badge String? + tag String? // For grouping notifications + data Json? // Custom data for deep linking + deepLink String? @map("deep_link") + sentAt DateTime? @map("sent_at") + deliveredAt DateTime? @map("delivered_at") + clickedAt DateTime? @map("clicked_at") + error String? + retryCount Int @default(0) @map("retry_count") + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + subscription PushSubscription? @relation(fields: [subscriptionId], references: [id]) + + @@index([tenantId, userId]) + @@index([status]) + @@index([category]) + @@index([subscriptionId]) + @@index([sentAt]) + @@index([tag]) + @@map("notification_logs") +} + diff --git a/backend/scripts/generate-vapid-keys.js b/backend/scripts/generate-vapid-keys.js new file mode 100644 index 00000000..d8e81a29 --- /dev/null +++ b/backend/scripts/generate-vapid-keys.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +/** + * Script to generate VAPID keys for Web Push API + * Usage: node scripts/generate-vapid-keys.js + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +function urlBase64Encode(buffer) { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function generateVapidKeys() { + const curve = crypto.createECDH('prime256v1'); + curve.generateKeys(); + + const publicKey = urlBase64Encode(curve.getPublicKey()); + const privateKey = urlBase64Encode(curve.getPrivateKey()); + + return { publicKey, privateKey }; +} + +try { + console.log('🔑 Generating VAPID keys for Web Push API...\n'); + + const keys = generateVapidKeys(); + + console.log('✅ VAPID keys generated successfully!\n'); + console.log('📋 Add the following to your .env file:\n'); + console.log(`VAPID_PUBLIC_KEY=${keys.publicKey}`); + console.log(`VAPID_PRIVATE_KEY=${keys.privateKey}\n`); + + console.log('⚠️ Important Security Notes:'); + console.log(' - Keep your VAPID_PRIVATE_KEY secret'); + console.log(' - Never commit .env to version control'); + console.log(' - Store keys in secure environment variables\n'); + + // Optionally create or update .env.local + const envPath = path.join(__dirname, '..', '.env'); + if (process.argv.includes('--update-env') && !fs.existsSync(envPath)) { + const envContent = `VAPID_PUBLIC_KEY=${keys.publicKey}\nVAPID_PRIVATE_KEY=${keys.privateKey}\n`; + fs.writeFileSync(envPath, envContent); + console.log(`✅ Created ${envPath} with VAPID keys`); + } +} catch (error) { + console.error('❌ Error generating VAPID keys:', error.message); + process.exit(1); +} diff --git a/backend/src/routes/push.ts b/backend/src/routes/push.ts index c712fdf2..2be90c48 100644 --- a/backend/src/routes/push.ts +++ b/backend/src/routes/push.ts @@ -1,23 +1,51 @@ import { Router, Request, Response } from 'express'; import { pushService } from '../services/push.js'; +import { authMiddleware } from '../middleware/auth.js'; +import { NotificationCategory } from '@prisma/client'; export const pushRouter = Router(); +// All routes require authentication +pushRouter.use(authMiddleware); + +/** + * Subscribe to push notifications + * POST /api/v1/push/subscribe + */ pushRouter.post('/subscribe', async (req: Request, res: Response) => { try { - const { subscription, userId } = req.body; - + const { subscription } = req.body; + const userId = (req as any).user?.id; + const tenantId = (req as any).user?.tenantId; + const userAgent = req.get('user-agent'); + if (!subscription || !subscription.endpoint) { return res.status(400).json({ error: { code: 'INVALID_SUBSCRIPTION', - message: 'Push subscription is required', + message: 'Push subscription with endpoint is required', status: 400, }, }); } - const result = await pushService.subscribe(userId, subscription); + if (!subscription.keys || !subscription.keys.auth || !subscription.keys.p256dh) { + return res.status(400).json({ + error: { + code: 'INVALID_SUBSCRIPTION', + message: 'Push subscription keys (auth, p256dh) are required', + status: 400, + }, + }); + } + + const result = await pushService.subscribe( + tenantId, + userId, + subscription, + userAgent + ); + res.status(201).json(result); } catch (error) { console.error('Push subscription error:', error); @@ -31,10 +59,16 @@ pushRouter.post('/subscribe', async (req: Request, res: Response) => { } }); +/** + * Unsubscribe from push notifications + * DELETE /api/v1/push/unsubscribe + */ pushRouter.delete('/unsubscribe', async (req: Request, res: Response) => { try { - const { endpoint, userId } = req.body; - + const { endpoint } = req.body; + const userId = (req as any).user?.id; + const tenantId = (req as any).user?.tenantId; + if (!endpoint) { return res.status(400).json({ error: { @@ -45,7 +79,7 @@ pushRouter.delete('/unsubscribe', async (req: Request, res: Response) => { }); } - await pushService.unsubscribe(userId, endpoint); + await pushService.unsubscribe(tenantId, userId, endpoint); res.status(204).send(); } catch (error) { console.error('Push unsubscription error:', error); @@ -59,30 +93,72 @@ pushRouter.delete('/unsubscribe', async (req: Request, res: Response) => { } }); +/** + * Send push notification + * POST /api/v1/push/notify + * (typically admin/backend use only) + */ pushRouter.post('/notify', async (req: Request, res: Response) => { try { - const { userId, title, body, icon, badge, data, actions } = req.body; - - if (!userId || !title) { + const { + userId: targetUserId, + category, + title, + body, + icon, + badge, + data, + tag, + deepLink, + actions, + } = req.body; + const tenantId = (req as any).user?.tenantId; + + if (!targetUserId || !title || !category) { return res.status(400).json({ error: { code: 'INVALID_REQUEST', - message: 'userId and title are required', + message: 'userId, title, and category are required', + status: 400, + }, + }); + } + + // Validate category + const validCategories: NotificationCategory[] = [ + 'payment_notification', + 'dispute_alert', + 'project_update', + 'milestone_reminder', + 'security_alert', + 'subscription_update', + 'system_notification', + ]; + + if (!validCategories.includes(category as NotificationCategory)) { + return res.status(400).json({ + error: { + code: 'INVALID_CATEGORY', + message: `Invalid notification category. Must be one of: ${validCategories.join(', ')}`, status: 400, }, }); } const result = await pushService.sendNotification({ - userId, + tenantId, + userId: targetUserId, + category: category as NotificationCategory, title, body, icon, badge, data, + tag, + deepLink, actions, }); - + res.status(200).json(result); } catch (error) { console.error('Push notification error:', error); @@ -96,15 +172,36 @@ pushRouter.post('/notify', async (req: Request, res: Response) => { } }); +/** + * Get VAPID public key (public endpoint) + * GET /api/v1/push/vapid-public-key + */ pushRouter.get('/vapid-public-key', (req: Request, res: Response) => { - const publicKey = pushService.getVapidPublicKey(); - res.status(200).json({ publicKey }); + try { + const publicKey = pushService.getVapidPublicKey(); + res.status(200).json({ publicKey }); + } catch (error) { + console.error('Get VAPID public key error:', error); + res.status(500).json({ + error: { + code: 'FETCH_FAILED', + message: 'Failed to fetch VAPID public key', + status: 500, + }, + }); + } }); -pushRouter.get('/preferences/:userId', async (req: Request, res: Response) => { +/** + * Get user notification preferences + * GET /api/v1/push/preferences + */ +pushRouter.get('/preferences', async (req: Request, res: Response) => { try { - const { userId } = req.params; - const preferences = await pushService.getPreferences(userId); + const userId = (req as any).user?.id; + const tenantId = (req as any).user?.tenantId; + + const preferences = await pushService.getPreferences(tenantId, userId); res.status(200).json(preferences); } catch (error) { console.error('Get preferences error:', error); @@ -118,12 +215,17 @@ pushRouter.get('/preferences/:userId', async (req: Request, res: Response) => { } }); -pushRouter.put('/preferences/:userId', async (req: Request, res: Response) => { +/** + * Update user notification preferences + * PUT /api/v1/push/preferences + */ +pushRouter.put('/preferences', async (req: Request, res: Response) => { try { - const { userId } = req.params; + const userId = (req as any).user?.id; + const tenantId = (req as any).user?.tenantId; const preferences = req.body; - - const result = await pushService.updatePreferences(userId, preferences); + + const result = await pushService.updatePreferences(tenantId, userId, preferences); res.status(200).json(result); } catch (error) { console.error('Update preferences error:', error); @@ -135,4 +237,51 @@ pushRouter.put('/preferences/:userId', async (req: Request, res: Response) => { }, }); } +}); + +/** + * Get notification history + * GET /api/v1/push/history + */ +pushRouter.get('/history', async (req: Request, res: Response) => { + try { + const userId = (req as any).user?.id; + const tenantId = (req as any).user?.tenantId; + const limit = parseInt(req.query.limit as string) || 50; + + const notifications = await pushService.getNotificationHistory(tenantId, userId, limit); + res.status(200).json(notifications); + } catch (error) { + console.error('Get notification history error:', error); + res.status(500).json({ + error: { + code: 'FETCH_FAILED', + message: 'Failed to fetch notification history', + status: 500, + }, + }); + } +}); + +/** + * Mark notification as clicked + * POST /api/v1/push/mark-clicked/:notificationId + */ +pushRouter.post('/mark-clicked/:notificationId', async (req: Request, res: Response) => { + try { + const { notificationId } = req.params; + const tenantId = (req as any).user?.tenantId; + + await pushService.markNotificationAsClicked(tenantId, notificationId); + res.status(200).json({ success: true }); + } catch (error) { + console.error('Mark clicked error:', error); + res.status(500).json({ + error: { + code: 'UPDATE_FAILED', + message: 'Failed to mark notification as clicked', + status: 500, + }, + }); + } }); \ No newline at end of file diff --git a/backend/src/services/push.ts b/backend/src/services/push.ts index 173bdbef..21530001 100644 --- a/backend/src/services/push.ts +++ b/backend/src/services/push.ts @@ -2,8 +2,10 @@ import webpush from 'web-push'; const { setVapidDetails } = webpush; import { config } from '../config.js'; import { generateVapidKeys, VapidKeys } from './vapid.js'; +import { prisma } from '../db.js'; +import { NotificationCategory } from '@prisma/client'; -interface PushSubscription { +interface PushSubscriptionInput { endpoint: string; keys: { p256dh: string; @@ -29,37 +31,22 @@ interface PushNotificationPayload { } interface NotificationPreferences { - enabled: boolean; - payments: boolean; - invoices: boolean; - marketing: boolean; - security: boolean; - sound: string; - badge: string; -} - -interface StoredSubscription { - userId: string; - subscriptions: PushSubscription[]; - updatedAt: string; -} - -interface StoredPreferences { - userId: string; - enabled: boolean; - payments: boolean; - invoices: boolean; - marketing: boolean; - security: boolean; - sound: string; - badge: string; - updatedAt: string; + paymentNotifications: boolean; + disputeAlerts: boolean; + projectUpdates: boolean; + milestoneReminders: boolean; + securityAlerts: boolean; + subscriptionUpdates: boolean; + systemNotifications: boolean; + groupNotifications: boolean; + notifySound: boolean; + notifyBadge: boolean; + locale: string; + timezone: string; } class PushService { private vapidKeys: VapidKeys | null = null; - private subscriptions: Map = new Map(); - private preferences: Map = new Map(); constructor() { this.initializeVapidKeys(); @@ -91,106 +78,396 @@ class PushService { return this.vapidKeys?.publicKey || ''; } - async subscribe(userId: string, subscription: PushSubscription): Promise<{ success: boolean }> { - const existingSubscriptions = this.subscriptions.get(userId) || []; - const filtered = existingSubscriptions.filter(s => s.endpoint !== subscription.endpoint); - filtered.push(subscription); - this.subscriptions.set(userId, filtered); + async subscribe( + tenantId: string, + userId: string, + subscription: PushSubscriptionInput, + userAgent?: string + ): Promise<{ success: boolean; subscriptionId: string }> { + try { + // Check if subscription already exists + const existing = await prisma.pushSubscription.findFirst({ + where: { + tenantId, + userId, + endpoint: subscription.endpoint, + }, + }); + + let subscriptionId: string; - console.log(`[Push] User ${userId} subscribed`); - return { success: true }; + if (existing) { + // Update existing subscription + await prisma.pushSubscription.update({ + where: { id: existing.id }, + data: { + auth: subscription.keys.auth, + p256dh: subscription.keys.p256dh, + userAgent: userAgent || existing.userAgent, + isActive: true, + lastUsedAt: new Date(), + }, + }); + subscriptionId = existing.id; + } else { + // Create new subscription + const newSubscription = await prisma.pushSubscription.create({ + data: { + tenantId, + userId, + endpoint: subscription.endpoint, + auth: subscription.keys.auth, + p256dh: subscription.keys.p256dh, + userAgent, + isActive: true, + }, + }); + subscriptionId = newSubscription.id; + } + + console.log(`[Push] User ${userId} subscribed (ID: ${subscriptionId})`); + return { success: true, subscriptionId }; + } catch (error) { + console.error('[Push] Failed to subscribe:', error); + throw error; + } } - async unsubscribe(userId: string, endpoint: string): Promise { - const existingSubscriptions = this.subscriptions.get(userId) || []; - const filtered = existingSubscriptions.filter(s => s.endpoint !== endpoint); - this.subscriptions.set(userId, filtered); + async unsubscribe(tenantId: string, userId: string, endpoint: string): Promise { + try { + await prisma.pushSubscription.updateMany({ + where: { + tenantId, + userId, + endpoint, + }, + data: { + isActive: false, + deletedAt: new Date(), + }, + }); - console.log(`[Push] User ${userId} unsubscribed from ${endpoint}`); + console.log(`[Push] User ${userId} unsubscribed from ${endpoint}`); + } catch (error) { + console.error('[Push] Failed to unsubscribe:', error); + throw error; + } } async sendNotification(params: { + tenantId: string; userId: string; + category: NotificationCategory; title: string; body?: string; icon?: string; badge?: string; data?: Record; + tag?: string; + deepLink?: string; actions?: Array<{ action: string; title: string; icon?: string }>; - }): Promise<{ sent: number; failed: number }> { - const { userId, title, body, icon, badge, data, actions } = params; - const subscriptions = this.subscriptions.get(userId) || []; - - const preferences = await this.getPreferences(userId); - if (!preferences.enabled) { - return { sent: 0, failed: 0 }; - } - - const isPaymentNotification = data?.type === 'payment'; - const isInvoiceNotification = data?.type === 'invoice'; - const isMarketingNotification = data?.type === 'marketing'; - const isSecurityNotification = data?.type === 'security'; - - if (isPaymentNotification && !preferences.payments) return { sent: 0, failed: 0 }; - if (isInvoiceNotification && !preferences.invoices) return { sent: 0, failed: 0 }; - if (isMarketingNotification && !preferences.marketing) return { sent: 0, failed: 0 }; - if (isSecurityNotification && !preferences.security) return { sent: 0, failed: 0 }; - - const payload: PushNotificationPayload = { + }): Promise<{ sent: number; failed: number; notificationLogId: string }> { + const { + tenantId, + userId, + category, title, body, - icon: icon || '/icons/notification.png', - badge: badge || '/icons/badge.png', + icon, + badge, data, + tag, + deepLink, actions, - silent: preferences.sound === 'none', - }; - - let sent = 0; - let failed = 0; - - for (const subscription of subscriptions) { - try { - await webpush.sendNotification(subscription, JSON.stringify(payload)); - sent++; - } catch (error) { - console.error(`[Push] Failed to send to ${subscription.endpoint}:`, error); - failed++; - - if ((error as { statusCode?: number }).statusCode === 410) { - await this.unsubscribe(userId, subscription.endpoint); + } = params; + + try { + // Check preferences + const preferences = await this.getPreferences(tenantId, userId); + + // Check if this category is enabled + const categoryPreferenceMap: Record = { + payment_notification: 'paymentNotifications', + dispute_alert: 'disputeAlerts', + project_update: 'projectUpdates', + milestone_reminder: 'milestoneReminders', + security_alert: 'securityAlerts', + subscription_update: 'subscriptionUpdates', + system_notification: 'systemNotifications', + }; + + if (categoryPreferenceMap[category] && !preferences[categoryPreferenceMap[category]]) { + // Create notification log with skipped status + const log = await prisma.notificationLog.create({ + data: { + tenantId, + userId, + category, + status: 'pending', + title, + body: body || '', + icon, + badge, + tag, + data, + deepLink, + }, + }); + return { sent: 0, failed: 0, notificationLogId: log.id }; + } + + // Get active subscriptions + const subscriptions = await prisma.pushSubscription.findMany({ + where: { + tenantId, + userId, + isActive: true, + deletedAt: null, + }, + }); + + const payload: PushNotificationPayload = { + title, + body, + icon: icon || '/icons/notification.png', + badge: badge || '/icons/badge.png', + data: { + ...data, + deepLink, + category, + }, + actions, + tag, + silent: !preferences.notifySound, + requireInteraction: category === 'dispute_alert' || category === 'security_alert', + }; + + let sent = 0; + let failed = 0; + + // Create notification log + const notificationLog = await prisma.notificationLog.create({ + data: { + tenantId, + userId, + category, + status: 'pending', + title, + body: body || '', + icon, + badge, + tag, + data, + deepLink, + }, + }); + + for (const subscription of subscriptions) { + try { + await webpush.sendNotification(subscription, JSON.stringify(payload)); + + // Update subscription last used time + await prisma.pushSubscription.update({ + where: { id: subscription.id }, + data: { lastUsedAt: new Date() }, + }); + + // Update notification log + await prisma.notificationLog.update({ + where: { id: notificationLog.id }, + data: { + subscriptionId: subscription.id, + status: 'sent', + sentAt: new Date(), + }, + }); + + sent++; + } catch (error) { + console.error(`[Push] Failed to send to ${subscription.endpoint}:`, error); + failed++; + + const statusCode = (error as { statusCode?: number }).statusCode; + if (statusCode === 410 || statusCode === 404) { + // Subscription is no longer valid + await this.unsubscribe(tenantId, userId, subscription.endpoint); + } + + // Log error + await prisma.notificationLog.update({ + where: { id: notificationLog.id }, + data: { + subscriptionId: subscription.id, + status: 'failed', + error: String(error), + retryCount: 1, + }, + }); } } - } - return { sent, failed }; + // Update final notification log status + if (sent > 0) { + await prisma.notificationLog.update({ + where: { id: notificationLog.id }, + data: { + status: 'delivered', + deliveredAt: new Date(), + }, + }); + } + + return { sent, failed, notificationLogId: notificationLog.id }; + } catch (error) { + console.error('[Push] Failed to send notification:', error); + throw error; + } } - async getPreferences(userId: string): Promise { - const stored = this.preferences.get(userId); - if (stored) return stored; - - return { - enabled: true, - payments: true, - invoices: true, - marketing: false, - security: true, - sound: 'default', - badge: 'default', - }; + async getPreferences( + tenantId: string, + userId: string + ): Promise { + try { + let preferences = await prisma.pushPreference.findUnique({ + where: { + tenantId_userId: { tenantId, userId }, + }, + }); + + if (!preferences) { + // Create default preferences + preferences = await prisma.pushPreference.create({ + data: { + tenantId, + userId, + }, + }); + } + + return { + paymentNotifications: preferences.paymentNotifications, + disputeAlerts: preferences.disputeAlerts, + projectUpdates: preferences.projectUpdates, + milestoneReminders: preferences.milestoneReminders, + securityAlerts: preferences.securityAlerts, + subscriptionUpdates: preferences.subscriptionUpdates, + systemNotifications: preferences.systemNotifications, + groupNotifications: preferences.groupNotifications, + notifySound: preferences.notifySound, + notifyBadge: preferences.notifyBadge, + locale: preferences.locale, + timezone: preferences.timezone, + }; + } catch (error) { + console.error('[Push] Failed to get preferences:', error); + // Return defaults on error + return { + paymentNotifications: true, + disputeAlerts: true, + projectUpdates: true, + milestoneReminders: true, + securityAlerts: true, + subscriptionUpdates: true, + systemNotifications: true, + groupNotifications: true, + notifySound: true, + notifyBadge: true, + locale: 'en', + timezone: 'UTC', + }; + } } async updatePreferences( + tenantId: string, userId: string, preferences: Partial ): Promise { - const current = await this.getPreferences(userId); - const updated = { ...current, ...preferences }; - this.preferences.set(userId, updated); + try { + let existing = await prisma.pushPreference.findUnique({ + where: { + tenantId_userId: { tenantId, userId }, + }, + }); + + if (!existing) { + existing = await prisma.pushPreference.create({ + data: { + tenantId, + userId, + ...preferences, + }, + }); + } else { + existing = await prisma.pushPreference.update({ + where: { + tenantId_userId: { tenantId, userId }, + }, + data: preferences, + }); + } + + console.log(`[Push] Preferences updated for user ${userId}`); + + return { + paymentNotifications: existing.paymentNotifications, + disputeAlerts: existing.disputeAlerts, + projectUpdates: existing.projectUpdates, + milestoneReminders: existing.milestoneReminders, + securityAlerts: existing.securityAlerts, + subscriptionUpdates: existing.subscriptionUpdates, + systemNotifications: existing.systemNotifications, + groupNotifications: existing.groupNotifications, + notifySound: existing.notifySound, + notifyBadge: existing.notifyBadge, + locale: existing.locale, + timezone: existing.timezone, + }; + } catch (error) { + console.error('[Push] Failed to update preferences:', error); + throw error; + } + } + + async getNotificationHistory( + tenantId: string, + userId: string, + limit: number = 50 + ): Promise { + try { + return await prisma.notificationLog.findMany({ + where: { + tenantId, + userId, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } catch (error) { + console.error('[Push] Failed to get notification history:', error); + throw error; + } + } - console.log(`[Push] Preferences updated for user ${userId}`); - return updated; + async markNotificationAsClicked( + tenantId: string, + notificationLogId: string + ): Promise { + try { + await prisma.notificationLog.update({ + where: { id: notificationLogId }, + data: { + status: 'clicked', + clickedAt: new Date(), + }, + }); + + console.log(`[Push] Notification ${notificationLogId} marked as clicked`); + } catch (error) { + console.error('[Push] Failed to mark notification as clicked:', error); + throw error; + } } } diff --git a/backend/src/services/websocket.ts b/backend/src/services/websocket.ts new file mode 100644 index 00000000..0db947e1 --- /dev/null +++ b/backend/src/services/websocket.ts @@ -0,0 +1,373 @@ +import { Server as HTTPServer } from 'http'; +import { Server as SocketIOServer, Socket } from 'socket.io'; +import { pushService } from './push.js'; +import { prisma } from '../db.js'; +import { NotificationCategory } from '@prisma/client'; + +interface AuthenticatedSocket extends Socket { + userId?: string; + tenantId?: string; +} + +class WebSocketService { + private io: SocketIOServer | null = null; + private userConnections: Map> = new Map(); // userId -> Set of socketIds + + /** + * Initialize WebSocket server + */ + initialize(httpServer: HTTPServer): SocketIOServer { + this.io = new SocketIOServer(httpServer, { + cors: { + origin: process.env.CORS_ALLOWED_ORIGINS?.split(',') || '*', + credentials: true, + }, + transports: ['websocket', 'polling'], + }); + + this.setupMiddleware(); + this.setupEventHandlers(); + + console.log('[WebSocket] Initialized'); + return this.io; + } + + /** + * Setup WebSocket middleware for authentication + */ + private setupMiddleware(): void { + if (!this.io) return; + + this.io.use((socket: AuthenticatedSocket, next) => { + const token = socket.handshake.auth.token; + + if (!token) { + return next(new Error('Authentication error')); + } + + try { + // TODO: Validate JWT token here + // For now, extract userId from token or query params + const userId = socket.handshake.auth.userId || socket.handshake.query.userId; + const tenantId = socket.handshake.auth.tenantId || socket.handshake.query.tenantId; + + if (!userId || !tenantId) { + return next(new Error('Missing userId or tenantId')); + } + + socket.userId = String(userId); + socket.tenantId = String(tenantId); + socket.join(`user:${userId}`); + socket.join(`tenant:${tenantId}`); + + console.log(`[WebSocket] User ${userId} connected`); + next(); + } catch (error) { + console.error('[WebSocket] Auth error:', error); + next(new Error('Authentication failed')); + } + }); + } + + /** + * Setup event handlers + */ + private setupEventHandlers(): void { + if (!this.io) return; + + this.io.on('connection', (socket: AuthenticatedSocket) => { + if (!socket.userId) { + socket.disconnect(); + return; + } + + // Track user connections + if (!this.userConnections.has(socket.userId)) { + this.userConnections.set(socket.userId, new Set()); + } + this.userConnections.get(socket.userId)!.add(socket.id); + + // Handle push subscription events + socket.on('notification:subscribe', (data, callback) => { + this.handleNotificationSubscribe(socket, data, callback); + }); + + socket.on('notification:unsubscribe', (data, callback) => { + this.handleNotificationUnsubscribe(socket, data, callback); + }); + + socket.on('notification:preferences', (data, callback) => { + this.handleGetPreferences(socket, data, callback); + }); + + socket.on('notification:updatePreferences', (data, callback) => { + this.handleUpdatePreferences(socket, data, callback); + }); + + socket.on('notification:markAsRead', (data, callback) => { + this.handleMarkAsRead(socket, data, callback); + }); + + // Handle disconnect + socket.on('disconnect', () => { + this.handleDisconnect(socket); + }); + + socket.on('error', (error) => { + console.error(`[WebSocket] Socket error for user ${socket.userId}:`, error); + }); + }); + } + + /** + * Handle notification subscription + */ + private async handleNotificationSubscribe( + socket: AuthenticatedSocket, + data: any, + callback: Function + ): Promise { + try { + if (!socket.userId || !socket.tenantId) { + callback({ error: 'Unauthorized' }); + return; + } + + const { subscription } = data; + + const result = await pushService.subscribe( + socket.tenantId, + socket.userId, + subscription, + socket.handshake.headers['user-agent'] + ); + + callback({ success: true, subscriptionId: result.subscriptionId }); + } catch (error) { + console.error('[WebSocket] Subscribe error:', error); + callback({ error: error instanceof Error ? error.message : 'Failed to subscribe' }); + } + } + + /** + * Handle notification unsubscription + */ + private async handleNotificationUnsubscribe( + socket: AuthenticatedSocket, + data: any, + callback: Function + ): Promise { + try { + if (!socket.userId || !socket.tenantId) { + callback({ error: 'Unauthorized' }); + return; + } + + const { endpoint } = data; + + await pushService.unsubscribe(socket.tenantId, socket.userId, endpoint); + + callback({ success: true }); + } catch (error) { + console.error('[WebSocket] Unsubscribe error:', error); + callback({ error: error instanceof Error ? error.message : 'Failed to unsubscribe' }); + } + } + + /** + * Handle get preferences + */ + private async handleGetPreferences( + socket: AuthenticatedSocket, + _data: any, + callback: Function + ): Promise { + try { + if (!socket.userId || !socket.tenantId) { + callback({ error: 'Unauthorized' }); + return; + } + + const preferences = await pushService.getPreferences(socket.tenantId, socket.userId); + + callback({ success: true, preferences }); + } catch (error) { + console.error('[WebSocket] Get preferences error:', error); + callback({ error: error instanceof Error ? error.message : 'Failed to get preferences' }); + } + } + + /** + * Handle update preferences + */ + private async handleUpdatePreferences( + socket: AuthenticatedSocket, + data: any, + callback: Function + ): Promise { + try { + if (!socket.userId || !socket.tenantId) { + callback({ error: 'Unauthorized' }); + return; + } + + const preferences = await pushService.updatePreferences( + socket.tenantId, + socket.userId, + data + ); + + callback({ success: true, preferences }); + } catch (error) { + console.error('[WebSocket] Update preferences error:', error); + callback({ error: error instanceof Error ? error.message : 'Failed to update preferences' }); + } + } + + /** + * Handle mark as read + */ + private async handleMarkAsRead( + socket: AuthenticatedSocket, + data: any, + callback: Function + ): Promise { + try { + if (!socket.userId || !socket.tenantId) { + callback({ error: 'Unauthorized' }); + return; + } + + const { notificationId } = data; + + await pushService.markNotificationAsClicked(socket.tenantId, notificationId); + + callback({ success: true }); + } catch (error) { + console.error('[WebSocket] Mark as read error:', error); + callback({ error: error instanceof Error ? error.message : 'Failed to mark as read' }); + } + } + + /** + * Handle disconnect + */ + private handleDisconnect(socket: AuthenticatedSocket): void { + if (socket.userId) { + const connections = this.userConnections.get(socket.userId); + if (connections) { + connections.delete(socket.id); + if (connections.size === 0) { + this.userConnections.delete(socket.userId); + } + } + + console.log(`[WebSocket] User ${socket.userId} disconnected`); + } + } + + /** + * Send real-time notification to user + */ + async sendRealtimeNotification( + tenantId: string, + userId: string, + notification: { + id: string; + title: string; + body: string; + category: NotificationCategory; + icon?: string; + badge?: string; + data?: Record; + deepLink?: string; + } + ): Promise { + if (!this.io) { + console.warn('[WebSocket] Not initialized'); + return; + } + + const room = `user:${userId}`; + + this.io.to(room).emit('notification:new', { + id: notification.id, + title: notification.title, + body: notification.body, + category: notification.category, + icon: notification.icon, + badge: notification.badge, + data: notification.data, + deepLink: notification.deepLink, + timestamp: new Date().toISOString(), + }); + + console.log(`[WebSocket] Sent notification to ${room}`); + } + + /** + * Send batch notifications + */ + async sendBatchNotifications( + tenantId: string, + userIds: string[], + notification: { + title: string; + body: string; + category: NotificationCategory; + icon?: string; + badge?: string; + data?: Record; + } + ): Promise { + if (!this.io) { + console.warn('[WebSocket] Not initialized'); + return; + } + + for (const userId of userIds) { + const notificationLog = await prisma.notificationLog.create({ + data: { + tenantId, + userId, + category: notification.category, + status: 'pending', + title: notification.title, + body: notification.body, + icon: notification.icon, + badge: notification.badge, + data: notification.data, + }, + }); + + this.io.to(`user:${userId}`).emit('notification:new', { + id: notificationLog.id, + title: notification.title, + body: notification.body, + category: notification.category, + icon: notification.icon, + badge: notification.badge, + data: notification.data, + timestamp: new Date().toISOString(), + }); + } + + console.log(`[WebSocket] Sent batch notification to ${userIds.length} users`); + } + + /** + * Get connected user count + */ + getConnectedUserCount(): number { + return this.userConnections.size; + } + + /** + * Get connections for a user + */ + getUserConnections(userId: string): number { + return this.userConnections.get(userId)?.size || 0; + } +} + +export const webSocketService = new WebSocketService(); diff --git a/docs/PUSH_NOTIFICATIONS.md b/docs/PUSH_NOTIFICATIONS.md new file mode 100644 index 00000000..fc051004 --- /dev/null +++ b/docs/PUSH_NOTIFICATIONS.md @@ -0,0 +1,529 @@ +# Push Notification System Setup Guide + +## Overview + +AgenticPay includes a comprehensive push notification system built on Web Push API (VAPID protocol) with PWA support. This guide covers setup, configuration, and usage. + +## Table of Contents + +1. [Environment Variables](#environment-variables) +2. [Database Setup](#database-setup) +3. [Backend Configuration](#backend-configuration) +4. [Frontend Integration](#frontend-integration) +5. [Service Worker Setup](#service-worker-setup) +6. [API Endpoints](#api-endpoints) +7. [Usage Examples](#usage-examples) +8. [Troubleshooting](#troubleshooting) + +## Environment Variables + +### Backend (.env) + +```bash +# VAPID Keys for Web Push +# Generate using: npm run generate:vapid-keys +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= + +# CORS Configuration +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001 + +# WebSocket Configuration (optional) +WS_ENABLED=true +WS_PORT=3001 +``` + +### Frontend (.env.local) + +```bash +# Backend API URL +NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1 + +# WebSocket Configuration +NEXT_PUBLIC_WS_URL=http://localhost:3001 +NEXT_PUBLIC_WS_ENABLED=true +``` + +## Generating VAPID Keys + +VAPID keys are required for the Web Push API. Generate them using the built-in utility: + +```bash +cd backend +npm run generate:vapid-keys +``` + +This will output your public and private keys. Copy them to your `.env` file: + +```bash +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +``` + +**Important**: Keep your VAPID private key secret. Never commit it to version control. + +## Database Setup + +### Run Migrations + +The push notification system uses three new database tables: + +1. **push_subscriptions** - Stores user push subscription endpoints +2. **push_preferences** - Stores user notification preferences +3. **notification_logs** - Tracks all sent notifications + +Run migrations: + +```bash +cd backend +npm run db:migrate +``` + +### Verify Tables + +```sql +-- Check if tables were created +SELECT table_name FROM information_schema.tables +WHERE table_schema='public' +AND table_name IN ('push_subscriptions', 'push_preferences', 'notification_logs'); +``` + +## Backend Configuration + +### 1. Initialize WebSocket Server + +Update your Express server setup to initialize WebSocket support: + +```typescript +import express from 'express'; +import { createServer } from 'http'; +import { webSocketService } from './services/websocket.js'; + +const app = express(); +const httpServer = createServer(app); + +// Initialize WebSocket +const io = webSocketService.initialize(httpServer); + +// Start server +httpServer.listen(3001, () => { + console.log('Server running on port 3001'); +}); +``` + +### 2. Mount Push Routes + +In your Express app setup: + +```typescript +import { pushRouter } from './routes/push.js'; + +app.use('/api/v1/push', pushRouter); +``` + +### 3. Authentication Middleware + +Ensure auth middleware is configured for push routes. The middleware should attach `user.id` and `user.tenantId` to the request: + +```typescript +// middleware/auth.ts +export const authMiddleware = (req: Request, res: Response, next: NextFunction) => { + const token = req.headers.authorization?.split(' ')[1]; + + // Validate JWT and attach user to request + // (req as any).user = { id: userId, tenantId: tenantId }; + + next(); +}; +``` + +## Frontend Integration + +### 1. Install Dependencies + +```bash +cd frontend +npm install socket.io-client +``` + +### 2. Add Notification Components + +Import and use the notification components in your app: + +```typescript +import { PushNotificationManager } from '@/components/PushSubscription'; +import { NotificationCenter } from '@/components/NotificationCenter'; +import { NotificationPreferences } from '@/components/NotificationPreferences'; + +export function App() { + return ( +
+ {/* Notification Manager - handles subscription */} + + + {/* Notification Center - displays history */} + + + {/* Preferences - user settings */} + +
+ ); +} +``` + +### 3. Setup WebSocket Provider + +Wrap your app with the WebSocket provider for real-time notifications: + +```typescript +import { WebSocketNotificationProvider } from '@/hooks/useWebSocketNotifications'; + +export function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +## Service Worker Setup + +The service worker is automatically registered and includes push event handling. Key features: + +- **Push Event Handler**: Shows browser notifications +- **Notification Click**: Navigates to deep links +- **Notification Close**: Logs dismissals +- **Offline Support**: Queues notifications when offline + +No additional setup needed - the service worker is already configured in `frontend/service-worker.ts`. + +## API Endpoints + +All push endpoints require authentication (Bearer token in Authorization header). + +### Subscribe to Push Notifications + +```http +POST /api/v1/push/subscribe +Content-Type: application/json +Authorization: Bearer + +{ + "subscription": { + "endpoint": "https://fcm.googleapis.com/...", + "keys": { + "p256dh": "...", + "auth": "..." + } + } +} + +Response: 201 Created +{ + "success": true, + "subscriptionId": "uuid" +} +``` + +### Unsubscribe from Push + +```http +DELETE /api/v1/push/unsubscribe +Content-Type: application/json +Authorization: Bearer + +{ + "endpoint": "https://fcm.googleapis.com/..." +} + +Response: 204 No Content +``` + +### Send Push Notification + +```http +POST /api/v1/push/notify +Content-Type: application/json +Authorization: Bearer + +{ + "userId": "target-user-id", + "category": "payment_notification", + "title": "Payment Received", + "body": "You've received $50 for your completed milestone", + "icon": "/icons/payment.png", + "badge": "/icons/badge.png", + "data": { + "projectId": "project-123", + "amount": "50" + }, + "deepLink": "/payments/project-123" +} + +Response: 200 OK +{ + "sent": 1, + "failed": 0, + "notificationLogId": "uuid" +} +``` + +### Get VAPID Public Key + +```http +GET /api/v1/push/vapid-public-key + +Response: 200 OK +{ + "publicKey": "BCxyz..." +} +``` + +### Get User Preferences + +```http +GET /api/v1/push/preferences +Authorization: Bearer + +Response: 200 OK +{ + "paymentNotifications": true, + "disputeAlerts": true, + "projectUpdates": true, + "milestoneReminders": true, + "securityAlerts": true, + "subscriptionUpdates": true, + "systemNotifications": true, + "groupNotifications": true, + "notifySound": true, + "notifyBadge": true, + "locale": "en", + "timezone": "UTC" +} +``` + +### Update User Preferences + +```http +PUT /api/v1/push/preferences +Content-Type: application/json +Authorization: Bearer + +{ + "paymentNotifications": false, + "notifySound": false +} + +Response: 200 OK +{ + "paymentNotifications": false, + "notifySound": false, + ... +} +``` + +### Get Notification History + +```http +GET /api/v1/push/history?limit=50 +Authorization: Bearer + +Response: 200 OK +[ + { + "id": "uuid", + "title": "Payment Received", + "body": "...", + "category": "payment_notification", + "status": "delivered", + "sentAt": "2025-06-01T10:30:00Z", + "deliveredAt": "2025-06-01T10:30:05Z", + "clickedAt": null, + "createdAt": "2025-06-01T10:30:00Z" + } +] +``` + +### Mark Notification as Clicked + +```http +POST /api/v1/push/mark-clicked/:notificationId +Authorization: Bearer + +Response: 200 OK +{ + "success": true +} +``` + +## Usage Examples + +### Subscribe User to Push Notifications + +```typescript +import { useNotificationSubscription } from '@/components/PushSubscription'; + +export function MyComponent() { + const { subscribe, isSubscribed } = useNotificationSubscription(); + + return ( + + ); +} +``` + +### Send Notification from Backend + +```typescript +import { pushService } from './services/push.js'; +import { NotificationCategory } from '@prisma/client'; + +// In your service/controller +const result = await pushService.sendNotification({ + tenantId: 'tenant-123', + userId: 'user-456', + category: 'payment_notification' as NotificationCategory, + title: 'Payment Received', + body: 'Your payment of $100 has been received and approved', + icon: '/icons/payment.png', + deepLink: '/payments/123', + data: { + paymentId: 'payment-123', + amount: '100' + } +}); + +console.log(`Sent: ${result.sent}, Failed: ${result.failed}`); +``` + +### Listen for Real-Time Notifications (Frontend) + +```typescript +import { useWebSocketNotifications } from '@/hooks/useWebSocketNotifications'; + +export function NotificationListener() { + const { isConnected, notification } = useWebSocketNotifications(); + + useEffect(() => { + if (notification) { + console.log('Received:', notification); + // Handle notification + } + }, [notification]); + + return ( +
+ Status: {isConnected ? 'Connected' : 'Disconnected'} +
+ ); +} +``` + +## Notification Categories + +The system supports the following notification categories: + +- **payment_notification** - Payment-related updates +- **dispute_alert** - Dispute notifications (high priority) +- **project_update** - Project status changes +- **milestone_reminder** - Milestone reminders +- **security_alert** - Security-related alerts (high priority) +- **subscription_update** - Subscription changes +- **system_notification** - General system messages + +Users can enable/disable each category via preferences. + +## Browser Support + +| Browser | Support | Requirements | +|---------|---------|--------------| +| Chrome 50+ | ✅ | HTTPS, Service Worker | +| Firefox 48+ | ✅ | HTTPS, Service Worker | +| Safari 15.1+ | ✅ | HTTPS, Service Worker | +| Edge 17+ | ✅ | HTTPS, Service Worker | + +**HTTPS Required**: Push notifications only work on HTTPS connections (or localhost for development). + +## Troubleshooting + +### "Service Worker not registered" + +Make sure `service-worker.ts` is available at `public/service-worker.js`: + +```bash +# Build the service worker +npm run build +``` + +### "Push permission denied" + +Users can re-enable notifications in browser settings: +- Chrome/Edge: Settings → Privacy → Site Settings → Notifications +- Firefox: Preferences → Privacy → Permissions → Notifications +- Safari: System Preferences → Notifications + +### "Failed to fetch VAPID public key" + +Check that: +1. Backend is running and accessible +2. CORS is properly configured +3. `VAPID_PUBLIC_KEY` env var is set +4. Push routes are mounted on the Express app + +### "Subscription endpoint invalid" + +This usually means: +1. Push subscription expired (unsubscribe and resubscribe) +2. Browser cleared service worker data (unsubscribe and resubscribe) +3. User manually disabled notifications (check browser settings) + +### "WebSocket connection failed" + +Check that: +1. WebSocket is enabled (`WS_ENABLED=true`) +2. Socket.io is installed on frontend +3. CORS is configured for WebSocket connections +4. Auth token is valid + +### Notifications not appearing + +Check: +1. User has not disabled the category in preferences +2. Notification is actually being sent (check logs) +3. Service worker is installed (check DevTools → Application → Service Workers) +4. Browser is not in "Do Not Disturb" mode + +## Security Considerations + +1. **VAPID Keys**: Keep private keys secret and never commit to version control +2. **Authentication**: All endpoints require valid JWT tokens +3. **Rate Limiting**: Implement rate limiting on notification endpoints +4. **Validation**: Validate all notification data before sending +5. **HTTPS**: Always use HTTPS in production (required for push) + +## Performance Tips + +1. **Batch Operations**: Use batch endpoints for multiple users +2. **Caching**: Cache VAPID public key on client +3. **Retry Logic**: Implement exponential backoff for failed sends +4. **Cleanup**: Regularly clean up old notification logs +5. **Grouping**: Use notification tags to group related notifications + +## Next Steps + +- Implement analytics for notification tracking +- Add scheduled notification support +- Create admin dashboard for notification management +- Integrate with email for fallback notifications +- Add webhook support for external notification triggers + +## Support + +For issues or questions: +- Check the [Troubleshooting](#troubleshooting) section +- Review server logs for error details +- Check browser console for client-side errors +- See [API Endpoints](#api-endpoints) for endpoint documentation diff --git a/frontend/components/NotificationCenter.tsx b/frontend/components/NotificationCenter.tsx new file mode 100644 index 00000000..9347d93b --- /dev/null +++ b/frontend/components/NotificationCenter.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { formatDistanceToNow } from "date-fns"; + +interface Notification { + id: string; + title: string; + body: string; + category: string; + status: string; + icon?: string; + badge?: string; + tag?: string; + deepLink?: string; + sentAt?: string; + deliveredAt?: string; + clickedAt?: string; + data?: Record; + createdAt: string; +} + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api/v1"; + +/** + * Component to display notification history + */ +export function NotificationCenter() { + const [notifications, setNotifications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchNotifications(); + }, []); + + const fetchNotifications = async () => { + try { + setIsLoading(true); + const response = await fetch(`${API_BASE_URL}/push/history?limit=50`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("auth_token")}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch notifications"); + } + + const data = await response.json(); + setNotifications(data); + setError(null); + } catch (err) { + console.error("Error fetching notifications:", err); + setError(err instanceof Error ? err.message : "Failed to load notifications"); + } finally { + setIsLoading(false); + } + }; + + const getCategoryColor = (category: string) => { + const colors: Record = { + payment_notification: "bg-green-100 text-green-800", + dispute_alert: "bg-red-100 text-red-800", + project_update: "bg-blue-100 text-blue-800", + milestone_reminder: "bg-purple-100 text-purple-800", + security_alert: "bg-orange-100 text-orange-800", + subscription_update: "bg-indigo-100 text-indigo-800", + system_notification: "bg-gray-100 text-gray-800", + }; + return colors[category] || "bg-gray-100 text-gray-800"; + }; + + const getStatusBadge = (status: string) => { + const statusMap: Record = { + pending: { label: "Pending", color: "bg-yellow-100 text-yellow-800" }, + sent: { label: "Sent", color: "bg-blue-100 text-blue-800" }, + delivered: { label: "Delivered", color: "bg-green-100 text-green-800" }, + clicked: { label: "Clicked", color: "bg-purple-100 text-purple-800" }, + failed: { label: "Failed", color: "bg-red-100 text-red-800" }, + }; + const info = statusMap[status] || statusMap.pending; + return info; + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + if (notifications.length === 0) { + return ( +
+

No notifications yet

+
+ ); + } + + return ( +
+ {notifications.map((notification) => { + const status = getStatusBadge(notification.status); + return ( +
+
+
+
+

{notification.title}

+ + {notification.category.replace(/_/g, " ")} + +
+

{notification.body}

+
+ {formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })} + + + {status.label} + +
+
+
+
+ ); + })} +
+ ); +} + +/** + * Notification Toast Component + */ +export function NotificationToast({ + notification, + onDismiss, +}: { + notification: Notification; + onDismiss: () => void; +}) { + useEffect(() => { + const timer = setTimeout(onDismiss, 5000); + return () => clearTimeout(timer); + }, [onDismiss]); + + return ( +
+ {notification.icon && ( + + )} +
+

{notification.title}

+

{notification.body}

+
+ +
+ ); +} + +/** + * In-App Notification Container - manages multiple toasts + */ +export function NotificationContainer() { + const [toasts, setToasts] = useState([]); + + useEffect(() => { + // Listen for broadcast messages from service worker or other sources + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === "NOTIFICATION_RECEIVED") { + const notification = event.data.notification; + setToasts((prev) => [...prev, notification]); + } + }; + + navigator.serviceWorker?.controller?.addEventListener("message", handleMessage); + + return () => { + navigator.serviceWorker?.controller?.removeEventListener("message", handleMessage); + }; + }, []); + + const removeToast = (id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }; + + return ( +
+ {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} +
+ ); +} diff --git a/frontend/components/NotificationPreferences.tsx b/frontend/components/NotificationPreferences.tsx new file mode 100644 index 00000000..b16f722b --- /dev/null +++ b/frontend/components/NotificationPreferences.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useToast } from "@/components/ui/use-toast"; + +interface NotificationPreferences { + paymentNotifications: boolean; + disputeAlerts: boolean; + projectUpdates: boolean; + milestoneReminders: boolean; + securityAlerts: boolean; + subscriptionUpdates: boolean; + systemNotifications: boolean; + groupNotifications: boolean; + notifySound: boolean; + notifyBadge: boolean; + locale: string; + timezone: string; +} + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api/v1"; + +/** + * Component for managing notification preferences + */ +export function NotificationPreferences() { + const [preferences, setPreferences] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const { toast } = useToast(); + + useEffect(() => { + fetchPreferences(); + }, []); + + const fetchPreferences = async () => { + try { + setIsLoading(true); + const response = await fetch(`${API_BASE_URL}/push/preferences`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("auth_token")}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch preferences"); + } + + const data = await response.json(); + setPreferences(data); + setError(null); + } catch (err) { + console.error("Error fetching preferences:", err); + setError(err instanceof Error ? err.message : "Failed to load preferences"); + } finally { + setIsLoading(false); + } + }; + + const handleToggle = (key: keyof NotificationPreferences) => { + if (preferences) { + const updated = { + ...preferences, + [key]: !preferences[key], + }; + setPreferences(updated); + } + }; + + const handleSelectChange = (key: keyof NotificationPreferences, value: string) => { + if (preferences) { + const updated = { + ...preferences, + [key]: value, + }; + setPreferences(updated); + } + }; + + const savePreferences = async () => { + if (!preferences) return; + + try { + setIsSaving(true); + const response = await fetch(`${API_BASE_URL}/push/preferences`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("auth_token")}`, + }, + body: JSON.stringify(preferences), + }); + + if (!response.ok) { + throw new Error("Failed to save preferences"); + } + + toast({ + title: "Success", + description: "Notification preferences updated", + }); + } catch (err) { + console.error("Error saving preferences:", err); + toast({ + title: "Error", + description: err instanceof Error ? err.message : "Failed to save preferences", + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error || !preferences) { + return ( +
+

{error || "Failed to load preferences"}

+ +
+ ); + } + + const categoryPreferences = [ + { + key: "paymentNotifications" as const, + label: "Payment Notifications", + description: "Receive notifications when payments are processed or completed", + }, + { + key: "disputeAlerts" as const, + label: "Dispute Alerts", + description: "Get alerts about disputes or issues with your payments", + }, + { + key: "projectUpdates" as const, + label: "Project Updates", + description: "Receive updates about project status and changes", + }, + { + key: "milestoneReminders" as const, + label: "Milestone Reminders", + description: "Get reminders about upcoming project milestones", + }, + { + key: "securityAlerts" as const, + label: "Security Alerts", + description: "Important security and account notifications", + }, + { + key: "subscriptionUpdates" as const, + label: "Subscription Updates", + description: "Updates about your subscription plans and billing", + }, + { + key: "systemNotifications" as const, + label: "System Notifications", + description: "General system and maintenance updates", + }, + ]; + + return ( +
+ {/* Notification Categories */} +
+

Notification Categories

+

+ Choose which types of notifications you want to receive +

+ +
+ {categoryPreferences.map((category) => ( + + ))} +
+
+ + {/* Notification Features */} +
+

Notification Features

+
+ + + + + +
+
+ + {/* Regional Settings */} +
+

Regional Settings

+
+
+ + +
+ +
+ + +
+
+
+ + {/* Action Buttons */} +
+ + +
+
+ ); +} + +/** + * Simpler toggle component for notification settings in header/navigation + */ +export function NotificationPreferenceQuickToggle() { + const [preferences, setPreferences] = useState>({ + paymentNotifications: true, + disputeAlerts: true, + securityAlerts: true, + }); + const [isExpanded, setIsExpanded] = useState(false); + const { toast } = useToast(); + + const handleToggle = async (key: string) => { + const updated = { + ...preferences, + [key]: !preferences[key as keyof NotificationPreferences], + }; + setPreferences(updated); + + try { + const response = await fetch(`${API_BASE_URL}/push/preferences`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("auth_token")}`, + }, + body: JSON.stringify(updated), + }); + + if (!response.ok) { + throw new Error("Failed to update"); + } + } catch (err) { + console.error("Error:", err); + toast({ + title: "Error", + description: "Failed to update preferences", + variant: "destructive", + }); + } + }; + + return ( +
+ + + {isExpanded && ( +
+
+ {Object.entries({ + paymentNotifications: "Payments", + disputeAlerts: "Disputes", + securityAlerts: "Security", + }).map(([key, label]) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/components/PushSubscription.tsx b/frontend/components/PushSubscription.tsx new file mode 100644 index 00000000..31b25dfe --- /dev/null +++ b/frontend/components/PushSubscription.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useToast } from "@/components/ui/use-toast"; + +interface PushSubscriptionStatus { + isSupported: boolean; + isSubscribed: boolean; + isLoading: boolean; + permission: NotificationPermission; +} + +interface UseNotificationSubscriptionReturn extends PushSubscriptionStatus { + subscribe: () => Promise; + unsubscribe: () => Promise; + requestPermission: () => Promise; +} + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api/v1"; + +/** + * Hook to manage push notification subscriptions + */ +export function useNotificationSubscription(): UseNotificationSubscriptionReturn { + const [isSupported, setIsSupported] = useState(false); + const [isSubscribed, setIsSubscribed] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [permission, setPermission] = useState("default"); + const { toast } = useToast(); + + // Check browser support + useEffect(() => { + const supported = + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window; + + setIsSupported(supported); + if (supported) { + setPermission(Notification.permission); + } + }, []); + + // Check current subscription status + useEffect(() => { + if (!isSupported) return; + + const checkSubscription = async () => { + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + setIsSubscribed(!!subscription); + } catch (error) { + console.error("Error checking subscription:", error); + } + }; + + checkSubscription(); + }, [isSupported]); + + /** + * Request notification permission from user + */ + const requestPermission = useCallback(async () => { + if (!isSupported) { + toast({ + title: "Not Supported", + description: "Push notifications are not supported in this browser", + variant: "destructive", + }); + return; + } + + try { + const permission = await Notification.requestPermission(); + setPermission(permission); + + if (permission === "granted") { + toast({ + title: "Permission Granted", + description: "You will now receive push notifications", + }); + } else if (permission === "denied") { + toast({ + title: "Permission Denied", + description: "Push notifications are disabled. You can enable them in settings.", + variant: "destructive", + }); + } + } catch (error) { + console.error("Error requesting permission:", error); + toast({ + title: "Error", + description: "Failed to request notification permission", + variant: "destructive", + }); + } + }, [isSupported, toast]); + + /** + * Subscribe to push notifications + */ + const subscribe = useCallback(async () => { + if (!isSupported) { + toast({ + title: "Not Supported", + description: "Push notifications are not supported in this browser", + variant: "destructive", + }); + return; + } + + if (permission !== "granted") { + await requestPermission(); + return; + } + + setIsLoading(true); + try { + const registration = await navigator.serviceWorker.ready; + + // Get VAPID public key + const keyResponse = await fetch(`${API_BASE_URL}/push/vapid-public-key`); + const { publicKey } = await keyResponse.json(); + + // Create push subscription + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + + // Send subscription to backend + const response = await fetch(`${API_BASE_URL}/push/subscribe`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("auth_token")}`, + }, + body: JSON.stringify({ subscription }), + }); + + if (!response.ok) { + throw new Error("Failed to save subscription to server"); + } + + const data = await response.json(); + setIsSubscribed(true); + + toast({ + title: "Subscribed", + description: "You are now subscribed to push notifications", + }); + + // Store subscription locally for reference + localStorage.setItem("push_subscription_id", data.subscriptionId); + } catch (error) { + console.error("Error subscribing:", error); + toast({ + title: "Subscription Failed", + description: "Failed to subscribe to push notifications. Please try again.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }, [isSupported, permission, requestPermission, toast]); + + /** + * Unsubscribe from push notifications + */ + const unsubscribe = useCallback(async () => { + if (!isSupported) return; + + setIsLoading(true); + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + setIsSubscribed(false); + return; + } + + // Send unsubscribe request to backend + await fetch(`${API_BASE_URL}/push/unsubscribe`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("auth_token")}`, + }, + body: JSON.stringify({ endpoint: subscription.endpoint }), + }); + + // Unsubscribe from push manager + await subscription.unsubscribe(); + setIsSubscribed(false); + + localStorage.removeItem("push_subscription_id"); + + toast({ + title: "Unsubscribed", + description: "You have been unsubscribed from push notifications", + }); + } catch (error) { + console.error("Error unsubscribing:", error); + toast({ + title: "Unsubscription Failed", + description: "Failed to unsubscribe from push notifications", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }, [isSupported, toast]); + + return { + isSupported, + isSubscribed, + isLoading, + permission, + subscribe, + unsubscribe, + requestPermission, + }; +} + +/** + * Component for managing push notification subscription + */ +export function PushNotificationManager() { + const { + isSupported, + isSubscribed, + isLoading, + permission, + subscribe, + unsubscribe, + requestPermission, + } = useNotificationSubscription(); + + if (!isSupported) { + return null; + } + + return ( +
+ {permission === "default" && ( +
+

+ Enable push notifications to receive real-time updates about payments, disputes, and more. +

+ +
+ )} + + {permission === "granted" && ( +
+
+
+

+ {isSubscribed + ? "Push notifications are enabled" + : "Push notifications are ready to enable"} +

+
+ +
+ )} + + {permission === "denied" && ( +
+

+ Push notifications are disabled. You can enable them in your browser settings. +

+
+ )} +
+ ); +} + +/** + * Helper function to convert VAPID key to Uint8Array + */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + 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; +} + +/** + * Simple button component for toggling notifications + */ +export function NotificationToggle() { + const { + isSupported, + isSubscribed, + isLoading, + subscribe, + unsubscribe, + permission, + } = useNotificationSubscription(); + + if (!isSupported || permission !== "granted") { + return null; + } + + return ( + + ); +} diff --git a/frontend/hooks/useWebSocketNotifications.ts b/frontend/hooks/useWebSocketNotifications.ts new file mode 100644 index 00000000..170983cc --- /dev/null +++ b/frontend/hooks/useWebSocketNotifications.ts @@ -0,0 +1,282 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import { useToast } from "@/components/ui/use-toast"; + +interface WebSocketNotification { + id: string; + title: string; + body: string; + category: string; + icon?: string; + badge?: string; + data?: Record; + deepLink?: string; + timestamp: string; +} + +interface UseWebSocketReturn { + isConnected: boolean; + isConnecting: boolean; + notification: WebSocketNotification | null; + sendMessage: (event: string, data: any, callback?: (response: any) => void) => void; + disconnect: () => void; +} + +/** + * Hook for WebSocket real-time notifications + */ +export function useWebSocketNotifications(): UseWebSocketReturn { + const socketRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [notification, setNotification] = useState(null); + const { toast } = useToast(); + + const connectWebSocket = useCallback(() => { + if (isConnecting || isConnected || socketRef.current?.connected) { + return; + } + + setIsConnecting(true); + + try { + // Dynamically import socket.io client + const io = require("socket.io-client").io || window.io; + + if (!io) { + console.error("Socket.io client not available"); + setIsConnecting(false); + return; + } + + const token = localStorage.getItem("auth_token"); + const userId = localStorage.getItem("user_id"); + const tenantId = localStorage.getItem("tenant_id"); + + const socket = io(process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001", { + auth: { + token, + userId, + tenantId, + }, + reconnection: true, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + reconnectionAttempts: 5, + }); + + socket.on("connect", () => { + console.log("[WebSocket] Connected"); + setIsConnected(true); + setIsConnecting(false); + }); + + socket.on("notification:new", (data: WebSocketNotification) => { + console.log("[WebSocket] Received notification:", data); + setNotification(data); + + // Show toast + toast({ + title: data.title, + description: data.body, + }); + }); + + socket.on("disconnect", () => { + console.log("[WebSocket] Disconnected"); + setIsConnected(false); + }); + + socket.on("error", (error: string) => { + console.error("[WebSocket] Error:", error); + toast({ + title: "Connection Error", + description: error, + variant: "destructive", + }); + }); + + socket.on("connect_error", (error: Error) => { + console.error("[WebSocket] Connect error:", error); + setIsConnecting(false); + }); + + socketRef.current = socket; + } catch (error) { + console.error("[WebSocket] Setup error:", error); + setIsConnecting(false); + } + }, [isConnecting, isConnected, toast]); + + const sendMessage = useCallback( + (event: string, data: any, callback?: (response: any) => void) => { + if (!socketRef.current?.connected) { + console.warn("[WebSocket] Not connected"); + return; + } + + socketRef.current.emit(event, data, callback); + }, + [] + ); + + const disconnect = useCallback(() => { + if (socketRef.current) { + socketRef.current.disconnect(); + setIsConnected(false); + } + }, []); + + // Connect on mount + useEffect(() => { + connectWebSocket(); + + return () => { + disconnect(); + }; + }, [connectWebSocket, disconnect]); + + return { + isConnected, + isConnecting, + notification, + sendMessage, + disconnect, + }; +} + +/** + * Component that integrates WebSocket notifications with the app + */ +export function WebSocketNotificationProvider({ children }: { children: React.ReactNode }) { + const { isConnected, notification } = useWebSocketNotifications(); + + useEffect(() => { + if (!isConnected) { + console.log("[WebSocket] Reconnecting..."); + } + }, [isConnected]); + + return ( + <> + {children} + {/* Optional: Display connection status indicator */} +
+
+ {isConnected ? "Connected" : "Disconnected"} +
+ + ); +} + +/** + * Hook to subscribe to push notifications via WebSocket + */ +export function useWebSocketPushSubscribe() { + const { sendMessage } = useWebSocketNotifications(); + const { toast } = useToast(); + + const subscribe = useCallback( + async (subscription: PushSubscriptionJSON) => { + return new Promise((resolve, reject) => { + sendMessage("notification:subscribe", { subscription }, (response) => { + if (response.error) { + toast({ + title: "Subscription Failed", + description: response.error, + variant: "destructive", + }); + reject(new Error(response.error)); + } else { + resolve(response.subscriptionId); + } + }); + }); + }, + [sendMessage, toast] + ); + + const unsubscribe = useCallback( + async (endpoint: string) => { + return new Promise((resolve, reject) => { + sendMessage("notification:unsubscribe", { endpoint }, (response) => { + if (response.error) { + toast({ + title: "Unsubscribe Failed", + description: response.error, + variant: "destructive", + }); + reject(new Error(response.error)); + } else { + resolve(true); + } + }); + }); + }, + [sendMessage, toast] + ); + + return { subscribe, unsubscribe }; +} + +/** + * Hook to manage preferences via WebSocket + */ +export function useWebSocketPreferences() { + const { sendMessage } = useWebSocketNotifications(); + const { toast } = useToast(); + + const getPreferences = useCallback(async () => { + return new Promise((resolve, reject) => { + sendMessage("notification:preferences", {}, (response) => { + if (response.error) { + reject(new Error(response.error)); + } else { + resolve(response.preferences); + } + }); + }); + }, [sendMessage]); + + const updatePreferences = useCallback( + async (preferences: Record) => { + return new Promise((resolve, reject) => { + sendMessage("notification:updatePreferences", preferences, (response) => { + if (response.error) { + toast({ + title: "Update Failed", + description: response.error, + variant: "destructive", + }); + reject(new Error(response.error)); + } else { + toast({ + title: "Success", + description: "Preferences updated", + }); + resolve(response.preferences); + } + }); + }); + }, + [sendMessage, toast] + ); + + const markAsRead = useCallback( + async (notificationId: string) => { + return new Promise((resolve, reject) => { + sendMessage("notification:markAsRead", { notificationId }, (response) => { + if (response.error) { + reject(new Error(response.error)); + } else { + resolve(true); + } + }); + }); + }, + [sendMessage] + ); + + return { getPreferences, updatePreferences, markAsRead }; +} diff --git a/frontend/service-worker.ts b/frontend/service-worker.ts index 6667fd68..8ca796f4 100644 --- a/frontend/service-worker.ts +++ b/frontend/service-worker.ts @@ -316,4 +316,132 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { } }); +// ─── Push Notification Handlers ───────────────────────────────────────────── + +interface PushMessage { + title: string; + body: string; + icon?: string; + badge?: string; + tag?: string; + data?: { + deepLink?: string; + category?: string; + [key: string]: any; + }; +} + +self.addEventListener('push', (event: PushEvent) => { + if (!event.data) { + console.warn('[SW] Push event without data'); + return; + } + + try { + const message: PushMessage = event.data.json(); + + // Set default values + const options: NotificationOptions = { + body: message.body || 'You have a new notification', + icon: message.icon || '/icons/notification.png', + badge: message.badge || '/icons/badge.png', + tag: message.tag || 'notification', + requireInteraction: + message.data?.category === 'dispute_alert' || + message.data?.category === 'security_alert', + data: { + ...message.data, + timestamp: Date.now(), + }, + }; + + // Handle notification grouping + if (message.tag) { + options.tag = message.tag; + } + + event.waitUntil( + self.registration.showNotification(message.title || 'Notification', options) + ); + } catch (error) { + console.error('[SW] Error handling push event:', error); + + // Fallback: show generic notification + event.waitUntil( + self.registration.showNotification('New Notification', { + body: 'You have a new message', + icon: '/icons/notification.png', + badge: '/icons/badge.png', + }) + ); + } +}); + +self.addEventListener('notificationclick', (event: NotificationEvent) => { + const notification = event.notification; + const deepLink = notification.data?.deepLink; + + // Mark notification as clicked via API + if (notification.data?.notificationId) { + markNotificationAsClicked(notification.data.notificationId).catch(err => + console.error('[SW] Error marking notification as clicked:', err) + ); + } + + event.notification.close(); + + // Handle deep link navigation + const targetUrl = deepLink || '/'; + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { + // Check if already a window open + for (let i = 0; i < clientList.length; i++) { + const client = clientList[i]; + if (client.url === targetUrl && 'focus' in client) { + return client.focus(); + } + } + + // Open new window with deep link + if (clients.openWindow) { + return clients.openWindow(targetUrl); + } + }) + ); +}); + +self.addEventListener('notificationclose', (event: NotificationEvent) => { + const notification = event.notification; + + // Log notification dismissal (optional) + console.log('[SW] Notification closed:', notification.data?.tag); + + // You can send analytics here + if (notification.data?.notificationId) { + // Could send a dismissal event to analytics service + } +}); + +/** + * Helper function to mark notification as clicked in the backend + */ +async function markNotificationAsClicked(notificationId: string): Promise { + try { + const response = await fetch(`/api/v1/push/mark-clicked/${notificationId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.warn('[SW] Failed to mark notification as clicked:', response.statusText); + } + } catch (error) { + console.error('[SW] Error marking notification as clicked:', error); + // Silently fail - this is not critical + } +} + export default null;