138 lines
4.7 KiB
TypeScript
138 lines
4.7 KiB
TypeScript
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<string, string>).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<string, string>;
|
|
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) };
|
|
});
|
|
}
|