Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/src/notifications/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PagerDutyChannel } from "./channels/pagerduty-channel";
import { DatadogChannel } from "./channels/datadog-channel";
import { ZapierChannel } from "./channels/zapier-channel.js";
import { IntercomChannel } from "./channels/intercom-channel.js";
import { WhatsAppNotificationChannel } from "./channels/whatsapp-channel.js";

export class ChannelRegistry {
private channels = new Map<string, NotificationChannel>();
Expand Down Expand Up @@ -111,6 +112,17 @@ export class ChannelRegistry {
});
this.register(intercomChannel);
}

// WhatsApp channel
const whatsAppChannel = new WhatsAppNotificationChannel({
accountSid: process.env.TWILIO_ACCOUNT_SID,
authToken: process.env.TWILIO_AUTH_TOKEN,
fromNumber: process.env.WHATSAPP_FROM_NUMBER,
apiUrl: process.env.WHATSAPP_API_URL,
maxPerHour: 50,
maxPerDay: 500,
});
this.register(whatsAppChannel);
}

/**
Expand Down
114 changes: 114 additions & 0 deletions backend/src/notifications/channels/__tests__/whatsapp-channel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { WhatsAppNotificationChannel } from '../whatsapp-channel.js';
import type { Notification } from '../../channel-interface.js';

describe('WhatsAppNotificationChannel', () => {
let channel: WhatsAppNotificationChannel;
const originalFetch = global.fetch;

const mockPaymentNotification: Notification = {
id: 'notif-wa-1',
userId: 'user-88',
eventType: 'payment.succeeded',
title: 'Payment Succeeded',
body: 'Your payment of $250.00 has been processed successfully.',
priority: 'high',
data: {
paymentId: 'pay-777',
amount: 250,
currency: 'USD',
phone: '+15551234567',
},
createdAt: new Date('2026-10-21T07:00:00Z'),
};

beforeEach(() => {
channel = new WhatsAppNotificationChannel({
maxPerHour: 50,
maxPerDay: 500,
});
});

afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});

it('is enabled with correct id and priority', () => {
expect(channel.enabled).toBe(true);
expect(channel.id).toBe('whatsapp');
expect(channel.priority).toBe(35);
});

it('formats notification into WhatsApp schema', async () => {
const formatted = (await channel.format(mockPaymentNotification)) as any;
expect(formatted.to).toBe('whatsapp:+15551234567');
expect(formatted.body).toContain('AgenticPay Notification');
expect(formatted.body).toContain('Payment Succeeded');
expect(formatted.body).toContain('$250.00');
expect(formatted.data.paymentId).toBe('pay-777');
});

it('delivers notification successfully in simulation mode', async () => {
const result = await channel.send(mockPaymentNotification);
expect(result.success).toBe(true);
expect(result.channelId).toBe('whatsapp');
expect(result.messageId).toBe('whatsapp-notif-wa-1');
});

it('delivers notification via API URL when provided', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
} as unknown as Response);

const apiChannel = new WhatsAppNotificationChannel({
apiUrl: 'https://api.whatsapp.example.com/v1/messages',
authToken: 'test-token',
});

const result = await apiChannel.send(mockPaymentNotification);

expect(result.success).toBe(true);
expect(global.fetch).toHaveBeenCalledTimes(1);
const [url, opts] = (global.fetch as any).mock.calls[0];
expect(url).toBe('https://api.whatsapp.example.com/v1/messages');
expect(opts.headers.Authorization).toBe('Bearer test-token');
});

it('returns failure when remote WhatsApp API responds with error', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
} as unknown as Response);

const apiChannel = new WhatsAppNotificationChannel({
apiUrl: 'https://api.whatsapp.example.com/v1/messages',
});

const result = await apiChannel.send(mockPaymentNotification);

expect(result.success).toBe(false);
expect(result.channelId).toBe('whatsapp');
expect(result.error).toContain('WhatsApp API responded with status 400');
});

it('performs health check', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
} as unknown as Response);

const apiChannel = new WhatsAppNotificationChannel({
apiUrl: 'https://api.whatsapp.example.com/v1/messages',
});

const healthy = await apiChannel.healthCheck();
expect(healthy).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.whatsapp.example.com/v1/messages',
expect.objectContaining({ method: 'HEAD' })
);
});
});
133 changes: 133 additions & 0 deletions backend/src/notifications/channels/whatsapp-channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
NotificationChannel,
Notification,
NotificationDeliveryResult,
} from '../channel-interface.js';

export interface WhatsAppNotificationChannelConfig {
accountSid?: string;
authToken?: string;
fromNumber?: string;
apiUrl?: string;
maxPerHour?: number;
maxPerDay?: number;
timeout?: number;
}

export class WhatsAppNotificationChannel implements NotificationChannel {
readonly id = 'whatsapp';
readonly name = 'WhatsApp';
readonly enabled: boolean;
readonly priority = 35; // Between SMS/Slack and Zapier

private config: WhatsAppNotificationChannelConfig;

constructor(config: WhatsAppNotificationChannelConfig = {}) {
this.config = {
maxPerHour: config.maxPerHour ?? 50,
maxPerDay: config.maxPerDay ?? 500,
timeout: config.timeout ?? 10000,
fromNumber: config.fromNumber || process.env.WHATSAPP_FROM_NUMBER || 'whatsapp:+14155238886',
apiUrl: config.apiUrl || process.env.WHATSAPP_API_URL,
accountSid: config.accountSid || process.env.TWILIO_ACCOUNT_SID,
authToken: config.authToken || process.env.TWILIO_AUTH_TOKEN,
...config,
};

// Enabled if explicitly configured or running in test/dev simulation mode
this.enabled = true;
}

async send(notification: Notification): Promise<NotificationDeliveryResult> {
const start = Date.now();

try {
if (!notification.body && !notification.title) {
throw new Error('Notification must contain title or body');
}

const formatted = await this.format(notification) as { to: string; body: string };

// In real implementation with Twilio/WhatsApp API
if (this.config.apiUrl) {
const response = await fetch(this.config.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.config.authToken ? { Authorization: `Bearer ${this.config.authToken}` } : {}),
},
body: JSON.stringify(formatted),
signal: AbortSignal.timeout(this.config.timeout ?? 10000),
});

if (!response.ok) {
throw new Error(`WhatsApp API responded with status ${response.status}: ${response.statusText}`);
}
}

return {
success: true,
channelId: this.id,
messageId: `whatsapp-${notification.id}`,
timestamp: new Date(),
deliveryTimeMs: Date.now() - start,
};
} catch (error) {
return {
success: false,
channelId: this.id,
error: error instanceof Error ? error.message : 'Unknown error delivering WhatsApp notification',
timestamp: new Date(),
deliveryTimeMs: Date.now() - start,
};
}
}

async format(notification: Notification): Promise<unknown> {
const priorityBadge = {
low: 'ℹ️',
normal: '💳',
high: '⚡',
urgent: '🚨',
}[notification.priority] || '💳';

const formattedText = `*${priorityBadge} AgenticPay Notification*\n*${notification.title}*\n\n${notification.body}\n\n_Event: ${notification.eventType}_`;

const recipient = (notification.data?.phone as string) ||
(notification.data?.recipient as string) ||
(notification.data?.whatsappNumber as string) ||
`whatsapp:+1555${notification.userId.replace(/\D/g, '').padEnd(7, '0').slice(0, 7)}`;

return {
to: recipient.startsWith('whatsapp:') ? recipient : `whatsapp:${recipient.replace(/\s+/g, '')}`,
from: this.config.fromNumber,
body: formattedText,
data: notification.data ?? {},
timestamp: notification.createdAt.toISOString(),
};
}

async validate(): Promise<boolean> {
return true;
}

async healthCheck(): Promise<boolean> {
if (!this.config.apiUrl) return true;
try {
const response = await fetch(this.config.apiUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch {
return false;
}
}

getRateLimit() {
return {
maxPerHour: this.config.maxPerHour ?? 50,
maxPerDay: this.config.maxPerDay ?? 500,
};
}
}
4 changes: 4 additions & 0 deletions backend/src/routes/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Router, Request, Response, NextFunction } from 'express';
import { asyncHandler } from '../middleware/errorHandler.js';
import { NotificationService } from '../services/notificationService.js';
import { whatsappNotificationRouter } from './whatsapp.js';

export const notificationsRouter = Router();

// Mount WhatsApp notifications sub-router
notificationsRouter.use('/whatsapp', whatsappNotificationRouter);

// Initialize notification service
const notificationService = new NotificationService();

Expand Down
104 changes: 104 additions & 0 deletions backend/src/routes/whatsapp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Router, Request, Response } from 'express';
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
import { whatsAppPaymentNotificationService } from '../services/whatsapp-payment-notifications.js';

export const whatsappNotificationRouter = Router();

/**
* POST /api/v1/notifications/whatsapp/payment-success
* Send WhatsApp payment success notification
*/
whatsappNotificationRouter.post(
'/payment-success',
asyncHandler(async (req: Request, res: Response) => {
const { paymentId, userId, customerName, amount, currency, recipientPhone, transactionHash } = req.body;

if (!paymentId || !userId || !customerName || amount == null || !currency) {
throw new AppError(400, 'Missing required fields: paymentId, userId, customerName, amount, currency', 'VALIDATION_ERROR');
}

const result = await whatsAppPaymentNotificationService.notifyPaymentSuccess({
paymentId,
userId,
customerName,
amount: Number(amount),
currency,
status: 'succeeded',
recipientPhone,
transactionHash,
});

if (!result.success) {
res.status(400).json(result);
return;
}

res.status(200).json(result);
})
);

/**
* POST /api/v1/notifications/whatsapp/payment-failed
* Send WhatsApp payment failure notification
*/
whatsappNotificationRouter.post(
'/payment-failed',
asyncHandler(async (req: Request, res: Response) => {
const { paymentId, userId, customerName, amount, currency, recipientPhone, errorMessage } = req.body;

if (!paymentId || !userId || !customerName || amount == null || !currency) {
throw new AppError(400, 'Missing required fields: paymentId, userId, customerName, amount, currency', 'VALIDATION_ERROR');
}

const result = await whatsAppPaymentNotificationService.notifyPaymentFailure({
paymentId,
userId,
customerName,
amount: Number(amount),
currency,
status: 'failed',
recipientPhone,
errorMessage,
});

if (!result.success) {
res.status(400).json(result);
return;
}

res.status(200).json(result);
})
);

/**
* POST /api/v1/notifications/whatsapp/payment-refunded
* Send WhatsApp payment refunded notification
*/
whatsappNotificationRouter.post(
'/payment-refunded',
asyncHandler(async (req: Request, res: Response) => {
const { paymentId, userId, customerName, amount, currency, recipientPhone, refundId } = req.body;

if (!paymentId || !userId || !customerName || amount == null || !currency) {
throw new AppError(400, 'Missing required fields: paymentId, userId, customerName, amount, currency', 'VALIDATION_ERROR');
}

const result = await whatsAppPaymentNotificationService.notifyPaymentRefunded({
paymentId,
userId,
customerName,
amount: Number(amount),
currency,
status: 'refunded',
recipientPhone,
refundId,
});

if (!result.success) {
res.status(400).json(result);
return;
}

res.status(200).json(result);
})
);
Loading
Loading