import { Prisma } from '@vector/db'; import type { LogRepairRequest, RepairListQuery } from '@vector/shared'; import { errors } from '../lib/http-error.js'; import { emit } from '../lib/webhook-emitter.js'; import * as partsSvc from './parts.js'; import * as partModelsSvc from './part-models.js'; import { resolveHost } from './fms.js'; import type { Actor, Tx } from './types.js'; // A Repair is the persistent log of a physical part swap on a host. The tech enters the broken // serial + mpn + replacement serial; if the broken part isn't in the catalog we ingest it. The // broken part is placed into the tech's custody (dropped in a bin later via the custody flow). const repairInclude = { host: true, brokenPart: { include: { partModel: true, manufacturer: true } }, replacement: { include: { partModel: true, manufacturer: true } }, performedBy: { select: { id: true, username: true } }, fm: { select: { id: true, status: true } }, } satisfies Prisma.RepairInclude; export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>; function buildWhere(q: RepairListQuery): Prisma.RepairWhereInput { const where: Prisma.RepairWhereInput = {}; if (q.hostId) where.hostId = q.hostId; if (q.performedById) where.performedById = q.performedById; if (q.fmId) where.fmId = q.fmId; if (q.since) where.performedAt = { gte: new Date(q.since) }; return where; } export async function list(tx: Tx, q: RepairListQuery) { const { page, pageSize } = q; const where = buildWhere(q); const [data, total] = await Promise.all([ tx.repair.findMany({ where, orderBy: { performedAt: 'desc' }, include: repairInclude, skip: (page - 1) * pageSize, take: pageSize, }), tx.repair.count({ where }), ]); return { data, page, pageSize, total }; } export function get(tx: Tx, id: string) { return tx.repair.findUnique({ where: { id }, include: repairInclude }); } function repairPayload(r: RepairWithRelations) { return { id: r.id, host: { id: r.host.id, assetId: r.host.assetId, name: r.host.name }, brokenPart: { id: r.brokenPart.id, serialNumber: r.brokenPart.serialNumber, mpn: r.brokenPart.partModel.mpn, state: r.brokenPart.state, }, replacement: { id: r.replacement.id, serialNumber: r.replacement.serialNumber, mpn: r.replacement.partModel.mpn, state: r.replacement.state, }, performedBy: r.performedBy, performedAt: r.performedAt.toISOString(), fmId: r.fmId, }; } export async function log( tx: Tx, input: LogRepairRequest, actor: Actor, ): Promise { const host = await resolveHost(tx, input); // 1. Resolve replacement — must exist; must be SPARE, or a PENDING_REPAIR held by the actor. const replacement = await tx.part.findUnique({ where: { serialNumber: input.replacementSerial }, include: { partModel: true }, }); if (!replacement) { throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`); } const heldForRepairByActor = replacement.state === 'PENDING_REPAIR' && replacement.custodianId === actor.id; if (replacement.state !== 'SPARE' && !heldForRepairByActor) { throw errors.badRequest( `Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE or PENDING_REPAIR held by you`, ); } // 2. Resolve broken — reuse if found, else ingest. let broken = await tx.part.findUnique({ where: { serialNumber: input.brokenSerial }, include: { partModel: true }, }); if (broken) { if (broken.hostId && broken.hostId !== host.id) { throw errors.badRequest( `Broken part ${input.brokenSerial} is currently on a different host`, ); } } else { let pm: { id: string; manufacturerId: string }; if (input.brokenPartModelId) { const existing = await tx.partModel.findUnique({ where: { id: input.brokenPartModelId } }); if (!existing) throw errors.badRequest('Broken part model does not exist'); pm = { id: existing.id, manufacturerId: existing.manufacturerId }; } else { if (!input.brokenMpn || !input.brokenManufacturerId) { throw errors.badRequest( 'Provide brokenPartModelId or both brokenMpn and brokenManufacturerId', ); } pm = await partModelsSvc.upsertByMpn(tx, { manufacturerId: input.brokenManufacturerId, mpn: input.brokenMpn, }); } const created = await tx.part.create({ data: { serialNumber: input.brokenSerial, partModelId: pm.id, manufacturerId: pm.manufacturerId, state: 'DEPLOYED', hostId: host.id, }, include: { partModel: true }, }); await tx.partEvent.create({ data: { partId: created.id, userId: actor.id, type: 'CREATED', newValue: created.serialNumber, }, }); broken = created; } // 3. Optional FM link — must belong to the same host; we do NOT auto-close it. if (input.fmId) { const fm = await tx.fm.findUnique({ where: { id: input.fmId } }); if (!fm) throw errors.badRequest('FM does not exist'); if (fm.hostId !== host.id) { throw errors.badRequest('FM is on a different host than the repair'); } } // 4. Custody state is driven by the broken model's destroyOnFail flag. const custodyState = broken.partModel.destroyOnFail ? 'PENDING_DESTRUCTION_IN_CUSTODY' : 'PENDING_DROP_IN_CUSTODY'; // 5. Transition both parts through the standard parts.update machinery so every state // and location change emits the usual PartEvents. The resolver clears host/bin // automatically when entering custody / DEPLOYED. await partsSvc.update( tx, broken.id, { state: custodyState, custodianId: actor.id }, actor, ); await partsSvc.update( tx, replacement.id, { state: 'DEPLOYED', hostId: host.id }, actor, ); // 6. Persist the Repair row. const repair = await tx.repair.create({ data: { hostId: host.id, brokenPartId: broken.id, replacementPartId: replacement.id, performedById: actor.id, fmId: input.fmId ?? null, }, include: repairInclude, }); // 7. Swap event on each part — so the part timeline shows the repair link. await tx.partEvent.createMany({ data: [ { partId: broken.id, userId: actor.id, type: 'PART_SWAPPED', field: 'role', oldValue: 'DEPLOYED', newValue: repair.id, }, { partId: replacement.id, userId: actor.id, type: 'PART_SWAPPED', field: 'role', oldValue: 'SPARE', newValue: repair.id, }, ], }); void emit({ event: 'repair.logged', payload: { repair: repairPayload(repair) } }); return repair; }