chore: initial Vector 2.0 monorepo
CI / Lint · Typecheck · Test · Build (push) Failing after 5m41s
CI / Playwright (smoke) (push) Has been skipped

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:
2026-04-16 20:52:32 -04:00
commit 7c0d422228
216 changed files with 19393 additions and 0 deletions
+48
View File
@@ -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);
});
});
+23
View File
@@ -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;
+9
View File
@@ -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' } } }),
});
+84
View File
@@ -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');
}