import type { FastifyInstance } from 'fastify'; import { CreateSubscriptionSchema, UpdateSubscriptionSchema, WEBHOOK_EVENT_TYPES, } from './types.js'; import * as repo from './repository.js'; import { dispatchEvent } from './dispatcher.js'; import { extractAuth } from '../../lib/auth.js'; import { BadRequestError } from '@bytelyst/errors'; import { PRODUCT_ID } from '../../lib/product-config.js'; export async function webhookRoutes(app: FastifyInstance) { // Event types — must be before :id param route app.get('/webhooks/event-types', async (_req, reply) => { return reply.send({ eventTypes: WEBHOOK_EVENT_TYPES.map(type => ({ type, category: type.split('.')[0], action: type.split('.')[1], })), }); }); // Test — must be before :id param route app.post('/webhooks/test', async (req, reply) => { const auth = await extractAuth(req); const body = req.body as { subscriptionId?: string; eventType?: string }; if (!body.subscriptionId) { throw new BadRequestError('subscriptionId is required'); } await repo.getSubscription(body.subscriptionId, auth.sub); const eventType = (body.eventType || 'timer.fired') as (typeof WEBHOOK_EVENT_TYPES)[number]; if (!WEBHOOK_EVENT_TYPES.includes(eventType)) { throw new BadRequestError(`Invalid event type: ${eventType}`); } const results = await dispatchEvent( auth.sub, PRODUCT_ID, eventType, { test: true, message: 'This is a test webhook event from ChronoMind', timestamp: new Date().toISOString(), }, req.log ); return reply.send({ results }); }); // List subscriptions app.get('/webhooks', async req => { const auth = await extractAuth(req); return repo.listSubscriptions(auth.sub, PRODUCT_ID); }); // Get subscription app.get('/webhooks/:id', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; return repo.getSubscription(id, auth.sub); }); // Create subscription app.post('/webhooks', async (req, reply) => { const auth = await extractAuth(req); const parsed = CreateSubscriptionSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const id = crypto.randomUUID(); const sub = await repo.createSubscription(id, auth.sub, PRODUCT_ID, parsed.data); return reply.status(201).send(sub); }); // Update subscription app.put('/webhooks/:id', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; const parsed = UpdateSubscriptionSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } return repo.updateSubscription(id, auth.sub, parsed.data); }); // Delete subscription app.delete('/webhooks/:id', async (req, reply) => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; await repo.deleteSubscription(id, auth.sub); return reply.status(204).send(); }); // List events for subscription app.get('/webhooks/:id/events', async req => { const auth = await extractAuth(req); const { id } = req.params as { id: string }; // Verify ownership await repo.getSubscription(id, auth.sub); const limit = parseInt((req.query as Record).limit || '50', 10); return repo.listEvents(id, Math.min(limit, 100)); }); // ── Zapier-compatible polling endpoint ───────────────────── // GET /webhooks/zapier/poll?eventType=timer.fired // Returns recent events for Zapier polling triggers app.get('/webhooks/zapier/poll', async req => { const auth = await extractAuth(req); const q = req.query as Record; const eventType = q.eventType; if (!eventType || !WEBHOOK_EVENT_TYPES.includes(eventType as (typeof WEBHOOK_EVENT_TYPES)[number])) { throw new BadRequestError(`eventType query parameter must be one of: ${WEBHOOK_EVENT_TYPES.join(', ')}`); } const subs = await repo.listSubscriptions(auth.sub, PRODUCT_ID); const matching = subs.filter(s => s.events.includes(eventType as (typeof WEBHOOK_EVENT_TYPES)[number])); if (matching.length === 0) { return { items: [] }; } // Return events from all matching subscriptions const allEvents = []; for (const sub of matching) { const events = await repo.listEvents(sub.id, 10); allEvents.push(...events); } // Sort by creation time descending allEvents.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); return { items: allEvents.slice(0, 25) }; }); }