import { Prisma } from '@vector/db'; import type { CreateRepairCommentRequest, CreateRepairJobRequest, RepairCommentListQuery, RepairJobListQuery, UpdateRepairJobRequest, } from '@vector/shared'; import { errors } from '../lib/http-error.js'; import type { Actor, Tx } from './types.js'; 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; const commentInclude = { user: { select: { id: true, username: true } }, } satisfies Prisma.RepairCommentInclude; 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 } }; const [data, total] = await Promise.all([ tx.repairJob.findMany({ where, orderBy: [{ status: 'asc' }, { openedAt: 'desc' }], include: repairInclude, skip: (page - 1) * pageSize, take: pageSize, }), tx.repairJob.count({ where }), ]); return { data, page, pageSize, total }; } export function get(tx: Tx, id: string) { return tx.repairJob.findUnique({ where: { id }, include: repairInclude }); } export function listForHost(tx: Tx, hostId: string) { return tx.repairJob.findMany({ where: { hostId }, orderBy: { openedAt: 'desc' }, include: repairInclude, }); } // 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( tx: Tx, input: CreateRepairJobRequest, actor: Actor | null, ) { const host = await tx.host.findUnique({ where: { id: input.hostId } }); if (!host) throw errors.notFound('Host'); await validateProblemParts(tx, input.hostId, input.problemPartIds); try { const repair = await tx.repairJob.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, }, include: repairInclude, }); 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; } } 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 })), }); } } const repair = await tx.repairJob.update({ where: { id }, data, include: repairInclude, }); const userId = actor?.id ?? null; if (addedPartIds.length > 0) { await tx.partEvent.createMany({ data: addedPartIds.map((partId) => ({ partId, userId, type: 'REPAIR_STARTED', 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, })), }); } } 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; }