0f952d6c1b
Four domain-model changes driven by exercising the deployed 2.0 build: - EOL moves from manufacturer to MPN via new PartModel catalog table, so alerts fire on the thing that actually ages. - Repairs re-home to Host (required hostId + problem text) with an optional RepairJobPart join for affected parts; drop Part.replacementPartId. - New /repairs/:id detail page with editable problem, part list, and a RepairComment thread (REPAIR_COMMENTED events fan out to each problem part's timeline). - Host.assetId (required, unique) surfaces prominently on the repair page so techs can confirm they're touching the right box. Single destructive migration reshapes existing dev data. All 7 packages typecheck clean; 30 API tests pass (9 new covering host membership, upsertByMpn idempotency + race, assetId 409, comment userId stamping). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
276 lines
8.5 KiB
TypeScript
276 lines
8.5 KiB
TypeScript
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;
|
|
}
|