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

Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:

- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate

Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
This commit is contained in:
2026-04-16 20:52:32 -04:00
commit 7c0d422228
216 changed files with 19393 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import * as svc from '../services/analytics.js';
export async function dashboard(_req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.$transaction((tx) => svc.dashboard(tx));
res.json(data);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { csvCell } from './audit-export.js';
describe('csvCell', () => {
it('returns empty string for null / undefined', () => {
expect(csvCell(null)).toBe('');
expect(csvCell(undefined)).toBe('');
});
it('stringifies primitives', () => {
expect(csvCell('hi')).toBe('hi');
expect(csvCell(42)).toBe('42');
expect(csvCell(true)).toBe('true');
});
it('formats Date as ISO string', () => {
const d = new Date('2026-01-01T00:00:00.000Z');
expect(csvCell(d)).toBe('2026-01-01T00:00:00.000Z');
});
it('quotes values containing commas', () => {
expect(csvCell('a,b')).toBe('"a,b"');
});
it('quotes values containing newlines', () => {
expect(csvCell('line1\nline2')).toBe('"line1\nline2"');
expect(csvCell('line1\r\nline2')).toBe('"line1\r\nline2"');
});
it('escapes embedded double-quotes by doubling them', () => {
expect(csvCell('say "hi"')).toBe('"say ""hi"""');
});
it('leaves plain text untouched', () => {
expect(csvCell('plain-text_123')).toBe('plain-text_123');
});
});
+100
View File
@@ -0,0 +1,100 @@
import type { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { prisma, Prisma } from '@vector/db';
import { PartEventType } from '@vector/shared';
const Query = z.object({
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
type: PartEventType.optional(),
partId: z.string().uuid().optional(),
});
const HEADERS = [
'createdAt',
'eventType',
'partId',
'serialNumber',
'field',
'oldValue',
'newValue',
'actorUsername',
];
// CSV-escape: wrap in quotes, double up embedded quotes. Handles commas, newlines, quotes.
export function csvCell(value: unknown): string {
if (value === null || value === undefined) return '';
const str = value instanceof Date ? value.toISOString() : String(value);
if (/["\n\r,]/.test(str)) return `"${str.replace(/"/g, '""')}"`;
return str;
}
export async function eventsCsv(req: Request, res: Response, next: NextFunction) {
try {
const parsed = Query.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({
code: 'VALIDATION_FAILED',
message: 'Invalid export filters',
issues: parsed.error.issues,
});
return;
}
const { from, to, type, partId } = parsed.data;
const where: Prisma.PartEventWhereInput = {};
if (type) where.type = type;
if (partId) where.partId = partId;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = from;
if (to) where.createdAt.lte = to;
}
res.setHeader('content-type', 'text/csv; charset=utf-8');
res.setHeader(
'content-disposition',
`attachment; filename="vector-audit-${new Date().toISOString().slice(0, 10)}.csv"`,
);
res.setHeader('cache-control', 'no-store');
res.write(HEADERS.join(',') + '\n');
// Keyset-paginate by createdAt+id so we never materialize the full table in memory.
const BATCH = 1000;
let cursor: { id: string } | undefined;
for (;;) {
const rows = await prisma.partEvent.findMany({
where,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: BATCH,
...(cursor ? { skip: 1, cursor } : {}),
include: {
part: { select: { serialNumber: true } },
user: { select: { username: true } },
},
});
if (rows.length === 0) break;
for (const row of rows) {
res.write(
[
csvCell(row.createdAt),
csvCell(row.type),
csvCell(row.partId),
csvCell(row.part.serialNumber),
csvCell(row.field),
csvCell(row.oldValue),
csvCell(row.newValue),
csvCell(row.user?.username ?? null),
].join(',') + '\n',
);
}
if (rows.length < BATCH) break;
const last = rows[rows.length - 1];
if (!last) break;
cursor = { id: last.id };
}
res.end();
} catch (err) {
next(err);
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { CookieOptions, NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type { LoginRequest } from '@vector/shared';
import { env } from '../env.js';
import * as authService from '../services/auth.js';
import { issueCsrfToken } from '../middleware/csrf.js';
import { errors } from '../lib/http-error.js';
const accessCookieOpts: CookieOptions = {
httpOnly: true,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
path: '/',
maxAge: authService.ACCESS_TOKEN_TTL_MS,
};
const refreshCookieOpts: CookieOptions = {
httpOnly: true,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
path: '/api/auth',
maxAge: authService.REFRESH_TOKEN_TTL_MS,
};
function setAuthCookies(res: Response, tokens: authService.AuthTokens) {
res.cookie('token', tokens.accessToken, accessCookieOpts);
res.cookie('refresh', tokens.refreshToken, refreshCookieOpts);
issueCsrfToken(res);
}
function clearAuthCookies(res: Response) {
res.clearCookie('token', { path: '/' });
res.clearCookie('refresh', { path: '/api/auth' });
res.clearCookie('csrf', { path: '/' });
}
export async function login(req: Request, res: Response, next: NextFunction) {
try {
const { username, password } = req.validated!.body as LoginRequest;
const { user, tokens } = await prisma.$transaction((tx) =>
authService.login(tx, username, password),
);
setAuthCookies(res, tokens);
res.json(user);
} catch (err) {
next(err);
}
}
export async function refresh(req: Request, res: Response, next: NextFunction) {
try {
const presented = req.cookies?.refresh;
if (!presented) throw errors.unauthorized('Missing refresh token');
const { user, tokens } = await prisma.$transaction((tx) =>
authService.rotate(tx, presented),
);
setAuthCookies(res, tokens);
res.json(user);
} catch (err) {
next(err);
}
}
export async function logout(req: Request, res: Response, next: NextFunction) {
try {
const presented = req.cookies?.refresh;
await prisma.$transaction((tx) => authService.revoke(tx, presented));
clearAuthCookies(res);
res.json({ message: 'Logged out' });
} catch (err) {
next(err);
}
}
export async function me(req: Request, res: Response, next: NextFunction) {
try {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
if (!user) throw errors.unauthorized();
res.json({ id: user.id, username: user.username, email: user.email, role: user.role });
} catch (err) {
next(err);
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { prisma } from '@vector/db';
import {
CreateBinRequest,
PaginationQuery,
UpdateBinRequest,
} from '@vector/shared';
import * as svc from '../services/locations.js';
import { errors } from '../lib/http-error.js';
const BinListQuery = PaginationQuery.extend({
roomId: z.string().uuid().optional(),
siteId: z.string().uuid().optional(),
});
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as z.infer<typeof BinListQuery>;
const result = await prisma.$transaction((tx) => svc.listBins(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const bin = await prisma.$transaction((tx) => svc.getBin(tx, req.params.id));
if (!bin) throw errors.notFound('Bin');
res.json(bin);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateBinRequest;
const bin = await prisma.$transaction((tx) => svc.createBin(tx, input));
res.status(201).json(bin);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateBinRequest;
const bin = await prisma.$transaction((tx) => svc.updateBin(tx, req.params.id, input));
res.json(bin);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.removeBin(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export { BinListQuery };
+49
View File
@@ -0,0 +1,49 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CategoryListQuery,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@vector/shared';
import * as svc from '../services/categories.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as CategoryListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateCategoryRequest;
const category = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(category);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateCategoryRequest;
const category = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input),
);
res.json(category);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateHostRequest,
HostListQuery,
UpdateHostRequest,
} from '@vector/shared';
import * as svc from '../services/hosts.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as HostListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const host = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!host) throw errors.notFound('Host');
res.json(host);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateHostRequest;
const host = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(host);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateHostRequest;
const host = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(host);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateManufacturerRequest,
PaginationQuery,
UpdateManufacturerRequest,
} from '@vector/shared';
import * as svc from '../services/manufacturers.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = (req.validated!.query as PaginationQuery);
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateManufacturerRequest;
const m = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(m);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateManufacturerRequest;
const m = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(m);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export { errors };
+88
View File
@@ -0,0 +1,88 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
BulkPartsRequest,
CreatePartRequest,
PartEventsQuery,
PartListQuery,
UpdatePartRequest,
} from '@vector/shared';
import * as svc from '../services/parts.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PartListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const part = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!part) throw errors.notFound('Part');
res.json(part);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreatePartRequest;
const part = await prisma.$transaction((tx) =>
svc.create(tx, input, req.user ?? null),
);
res.status(201).json(part);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdatePartRequest;
const part = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input, req.user ?? null),
);
res.json(part);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export async function bulk(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as BulkPartsRequest;
const result = await prisma.$transaction((tx) =>
svc.bulkUpdate(tx, input, req.user ?? null),
);
res.json(result);
} catch (err) {
next(err);
}
}
export async function getEvents(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PartEventsQuery;
const result = await prisma.$transaction((tx) =>
svc.listEvents(tx, req.params.id, q),
);
res.json(result);
} catch (err) {
next(err);
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateRepairJobRequest,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
import * as svc from '../services/repairs.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as RepairJobListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const repair = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!repair) throw errors.notFound('Repair');
res.json(repair);
} catch (err) {
next(err);
}
}
export async function listForPart(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const repairs = await prisma.$transaction((tx) => svc.listForPart(tx, req.params.id));
res.json(repairs);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateRepairJobRequest;
const repair = await prisma.$transaction((tx) =>
svc.create(tx, input, req.user ?? null),
);
res.status(201).json(repair);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateRepairJobRequest;
const repair = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input, req.user ?? null),
);
res.json(repair);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+63
View File
@@ -0,0 +1,63 @@
import type { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { prisma } from '@vector/db';
import {
CreateRoomRequest,
PaginationQuery,
UpdateRoomRequest,
} from '@vector/shared';
import * as svc from '../services/locations.js';
import { errors } from '../lib/http-error.js';
const RoomListQuery = PaginationQuery.extend({ siteId: z.string().uuid().optional() });
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as z.infer<typeof RoomListQuery>;
const result = await prisma.$transaction((tx) => svc.listRooms(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const room = await prisma.$transaction((tx) => svc.getRoom(tx, req.params.id));
if (!room) throw errors.notFound('Room');
res.json(room);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateRoomRequest;
const room = await prisma.$transaction((tx) => svc.createRoom(tx, input));
res.status(201).json(room);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateRoomRequest;
const room = await prisma.$transaction((tx) => svc.updateRoom(tx, req.params.id, input));
res.json(room);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.removeRoom(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export { RoomListQuery };
+54
View File
@@ -0,0 +1,54 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateSavedViewRequest,
SavedViewListQuery,
UpdateSavedViewRequest,
} from '@vector/shared';
import * as svc from '../services/saved-views.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
const q = req.validated!.query as SavedViewListQuery;
const result = await prisma.$transaction((tx) => svc.listMine(tx, req.user!, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
const input = req.validated!.body as CreateSavedViewRequest;
const view = await prisma.$transaction((tx) => svc.create(tx, req.user!, input));
res.status(201).json(view);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
const input = req.validated!.body as UpdateSavedViewRequest;
const view = await prisma.$transaction((tx) =>
svc.update(tx, req.user!, req.params.id, input),
);
res.json(view);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
await prisma.$transaction((tx) => svc.remove(tx, req.user!, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateSiteRequest,
PaginationQuery,
UpdateSiteRequest,
} from '@vector/shared';
import * as svc from '../services/locations.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PaginationQuery;
const result = await prisma.$transaction((tx) => svc.listSites(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const site = await prisma.$transaction((tx) => svc.getSite(tx, req.params.id));
if (!site) throw errors.notFound('Site');
res.json(site);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateSiteRequest;
const site = await prisma.$transaction((tx) => svc.createSite(tx, input));
res.status(201).json(site);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateSiteRequest;
const site = await prisma.$transaction((tx) => svc.updateSite(tx, req.params.id, input));
res.json(site);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.removeSite(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+92
View File
@@ -0,0 +1,92 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
AssignTagsRequest,
CreateTagRequest,
TagListQuery,
UpdateTagRequest,
} from '@vector/shared';
import * as svc from '../services/tags.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as TagListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateTagRequest;
const tag = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(tag);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateTagRequest;
const tag = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(tag);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export async function listForPart(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const tags = await prisma.$transaction((tx) => svc.listForPart(tx, req.params.id));
res.json(tags);
} catch (err) {
next(err);
}
}
export async function assignToPart(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const input = req.validated!.body as AssignTagsRequest;
const tags = await prisma.$transaction((tx) =>
svc.assignToPart(tx, req.params.id, input, req.user ?? null),
);
res.json(tags);
} catch (err) {
next(err);
}
}
export async function unassignFromPart(
req: Request<{ id: string; tagId: string }>,
res: Response,
next: NextFunction,
) {
try {
const tags = await prisma.$transaction((tx) =>
svc.unassignFromPart(tx, req.params.id, req.params.tagId, req.user ?? null),
);
res.json(tags);
} catch (err) {
next(err);
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateUserRequest,
PaginationQuery,
UpdateUserRequest,
} from '@vector/shared';
import * as svc from '../services/users.js';
export async function listUsers(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PaginationQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function createUser(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateUserRequest;
const u = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(u);
} catch (err) {
next(err);
}
}
export async function updateUser(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateUserRequest;
const u = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(u);
} catch (err) {
next(err);
}
}
export async function deleteUser(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateWebhookSubscriptionRequest,
UpdateWebhookSubscriptionRequest,
WebhookSubscriptionListQuery,
} from '@vector/shared';
import * as svc from '../services/webhooks.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as WebhookSubscriptionListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateWebhookSubscriptionRequest;
const sub = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(sub);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateWebhookSubscriptionRequest;
const sub = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(sub);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export async function rotate(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const sub = await prisma.$transaction((tx) => svc.rotateSecret(tx, req.params.id));
res.json(sub);
} catch (err) {
next(err);
}
}