chore: initial Vector 2.0 monorepo
Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:
- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate
Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
CLIENT_ORIGIN=http://localhost:5173
|
||||
|
||||
# Provisional local SQLite. Switch to Postgres when Docker is available:
|
||||
# DATABASE_URL=postgresql://vector:vector@localhost:5432/vector
|
||||
DATABASE_URL=file:../../packages/db/prisma/dev.db
|
||||
|
||||
# Generate: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||
JWT_SECRET=replace-with-at-least-32-char-random-hex-secret
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@vector/api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=.env src/index.ts",
|
||||
"start": "node --env-file=.env dist/index.js",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"clean": "rimraf dist .turbo coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vector/db": "workspace:*",
|
||||
"@vector/shared": "workspace:*",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pino": "^10.3.1",
|
||||
"pino-http": "^11.0.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vector/config": "workspace:*",
|
||||
"@vitest/coverage-v8": "^4.1.4",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AppError, errors } from './http-error.js';
|
||||
|
||||
describe('AppError', () => {
|
||||
it('carries status, code, message, and optional details', () => {
|
||||
const e = new AppError(418, 'TEAPOT', 'short and stout', { reason: 'tea' });
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect(e.status).toBe(418);
|
||||
expect(e.code).toBe('TEAPOT');
|
||||
expect(e.message).toBe('short and stout');
|
||||
expect(e.details).toEqual({ reason: 'tea' });
|
||||
expect(e.name).toBe('AppError');
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors factory', () => {
|
||||
it('unauthorized defaults and overrides', () => {
|
||||
const def = errors.unauthorized();
|
||||
expect(def.status).toBe(401);
|
||||
expect(def.code).toBe('UNAUTHORIZED');
|
||||
expect(def.message).toBe('Unauthorized');
|
||||
expect(errors.unauthorized('custom').message).toBe('custom');
|
||||
});
|
||||
|
||||
it('notFound uses resource name in message', () => {
|
||||
const e = errors.notFound('Part');
|
||||
expect(e.status).toBe(404);
|
||||
expect(e.message).toBe('Part not found');
|
||||
});
|
||||
|
||||
it('validation wraps details', () => {
|
||||
const e = errors.validation({ field: 'x' });
|
||||
expect(e.status).toBe(400);
|
||||
expect(e.code).toBe('VALIDATION_ERROR');
|
||||
expect(e.details).toEqual({ field: 'x' });
|
||||
});
|
||||
|
||||
it('conflict requires a message', () => {
|
||||
const e = errors.conflict('serial already exists');
|
||||
expect(e.status).toBe(409);
|
||||
expect(e.code).toBe('CONFLICT');
|
||||
expect(e.message).toBe('serial already exists');
|
||||
});
|
||||
|
||||
it('tooManyRequests returns 429', () => {
|
||||
expect(errors.tooManyRequests().status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export class AppError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details?: unknown;
|
||||
|
||||
constructor(status: number, code: string, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export const errors = {
|
||||
unauthorized: (msg = 'Unauthorized') => new AppError(401, 'UNAUTHORIZED', msg),
|
||||
forbidden: (msg = 'Forbidden') => new AppError(403, 'FORBIDDEN', msg),
|
||||
notFound: (resource: string) => new AppError(404, 'NOT_FOUND', `${resource} not found`),
|
||||
conflict: (msg: string) => new AppError(409, 'CONFLICT', msg),
|
||||
badRequest: (msg: string, details?: unknown) => new AppError(400, 'BAD_REQUEST', msg, details),
|
||||
validation: (details: unknown) => new AppError(400, 'VALIDATION_ERROR', 'Validation failed', details),
|
||||
tooManyRequests: (msg = 'Too many requests') => new AppError(429, 'RATE_LIMITED', msg),
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
import pino from 'pino';
|
||||
import { env } from '../env.js';
|
||||
|
||||
export const logger = pino({
|
||||
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
...(env.NODE_ENV === 'production'
|
||||
? {}
|
||||
: { transport: { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss.l' } } }),
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { WebhookEventName } from '@vector/shared';
|
||||
import { prisma } from '@vector/db';
|
||||
import * as webhooksSvc from '../services/webhooks.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// Recursion guard: deliveries include this header so receivers know the payload
|
||||
// originated from Vector and can short-circuit echo loops. Worker-side BullMQ
|
||||
// delivery (planned in the Phase 7 follow-up) will honor the same header plus a
|
||||
// max-depth check.
|
||||
export const VECTOR_HOOK_HEADER = 'x-vector-webhook';
|
||||
|
||||
const DELIVERY_TIMEOUT_MS = 8_000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const BACKOFF_MS = [0, 2_000, 10_000];
|
||||
|
||||
interface EmitOptions {
|
||||
event: WebhookEventName;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Fire-and-forget: collects active subscriptions for the event and schedules delivery
|
||||
// to each. Never throws into caller. This is the interim in-process implementation;
|
||||
// the plan calls for a BullMQ worker — keep the signature stable so swapping stays
|
||||
// a one-line change in `emit`.
|
||||
export async function emit({ event, payload }: EmitOptions): Promise<void> {
|
||||
const subs = await prisma
|
||||
.$transaction((tx) => webhooksSvc.listActiveForEvent(tx, event))
|
||||
.catch((err) => {
|
||||
logger.warn({ err, event }, 'webhook emit: subscription lookup failed');
|
||||
return [];
|
||||
});
|
||||
if (subs.length === 0) return;
|
||||
const body = JSON.stringify({ event, data: payload, emittedAt: new Date().toISOString() });
|
||||
for (const sub of subs) {
|
||||
if (!sub.secret) continue;
|
||||
void deliver(sub.id, sub.url, sub.secret, body, event).catch((err) => {
|
||||
logger.warn({ err, event, subId: sub.id }, 'webhook delivery crashed');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function deliver(
|
||||
subId: string,
|
||||
url: string,
|
||||
secret: string,
|
||||
body: string,
|
||||
event: WebhookEventName,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
||||
const wait = BACKOFF_MS[attempt] ?? 0;
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signature = webhooksSvc.signBody(secret, body, timestamp);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
[VECTOR_HOOK_HEADER]: 'v1',
|
||||
'x-vector-event': event,
|
||||
'x-vector-timestamp': String(timestamp),
|
||||
'x-vector-signature': signature,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (res.ok) {
|
||||
logger.debug({ subId, event, status: res.status, attempt }, 'webhook delivered');
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
{ subId, event, status: res.status, attempt },
|
||||
'webhook non-2xx, will retry',
|
||||
);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
logger.warn({ err, subId, event, attempt }, 'webhook delivery error');
|
||||
}
|
||||
}
|
||||
logger.error({ subId, event }, 'webhook delivery exhausted retries');
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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['0–30d']).toBe(1);
|
||||
expect(byLabel['31–90d']).toBe(1);
|
||||
expect(byLabel['1–2y']).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 });
|
||||
});
|
||||
});
|
||||
@@ -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: '0–30d', maxDays: 30 },
|
||||
{ label: '31–90d', maxDays: 90 },
|
||||
{ label: '91–180d', maxDays: 180 },
|
||||
{ label: '181–365d', maxDays: 365 },
|
||||
{ label: '1–2y', 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 };
|
||||
}
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
Vendored
+21
@@ -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 {};
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@vector/config/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
include: ['src/services/**', 'src/lib/**'],
|
||||
exclude: ['**/*.test.ts', '**/types.ts'],
|
||||
thresholds: {
|
||||
lines: 60,
|
||||
functions: 60,
|
||||
branches: 60,
|
||||
statements: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user