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>
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user