diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 28458021..26bf948a 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -32,6 +32,7 @@ model User { organizer Event[] attendedEvents EventAttendee[] + webhookEndpoints WebhookEndpoint[] ownedTeams Team[] @relation("TeamOwner") teamMemberships TeamMember[] @relation("TeamMember") @@ -142,7 +143,6 @@ model Event { isPublic Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") attendees EventAttendee[] - organizer User @relation(fields: [organizerId], references: [id]) } @@ -150,7 +150,7 @@ model EventAttendee { id String @id @default(uuid()) userId String eventId String - joinedAt DateTime + joinedAt DateTime event Event @relation(fields: [eventId] , references: [id]) user User @relation(fields: [userId],references: [id]) @@ -158,6 +158,41 @@ model EventAttendee { @@unique([userId, eventId]) } +model WebhookEndpoint { + id String @id @default(uuid()) + userId String @map("user_id") + url String + secret String + events String[] + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("webhook_endpoints") +} + +model WebhookDelivery { + id String @id @default(uuid()) + endpointId String @map("endpoint_id") + eventType String @map("event_type") + payload Json + status String @default("pending") // "pending" | "success" | "failed" + responseCode Int? @map("response_code") + attempts Int @default(0) + nextRetryAt DateTime? @map("next_retry_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + errorMessage String? @map("error_message") + deliveredAt DateTime? @map("delivered_at") + + @@index([endpointId]) + @@index([status, nextRetryAt]) + @@map("webhook_deliveries") +} + enum TeamRole { OWNER ADMIN @@ -194,4 +229,4 @@ model TeamMember{ @@unique([userId, teamId]) @@index([userId]) @@map("team_members") -} \ No newline at end of file +} diff --git a/apps/backend/src/__tests__/webhooks.test.ts b/apps/backend/src/__tests__/webhooks.test.ts new file mode 100644 index 00000000..2baca331 --- /dev/null +++ b/apps/backend/src/__tests__/webhooks.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify from 'fastify'; +import crypto from 'crypto'; +import { webhookRoutes } from '../routes/webhooks.js'; +import { signPayload } from '../utils/webhookDispatch.js'; + +// ─── Mock Encryption ─── +// We mock encryption so tests don't need the ENCRYPTION_KEY env var. +vi.mock('../utils/encryption.js', () => ({ + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (encrypted: string) => encrypted.replace('encrypted:', ''), +})); + +// ─── Mock Prisma ─── + +const mockEndpoint = { + id: 'wh-1', + userId: 'user-123', + url: 'https://example.com/webhook', + secret: 'encrypted:abc123', + events: ['card.viewed'], + isActive: true, + createdAt: new Date(), +}; + +const mockDelivery = { + id: 'del-1', + endpointId: 'wh-1', + eventType: 'card.viewed', + payload: { event: 'card.viewed' }, + status: 'success', + responseCode: 200, + attempts: 1, + nextRetryAt: null, + createdAt: new Date(), +}; + const mockPrisma = { + $transaction: vi.fn().mockImplementation(async (fn: any) => fn(mockPrisma)), + webhookEndpoint: { + count: vi.fn(), + create: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + }, + webhookDelivery: { + findMany: vi.fn(), + findUnique: vi.fn(), + count: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, +}; + +// ─── App Builder ─── + +async function buildApp() { + const app = Fastify(); + app.decorate('prisma', mockPrisma); + app.decorate('authenticate', async (request: any) => { + request.user = { id: 'user-123' }; + }); + app.register(webhookRoutes, { prefix: '/api/webhooks' }); + await app.ready(); + return app; +} + +// ─── Tests ─── + +describe('POST /api/webhooks — register endpoint', () => { + beforeEach(() => vi.clearAllMocks()); + + it('should create a webhook endpoint and return plaintext secret', async () => { + mockPrisma.webhookEndpoint.count.mockResolvedValue(0); + mockPrisma.webhookEndpoint.create.mockResolvedValue({ + ...mockEndpoint, + id: 'new-wh', + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks', + payload: { + url: 'https://example.com/webhook', + events: ['card.viewed'], + }, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.id).toBe('new-wh'); + expect(body.secret).toBeDefined(); + expect(typeof body.secret).toBe('string'); + expect(body.secret.length).toBeGreaterThan(0); + }); + + it('should reject when max 5 endpoints reached', async () => { + mockPrisma.webhookEndpoint.count.mockResolvedValue(5); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks', + payload: { + url: 'https://example.com/webhook', + events: ['card.viewed'], + }, + }); + + expect(res.statusCode).toBe(409); + expect(res.json().error).toContain('Maximum'); + }); + + it('should return 400 for invalid URL', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks', + payload: { + url: 'not-a-url', + events: ['card.viewed'], + }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('should return 400 for empty events array', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks', + payload: { + url: 'https://example.com/webhook', + events: [], + }, + }); + + expect(res.statusCode).toBe(400); + }); +}); + +describe('GET /api/webhooks — list endpoints', () => { + beforeEach(() => vi.clearAllMocks()); + + it('should return user endpoints without secrets', async () => { + const { secret, ...endpointWithoutSecret } = mockEndpoint; + mockPrisma.webhookEndpoint.findMany.mockResolvedValue([endpointWithoutSecret]); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/webhooks', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body[0]).not.toHaveProperty('secret'); + }); +}); + +describe('DELETE /api/webhooks/:id — remove endpoint', () => { + beforeEach(() => vi.clearAllMocks()); + + it('should delete an owned endpoint', async () => { + mockPrisma.webhookEndpoint.findFirst.mockResolvedValue(mockEndpoint); + mockPrisma.webhookEndpoint.delete.mockResolvedValue(mockEndpoint); + + const app = await buildApp(); + const res = await app.inject({ + method: 'DELETE', + url: '/api/webhooks/wh-1', + }); + + expect(res.statusCode).toBe(204); + }); + + it('should return 404 for non-existent endpoint', async () => { + mockPrisma.webhookEndpoint.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'DELETE', + url: '/api/webhooks/non-existent', + }); + + expect(res.statusCode).toBe(404); + }); +}); + +describe('GET /api/webhooks/:id/deliveries — delivery logs', () => { + beforeEach(() => vi.clearAllMocks()); + + it('should return paginated deliveries', async () => { + mockPrisma.webhookEndpoint.findFirst.mockResolvedValue(mockEndpoint); + mockPrisma.webhookDelivery.findMany.mockResolvedValue([mockDelivery]); + mockPrisma.webhookDelivery.count.mockResolvedValue(1); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/webhooks/wh-1/deliveries?page=1&limit=10', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(1); + expect(body.pagination.total).toBe(1); + expect(body.pagination.page).toBe(1); + }); + + it('should return 404 if endpoint not owned by user', async () => { + mockPrisma.webhookEndpoint.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/webhooks/other-wh/deliveries', + }); + + expect(res.statusCode).toBe(404); + }); +}); + +describe('PATCH /api/webhooks/:id/rotate-secret', () => { + beforeEach(() => vi.clearAllMocks()); + + it('should rotate the secret and return new plaintext', async () => { + mockPrisma.webhookEndpoint.findFirst.mockResolvedValue(mockEndpoint); + mockPrisma.webhookEndpoint.update.mockResolvedValue(mockEndpoint); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PATCH', + url: '/api/webhooks/wh-1/rotate-secret', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.secret).toBeDefined(); + expect(typeof body.secret).toBe('string'); + expect(body.secret.length).toBe(64); // 32 bytes hex + expect(body.message).toContain('rotated'); + }); + + it('should return 404 for non-owned endpoint', async () => { + mockPrisma.webhookEndpoint.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PATCH', + url: '/api/webhooks/other-wh/rotate-secret', + }); + + expect(res.statusCode).toBe(404); + }); +}); + +describe('signPayload — HMAC-SHA256 signature', () => { + it('should produce a valid HMAC-SHA256 hex signature', () => { + const secret = 'test-secret'; + const payload = JSON.stringify({ event: 'card.viewed', cardId: '123' }); + + const signature = signPayload(secret, payload); + + // Verify independently + const expected = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + + expect(signature).toBe(expected); + }); + + it('should produce different signatures for different secrets', () => { + const payload = JSON.stringify({ event: 'card.viewed' }); + const sig1 = signPayload('secret-a', payload); + const sig2 = signPayload('secret-b', payload); + expect(sig1).not.toBe(sig2); + }); + + it('should produce different signatures for different payloads', () => { + const secret = 'same-secret'; + const sig1 = signPayload(secret, '{"a":1}'); + const sig2 = signPayload(secret, '{"a":2}'); + expect(sig1).not.toBe(sig2); + }); +}); + +describe('deliverWebhook — retry logic', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + it('should mark delivery as success on 2xx response', async () => { + // We test attemptDelivery indirectly via the dispatch utility + // by importing and testing signPayload + attemptDelivery separately + const { attemptDelivery } = await import('../utils/webhookDispatch.js'); + + // Mock global fetch + const mockFetch = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + }); + vi.stubGlobal('fetch', mockFetch); + + const result = await attemptDelivery( + 'https://example.com/webhook', + '{"event":"test"}', + 'abc123', + ); + + expect(result.success).toBe(true); + expect(result.statusCode).toBe(200); + + vi.unstubAllGlobals(); + }); + + it('should return failure on non-2xx response', async () => { + const { attemptDelivery } = await import('../utils/webhookDispatch.js'); + + const mockFetch = vi.fn().mockResolvedValue({ + status: 500, + ok: false, + }); + vi.stubGlobal('fetch', mockFetch); + + const result = await attemptDelivery( + 'https://example.com/webhook', + '{"event":"test"}', + 'abc123', + ); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(500); + + vi.unstubAllGlobals(); + }); + + it('should return failure on network error / timeout', async () => { + const { attemptDelivery } = await import('../utils/webhookDispatch.js'); + + const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')); + vi.stubGlobal('fetch', mockFetch); + + const result = await attemptDelivery( + 'https://example.com/webhook', + '{"event":"test"}', + 'abc123', + ); + + expect(result.success).toBe(false); + expect(result.statusCode).toBeNull(); + + vi.unstubAllGlobals(); + }); +}); diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 06b87205..7789411a 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -21,8 +21,9 @@ import { followRoutes } from './routes/follow.js'; import { nfcRoutes } from './routes/nfc.js'; import { profileRoutes } from './routes/profiles.js'; import { publicRoutes } from './routes/public.js'; -import { validateEnv } from './utils/validateEnv.js'; import { teamRoutes } from './routes/team.js'; +import { webhookRoutes } from './routes/webhooks.js'; +import { validateEnv } from './utils/validateEnv.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -110,7 +111,7 @@ export async function buildApp():Promise { await app.register(nfcRoutes, { prefix: '/api/nfc' }); await app.register(eventRoutes, {prefix: '/api/events'}) await app.register(teamRoutes, {prefix: '/api/teams'}) - + await app.register(webhookRoutes, { prefix: '/api/webhooks' }); // ─── Health Check ─── type HealthResponse = { diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 27f544d8..13bf39ae 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -277,4 +277,7 @@ export async function publicRoutes(app: FastifyInstance) { return reply.status(500).send({ error: 'QR code generation failed' }) } }); + + // TODO: Hook dispatchWebhook(app.prisma, userId, 'contact.saved', { ... }) + // into the contact save route once that feature is implemented. } diff --git a/apps/backend/src/routes/webhooks.ts b/apps/backend/src/routes/webhooks.ts new file mode 100644 index 00000000..c663ae49 --- /dev/null +++ b/apps/backend/src/routes/webhooks.ts @@ -0,0 +1,241 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import crypto from 'crypto'; +import { z } from 'zod'; +import { encrypt } from '../utils/encryption.js'; + +// ─── Validation Schemas ─── + +const ALLOWED_EVENTS = ['card.viewed', 'contact.saved'] as const; + +const createWebhookSchema = z.object({ + url: z.string().url('Must be a valid URL'), + events: z + .array(z.enum(ALLOWED_EVENTS)) + .min(1, 'At least one event is required'), +}); + +// ─── Route Definitions ─── + +export async function webhookRoutes(app: FastifyInstance) { + // All webhook routes require authentication + app.addHook('preHandler', app.authenticate); + + // ─── Register Webhook Endpoint ─── + /** + * POST /api/webhooks + * Creates a new webhook endpoint for the authenticated user. + * Max 5 endpoints per user. Auto-generates and encrypts a secret. + * Returns the plaintext secret once — user must store it. + */ + app.post('/', { + schema: { + body: { + type: 'object', + required: ['url', 'events'], + properties: { + url: { type: 'string', format: 'uri' }, + events: { + type: 'array', + items: { type: 'string', enum: ['card.viewed', 'contact.saved'] }, + minItems: 1, + }, + }, + }, + }, + }, async (request: FastifyRequest, reply: FastifyReply) => { + const userId = (request.user as any).id; + const parsed = createWebhookSchema.safeParse(request.body); + + if (!parsed.success) { + return reply.status(400).send({ + error: 'Validation failed', + details: parsed.error.flatten(), + }); + } + + try { + const endpoint = await app.prisma.$transaction(async (tx: any) => { + const existingCount = await tx.webhookEndpoint.count({ + where: { userId }, + }); + + if (existingCount >= 5) { + throw Object.assign(new Error('Maximum of 5 webhook endpoints allowed per user'), { statusCode: 409 }); + } + + const plaintextSecret = crypto.randomBytes(32).toString('hex'); + const encryptedSecret = encrypt(plaintextSecret); + + const created = await tx.webhookEndpoint.create({ + data: { + userId, + url: parsed.data.url, + secret: encryptedSecret, + events: parsed.data.events, + }, + }); + + return { ...created, plaintextSecret }; + }); + + return reply.status(201).send({ + id: endpoint.id, + url: endpoint.url, + events: endpoint.events, + isActive: endpoint.isActive, + createdAt: endpoint.createdAt, + secret: endpoint.plaintextSecret, + }); + } catch (err: any) { + if (err.statusCode === 409) { + return reply.status(409).send({ error: err.message }); + } + throw err; + } + }); + + // ─── List Webhook Endpoints ─── + /** + * GET /api/webhooks + * Returns all webhook endpoints for the authenticated user. + * The secret field is never returned. + */ + app.get('/', { + schema: { + querystring: { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 }, + }, + }, + }, + }, async (request: FastifyRequest, reply: FastifyReply) => { + const userId = (request.user as any).id; + const limit = Math.min(100, parseInt((request.query as any).limit || '20', 10)); + + const endpoints = await app.prisma.webhookEndpoint.findMany({ + where: { userId }, + select: { + id: true, + url: true, + events: true, + isActive: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + + return endpoints; + }); + + // ─── Delete Webhook Endpoint ─── + /** + * DELETE /api/webhooks/:id + * Removes a webhook endpoint. Only the owner can delete their own endpoints. + */ + app.delete('/:id', async ( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ) => { + const userId = (request.user as any).id; + const { id } = request.params; + + const endpoint = await app.prisma.webhookEndpoint.findFirst({ + where: { id, userId }, + }); + + if (!endpoint) { + return reply.status(404).send({ error: 'Webhook endpoint not found' }); + } + + await app.prisma.webhookEndpoint.delete({ where: { id } }); + return reply.status(204).send(); + }); + + // ─── Delivery Logs ─── + /** + * GET /api/webhooks/:id/deliveries + * Returns paginated delivery logs for a specific endpoint. + * Query params: ?page=1&limit=20 + */ + app.get('/:id/deliveries', async ( + request: FastifyRequest<{ + Params: { id: string }; + Querystring: { page?: string; limit?: string }; + }>, + reply: FastifyReply, + ) => { + const userId = (request.user as any).id; + const { id } = request.params; + const page = Math.max(1, parseInt((request.query as any).page || '1', 10)); + const limit = Math.min(100, Math.max(1, parseInt((request.query as any).limit || '20', 10))); + + // Verify ownership + const endpoint = await app.prisma.webhookEndpoint.findFirst({ + where: { id, userId }, + }); + + if (!endpoint) { + return reply.status(404).send({ error: 'Webhook endpoint not found' }); + } + + const [deliveries, total] = await Promise.all([ + app.prisma.webhookDelivery.findMany({ + where: { endpointId: id }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + app.prisma.webhookDelivery.count({ + where: { endpointId: id }, + }), + ]); + + return { + data: deliveries, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + }); + + // ─── Rotate Secret ─── + /** + * PATCH /api/webhooks/:id/rotate-secret + * Generates a new secret for the endpoint. + * Returns the new plaintext secret once — user must store it. + */ + app.patch('/:id/rotate-secret', async ( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ) => { + const userId = (request.user as any).id; + const { id } = request.params; + + const endpoint = await app.prisma.webhookEndpoint.findFirst({ + where: { id, userId }, + }); + + if (!endpoint) { + return reply.status(404).send({ error: 'Webhook endpoint not found' }); + } + + const plaintextSecret = crypto.randomBytes(32).toString('hex'); + const encryptedSecret = encrypt(plaintextSecret); + + await app.prisma.webhookEndpoint.update({ + where: { id }, + data: { secret: encryptedSecret }, + }); + + return { + id: endpoint.id, + secret: plaintextSecret, + message: 'Secret rotated successfully. Store this secret — it will not be shown again.', + }; + }); +} \ No newline at end of file diff --git a/apps/backend/src/services/publicService.ts b/apps/backend/src/services/publicService.ts index 758ab78f..eaab248e 100644 --- a/apps/backend/src/services/publicService.ts +++ b/apps/backend/src/services/publicService.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify' import { getErrorMessage } from '../utils/error.util.js' +import { dispatchWebhook } from '../utils/webhookDispatch.js' const PROFILE_CACHE_TTL = 300 const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60' @@ -14,6 +15,7 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v const { _userId, ...profileData } = JSON.parse(cached) if (viewerId && viewerId !== _userId) { app.prisma.cardView.create({ data: { ownerId: _userId, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((err: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(err)}`)) + dispatchWebhook(app.prisma, _userId, 'card.viewed', { event: 'card.viewed', cardId: null, viewerId, source: request.query?.source || 'link', timestamp: new Date().toISOString() }).catch((err: unknown) => app.log.error(`Webhook dispatch failed: ${getErrorMessage(err)}`)) } return { cached: true, data: profileData, cacheKey } } @@ -27,6 +29,7 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v if (viewerId && viewerId !== user.id) { app.prisma.cardView.create({ data: { ownerId: user.id, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) + dispatchWebhook(app.prisma, user.id, 'card.viewed', { event: 'card.viewed', cardId: null, viewerId, source: request.query?.source || 'link', timestamp: new Date().toISOString() }).catch((error: unknown) => app.log.error(`Webhook dispatch failed: ${getErrorMessage(error)}`)) } let followedLinkIds: string[] = [] @@ -60,6 +63,7 @@ export async function getUserCard(app: FastifyInstance, username: string, cardId if (viewerId && viewerId !== user.id) { app.prisma.cardView.create({ data: { ownerId: user.id, cardId: card.id, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'qr' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) + dispatchWebhook(app.prisma, user.id, 'card.viewed', { event: 'card.viewed', cardId: card.id, viewerId, source: request.query?.source || 'qr', timestamp: new Date().toISOString() }).catch((error: unknown) => app.log.error(`Webhook dispatch failed: ${getErrorMessage(error)}`)) } const response = { title: card.title, owner: { username: user.username, displayName: user.displayName, bio: user.bio, pronouns: user.pronouns, role: user.role, company: user.company, avatarUrl: user.avatarUrl, accentColor: user.accentColor }, links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url, displayOrder: cl.displayOrder })) } diff --git a/apps/backend/src/utils/webhookDispatch.ts b/apps/backend/src/utils/webhookDispatch.ts new file mode 100644 index 00000000..09997218 --- /dev/null +++ b/apps/backend/src/utils/webhookDispatch.ts @@ -0,0 +1,180 @@ +import crypto from 'crypto'; +import { decrypt } from './encryption.js'; + +// Use a minimal type for the Prisma client to avoid depending on generated types. +// The actual PrismaClient instance is provided at runtime via the Fastify plugin. +type PrismaLike = { + webhookEndpoint: { + findMany: (args: any) => Promise; + }; + webhookDelivery: { + findUnique: (args: any) => Promise; + create: (args: any) => Promise; + update: (args: any) => Promise; + }; +}; + +// Retry delays in milliseconds: 30s, 5min, 30min +const RETRY_DELAYS_MS = [30_000, 300_000, 1_800_000]; +const MAX_ATTEMPTS = 3; +const DELIVERY_TIMEOUT_MS = 5_000; + +/** + * Sign a JSON payload string with HMAC-SHA256. + * Returns the hex digest string (without the "sha256=" prefix). + */ +export function signPayload(secret: string, payload: string): string { + return crypto.createHmac('sha256', secret).update(payload).digest('hex'); +} + +/** + * Attempt a single webhook delivery. + * Returns { success, statusCode } indicating whether the remote accepted (2xx). + */ +export async function attemptDelivery( + url: string, + payloadString: string, + signature: string, +): Promise<{ success: boolean; statusCode: number | null }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-DevCard-Signature': `sha256=${signature}`, + }, + body: payloadString, + signal: controller.signal, + }); + + clearTimeout(timeout); + return { + success: response.status >= 200 && response.status < 300, + statusCode: response.status, + }; + } catch { + clearTimeout(timeout); + return { success: false, statusCode: null }; + } +} + +/** + * Deliver a single webhook and handle retries. + * This function updates the WebhookDelivery record in the database after each attempt. + */ +export async function deliverWebhook( + prisma: PrismaLike, + deliveryId: string, + endpointUrl: string, + encryptedSecret: string, + payloadString: string, +): Promise { + const secret = decrypt(encryptedSecret); + const signature = signPayload(secret, payloadString); + const { success, statusCode } = await attemptDelivery(endpointUrl, payloadString, signature); + + // Fetch current delivery to get attempt count + const delivery = await prisma.webhookDelivery.findUnique({ + where: { id: deliveryId }, + }); + + if (!delivery) return; + + const newAttempts = delivery.attempts + 1; + + if (success) { + await prisma.webhookDelivery.update({ + where: { id: deliveryId }, + data: { + status: 'success', + responseCode: statusCode, + attempts: newAttempts, + nextRetryAt: null, + }, + }); + return; + } + + // Failed — check if we can retry + if (newAttempts < MAX_ATTEMPTS) { + const delayMs = RETRY_DELAYS_MS[newAttempts - 1] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1]; + const nextRetryAt = new Date(Date.now() + delayMs); + + await prisma.webhookDelivery.update({ + where: { id: deliveryId }, + data: { + attempts: newAttempts, + responseCode: statusCode, + nextRetryAt, + }, + }); + + // Schedule retry (non-blocking, in-process) + setTimeout(() => { + deliverWebhook(prisma, deliveryId, endpointUrl, encryptedSecret, payloadString).catch( + () => {}, // Silently catch — delivery status is tracked in DB + ); + }, delayMs); + } else { + // Exhausted all retries + await prisma.webhookDelivery.update({ + where: { id: deliveryId }, + data: { + status: 'failed', + responseCode: statusCode, + attempts: newAttempts, + nextRetryAt: null, + }, + }); + } +} + +/** + * Dispatch a webhook event to all active endpoints for a given user. + * Creates WebhookDelivery records and kicks off async delivery for each. + * + * @param prisma - Prisma client instance + * @param userId - The user whose endpoints should be notified + * @param event - Event name, e.g. "card.viewed" or "contact.saved" + * @param payload - Arbitrary JSON-serialisable payload object + */ +export async function dispatchWebhook( + prisma: PrismaLike, + userId: string, + event: string, + payload: Record, +): Promise { + // Find all active endpoints for this user that are subscribed to this event + const endpoints = await prisma.webhookEndpoint.findMany({ + where: { + userId, + isActive: true, + events: { has: event }, + }, + }); + + if (endpoints.length === 0) return; + + const payloadString = JSON.stringify(payload); + + for (const endpoint of endpoints) { + // Create a pending delivery record + const delivery = await prisma.webhookDelivery.create({ + data: { + endpointId: endpoint.id, + eventType: event, + payload, + status: 'pending', + attempts: 0, + }, + }); + + // Fire-and-forget delivery (non-blocking) + deliverWebhook(prisma, delivery.id, endpoint.url, endpoint.secret, payloadString).catch( + () => {}, // Errors are tracked in the delivery record + ); + } +}