Files
Vector/apps/api/src/services/webhooks.ts
T
josh 7c0d422228
CI / Lint · Typecheck · Test · Build (push) Failing after 5m41s
CI / Playwright (smoke) (push) Has been skipped
chore: initial Vector 2.0 monorepo
Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:

- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate

Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
2026-04-16 20:52:32 -04:00

139 lines
4.1 KiB
TypeScript

import crypto from 'node:crypto';
import { Prisma } from '@vector/db';
import type {
CreateWebhookSubscriptionRequest,
UpdateWebhookSubscriptionRequest,
WebhookEventName,
WebhookSubscriptionListQuery,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
// The DB stores `events` as a JSON string (pending Postgres cutover to String[]).
// Parse on the way out, stringify on the way in. Keep this boundary in the service.
interface StoredSubscription {
id: string;
url: string;
secret: string;
events: string;
active: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface WebhookSubscriptionDto {
id: string;
url: string;
events: WebhookEventName[];
active: boolean;
createdAt: string;
updatedAt: string;
// `secret` is returned only on create so operators can copy it into their receiver config.
secret?: string;
}
function toDto(sub: StoredSubscription, includeSecret = false): WebhookSubscriptionDto {
let events: WebhookEventName[] = [];
try {
const parsed = JSON.parse(sub.events);
if (Array.isArray(parsed)) events = parsed as WebhookEventName[];
} catch {
events = [];
}
return {
id: sub.id,
url: sub.url,
events,
active: sub.active,
createdAt: sub.createdAt.toISOString(),
updatedAt: sub.updatedAt.toISOString(),
...(includeSecret ? { secret: sub.secret } : {}),
};
}
export async function list(tx: Tx, q: WebhookSubscriptionListQuery) {
const { page, pageSize, active } = q;
const where: Prisma.WebhookSubscriptionWhereInput = {};
if (active !== undefined) where.active = active;
const [rows, total] = await Promise.all([
tx.webhookSubscription.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.webhookSubscription.count({ where }),
]);
return { data: rows.map((r) => toDto(r)), page, pageSize, total };
}
export async function create(tx: Tx, input: CreateWebhookSubscriptionRequest) {
const secret = crypto.randomBytes(24).toString('base64url');
const row = await tx.webhookSubscription.create({
data: {
url: input.url,
secret,
events: JSON.stringify(input.events),
active: input.active ?? true,
},
});
return toDto(row, true);
}
export async function update(tx: Tx, id: string, input: UpdateWebhookSubscriptionRequest) {
const data: Prisma.WebhookSubscriptionUpdateInput = {};
if (input.url !== undefined) data.url = input.url;
if (input.events !== undefined) data.events = JSON.stringify(input.events);
if (input.active !== undefined) data.active = input.active;
try {
const row = await tx.webhookSubscription.update({ where: { id }, data });
return toDto(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('WebhookSubscription');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.webhookSubscription.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('WebhookSubscription');
}
throw err;
}
}
export async function rotateSecret(tx: Tx, id: string) {
const secret = crypto.randomBytes(24).toString('base64url');
try {
const row = await tx.webhookSubscription.update({
where: { id },
data: { secret },
});
return toDto(row, true);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('WebhookSubscription');
}
throw err;
}
}
export async function listActiveForEvent(tx: Tx, event: WebhookEventName) {
const rows = await tx.webhookSubscription.findMany({ where: { active: true } });
return rows
.map((r) => toDto(r, true))
.filter((s) => s.events.includes(event));
}
export function signBody(secret: string, body: string, timestamp: number): string {
return crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
}