feat: split Repairs into FM, Repair, and Custody workflows
The old Repairs module had grown ticketing-system features (status lifecycle, comments, assignee, notes) that duplicate what the external ticketing tool already owns. Vector only needs to track whether maintenance is open or closed. - Rename RepairJob -> Fm (OPEN/CLOSED only), drop RepairComment, assignee, notes - New Repair table: persistent log of physical part swaps, with ingest on unknown broken MPN via partModels.upsertByMpn - New custody model: PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY states + Part.custodianId, with a "My Custody" page for drop-off - PartModel.destroyOnFail routes broken parts to the destruction path - Host lookup on /fms and /repairs accepts hostId XOR assetId - Wire the dormant webhook emitter: fm.opened, fm.closed, repair.logged - Single fresh Prisma migration (dev DB was wiped, no backfill) Tests: 60 passing (custody transitions in parts.test.ts; new fms.test.ts, repairs.test.ts, custody.test.ts covering happy paths, validation failures, webhook emissions, and ingest-on-unknown-MPN). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -13,7 +13,7 @@ const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
|
||||
];
|
||||
|
||||
export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
|
||||
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<DashboardAnalytics> {
|
||||
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<DashboardAnalytics> {
|
||||
.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 };
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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<typeof custodyPart>) {
|
||||
const current = { ...initial };
|
||||
const partUpdate = vi.fn(
|
||||
async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, unknown>> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -45,6 +45,19 @@ function deployedPart(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function custodyPart(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<Record<string, unknown>>) {
|
||||
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<ReturnType<typeof partRow>>;
|
||||
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<string, unknown> }) => {
|
||||
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<string, unknown> }) => {
|
||||
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<string, unknown> }) => ({
|
||||
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<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
expect(partModelCreate).toHaveBeenCalledWith({
|
||||
data: { manufacturerId: 'mfr-1', mpn: 'NEW-MPN' },
|
||||
});
|
||||
|
||||
const partCreate = tx.part.create as unknown as ReturnType<typeof vi.fn>;
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
+156
-233
@@ -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<RepairWithRelations> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user