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
+95
View File
@@ -0,0 +1,95 @@
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import helmet from 'helmet';
import { pinoHttp } from 'pino-http';
import rateLimit from 'express-rate-limit';
import { prisma } from '@vector/db';
import { env } from './env.js';
import { logger } from './lib/logger.js';
import { requestId } from './middleware/request-id.js';
import { requireCsrf } from './middleware/csrf.js';
import { errorHandler } from './middleware/error.js';
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import manufacturerRoutes from './routes/manufacturers.js';
import siteRoutes from './routes/sites.js';
import roomRoutes from './routes/rooms.js';
import binRoutes from './routes/bins.js';
import partRoutes from './routes/parts.js';
import tagRoutes from './routes/tags.js';
import categoryRoutes from './routes/categories.js';
import hostRoutes from './routes/hosts.js';
import repairRoutes from './routes/repairs.js';
import savedViewRoutes from './routes/saved-views.js';
import analyticsRoutes from './routes/analytics.js';
import webhookRoutes from './routes/webhooks.js';
import auditRoutes from './routes/audit.js';
export const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(helmet({ contentSecurityPolicy: false, crossOriginResourcePolicy: { policy: 'same-site' } }));
app.use(
cors({
origin: env.CLIENT_ORIGIN,
credentials: true,
}),
);
app.use(express.json({ limit: '1mb' }));
app.use(cookieParser());
app.use(requestId);
app.use(
pinoHttp({
logger,
customProps: (req) => ({ requestId: (req as express.Request).requestId }),
customLogLevel: (_req, res, err) => {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
}),
);
app.get('/healthz', (_req, res) => {
res.json({ status: 'ok' });
});
app.get('/readyz', async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`;
res.json({ status: 'ok', db: 'ok' });
} catch {
res.status(503).json({ status: 'error', db: 'unreachable' });
}
});
const authLimiter = rateLimit({
windowMs: 60 * 1000,
limit: env.NODE_ENV === 'production' ? 5 : 50,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { code: 'RATE_LIMITED', message: 'Too many auth requests. Try again soon.' },
});
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api', requireCsrf);
app.use('/api/users', userRoutes);
app.use('/api/manufacturers', manufacturerRoutes);
app.use('/api/sites', siteRoutes);
app.use('/api/rooms', roomRoutes);
app.use('/api/bins', binRoutes);
app.use('/api/parts', partRoutes);
app.use('/api/tags', tagRoutes);
app.use('/api/categories', categoryRoutes);
app.use('/api/hosts', hostRoutes);
app.use('/api/repairs', repairRoutes);
app.use('/api/saved-views', savedViewRoutes);
app.use('/api/analytics', analyticsRoutes);
app.use('/api/admin/webhooks', webhookRoutes);
app.use('/api/admin/audit', auditRoutes);
app.use(errorHandler);
+12
View File
@@ -0,0 +1,12 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import * as svc from '../services/analytics.js';
export async function dashboard(_req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.$transaction((tx) => svc.dashboard(tx));
res.json(data);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { csvCell } from './audit-export.js';
describe('csvCell', () => {
it('returns empty string for null / undefined', () => {
expect(csvCell(null)).toBe('');
expect(csvCell(undefined)).toBe('');
});
it('stringifies primitives', () => {
expect(csvCell('hi')).toBe('hi');
expect(csvCell(42)).toBe('42');
expect(csvCell(true)).toBe('true');
});
it('formats Date as ISO string', () => {
const d = new Date('2026-01-01T00:00:00.000Z');
expect(csvCell(d)).toBe('2026-01-01T00:00:00.000Z');
});
it('quotes values containing commas', () => {
expect(csvCell('a,b')).toBe('"a,b"');
});
it('quotes values containing newlines', () => {
expect(csvCell('line1\nline2')).toBe('"line1\nline2"');
expect(csvCell('line1\r\nline2')).toBe('"line1\r\nline2"');
});
it('escapes embedded double-quotes by doubling them', () => {
expect(csvCell('say "hi"')).toBe('"say ""hi"""');
});
it('leaves plain text untouched', () => {
expect(csvCell('plain-text_123')).toBe('plain-text_123');
});
});
+100
View File
@@ -0,0 +1,100 @@
import type { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { prisma, Prisma } from '@vector/db';
import { PartEventType } from '@vector/shared';
const Query = z.object({
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
type: PartEventType.optional(),
partId: z.string().uuid().optional(),
});
const HEADERS = [
'createdAt',
'eventType',
'partId',
'serialNumber',
'field',
'oldValue',
'newValue',
'actorUsername',
];
// CSV-escape: wrap in quotes, double up embedded quotes. Handles commas, newlines, quotes.
export function csvCell(value: unknown): string {
if (value === null || value === undefined) return '';
const str = value instanceof Date ? value.toISOString() : String(value);
if (/["\n\r,]/.test(str)) return `"${str.replace(/"/g, '""')}"`;
return str;
}
export async function eventsCsv(req: Request, res: Response, next: NextFunction) {
try {
const parsed = Query.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({
code: 'VALIDATION_FAILED',
message: 'Invalid export filters',
issues: parsed.error.issues,
});
return;
}
const { from, to, type, partId } = parsed.data;
const where: Prisma.PartEventWhereInput = {};
if (type) where.type = type;
if (partId) where.partId = partId;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = from;
if (to) where.createdAt.lte = to;
}
res.setHeader('content-type', 'text/csv; charset=utf-8');
res.setHeader(
'content-disposition',
`attachment; filename="vector-audit-${new Date().toISOString().slice(0, 10)}.csv"`,
);
res.setHeader('cache-control', 'no-store');
res.write(HEADERS.join(',') + '\n');
// Keyset-paginate by createdAt+id so we never materialize the full table in memory.
const BATCH = 1000;
let cursor: { id: string } | undefined;
for (;;) {
const rows = await prisma.partEvent.findMany({
where,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: BATCH,
...(cursor ? { skip: 1, cursor } : {}),
include: {
part: { select: { serialNumber: true } },
user: { select: { username: true } },
},
});
if (rows.length === 0) break;
for (const row of rows) {
res.write(
[
csvCell(row.createdAt),
csvCell(row.type),
csvCell(row.partId),
csvCell(row.part.serialNumber),
csvCell(row.field),
csvCell(row.oldValue),
csvCell(row.newValue),
csvCell(row.user?.username ?? null),
].join(',') + '\n',
);
}
if (rows.length < BATCH) break;
const last = rows[rows.length - 1];
if (!last) break;
cursor = { id: last.id };
}
res.end();
} catch (err) {
next(err);
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { CookieOptions, NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type { LoginRequest } from '@vector/shared';
import { env } from '../env.js';
import * as authService from '../services/auth.js';
import { issueCsrfToken } from '../middleware/csrf.js';
import { errors } from '../lib/http-error.js';
const accessCookieOpts: CookieOptions = {
httpOnly: true,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
path: '/',
maxAge: authService.ACCESS_TOKEN_TTL_MS,
};
const refreshCookieOpts: CookieOptions = {
httpOnly: true,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
path: '/api/auth',
maxAge: authService.REFRESH_TOKEN_TTL_MS,
};
function setAuthCookies(res: Response, tokens: authService.AuthTokens) {
res.cookie('token', tokens.accessToken, accessCookieOpts);
res.cookie('refresh', tokens.refreshToken, refreshCookieOpts);
issueCsrfToken(res);
}
function clearAuthCookies(res: Response) {
res.clearCookie('token', { path: '/' });
res.clearCookie('refresh', { path: '/api/auth' });
res.clearCookie('csrf', { path: '/' });
}
export async function login(req: Request, res: Response, next: NextFunction) {
try {
const { username, password } = req.validated!.body as LoginRequest;
const { user, tokens } = await prisma.$transaction((tx) =>
authService.login(tx, username, password),
);
setAuthCookies(res, tokens);
res.json(user);
} catch (err) {
next(err);
}
}
export async function refresh(req: Request, res: Response, next: NextFunction) {
try {
const presented = req.cookies?.refresh;
if (!presented) throw errors.unauthorized('Missing refresh token');
const { user, tokens } = await prisma.$transaction((tx) =>
authService.rotate(tx, presented),
);
setAuthCookies(res, tokens);
res.json(user);
} catch (err) {
next(err);
}
}
export async function logout(req: Request, res: Response, next: NextFunction) {
try {
const presented = req.cookies?.refresh;
await prisma.$transaction((tx) => authService.revoke(tx, presented));
clearAuthCookies(res);
res.json({ message: 'Logged out' });
} catch (err) {
next(err);
}
}
export async function me(req: Request, res: Response, next: NextFunction) {
try {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
if (!user) throw errors.unauthorized();
res.json({ id: user.id, username: user.username, email: user.email, role: user.role });
} catch (err) {
next(err);
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { prisma } from '@vector/db';
import {
CreateBinRequest,
PaginationQuery,
UpdateBinRequest,
} from '@vector/shared';
import * as svc from '../services/locations.js';
import { errors } from '../lib/http-error.js';
const BinListQuery = PaginationQuery.extend({
roomId: z.string().uuid().optional(),
siteId: z.string().uuid().optional(),
});
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as z.infer<typeof BinListQuery>;
const result = await prisma.$transaction((tx) => svc.listBins(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const bin = await prisma.$transaction((tx) => svc.getBin(tx, req.params.id));
if (!bin) throw errors.notFound('Bin');
res.json(bin);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateBinRequest;
const bin = await prisma.$transaction((tx) => svc.createBin(tx, input));
res.status(201).json(bin);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateBinRequest;
const bin = await prisma.$transaction((tx) => svc.updateBin(tx, req.params.id, input));
res.json(bin);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.removeBin(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export { BinListQuery };
+49
View File
@@ -0,0 +1,49 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CategoryListQuery,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@vector/shared';
import * as svc from '../services/categories.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as CategoryListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateCategoryRequest;
const category = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(category);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateCategoryRequest;
const category = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input),
);
res.json(category);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateHostRequest,
HostListQuery,
UpdateHostRequest,
} from '@vector/shared';
import * as svc from '../services/hosts.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as HostListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const host = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!host) throw errors.notFound('Host');
res.json(host);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateHostRequest;
const host = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(host);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateHostRequest;
const host = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(host);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateManufacturerRequest,
PaginationQuery,
UpdateManufacturerRequest,
} from '@vector/shared';
import * as svc from '../services/manufacturers.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = (req.validated!.query as PaginationQuery);
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateManufacturerRequest;
const m = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(m);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateManufacturerRequest;
const m = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(m);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export { errors };
+88
View File
@@ -0,0 +1,88 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
BulkPartsRequest,
CreatePartRequest,
PartEventsQuery,
PartListQuery,
UpdatePartRequest,
} from '@vector/shared';
import * as svc from '../services/parts.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PartListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const part = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!part) throw errors.notFound('Part');
res.json(part);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreatePartRequest;
const part = await prisma.$transaction((tx) =>
svc.create(tx, input, req.user ?? null),
);
res.status(201).json(part);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdatePartRequest;
const part = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input, req.user ?? null),
);
res.json(part);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export async function bulk(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as BulkPartsRequest;
const result = await prisma.$transaction((tx) =>
svc.bulkUpdate(tx, input, req.user ?? null),
);
res.json(result);
} catch (err) {
next(err);
}
}
export async function getEvents(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PartEventsQuery;
const result = await prisma.$transaction((tx) =>
svc.listEvents(tx, req.params.id, q),
);
res.json(result);
} catch (err) {
next(err);
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateRepairJobRequest,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
import * as svc from '../services/repairs.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as RepairJobListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const repair = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!repair) throw errors.notFound('Repair');
res.json(repair);
} catch (err) {
next(err);
}
}
export async function listForPart(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const repairs = await prisma.$transaction((tx) => svc.listForPart(tx, req.params.id));
res.json(repairs);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateRepairJobRequest;
const repair = await prisma.$transaction((tx) =>
svc.create(tx, input, req.user ?? null),
);
res.status(201).json(repair);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateRepairJobRequest;
const repair = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input, req.user ?? null),
);
res.json(repair);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+63
View File
@@ -0,0 +1,63 @@
import type { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { prisma } from '@vector/db';
import {
CreateRoomRequest,
PaginationQuery,
UpdateRoomRequest,
} from '@vector/shared';
import * as svc from '../services/locations.js';
import { errors } from '../lib/http-error.js';
const RoomListQuery = PaginationQuery.extend({ siteId: z.string().uuid().optional() });
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as z.infer<typeof RoomListQuery>;
const result = await prisma.$transaction((tx) => svc.listRooms(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const room = await prisma.$transaction((tx) => svc.getRoom(tx, req.params.id));
if (!room) throw errors.notFound('Room');
res.json(room);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateRoomRequest;
const room = await prisma.$transaction((tx) => svc.createRoom(tx, input));
res.status(201).json(room);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateRoomRequest;
const room = await prisma.$transaction((tx) => svc.updateRoom(tx, req.params.id, input));
res.json(room);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.removeRoom(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export { RoomListQuery };
+54
View File
@@ -0,0 +1,54 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateSavedViewRequest,
SavedViewListQuery,
UpdateSavedViewRequest,
} from '@vector/shared';
import * as svc from '../services/saved-views.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
const q = req.validated!.query as SavedViewListQuery;
const result = await prisma.$transaction((tx) => svc.listMine(tx, req.user!, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
const input = req.validated!.body as CreateSavedViewRequest;
const view = await prisma.$transaction((tx) => svc.create(tx, req.user!, input));
res.status(201).json(view);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
const input = req.validated!.body as UpdateSavedViewRequest;
const view = await prisma.$transaction((tx) =>
svc.update(tx, req.user!, req.params.id, input),
);
res.json(view);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
await prisma.$transaction((tx) => svc.remove(tx, req.user!, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateSiteRequest,
PaginationQuery,
UpdateSiteRequest,
} from '@vector/shared';
import * as svc from '../services/locations.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PaginationQuery;
const result = await prisma.$transaction((tx) => svc.listSites(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const site = await prisma.$transaction((tx) => svc.getSite(tx, req.params.id));
if (!site) throw errors.notFound('Site');
res.json(site);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateSiteRequest;
const site = await prisma.$transaction((tx) => svc.createSite(tx, input));
res.status(201).json(site);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateSiteRequest;
const site = await prisma.$transaction((tx) => svc.updateSite(tx, req.params.id, input));
res.json(site);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.removeSite(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+92
View File
@@ -0,0 +1,92 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
AssignTagsRequest,
CreateTagRequest,
TagListQuery,
UpdateTagRequest,
} from '@vector/shared';
import * as svc from '../services/tags.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as TagListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateTagRequest;
const tag = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(tag);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateTagRequest;
const tag = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(tag);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export async function listForPart(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const tags = await prisma.$transaction((tx) => svc.listForPart(tx, req.params.id));
res.json(tags);
} catch (err) {
next(err);
}
}
export async function assignToPart(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const input = req.validated!.body as AssignTagsRequest;
const tags = await prisma.$transaction((tx) =>
svc.assignToPart(tx, req.params.id, input, req.user ?? null),
);
res.json(tags);
} catch (err) {
next(err);
}
}
export async function unassignFromPart(
req: Request<{ id: string; tagId: string }>,
res: Response,
next: NextFunction,
) {
try {
const tags = await prisma.$transaction((tx) =>
svc.unassignFromPart(tx, req.params.id, req.params.tagId, req.user ?? null),
);
res.json(tags);
} catch (err) {
next(err);
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateUserRequest,
PaginationQuery,
UpdateUserRequest,
} from '@vector/shared';
import * as svc from '../services/users.js';
export async function listUsers(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PaginationQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function createUser(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateUserRequest;
const u = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(u);
} catch (err) {
next(err);
}
}
export async function updateUser(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateUserRequest;
const u = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(u);
} catch (err) {
next(err);
}
}
export async function deleteUser(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateWebhookSubscriptionRequest,
UpdateWebhookSubscriptionRequest,
WebhookSubscriptionListQuery,
} from '@vector/shared';
import * as svc from '../services/webhooks.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as WebhookSubscriptionListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateWebhookSubscriptionRequest;
const sub = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(sub);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateWebhookSubscriptionRequest;
const sub = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(sub);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export async function rotate(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const sub = await prisma.$transaction((tx) => svc.rotateSecret(tx, req.params.id));
res.json(sub);
} catch (err) {
next(err);
}
}
+14
View File
@@ -0,0 +1,14 @@
import 'dotenv/config';
import { ApiEnv } from '@vector/shared';
const parsed = ApiEnv.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join('.') || '(root)'}: ${issue.message}`);
}
process.exit(1);
}
export const env = parsed.data;
+7
View File
@@ -0,0 +1,7 @@
import './env.js';
import { app } from './app.js';
import { env } from './env.js';
app.listen(env.PORT, () => {
console.log(`Vector API listening on port ${env.PORT}`);
});
+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');
}
+32
View File
@@ -0,0 +1,32 @@
import type { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import type { Role } from '@vector/shared';
import { env } from '../env.js';
import { errors } from '../lib/http-error.js';
type JwtPayload = { id: string; username: string; role: Role };
export function requireAuth(req: Request, _res: Response, next: NextFunction) {
const token = req.cookies?.token;
if (!token) {
next(errors.unauthorized());
return;
}
try {
const decoded = jwt.verify(token, env.JWT_SECRET) as JwtPayload;
req.user = { id: decoded.id, username: decoded.username, role: decoded.role };
next();
} catch {
next(errors.unauthorized('Invalid or expired token'));
}
}
export function requireRole(role: Role) {
return (req: Request, _res: Response, next: NextFunction) => {
if (!req.user || req.user.role !== role) {
next(errors.forbidden());
return;
}
next();
};
}
+39
View File
@@ -0,0 +1,39 @@
import { randomBytes, timingSafeEqual } from 'node:crypto';
import type { CookieOptions, NextFunction, Request, Response } from 'express';
import { env } from '../env.js';
import { errors } from '../lib/http-error.js';
export const CSRF_COOKIE = 'csrf';
export const CSRF_HEADER = 'x-csrf-token';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function issueCsrfToken(res: Response): string {
const token = randomBytes(32).toString('hex');
const opts: CookieOptions = {
httpOnly: false,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
path: '/',
};
res.cookie(CSRF_COOKIE, token, opts);
return token;
}
function tokensMatch(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
export function requireCsrf(req: Request, _res: Response, next: NextFunction) {
if (SAFE_METHODS.has(req.method)) {
next();
return;
}
const cookieToken = req.cookies?.[CSRF_COOKIE];
const headerToken = req.header(CSRF_HEADER);
if (!cookieToken || !headerToken || !tokensMatch(cookieToken, headerToken)) {
next(errors.forbidden('CSRF token missing or invalid'));
return;
}
next();
}
+57
View File
@@ -0,0 +1,57 @@
import type { NextFunction, Request, Response } from 'express';
import { ZodError } from 'zod';
import { Prisma } from '@vector/db';
import { AppError } from '../lib/http-error.js';
import { logger } from '../lib/logger.js';
interface ErrorEnvelope {
code: string;
message: string;
requestId: string;
details?: unknown;
}
export function errorHandler(
err: unknown,
req: Request,
res: Response,
_next: NextFunction,
) {
const requestId = req.requestId ?? 'unknown';
let envelope: ErrorEnvelope;
let status = 500;
if (err instanceof AppError) {
status = err.status;
envelope = { code: err.code, message: err.message, requestId, details: err.details };
} else if (err instanceof ZodError) {
status = 400;
envelope = {
code: 'VALIDATION_ERROR',
message: 'Validation failed',
requestId,
details: err.issues,
};
} else if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') {
status = 404;
envelope = { code: 'NOT_FOUND', message: 'Resource not found', requestId };
} else if (err.code === 'P2002') {
status = 409;
envelope = { code: 'CONFLICT', message: 'Unique constraint violated', requestId };
} else if (err.code === 'P2003') {
status = 409;
envelope = { code: 'CONFLICT', message: 'Foreign key constraint violated', requestId };
} else {
envelope = { code: 'DB_ERROR', message: 'Database error', requestId };
}
} else {
envelope = { code: 'INTERNAL_ERROR', message: 'Internal server error', requestId };
}
const logPayload = { requestId, status, err };
if (status >= 500) logger.error(logPayload, 'request failed');
else logger.warn(logPayload, 'request rejected');
res.status(status).json(envelope);
}
+10
View File
@@ -0,0 +1,10 @@
import { randomUUID } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';
export function requestId(req: Request, res: Response, next: NextFunction) {
const incoming = req.header('x-request-id');
const id = incoming && incoming.length <= 128 ? incoming : randomUUID();
req.requestId = id;
res.setHeader('X-Request-Id', id);
next();
}
+17
View File
@@ -0,0 +1,17 @@
import type { NextFunction, Request, Response } from 'express';
import type { ZodTypeAny } from 'zod';
type Target = 'body' | 'query' | 'params';
export function validate(target: Target, schema: ZodTypeAny) {
return (req: Request, _res: Response, next: NextFunction) => {
const result = schema.safeParse(req[target]);
if (!result.success) {
next(result.error);
return;
}
req.validated ??= {};
req.validated[target] = result.data;
next();
};
}
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import * as ctrl from '../controllers/analytics.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
router.get('/dashboard', ctrl.dashboard);
export default router;
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import * as ctrl from '../controllers/audit-export.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth, requireRole('ADMIN'));
router.get('/events.csv', ctrl.eventsCsv);
export default router;
+14
View File
@@ -0,0 +1,14 @@
import { Router } from 'express';
import { LoginRequest } from '@vector/shared';
import * as ctrl from '../controllers/auth.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.post('/login', validate('body', LoginRequest), ctrl.login);
router.post('/refresh', ctrl.refresh);
router.post('/logout', ctrl.logout);
router.get('/me', requireAuth, ctrl.me);
export default router;
+15
View File
@@ -0,0 +1,15 @@
import { Router } from 'express';
import { CreateBinRequest, UpdateBinRequest } from '@vector/shared';
import * as ctrl from '../controllers/bins.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', ctrl.BinListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateBinRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateBinRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express';
import {
CategoryListQuery,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/categories.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', CategoryListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateCategoryRequest), ctrl.create);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateCategoryRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+19
View File
@@ -0,0 +1,19 @@
import { Router } from 'express';
import {
CreateHostRequest,
HostListQuery,
UpdateHostRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/hosts.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateHostRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express';
import {
CreateManufacturerRequest,
PaginationQuery,
UpdateManufacturerRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/manufacturers.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', PaginationQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateManufacturerRequest), ctrl.create);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateManufacturerRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+32
View File
@@ -0,0 +1,32 @@
import { Router } from 'express';
import {
AssignTagsRequest,
BulkPartsRequest,
CreatePartRequest,
PartEventsQuery,
PartListQuery,
UpdatePartRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/parts.js';
import * as tagsCtrl from '../controllers/tags.js';
import * as repairsCtrl from '../controllers/repairs.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', PartListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreatePartRequest), ctrl.create);
router.post('/bulk', requireAuth, validate('body', BulkPartsRequest), ctrl.bulk);
router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/events', requireAuth, validate('query', PartEventsQuery), ctrl.getEvents);
router.patch('/:id', requireAuth, validate('body', UpdatePartRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
router.get('/:id/tags', requireAuth, tagsCtrl.listForPart);
router.post('/:id/tags', requireAuth, validate('body', AssignTagsRequest), tagsCtrl.assignToPart);
router.delete('/:id/tags/:tagId', requireAuth, tagsCtrl.unassignFromPart);
router.get('/:id/repairs', requireAuth, repairsCtrl.listForPart);
export default router;
+19
View File
@@ -0,0 +1,19 @@
import { Router } from 'express';
import {
CreateRepairJobRequest,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/repairs.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', RepairJobListQuery), ctrl.list);
router.post('/', requireAuth, validate('body', CreateRepairJobRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, validate('body', UpdateRepairJobRequest), ctrl.update);
router.delete('/:id', requireAuth, ctrl.remove);
export default router;
+15
View File
@@ -0,0 +1,15 @@
import { Router } from 'express';
import { CreateRoomRequest, UpdateRoomRequest } from '@vector/shared';
import * as ctrl from '../controllers/rooms.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', ctrl.RoomListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateRoomRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateRoomRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+20
View File
@@ -0,0 +1,20 @@
import { Router } from 'express';
import {
CreateSavedViewRequest,
SavedViewListQuery,
UpdateSavedViewRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/saved-views.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.use(requireAuth);
router.get('/', validate('query', SavedViewListQuery), ctrl.list);
router.post('/', validate('body', CreateSavedViewRequest), ctrl.create);
router.patch('/:id', validate('body', UpdateSavedViewRequest), ctrl.update);
router.delete('/:id', ctrl.remove);
export default router;
+15
View File
@@ -0,0 +1,15 @@
import { Router } from 'express';
import { CreateSiteRequest, PaginationQuery, UpdateSiteRequest } from '@vector/shared';
import * as ctrl from '../controllers/sites.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', PaginationQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateSiteRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateSiteRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+19
View File
@@ -0,0 +1,19 @@
import { Router } from 'express';
import {
AssignTagsRequest,
CreateTagRequest,
TagListQuery,
UpdateTagRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/tags.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', TagListQuery), ctrl.list);
router.post('/', requireAuth, validate('body', CreateTagRequest), ctrl.create);
router.patch('/:id', requireAuth, validate('body', UpdateTagRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+16
View File
@@ -0,0 +1,16 @@
import { Router } from 'express';
import { CreateUserRequest, PaginationQuery, UpdateUserRequest } from '@vector/shared';
import * as ctrl from '../controllers/users.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.use(requireAuth, requireRole('ADMIN'));
router.get('/', validate('query', PaginationQuery), ctrl.listUsers);
router.post('/', validate('body', CreateUserRequest), ctrl.createUser);
router.patch('/:id', validate('body', UpdateUserRequest), ctrl.updateUser);
router.delete('/:id', ctrl.deleteUser);
export default router;
+21
View File
@@ -0,0 +1,21 @@
import { Router } from 'express';
import {
CreateWebhookSubscriptionRequest,
UpdateWebhookSubscriptionRequest,
WebhookSubscriptionListQuery,
} from '@vector/shared';
import * as ctrl from '../controllers/webhooks.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.use(requireAuth, requireRole('ADMIN'));
router.get('/', validate('query', WebhookSubscriptionListQuery), ctrl.list);
router.post('/', validate('body', CreateWebhookSubscriptionRequest), ctrl.create);
router.patch('/:id', validate('body', UpdateWebhookSubscriptionRequest), ctrl.update);
router.post('/:id/rotate-secret', ctrl.rotate);
router.delete('/:id', ctrl.remove);
export default router;
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import type { Tx } from './types.js';
import { dashboard } from './analytics.js';
// Minimal in-memory tx double exercising the dashboard() aggregator.
// We only stub the calls dashboard() actually makes; other Prisma methods remain unimplemented.
function makeTx(args: {
partCount: number;
stateRows: { state: string; count: number; totalPrice: number }[];
parts: {
id: string;
state: string;
binId: string | null;
createdAt: Date;
manufacturerId: string;
}[];
openRepairs: number;
eolManufacturers: { id: string; name: string; eolDate: Date | null }[];
bins: { id: string; name: string; room: { name: string; site: { name: string } } }[];
}): Tx {
const tx = {
part: {
count: async () => args.partCount,
groupBy: async () =>
args.stateRows.map((s) => ({
state: s.state,
_count: { _all: s.count },
_sum: { price: s.totalPrice },
})),
findMany: async () => args.parts,
},
repairJob: {
count: async () => args.openRepairs,
},
manufacturer: {
findMany: async () => args.eolManufacturers,
},
bin: {
findMany: async () => args.bins,
},
};
return tx as unknown as Tx;
}
const now = new Date('2026-04-16T00:00:00.000Z');
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
describe('analytics.dashboard', () => {
it('aggregates totals, state counts and open repairs', async () => {
const tx = makeTx({
partCount: 5,
stateRows: [
{ state: 'SPARE', count: 3, totalPrice: 1500 },
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
],
parts: [],
openRepairs: 4,
eolManufacturers: [],
bins: [],
});
const r = await dashboard(tx);
expect(r.totalParts).toBe(5);
expect(r.openRepairs).toBe(4);
expect(r.byState).toEqual([
{ state: 'SPARE', count: 3, totalPrice: 1500 },
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
]);
});
it('buckets parts by age correctly', async () => {
const tx = makeTx({
partCount: 4,
stateRows: [],
parts: [
{ id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), manufacturerId: 'm' },
{ id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), manufacturerId: 'm' },
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), manufacturerId: 'm' },
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), manufacturerId: 'm' },
],
openRepairs: 0,
eolManufacturers: [],
bins: [],
});
const r = await dashboard(tx);
const byLabel = Object.fromEntries(r.ageBuckets.map((b) => [b.label, b.count]));
expect(byLabel['030d']).toBe(1);
expect(byLabel['3190d']).toBe(1);
expect(byLabel['12y']).toBe(1);
expect(byLabel['2y+']).toBe(1);
// totals should match
expect(r.ageBuckets.reduce((s, b) => s + b.count, 0)).toBe(4);
});
it('ranks top bins and labels them site/room/bin', async () => {
const tx = makeTx({
partCount: 4,
stateRows: [],
parts: [
{ id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' },
{ id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' },
{ id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), manufacturerId: 'm' },
{ id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), manufacturerId: 'm' },
],
openRepairs: 0,
eolManufacturers: [],
bins: [
{ id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } },
{ id: 'b2', name: 'B2', room: { name: 'Lab', site: { name: 'HQ' } } },
],
});
const r = await dashboard(tx);
expect(r.topBins).toEqual([
{ binId: 'b1', label: 'HQ / Lab / A1', count: 2 },
{ binId: 'b2', label: 'HQ / Lab / B2', count: 1 },
]);
});
it('flags manufacturers whose EOL has passed and have deployed parts', async () => {
const tx = makeTx({
partCount: 3,
stateRows: [],
parts: [
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' },
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' },
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm2' },
],
openRepairs: 0,
eolManufacturers: [
{ id: 'm1', name: 'Acme', eolDate: daysAgo(30) },
{ id: 'm2', name: 'Beta', eolDate: daysAgo(10) },
{ id: 'm3', name: 'Gamma', eolDate: daysAgo(5) },
],
bins: [],
});
const r = await dashboard(tx);
expect(r.deployedPastEol.map((m) => m.name)).toEqual(['Acme', 'Beta']);
expect(r.deployedPastEol[0]).toMatchObject({ manufacturerId: 'm1', deployedCount: 2 });
expect(r.deployedPastEol[1]).toMatchObject({ manufacturerId: 'm2', deployedCount: 1 });
});
});
+88
View File
@@ -0,0 +1,88 @@
import type { DashboardAnalytics } from '@vector/shared';
import type { Tx } from './types.js';
const DAY = 24 * 60 * 60 * 1000;
const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
{ label: '030d', maxDays: 30 },
{ label: '3190d', maxDays: 90 },
{ label: '91180d', maxDays: 180 },
{ label: '181365d', maxDays: 365 },
{ label: '12y', maxDays: 730 },
{ label: '2y+', maxDays: null },
];
export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
const [totalParts, stateRows, parts, openRepairs, manufacturersWithEol] = await Promise.all([
tx.part.count(),
tx.part.groupBy({
by: ['state'],
_count: { _all: true },
_sum: { price: true },
}),
tx.part.findMany({
select: { id: true, state: true, binId: true, createdAt: true, manufacturerId: true },
}),
tx.repairJob.count({ where: { status: { in: ['PENDING', 'IN_PROGRESS'] } } }),
tx.manufacturer.findMany({
where: { eolDate: { not: null, lte: new Date() } },
select: { id: true, name: true, eolDate: true },
}),
]);
const byState = stateRows.map((row) => ({
state: row.state as DashboardAnalytics['byState'][number]['state'],
count: row._count._all,
totalPrice: row._sum.price ?? 0,
}));
const now = Date.now();
const buckets = AGE_BUCKETS.map((b) => ({ label: b.label, count: 0 }));
for (const part of parts) {
const ageDays = (now - part.createdAt.getTime()) / DAY;
const idx = AGE_BUCKETS.findIndex((b) => b.maxDays === null || ageDays <= b.maxDays);
const bucket = idx >= 0 ? buckets[idx] : undefined;
if (bucket) bucket.count += 1;
}
const binCounts = new Map<string, number>();
for (const part of parts) {
if (!part.binId) continue;
binCounts.set(part.binId, (binCounts.get(part.binId) ?? 0) + 1);
}
const topBinIds = [...binCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([id]) => id);
const binRows = topBinIds.length
? await tx.bin.findMany({
where: { id: { in: topBinIds } },
include: { room: { include: { site: true } } },
})
: [];
const binLabels = new Map(
binRows.map((b) => [b.id, `${b.room.site.name} / ${b.room.name} / ${b.name}`]),
);
const topBins = topBinIds.map((id) => ({
binId: id,
label: binLabels.get(id) ?? 'Unknown',
count: binCounts.get(id) ?? 0,
}));
const deployedByMfg = new Map<string, number>();
for (const part of parts) {
if (part.state !== 'DEPLOYED') continue;
deployedByMfg.set(part.manufacturerId, (deployedByMfg.get(part.manufacturerId) ?? 0) + 1);
}
const deployedPastEol = manufacturersWithEol
.map((m) => ({
manufacturerId: m.id,
name: m.name,
eolDate: m.eolDate ? m.eolDate.toISOString() : null,
deployedCount: deployedByMfg.get(m.id) ?? 0,
}))
.filter((m) => m.deployedCount > 0)
.sort((a, b) => b.deployedCount - a.deployedCount);
return { totalParts, byState, ageBuckets: buckets, topBins, deployedPastEol, openRepairs };
}
+126
View File
@@ -0,0 +1,126 @@
import { createHash, randomBytes } from 'node:crypto';
import bcrypt from 'bcryptjs';
import jwt, { type SignOptions } from 'jsonwebtoken';
import { Prisma } from '@vector/db';
import type { Role } from '@vector/shared';
import { env } from '../env.js';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
export const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export interface AuthTokens {
accessToken: string;
refreshToken: string;
refreshExpiresAt: Date;
}
export interface AuthUser {
id: string;
username: string;
email: string;
role: Role;
}
function hashRefreshToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function signAccessToken(user: AuthUser): string {
const opts: SignOptions = { expiresIn: Math.floor(ACCESS_TOKEN_TTL_MS / 1000) };
return jwt.sign({ id: user.id, username: user.username, role: user.role }, env.JWT_SECRET, opts);
}
async function issueRefreshToken(
tx: Tx,
userId: string,
replacedBy?: string,
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(48).toString('hex');
const tokenHash = hashRefreshToken(token);
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_MS);
await tx.refreshToken.create({
data: { userId, tokenHash, expiresAt, replacedBy: replacedBy ?? null },
});
return { token, expiresAt };
}
export async function login(
tx: Tx,
username: string,
password: string,
): Promise<{ user: AuthUser; tokens: AuthTokens }> {
const user = await tx.user.findUnique({ where: { username } });
if (!user) throw errors.unauthorized('Invalid credentials');
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) throw errors.unauthorized('Invalid credentials');
const publicUser: AuthUser = {
id: user.id,
username: user.username,
email: user.email,
role: user.role as Role,
};
const accessToken = signAccessToken(publicUser);
const { token: refreshToken, expiresAt } = await issueRefreshToken(tx, user.id);
return {
user: publicUser,
tokens: { accessToken, refreshToken, refreshExpiresAt: expiresAt },
};
}
export async function rotate(
tx: Tx,
presentedToken: string,
): Promise<{ user: AuthUser; tokens: AuthTokens }> {
const tokenHash = hashRefreshToken(presentedToken);
const existing = await tx.refreshToken.findUnique({
where: { tokenHash },
include: { user: true },
});
if (!existing || existing.revokedAt || existing.expiresAt < new Date()) {
throw errors.unauthorized('Invalid refresh token');
}
const { token: newToken, expiresAt } = await issueRefreshToken(tx, existing.userId);
const newRow = await tx.refreshToken.findUnique({
where: { tokenHash: hashRefreshToken(newToken) },
});
await tx.refreshToken.update({
where: { id: existing.id },
data: { revokedAt: new Date(), replacedBy: newRow?.id ?? null },
});
const publicUser: AuthUser = {
id: existing.user.id,
username: existing.user.username,
email: existing.user.email,
role: existing.user.role as Role,
};
return {
user: publicUser,
tokens: { accessToken: signAccessToken(publicUser), refreshToken: newToken, refreshExpiresAt: expiresAt },
};
}
export async function revoke(tx: Tx, presentedToken: string | undefined): Promise<void> {
if (!presentedToken) return;
const tokenHash = hashRefreshToken(presentedToken);
try {
await tx.refreshToken.update({
where: { tokenHash },
data: { revokedAt: new Date() },
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') return;
throw err;
}
}
export async function revokeAllForUser(tx: Tx, userId: string): Promise<void> {
await tx.refreshToken.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
+60
View File
@@ -0,0 +1,60 @@
import { Prisma } from '@vector/db';
import type {
CategoryListQuery,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export async function list(tx: Tx, q: CategoryListQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([
tx.category.findMany({
orderBy: { name: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.category.count(),
]);
return { data, page, pageSize, total };
}
export async function create(tx: Tx, input: CreateCategoryRequest) {
try {
return await tx.category.create({
data: { name: input.name, description: input.description ?? null },
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Category name already exists');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdateCategoryRequest) {
const data: Prisma.CategoryUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.description !== undefined) data.description = input.description;
try {
return await tx.category.update({ where: { id }, data });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Category');
if (err.code === 'P2002') throw errors.conflict('Category name already exists');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.category.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Category');
}
throw err;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { Prisma } from '@vector/db';
import type {
CreateHostRequest,
HostListQuery,
UpdateHostRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export async function list(tx: Tx, q: HostListQuery) {
const { page, pageSize, q: search } = q;
const where: Prisma.HostWhereInput = search
? {
OR: [
{ name: { contains: search } },
{ location: { contains: search } },
],
}
: {};
const [data, total] = await Promise.all([
tx.host.findMany({
where,
orderBy: { name: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.host.count({ where }),
]);
return { data, page, pageSize, total };
}
export function get(tx: Tx, id: string) {
return tx.host.findUnique({ where: { id } });
}
export async function create(tx: Tx, input: CreateHostRequest) {
try {
return await tx.host.create({
data: {
name: input.name,
location: input.location ?? null,
notes: input.notes ?? null,
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Host name already exists');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
const data: Prisma.HostUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.location !== undefined) data.location = input.location;
if (input.notes !== undefined) data.notes = input.notes;
try {
return await tx.host.update({ where: { id }, data });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Host');
if (err.code === 'P2002') throw errors.conflict('Host name already exists');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.host.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Host');
}
throw err;
}
}
+216
View File
@@ -0,0 +1,216 @@
import { Prisma } from '@vector/db';
import type {
CreateBinRequest,
CreateRoomRequest,
CreateSiteRequest,
PaginationQuery,
UpdateBinRequest,
UpdateRoomRequest,
UpdateSiteRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
const binInclude = { room: { include: { site: true } } } satisfies Prisma.BinInclude;
type BinWithRelations = Prisma.BinGetPayload<{ include: typeof binInclude }>;
export type BinWithPath = BinWithRelations & { fullPath: string };
function binPath(bin: BinWithRelations): string {
return `${bin.room.site.name}.${bin.room.name}.${bin.name}`;
}
export function withBinPath(bin: BinWithRelations): BinWithPath {
return { ...bin, fullPath: binPath(bin) };
}
// ---- sites ----
export async function listSites(tx: Tx, q: PaginationQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([
tx.site.findMany({
orderBy: { name: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.site.count(),
]);
return { data, page, pageSize, total };
}
export function getSite(tx: Tx, id: string) {
return tx.site.findUnique({
where: { id },
include: { rooms: { include: { bins: true } } },
});
}
export async function createSite(tx: Tx, input: CreateSiteRequest) {
try {
return await tx.site.create({ data: { name: input.name } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Site already exists');
}
throw err;
}
}
export async function updateSite(tx: Tx, id: string, input: UpdateSiteRequest) {
try {
return await tx.site.update({ where: { id }, data: { name: input.name } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Site');
if (err.code === 'P2002') throw errors.conflict('Site already exists');
}
throw err;
}
}
export async function removeSite(tx: Tx, id: string) {
try {
await tx.site.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Site');
}
throw err;
}
}
// ---- rooms ----
export async function listRooms(tx: Tx, q: PaginationQuery & { siteId?: string }) {
const { page, pageSize, siteId } = q;
const where = siteId ? { siteId } : {};
const [data, total] = await Promise.all([
tx.room.findMany({
where,
orderBy: { name: 'asc' },
include: { site: true },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.room.count({ where }),
]);
return { data, page, pageSize, total };
}
export function getRoom(tx: Tx, id: string) {
return tx.room.findUnique({ where: { id }, include: { site: true, bins: true } });
}
export async function createRoom(tx: Tx, input: CreateRoomRequest) {
try {
return await tx.room.create({
data: { name: input.name, siteId: input.siteId },
include: { site: true },
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Room already exists in this site');
}
throw err;
}
}
export async function updateRoom(tx: Tx, id: string, input: UpdateRoomRequest) {
const data: Prisma.RoomUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.siteId !== undefined) data.site = { connect: { id: input.siteId } };
try {
return await tx.room.update({ where: { id }, data, include: { site: true } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Room');
if (err.code === 'P2002') throw errors.conflict('Room already exists in this site');
}
throw err;
}
}
export async function removeRoom(tx: Tx, id: string) {
try {
await tx.room.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Room');
}
throw err;
}
}
// ---- bins ----
export async function listBins(
tx: Tx,
q: PaginationQuery & { roomId?: string; siteId?: string },
) {
const { page, pageSize, roomId, siteId } = q;
const where: Prisma.BinWhereInput = {};
if (roomId) where.roomId = roomId;
if (siteId) where.room = { siteId };
const [rows, total] = await Promise.all([
tx.bin.findMany({
where,
orderBy: { name: 'asc' },
include: binInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.bin.count({ where }),
]);
return { data: rows.map(withBinPath), page, pageSize, total };
}
export async function getBin(tx: Tx, id: string): Promise<BinWithPath | null> {
const bin = await tx.bin.findUnique({ where: { id }, include: binInclude });
return bin ? withBinPath(bin) : null;
}
export async function createBin(tx: Tx, input: CreateBinRequest): Promise<BinWithPath> {
try {
const bin = await tx.bin.create({
data: { name: input.name, roomId: input.roomId },
include: binInclude,
});
return withBinPath(bin);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Bin already exists in this room');
}
throw err;
}
}
export async function updateBin(
tx: Tx,
id: string,
input: UpdateBinRequest,
): Promise<BinWithPath> {
const data: Prisma.BinUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.roomId !== undefined) data.room = { connect: { id: input.roomId } };
try {
const bin = await tx.bin.update({ where: { id }, data, include: binInclude });
return withBinPath(bin);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Bin');
if (err.code === 'P2002') throw errors.conflict('Bin already exists in this room');
}
throw err;
}
}
export async function removeBin(tx: Tx, id: string) {
try {
await tx.bin.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Bin');
}
throw err;
}
}
+66
View File
@@ -0,0 +1,66 @@
import { Prisma } from '@vector/db';
import type {
CreateManufacturerRequest,
UpdateManufacturerRequest,
PaginationQuery,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export async function list(tx: Tx, q: PaginationQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([
tx.manufacturer.findMany({
orderBy: { name: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.manufacturer.count(),
]);
return { data, page, pageSize, total };
}
export async function create(tx: Tx, input: CreateManufacturerRequest) {
try {
return await tx.manufacturer.create({
data: {
name: input.name,
eolDate: input.eolDate ? new Date(input.eolDate) : null,
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Manufacturer already exists');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdateManufacturerRequest) {
try {
const data: Prisma.ManufacturerUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.eolDate !== undefined) data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
return await tx.manufacturer.update({ where: { id }, data });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Manufacturer');
if (err.code === 'P2002') throw errors.conflict('Manufacturer already exists');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.manufacturer.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Manufacturer');
if (err.code === 'P2003') {
throw errors.conflict('Cannot delete: manufacturer has parts assigned');
}
}
throw err;
}
}
+328
View File
@@ -0,0 +1,328 @@
import { Prisma } from '@vector/db';
import type {
CreatePartRequest,
PaginationQuery,
PartListQuery,
UpdatePartRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import * as tagsSvc from './tags.js';
import type { Actor, Tx } from './types.js';
const partInclude = {
manufacturer: true,
bin: { include: { room: { include: { site: true } } } },
category: true,
tags: { include: { tag: true } },
} satisfies Prisma.PartInclude;
type PartWithRelations = Prisma.PartGetPayload<{ include: typeof partInclude }>;
type BinWithSite = NonNullable<PartWithRelations['bin']>;
export type PartWithPath = Omit<PartWithRelations, 'tags'> & {
bin: (BinWithSite & { fullPath?: string }) | null;
tags: { id: string; name: string; color: string | null }[];
};
function binPath(bin: BinWithSite | null | undefined): string | null {
if (!bin) return null;
return `${bin.room.site.name}.${bin.room.name}.${bin.name}`;
}
function flattenTags(part: PartWithRelations): PartWithPath {
const { tags, ...rest } = part;
const out = rest as PartWithPath;
if (out.bin) out.bin.fullPath = binPath(out.bin) ?? undefined;
out.tags = tags.map((t) => ({
id: t.tag.id,
name: t.tag.name,
color: t.tag.color,
}));
return out;
}
function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
const where: Prisma.PartWhereInput = {};
if (q.state) where.state = q.state;
if (q.binId) where.binId = q.binId;
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
if (q.categoryId) where.categoryId = q.categoryId;
if (q.mpn) where.mpn = { contains: q.mpn };
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
if (q.q) {
where.OR = [
{ serialNumber: { contains: q.q } },
{ mpn: { contains: q.q } },
{ notes: { contains: q.q } },
];
}
if (q.tagId) where.tags = { some: { tagId: q.tagId } };
if (q.eolOnly) {
// Parts attached to a manufacturer with an EOL date that has already passed.
where.manufacturer = { eolDate: { lt: new Date() } };
}
return where;
}
export async function list(tx: Tx, q: PartListQuery) {
const { page, pageSize } = q;
const where = buildWhere(q);
const [rows, total] = await Promise.all([
tx.part.findMany({
where,
orderBy: { createdAt: 'desc' },
include: partInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.part.count({ where }),
]);
return { data: rows.map(flattenTags), page, pageSize, total };
}
export async function get(tx: Tx, id: string): Promise<PartWithPath | null> {
const p = await tx.part.findUnique({ where: { id }, include: partInclude });
return p ? flattenTags(p) : null;
}
export async function create(
tx: Tx,
input: CreatePartRequest,
actor: Actor | null,
): Promise<PartWithPath> {
try {
const p = await tx.part.create({
data: {
serialNumber: input.serialNumber,
mpn: input.mpn,
manufacturerId: input.manufacturerId,
price: input.price ?? null,
state: input.state ?? 'SPARE',
binId: input.binId ?? null,
categoryId: input.categoryId ?? null,
replacementPartId: input.replacementPartId ?? null,
notes: input.notes ?? null,
},
include: partInclude,
});
await tx.partEvent.create({
data: {
partId: p.id,
userId: actor?.id ?? null,
type: 'CREATED',
newValue: p.serialNumber,
},
});
if (input.tagIds && input.tagIds.length > 0) {
await tagsSvc.setPartTags(tx, p.id, input.tagIds, actor);
}
const refreshed = await tx.part.findUnique({ where: { id: p.id }, include: partInclude });
return flattenTags(refreshed!);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Serial number already exists');
}
throw err;
}
}
export async function update(
tx: Tx,
id: string,
input: UpdatePartRequest,
actor: Actor | null,
): Promise<PartWithPath> {
const current = await tx.part.findUnique({ where: { id }, include: partInclude });
if (!current) throw errors.notFound('Part');
const data: Prisma.PartUpdateInput = {};
if (input.serialNumber !== undefined) data.serialNumber = input.serialNumber;
if (input.mpn !== undefined) data.mpn = input.mpn;
if (input.manufacturerId !== undefined) {
data.manufacturer = { connect: { id: input.manufacturerId } };
}
if (input.price !== undefined) data.price = input.price;
if (input.state !== undefined) data.state = input.state;
if (input.binId !== undefined) {
data.bin = input.binId ? { connect: { id: input.binId } } : { disconnect: true };
}
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { disconnect: true };
}
if (input.replacementPartId !== undefined) {
data.replacement = input.replacementPartId
? { connect: { id: input.replacementPartId } }
: { disconnect: true };
}
if (input.notes !== undefined) data.notes = input.notes;
let part: PartWithRelations;
try {
part = await tx.part.update({ where: { id }, data, include: partInclude });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part');
if (err.code === 'P2002') throw errors.conflict('Serial number already exists');
}
throw err;
}
const userId = actor?.id ?? null;
const events: Prisma.PartEventCreateManyInput[] = [];
if (input.state !== undefined && input.state !== current.state) {
events.push({
partId: part.id,
userId,
type: 'STATE_CHANGED',
field: 'state',
oldValue: current.state,
newValue: input.state,
});
}
if (input.binId !== undefined && input.binId !== current.binId) {
events.push({
partId: part.id,
userId,
type: 'LOCATION_CHANGED',
field: 'bin',
oldValue: binPath(current.bin),
newValue: binPath(part.bin),
});
}
if (input.mpn !== undefined && input.mpn !== current.mpn) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'mpn',
oldValue: current.mpn,
newValue: input.mpn,
});
}
if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'serialNumber',
oldValue: current.serialNumber,
newValue: input.serialNumber,
});
}
if (input.manufacturerId !== undefined && input.manufacturerId !== current.manufacturerId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'manufacturer',
oldValue: current.manufacturer.name,
newValue: part.manufacturer.name,
});
}
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'category',
oldValue: current.category?.name ?? null,
newValue: part.category?.name ?? null,
});
}
if (input.price !== undefined && input.price !== current.price) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'price',
oldValue: current.price?.toString() ?? null,
newValue: input.price?.toString() ?? null,
});
}
if (input.notes !== undefined && (input.notes ?? null) !== (current.notes ?? null)) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'notes',
oldValue: current.notes ?? null,
newValue: input.notes ?? null,
});
}
if (events.length > 0) await tx.partEvent.createMany({ data: events });
if (input.tagIds !== undefined) {
await tagsSvc.setPartTags(tx, part.id, input.tagIds, actor);
const refreshed = await tx.part.findUnique({ where: { id: part.id }, include: partInclude });
return flattenTags(refreshed!);
}
return flattenTags(part);
}
export async function remove(tx: Tx, id: string) {
try {
await tx.part.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Part');
}
throw err;
}
}
export async function listEvents(tx: Tx, partId: string, q: PaginationQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([
tx.partEvent.findMany({
where: { partId },
orderBy: { createdAt: 'desc' },
include: { user: { select: { username: true } } },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.partEvent.count({ where: { partId } }),
]);
return { data, page, pageSize, total };
}
// Bulk mutation. Batches all writes inside a single transaction so partial failures roll back.
// Intentionally capped so callers can't accidentally lock the whole parts table.
export interface BulkPartsInput {
ids: string[];
state?: CreatePartRequest['state'];
binId?: string | null;
addTagIds?: string[];
removeTagIds?: string[];
}
const BULK_LIMIT = 500;
export async function bulkUpdate(tx: Tx, input: BulkPartsInput, actor: Actor | null) {
if (input.ids.length === 0) throw errors.badRequest('No part ids supplied');
if (input.ids.length > BULK_LIMIT) {
throw errors.badRequest(`Bulk operations are limited to ${BULK_LIMIT} parts per call`);
}
const touched: string[] = [];
for (const id of input.ids) {
const patch: UpdatePartRequest = {};
if (input.state !== undefined) patch.state = input.state;
if (input.binId !== undefined) patch.binId = input.binId;
if (Object.keys(patch).length > 0) {
await update(tx, id, patch, actor);
}
if (input.addTagIds || input.removeTagIds) {
const existing = await tx.partTag.findMany({ where: { partId: id }, select: { tagId: true } });
let next = new Set(existing.map((r) => r.tagId));
(input.addTagIds ?? []).forEach((t) => next.add(t));
(input.removeTagIds ?? []).forEach((t) => next.delete(t));
await tagsSvc.setPartTags(tx, id, [...next], actor);
}
touched.push(id);
}
return { updated: touched.length };
}
+145
View File
@@ -0,0 +1,145 @@
import { Prisma } from '@vector/db';
import type {
CreateRepairJobRequest,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Actor, Tx } from './types.js';
const repairInclude = {
part: {
include: { manufacturer: true },
},
host: true,
assignee: { select: { id: true, username: true, email: true, role: true } },
} satisfies Prisma.RepairJobInclude;
export async function list(tx: Tx, q: RepairJobListQuery) {
const { page, pageSize, status, partId, hostId, assigneeId, openOnly } = q;
const where: Prisma.RepairJobWhereInput = {};
if (status) where.status = status;
if (partId) where.partId = partId;
if (hostId) where.hostId = hostId;
if (assigneeId) where.assigneeId = assigneeId;
if (openOnly) where.status = { in: ['PENDING', 'IN_PROGRESS'] };
const [data, total] = await Promise.all([
tx.repairJob.findMany({
where,
orderBy: [{ status: 'asc' }, { openedAt: 'desc' }],
include: repairInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.repairJob.count({ where }),
]);
return { data, page, pageSize, total };
}
export function get(tx: Tx, id: string) {
return tx.repairJob.findUnique({ where: { id }, include: repairInclude });
}
export function listForPart(tx: Tx, partId: string) {
return tx.repairJob.findMany({
where: { partId },
orderBy: { openedAt: 'desc' },
include: repairInclude,
});
}
export async function create(
tx: Tx,
input: CreateRepairJobRequest,
actor: Actor | null,
) {
const part = await tx.part.findUnique({ where: { id: input.partId } });
if (!part) throw errors.notFound('Part');
try {
const repair = await tx.repairJob.create({
data: {
partId: input.partId,
hostId: input.hostId ?? null,
assigneeId: input.assigneeId ?? null,
notes: input.notes ?? null,
status: 'PENDING',
},
include: repairInclude,
});
await tx.partEvent.create({
data: {
partId: part.id,
userId: actor?.id ?? null,
type: 'REPAIR_STARTED',
newValue: repair.id,
},
});
return repair;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
throw errors.badRequest('Invalid host or assignee id');
}
throw err;
}
}
export async function update(
tx: Tx,
id: string,
input: UpdateRepairJobRequest,
actor: Actor | null,
) {
const current = await tx.repairJob.findUnique({ where: { id } });
if (!current) throw errors.notFound('Repair');
const data: Prisma.RepairJobUpdateInput = {};
if (input.status !== undefined && input.status !== current.status) {
data.status = input.status;
// closedAt follows terminal status transitions.
const nowTerminal = input.status === 'COMPLETED' || input.status === 'CANCELLED';
const wasTerminal = current.status === 'COMPLETED' || current.status === 'CANCELLED';
if (nowTerminal && !wasTerminal) data.closedAt = new Date();
if (!nowTerminal && wasTerminal) data.closedAt = null;
}
if (input.hostId !== undefined) {
data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true };
}
if (input.assigneeId !== undefined) {
data.assignee = input.assigneeId
? { connect: { id: input.assigneeId } }
: { disconnect: true };
}
if (input.notes !== undefined) data.notes = input.notes;
const repair = await tx.repairJob.update({
where: { id },
data,
include: repairInclude,
});
if (input.status === 'COMPLETED' && current.status !== 'COMPLETED') {
await tx.partEvent.create({
data: {
partId: repair.partId,
userId: actor?.id ?? null,
type: 'REPAIR_COMPLETED',
newValue: repair.id,
},
});
}
return repair;
}
export async function remove(tx: Tx, id: string) {
try {
await tx.repairJob.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Repair');
}
throw err;
}
}
+106
View File
@@ -0,0 +1,106 @@
import { Prisma } from '@vector/db';
import type {
CreateSavedViewRequest,
SavedViewFilter,
SavedViewListQuery,
UpdateSavedViewRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Actor, Tx } from './types.js';
interface SavedViewRow {
id: string;
userId: string;
resource: string;
name: string;
filterJson: string;
createdAt: Date;
updatedAt: Date;
}
interface SavedView {
id: string;
userId: string;
resource: string;
name: string;
filter: SavedViewFilter;
createdAt: Date;
updatedAt: Date;
}
// SavedView.filterJson is a JSON-encoded string on SQLite; we parse on read, stringify on write.
function hydrate(row: SavedViewRow): SavedView {
let filter: SavedViewFilter = {};
try {
filter = JSON.parse(row.filterJson) as SavedViewFilter;
} catch {
// Corrupt row — surface an empty view rather than a 500. Bad rows should be cleaned up.
}
const { filterJson: _ignored, ...rest } = row;
void _ignored;
return { ...rest, filter };
}
export async function listMine(tx: Tx, actor: Actor, q: SavedViewListQuery) {
const where: Prisma.SavedViewWhereInput = { userId: actor.id };
if (q.resource) where.resource = q.resource;
const [rows, total] = await Promise.all([
tx.savedView.findMany({
where,
orderBy: { name: 'asc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
tx.savedView.count({ where }),
]);
return { data: rows.map(hydrate), page: q.page, pageSize: q.pageSize, total };
}
export async function create(tx: Tx, actor: Actor, input: CreateSavedViewRequest) {
try {
const row = await tx.savedView.create({
data: {
userId: actor.id,
resource: input.resource,
name: input.name,
filterJson: JSON.stringify(input.filter),
},
});
return hydrate(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('A saved view with this name already exists');
}
throw err;
}
}
export async function update(
tx: Tx,
actor: Actor,
id: string,
input: UpdateSavedViewRequest,
) {
const existing = await tx.savedView.findUnique({ where: { id } });
if (!existing || existing.userId !== actor.id) throw errors.notFound('Saved view');
const data: Prisma.SavedViewUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.filter !== undefined) data.filterJson = JSON.stringify(input.filter);
try {
const row = await tx.savedView.update({ where: { id }, data });
return hydrate(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('A saved view with this name already exists');
}
throw err;
}
}
export async function remove(tx: Tx, actor: Actor, id: string) {
const existing = await tx.savedView.findUnique({ where: { id } });
if (!existing || existing.userId !== actor.id) throw errors.notFound('Saved view');
await tx.savedView.delete({ where: { id } });
}
+157
View File
@@ -0,0 +1,157 @@
import { Prisma } from '@vector/db';
import type {
AssignTagsRequest,
CreateTagRequest,
TagListQuery,
UpdateTagRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Actor, Tx } from './types.js';
export async function list(tx: Tx, q: TagListQuery) {
const { page, pageSize, q: search } = q;
const where: Prisma.TagWhereInput = search ? { name: { contains: search } } : {};
const [data, total] = await Promise.all([
tx.tag.findMany({
where,
orderBy: { name: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.tag.count({ where }),
]);
return { data, page, pageSize, total };
}
export async function create(tx: Tx, input: CreateTagRequest) {
try {
return await tx.tag.create({
data: { name: input.name, color: input.color ?? null },
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Tag name already exists');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdateTagRequest) {
const data: Prisma.TagUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.color !== undefined) data.color = input.color;
try {
return await tx.tag.update({ where: { id }, data });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Tag');
if (err.code === 'P2002') throw errors.conflict('Tag name already exists');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.tag.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Tag');
}
throw err;
}
}
// Replace the full set of tags on a part. Emits TAG_ADDED / TAG_REMOVED events for the diff so
// history stays accurate. Used by assignPart / the part create/update flow.
export async function setPartTags(
tx: Tx,
partId: string,
tagIds: string[],
actor: Actor | null,
) {
const existing = await tx.partTag.findMany({ where: { partId }, select: { tagId: true } });
const before = new Set(existing.map((r) => r.tagId));
const after = new Set(tagIds);
const toAdd = [...after].filter((id) => !before.has(id));
const toRemove = [...before].filter((id) => !after.has(id));
if (toRemove.length > 0) {
await tx.partTag.deleteMany({
where: { partId, tagId: { in: toRemove } },
});
}
if (toAdd.length > 0) {
try {
await tx.partTag.createMany({
data: toAdd.map((tagId) => ({ partId, tagId })),
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
throw errors.badRequest('One or more tag ids are invalid');
}
throw err;
}
}
if (toAdd.length + toRemove.length > 0) {
const tagNames = await tx.tag.findMany({
where: { id: { in: [...toAdd, ...toRemove] } },
select: { id: true, name: true },
});
const nameById = new Map(tagNames.map((t) => [t.id, t.name]));
const events: Prisma.PartEventCreateManyInput[] = [
...toAdd.map((id) => ({
partId,
userId: actor?.id ?? null,
type: 'TAG_ADDED' as const,
newValue: nameById.get(id) ?? id,
})),
...toRemove.map((id) => ({
partId,
userId: actor?.id ?? null,
type: 'TAG_REMOVED' as const,
oldValue: nameById.get(id) ?? id,
})),
];
await tx.partEvent.createMany({ data: events });
}
}
export async function listForPart(tx: Tx, partId: string) {
const rows = await tx.partTag.findMany({
where: { partId },
include: { tag: true },
orderBy: { tag: { name: 'asc' } },
});
return rows.map((r) => r.tag);
}
export async function assignToPart(
tx: Tx,
partId: string,
input: AssignTagsRequest,
actor: Actor | null,
) {
const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part');
const existing = await tx.partTag.findMany({ where: { partId }, select: { tagId: true } });
const merged = Array.from(new Set([...existing.map((e) => e.tagId), ...input.tagIds]));
await setPartTags(tx, partId, merged, actor);
return listForPart(tx, partId);
}
export async function unassignFromPart(
tx: Tx,
partId: string,
tagId: string,
actor: Actor | null,
) {
const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part');
const existing = await tx.partTag.findMany({ where: { partId }, select: { tagId: true } });
const next = existing.map((e) => e.tagId).filter((id) => id !== tagId);
await setPartTags(tx, partId, next, actor);
return listForPart(tx, partId);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Prisma } from '@vector/db';
import type { Role } from '@vector/shared';
export type Tx = Prisma.TransactionClient;
export interface Actor {
id: string;
username: string;
role: Role;
}
+77
View File
@@ -0,0 +1,77 @@
import bcrypt from 'bcryptjs';
import { Prisma, type User } from '@vector/db';
import type {
CreateUserRequest,
PaginationQuery,
UpdateUserRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export type PublicUser = Pick<User, 'id' | 'username' | 'email' | 'role' | 'createdAt'>;
export function toPublic(u: User): PublicUser {
return { id: u.id, username: u.username, email: u.email, role: u.role, createdAt: u.createdAt };
}
export async function list(tx: Tx, q: PaginationQuery) {
const { page, pageSize } = q;
const [rows, total] = await Promise.all([
tx.user.findMany({
orderBy: { username: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.user.count(),
]);
return { data: rows.map(toPublic), page, pageSize, total };
}
export async function create(tx: Tx, input: CreateUserRequest): Promise<PublicUser> {
try {
const passwordHash = await bcrypt.hash(input.password, 12);
const u = await tx.user.create({
data: {
username: input.username,
email: input.email,
passwordHash,
role: input.role ?? 'TECHNICIAN',
},
});
return toPublic(u);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Username or email already exists');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdateUserRequest): Promise<PublicUser> {
const data: Prisma.UserUpdateInput = {};
if (input.username !== undefined) data.username = input.username;
if (input.email !== undefined) data.email = input.email;
if (input.role !== undefined) data.role = input.role;
if (input.password !== undefined) data.passwordHash = await bcrypt.hash(input.password, 12);
try {
const u = await tx.user.update({ where: { id }, data });
return toPublic(u);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('User');
if (err.code === 'P2002') throw errors.conflict('Username or email already exists');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.user.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('User');
}
throw err;
}
}
+41
View File
@@ -0,0 +1,41 @@
import crypto from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { signBody } from './webhooks.js';
describe('signBody', () => {
it('produces a stable hex HMAC-SHA256 of `${timestamp}.${body}`', () => {
const secret = 'test-secret';
const body = JSON.stringify({ event: 'part.created', data: { id: 'p1' } });
const ts = 1_700_000_000;
const signature = signBody(secret, body, ts);
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${body}`)
.digest('hex');
expect(signature).toBe(expected);
expect(signature).toHaveLength(64);
expect(signature).toMatch(/^[0-9a-f]+$/);
});
it('changes when body changes', () => {
const a = signBody('s', '{"a":1}', 1);
const b = signBody('s', '{"a":2}', 1);
expect(a).not.toBe(b);
});
it('changes when timestamp changes (prevents replay)', () => {
const body = '{}';
const a = signBody('s', body, 1);
const b = signBody('s', body, 2);
expect(a).not.toBe(b);
});
it('changes when secret changes', () => {
const body = '{}';
const a = signBody('secret-a', body, 1);
const b = signBody('secret-b', body, 1);
expect(a).not.toBe(b);
});
});
+138
View File
@@ -0,0 +1,138 @@
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');
}
+21
View File
@@ -0,0 +1,21 @@
import type { Role } from '@vector/shared';
declare global {
namespace Express {
interface Request {
user?: {
id: string;
username: string;
role: Role;
};
validated?: {
body?: unknown;
query?: unknown;
params?: unknown;
};
requestId?: string;
}
}
}
export {};