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:
+156
-233
@@ -1,275 +1,198 @@
|
||||
import { Prisma } from '@vector/db';
|
||||
import type {
|
||||
CreateRepairCommentRequest,
|
||||
CreateRepairJobRequest,
|
||||
RepairCommentListQuery,
|
||||
RepairJobListQuery,
|
||||
UpdateRepairJobRequest,
|
||||
} from '@vector/shared';
|
||||
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,
|
||||
assignee: { select: { id: true, username: true, email: true, role: true } },
|
||||
problemParts: {
|
||||
include: {
|
||||
part: {
|
||||
include: { partModel: true, manufacturer: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.RepairJobInclude;
|
||||
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;
|
||||
|
||||
const commentInclude = {
|
||||
user: { select: { id: true, username: true } },
|
||||
} satisfies Prisma.RepairCommentInclude;
|
||||
export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>;
|
||||
|
||||
export async function list(tx: Tx, q: RepairJobListQuery) {
|
||||
const { page, pageSize, status, hostId, assigneeId, openOnly, problemPartId } = q;
|
||||
const where: Prisma.RepairJobWhereInput = {};
|
||||
if (status) where.status = status;
|
||||
if (hostId) where.hostId = hostId;
|
||||
if (assigneeId) where.assigneeId = assigneeId;
|
||||
if (openOnly) where.status = { in: ['PENDING', 'IN_PROGRESS'] };
|
||||
if (problemPartId) where.problemParts = { some: { partId: problemPartId } };
|
||||
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.repairJob.findMany({
|
||||
tx.repair.findMany({
|
||||
where,
|
||||
orderBy: [{ status: 'asc' }, { openedAt: 'desc' }],
|
||||
orderBy: { performedAt: 'desc' },
|
||||
include: repairInclude,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
tx.repairJob.count({ where }),
|
||||
tx.repair.count({ where }),
|
||||
]);
|
||||
return { data, page, pageSize, total };
|
||||
}
|
||||
|
||||
export function get(tx: Tx, id: string) {
|
||||
return tx.repairJob.findUnique({ where: { id }, include: repairInclude });
|
||||
return tx.repair.findUnique({ where: { id }, include: repairInclude });
|
||||
}
|
||||
|
||||
export function listForHost(tx: Tx, hostId: string) {
|
||||
return tx.repairJob.findMany({
|
||||
where: { hostId },
|
||||
orderBy: { openedAt: 'desc' },
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Validates that the submitted problem-part ids are all attached to the named host.
|
||||
// Parts that aren't on the host, or that don't exist, cause the whole repair create/update to fail
|
||||
// — no silent skipping. Parts can be in any state (a repair can target a SPARE that was tagged as
|
||||
// faulty during intake); the host-membership check is what matters.
|
||||
async function validateProblemParts(tx: Tx, hostId: string, partIds: string[] | undefined) {
|
||||
if (!partIds || partIds.length === 0) return;
|
||||
const uniqueIds = [...new Set(partIds)];
|
||||
const rows = await tx.part.findMany({
|
||||
where: { id: { in: uniqueIds } },
|
||||
select: { id: true, hostId: true },
|
||||
});
|
||||
const found = new Map(rows.map((r) => [r.id, r]));
|
||||
for (const id of uniqueIds) {
|
||||
const row = found.get(id);
|
||||
if (!row) throw errors.badRequest(`Part ${id} does not exist`);
|
||||
if (row.hostId !== hostId) {
|
||||
throw errors.badRequest(`Part ${id} is not on the selected host`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(
|
||||
export async function log(
|
||||
tx: Tx,
|
||||
input: CreateRepairJobRequest,
|
||||
actor: Actor | null,
|
||||
) {
|
||||
const host = await tx.host.findUnique({ where: { id: input.hostId } });
|
||||
if (!host) throw errors.notFound('Host');
|
||||
input: LogRepairRequest,
|
||||
actor: Actor,
|
||||
): Promise<RepairWithRelations> {
|
||||
const host = await resolveHost(tx, input);
|
||||
|
||||
await validateProblemParts(tx, input.hostId, input.problemPartIds);
|
||||
// 1. Resolve replacement — must exist, must be SPARE.
|
||||
const replacement = await tx.part.findUnique({
|
||||
where: { serialNumber: input.replacementSerial },
|
||||
include: { partModel: true },
|
||||
});
|
||||
if (!replacement) {
|
||||
throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`);
|
||||
}
|
||||
if (replacement.state !== 'SPARE') {
|
||||
throw errors.badRequest(
|
||||
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const repair = await tx.repairJob.create({
|
||||
// 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 {
|
||||
const pm = await partModelsSvc.upsertByMpn(tx, {
|
||||
manufacturerId: input.brokenManufacturerId,
|
||||
mpn: input.brokenMpn,
|
||||
});
|
||||
const created = await tx.part.create({
|
||||
data: {
|
||||
hostId: input.hostId,
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
notes: input.notes ?? null,
|
||||
problem: input.problem,
|
||||
status: 'PENDING',
|
||||
problemParts: input.problemPartIds && input.problemPartIds.length > 0
|
||||
? { create: [...new Set(input.problemPartIds)].map((partId) => ({ partId })) }
|
||||
: undefined,
|
||||
serialNumber: input.brokenSerial,
|
||||
partModelId: pm.id,
|
||||
manufacturerId: pm.manufacturerId,
|
||||
state: 'DEPLOYED',
|
||||
hostId: host.id,
|
||||
},
|
||||
include: repairInclude,
|
||||
include: { partModel: true },
|
||||
});
|
||||
if (input.problemPartIds && input.problemPartIds.length > 0) {
|
||||
await tx.partEvent.createMany({
|
||||
data: [...new Set(input.problemPartIds)].map((partId) => ({
|
||||
partId,
|
||||
userId: actor?.id ?? null,
|
||||
type: 'REPAIR_STARTED',
|
||||
newValue: repair.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return repair;
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
|
||||
throw errors.badRequest('Invalid host, assignee, or part id');
|
||||
}
|
||||
throw err;
|
||||
await tx.partEvent.create({
|
||||
data: {
|
||||
partId: created.id,
|
||||
userId: actor.id,
|
||||
type: 'CREATED',
|
||||
newValue: created.serialNumber,
|
||||
},
|
||||
});
|
||||
broken = created;
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(
|
||||
tx: Tx,
|
||||
id: string,
|
||||
input: UpdateRepairJobRequest,
|
||||
actor: Actor | null,
|
||||
) {
|
||||
const current = await tx.repairJob.findUnique({
|
||||
where: { id },
|
||||
include: { problemParts: { select: { partId: true } }, host: true },
|
||||
});
|
||||
if (!current) throw errors.notFound('Repair');
|
||||
|
||||
const data: Prisma.RepairJobUpdateInput = {};
|
||||
let terminalTransition: 'COMPLETED' | 'CANCELLED' | null = null;
|
||||
if (input.status !== undefined && input.status !== current.status) {
|
||||
data.status = input.status;
|
||||
const nowTerminal = input.status === 'COMPLETED' || input.status === 'CANCELLED';
|
||||
const wasTerminal = current.status === 'COMPLETED' || current.status === 'CANCELLED';
|
||||
if (nowTerminal && !wasTerminal) {
|
||||
data.closedAt = new Date();
|
||||
terminalTransition = input.status as 'COMPLETED' | 'CANCELLED';
|
||||
}
|
||||
if (!nowTerminal && wasTerminal) data.closedAt = null;
|
||||
}
|
||||
if (input.problem !== undefined) data.problem = input.problem;
|
||||
if (input.assigneeId !== undefined) {
|
||||
data.assignee = input.assigneeId
|
||||
? { connect: { id: input.assigneeId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (input.notes !== undefined) data.notes = input.notes;
|
||||
|
||||
// Problem-parts follow full-replace semantics: the request carries the final desired set.
|
||||
let addedPartIds: string[] = [];
|
||||
if (input.problemPartIds !== undefined) {
|
||||
await validateProblemParts(tx, current.hostId, input.problemPartIds);
|
||||
const existing = new Set(current.problemParts.map((p) => p.partId));
|
||||
const desired = new Set(input.problemPartIds);
|
||||
addedPartIds = [...desired].filter((p) => !existing.has(p));
|
||||
const removed = [...existing].filter((p) => !desired.has(p));
|
||||
if (removed.length > 0) {
|
||||
await tx.repairJobPart.deleteMany({
|
||||
where: { repairJobId: id, partId: { in: removed } },
|
||||
});
|
||||
}
|
||||
if (addedPartIds.length > 0) {
|
||||
await tx.repairJobPart.createMany({
|
||||
data: addedPartIds.map((partId) => ({ repairJobId: id, partId })),
|
||||
});
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
const repair = await tx.repairJob.update({
|
||||
where: { id },
|
||||
data,
|
||||
// 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,
|
||||
});
|
||||
|
||||
const userId = actor?.id ?? null;
|
||||
if (addedPartIds.length > 0) {
|
||||
await tx.partEvent.createMany({
|
||||
data: addedPartIds.map((partId) => ({
|
||||
partId,
|
||||
userId,
|
||||
type: 'REPAIR_STARTED',
|
||||
// 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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (terminalTransition !== null) {
|
||||
const partIds = repair.problemParts.map((p) => p.partId);
|
||||
if (partIds.length > 0) {
|
||||
await tx.partEvent.createMany({
|
||||
data: partIds.map((partId) => ({
|
||||
partId,
|
||||
userId,
|
||||
type: terminalTransition === 'COMPLETED' ? 'REPAIR_COMPLETED' : 'REPAIR_CANCELLED',
|
||||
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;
|
||||
}
|
||||
|
||||
export async function remove(tx: Tx, id: string) {
|
||||
try {
|
||||
await tx.repairJob.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||
throw errors.notFound('Repair');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listComments(tx: Tx, repairJobId: string, q: RepairCommentListQuery) {
|
||||
const { page, pageSize } = q;
|
||||
const repair = await tx.repairJob.findUnique({ where: { id: repairJobId }, select: { id: true } });
|
||||
if (!repair) throw errors.notFound('Repair');
|
||||
const [data, total] = await Promise.all([
|
||||
tx.repairComment.findMany({
|
||||
where: { repairJobId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: commentInclude,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
tx.repairComment.count({ where: { repairJobId } }),
|
||||
]);
|
||||
return { data, page, pageSize, total };
|
||||
}
|
||||
|
||||
export async function addComment(
|
||||
tx: Tx,
|
||||
repairJobId: string,
|
||||
input: CreateRepairCommentRequest,
|
||||
actor: Actor | null,
|
||||
) {
|
||||
const repair = await tx.repairJob.findUnique({
|
||||
where: { id: repairJobId },
|
||||
include: { problemParts: { select: { partId: true } } },
|
||||
});
|
||||
if (!repair) throw errors.notFound('Repair');
|
||||
|
||||
const comment = await tx.repairComment.create({
|
||||
data: {
|
||||
repairJobId,
|
||||
userId: actor?.id ?? null,
|
||||
content: input.content,
|
||||
},
|
||||
include: commentInclude,
|
||||
});
|
||||
|
||||
// Surface the comment on each problem-part's timeline so a part owner sees the activity
|
||||
// without having to navigate through to the repair.
|
||||
if (repair.problemParts.length > 0) {
|
||||
await tx.partEvent.createMany({
|
||||
data: repair.problemParts.map((p) => ({
|
||||
partId: p.partId,
|
||||
userId: actor?.id ?? null,
|
||||
type: 'REPAIR_COMMENTED',
|
||||
newValue: repair.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user