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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user