feat: rework EOL, repairs, and hosts for real workflow
CI / Lint · Typecheck · Test · Build (push) Successful in 48s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m1s

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>
This commit is contained in:
2026-04-17 10:17:29 -04:00
parent 23bd0f0c6a
commit 0f952d6c1b
50 changed files with 2665 additions and 602 deletions
+74 -34
View File
@@ -6,13 +6,16 @@ import type {
UpdatePartRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import * as partModelsSvc from './part-models.js';
import * as tagsSvc from './tags.js';
import type { Actor, Tx } from './types.js';
const partInclude = {
manufacturer: true,
partModel: true,
bin: { include: { room: { include: { site: true } } } },
category: true,
host: true,
tags: { include: { tag: true } },
} satisfies Prisma.PartInclude;
@@ -40,26 +43,51 @@ function flattenTags(part: PartWithRelations): PartWithPath {
return out;
}
// Resolves a CreatePartRequest's partModelId — either the explicit one passed in, or looked up
// via (manufacturerId, mpn) shorthand that auto-creates a PartModel catalog row on first use.
// Exactly one of those two forms is required; the zod schema enforces that at the boundary.
async function resolvePartModel(
tx: Tx,
input: { partModelId?: string; manufacturerId?: string; mpn?: string },
): Promise<{ partModelId: string; manufacturerId: string }> {
if (input.partModelId) {
const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } });
if (!pm) throw errors.badRequest('Part model does not exist');
return { partModelId: pm.id, manufacturerId: pm.manufacturerId };
}
if (input.manufacturerId && input.mpn) {
const pm = await partModelsSvc.upsertByMpn(tx, {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
});
return { partModelId: pm.id, manufacturerId: pm.manufacturerId };
}
throw errors.badRequest('Provide partModelId or both manufacturerId and mpn');
}
function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
const where: 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.manufacturerId) where.manufacturerId = q.manufacturerId;
if (q.partModelId) where.partModelId = q.partModelId;
if (q.categoryId) where.categoryId = q.categoryId;
if (q.mpn) where.mpn = { contains: q.mpn };
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
if (q.q) {
where.OR = [
{ serialNumber: { contains: q.q } },
{ mpn: { contains: q.q } },
{ partModel: { mpn: { contains: q.q } } },
{ notes: { contains: q.q } },
];
}
if (q.tagId) where.tags = { some: { tagId: q.tagId } };
if (q.eolOnly) {
// Parts attached to a manufacturer with an EOL date that has already passed.
where.manufacturer = { eolDate: { lt: new Date() } };
}
const partModelFilter: Prisma.PartModelWhereInput = {};
if (q.mpn) partModelFilter.mpn = { contains: q.mpn };
if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() };
if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter;
return where;
}
@@ -89,17 +117,23 @@ export async function create(
input: CreatePartRequest,
actor: Actor | null,
): Promise<PartWithPath> {
const { partModelId, manufacturerId } = await resolvePartModel(tx, input);
// If caller also supplied manufacturerId explicitly, it must match the part model's.
if (input.manufacturerId && input.manufacturerId !== manufacturerId) {
throw errors.badRequest('manufacturerId does not match the selected part model');
}
try {
const p = await tx.part.create({
data: {
serialNumber: input.serialNumber,
mpn: input.mpn,
manufacturerId: input.manufacturerId,
partModelId,
manufacturerId,
price: input.price ?? null,
state: input.state ?? 'SPARE',
binId: input.binId ?? null,
hostId: input.hostId ?? null,
categoryId: input.categoryId ?? null,
replacementPartId: input.replacementPartId ?? null,
notes: input.notes ?? null,
},
include: partInclude,
@@ -136,25 +170,26 @@ export async function update(
const data: Prisma.PartUpdateInput = {};
if (input.serialNumber !== undefined) data.serialNumber = input.serialNumber;
if (input.mpn !== undefined) data.mpn = input.mpn;
if (input.manufacturerId !== undefined) {
data.manufacturer = { connect: { id: input.manufacturerId } };
if (input.partModelId !== undefined) {
const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } });
if (!pm) throw errors.badRequest('Part model does not exist');
data.partModel = { connect: { id: pm.id } };
// Keep denormalized manufacturerId consistent with the chosen model.
data.manufacturer = { connect: { id: pm.manufacturerId } };
}
if (input.price !== undefined) data.price = input.price;
if (input.state !== undefined) data.state = input.state;
if (input.binId !== undefined) {
data.bin = input.binId ? { connect: { id: input.binId } } : { disconnect: true };
}
if (input.hostId !== undefined) {
data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true };
}
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { disconnect: true };
}
if (input.replacementPartId !== undefined) {
data.replacement = input.replacementPartId
? { connect: { id: input.replacementPartId } }
: { disconnect: true };
}
if (input.notes !== undefined) data.notes = input.notes;
let part: PartWithRelations;
@@ -191,14 +226,24 @@ export async function update(
newValue: binPath(part.bin),
});
}
if (input.mpn !== undefined && input.mpn !== current.mpn) {
if (input.hostId !== undefined && input.hostId !== current.hostId) {
events.push({
partId: part.id,
userId,
type: 'LOCATION_CHANGED',
field: 'host',
oldValue: current.host?.name ?? null,
newValue: part.host?.name ?? null,
});
}
if (input.partModelId !== undefined && input.partModelId !== current.partModelId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'mpn',
oldValue: current.mpn,
newValue: input.mpn,
field: 'partModel',
oldValue: current.partModel.mpn,
newValue: part.partModel.mpn,
});
}
if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) {
@@ -211,16 +256,6 @@ export async function update(
newValue: input.serialNumber,
});
}
if (input.manufacturerId !== undefined && input.manufacturerId !== current.manufacturerId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'manufacturer',
oldValue: current.manufacturer.name,
newValue: part.manufacturer.name,
});
}
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
events.push({
partId: part.id,
@@ -267,8 +302,11 @@ export async function remove(tx: Tx, id: string) {
try {
await tx.part.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Part');
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 err;
}
@@ -295,6 +333,7 @@ export interface BulkPartsInput {
ids: string[];
state?: CreatePartRequest['state'];
binId?: string | null;
hostId?: string | null;
addTagIds?: string[];
removeTagIds?: string[];
}
@@ -312,12 +351,13 @@ export async function bulkUpdate(tx: Tx, input: BulkPartsInput, actor: Actor | n
const patch: UpdatePartRequest = {};
if (input.state !== undefined) patch.state = input.state;
if (input.binId !== undefined) patch.binId = input.binId;
if (input.hostId !== undefined) patch.hostId = input.hostId;
if (Object.keys(patch).length > 0) {
await update(tx, id, patch, actor);
}
if (input.addTagIds || input.removeTagIds) {
const existing = await tx.partTag.findMany({ where: { partId: id }, select: { tagId: true } });
let next = new Set(existing.map((r) => r.tagId));
const next = new Set(existing.map((r) => r.tagId));
(input.addTagIds ?? []).forEach((t) => next.add(t));
(input.removeTagIds ?? []).forEach((t) => next.delete(t));
await tagsSvc.setPartTags(tx, id, [...next], actor);