feat: split Repairs into FM, Repair, and Custody workflows
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m0s

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>
This commit is contained in:
2026-04-17 12:22:56 -04:00
parent 6690d8a5dd
commit 3d77f2846d
54 changed files with 3304 additions and 1287 deletions
+75 -13
View File
@@ -11,27 +11,56 @@ import * as partModelsSvc from './part-models.js';
import * as tagsSvc from './tags.js';
import type { Actor, Tx } from './types.js';
// DEPLOYED parts live on a host; every other state lives in a bin (or is unassigned).
// This helper enforces the invariant on create/update and auto-clears the stale field on a
// state transition, so callers don't have to remember to null the opposite relation.
// 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 },
current: { binId: string | null; hostId: string | null } = { binId: null, hostId: null },
): { binId: string | null; hostId: string | null } {
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');
}
return { binId: null, hostId };
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 };
return { binId, hostId: null, custodianId: null };
}
const partInclude = {
@@ -40,6 +69,7 @@ const partInclude = {
bin: { include: { room: { include: { site: true } } } },
category: true,
host: true,
custodian: { select: { id: true, username: true } },
tags: { include: { tag: true } },
} satisfies Prisma.PartInclude;
@@ -94,6 +124,7 @@ function buildWhere(q: PartListQuery): 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;
@@ -148,7 +179,11 @@ export async function create(
}
const state = input.state ?? 'SPARE';
const location = resolveLocation(state, { binId: input.binId, hostId: input.hostId });
const location = resolveLocation(state, {
binId: input.binId,
hostId: input.hostId,
custodianId: input.custodianId,
});
try {
const p = await tx.part.create({
@@ -160,6 +195,7 @@ export async function create(
state,
binId: location.binId,
hostId: location.hostId,
custodianId: location.custodianId,
categoryId: input.categoryId ?? null,
notes: input.notes ?? null,
},
@@ -209,19 +245,35 @@ export async function update(
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.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 },
{ binId: current.binId, hostId: current.hostId },
{
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) {
@@ -275,6 +327,16 @@ export async function update(
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,
@@ -344,7 +406,7 @@ export async function remove(tx: Tx, id: string) {
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 a repair');
throw errors.conflict('Cannot delete: part is referenced by an FM or repair');
}
}
throw err;