feat: rework EOL, repairs, and hosts for real workflow
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user