7c0d422228
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.
139 lines
4.1 KiB
TypeScript
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');
|
|
}
|