Files
Vector/apps/api/src/services/parts.ts
T
josh 3d77f2846d
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m0s
feat: split Repairs into FM, Repair, and Custody workflows
The old Repairs module had grown ticketing-system features (status lifecycle,
comments, assignee, notes) that duplicate what the external ticketing tool
already owns. Vector only needs to track whether maintenance is open or closed.

- Rename RepairJob -> Fm (OPEN/CLOSED only), drop RepairComment, assignee, notes
- New Repair table: persistent log of physical part swaps, with ingest on
  unknown broken MPN via partModels.upsertByMpn
- New custody model: PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
  states + Part.custodianId, with a "My Custody" page for drop-off
- PartModel.destroyOnFail routes broken parts to the destruction path
- Host lookup on /fms and /repairs accepts hostId XOR assetId
- Wire the dormant webhook emitter: fm.opened, fm.closed, repair.logged
- Single fresh Prisma migration (dev DB was wiped, no backfill)

Tests: 60 passing (custody transitions in parts.test.ts; new fms.test.ts,
repairs.test.ts, custody.test.ts covering happy paths, validation failures,
webhook emissions, and ingest-on-unknown-MPN).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 12:22:56 -04:00

470 lines
16 KiB
TypeScript

import { Prisma } from '@vector/db';
import type {
CreatePartRequest,
PaginationQuery,
PartListQuery,
PartState as PartStateValue,
UpdatePartRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import * as partModelsSvc from './part-models.js';
import * as tagsSvc from './tags.js';
import type { Actor, Tx } from './types.js';
// Enforces the Part state/location invariant and auto-clears stale fields on state transitions.
// The matrix is:
// DEPLOYED — hostId required, binId forbidden, custodianId forbidden
// SPARE / BROKEN / PENDING_DESTRUCTION — binId optional, hostId + custodian forbidden
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
// — custodianId required, host + bin forbidden
// Callers only need to pass what's changing; anything omitted is inherited from `current`.
function resolveLocation(
state: PartStateValue,
input: {
binId?: string | null;
hostId?: string | null;
custodianId?: string | null;
},
current: {
binId: string | null;
hostId: string | null;
custodianId: string | null;
} = { binId: null, hostId: null, custodianId: null },
): { binId: string | null; hostId: string | null; custodianId: string | null } {
if (state === 'DEPLOYED') {
const hostId = input.hostId !== undefined ? input.hostId : current.hostId;
if (!hostId) throw errors.badRequest('A deployed part must be assigned to a host');
if (input.binId) {
throw errors.badRequest('A deployed part cannot also be in a storage bin');
}
if (input.custodianId) {
throw errors.badRequest('A deployed part cannot be in custody');
}
return { binId: null, hostId, custodianId: null };
}
if (state === 'PENDING_DROP_IN_CUSTODY' || state === 'PENDING_DESTRUCTION_IN_CUSTODY') {
const custodianId =
input.custodianId !== undefined ? input.custodianId : current.custodianId;
if (!custodianId) throw errors.badRequest('A part in custody must name a custodian');
if (input.hostId) throw errors.badRequest('A part in custody cannot be on a host');
if (input.binId) throw errors.badRequest('A part in custody cannot be in a bin');
return { binId: null, hostId: null, custodianId };
}
// SPARE / BROKEN / PENDING_DESTRUCTION
if (input.hostId) {
throw errors.badRequest('Only deployed parts can be assigned to a host');
}
if (input.custodianId) {
throw errors.badRequest('Only custody states can have a custodian');
}
const binId = input.binId !== undefined ? input.binId : current.binId;
return { binId, hostId: null, custodianId: null };
}
const partInclude = {
manufacturer: true,
partModel: true,
bin: { include: { room: { include: { site: true } } } },
category: true,
host: true,
custodian: { select: { id: true, username: 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;
}
// Resolves a CreatePartRequest's partModelId — either the explicit one passed in, or looked up
// via (manufacturerId, mpn) shorthand that auto-creates a PartModel catalog row on first use.
// Exactly one of those two forms is required; the zod schema enforces that at the boundary.
async function resolvePartModel(
tx: Tx,
input: { partModelId?: string; manufacturerId?: string; mpn?: string },
): Promise<{ partModelId: string; manufacturerId: string }> {
if (input.partModelId) {
const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } });
if (!pm) throw errors.badRequest('Part model does not exist');
return { partModelId: pm.id, manufacturerId: pm.manufacturerId };
}
if (input.manufacturerId && input.mpn) {
const pm = await partModelsSvc.upsertByMpn(tx, {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
});
return { partModelId: pm.id, manufacturerId: pm.manufacturerId };
}
throw errors.badRequest('Provide partModelId or both manufacturerId and mpn');
}
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.hostId) where.hostId = q.hostId;
if (q.custodianId) where.custodianId = q.custodianId;
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
if (q.partModelId) where.partModelId = q.partModelId;
if (q.categoryId) where.categoryId = q.categoryId;
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
if (q.q) {
where.OR = [
{ serialNumber: { contains: q.q } },
{ partModel: { mpn: { contains: q.q } } },
{ notes: { contains: q.q } },
];
}
if (q.tagId) where.tags = { some: { tagId: q.tagId } };
const partModelFilter: Prisma.PartModelWhereInput = {};
if (q.mpn) partModelFilter.mpn = { contains: q.mpn };
if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() };
if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter;
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> {
const { partModelId, manufacturerId } = await resolvePartModel(tx, input);
// If caller also supplied manufacturerId explicitly, it must match the part model's.
if (input.manufacturerId && input.manufacturerId !== manufacturerId) {
throw errors.badRequest('manufacturerId does not match the selected part model');
}
const state = input.state ?? 'SPARE';
const location = resolveLocation(state, {
binId: input.binId,
hostId: input.hostId,
custodianId: input.custodianId,
});
try {
const p = await tx.part.create({
data: {
serialNumber: input.serialNumber,
partModelId,
manufacturerId,
price: input.price ?? null,
state,
binId: location.binId,
hostId: location.hostId,
custodianId: location.custodianId,
categoryId: input.categoryId ?? 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.partModelId !== undefined) {
const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } });
if (!pm) throw errors.badRequest('Part model does not exist');
data.partModel = { connect: { id: pm.id } };
// Keep denormalized manufacturerId consistent with the chosen model.
data.manufacturer = { connect: { id: pm.manufacturerId } };
}
if (input.price !== undefined) data.price = input.price;
if (input.state !== undefined) data.state = input.state;
let nextBinId: string | null = current.binId;
let nextHostId: string | null = current.hostId;
let nextCustodianId: string | null = current.custodianId;
const locationTouched =
input.state !== undefined ||
input.binId !== undefined ||
input.hostId !== undefined ||
input.custodianId !== undefined;
if (locationTouched) {
const nextState = input.state ?? (current.state as PartStateValue);
const resolved = resolveLocation(
nextState,
{
binId: input.binId,
hostId: input.hostId,
custodianId: input.custodianId,
},
{
binId: current.binId,
hostId: current.hostId,
custodianId: current.custodianId,
},
);
nextBinId = resolved.binId;
nextHostId = resolved.hostId;
nextCustodianId = resolved.custodianId;
data.bin = resolved.binId ? { connect: { id: resolved.binId } } : { disconnect: true };
data.host = resolved.hostId ? { connect: { id: resolved.hostId } } : { disconnect: true };
data.custodian = resolved.custodianId
? { connect: { id: resolved.custodianId } }
: { disconnect: true };
}
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { 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 (nextBinId !== current.binId) {
events.push({
partId: part.id,
userId,
type: 'LOCATION_CHANGED',
field: 'bin',
oldValue: binPath(current.bin),
newValue: binPath(part.bin),
});
}
if (nextHostId !== current.hostId) {
events.push({
partId: part.id,
userId,
type: 'LOCATION_CHANGED',
field: 'host',
oldValue: current.host?.name ?? null,
newValue: part.host?.name ?? null,
});
}
if (nextCustodianId !== current.custodianId) {
events.push({
partId: part.id,
userId,
type: 'LOCATION_CHANGED',
field: 'custodian',
oldValue: current.custodian?.username ?? null,
newValue: part.custodian?.username ?? null,
});
}
if (input.partModelId !== undefined && input.partModelId !== current.partModelId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'partModel',
oldValue: current.partModel.mpn,
newValue: part.partModel.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.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) {
if (err.code === 'P2025') throw errors.notFound('Part');
if (err.code === 'P2003') {
throw errors.conflict('Cannot delete: part is referenced by an FM or repair');
}
}
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;
hostId?: 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 (input.hostId !== undefined) patch.hostId = input.hostId;
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 } });
const 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 };
}