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
+328
View File
@@ -0,0 +1,328 @@
import { Prisma } from '@vector/db';
import type {
CreatePartRequest,
PaginationQuery,
PartListQuery,
UpdatePartRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import * as tagsSvc from './tags.js';
import type { Actor, Tx } from './types.js';
const partInclude = {
manufacturer: true,
bin: { include: { room: { include: { site: true } } } },
category: true,
tags: { include: { tag: true } },
} satisfies Prisma.PartInclude;
type PartWithRelations = Prisma.PartGetPayload<{ include: typeof partInclude }>;
type BinWithSite = NonNullable<PartWithRelations['bin']>;
export type PartWithPath = Omit<PartWithRelations, 'tags'> & {
bin: (BinWithSite & { fullPath?: string }) | null;
tags: { id: string; name: string; color: string | null }[];
};
function binPath(bin: BinWithSite | null | undefined): string | null {
if (!bin) return null;
return `${bin.room.site.name}.${bin.room.name}.${bin.name}`;
}
function flattenTags(part: PartWithRelations): PartWithPath {
const { tags, ...rest } = part;
const out = rest as PartWithPath;
if (out.bin) out.bin.fullPath = binPath(out.bin) ?? undefined;
out.tags = tags.map((t) => ({
id: t.tag.id,
name: t.tag.name,
color: t.tag.color,
}));
return out;
}
function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
const where: Prisma.PartWhereInput = {};
if (q.state) where.state = q.state;
if (q.binId) where.binId = q.binId;
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
if (q.categoryId) where.categoryId = q.categoryId;
if (q.mpn) where.mpn = { contains: q.mpn };
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
if (q.q) {
where.OR = [
{ serialNumber: { contains: q.q } },
{ mpn: { contains: q.q } },
{ notes: { contains: q.q } },
];
}
if (q.tagId) where.tags = { some: { tagId: q.tagId } };
if (q.eolOnly) {
// Parts attached to a manufacturer with an EOL date that has already passed.
where.manufacturer = { eolDate: { lt: new Date() } };
}
return where;
}
export async function list(tx: Tx, q: PartListQuery) {
const { page, pageSize } = q;
const where = buildWhere(q);
const [rows, total] = await Promise.all([
tx.part.findMany({
where,
orderBy: { createdAt: 'desc' },
include: partInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.part.count({ where }),
]);
return { data: rows.map(flattenTags), page, pageSize, total };
}
export async function get(tx: Tx, id: string): Promise<PartWithPath | null> {
const p = await tx.part.findUnique({ where: { id }, include: partInclude });
return p ? flattenTags(p) : null;
}
export async function create(
tx: Tx,
input: CreatePartRequest,
actor: Actor | null,
): Promise<PartWithPath> {
try {
const p = await tx.part.create({
data: {
serialNumber: input.serialNumber,
mpn: input.mpn,
manufacturerId: input.manufacturerId,
price: input.price ?? null,
state: input.state ?? 'SPARE',
binId: input.binId ?? null,
categoryId: input.categoryId ?? null,
replacementPartId: input.replacementPartId ?? null,
notes: input.notes ?? null,
},
include: partInclude,
});
await tx.partEvent.create({
data: {
partId: p.id,
userId: actor?.id ?? null,
type: 'CREATED',
newValue: p.serialNumber,
},
});
if (input.tagIds && input.tagIds.length > 0) {
await tagsSvc.setPartTags(tx, p.id, input.tagIds, actor);
}
const refreshed = await tx.part.findUnique({ where: { id: p.id }, include: partInclude });
return flattenTags(refreshed!);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Serial number already exists');
}
throw err;
}
}
export async function update(
tx: Tx,
id: string,
input: UpdatePartRequest,
actor: Actor | null,
): Promise<PartWithPath> {
const current = await tx.part.findUnique({ where: { id }, include: partInclude });
if (!current) throw errors.notFound('Part');
const data: Prisma.PartUpdateInput = {};
if (input.serialNumber !== undefined) data.serialNumber = input.serialNumber;
if (input.mpn !== undefined) data.mpn = input.mpn;
if (input.manufacturerId !== undefined) {
data.manufacturer = { connect: { id: input.manufacturerId } };
}
if (input.price !== undefined) data.price = input.price;
if (input.state !== undefined) data.state = input.state;
if (input.binId !== undefined) {
data.bin = input.binId ? { connect: { id: input.binId } } : { disconnect: true };
}
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { disconnect: true };
}
if (input.replacementPartId !== undefined) {
data.replacement = input.replacementPartId
? { connect: { id: input.replacementPartId } }
: { disconnect: true };
}
if (input.notes !== undefined) data.notes = input.notes;
let part: PartWithRelations;
try {
part = await tx.part.update({ where: { id }, data, include: partInclude });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part');
if (err.code === 'P2002') throw errors.conflict('Serial number already exists');
}
throw err;
}
const userId = actor?.id ?? null;
const events: Prisma.PartEventCreateManyInput[] = [];
if (input.state !== undefined && input.state !== current.state) {
events.push({
partId: part.id,
userId,
type: 'STATE_CHANGED',
field: 'state',
oldValue: current.state,
newValue: input.state,
});
}
if (input.binId !== undefined && input.binId !== current.binId) {
events.push({
partId: part.id,
userId,
type: 'LOCATION_CHANGED',
field: 'bin',
oldValue: binPath(current.bin),
newValue: binPath(part.bin),
});
}
if (input.mpn !== undefined && input.mpn !== current.mpn) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'mpn',
oldValue: current.mpn,
newValue: input.mpn,
});
}
if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'serialNumber',
oldValue: current.serialNumber,
newValue: input.serialNumber,
});
}
if (input.manufacturerId !== undefined && input.manufacturerId !== current.manufacturerId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'manufacturer',
oldValue: current.manufacturer.name,
newValue: part.manufacturer.name,
});
}
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'category',
oldValue: current.category?.name ?? null,
newValue: part.category?.name ?? null,
});
}
if (input.price !== undefined && input.price !== current.price) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'price',
oldValue: current.price?.toString() ?? null,
newValue: input.price?.toString() ?? null,
});
}
if (input.notes !== undefined && (input.notes ?? null) !== (current.notes ?? null)) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'notes',
oldValue: current.notes ?? null,
newValue: input.notes ?? null,
});
}
if (events.length > 0) await tx.partEvent.createMany({ data: events });
if (input.tagIds !== undefined) {
await tagsSvc.setPartTags(tx, part.id, input.tagIds, actor);
const refreshed = await tx.part.findUnique({ where: { id: part.id }, include: partInclude });
return flattenTags(refreshed!);
}
return flattenTags(part);
}
export async function remove(tx: Tx, id: string) {
try {
await tx.part.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Part');
}
throw err;
}
}
export async function listEvents(tx: Tx, partId: string, q: PaginationQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([
tx.partEvent.findMany({
where: { partId },
orderBy: { createdAt: 'desc' },
include: { user: { select: { username: true } } },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.partEvent.count({ where: { partId } }),
]);
return { data, page, pageSize, total };
}
// Bulk mutation. Batches all writes inside a single transaction so partial failures roll back.
// Intentionally capped so callers can't accidentally lock the whole parts table.
export interface BulkPartsInput {
ids: string[];
state?: CreatePartRequest['state'];
binId?: string | null;
addTagIds?: string[];
removeTagIds?: string[];
}
const BULK_LIMIT = 500;
export async function bulkUpdate(tx: Tx, input: BulkPartsInput, actor: Actor | null) {
if (input.ids.length === 0) throw errors.badRequest('No part ids supplied');
if (input.ids.length > BULK_LIMIT) {
throw errors.badRequest(`Bulk operations are limited to ${BULK_LIMIT} parts per call`);
}
const touched: string[] = [];
for (const id of input.ids) {
const patch: UpdatePartRequest = {};
if (input.state !== undefined) patch.state = input.state;
if (input.binId !== undefined) patch.binId = input.binId;
if (Object.keys(patch).length > 0) {
await update(tx, id, patch, actor);
}
if (input.addTagIds || input.removeTagIds) {
const existing = await tx.partTag.findMany({ where: { partId: id }, select: { tagId: true } });
let next = new Set(existing.map((r) => r.tagId));
(input.addTagIds ?? []).forEach((t) => next.add(t));
(input.removeTagIds ?? []).forEach((t) => next.delete(t));
await tagsSvc.setPartTags(tx, id, [...next], actor);
}
touched.push(id);
}
return { updated: touched.length };
}