3d77f2846d
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>
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import { Prisma } from '@vector/db';
|
|
import type {
|
|
CreatePartRequest,
|
|
PaginationQuery,
|
|
PartListQuery,
|
|
PartState as PartStateValue,
|
|
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';
|
|
|
|
// Enforces the Part state/location invariant and auto-clears stale fields on state transitions.
|
|
// The matrix is:
|
|
// DEPLOYED — hostId required, binId forbidden, custodianId forbidden
|
|
// SPARE / BROKEN / PENDING_DESTRUCTION — binId optional, hostId + custodian forbidden
|
|
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
|
|
// — custodianId required, host + bin forbidden
|
|
// Callers only need to pass what's changing; anything omitted is inherited from `current`.
|
|
function resolveLocation(
|
|
state: PartStateValue,
|
|
input: {
|
|
binId?: string | null;
|
|
hostId?: string | null;
|
|
custodianId?: string | null;
|
|
},
|
|
current: {
|
|
binId: string | null;
|
|
hostId: string | null;
|
|
custodianId: string | null;
|
|
} = { binId: null, hostId: null, custodianId: null },
|
|
): { binId: string | null; hostId: string | null; custodianId: string | null } {
|
|
if (state === 'DEPLOYED') {
|
|
const hostId = input.hostId !== undefined ? input.hostId : current.hostId;
|
|
if (!hostId) throw errors.badRequest('A deployed part must be assigned to a host');
|
|
if (input.binId) {
|
|
throw errors.badRequest('A deployed part cannot also be in a storage bin');
|
|
}
|
|
if (input.custodianId) {
|
|
throw errors.badRequest('A deployed part cannot be in custody');
|
|
}
|
|
return { binId: null, hostId, custodianId: null };
|
|
}
|
|
|
|
if (state === 'PENDING_DROP_IN_CUSTODY' || state === 'PENDING_DESTRUCTION_IN_CUSTODY') {
|
|
const custodianId =
|
|
input.custodianId !== undefined ? input.custodianId : current.custodianId;
|
|
if (!custodianId) throw errors.badRequest('A part in custody must name a custodian');
|
|
if (input.hostId) throw errors.badRequest('A part in custody cannot be on a host');
|
|
if (input.binId) throw errors.badRequest('A part in custody cannot be in a bin');
|
|
return { binId: null, hostId: null, custodianId };
|
|
}
|
|
|
|
// SPARE / BROKEN / PENDING_DESTRUCTION
|
|
if (input.hostId) {
|
|
throw errors.badRequest('Only deployed parts can be assigned to a host');
|
|
}
|
|
if (input.custodianId) {
|
|
throw errors.badRequest('Only custody states can have a custodian');
|
|
}
|
|
const binId = input.binId !== undefined ? input.binId : current.binId;
|
|
return { binId, hostId: null, custodianId: null };
|
|
}
|
|
|
|
const partInclude = {
|
|
manufacturer: true,
|
|
partModel: true,
|
|
bin: { include: { room: { include: { site: true } } } },
|
|
category: true,
|
|
host: true,
|
|
custodian: { select: { id: true, username: true } },
|
|
tags: { include: { tag: true } },
|
|
} satisfies Prisma.PartInclude;
|
|
|
|
type PartWithRelations = Prisma.PartGetPayload<{ include: typeof partInclude }>;
|
|
type BinWithSite = NonNullable<PartWithRelations['bin']>;
|
|
export type PartWithPath = Omit<PartWithRelations, 'tags'> & {
|
|
bin: (BinWithSite & { fullPath?: string }) | null;
|
|
tags: { id: string; name: string; color: string | null }[];
|
|
};
|
|
|
|
function binPath(bin: BinWithSite | null | undefined): string | null {
|
|
if (!bin) return null;
|
|
return `${bin.room.site.name}.${bin.room.name}.${bin.name}`;
|
|
}
|
|
|
|
function flattenTags(part: PartWithRelations): PartWithPath {
|
|
const { tags, ...rest } = part;
|
|
const out = rest as PartWithPath;
|
|
if (out.bin) out.bin.fullPath = binPath(out.bin) ?? undefined;
|
|
out.tags = tags.map((t) => ({
|
|
id: t.tag.id,
|
|
name: t.tag.name,
|
|
color: t.tag.color,
|
|
}));
|
|
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.custodianId) where.custodianId = q.custodianId;
|
|
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
|
|
if (q.partModelId) where.partModelId = q.partModelId;
|
|
if (q.categoryId) where.categoryId = q.categoryId;
|
|
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
|
|
if (q.q) {
|
|
where.OR = [
|
|
{ serialNumber: { contains: q.q } },
|
|
{ partModel: { mpn: { contains: q.q } } },
|
|
{ notes: { contains: q.q } },
|
|
];
|
|
}
|
|
if (q.tagId) where.tags = { some: { tagId: q.tagId } };
|
|
|
|
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;
|
|
}
|
|
|
|
export async function list(tx: Tx, q: PartListQuery) {
|
|
const { page, pageSize } = q;
|
|
const where = buildWhere(q);
|
|
const [rows, total] = await Promise.all([
|
|
tx.part.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: partInclude,
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
tx.part.count({ where }),
|
|
]);
|
|
return { data: rows.map(flattenTags), page, pageSize, total };
|
|
}
|
|
|
|
export async function get(tx: Tx, id: string): Promise<PartWithPath | null> {
|
|
const p = await tx.part.findUnique({ where: { id }, include: partInclude });
|
|
return p ? flattenTags(p) : null;
|
|
}
|
|
|
|
export async function create(
|
|
tx: Tx,
|
|
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');
|
|
}
|
|
|
|
const state = input.state ?? 'SPARE';
|
|
const location = resolveLocation(state, {
|
|
binId: input.binId,
|
|
hostId: input.hostId,
|
|
custodianId: input.custodianId,
|
|
});
|
|
|
|
try {
|
|
const p = await tx.part.create({
|
|
data: {
|
|
serialNumber: input.serialNumber,
|
|
partModelId,
|
|
manufacturerId,
|
|
price: input.price ?? null,
|
|
state,
|
|
binId: location.binId,
|
|
hostId: location.hostId,
|
|
custodianId: location.custodianId,
|
|
categoryId: input.categoryId ?? null,
|
|
notes: input.notes ?? null,
|
|
},
|
|
include: partInclude,
|
|
});
|
|
await tx.partEvent.create({
|
|
data: {
|
|
partId: p.id,
|
|
userId: actor?.id ?? null,
|
|
type: 'CREATED',
|
|
newValue: p.serialNumber,
|
|
},
|
|
});
|
|
if (input.tagIds && input.tagIds.length > 0) {
|
|
await tagsSvc.setPartTags(tx, p.id, input.tagIds, actor);
|
|
}
|
|
const refreshed = await tx.part.findUnique({ where: { id: p.id }, include: partInclude });
|
|
return flattenTags(refreshed!);
|
|
} catch (err) {
|
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
|
throw errors.conflict('Serial number already exists');
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function update(
|
|
tx: Tx,
|
|
id: string,
|
|
input: UpdatePartRequest,
|
|
actor: Actor | null,
|
|
): Promise<PartWithPath> {
|
|
const current = await tx.part.findUnique({ where: { id }, include: partInclude });
|
|
if (!current) throw errors.notFound('Part');
|
|
|
|
const data: Prisma.PartUpdateInput = {};
|
|
if (input.serialNumber !== undefined) data.serialNumber = input.serialNumber;
|
|
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;
|
|
|
|
let nextBinId: string | null = current.binId;
|
|
let nextHostId: string | null = current.hostId;
|
|
let nextCustodianId: string | null = current.custodianId;
|
|
const locationTouched =
|
|
input.state !== undefined ||
|
|
input.binId !== undefined ||
|
|
input.hostId !== undefined ||
|
|
input.custodianId !== undefined;
|
|
if (locationTouched) {
|
|
const nextState = input.state ?? (current.state as PartStateValue);
|
|
const resolved = resolveLocation(
|
|
nextState,
|
|
{
|
|
binId: input.binId,
|
|
hostId: input.hostId,
|
|
custodianId: input.custodianId,
|
|
},
|
|
{
|
|
binId: current.binId,
|
|
hostId: current.hostId,
|
|
custodianId: current.custodianId,
|
|
},
|
|
);
|
|
nextBinId = resolved.binId;
|
|
nextHostId = resolved.hostId;
|
|
nextCustodianId = resolved.custodianId;
|
|
data.bin = resolved.binId ? { connect: { id: resolved.binId } } : { disconnect: true };
|
|
data.host = resolved.hostId ? { connect: { id: resolved.hostId } } : { disconnect: true };
|
|
data.custodian = resolved.custodianId
|
|
? { connect: { id: resolved.custodianId } }
|
|
: { disconnect: true };
|
|
}
|
|
|
|
if (input.categoryId !== undefined) {
|
|
data.category = input.categoryId
|
|
? { connect: { id: input.categoryId } }
|
|
: { disconnect: true };
|
|
}
|
|
if (input.notes !== undefined) data.notes = input.notes;
|
|
|
|
let part: PartWithRelations;
|
|
try {
|
|
part = await tx.part.update({ where: { id }, data, include: partInclude });
|
|
} catch (err) {
|
|
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
|
if (err.code === 'P2025') throw errors.notFound('Part');
|
|
if (err.code === 'P2002') throw errors.conflict('Serial number already exists');
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const userId = actor?.id ?? null;
|
|
const events: Prisma.PartEventCreateManyInput[] = [];
|
|
|
|
if (input.state !== undefined && input.state !== current.state) {
|
|
events.push({
|
|
partId: part.id,
|
|
userId,
|
|
type: 'STATE_CHANGED',
|
|
field: 'state',
|
|
oldValue: current.state,
|
|
newValue: input.state,
|
|
});
|
|
}
|
|
if (nextBinId !== current.binId) {
|
|
events.push({
|
|
partId: part.id,
|
|
userId,
|
|
type: 'LOCATION_CHANGED',
|
|
field: 'bin',
|
|
oldValue: binPath(current.bin),
|
|
newValue: binPath(part.bin),
|
|
});
|
|
}
|
|
if (nextHostId !== current.hostId) {
|
|
events.push({
|
|
partId: part.id,
|
|
userId,
|
|
type: 'LOCATION_CHANGED',
|
|
field: 'host',
|
|
oldValue: current.host?.name ?? null,
|
|
newValue: part.host?.name ?? null,
|
|
});
|
|
}
|
|
if (nextCustodianId !== current.custodianId) {
|
|
events.push({
|
|
partId: part.id,
|
|
userId,
|
|
type: 'LOCATION_CHANGED',
|
|
field: 'custodian',
|
|
oldValue: current.custodian?.username ?? null,
|
|
newValue: part.custodian?.username ?? null,
|
|
});
|
|
}
|
|
if (input.partModelId !== undefined && input.partModelId !== current.partModelId) {
|
|
events.push({
|
|
partId: part.id,
|
|
userId,
|
|
type: 'FIELD_UPDATED',
|
|
field: 'partModel',
|
|
oldValue: current.partModel.mpn,
|
|
newValue: part.partModel.mpn,
|
|
});
|
|
}
|
|
if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) {
|
|
events.push({
|
|
partId: part.id,
|
|
userId,
|
|
type: 'FIELD_UPDATED',
|
|
field: 'serialNumber',
|
|
oldValue: current.serialNumber,
|
|
newValue: input.serialNumber,
|
|
});
|
|
}
|
|
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
|
|
events.push({
|
|
partId: part.id,
|
|
userId,
|
|
type: 'FIELD_UPDATED',
|
|
field: 'category',
|
|
oldValue: current.category?.name ?? null,
|
|
newValue: part.category?.name ?? null,
|
|
});
|
|
}
|
|
if (input.price !== undefined && input.price !== current.price) {
|
|
events.push({
|
|
partId: part.id,
|
|
userId,
|
|
type: 'FIELD_UPDATED',
|
|
field: 'price',
|
|
oldValue: current.price?.toString() ?? null,
|
|
newValue: input.price?.toString() ?? null,
|
|
});
|
|
}
|
|
if (input.notes !== undefined && (input.notes ?? null) !== (current.notes ?? null)) {
|
|
events.push({
|
|
partId: part.id,
|
|
userId,
|
|
type: 'FIELD_UPDATED',
|
|
field: 'notes',
|
|
oldValue: current.notes ?? null,
|
|
newValue: input.notes ?? null,
|
|
});
|
|
}
|
|
|
|
if (events.length > 0) await tx.partEvent.createMany({ data: events });
|
|
|
|
if (input.tagIds !== undefined) {
|
|
await tagsSvc.setPartTags(tx, part.id, input.tagIds, actor);
|
|
const refreshed = await tx.part.findUnique({ where: { id: part.id }, include: partInclude });
|
|
return flattenTags(refreshed!);
|
|
}
|
|
|
|
return flattenTags(part);
|
|
}
|
|
|
|
export async function remove(tx: Tx, id: string) {
|
|
try {
|
|
await tx.part.delete({ where: { id } });
|
|
} catch (err) {
|
|
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 an FM or repair');
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function listEvents(tx: Tx, partId: string, q: PaginationQuery) {
|
|
const { page, pageSize } = q;
|
|
const [data, total] = await Promise.all([
|
|
tx.partEvent.findMany({
|
|
where: { partId },
|
|
orderBy: { createdAt: 'desc' },
|
|
include: { user: { select: { username: true } } },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
tx.partEvent.count({ where: { partId } }),
|
|
]);
|
|
return { data, page, pageSize, total };
|
|
}
|
|
|
|
// Bulk mutation. Batches all writes inside a single transaction so partial failures roll back.
|
|
// Intentionally capped so callers can't accidentally lock the whole parts table.
|
|
export interface BulkPartsInput {
|
|
ids: string[];
|
|
state?: CreatePartRequest['state'];
|
|
binId?: string | null;
|
|
hostId?: string | null;
|
|
addTagIds?: string[];
|
|
removeTagIds?: string[];
|
|
}
|
|
|
|
const BULK_LIMIT = 500;
|
|
|
|
export async function bulkUpdate(tx: Tx, input: BulkPartsInput, actor: Actor | null) {
|
|
if (input.ids.length === 0) throw errors.badRequest('No part ids supplied');
|
|
if (input.ids.length > BULK_LIMIT) {
|
|
throw errors.badRequest(`Bulk operations are limited to ${BULK_LIMIT} parts per call`);
|
|
}
|
|
|
|
const touched: string[] = [];
|
|
for (const id of input.ids) {
|
|
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 } });
|
|
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);
|
|
}
|
|
touched.push(id);
|
|
}
|
|
return { updated: touched.length };
|
|
}
|