diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index de0adeb..7b6847c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -22,7 +22,9 @@ import partRoutes from './routes/parts.js'; import tagRoutes from './routes/tags.js'; import categoryRoutes from './routes/categories.js'; import hostRoutes from './routes/hosts.js'; +import fmRoutes from './routes/fms.js'; import repairRoutes from './routes/repairs.js'; +import custodyRoutes from './routes/custody.js'; import savedViewRoutes from './routes/saved-views.js'; import analyticsRoutes from './routes/analytics.js'; import webhookRoutes from './routes/webhooks.js'; @@ -88,7 +90,9 @@ app.use('/api/parts', partRoutes); app.use('/api/tags', tagRoutes); app.use('/api/categories', categoryRoutes); app.use('/api/hosts', hostRoutes); +app.use('/api/fms', fmRoutes); app.use('/api/repairs', repairRoutes); +app.use('/api/custody', custodyRoutes); app.use('/api/saved-views', savedViewRoutes); app.use('/api/analytics', analyticsRoutes); app.use('/api/admin/webhooks', webhookRoutes); diff --git a/apps/api/src/controllers/custody.ts b/apps/api/src/controllers/custody.ts new file mode 100644 index 0000000..b13ecd9 --- /dev/null +++ b/apps/api/src/controllers/custody.ts @@ -0,0 +1,33 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { CustodyListQuery, DropOffRequest } from '@vector/shared'; +import * as svc from '../services/custody.js'; +import { errors } from '../lib/http-error.js'; + +export async function listMine(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) throw errors.unauthorized(); + const q = req.validated!.query as CustodyListQuery; + const result = await prisma.$transaction((tx) => svc.listMine(tx, req.user!.id, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function dropOff( + req: Request<{ partId: string }>, + res: Response, + next: NextFunction, +) { + try { + if (!req.user) throw errors.unauthorized(); + const input = req.validated!.body as DropOffRequest; + const part = await prisma.$transaction((tx) => + svc.dropOff(tx, req.params.partId, input, req.user!), + ); + res.json(part); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/controllers/fms.ts b/apps/api/src/controllers/fms.ts new file mode 100644 index 0000000..726c98a --- /dev/null +++ b/apps/api/src/controllers/fms.ts @@ -0,0 +1,56 @@ +import type { NextFunction, Request, Response } from 'express'; +import { prisma } from '@vector/db'; +import type { CreateFmRequest, FmListQuery, UpdateFmRequest } from '@vector/shared'; +import * as svc from '../services/fms.js'; +import { errors } from '../lib/http-error.js'; + +export async function list(req: Request, res: Response, next: NextFunction) { + try { + const q = req.validated!.query as FmListQuery; + const result = await prisma.$transaction((tx) => svc.list(tx, q)); + res.json(result); + } catch (err) { + next(err); + } +} + +export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const fm = await prisma.$transaction((tx) => svc.get(tx, req.params.id)); + if (!fm) throw errors.notFound('FM'); + res.json(fm); + } catch (err) { + next(err); + } +} + +export async function create(req: Request, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as CreateFmRequest; + const fm = await prisma.$transaction((tx) => svc.create(tx, input, req.user ?? null)); + res.status(201).json(fm); + } catch (err) { + next(err); + } +} + +export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const input = req.validated!.body as UpdateFmRequest; + const fm = await prisma.$transaction((tx) => + svc.update(tx, req.params.id, input, req.user ?? null), + ); + res.json(fm); + } catch (err) { + next(err); + } +} + +export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + await prisma.$transaction((tx) => svc.remove(tx, req.params.id)); + res.status(204).end(); + } catch (err) { + next(err); + } +} diff --git a/apps/api/src/controllers/repairs.ts b/apps/api/src/controllers/repairs.ts index 217c19a..5b189ca 100644 --- a/apps/api/src/controllers/repairs.ts +++ b/apps/api/src/controllers/repairs.ts @@ -1,18 +1,12 @@ import type { NextFunction, Request, Response } from 'express'; import { prisma } from '@vector/db'; -import type { - CreateRepairCommentRequest, - CreateRepairJobRequest, - RepairCommentListQuery, - RepairJobListQuery, - UpdateRepairJobRequest, -} from '@vector/shared'; +import type { LogRepairRequest, RepairListQuery } from '@vector/shared'; import * as svc from '../services/repairs.js'; import { errors } from '../lib/http-error.js'; export async function list(req: Request, res: Response, next: NextFunction) { try { - const q = req.validated!.query as RepairJobListQuery; + const q = req.validated!.query as RepairListQuery; const result = await prisma.$transaction((tx) => svc.list(tx, q)); res.json(result); } catch (err) { @@ -22,73 +16,21 @@ export async function list(req: Request, res: Response, next: NextFunction) { export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) { try { - const repair = await prisma.$transaction((tx) => svc.get(tx, req.params.id)); - if (!repair) throw errors.notFound('Repair'); - res.json(repair); + const r = await prisma.$transaction((tx) => svc.get(tx, req.params.id)); + if (!r) throw errors.notFound('Repair'); + res.json(r); } catch (err) { next(err); } } -export async function create(req: Request, res: Response, next: NextFunction) { +export async function log(req: Request, res: Response, next: NextFunction) { try { - const input = req.validated!.body as CreateRepairJobRequest; - const repair = await prisma.$transaction((tx) => - svc.create(tx, input, req.user ?? null), - ); + if (!req.user) throw errors.unauthorized(); + const input = req.validated!.body as LogRepairRequest; + const repair = await prisma.$transaction((tx) => svc.log(tx, input, req.user!)); res.status(201).json(repair); } catch (err) { next(err); } } - -export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { - try { - const input = req.validated!.body as UpdateRepairJobRequest; - const repair = await prisma.$transaction((tx) => - svc.update(tx, req.params.id, input, req.user ?? null), - ); - res.json(repair); - } catch (err) { - next(err); - } -} - -export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) { - try { - await prisma.$transaction((tx) => svc.remove(tx, req.params.id)); - res.status(204).end(); - } catch (err) { - next(err); - } -} - -export async function listComments( - req: Request<{ id: string }>, - res: Response, - next: NextFunction, -) { - try { - const q = req.validated!.query as RepairCommentListQuery; - const result = await prisma.$transaction((tx) => svc.listComments(tx, req.params.id, q)); - res.json(result); - } catch (err) { - next(err); - } -} - -export async function addComment( - req: Request<{ id: string }>, - res: Response, - next: NextFunction, -) { - try { - const input = req.validated!.body as CreateRepairCommentRequest; - const comment = await prisma.$transaction((tx) => - svc.addComment(tx, req.params.id, input, req.user ?? null), - ); - res.status(201).json(comment); - } catch (err) { - next(err); - } -} diff --git a/apps/api/src/routes/custody.ts b/apps/api/src/routes/custody.ts new file mode 100644 index 0000000..6ff68b4 --- /dev/null +++ b/apps/api/src/routes/custody.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { CustodyListQuery, DropOffRequest } from '@vector/shared'; +import * as ctrl from '../controllers/custody.js'; +import { requireAuth } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.get('/mine', requireAuth, validate('query', CustodyListQuery), ctrl.listMine); +router.post( + '/:partId/drop-off', + requireAuth, + validate('body', DropOffRequest), + ctrl.dropOff, +); + +export default router; diff --git a/apps/api/src/routes/fms.ts b/apps/api/src/routes/fms.ts new file mode 100644 index 0000000..bb79cec --- /dev/null +++ b/apps/api/src/routes/fms.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { CreateFmRequest, FmListQuery, UpdateFmRequest } from '@vector/shared'; +import * as ctrl from '../controllers/fms.js'; +import { requireAuth } from '../middleware/auth.js'; +import { validate } from '../middleware/validate.js'; + +const router = Router(); + +router.get('/', requireAuth, validate('query', FmListQuery), ctrl.list); +router.post('/', requireAuth, validate('body', CreateFmRequest), ctrl.create); +router.get('/:id', requireAuth, ctrl.get); +router.patch('/:id', requireAuth, validate('body', UpdateFmRequest), ctrl.update); +router.delete('/:id', requireAuth, ctrl.remove); + +export default router; diff --git a/apps/api/src/routes/repairs.ts b/apps/api/src/routes/repairs.ts index b6a600e..f4f4e46 100644 --- a/apps/api/src/routes/repairs.ts +++ b/apps/api/src/routes/repairs.ts @@ -1,34 +1,13 @@ import { Router } from 'express'; -import { - CreateRepairCommentRequest, - CreateRepairJobRequest, - RepairCommentListQuery, - RepairJobListQuery, - UpdateRepairJobRequest, -} from '@vector/shared'; +import { LogRepairRequest, RepairListQuery } from '@vector/shared'; import * as ctrl from '../controllers/repairs.js'; import { requireAuth } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; const router = Router(); -router.get('/', requireAuth, validate('query', RepairJobListQuery), ctrl.list); -router.post('/', requireAuth, validate('body', CreateRepairJobRequest), ctrl.create); +router.get('/', requireAuth, validate('query', RepairListQuery), ctrl.list); +router.post('/', requireAuth, validate('body', LogRepairRequest), ctrl.log); router.get('/:id', requireAuth, ctrl.get); -router.patch('/:id', requireAuth, validate('body', UpdateRepairJobRequest), ctrl.update); -router.delete('/:id', requireAuth, ctrl.remove); - -router.get( - '/:id/comments', - requireAuth, - validate('query', RepairCommentListQuery), - ctrl.listComments, -); -router.post( - '/:id/comments', - requireAuth, - validate('body', CreateRepairCommentRequest), - ctrl.addComment, -); export default router; diff --git a/apps/api/src/services/analytics.test.ts b/apps/api/src/services/analytics.test.ts index 2942869..5fd6889 100644 --- a/apps/api/src/services/analytics.test.ts +++ b/apps/api/src/services/analytics.test.ts @@ -14,7 +14,7 @@ function makeTx(args: { createdAt: Date; partModelId: string; }[]; - openRepairs: number; + openFms: number; eolPartModels: { id: string; mpn: string; @@ -35,8 +35,8 @@ function makeTx(args: { })), findMany: async () => args.parts, }, - repairJob: { - count: async () => args.openRepairs, + fm: { + count: async () => args.openFms, }, partModel: { findMany: async () => args.eolPartModels, @@ -52,7 +52,7 @@ const now = new Date('2026-04-16T00:00:00.000Z'); const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000); describe('analytics.dashboard', () => { - it('aggregates totals, state counts and open repairs', async () => { + it('aggregates totals, state counts and open FMs', async () => { const tx = makeTx({ partCount: 5, stateRows: [ @@ -60,14 +60,14 @@ describe('analytics.dashboard', () => { { state: 'DEPLOYED', count: 2, totalPrice: 8000 }, ], parts: [], - openRepairs: 4, + openFms: 4, eolPartModels: [], bins: [], }); const r = await dashboard(tx); expect(r.totalParts).toBe(5); - expect(r.openRepairs).toBe(4); + expect(r.openFms).toBe(4); expect(r.byState).toEqual([ { state: 'SPARE', count: 3, totalPrice: 1500 }, { state: 'DEPLOYED', count: 2, totalPrice: 8000 }, @@ -84,7 +84,7 @@ describe('analytics.dashboard', () => { { id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' }, { id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' }, ], - openRepairs: 0, + openFms: 0, eolPartModels: [], bins: [], }); @@ -109,7 +109,7 @@ describe('analytics.dashboard', () => { { id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), partModelId: 'pm' }, { id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' }, ], - openRepairs: 0, + openFms: 0, eolPartModels: [], bins: [ { id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } }, @@ -133,7 +133,7 @@ describe('analytics.dashboard', () => { { id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' }, { id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' }, ], - openRepairs: 0, + openFms: 0, eolPartModels: [ { id: 'pm1', diff --git a/apps/api/src/services/analytics.ts b/apps/api/src/services/analytics.ts index d899e6e..41fd47e 100644 --- a/apps/api/src/services/analytics.ts +++ b/apps/api/src/services/analytics.ts @@ -13,7 +13,7 @@ const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [ ]; export async function dashboard(tx: Tx): Promise { - const [totalParts, stateRows, parts, openRepairs, partModelsWithEol] = await Promise.all([ + const [totalParts, stateRows, parts, openFms, partModelsWithEol] = await Promise.all([ tx.part.count(), tx.part.groupBy({ by: ['state'], @@ -23,7 +23,7 @@ export async function dashboard(tx: Tx): Promise { tx.part.findMany({ select: { id: true, state: true, binId: true, createdAt: true, partModelId: true }, }), - tx.repairJob.count({ where: { status: { in: ['PENDING', 'IN_PROGRESS'] } } }), + tx.fm.count({ where: { status: 'OPEN' } }), tx.partModel.findMany({ where: { eolDate: { not: null, lte: new Date() } }, select: { @@ -92,5 +92,5 @@ export async function dashboard(tx: Tx): Promise { .filter((m) => m.deployedCount > 0) .sort((a, b) => b.deployedCount - a.deployedCount); - return { totalParts, byState, ageBuckets: buckets, topBins, deployedPastEol, openRepairs }; + return { totalParts, byState, ageBuckets: buckets, topBins, deployedPastEol, openFms }; } diff --git a/apps/api/src/services/custody.test.ts b/apps/api/src/services/custody.test.ts new file mode 100644 index 0000000..f0b2c35 --- /dev/null +++ b/apps/api/src/services/custody.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Tx, Actor } from './types.js'; +import { dropOff } from './custody.js'; + +const custodian: Actor = { id: 'user-1', username: 'tech', role: 'TECHNICIAN' }; +const admin: Actor = { id: 'user-admin', username: 'boss', role: 'ADMIN' }; +const otherUser: Actor = { id: 'user-2', username: 'other', role: 'TECHNICIAN' }; + +const brokenModel = { + id: 'pm-1', + manufacturerId: 'mfr-1', + mpn: 'WD-1', + destroyOnFail: false, + eolDate: null, +}; + +interface CustodyPartRow { + id: string; + serialNumber: string; + partModelId: string; + manufacturerId: string; + state: string; + binId: string | null; + hostId: string | null; + custodianId: string | null; + categoryId: string | null; + price: number | null; + notes: string | null; + partModel: typeof brokenModel; + manufacturer: { id: string; name: string }; + bin: unknown; + host: unknown; + category: unknown; + custodian: { id: string; username: string } | null; + tags: unknown[]; +} + +function custodyPart(overrides: Partial = {}): CustodyPartRow { + return { + id: 'p-1', + serialNumber: 'SN-1', + partModelId: 'pm-1', + manufacturerId: 'mfr-1', + state: 'PENDING_DROP_IN_CUSTODY', + binId: null, + hostId: null, + custodianId: 'user-1', + categoryId: null, + price: null, + notes: null, + partModel: brokenModel, + manufacturer: { id: 'mfr-1', name: 'WD' }, + bin: null, + host: null, + category: null, + custodian: { id: 'user-1', username: 'tech' }, + tags: [], + ...overrides, + }; +} + +// Build a Tx stub sufficient for custody.dropOff, which calls partsSvc.update internally. +function buildTx(initial: ReturnType) { + const current = { ...initial }; + const partUpdate = vi.fn( + async (args: { where: { id: string }; data: Record }) => { + const d = args.data; + if (d.state) current.state = d.state as string; + if (d.bin !== undefined) { + const v = d.bin as { connect?: { id: string }; disconnect?: boolean }; + current.binId = v.connect?.id ?? null; + } + if (d.host !== undefined) { + const v = d.host as { connect?: { id: string }; disconnect?: boolean }; + current.hostId = v.connect?.id ?? null; + } + if (d.custodian !== undefined) { + const v = d.custodian as { connect?: { id: string }; disconnect?: boolean }; + current.custodianId = v.connect?.id ?? null; + current.custodian = v.connect?.id + ? { id: v.connect.id, username: 'tech' } + : null; + } + return current; + }, + ); + + const tx = { + part: { + findUnique: async (args: { where: { id: string } }) => + args.where.id === current.id ? current : null, + update: partUpdate, + }, + partEvent: { createMany: vi.fn() }, + partTag: { findMany: async () => [] }, + } as unknown as Tx; + + return { tx, current, partUpdate }; +} + +describe('custody.dropOff', () => { + it('PENDING_DROP_IN_CUSTODY → BROKEN with a bin; custodian cleared', async () => { + const { tx, current, partUpdate } = buildTx(custodyPart()); + + await dropOff(tx, 'p-1', { binId: 'bin-2' }, custodian); + + expect(partUpdate).toHaveBeenCalledTimes(1); + const call = partUpdate.mock.calls[0]![0] as { + data: { state?: string; bin?: unknown; custodian?: unknown }; + }; + expect(call.data.state).toBe('BROKEN'); + expect(call.data.bin).toEqual({ connect: { id: 'bin-2' } }); + expect(call.data.custodian).toEqual({ disconnect: true }); + expect(current.state).toBe('BROKEN'); + expect(current.custodianId).toBeNull(); + }); + + it('PENDING_DESTRUCTION_IN_CUSTODY → PENDING_DESTRUCTION', async () => { + const { tx, current } = buildTx( + custodyPart({ state: 'PENDING_DESTRUCTION_IN_CUSTODY' }), + ); + + await dropOff(tx, 'p-1', { binId: null }, custodian); + + expect(current.state).toBe('PENDING_DESTRUCTION'); + expect(current.binId).toBeNull(); + expect(current.custodianId).toBeNull(); + }); + + it('admin can drop off a part held by someone else', async () => { + const { tx, current } = buildTx(custodyPart({ custodianId: 'user-2' })); + + await dropOff(tx, 'p-1', { binId: 'bin-1' }, admin); + + expect(current.state).toBe('BROKEN'); + expect(current.custodianId).toBeNull(); + }); + + it('rejects a non-custodian non-admin attempt with 403', async () => { + const { tx, partUpdate } = buildTx(custodyPart()); + + await expect( + dropOff(tx, 'p-1', { binId: 'bin-1' }, otherUser), + ).rejects.toMatchObject({ status: 403 }); + expect(partUpdate).not.toHaveBeenCalled(); + }); + + it('rejects drop-off on a non-custody state with 400', async () => { + const { tx, partUpdate } = buildTx( + custodyPart({ state: 'SPARE', custodianId: null, custodian: null }), + ); + + await expect( + dropOff(tx, 'p-1', { binId: 'bin-1' }, custodian), + ).rejects.toMatchObject({ status: 400 }); + expect(partUpdate).not.toHaveBeenCalled(); + }); + + it('rejects drop-off on a missing part with 404', async () => { + const tx = { + part: { findUnique: async () => null, update: vi.fn() }, + partEvent: { createMany: vi.fn() }, + } as unknown as Tx; + + await expect( + dropOff(tx, 'p-missing', { binId: null }, custodian), + ).rejects.toMatchObject({ status: 404 }); + }); +}); diff --git a/apps/api/src/services/custody.ts b/apps/api/src/services/custody.ts new file mode 100644 index 0000000..e419e00 --- /dev/null +++ b/apps/api/src/services/custody.ts @@ -0,0 +1,58 @@ +import { Prisma } from '@vector/db'; +import type { CustodyListQuery, DropOffRequest } from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import * as partsSvc from './parts.js'; +import type { Actor, Tx } from './types.js'; + +const custodyInclude = { + partModel: true, + manufacturer: true, + host: true, + custodian: { select: { id: true, username: true } }, +} satisfies Prisma.PartInclude; + +export async function listMine(tx: Tx, userId: string, q: CustodyListQuery) { + const { page, pageSize } = q; + const where: Prisma.PartWhereInput = { custodianId: userId }; + const [data, total] = await Promise.all([ + tx.part.findMany({ + where, + orderBy: { updatedAt: 'desc' }, + include: custodyInclude, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.part.count({ where }), + ]); + return { data, page, pageSize, total }; +} + +export async function dropOff( + tx: Tx, + partId: string, + input: DropOffRequest, + actor: Actor, +) { + const part = await tx.part.findUnique({ where: { id: partId } }); + if (!part) throw errors.notFound('Part'); + + if ( + part.state !== 'PENDING_DROP_IN_CUSTODY' && + part.state !== 'PENDING_DESTRUCTION_IN_CUSTODY' + ) { + throw errors.badRequest('Part is not in custody'); + } + if (part.custodianId !== actor.id && actor.role !== 'ADMIN') { + throw errors.forbidden('Only the current custodian can drop off this part'); + } + + const nextState = + part.state === 'PENDING_DROP_IN_CUSTODY' ? 'BROKEN' : 'PENDING_DESTRUCTION'; + + return partsSvc.update( + tx, + partId, + { state: nextState, binId: input.binId ?? null, custodianId: null }, + actor, + ); +} diff --git a/apps/api/src/services/fms.test.ts b/apps/api/src/services/fms.test.ts new file mode 100644 index 0000000..1ffa0c9 --- /dev/null +++ b/apps/api/src/services/fms.test.ts @@ -0,0 +1,279 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const emitMock = vi.fn(); +vi.mock('../lib/webhook-emitter.js', () => ({ + emit: (...args: unknown[]) => emitMock(...args), +})); + +import type { Tx, Actor } from './types.js'; +import { create, update, resolveHost } from './fms.js'; + +const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' }; + +const host = { id: 'host-1', assetId: 'ASSET-001', name: 'rack-1' }; + +function fmRow(overrides: Partial> = {}) { + return { + id: 'fm-1', + hostId: 'host-1', + status: 'OPEN', + problem: 'fans failing', + openedAt: new Date('2026-04-01T00:00:00Z'), + closedAt: null, + host: { ...host }, + problemParts: [] as Array<{ + partId: string; + part: { id: string; serialNumber: string; partModel: { mpn: string } }; + }>, + ...overrides, + }; +} + +beforeEach(() => { + emitMock.mockClear(); +}); + +describe('fms.resolveHost', () => { + it('resolves by assetId when hostId is absent', async () => { + const findUnique = vi.fn(async (args: { where: { assetId?: string } }) => { + if (args.where.assetId === 'ASSET-001') return host; + return null; + }); + const tx = { host: { findUnique } } as unknown as Tx; + + const r = await resolveHost(tx, { assetId: 'ASSET-001' }); + expect(r.id).toBe('host-1'); + expect(findUnique).toHaveBeenCalledWith({ where: { assetId: 'ASSET-001' } }); + }); + + it('rejects when neither hostId nor assetId is provided', async () => { + const tx = { host: { findUnique: vi.fn() } } as unknown as Tx; + await expect(resolveHost(tx, {})).rejects.toMatchObject({ status: 400 }); + }); + + it('throws 404 when assetId does not match any host', async () => { + const tx = { + host: { findUnique: vi.fn(async () => null) }, + } as unknown as Tx; + await expect( + resolveHost(tx, { assetId: 'MISSING' }), + ).rejects.toMatchObject({ status: 404 }); + }); +}); + +describe('fms.create', () => { + it('resolves host from assetId and writes the canonical hostId', async () => { + const created = fmRow(); + const fmCreate = vi.fn(); + fmCreate.mockResolvedValue(created); + const tx = { + host: { + findUnique: async (args: { where: { assetId?: string; id?: string } }) => + args.where.assetId === 'ASSET-001' ? host : null, + }, + fm: { create: fmCreate }, + part: { findMany: async () => [] }, + partEvent: { createMany: vi.fn() }, + } as unknown as Tx; + + await create(tx, { assetId: 'ASSET-001', problem: 'fans failing' }, actor); + + const args = fmCreate.mock.calls[0]![0] as { data: { hostId: string } }; + expect(args.data.hostId).toBe('host-1'); + }); + + it('fires fm.opened webhook with the resolved host payload', async () => { + const created = fmRow(); + const tx = { + host: { findUnique: async () => host }, + fm: { create: async () => created }, + part: { findMany: async () => [] }, + partEvent: { createMany: vi.fn() }, + } as unknown as Tx; + + await create(tx, { hostId: 'host-1', problem: 'fans failing' }, actor); + + expect(emitMock).toHaveBeenCalledTimes(1); + const call = emitMock.mock.calls[0]![0] as { + event: string; + payload: { fm: { id: string; assetId: string; status: string } }; + }; + expect(call.event).toBe('fm.opened'); + expect(call.payload.fm.id).toBe('fm-1'); + expect(call.payload.fm.assetId).toBe('ASSET-001'); + expect(call.payload.fm.status).toBe('OPEN'); + }); + + it('rejects when both hostId and assetId are provided', async () => { + // The shared-zod CreateFmRequest refine enforces XOR at the boundary; the service + // itself sees hostId first and resolves it. But if a caller passes both at the service + // layer (bypassing zod), hostId wins — we guard the boundary case in the shared test + // suite. Here we assert the combined-input path still fails cleanly when hostId is + // unknown, so the service never silently picks assetId. + const tx = { + host: { findUnique: async () => null }, + fm: { create: vi.fn() }, + partEvent: { createMany: vi.fn() }, + } as unknown as Tx; + + await expect( + create( + tx, + { hostId: '00000000-0000-0000-0000-000000000000', assetId: 'ASSET-001', problem: 'x' }, + actor, + ), + ).rejects.toMatchObject({ status: 404 }); + }); + + it('creates FM_OPENED PartEvents for each problem part', async () => { + const created = fmRow({ + problemParts: [ + { + partId: 'p-1', + part: { id: 'p-1', serialNumber: 'SN-1', partModel: { mpn: 'WD-1' } }, + }, + ], + }); + const partEventCreateMany = vi.fn(); + const tx = { + host: { findUnique: async () => host }, + part: { + findMany: async () => [{ id: 'p-1', hostId: 'host-1' }], + }, + fm: { create: async () => created }, + partEvent: { createMany: partEventCreateMany }, + } as unknown as Tx; + + await create( + tx, + { hostId: 'host-1', problem: 'fans failing', problemPartIds: ['p-1'] }, + actor, + ); + + expect(partEventCreateMany).toHaveBeenCalledTimes(1); + const args = partEventCreateMany.mock.calls[0]![0] as { + data: Array<{ partId: string; type: string; newValue: string }>; + }; + expect(args.data).toEqual([ + { partId: 'p-1', userId: 'user-1', type: 'FM_OPENED', newValue: 'fm-1' }, + ]); + }); + + it('rejects a problem part that does not live on the selected host', async () => { + const tx = { + host: { findUnique: async () => host }, + part: { + findMany: async () => [{ id: 'p-1', hostId: 'other-host' }], + }, + fm: { create: vi.fn() }, + partEvent: { createMany: vi.fn() }, + } as unknown as Tx; + + await expect( + create( + tx, + { hostId: 'host-1', problem: 'x', problemPartIds: ['p-1'] }, + actor, + ), + ).rejects.toMatchObject({ status: 400 }); + }); +}); + +describe('fms.update — close flips status + sets closedAt + emits webhook', () => { + it('closes an OPEN FM and emits FM_CLOSED events per problem part', async () => { + const current = { + id: 'fm-1', + hostId: 'host-1', + status: 'OPEN', + problemParts: [{ partId: 'p-1' }, { partId: 'p-2' }], + host: { ...host }, + }; + const updated = fmRow({ + status: 'CLOSED', + closedAt: new Date('2026-04-10T00:00:00Z'), + problemParts: [ + { partId: 'p-1', part: { id: 'p-1', serialNumber: 'SN-1', partModel: { mpn: 'WD-1' } } }, + { partId: 'p-2', part: { id: 'p-2', serialNumber: 'SN-2', partModel: { mpn: 'WD-2' } } }, + ], + }); + const fmUpdate = vi.fn(); + fmUpdate.mockResolvedValue(updated); + const partEventCreateMany = vi.fn(); + const tx = { + fm: { + findUnique: async () => current, + update: fmUpdate, + }, + partEvent: { createMany: partEventCreateMany }, + } as unknown as Tx; + + await update(tx, 'fm-1', { status: 'CLOSED' }, actor); + + const updateArgs = fmUpdate.mock.calls[0]![0] as { + data: { status?: string; closedAt?: unknown }; + }; + expect(updateArgs.data.status).toBe('CLOSED'); + expect(updateArgs.data.closedAt).toBeInstanceOf(Date); + + const eventArgs = partEventCreateMany.mock.calls[0]![0] as { + data: Array<{ partId: string; type: string }>; + }; + expect(eventArgs.data).toEqual([ + { partId: 'p-1', userId: 'user-1', type: 'FM_CLOSED', newValue: 'fm-1' }, + { partId: 'p-2', userId: 'user-1', type: 'FM_CLOSED', newValue: 'fm-1' }, + ]); + + expect(emitMock).toHaveBeenCalledTimes(1); + expect(emitMock.mock.calls[0]![0]).toMatchObject({ + event: 'fm.closed', + payload: { fm: { id: 'fm-1', status: 'CLOSED' } }, + }); + }); + + it('reopening a closed FM clears closedAt and re-emits fm.opened', async () => { + const current = { + id: 'fm-1', + hostId: 'host-1', + status: 'CLOSED', + problemParts: [], + host: { ...host }, + }; + const updated = fmRow({ status: 'OPEN', closedAt: null }); + const fmUpdate = vi.fn(); + fmUpdate.mockResolvedValue(updated); + const tx = { + fm: { findUnique: async () => current, update: fmUpdate }, + partEvent: { createMany: vi.fn() }, + } as unknown as Tx; + + await update(tx, 'fm-1', { status: 'OPEN' }, actor); + + const args = fmUpdate.mock.calls[0]![0] as { + data: { status?: string; closedAt?: unknown }; + }; + expect(args.data.status).toBe('OPEN'); + expect(args.data.closedAt).toBeNull(); + expect(emitMock.mock.calls[0]![0]).toMatchObject({ event: 'fm.opened' }); + }); + + it('status-unchanged updates do not emit webhooks', async () => { + const current = { + id: 'fm-1', + hostId: 'host-1', + status: 'OPEN', + problemParts: [], + host: { ...host }, + }; + const tx = { + fm: { + findUnique: async () => current, + update: async () => fmRow({ problem: 'new text' }), + }, + partEvent: { createMany: vi.fn() }, + } as unknown as Tx; + + await update(tx, 'fm-1', { problem: 'new text' }, actor); + + expect(emitMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/services/fms.ts b/apps/api/src/services/fms.ts new file mode 100644 index 0000000..7c6c07b --- /dev/null +++ b/apps/api/src/services/fms.ts @@ -0,0 +1,231 @@ +import { Prisma } from '@vector/db'; +import type { CreateFmRequest, FmListQuery, UpdateFmRequest } from '@vector/shared'; +import { errors } from '../lib/http-error.js'; +import { emit } from '../lib/webhook-emitter.js'; +import type { Actor, Tx } from './types.js'; + +const fmInclude = { + host: true, + problemParts: { + include: { + part: { + include: { partModel: true, manufacturer: true }, + }, + }, + }, +} satisfies Prisma.FmInclude; + +export type FmWithRelations = Prisma.FmGetPayload<{ include: typeof fmInclude }>; + +// Accept either `hostId` (uuid) or `assetId` (string) — callers provide exactly one. +// Returns the resolved Host row so downstream writes can use the canonical id. +export async function resolveHost( + tx: Tx, + input: { hostId?: string | null; assetId?: string | null }, +) { + if (input.hostId) { + const host = await tx.host.findUnique({ where: { id: input.hostId } }); + if (!host) throw errors.notFound('Host'); + return host; + } + if (input.assetId) { + const host = await tx.host.findUnique({ where: { assetId: input.assetId } }); + if (!host) throw errors.notFound('Host'); + return host; + } + throw errors.badRequest('Provide exactly one of hostId or assetId'); +} + +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 list(tx: Tx, q: FmListQuery) { + const { page, pageSize, status, hostId, openOnly, problemPartId } = q; + const where: Prisma.FmWhereInput = {}; + if (status) where.status = status; + if (hostId) where.hostId = hostId; + if (openOnly) where.status = 'OPEN'; + if (problemPartId) where.problemParts = { some: { partId: problemPartId } }; + + const [data, total] = await Promise.all([ + tx.fm.findMany({ + where, + orderBy: [{ status: 'asc' }, { openedAt: 'desc' }], + include: fmInclude, + skip: (page - 1) * pageSize, + take: pageSize, + }), + tx.fm.count({ where }), + ]); + return { data, page, pageSize, total }; +} + +export function get(tx: Tx, id: string) { + return tx.fm.findUnique({ where: { id }, include: fmInclude }); +} + +export function listForHost(tx: Tx, hostId: string) { + return tx.fm.findMany({ + where: { hostId }, + orderBy: { openedAt: 'desc' }, + include: fmInclude, + }); +} + +function fmPayload(fm: FmWithRelations) { + return { + id: fm.id, + hostId: fm.hostId, + assetId: fm.host.assetId, + hostName: fm.host.name, + status: fm.status, + problem: fm.problem, + openedAt: fm.openedAt.toISOString(), + closedAt: fm.closedAt?.toISOString() ?? null, + problemParts: fm.problemParts.map((pp) => ({ + partId: pp.part.id, + serialNumber: pp.part.serialNumber, + mpn: pp.part.partModel.mpn, + })), + }; +} + +export async function create( + tx: Tx, + input: CreateFmRequest, + _actor: Actor | null, +) { + const host = await resolveHost(tx, input); + await validateProblemParts(tx, host.id, input.problemPartIds); + + const fm = await tx.fm.create({ + data: { + hostId: host.id, + problem: input.problem, + status: 'OPEN', + problemParts: + input.problemPartIds && input.problemPartIds.length > 0 + ? { create: [...new Set(input.problemPartIds)].map((partId) => ({ partId })) } + : undefined, + }, + include: fmInclude, + }); + + if (input.problemPartIds && input.problemPartIds.length > 0) { + await tx.partEvent.createMany({ + data: [...new Set(input.problemPartIds)].map((partId) => ({ + partId, + userId: _actor?.id ?? null, + type: 'FM_OPENED', + newValue: fm.id, + })), + }); + } + + // Fire-and-forget. Emitter owns retries + signing. + void emit({ event: 'fm.opened', payload: { fm: fmPayload(fm) } }); + return fm; +} + +export async function update( + tx: Tx, + id: string, + input: UpdateFmRequest, + actor: Actor | null, +) { + const current = await tx.fm.findUnique({ + where: { id }, + include: { problemParts: { select: { partId: true } }, host: true }, + }); + if (!current) throw errors.notFound('FM'); + + const data: Prisma.FmUpdateInput = {}; + let closing = false; + let reopening = false; + if (input.status !== undefined && input.status !== current.status) { + data.status = input.status; + if (input.status === 'CLOSED') { + data.closedAt = new Date(); + closing = true; + } else { + data.closedAt = null; + reopening = true; + } + } + if (input.problem !== undefined) data.problem = input.problem; + + 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.fmPart.deleteMany({ where: { fmId: id, partId: { in: removed } } }); + } + if (addedPartIds.length > 0) { + await tx.fmPart.createMany({ + data: addedPartIds.map((partId) => ({ fmId: id, partId })), + }); + } + } + + const fm = await tx.fm.update({ where: { id }, data, include: fmInclude }); + + const userId = actor?.id ?? null; + if (addedPartIds.length > 0) { + await tx.partEvent.createMany({ + data: addedPartIds.map((partId) => ({ + partId, + userId, + type: 'FM_OPENED', + newValue: fm.id, + })), + }); + } + if (closing) { + const partIds = fm.problemParts.map((p) => p.partId); + if (partIds.length > 0) { + await tx.partEvent.createMany({ + data: partIds.map((partId) => ({ + partId, + userId, + type: 'FM_CLOSED', + newValue: fm.id, + })), + }); + } + void emit({ event: 'fm.closed', payload: { fm: fmPayload(fm) } }); + } + if (reopening) { + void emit({ event: 'fm.opened', payload: { fm: fmPayload(fm) } }); + } + + return fm; +} + +export async function remove(tx: Tx, id: string) { + try { + await tx.fm.delete({ where: { id } }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { + throw errors.notFound('FM'); + } + throw err; + } +} diff --git a/apps/api/src/services/part-models.ts b/apps/api/src/services/part-models.ts index 9f26f87..48619aa 100644 --- a/apps/api/src/services/part-models.ts +++ b/apps/api/src/services/part-models.ts @@ -43,6 +43,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) { manufacturerId: input.manufacturerId, mpn: input.mpn, eolDate: input.eolDate ? new Date(input.eolDate) : null, + destroyOnFail: input.destroyOnFail ?? false, notes: input.notes ?? null, }, include: partModelInclude, @@ -65,6 +66,7 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest) if (input.eolDate !== undefined) { data.eolDate = input.eolDate ? new Date(input.eolDate) : null; } + if (input.destroyOnFail !== undefined) data.destroyOnFail = input.destroyOnFail; if (input.notes !== undefined) data.notes = input.notes; try { return await tx.partModel.update({ where: { id }, data, include: partModelInclude }); diff --git a/apps/api/src/services/parts.test.ts b/apps/api/src/services/parts.test.ts index fbb54be..d39163d 100644 --- a/apps/api/src/services/parts.test.ts +++ b/apps/api/src/services/parts.test.ts @@ -45,6 +45,19 @@ function deployedPart(overrides: Partial> = {}) { }); } +function custodyPart(overrides: Partial> = {}) { + return sparePart({ + state: 'PENDING_DROP_IN_CUSTODY', + binId: null, + hostId: null, + custodianId: 'user-1', + custodian: { id: 'user-1', username: 'tech' }, + bin: null, + host: null, + ...overrides, + }); +} + describe('parts.create — state/location coupling', () => { it('rejects DEPLOYED without a hostId', async () => { const partCreate = vi.fn(); @@ -238,3 +251,92 @@ describe('parts.update — state/location coupling', () => { expect(call.data.host).toBeUndefined(); }); }); + +describe('parts.update — custody state/location coupling', () => { + it('DEPLOYED → PENDING_DROP_IN_CUSTODY requires custodianId; clears host', async () => { + const current = deployedPart(); + const partUpdate = vi.fn(); + partUpdate.mockResolvedValue( + custodyPart({ state: 'PENDING_DROP_IN_CUSTODY', custodianId: 'user-1' }), + ); + const tx = { + part: { findUnique: async () => current, update: partUpdate }, + partEvent: { createMany: vi.fn() }, + partTag: { findMany: async () => [] }, + } as unknown as Tx; + + await update( + tx, + 'p-1', + { state: 'PENDING_DROP_IN_CUSTODY', custodianId: 'user-1' }, + actor, + ); + + const call = partUpdate.mock.calls[0]![0] as { + data: { host?: unknown; bin?: unknown; custodian?: unknown }; + }; + expect(call.data.host).toEqual({ disconnect: true }); + expect(call.data.bin).toEqual({ disconnect: true }); + expect(call.data.custodian).toEqual({ connect: { id: 'user-1' } }); + }); + + it('rejects transition into a custody state without a custodianId', async () => { + const current = deployedPart(); + const partUpdate = vi.fn(); + const tx = { + part: { findUnique: async () => current, update: partUpdate }, + partEvent: { createMany: vi.fn() }, + } as unknown as Tx; + + await expect( + update(tx, 'p-1', { state: 'PENDING_DROP_IN_CUSTODY' }, actor), + ).rejects.toMatchObject({ status: 400 }); + expect(partUpdate).not.toHaveBeenCalled(); + }); + + it('rejects DEPLOYED with a custodianId', async () => { + const current = sparePart({ binId: 'bin-1', hostId: null }); + const partUpdate = vi.fn(); + const tx = { + part: { findUnique: async () => current, update: partUpdate }, + partEvent: { createMany: vi.fn() }, + } as unknown as Tx; + + await expect( + update( + tx, + 'p-1', + { state: 'DEPLOYED', hostId: 'host-1', custodianId: 'user-1' }, + actor, + ), + ).rejects.toMatchObject({ status: 400 }); + expect(partUpdate).not.toHaveBeenCalled(); + }); + + it('custody → BROKEN with a binId clears custodianId', async () => { + const current = custodyPart(); + const partUpdate = vi.fn(); + partUpdate.mockResolvedValue( + sparePart({ state: 'BROKEN', binId: 'bin-2', hostId: null, custodianId: null }), + ); + const tx = { + part: { findUnique: async () => current, update: partUpdate }, + partEvent: { createMany: vi.fn() }, + partTag: { findMany: async () => [] }, + } as unknown as Tx; + + await update( + tx, + 'p-1', + { state: 'BROKEN', binId: 'bin-2', custodianId: null }, + actor, + ); + + const call = partUpdate.mock.calls[0]![0] as { + data: { host?: unknown; bin?: unknown; custodian?: unknown }; + }; + expect(call.data.bin).toEqual({ connect: { id: 'bin-2' } }); + expect(call.data.host).toEqual({ disconnect: true }); + expect(call.data.custodian).toEqual({ disconnect: true }); + }); +}); diff --git a/apps/api/src/services/parts.ts b/apps/api/src/services/parts.ts index 7efbc29..8700c9c 100644 --- a/apps/api/src/services/parts.ts +++ b/apps/api/src/services/parts.ts @@ -11,27 +11,56 @@ import * as partModelsSvc from './part-models.js'; import * as tagsSvc from './tags.js'; import type { Actor, Tx } from './types.js'; -// DEPLOYED parts live on a host; every other state lives in a bin (or is unassigned). -// This helper enforces the invariant on create/update and auto-clears the stale field on a -// state transition, so callers don't have to remember to null the opposite relation. +// 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 }, - current: { binId: string | null; hostId: string | null } = { binId: null, hostId: null }, -): { binId: string | null; hostId: string | null } { + 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'); } - return { binId: null, hostId }; + 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 }; + return { binId, hostId: null, custodianId: null }; } const partInclude = { @@ -40,6 +69,7 @@ const partInclude = { bin: { include: { room: { include: { site: true } } } }, category: true, host: true, + custodian: { select: { id: true, username: true } }, tags: { include: { tag: true } }, } satisfies Prisma.PartInclude; @@ -94,6 +124,7 @@ function buildWhere(q: PartListQuery): 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; @@ -148,7 +179,11 @@ export async function create( } const state = input.state ?? 'SPARE'; - const location = resolveLocation(state, { binId: input.binId, hostId: input.hostId }); + const location = resolveLocation(state, { + binId: input.binId, + hostId: input.hostId, + custodianId: input.custodianId, + }); try { const p = await tx.part.create({ @@ -160,6 +195,7 @@ export async function create( state, binId: location.binId, hostId: location.hostId, + custodianId: location.custodianId, categoryId: input.categoryId ?? null, notes: input.notes ?? null, }, @@ -209,19 +245,35 @@ export async function update( 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.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 }, - { binId: current.binId, hostId: current.hostId }, + { + 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) { @@ -275,6 +327,16 @@ export async function update( 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, @@ -344,7 +406,7 @@ export async function remove(tx: Tx, id: string) { 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 errors.conflict('Cannot delete: part is referenced by an FM or repair'); } } throw err; diff --git a/apps/api/src/services/repairs.test.ts b/apps/api/src/services/repairs.test.ts index 44e3334..0d805c6 100644 --- a/apps/api/src/services/repairs.test.ts +++ b/apps/api/src/services/repairs.test.ts @@ -1,225 +1,550 @@ -import { describe, expect, it, vi } from 'vitest'; -import { Prisma } from '@vector/db'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const emitMock = vi.fn(); +vi.mock('../lib/webhook-emitter.js', () => ({ + emit: (...args: unknown[]) => emitMock(...args), +})); + import type { Tx, Actor } from './types.js'; -import { create, addComment, listComments } from './repairs.js'; -import * as partModels from './part-models.js'; -import * as hosts from './hosts.js'; -import { AppError } from '../lib/http-error.js'; +import { log } from './repairs.js'; const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' }; -// Fabricate a minimal Prisma.PrismaClientKnownRequestError without requiring a live client. -function prismaError(code: string, meta?: Record) { - return new Prisma.PrismaClientKnownRequestError('simulated', { - code, - clientVersion: 'test', - meta, - }); +const host1 = { id: 'host-1', assetId: 'ASSET-001', name: 'rack-1' }; +const host2 = { id: 'host-2', assetId: 'ASSET-002', name: 'rack-2' }; + +const brokenModel = { + id: 'pm-broken', + manufacturerId: 'mfr-1', + mpn: 'WD-BROKEN', + destroyOnFail: false, + eolDate: null, +}; +const destroyModel = { + id: 'pm-destroy', + manufacturerId: 'mfr-1', + mpn: 'WD-DESTROY', + destroyOnFail: true, + eolDate: null, +}; +const replacementModel = { + id: 'pm-replacement', + manufacturerId: 'mfr-1', + mpn: 'WD-REPLACE', + destroyOnFail: false, + eolDate: null, +}; + +function partRow(overrides: Partial>) { + return { + id: overrides.id ?? 'p-x', + serialNumber: overrides.serialNumber ?? 'SN-X', + partModelId: overrides.partModelId ?? 'pm-x', + manufacturerId: 'mfr-1', + state: overrides.state ?? 'SPARE', + binId: overrides.binId ?? null, + hostId: overrides.hostId ?? null, + custodianId: overrides.custodianId ?? null, + categoryId: null, + price: null, + notes: null, + partModel: overrides.partModel ?? brokenModel, + manufacturer: { id: 'mfr-1', name: 'WD' }, + bin: null, + host: overrides.host ?? null, + category: null, + custodian: overrides.custodian ?? null, + tags: [], + ...overrides, + }; } -describe('repairs.create — host membership', () => { - it('rejects a problem-part that is not on the chosen host', async () => { - const partEventCreateMany = vi.fn(); - const repairCreate = vi.fn(); +// Builds a Tx stub whose `tx.part.findUnique` resolves from an internal registry. +// `tx.part.update` mutates the registry in place so the second parts.update call +// (for the replacement) sees the fallout from the first (broken) update. +function buildTx(options: { + parts: Array>; + hosts: Array<{ id: string; assetId: string; name: string }>; + fm?: { id: string; hostId: string } | null; + existingPartModel?: { id: string; manufacturerId: string; mpn: string } | null; +}) { + const registry = new Map(options.parts.map((p) => [p.id, p])); - const tx = { - host: { findUnique: async () => ({ id: 'host-1' }) }, - part: { - findMany: async () => [{ id: 'part-a', hostId: 'host-2' }], + const tx = { + host: { + findUnique: async (args: { where: { id?: string; assetId?: string } }) => { + if (args.where.id) return options.hosts.find((h) => h.id === args.where.id) ?? null; + if (args.where.assetId) + return options.hosts.find((h) => h.assetId === args.where.assetId) ?? null; + return null; }, - repairJob: { create: repairCreate }, - partEvent: { createMany: partEventCreateMany }, - } as unknown as Tx; + }, + part: { + findUnique: async (args: { + where: { id?: string; serialNumber?: string }; + }) => { + const found = [...registry.values()].find( + (p) => + (args.where.id && p.id === args.where.id) || + (args.where.serialNumber && p.serialNumber === args.where.serialNumber), + ); + return found ?? null; + }, + create: vi.fn(async (args: { data: Record }) => { + const data = args.data; + const pm = + data.partModelId === brokenModel.id + ? brokenModel + : data.partModelId === destroyModel.id + ? destroyModel + : replacementModel; + const created = partRow({ + id: `p-ingested-${data.serialNumber}`, + serialNumber: data.serialNumber as string, + partModelId: data.partModelId as string, + state: data.state as string, + hostId: data.hostId as string | null, + partModel: pm, + }); + registry.set(created.id, created); + return created; + }), + update: vi.fn(async (args: { where: { id: string }; data: Record }) => { + const current = registry.get(args.where.id); + if (!current) throw new Error(`No part ${args.where.id} in stub registry`); + const data = args.data; + if (data.state) current.state = data.state as string; + if (data.bin !== undefined) { + const v = data.bin as { connect?: { id: string }; disconnect?: boolean }; + current.binId = v.connect?.id ?? null; + } + if (data.host !== undefined) { + const v = data.host as { connect?: { id: string }; disconnect?: boolean }; + current.hostId = v.connect?.id ?? null; + current.host = v.connect?.id + ? options.hosts.find((h) => h.id === v.connect!.id) ?? null + : null; + } + if (data.custodian !== undefined) { + const v = data.custodian as { connect?: { id: string }; disconnect?: boolean }; + current.custodianId = v.connect?.id ?? null; + current.custodian = v.connect?.id + ? { id: v.connect.id, username: 'tech' } + : null; + } + return current; + }), + }, + partModel: { + findUnique: async (args: { + where: { + id?: string; + manufacturerId_mpn?: { manufacturerId: string; mpn: string }; + }; + }) => { + if (args.where.id) { + if (args.where.id === brokenModel.id) return brokenModel; + if (args.where.id === destroyModel.id) return destroyModel; + if (args.where.id === replacementModel.id) return replacementModel; + return null; + } + if (options.existingPartModel && args.where.manufacturerId_mpn) { + const k = args.where.manufacturerId_mpn; + if ( + options.existingPartModel.manufacturerId === k.manufacturerId && + options.existingPartModel.mpn === k.mpn + ) { + return options.existingPartModel; + } + } + return null; + }, + create: vi.fn(async (args: { data: { manufacturerId: string; mpn: string } }) => ({ + id: `pm-new-${args.data.mpn}`, + manufacturerId: args.data.manufacturerId, + mpn: args.data.mpn, + destroyOnFail: false, + eolDate: null, + })), + }, + fm: { + findUnique: async (args: { where: { id: string } }) => { + if (options.fm && options.fm.id === args.where.id) return options.fm; + return null; + }, + }, + repair: { + create: vi.fn(async (args: { data: Record }) => ({ + id: 'repair-1', + hostId: args.data.hostId, + brokenPartId: args.data.brokenPartId, + replacementPartId: args.data.replacementPartId, + performedById: args.data.performedById, + fmId: args.data.fmId ?? null, + performedAt: new Date('2026-04-15T00:00:00Z'), + host: options.hosts.find((h) => h.id === args.data.hostId) ?? host1, + brokenPart: registry.get(args.data.brokenPartId as string), + replacement: registry.get(args.data.replacementPartId as string), + performedBy: { id: actor.id, username: actor.username }, + fm: options.fm ? { id: options.fm.id, status: 'OPEN' } : null, + })), + }, + partEvent: { + create: vi.fn(), + createMany: vi.fn(), + }, + partTag: { + findMany: async () => [], + }, + } as unknown as Tx; + return { tx, registry }; +} + +beforeEach(() => { + emitMock.mockClear(); +}); + +describe('repairs.log — happy paths', () => { + it('swaps a known broken deployed part with a SPARE replacement', async () => { + const broken = partRow({ + id: 'p-broken', + serialNumber: 'SN-BROKEN', + partModelId: brokenModel.id, + state: 'DEPLOYED', + hostId: 'host-1', + host: host1, + partModel: brokenModel, + }); + const replacement = partRow({ + id: 'p-replacement', + serialNumber: 'SN-REPLACE', + partModelId: replacementModel.id, + state: 'SPARE', + binId: 'bin-1', + partModel: replacementModel, + }); + const { tx, registry } = buildTx({ + parts: [broken, replacement], + hosts: [host1], + }); + + const r = await log( + tx, + { + hostId: 'host-1', + brokenSerial: 'SN-BROKEN', + brokenMpn: 'WD-BROKEN', + brokenManufacturerId: 'mfr-1', + replacementSerial: 'SN-REPLACE', + }, + actor, + ); + + expect(r.id).toBe('repair-1'); + + // After swap: broken is in custody, replacement is DEPLOYED on the host. + const updatedBroken = registry.get('p-broken')!; + expect(updatedBroken.state).toBe('PENDING_DROP_IN_CUSTODY'); + expect(updatedBroken.custodianId).toBe('user-1'); + expect(updatedBroken.hostId).toBeNull(); + + const updatedReplacement = registry.get('p-replacement')!; + expect(updatedReplacement.state).toBe('DEPLOYED'); + expect(updatedReplacement.hostId).toBe('host-1'); + expect(updatedReplacement.binId).toBeNull(); + + expect(emitMock).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'repair.logged', + payload: expect.objectContaining({ + repair: expect.objectContaining({ id: 'repair-1' }), + }), + }), + ); + }); + + it('PART_SWAPPED events are emitted for both parts', async () => { + const broken = partRow({ + id: 'p-broken', + serialNumber: 'SN-BROKEN', + partModelId: brokenModel.id, + state: 'DEPLOYED', + hostId: 'host-1', + host: host1, + partModel: brokenModel, + }); + const replacement = partRow({ + id: 'p-replacement', + serialNumber: 'SN-REPLACE', + partModelId: replacementModel.id, + state: 'SPARE', + partModel: replacementModel, + }); + const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1] }); + + await log( + tx, + { + hostId: 'host-1', + brokenSerial: 'SN-BROKEN', + brokenMpn: 'WD-BROKEN', + brokenManufacturerId: 'mfr-1', + replacementSerial: 'SN-REPLACE', + }, + actor, + ); + + // Find the createMany call with PART_SWAPPED entries. + const createManyMock = tx.partEvent.createMany as unknown as ReturnType; + const swapCall = createManyMock.mock.calls.find((c) => { + const data = (c[0] as { data: Array<{ type: string }> }).data; + return data.some((d) => d.type === 'PART_SWAPPED'); + }); + expect(swapCall).toBeDefined(); + const swapData = (swapCall![0] as { data: Array<{ partId: string; type: string }> }).data; + const swapped = swapData.filter((d) => d.type === 'PART_SWAPPED').map((d) => d.partId); + expect(swapped).toEqual(expect.arrayContaining(['p-broken', 'p-replacement'])); + }); +}); + +describe('repairs.log — ingest on unknown MPN', () => { + it('ingests a brand-new broken part when the serial is absent', async () => { + const replacement = partRow({ + id: 'p-replacement', + serialNumber: 'SN-REPLACE', + partModelId: replacementModel.id, + state: 'SPARE', + partModel: replacementModel, + }); + const { tx, registry } = buildTx({ + parts: [replacement], + hosts: [host1], + existingPartModel: null, + }); + + await log( + tx, + { + hostId: 'host-1', + brokenSerial: 'NEW-SN', + brokenMpn: 'NEW-MPN', + brokenManufacturerId: 'mfr-1', + replacementSerial: 'SN-REPLACE', + }, + actor, + ); + + const partModelCreate = tx.partModel.create as unknown as ReturnType; + expect(partModelCreate).toHaveBeenCalledWith({ + data: { manufacturerId: 'mfr-1', mpn: 'NEW-MPN' }, + }); + + const partCreate = tx.part.create as unknown as ReturnType; + expect(partCreate).toHaveBeenCalledTimes(1); + const createdArgs = partCreate.mock.calls[0]![0] as { + data: { serialNumber: string; state: string; hostId: string }; + }; + expect(createdArgs.data.serialNumber).toBe('NEW-SN'); + expect(createdArgs.data.state).toBe('DEPLOYED'); + expect(createdArgs.data.hostId).toBe('host-1'); + + // The ingested part must end up in custody just like the known-broken path. + const ingested = registry.get('p-ingested-NEW-SN')!; + expect(ingested.state).toBe('PENDING_DROP_IN_CUSTODY'); + expect(ingested.custodianId).toBe('user-1'); + }); + + it('destroyOnFail=true routes the broken part to PENDING_DESTRUCTION_IN_CUSTODY', async () => { + const broken = partRow({ + id: 'p-broken', + serialNumber: 'SN-BROKEN', + partModelId: destroyModel.id, + state: 'DEPLOYED', + hostId: 'host-1', + host: host1, + partModel: destroyModel, + }); + const replacement = partRow({ + id: 'p-replacement', + serialNumber: 'SN-REPLACE', + partModelId: replacementModel.id, + state: 'SPARE', + partModel: replacementModel, + }); + const { tx, registry } = buildTx({ parts: [broken, replacement], hosts: [host1] }); + + await log( + tx, + { + hostId: 'host-1', + brokenSerial: 'SN-BROKEN', + brokenMpn: 'WD-DESTROY', + brokenManufacturerId: 'mfr-1', + replacementSerial: 'SN-REPLACE', + }, + actor, + ); + + expect(registry.get('p-broken')!.state).toBe('PENDING_DESTRUCTION_IN_CUSTODY'); + }); +}); + +describe('repairs.log — validation failures', () => { + it('rejects when replacement is missing', async () => { + const { tx } = buildTx({ parts: [], hosts: [host1] }); await expect( - create( + log( tx, - { hostId: 'host-1', problem: 'fan noise', problemPartIds: ['part-a'] }, + { + hostId: 'host-1', + brokenSerial: 'SN-BROKEN', + brokenMpn: 'WD-BROKEN', + brokenManufacturerId: 'mfr-1', + replacementSerial: 'DOES-NOT-EXIST', + }, actor, ), ).rejects.toMatchObject({ status: 400 }); - - expect(repairCreate).not.toHaveBeenCalled(); - expect(partEventCreateMany).not.toHaveBeenCalled(); }); - it('succeeds with empty problemPartIds and emits no REPAIR_STARTED events', async () => { - const partEventCreateMany = vi.fn(); - const repairCreate = vi.fn(async () => ({ - id: 'repair-1', + it('rejects when replacement is not SPARE', async () => { + const replacement = partRow({ + id: 'p-replacement', + serialNumber: 'SN-REPLACE', + partModelId: replacementModel.id, + state: 'DEPLOYED', hostId: 'host-1', - problem: 'power drops', - status: 'PENDING', - problemParts: [], - })); - - const tx = { - host: { findUnique: async () => ({ id: 'host-1' }) }, - part: { findMany: async () => [] }, - repairJob: { create: repairCreate }, - partEvent: { createMany: partEventCreateMany }, - } as unknown as Tx; - - const r = await create(tx, { hostId: 'host-1', problem: 'power drops' }, actor); - expect(r.id).toBe('repair-1'); - expect(repairCreate).toHaveBeenCalledOnce(); - expect(partEventCreateMany).not.toHaveBeenCalled(); - }); -}); - -describe('part-models.upsertByMpn', () => { - it('returns the existing row without creating when (manufacturerId, mpn) is taken', async () => { - const existing = { id: 'pm-1', manufacturerId: 'mfr-1', mpn: 'WD-1000', eolDate: null }; - const create = vi.fn(); - const tx = { - partModel: { - findUnique: async () => existing, - create, - }, - } as unknown as Tx; - - const r1 = await partModels.upsertByMpn(tx, { manufacturerId: 'mfr-1', mpn: 'WD-1000' }); - const r2 = await partModels.upsertByMpn(tx, { manufacturerId: 'mfr-1', mpn: 'WD-1000' }); - expect(r1).toBe(existing); - expect(r2).toBe(existing); - expect(create).not.toHaveBeenCalled(); + partModel: replacementModel, + }); + const { tx } = buildTx({ parts: [replacement], hosts: [host1] }); + await expect( + log( + tx, + { + hostId: 'host-1', + brokenSerial: 'SN-BROKEN', + brokenMpn: 'WD-BROKEN', + brokenManufacturerId: 'mfr-1', + replacementSerial: 'SN-REPLACE', + }, + actor, + ), + ).rejects.toMatchObject({ status: 400 }); }); - it('recovers from a race by re-fetching the winning row on P2002', async () => { - const winner = { id: 'pm-9', manufacturerId: 'mfr-1', mpn: 'WD-1000', eolDate: null }; - let findCall = 0; - const tx = { - partModel: { - findUnique: async () => { - findCall += 1; - if (findCall === 1) return null; - return winner; - }, - create: async () => { - throw prismaError('P2002'); - }, - }, - } as unknown as Tx; - - const r = await partModels.upsertByMpn(tx, { manufacturerId: 'mfr-1', mpn: 'WD-1000' }); - expect(r).toBe(winner); - }); -}); - -describe('hosts.create — assetId uniqueness', () => { - it('surfaces a P2002 on assetId as a 409 with the Asset ID message', async () => { - const tx = { - host: { - create: async () => { - throw prismaError('P2002', { target: ['assetId'] }); - }, - }, - } as unknown as Tx; + it('rejects when fmId belongs to a different host', async () => { + const broken = partRow({ + id: 'p-broken', + serialNumber: 'SN-BROKEN', + partModelId: brokenModel.id, + state: 'DEPLOYED', + hostId: 'host-1', + host: host1, + partModel: brokenModel, + }); + const replacement = partRow({ + id: 'p-replacement', + serialNumber: 'SN-REPLACE', + partModelId: replacementModel.id, + state: 'SPARE', + partModel: replacementModel, + }); + const { tx } = buildTx({ + parts: [broken, replacement], + hosts: [host1, host2], + fm: { id: 'fm-other', hostId: 'host-2' }, + }); await expect( - hosts.create(tx, { assetId: 'ASSET-001', name: 'rack-1' }), - ).rejects.toMatchObject({ status: 409, message: 'Asset ID already in use' }); + log( + tx, + { + hostId: 'host-1', + brokenSerial: 'SN-BROKEN', + brokenMpn: 'WD-BROKEN', + brokenManufacturerId: 'mfr-1', + replacementSerial: 'SN-REPLACE', + fmId: 'fm-other', + }, + actor, + ), + ).rejects.toMatchObject({ status: 400 }); }); - it('falls through to the name-uniqueness message for other unique targets', async () => { - const tx = { - host: { - create: async () => { - throw prismaError('P2002', { target: ['name'] }); - }, + it('accepts a matching fmId and does NOT auto-close the FM', async () => { + const broken = partRow({ + id: 'p-broken', + serialNumber: 'SN-BROKEN', + partModelId: brokenModel.id, + state: 'DEPLOYED', + hostId: 'host-1', + host: host1, + partModel: brokenModel, + }); + const replacement = partRow({ + id: 'p-replacement', + serialNumber: 'SN-REPLACE', + partModelId: replacementModel.id, + state: 'SPARE', + partModel: replacementModel, + }); + const { tx } = buildTx({ + parts: [broken, replacement], + hosts: [host1], + fm: { id: 'fm-1', hostId: 'host-1' }, + }); + + const r = await log( + tx, + { + hostId: 'host-1', + brokenSerial: 'SN-BROKEN', + brokenMpn: 'WD-BROKEN', + brokenManufacturerId: 'mfr-1', + replacementSerial: 'SN-REPLACE', + fmId: 'fm-1', }, - } as unknown as Tx; + actor, + ); + + expect(r.fmId).toBe('fm-1'); + // Only the repair.logged webhook fires — no fm.closed. + const events = emitMock.mock.calls.map((c) => (c[0] as { event: string }).event); + expect(events).toEqual(['repair.logged']); + }); + + it('rejects when broken part is on a different host than the repair', async () => { + const broken = partRow({ + id: 'p-broken', + serialNumber: 'SN-BROKEN', + partModelId: brokenModel.id, + state: 'DEPLOYED', + hostId: 'host-2', + host: host2, + partModel: brokenModel, + }); + const replacement = partRow({ + id: 'p-replacement', + serialNumber: 'SN-REPLACE', + partModelId: replacementModel.id, + state: 'SPARE', + partModel: replacementModel, + }); + const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1, host2] }); await expect( - hosts.create(tx, { assetId: 'ASSET-002', name: 'rack-1' }), - ).rejects.toMatchObject({ status: 409, message: 'Host name already exists' }); - }); -}); - -describe('repairs.addComment / listComments', () => { - it('stamps userId from the actor and returns it via listComments', async () => { - const stored: { - id: string; - repairJobId: string; - userId: string | null; - content: string; - createdAt: Date; - user: { id: string; username: string } | null; - }[] = []; - let nextId = 1; - - const tx = { - repairJob: { - findUnique: async ({ include }: { include?: unknown }) => { - if (include) return { id: 'repair-1', problemParts: [] }; - return { id: 'repair-1' }; - }, - }, - repairComment: { - create: async ({ data }: { data: { repairJobId: string; userId: string | null; content: string } }) => { - const row = { - id: `c-${nextId++}`, - repairJobId: data.repairJobId, - userId: data.userId, - content: data.content, - createdAt: new Date(), - user: data.userId ? { id: data.userId, username: actor.username } : null, - }; - stored.push(row); - return row; - }, - findMany: async () => stored, - count: async () => stored.length, - }, - partEvent: { createMany: vi.fn() }, - } as unknown as Tx; - - const created = await addComment(tx, 'repair-1', { content: 'Checked fans' }, actor); - expect(created.userId).toBe(actor.id); - - const page = await listComments(tx, 'repair-1', { page: 1, pageSize: 20 }); - expect(page.total).toBe(1); - expect(page.data[0]?.userId).toBe(actor.id); - expect(page.data[0]?.content).toBe('Checked fans'); - }); - - it('emits a REPAIR_COMMENTED PartEvent for each problem part', async () => { - const partEventCreateMany = vi.fn(); - const tx = { - repairJob: { - findUnique: async () => ({ - id: 'repair-1', - problemParts: [{ partId: 'part-a' }, { partId: 'part-b' }], - }), - }, - repairComment: { - create: async ({ data }: { data: { repairJobId: string; userId: string | null; content: string } }) => ({ - id: 'c-1', - ...data, - createdAt: new Date(), - user: null, - }), - }, - partEvent: { createMany: partEventCreateMany }, - } as unknown as Tx; - - await addComment(tx, 'repair-1', { content: 'ping' }, actor); - expect(partEventCreateMany).toHaveBeenCalledOnce(); - const call = partEventCreateMany.mock.calls[0]![0] as { - data: { partId: string; type: string; userId: string | null }[]; - }; - expect(call.data.map((d) => d.partId)).toEqual(['part-a', 'part-b']); - expect(call.data.every((d) => d.type === 'REPAIR_COMMENTED')).toBe(true); - expect(call.data.every((d) => d.userId === actor.id)).toBe(true); - }); - - it('returns 404 when the repair does not exist', async () => { - const tx = { - repairJob: { findUnique: async () => null }, - } as unknown as Tx; - - await expect( - addComment(tx, 'missing', { content: 'hi' }, actor), - ).rejects.toBeInstanceOf(AppError); + log( + tx, + { + hostId: 'host-1', + brokenSerial: 'SN-BROKEN', + brokenMpn: 'WD-BROKEN', + brokenManufacturerId: 'mfr-1', + replacementSerial: 'SN-REPLACE', + }, + actor, + ), + ).rejects.toMatchObject({ status: 400 }); }); }); diff --git a/apps/api/src/services/repairs.ts b/apps/api/src/services/repairs.ts index 961fef6..d21db67 100644 --- a/apps/api/src/services/repairs.ts +++ b/apps/api/src/services/repairs.ts @@ -1,275 +1,198 @@ import { Prisma } from '@vector/db'; -import type { - CreateRepairCommentRequest, - CreateRepairJobRequest, - RepairCommentListQuery, - RepairJobListQuery, - UpdateRepairJobRequest, -} from '@vector/shared'; +import type { LogRepairRequest, RepairListQuery } from '@vector/shared'; import { errors } from '../lib/http-error.js'; +import { emit } from '../lib/webhook-emitter.js'; +import * as partsSvc from './parts.js'; +import * as partModelsSvc from './part-models.js'; +import { resolveHost } from './fms.js'; import type { Actor, Tx } from './types.js'; +// A Repair is the persistent log of a physical part swap on a host. The tech enters the broken +// serial + mpn + replacement serial; if the broken part isn't in the catalog we ingest it. The +// broken part is placed into the tech's custody (dropped in a bin later via the custody flow). 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; + brokenPart: { include: { partModel: true, manufacturer: true } }, + replacement: { include: { partModel: true, manufacturer: true } }, + performedBy: { select: { id: true, username: true } }, + fm: { select: { id: true, status: true } }, +} satisfies Prisma.RepairInclude; -const commentInclude = { - user: { select: { id: true, username: true } }, -} satisfies Prisma.RepairCommentInclude; +export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>; -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 } }; +function buildWhere(q: RepairListQuery): Prisma.RepairWhereInput { + const where: Prisma.RepairWhereInput = {}; + if (q.hostId) where.hostId = q.hostId; + if (q.performedById) where.performedById = q.performedById; + if (q.fmId) where.fmId = q.fmId; + if (q.since) where.performedAt = { gte: new Date(q.since) }; + return where; +} +export async function list(tx: Tx, q: RepairListQuery) { + const { page, pageSize } = q; + const where = buildWhere(q); const [data, total] = await Promise.all([ - tx.repairJob.findMany({ + tx.repair.findMany({ where, - orderBy: [{ status: 'asc' }, { openedAt: 'desc' }], + orderBy: { performedAt: 'desc' }, include: repairInclude, skip: (page - 1) * pageSize, take: pageSize, }), - tx.repairJob.count({ where }), + tx.repair.count({ where }), ]); return { data, page, pageSize, total }; } export function get(tx: Tx, id: string) { - return tx.repairJob.findUnique({ where: { id }, include: repairInclude }); + return tx.repair.findUnique({ where: { id }, include: repairInclude }); } -export function listForHost(tx: Tx, hostId: string) { - return tx.repairJob.findMany({ - where: { hostId }, - orderBy: { openedAt: 'desc' }, - include: repairInclude, - }); +function repairPayload(r: RepairWithRelations) { + return { + id: r.id, + host: { id: r.host.id, assetId: r.host.assetId, name: r.host.name }, + brokenPart: { + id: r.brokenPart.id, + serialNumber: r.brokenPart.serialNumber, + mpn: r.brokenPart.partModel.mpn, + state: r.brokenPart.state, + }, + replacement: { + id: r.replacement.id, + serialNumber: r.replacement.serialNumber, + mpn: r.replacement.partModel.mpn, + state: r.replacement.state, + }, + performedBy: r.performedBy, + performedAt: r.performedAt.toISOString(), + fmId: r.fmId, + }; } -// 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( +export async function log( tx: Tx, - input: CreateRepairJobRequest, - actor: Actor | null, -) { - const host = await tx.host.findUnique({ where: { id: input.hostId } }); - if (!host) throw errors.notFound('Host'); + input: LogRepairRequest, + actor: Actor, +): Promise { + const host = await resolveHost(tx, input); - await validateProblemParts(tx, input.hostId, input.problemPartIds); + // 1. Resolve replacement — must exist, must be SPARE. + const replacement = await tx.part.findUnique({ + where: { serialNumber: input.replacementSerial }, + include: { partModel: true }, + }); + if (!replacement) { + throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`); + } + if (replacement.state !== 'SPARE') { + throw errors.badRequest( + `Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE`, + ); + } - try { - const repair = await tx.repairJob.create({ + // 2. Resolve broken — reuse if found, else ingest. + let broken = await tx.part.findUnique({ + where: { serialNumber: input.brokenSerial }, + include: { partModel: true }, + }); + if (broken) { + if (broken.hostId && broken.hostId !== host.id) { + throw errors.badRequest( + `Broken part ${input.brokenSerial} is currently on a different host`, + ); + } + } else { + const pm = await partModelsSvc.upsertByMpn(tx, { + manufacturerId: input.brokenManufacturerId, + mpn: input.brokenMpn, + }); + const created = await tx.part.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, + serialNumber: input.brokenSerial, + partModelId: pm.id, + manufacturerId: pm.manufacturerId, + state: 'DEPLOYED', + hostId: host.id, }, - include: repairInclude, + include: { partModel: true }, }); - 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; + await tx.partEvent.create({ + data: { + partId: created.id, + userId: actor.id, + type: 'CREATED', + newValue: created.serialNumber, + }, + }); + broken = created; } -} -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 })), - }); + // 3. Optional FM link — must belong to the same host; we do NOT auto-close it. + if (input.fmId) { + const fm = await tx.fm.findUnique({ where: { id: input.fmId } }); + if (!fm) throw errors.badRequest('FM does not exist'); + if (fm.hostId !== host.id) { + throw errors.badRequest('FM is on a different host than the repair'); } } - const repair = await tx.repairJob.update({ - where: { id }, - data, + // 4. Custody state is driven by the broken model's destroyOnFail flag. + const custodyState = broken.partModel.destroyOnFail + ? 'PENDING_DESTRUCTION_IN_CUSTODY' + : 'PENDING_DROP_IN_CUSTODY'; + + // 5. Transition both parts through the standard parts.update machinery so every state + // and location change emits the usual PartEvents. The resolver clears host/bin + // automatically when entering custody / DEPLOYED. + await partsSvc.update( + tx, + broken.id, + { state: custodyState, custodianId: actor.id }, + actor, + ); + await partsSvc.update( + tx, + replacement.id, + { state: 'DEPLOYED', hostId: host.id }, + actor, + ); + + // 6. Persist the Repair row. + const repair = await tx.repair.create({ + data: { + hostId: host.id, + brokenPartId: broken.id, + replacementPartId: replacement.id, + performedById: actor.id, + fmId: input.fmId ?? null, + }, include: repairInclude, }); - const userId = actor?.id ?? null; - if (addedPartIds.length > 0) { - await tx.partEvent.createMany({ - data: addedPartIds.map((partId) => ({ - partId, - userId, - type: 'REPAIR_STARTED', + // 7. Swap event on each part — so the part timeline shows the repair link. + await tx.partEvent.createMany({ + data: [ + { + partId: broken.id, + userId: actor.id, + type: 'PART_SWAPPED', + field: 'role', + oldValue: 'DEPLOYED', 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, - })), - }); - } - } + }, + { + partId: replacement.id, + userId: actor.id, + type: 'PART_SWAPPED', + field: 'role', + oldValue: 'SPARE', + newValue: repair.id, + }, + ], + }); + void emit({ event: 'repair.logged', payload: { repair: repairPayload(repair) } }); 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; -} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9050396..6a6b471 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -13,8 +13,10 @@ import PartDetail from './pages/PartDetail.js'; import Locations from './pages/Locations.js'; import Manufacturers from './pages/Manufacturers.js'; import PartModels from './pages/PartModels.js'; +import Fms from './pages/Fms.js'; +import FmDetail from './pages/FmDetail.js'; import Repairs from './pages/Repairs.js'; -import RepairDetail from './pages/RepairDetail.js'; +import MyCustody from './pages/MyCustody.js'; import Hosts from './pages/Hosts.js'; import Users from './pages/admin/Users.js'; import Webhooks from './pages/admin/Webhooks.js'; @@ -57,8 +59,10 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> - } /> + } /> } /> void; + onConfirm: (binId: string | null) => void; + pending: boolean; +} + +export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOffDialogProps) { + const open = Boolean(part); + const [binId, setBinId] = useState(''); + + useEffect(() => { + if (open) setBinId(''); + }, [open]); + + const bins = useQuery({ + queryKey: queryKeys.bins.list({ pageSize: 100 }), + queryFn: () => listBins({ pageSize: 100 }), + enabled: open, + }); + + const destruction = part?.state === 'PENDING_DESTRUCTION_IN_CUSTODY'; + + return ( + + + + Drop in bin + + {destruction + ? 'This part is flagged for destruction. It will move to pending-destruction — optionally place it in a destruction bin.' + : `Dropping ${part?.serialNumber ?? ''} into a bin marks it broken.`} + + + +
+ +
+ + + + + +
+
+ ); +} diff --git a/apps/web/src/components/repairs/RepairFormDialog.tsx b/apps/web/src/components/fms/FmFormDialog.tsx similarity index 83% rename from apps/web/src/components/repairs/RepairFormDialog.tsx rename to apps/web/src/components/fms/FmFormDialog.tsx index 148c6fd..f9b3ea2 100644 --- a/apps/web/src/components/repairs/RepairFormDialog.tsx +++ b/apps/web/src/components/fms/FmFormDialog.tsx @@ -29,35 +29,34 @@ import { Skeleton, Textarea, } from '@vector/ui'; -import { createRepair } from '../../lib/api/repairs.js'; +import { createFm } from '../../lib/api/fms.js'; import { listHosts, listHostDeployedParts } from '../../lib/api/hosts.js'; import { ApiRequestError } from '../../lib/api/client.js'; import { queryKeys } from '../../lib/queryKeys.js'; -import type { RepairJob } from '../../lib/api/types.js'; +import type { Fm } from '../../lib/api/types.js'; const CreateSchema = z.object({ hostId: z.string().uuid('Pick a host'), problem: z.string().trim().min(1, 'Describe the problem').max(2000), problemPartIds: z.array(z.string().uuid()).max(100), - notes: z.string().max(4096).optional(), }); type CreateValues = z.infer; -interface RepairFormDialogProps { +interface FmFormDialogProps { open: boolean; onOpenChange: (open: boolean) => void; defaultHostId?: string; defaultProblemPartIds?: string[]; - onCreated?: (repair: RepairJob) => void; + onCreated?: (fm: Fm) => void; } -export function RepairFormDialog({ +export function FmFormDialog({ open, onOpenChange, defaultHostId, defaultProblemPartIds, onCreated, -}: RepairFormDialogProps) { +}: FmFormDialogProps) { const queryClient = useQueryClient(); const hostsQuery = useQuery({ @@ -68,12 +67,7 @@ export function RepairFormDialog({ const form = useForm({ resolver: zodResolver(CreateSchema), - defaultValues: { - hostId: '', - problem: '', - problemPartIds: [], - notes: '', - }, + defaultValues: { hostId: '', problem: '', problemPartIds: [] }, }); useEffect(() => { @@ -82,7 +76,6 @@ export function RepairFormDialog({ hostId: defaultHostId ?? '', problem: '', problemPartIds: defaultProblemPartIds ?? [], - notes: '', }); }, [open, defaultHostId, defaultProblemPartIds, form]); @@ -96,19 +89,18 @@ export function RepairFormDialog({ const createMutation = useMutation({ mutationFn: async (values: CreateValues) => - createRepair({ + createFm({ hostId: values.hostId, problem: values.problem, problemPartIds: values.problemPartIds.length > 0 ? values.problemPartIds : undefined, - notes: values.notes ? values.notes : null, }), - onSuccess: (repair) => { - toast.success('Repair opened'); - queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all }); + onSuccess: (fm) => { + toast.success('FM opened'); + queryClient.invalidateQueries({ queryKey: queryKeys.fms.all }); queryClient.invalidateQueries({ queryKey: queryKeys.parts.all }); onOpenChange(false); - onCreated?.(repair); + onCreated?.(fm); }, onError: (err) => toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'), @@ -127,9 +119,9 @@ export function RepairFormDialog({ - Open repair + Open FM - Create a repair against a host. Select the deployed parts involved (optional). + Open a Future Maintenance item against a host. Select deployed parts involved (optional). @@ -147,9 +139,7 @@ export function RepairFormDialog({