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.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AppError, errors } from './http-error.js';
|
||||
|
||||
describe('AppError', () => {
|
||||
it('carries status, code, message, and optional details', () => {
|
||||
const e = new AppError(418, 'TEAPOT', 'short and stout', { reason: 'tea' });
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect(e.status).toBe(418);
|
||||
expect(e.code).toBe('TEAPOT');
|
||||
expect(e.message).toBe('short and stout');
|
||||
expect(e.details).toEqual({ reason: 'tea' });
|
||||
expect(e.name).toBe('AppError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors factory', () => {
|
||||
it('unauthorized defaults and overrides', () => {
|
||||
const def = errors.unauthorized();
|
||||
expect(def.status).toBe(401);
|
||||
expect(def.code).toBe('UNAUTHORIZED');
|
||||
expect(def.message).toBe('Unauthorized');
|
||||
expect(errors.unauthorized('custom').message).toBe('custom');
|
||||
});
|
||||
|
||||
it('notFound uses resource name in message', () => {
|
||||
const e = errors.notFound('Part');
|
||||
expect(e.status).toBe(404);
|
||||
expect(e.message).toBe('Part not found');
|
||||
});
|
||||
|
||||
it('validation wraps details', () => {
|
||||
const e = errors.validation({ field: 'x' });
|
||||
expect(e.status).toBe(400);
|
||||
expect(e.code).toBe('VALIDATION_ERROR');
|
||||
expect(e.details).toEqual({ field: 'x' });
|
||||
});
|
||||
|
||||
it('conflict requires a message', () => {
|
||||
const e = errors.conflict('serial already exists');
|
||||
expect(e.status).toBe(409);
|
||||
expect(e.code).toBe('CONFLICT');
|
||||
expect(e.message).toBe('serial already exists');
|
||||
});
|
||||
|
||||
it('tooManyRequests returns 429', () => {
|
||||
expect(errors.tooManyRequests().status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export class AppError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details?: unknown;
|
||||
|
||||
constructor(status: number, code: string, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export const errors = {
|
||||
unauthorized: (msg = 'Unauthorized') => new AppError(401, 'UNAUTHORIZED', msg),
|
||||
forbidden: (msg = 'Forbidden') => new AppError(403, 'FORBIDDEN', msg),
|
||||
notFound: (resource: string) => new AppError(404, 'NOT_FOUND', `${resource} not found`),
|
||||
conflict: (msg: string) => new AppError(409, 'CONFLICT', msg),
|
||||
badRequest: (msg: string, details?: unknown) => new AppError(400, 'BAD_REQUEST', msg, details),
|
||||
validation: (details: unknown) => new AppError(400, 'VALIDATION_ERROR', 'Validation failed', details),
|
||||
tooManyRequests: (msg = 'Too many requests') => new AppError(429, 'RATE_LIMITED', msg),
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
import pino from 'pino';
|
||||
import { env } from '../env.js';
|
||||
|
||||
export const logger = pino({
|
||||
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
...(env.NODE_ENV === 'production'
|
||||
? {}
|
||||
: { transport: { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss.l' } } }),
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { WebhookEventName } from '@vector/shared';
|
||||
import { prisma } from '@vector/db';
|
||||
import * as webhooksSvc from '../services/webhooks.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// Recursion guard: deliveries include this header so receivers know the payload
|
||||
// originated from Vector and can short-circuit echo loops. Worker-side BullMQ
|
||||
// delivery (planned in the Phase 7 follow-up) will honor the same header plus a
|
||||
// max-depth check.
|
||||
export const VECTOR_HOOK_HEADER = 'x-vector-webhook';
|
||||
|
||||
const DELIVERY_TIMEOUT_MS = 8_000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const BACKOFF_MS = [0, 2_000, 10_000];
|
||||
|
||||
interface EmitOptions {
|
||||
event: WebhookEventName;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Fire-and-forget: collects active subscriptions for the event and schedules delivery
|
||||
// to each. Never throws into caller. This is the interim in-process implementation;
|
||||
// the plan calls for a BullMQ worker — keep the signature stable so swapping stays
|
||||
// a one-line change in `emit`.
|
||||
export async function emit({ event, payload }: EmitOptions): Promise<void> {
|
||||
const subs = await prisma
|
||||
.$transaction((tx) => webhooksSvc.listActiveForEvent(tx, event))
|
||||
.catch((err) => {
|
||||
logger.warn({ err, event }, 'webhook emit: subscription lookup failed');
|
||||
return [];
|
||||
});
|
||||
if (subs.length === 0) return;
|
||||
const body = JSON.stringify({ event, data: payload, emittedAt: new Date().toISOString() });
|
||||
for (const sub of subs) {
|
||||
if (!sub.secret) continue;
|
||||
void deliver(sub.id, sub.url, sub.secret, body, event).catch((err) => {
|
||||
logger.warn({ err, event, subId: sub.id }, 'webhook delivery crashed');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function deliver(
|
||||
subId: string,
|
||||
url: string,
|
||||
secret: string,
|
||||
body: string,
|
||||
event: WebhookEventName,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
||||
const wait = BACKOFF_MS[attempt] ?? 0;
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signature = webhooksSvc.signBody(secret, body, timestamp);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
[VECTOR_HOOK_HEADER]: 'v1',
|
||||
'x-vector-event': event,
|
||||
'x-vector-timestamp': String(timestamp),
|
||||
'x-vector-signature': signature,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (res.ok) {
|
||||
logger.debug({ subId, event, status: res.status, attempt }, 'webhook delivered');
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
{ subId, event, status: res.status, attempt },
|
||||
'webhook non-2xx, will retry',
|
||||
);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
logger.warn({ err, subId, event, attempt }, 'webhook delivery error');
|
||||
}
|
||||
}
|
||||
logger.error({ subId, event }, 'webhook delivery exhausted retries');
|
||||
}
|
||||
Reference in New Issue
Block a user