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; export type PartWithPath = Omit & { 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 { 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 { 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 { 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 }; }