feat: split Repairs into FM, Repair, and Custody workflows
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m0s

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:
2026-04-17 12:22:56 -04:00
parent 6690d8a5dd
commit 3d77f2846d
54 changed files with 3304 additions and 1287 deletions
+4
View File
@@ -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);
+33
View File
@@ -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);
}
}
+56
View File
@@ -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);
}
}
+9 -67
View File
@@ -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);
}
}
+17
View File
@@ -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;
+15
View File
@@ -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;
+3 -24
View File
@@ -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;
+9 -9
View File
@@ -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',
+3 -3
View File
@@ -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 };
}
+169
View File
@@ -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 });
});
});
+58
View File
@@ -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,
);
}
+279
View File
@@ -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();
});
});
+231
View File
@@ -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;
}
}
+2
View File
@@ -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 });
+102
View File
@@ -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 });
});
});
+75 -13
View File
@@ -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;
+517 -192
View File
@@ -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' }],
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;
},
},
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 () => [],
},
repairJob: { create: repairCreate },
partEvent: { createMany: partEventCreateMany },
} as unknown as Tx;
await expect(
create(
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', problem: 'fan noise', problemPartIds: ['part-a'] },
{
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(
log(
tx,
{
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();
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 });
});
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();
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,
});
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);
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' },
});
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;
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 });
});
});
+160 -237
View File
@@ -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: { partModel: true },
});
await tx.partEvent.create({
data: {
partId: created.id,
userId: actor.id,
type: 'CREATED',
newValue: created.serialNumber,
},
});
broken = created;
}
// 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');
}
}
// 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,
});
if (input.problemPartIds && input.problemPartIds.length > 0) {
// 7. Swap event on each part — so the part timeline shows the repair link.
await tx.partEvent.createMany({
data: [...new Set(input.problemPartIds)].map((partId) => ({
partId,
userId: actor?.id ?? null,
type: 'REPAIR_STARTED',
data: [
{
partId: broken.id,
userId: actor.id,
type: 'PART_SWAPPED',
field: 'role',
oldValue: 'DEPLOYED',
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;
}
}
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 })),
});
}
}
const repair = await tx.repairJob.update({
where: { id },
data,
include: repairInclude,
});
const userId = actor?.id ?? null;
if (addedPartIds.length > 0) {
await tx.partEvent.createMany({
data: addedPartIds.map((partId) => ({
partId,
userId,
type: 'REPAIR_STARTED',
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,
})),
});
}
}
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',
{
partId: replacement.id,
userId: actor.id,
type: 'PART_SWAPPED',
field: 'role',
oldValue: 'SPARE',
newValue: repair.id,
})),
},
],
});
}
return comment;
void emit({ event: 'repair.logged', payload: { repair: repairPayload(repair) } });
return repair;
}
+6 -2
View File
@@ -13,8 +13,10 @@ import PartDetail from './pages/PartDetail.js';
import Locations from './pages/Locations.js';
import Manufacturers from './pages/Manufacturers.js';
import PartModels from './pages/PartModels.js';
import Fms from './pages/Fms.js';
import FmDetail from './pages/FmDetail.js';
import Repairs from './pages/Repairs.js';
import RepairDetail from './pages/RepairDetail.js';
import MyCustody from './pages/MyCustody.js';
import Hosts from './pages/Hosts.js';
import Users from './pages/admin/Users.js';
import Webhooks from './pages/admin/Webhooks.js';
@@ -57,8 +59,10 @@ export default function App() {
<Route path="/locations" element={<Locations />} />
<Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/part-models" element={<PartModels />} />
<Route path="/fms" element={<Fms />} />
<Route path="/fms/:id" element={<FmDetail />} />
<Route path="/repairs" element={<Repairs />} />
<Route path="/repairs/:id" element={<RepairDetail />} />
<Route path="/custody" element={<MyCustody />} />
<Route path="/hosts" element={<Hosts />} />
<Route
path="/admin/users"
@@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { listBins } from '../../lib/api/bins.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { Part } from '../../lib/api/types.js';
const UNASSIGNED = '__none__';
interface DropOffDialogProps {
part: Part | null;
onOpenChange: (open: boolean) => void;
onConfirm: (binId: string | null) => void;
pending: boolean;
}
export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOffDialogProps) {
const open = Boolean(part);
const [binId, setBinId] = useState<string>('');
useEffect(() => {
if (open) setBinId('');
}, [open]);
const bins = useQuery({
queryKey: queryKeys.bins.list({ pageSize: 100 }),
queryFn: () => listBins({ pageSize: 100 }),
enabled: open,
});
const destruction = part?.state === 'PENDING_DESTRUCTION_IN_CUSTODY';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Drop in bin</DialogTitle>
<DialogDescription>
{destruction
? 'This part is flagged for destruction. It will move to pending-destruction — optionally place it in a destruction bin.'
: `Dropping ${part?.serialNumber ?? ''} into a bin marks it broken.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm">
<Select
value={binId ? binId : UNASSIGNED}
onValueChange={(v) => setBinId(v === UNASSIGNED ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
{bins.data?.data.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.fullPath ?? b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button onClick={() => onConfirm(binId || null)} disabled={pending}>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
Drop off
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -29,35 +29,34 @@ import {
Skeleton,
Textarea,
} from '@vector/ui';
import { createRepair } from '../../lib/api/repairs.js';
import { createFm } from '../../lib/api/fms.js';
import { listHosts, listHostDeployedParts } from '../../lib/api/hosts.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { RepairJob } from '../../lib/api/types.js';
import type { Fm } from '../../lib/api/types.js';
const CreateSchema = z.object({
hostId: z.string().uuid('Pick a host'),
problem: z.string().trim().min(1, 'Describe the problem').max(2000),
problemPartIds: z.array(z.string().uuid()).max(100),
notes: z.string().max(4096).optional(),
});
type CreateValues = z.infer<typeof CreateSchema>;
interface RepairFormDialogProps {
interface FmFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultHostId?: string;
defaultProblemPartIds?: string[];
onCreated?: (repair: RepairJob) => void;
onCreated?: (fm: Fm) => void;
}
export function RepairFormDialog({
export function FmFormDialog({
open,
onOpenChange,
defaultHostId,
defaultProblemPartIds,
onCreated,
}: RepairFormDialogProps) {
}: FmFormDialogProps) {
const queryClient = useQueryClient();
const hostsQuery = useQuery({
@@ -68,12 +67,7 @@ export function RepairFormDialog({
const form = useForm<CreateValues>({
resolver: zodResolver(CreateSchema),
defaultValues: {
hostId: '',
problem: '',
problemPartIds: [],
notes: '',
},
defaultValues: { hostId: '', problem: '', problemPartIds: [] },
});
useEffect(() => {
@@ -82,7 +76,6 @@ export function RepairFormDialog({
hostId: defaultHostId ?? '',
problem: '',
problemPartIds: defaultProblemPartIds ?? [],
notes: '',
});
}, [open, defaultHostId, defaultProblemPartIds, form]);
@@ -96,19 +89,18 @@ export function RepairFormDialog({
const createMutation = useMutation({
mutationFn: async (values: CreateValues) =>
createRepair({
createFm({
hostId: values.hostId,
problem: values.problem,
problemPartIds:
values.problemPartIds.length > 0 ? values.problemPartIds : undefined,
notes: values.notes ? values.notes : null,
}),
onSuccess: (repair) => {
toast.success('Repair opened');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
onSuccess: (fm) => {
toast.success('FM opened');
queryClient.invalidateQueries({ queryKey: queryKeys.fms.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
onOpenChange(false);
onCreated?.(repair);
onCreated?.(fm);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
@@ -127,9 +119,9 @@ export function RepairFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Open repair</DialogTitle>
<DialogTitle>Open FM</DialogTitle>
<DialogDescription>
Create a repair against a host. Select the deployed parts involved (optional).
Open a Future Maintenance item against a host. Select deployed parts involved (optional).
</DialogDescription>
</DialogHeader>
@@ -147,9 +139,7 @@ export function RepairFormDialog({
<Select
onValueChange={(v) => {
field.onChange(v);
form.setValue('problemPartIds', [], {
shouldValidate: false,
});
form.setValue('problemPartIds', [], { shouldValidate: false });
}}
value={field.value}
>
@@ -224,9 +214,7 @@ export function RepairFormDialog({
htmlFor={`pp-${part.id}`}
className="flex-1 cursor-pointer select-none"
>
<span className="font-mono text-xs">
{part.serialNumber}
</span>{' '}
<span className="font-mono text-xs">{part.serialNumber}</span>{' '}
<span className="text-xs text-muted-foreground">
{part.partModel.mpn}
</span>
@@ -243,20 +231,6 @@ export function RepairFormDialog({
/>
)}
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes (optional)</FormLabel>
<FormControl>
<Textarea rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
@@ -268,7 +242,7 @@ export function RepairFormDialog({
</Button>
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
Open repair
Open FM
</Button>
</DialogFooter>
</form>
+5 -1
View File
@@ -1,8 +1,10 @@
import { NavLink } from 'react-router-dom';
import {
ArrowRightLeft,
Boxes,
ChevronsLeft,
ChevronsRight,
Hand,
LayoutDashboard,
Layers,
type LucideIcon,
@@ -29,7 +31,9 @@ const NAV_ITEMS: NavItem[] = [
{ to: '/part-models', label: 'Part models', icon: Layers },
{ to: '/locations', label: 'Locations', icon: MapPinned },
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
{ to: '/repairs', label: 'Repairs', icon: Wrench },
{ to: '/fms', label: 'FMs', icon: Wrench },
{ to: '/repairs', label: 'Repairs', icon: ArrowRightLeft },
{ to: '/custody', label: 'My Custody', icon: Hand },
{ to: '/hosts', label: 'Hosts', icon: Server },
{ to: '/admin/users', label: 'Users', icon: UsersIcon, adminOnly: true },
{ to: '/admin/webhooks', label: 'Webhooks', icon: Webhook, adminOnly: true },
@@ -7,6 +7,7 @@ import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
@@ -42,6 +43,7 @@ const Schema = z.object({
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
.or(z.literal(''))
.optional(),
destroyOnFail: z.boolean(),
notes: z.string().max(4096).optional(),
});
type Values = z.infer<typeof Schema>;
@@ -67,7 +69,13 @@ export function PartModelFormDialog({
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { manufacturerId: '', mpn: '', eolDate: '', notes: '' },
defaultValues: {
manufacturerId: '',
mpn: '',
eolDate: '',
destroyOnFail: false,
notes: '',
},
});
useEffect(() => {
@@ -76,6 +84,7 @@ export function PartModelFormDialog({
manufacturerId: partModel?.manufacturerId ?? '',
mpn: partModel?.mpn ?? '',
eolDate: isoToDateInput(partModel?.eolDate ?? null),
destroyOnFail: partModel?.destroyOnFail ?? false,
notes: partModel?.notes ?? '',
});
}, [open, partModel, form]);
@@ -92,6 +101,7 @@ export function PartModelFormDialog({
manufacturerId: values.manufacturerId,
mpn: values.mpn,
eolDate: values.eolDate ? values.eolDate : null,
destroyOnFail: values.destroyOnFail,
notes: values.notes ? values.notes : null,
};
return editing && partModel
@@ -172,6 +182,29 @@ export function PartModelFormDialog({
</FormItem>
)}
/>
<FormField
control={form.control}
name="destroyOnFail"
render={({ field }) => (
<FormItem className="flex items-start gap-2 space-y-0">
<FormControl>
<Checkbox
id="destroyOnFail"
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
className="mt-0.5"
/>
</FormControl>
<div className="space-y-0.5">
<FormLabel htmlFor="destroyOnFail">Destroy on fail</FormLabel>
<FormDescription>
When this model fails, its broken part goes to the destruction path instead
of being held for return/repair.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
@@ -2,14 +2,13 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
ArrowRightLeft,
CheckCircle2,
MapPin,
MessageSquare,
Package,
Pencil,
Tag,
Wrench,
XCircle,
type LucideIcon,
} from 'lucide-react';
import type { PartEventType } from '@vector/shared';
@@ -22,10 +21,9 @@ const EVENT_ICON: Record<PartEventType, LucideIcon> = {
STATE_CHANGED: CheckCircle2,
LOCATION_CHANGED: MapPin,
FIELD_UPDATED: Pencil,
REPAIR_STARTED: Wrench,
REPAIR_COMPLETED: Wrench,
REPAIR_CANCELLED: XCircle,
REPAIR_COMMENTED: MessageSquare,
FM_OPENED: Wrench,
FM_CLOSED: Wrench,
PART_SWAPPED: ArrowRightLeft,
TAG_ADDED: Tag,
TAG_REMOVED: Tag,
};
@@ -35,10 +33,9 @@ const EVENT_TITLE: Record<PartEventType, string> = {
STATE_CHANGED: 'State changed',
LOCATION_CHANGED: 'Location changed',
FIELD_UPDATED: 'Field updated',
REPAIR_STARTED: 'Repair started',
REPAIR_COMPLETED: 'Repair completed',
REPAIR_CANCELLED: 'Repair cancelled',
REPAIR_COMMENTED: 'Repair comment',
FM_OPENED: 'FM opened',
FM_CLOSED: 'FM closed',
PART_SWAPPED: 'Part swapped',
TAG_ADDED: 'Tag added',
TAG_REMOVED: 'Tag removed',
};
@@ -294,7 +294,18 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
/>
</div>
{watchedState === 'DEPLOYED' ? (
{watchedState === 'PENDING_DROP_IN_CUSTODY' ||
watchedState === 'PENDING_DESTRUCTION_IN_CUSTODY' ? (
<div className="space-y-1">
<div className="text-sm font-medium">Location</div>
<div className="inline-flex items-center rounded-md border border-border bg-muted/50 px-2.5 py-1 text-xs">
In custody: {part?.custodian?.username ?? '—'}
</div>
<p className="text-xs text-muted-foreground">
Drop-off happens through the My Custody page.
</p>
</div>
) : watchedState === 'DEPLOYED' ? (
<FormField
control={form.control}
name="hostId"
@@ -1,53 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Skeleton } from '@vector/ui';
import { listRepairs } from '../../lib/api/repairs.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { RepairStatusBadge } from '../repairs/RepairStatusBadge.js';
interface PartRepairSectionProps {
partId: string;
}
export function PartRepairSection({ partId }: PartRepairSectionProps) {
const query = useQuery({
queryKey: queryKeys.repairs.list({ problemPartId: partId, pageSize: 50 }),
queryFn: () => listRepairs({ problemPartId: partId, pageSize: 50 }),
});
return (
<div className="space-y-2">
<p className="text-sm font-medium">Repairs touching this part</p>
{query.isPending ? (
<Skeleton className="h-16 w-full" />
) : !query.data || query.data.data.length === 0 ? (
<p className="text-xs text-muted-foreground">No repairs reference this part yet.</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border text-sm">
{query.data.data.map((repair) => (
<li
key={repair.id}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex min-w-0 items-center gap-2">
<RepairStatusBadge status={repair.status} />
<Link
to={`/repairs/${repair.id}`}
className="truncate text-xs text-foreground hover:underline"
>
{repair.problem}
</Link>
<span className="shrink-0 text-xs text-muted-foreground">
· {repair.host.name}
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(repair.openedAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
)}
</div>
);
}
@@ -6,6 +6,8 @@ const STATE_LABEL: Record<PartState, string> = {
DEPLOYED: 'Deployed',
BROKEN: 'Broken',
PENDING_DESTRUCTION: 'Pending destruction',
PENDING_DROP_IN_CUSTODY: 'In custody',
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
};
const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
@@ -13,12 +15,16 @@ const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
DEPLOYED: 'success',
BROKEN: 'warning',
PENDING_DESTRUCTION: 'destructive',
PENDING_DROP_IN_CUSTODY: 'outline',
PENDING_DESTRUCTION_IN_CUSTODY: 'outline',
};
export function PartStateBadge({ state }: { state: PartState }) {
return <Badge variant={STATE_VARIANT[state]}>{STATE_LABEL[state]}</Badge>;
}
// Options users can set via the Part form. Custody states are intentionally excluded —
// they're only reached via the Repair flow, then unwound via the Custody drop-off page.
export const partStateOptions: { value: PartState; label: string }[] = [
{ value: 'SPARE', label: 'Spare' },
{ value: 'DEPLOYED', label: 'Deployed' },
@@ -0,0 +1,312 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { logRepair } from '../../lib/api/repairs.js';
import { listHosts } from '../../lib/api/hosts.js';
import { listManufacturers } from '../../lib/api/manufacturers.js';
import { listFms } from '../../lib/api/fms.js';
import { listParts } from '../../lib/api/parts.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { Repair } from '../../lib/api/types.js';
const Schema = z.object({
hostId: z.string().uuid('Pick a host'),
brokenSerial: z.string().trim().min(1, 'Required').max(128),
brokenMpn: z.string().trim().min(1, 'Required').max(128),
brokenManufacturerId: z.string().uuid('Select a manufacturer'),
replacementSerial: z.string().trim().min(1, 'Required').max(128),
fmId: z.string().optional(),
});
type Values = z.infer<typeof Schema>;
const NO_FM = '__none__';
interface LogRepairDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onLogged?: (repair: Repair) => void;
}
export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialogProps) {
const queryClient = useQueryClient();
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: {
hostId: '',
brokenSerial: '',
brokenMpn: '',
brokenManufacturerId: '',
replacementSerial: '',
fmId: '',
},
});
useEffect(() => {
if (!open) return;
form.reset({
hostId: '',
brokenSerial: '',
brokenMpn: '',
brokenManufacturerId: '',
replacementSerial: '',
fmId: '',
});
}, [open, form]);
const hostId = form.watch('hostId');
const brokenSerial = form.watch('brokenSerial').trim();
const hosts = useQuery({
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
queryFn: () => listHosts({ pageSize: 100 }),
enabled: open,
});
const manufacturers = useQuery({
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }),
enabled: open,
});
// Open FMs on the chosen host, so the optional linker only shows relevant items.
const openFms = useQuery({
queryKey: queryKeys.fms.list({ hostId, status: 'OPEN', pageSize: 50 }),
queryFn: () => listFms({ hostId, status: 'OPEN', pageSize: 50 }),
enabled: open && Boolean(hostId),
});
// Debounced live lookup — as the tech types a broken serial, hint whether Vector
// already knows that part (existing) or will auto-ingest it (new).
const brokenLookup = useQuery({
queryKey: queryKeys.parts.list({ serialNumber: brokenSerial, pageSize: 1 }),
queryFn: () => listParts({ serialNumber: brokenSerial, pageSize: 1 }),
enabled: open && brokenSerial.length >= 3,
staleTime: 5_000,
});
const existingBroken = brokenLookup.data?.data.find(
(p) => p.serialNumber === brokenSerial,
);
const mutation = useMutation({
mutationFn: (v: Values) =>
logRepair({
hostId: v.hostId,
brokenSerial: v.brokenSerial.trim(),
brokenMpn: v.brokenMpn.trim(),
brokenManufacturerId: v.brokenManufacturerId,
replacementSerial: v.replacementSerial.trim(),
fmId: v.fmId ? v.fmId : undefined,
}),
onSuccess: (repair) => {
toast.success('Repair logged');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
onOpenChange(false);
onLogged?.(repair);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not log repair'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Log a repair</DialogTitle>
<DialogDescription>
Record a physical part swap. The broken part goes into your custody until you drop it
in a bin from the My Custody page.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((v) => mutation.mutate(v))}
className="space-y-3"
>
<FormField
control={form.control}
name="hostId"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select host" />
</SelectTrigger>
</FormControl>
<SelectContent>
{hosts.data?.data.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.assetId} {h.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="brokenSerial"
render={({ field }) => (
<FormItem>
<FormLabel>Broken serial</FormLabel>
<FormControl>
<Input autoFocus placeholder="SN-…" {...field} />
</FormControl>
{brokenSerial.length >= 3 && (
<FormDescription>
{brokenLookup.isFetching
? 'Looking up…'
: existingBroken
? `Found: ${existingBroken.partModel.mpn}`
: 'Will be ingested as a new part.'}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="replacementSerial"
render={({ field }) => (
<FormItem>
<FormLabel>Replacement serial</FormLabel>
<FormControl>
<Input placeholder="SN-…" {...field} />
</FormControl>
<FormDescription>Must be an existing SPARE.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="brokenMpn"
render={({ field }) => (
<FormItem>
<FormLabel>Broken MPN</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="brokenManufacturerId"
render={({ field }) => (
<FormItem>
<FormLabel>Broken manufacturer</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
</FormControl>
<SelectContent>
{manufacturers.data?.data.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="fmId"
render={({ field }) => (
<FormItem>
<FormLabel>Link to open FM (optional)</FormLabel>
<Select
value={field.value ? field.value : NO_FM}
onValueChange={(v) => field.onChange(v === NO_FM ? '' : v)}
disabled={!hostId}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={hostId ? 'No linked FM' : 'Pick a host first'} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NO_FM}>No linked FM</SelectItem>
{openFms.data?.data.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.problem.slice(0, 80)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Linking doesn't auto-close the FM n8n handles that.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Log repair
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -1,118 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Send } from 'lucide-react';
import { toast } from 'sonner';
import { Button, Skeleton, Textarea } from '@vector/ui';
import { addRepairComment, listRepairComments } from '../../lib/api/repairs.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
function formatWhen(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function initialsOf(username: string | undefined | null): string {
if (!username) return '?';
return username.slice(0, 2).toUpperCase();
}
export function RepairCommentThread({ repairId }: { repairId: string }) {
const queryClient = useQueryClient();
const [content, setContent] = useState('');
const query = useQuery({
queryKey: queryKeys.repairs.comments(repairId),
queryFn: () => listRepairComments(repairId, { pageSize: 100 }),
});
const mutation = useMutation({
mutationFn: (body: string) => addRepairComment(repairId, { content: body }),
onSuccess: () => {
setContent('');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.comments(repairId) });
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Post failed'),
});
const submit = () => {
const trimmed = content.trim();
if (!trimmed) return;
mutation.mutate(trimmed);
};
return (
<div className="space-y-4">
{query.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : query.isError ? (
<p className="text-sm text-destructive">Could not load comments.</p>
) : query.data && query.data.data.length > 0 ? (
<ol className="space-y-3">
{query.data.data.map((c) => (
<li key={c.id} className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-[10px] font-semibold text-accent-foreground">
{initialsOf(c.user?.username)}
</div>
<div className="flex-1 space-y-0.5">
<div className="flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{c.user?.username ?? 'Unknown user'}
</span>
<span>·</span>
<span>{formatWhen(c.createdAt)}</span>
</div>
<p className="whitespace-pre-wrap text-sm text-foreground">{c.content}</p>
</div>
</li>
))}
</ol>
) : (
<p className="text-sm text-muted-foreground">No comments yet. Start the thread below.</p>
)}
<div className="space-y-2">
<Textarea
rows={3}
placeholder="Leave a comment..."
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={mutation.isPending}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{content.length}/4000 · Ctrl/+Enter to post
</span>
<Button
size="sm"
onClick={submit}
disabled={mutation.isPending || content.trim().length === 0}
>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
Post
</Button>
</div>
</div>
</div>
);
}
@@ -1,24 +0,0 @@
import type { RepairStatus } from '@vector/shared';
import { Badge } from '@vector/ui';
const LABELS: Record<RepairStatus, string> = {
PENDING: 'Pending',
IN_PROGRESS: 'In progress',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
};
const VARIANTS: Record<RepairStatus, 'outline' | 'warning' | 'success' | 'secondary'> = {
PENDING: 'outline',
IN_PROGRESS: 'warning',
COMPLETED: 'success',
CANCELLED: 'secondary',
};
export const repairStatusOptions: { value: RepairStatus; label: string }[] = (
Object.keys(LABELS) as RepairStatus[]
).map((value) => ({ value, label: LABELS[value] }));
export function RepairStatusBadge({ status }: { status: RepairStatus }) {
return <Badge variant={VARIANTS[status]}>{LABELS[status]}</Badge>;
}
+13
View File
@@ -0,0 +1,13 @@
import type { DropOffRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Part } from './types.js';
export function listMyCustody(filters: { page?: number; pageSize?: number } = {}) {
return getList<Part>('/custody/mine', filters);
}
export async function dropOff(partId: string, input: DropOffRequest): Promise<Part> {
const res = await api.post<Part>(`/custody/${partId}/drop-off`, input);
return res.data;
}
+36
View File
@@ -0,0 +1,36 @@
import type { CreateFmRequest, FmStatus, UpdateFmRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Fm } from './types.js';
export type FmListFilters = {
page?: number;
pageSize?: number;
status?: FmStatus;
hostId?: string;
problemPartId?: string;
openOnly?: boolean;
};
export function listFms(filters: FmListFilters = {}) {
return getList<Fm>('/fms', filters);
}
export async function getFm(id: string): Promise<Fm> {
const res = await api.get<Fm>(`/fms/${id}`);
return res.data;
}
export async function createFm(input: CreateFmRequest): Promise<Fm> {
const res = await api.post<Fm>('/fms', input);
return res.data;
}
export async function updateFm(id: string, input: UpdateFmRequest): Promise<Fm> {
const res = await api.patch<Fm>(`/fms/${id}`, input);
return res.data;
}
export async function deleteFm(id: string): Promise<void> {
await api.delete(`/fms/${id}`);
}
+2
View File
@@ -18,6 +18,8 @@ export type PartListFilters = {
binId?: string;
tagId?: string;
eolOnly?: boolean;
serialNumber?: string;
custodianId?: string;
};
export function listParts(filters: PartListFilters) {
+10 -43
View File
@@ -1,60 +1,27 @@
import type {
CreateRepairCommentRequest,
CreateRepairJobRequest,
RepairStatus,
UpdateRepairJobRequest,
} from '@vector/shared';
import type { LogRepairRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { RepairComment, RepairJob } from './types.js';
import type { Repair } from './types.js';
export type RepairListFilters = {
page?: number;
pageSize?: number;
status?: RepairStatus;
hostId?: string;
problemPartId?: string;
assigneeId?: string;
openOnly?: boolean;
performedById?: string;
fmId?: string;
since?: string;
};
export function listRepairs(filters: RepairListFilters = {}) {
return getList<RepairJob>('/repairs', filters);
return getList<Repair>('/repairs', filters);
}
export async function getRepair(id: string): Promise<RepairJob> {
const res = await api.get<RepairJob>(`/repairs/${id}`);
export async function getRepair(id: string): Promise<Repair> {
const res = await api.get<Repair>(`/repairs/${id}`);
return res.data;
}
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
const res = await api.post<RepairJob>('/repairs', input);
return res.data;
}
export async function updateRepair(
id: string,
input: UpdateRepairJobRequest,
): Promise<RepairJob> {
const res = await api.patch<RepairJob>(`/repairs/${id}`, input);
return res.data;
}
export async function deleteRepair(id: string): Promise<void> {
await api.delete(`/repairs/${id}`);
}
export function listRepairComments(
id: string,
filters: { page?: number; pageSize?: number } = {},
) {
return getList<RepairComment>(`/repairs/${id}/comments`, filters);
}
export async function addRepairComment(
id: string,
input: CreateRepairCommentRequest,
): Promise<RepairComment> {
const res = await api.post<RepairComment>(`/repairs/${id}/comments`, input);
export async function logRepair(input: LogRepairRequest): Promise<Repair> {
const res = await api.post<Repair>('/repairs', input);
return res.data;
}
+23 -15
View File
@@ -1,4 +1,4 @@
import type { PartEventType, PartState, RepairStatus, Role } from '@vector/shared';
import type { FmStatus, PartEventType, PartState, Role } from '@vector/shared';
// Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
// Keep these in sync with apps/api/src/services responses.
@@ -15,6 +15,7 @@ export interface PartModel {
manufacturerId: string;
mpn: string;
eolDate: string | null;
destroyOnFail: boolean;
notes: string | null;
createdAt: string;
updatedAt: string;
@@ -60,6 +61,7 @@ export interface Part {
binId: string | null;
categoryId: string | null;
hostId: string | null;
custodianId: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
@@ -67,6 +69,7 @@ export interface Part {
partModel: PartModel;
bin: BinWithPath | null;
host: Host | null;
custodian: Pick<User, 'id' | 'username'> | null;
}
export interface PartEvent {
@@ -115,42 +118,47 @@ export interface Category {
updatedAt: string;
}
export interface RepairJobProblemPart {
repairJobId: string;
export interface FmProblemPart {
fmId: string;
partId: string;
createdAt: string;
part: Part;
}
export interface RepairJob {
export interface Fm {
id: string;
hostId: string;
assigneeId: string | null;
status: RepairStatus;
status: FmStatus;
problem: string;
notes: string | null;
openedAt: string;
closedAt: string | null;
createdAt: string;
updatedAt: string;
host: Host;
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
problemParts: RepairJobProblemPart[];
problemParts: FmProblemPart[];
}
export interface RepairComment {
export interface Repair {
id: string;
repairJobId: string;
userId: string | null;
content: string;
hostId: string;
brokenPartId: string;
replacementPartId: string;
performedById: string;
performedAt: string;
fmId: string | null;
createdAt: string;
user: Pick<User, 'id' | 'username'> | null;
updatedAt: string;
host: Host;
brokenPart: Part;
replacement: Part;
performedBy: Pick<User, 'id' | 'username'>;
fm: { id: string; status: FmStatus } | null;
}
export interface SavedView {
id: string;
userId: string;
resource: 'parts' | 'repairs' | 'hosts' | 'manufacturers';
resource: 'parts' | 'fms' | 'repairs' | 'hosts' | 'manufacturers';
name: string;
filterJson: unknown;
createdAt: string;
+11 -1
View File
@@ -49,12 +49,22 @@ export const queryKeys = {
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
},
fms: {
all: ['fms'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.fms.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.fms.all, 'detail', id] as const,
},
repairs: {
all: ['repairs'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.repairs.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const,
comments: (id: string) => [...queryKeys.repairs.all, 'comments', id] as const,
},
custody: {
all: ['custody'] as const,
mine: (filters?: Record<string, unknown>) =>
[...queryKeys.custody.all, 'mine', filters ?? {}] as const,
},
partModels: {
all: ['part-models'] as const,
+7 -3
View File
@@ -33,6 +33,8 @@ const STATE_LABELS: Record<PartState, string> = {
DEPLOYED: 'Deployed',
BROKEN: 'Broken',
PENDING_DESTRUCTION: 'Pending destruction',
PENDING_DROP_IN_CUSTODY: 'In custody',
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
};
const STATE_COLORS: Record<PartState, string> = {
@@ -40,6 +42,8 @@ const STATE_COLORS: Record<PartState, string> = {
DEPLOYED: 'hsl(142 71% 45%)',
BROKEN: 'hsl(0 84% 60%)',
PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)',
PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)',
};
function currency(cents: number): string {
@@ -90,9 +94,9 @@ export default function Dashboard() {
/>
<KpiCard
icon={<Wrench className="h-4 w-4" />}
label="Open repairs"
value={data.openRepairs.toLocaleString()}
href="/repairs"
label="Open FMs"
value={data.openFms.toLocaleString()}
href="/fms"
/>
<KpiCard
label="Deployed value"
@@ -1,19 +1,10 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Check,
Loader2,
Pencil,
Plus,
Server,
Trash2,
X,
} from 'lucide-react';
import { ArrowLeft, Check, Loader2, Pencil, Plus, Server, Trash2, X } from 'lucide-react';
import { toast } from 'sonner';
import type { RepairStatus } from '@vector/shared';
import {
Badge,
Button,
Card,
CardContent,
@@ -21,45 +12,38 @@ import {
CardHeader,
CardTitle,
Checkbox,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Skeleton,
Textarea,
} from '@vector/ui';
import { getRepair, updateRepair } from '../lib/api/repairs.js';
import { getFm, updateFm } from '../lib/api/fms.js';
import { listHostDeployedParts } from '../lib/api/hosts.js';
import { ApiRequestError } from '../lib/api/client.js';
import { queryKeys } from '../lib/queryKeys.js';
import type { RepairJob } from '../lib/api/types.js';
import { RepairStatusBadge, repairStatusOptions } from '../components/repairs/RepairStatusBadge.js';
import type { Fm } from '../lib/api/types.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { RepairCommentThread } from '../components/repairs/RepairCommentThread.js';
export default function RepairDetail() {
export default function FmDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: repair, isPending, isError, error } = useQuery({
queryKey: queryKeys.repairs.detail(id!),
queryFn: () => getRepair(id!),
const { data: fm, isPending, isError, error } = useQuery({
queryKey: queryKeys.fms.detail(id!),
queryFn: () => getFm(id!),
enabled: Boolean(id),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.detail(id!) });
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.list() });
queryClient.invalidateQueries({ queryKey: queryKeys.fms.detail(id!) });
queryClient.invalidateQueries({ queryKey: queryKeys.fms.list() });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
};
const statusMutation = useMutation({
mutationFn: (status: RepairStatus) => updateRepair(id!, { status }),
const toggleMutation = useMutation({
mutationFn: () => updateFm(id!, { status: fm?.status === 'OPEN' ? 'CLOSED' : 'OPEN' }),
onSuccess: () => {
toast.success('Status updated');
toast.success(fm?.status === 'OPEN' ? 'FM closed' : 'FM reopened');
invalidate();
},
onError: (err) =>
@@ -76,25 +60,25 @@ export default function RepairDetail() {
);
}
if (isError || !repair) {
const msg = error instanceof ApiRequestError ? error.body.message : 'Repair not found.';
if (isError || !fm) {
const msg = error instanceof ApiRequestError ? error.body.message : 'FM not found.';
return (
<Card>
<CardHeader>
<CardTitle>Repair unavailable</CardTitle>
<CardTitle>FM unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/repairs')}>
<Button variant="outline" onClick={() => navigate('/fms')}>
<ArrowLeft className="h-4 w-4" />
Back to repairs
Back to FMs
</Button>
</CardContent>
</Card>
);
}
const terminal = repair.status === 'COMPLETED' || repair.status === 'CANCELLED';
const closed = fm.status === 'CLOSED';
return (
<div className="space-y-5">
@@ -103,99 +87,78 @@ export default function RepairDetail() {
<Button
variant="ghost"
size="icon"
onClick={() => navigate('/repairs')}
onClick={() => navigate('/fms')}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
Asset
</span>
<span className="text-xs uppercase tracking-wide text-muted-foreground">Asset</span>
<span className="font-mono text-2xl font-semibold tracking-tight text-foreground">
{repair.host.assetId}
{fm.host.assetId}
</span>
<RepairStatusBadge status={repair.status} />
<Badge variant={closed ? 'secondary' : 'warning'}>
{closed ? 'Closed' : 'Open'}
</Badge>
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Server className="h-3 w-3" />
<span>{repair.host.name}</span>
{repair.host.location && <span>· {repair.host.location}</span>}
<span>{fm.host.name}</span>
{fm.host.location && <span>· {fm.host.location}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Select
value={repair.status}
onValueChange={(v) => statusMutation.mutate(v as RepairStatus)}
disabled={statusMutation.isPending}
<Button
variant={closed ? 'outline' : 'default'}
onClick={() => toggleMutation.mutate()}
disabled={toggleMutation.isPending}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{repairStatusOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{toggleMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : closed ? (
<Pencil className="h-4 w-4" />
) : (
<Check className="h-4 w-4" />
)}
{closed ? 'Reopen' : 'Close FM'}
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1.3fr_1fr]">
<div className="space-y-4">
<ProblemCard repair={repair} onSaved={invalidate} disabled={terminal} />
<ProblemPartsCard repair={repair} onSaved={invalidate} disabled={terminal} />
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Comments</CardTitle>
<CardDescription>
Discuss progress, record findings, tag handoffs.
</CardDescription>
</CardHeader>
<CardContent>
<RepairCommentThread repairId={repair.id} />
</CardContent>
</Card>
<ProblemCard fm={fm} onSaved={invalidate} disabled={closed} />
<ProblemPartsCard fm={fm} onSaved={invalidate} disabled={closed} />
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Timeline</CardTitle>
<CardDescription>
The actual repair work lives in the external ticketing system.
</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-2 text-sm md:grid-cols-4">
<Field label="Opened" value={new Date(repair.openedAt).toLocaleString()} />
<dl className="grid grid-cols-2 gap-2 text-sm">
<Field label="Opened" value={new Date(fm.openedAt).toLocaleString()} />
<Field
label="Closed"
value={
repair.closedAt ? new Date(repair.closedAt).toLocaleString() : '—'
}
value={fm.closedAt ? new Date(fm.closedAt).toLocaleString() : '—'}
/>
<Field
label="Assignee"
value={repair.assignee?.username ?? '—'}
/>
<Field label="Updated" value={new Date(repair.updatedAt).toLocaleString()} />
<Field label="Updated" value={new Date(fm.updatedAt).toLocaleString()} />
</dl>
{repair.notes && (
<>
<Separator className="my-3" />
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">Notes</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{repair.notes}</p>
</div>
</>
)}
<p className="text-xs text-muted-foreground">
Opened and closed events fire <code className="font-mono">fm.opened</code> and{' '}
<code className="font-mono">fm.closed</code> webhooks.
</p>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -209,23 +172,23 @@ function Field({ label, value }: { label: string; value: React.ReactNode }) {
}
function ProblemCard({
repair,
fm,
onSaved,
disabled,
}: {
repair: { id: string; problem: string };
fm: { id: string; problem: string };
onSaved: () => void;
disabled: boolean;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(repair.problem);
const [value, setValue] = useState(fm.problem);
useEffect(() => {
setValue(repair.problem);
}, [repair.problem]);
setValue(fm.problem);
}, [fm.problem]);
const mutation = useMutation({
mutationFn: (problem: string) => updateRepair(repair.id, { problem }),
mutationFn: (problem: string) => updateFm(fm.id, { problem }),
onSuccess: () => {
toast.success('Problem updated');
setEditing(false);
@@ -260,7 +223,7 @@ function ProblemCard({
variant="outline"
size="sm"
onClick={() => {
setValue(repair.problem);
setValue(fm.problem);
setEditing(false);
}}
disabled={mutation.isPending}
@@ -274,7 +237,7 @@ function ProblemCard({
disabled={
mutation.isPending ||
value.trim().length === 0 ||
value.trim() === repair.problem
value.trim() === fm.problem
}
>
{mutation.isPending ? (
@@ -287,7 +250,7 @@ function ProblemCard({
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-sm text-foreground">{repair.problem}</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{fm.problem}</p>
)}
</CardContent>
</Card>
@@ -295,11 +258,11 @@ function ProblemCard({
}
function ProblemPartsCard({
repair,
fm,
onSaved,
disabled,
}: {
repair: RepairJob;
fm: Fm;
onSaved: () => void;
disabled: boolean;
}) {
@@ -307,20 +270,19 @@ function ProblemPartsCard({
const [draft, setDraft] = useState<string[]>([]);
const deployedQuery = useQuery({
queryKey: queryKeys.hosts.deployedParts(repair.hostId),
queryFn: () => listHostDeployedParts(repair.hostId),
queryKey: queryKeys.hosts.deployedParts(fm.hostId),
queryFn: () => listHostDeployedParts(fm.hostId),
enabled: picking,
});
useEffect(() => {
if (picking) {
setDraft(repair.problemParts.map((pp) => pp.partId));
setDraft(fm.problemParts.map((pp) => pp.partId));
}
}, [picking, repair.problemParts]);
}, [picking, fm.problemParts]);
const mutation = useMutation({
mutationFn: (problemPartIds: string[]) =>
updateRepair(repair.id, { problemPartIds }),
mutationFn: (problemPartIds: string[]) => updateFm(fm.id, { problemPartIds }),
onSuccess: () => {
toast.success('Problem parts updated');
setPicking(false);
@@ -332,8 +294,8 @@ function ProblemPartsCard({
const removeMutation = useMutation({
mutationFn: (partId: string) => {
const next = repair.problemParts.map((pp) => pp.partId).filter((pid) => pid !== partId);
return updateRepair(repair.id, { problemPartIds: next });
const next = fm.problemParts.map((pp) => pp.partId).filter((pid) => pid !== partId);
return updateFm(fm.id, { problemPartIds: next });
},
onSuccess: () => {
toast.success('Part removed');
@@ -354,9 +316,7 @@ function ProblemPartsCard({
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="text-base">Problem parts</CardTitle>
<CardDescription>
Deployed parts on this host involved in the issue.
</CardDescription>
<CardDescription>Deployed parts on this host involved in the issue.</CardDescription>
</div>
{!picking && !disabled && (
<Button variant="outline" size="sm" onClick={() => setPicking(true)}>
@@ -380,17 +340,14 @@ function ProblemPartsCard({
{deployedQuery.data.map((part) => {
const checked = draft.includes(part.id);
return (
<li
key={part.id}
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<li key={part.id} className="flex items-center gap-2 px-3 py-2 text-sm">
<Checkbox
id={`rd-pp-${part.id}`}
id={`fd-pp-${part.id}`}
checked={checked}
onCheckedChange={(v) => toggle(part.id, v === true)}
/>
<label
htmlFor={`rd-pp-${part.id}`}
htmlFor={`fd-pp-${part.id}`}
className="flex-1 cursor-pointer select-none"
>
<span className="font-mono text-xs">{part.serialNumber}</span>{' '}
@@ -428,13 +385,13 @@ function ProblemPartsCard({
</Button>
</div>
</div>
) : repair.problemParts.length === 0 ? (
) : fm.problemParts.length === 0 ? (
<p className="text-sm text-muted-foreground">
No specific parts tagged the repair is against the host itself.
No specific parts tagged the FM is against the host itself.
</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border">
{repair.problemParts.map((pp) => (
{fm.problemParts.map((pp) => (
<li
key={pp.partId}
className="flex items-center justify-between gap-3 px-3 py-2"
+232
View File
@@ -0,0 +1,232 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { parseAsString } from 'nuqs';
import { toast } from 'sonner';
import { MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
import type { FmStatus } from '@vector/shared';
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { FmFormDialog } from '../components/fms/FmFormDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { deleteFm, listFms } from '../lib/api/fms.js';
import { ApiRequestError } from '../lib/api/client.js';
import type { Fm } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js';
type FmFilters = {
status: string | null;
};
const filterParsers = {
status: parseAsString,
};
const ALL = '__all__';
const STATUS_OPTIONS: { value: FmStatus; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'CLOSED', label: 'Closed' },
];
function FmStatusBadge({ status }: { status: FmStatus }) {
return (
<Badge variant={status === 'OPEN' ? 'warning' : 'secondary'}>
{status === 'OPEN' ? 'Open' : 'Closed'}
</Badge>
);
}
export default function Fms() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false);
const [deleting, setDeleting] = useState<Fm | null>(null);
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteFm(id),
onSuccess: () => {
toast.success('FM removed');
queryClient.invalidateQueries({ queryKey: queryKeys.fms.all });
setDeleting(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
const columns = useMemo<ColumnDef<Fm>[]>(
() => [
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => <FmStatusBadge status={row.original.status} />,
},
{
id: 'assetId',
header: 'Asset ID',
cell: ({ row }) => (
<Link
to={`/fms/${row.original.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{row.original.host.assetId}
</Link>
),
},
{
id: 'host',
header: 'Host',
cell: ({ row }) => <span className="text-sm">{row.original.host.name}</span>,
},
{
id: 'problem',
header: 'Problem',
cell: ({ row }) => (
<Link
to={`/fms/${row.original.id}`}
className="line-clamp-1 max-w-sm text-sm text-foreground hover:underline"
>
{row.original.problem}
</Link>
),
},
{
accessorKey: 'openedAt',
header: 'Opened',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{new Date(row.original.openedAt).toLocaleDateString()}
</span>
),
},
{
accessorKey: 'closedAt',
header: 'Closed',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{row.original.closedAt
? new Date(row.original.closedAt).toLocaleDateString()
: '—'}
</span>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 40,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
],
[],
);
return (
<div className="space-y-5">
<PageHeader
title="FMs"
description="Future Maintenance items open against hosts. n8n handles the ticketing; Vector just tracks open/closed."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Open FM
</Button>
}
/>
<DataTable<Fm, FmFilters>
columns={columns}
getRowId={(r) => r.id}
filterParsers={filterParsers}
queryKey={(params) =>
queryKeys.fms.list({
page: params.page,
pageSize: params.pageSize,
status: params.filters.status,
})
}
queryFn={(params) =>
listFms({
page: params.page,
pageSize: params.pageSize,
status: (params.filters.status ?? undefined) as FmStatus | undefined,
})
}
enableSearch={false}
toolbar={({ filters, setFilter }) => (
<Select
value={filters.status ?? ALL}
onValueChange={(v) => setFilter('status', v === ALL ? null : v)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Any status" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Any status</SelectItem>
{STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
emptyState={
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
<Wrench className="h-6 w-6" />
<span className="text-sm">No FMs yet.</span>
</div>
}
/>
<FmFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={(fm) => navigate(`/fms/${fm.id}`)}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete FM?"
description={
deleting
? `Remove FM "${deleting.problem.slice(0, 60)}" on ${deleting.host.name}. This cannot be undone.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Hand, PackageCheck } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@vector/ui';
import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { DropOffDialog } from '../components/custody/DropOffDialog.js';
import { dropOff, listMyCustody } from '../lib/api/custody.js';
import { ApiRequestError } from '../lib/api/client.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import type { Part } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js';
export default function MyCustody() {
const queryClient = useQueryClient();
const [dropping, setDropping] = useState<Part | null>(null);
const mutation = useMutation({
mutationFn: ({ partId, binId }: { partId: string; binId: string | null }) =>
dropOff(partId, { binId }),
onSuccess: () => {
toast.success('Dropped off');
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
setDropping(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Drop-off failed'),
});
const columns = useMemo<ColumnDef<Part>[]>(
() => [
{
id: 'serial',
header: 'Serial',
cell: ({ row }) => (
<Link
to={`/parts/${row.original.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{row.original.serialNumber}
</Link>
),
},
{
id: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-mono text-xs">{row.original.partModel.mpn}</span>
<span className="text-xs text-muted-foreground">
{row.original.manufacturer.name}
</span>
</div>
),
},
{
id: 'state',
header: 'State',
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
},
{
id: 'since',
header: 'Since',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{new Date(row.original.updatedAt).toLocaleDateString()}
</span>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 140,
cell: ({ row }) => (
<Button size="sm" variant="outline" onClick={() => setDropping(row.original)}>
<PackageCheck className="h-3.5 w-3.5" />
Drop in bin
</Button>
),
},
],
[],
);
return (
<div className="space-y-5">
<PageHeader
title="My Custody"
description="Broken parts you're holding until you drop them in a bin."
/>
<DataTable<Part, Record<string, never>>
columns={columns}
getRowId={(p) => p.id}
queryKey={(params) =>
queryKeys.custody.mine({ page: params.page, pageSize: params.pageSize })
}
queryFn={(params) =>
listMyCustody({ page: params.page, pageSize: params.pageSize })
}
enableSearch={false}
emptyState={
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
<Hand className="h-6 w-6" />
<span className="text-sm">Nothing in your custody.</span>
</div>
}
/>
<DropOffDialog
part={dropping}
onOpenChange={(o) => !o && setDropping(null)}
onConfirm={(binId) =>
dropping && mutation.mutate({ partId: dropping.id, binId })
}
pending={mutation.isPending}
/>
</div>
);
}
+2 -3
View File
@@ -20,7 +20,6 @@ import { useAuth } from '../contexts/AuthContext.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { PartEventTimeline } from '../components/parts/PartEventTimeline.js';
import { PartFormDialog } from '../components/parts/PartFormDialog.js';
import { PartRepairSection } from '../components/parts/PartRepairSection.js';
import { TagPicker } from '../components/tags/TagPicker.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
@@ -170,6 +169,8 @@ export default function PartDetail() {
<span className="font-mono text-xs">
{part.host.assetId} / {part.host.name}
</span>
) : part.custodian ? (
<span className="text-xs">Custody: {part.custodian.username}</span>
) : part.bin?.fullPath ? (
<span className="font-mono text-xs">{part.bin.fullPath}</span>
) : (
@@ -223,8 +224,6 @@ export default function PartDetail() {
<p className="mb-2 text-xs font-medium text-muted-foreground">Tags</p>
<TagPicker partId={part.id} />
</div>
<Separator className="my-3" />
<PartRepairSection partId={part.id} />
</CardContent>
</Card>
+11 -1
View File
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Edit, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { Check, Edit, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
@@ -80,6 +80,16 @@ export default function PartModels() {
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
),
},
{
id: 'destroyOnFail',
header: 'Destroy on fail',
cell: ({ row }) =>
row.original.destroyOnFail ? (
<Check className="h-4 w-4 text-foreground" />
) : (
<span className="text-xs text-muted-foreground"></span>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
+10 -4
View File
@@ -116,7 +116,7 @@ export default function Parts() {
id: 'location',
header: 'Location',
cell: ({ row }) => {
const host = row.original.host;
const { host, custodian, bin } = row.original;
if (host) {
return (
<span className="text-xs font-mono text-muted-foreground">
@@ -124,9 +124,15 @@ export default function Parts() {
</span>
);
}
const path = row.original.bin?.fullPath;
return path ? (
<span className="text-xs font-mono text-muted-foreground">{path}</span>
if (custodian) {
return (
<span className="text-xs text-muted-foreground">
Custody: {custodian.username}
</span>
);
}
return bin?.fullPath ? (
<span className="text-xs font-mono text-muted-foreground">{bin.fullPath}</span>
) : (
<span className="text-xs text-muted-foreground italic">Unassigned</span>
);
+60 -164
View File
@@ -1,142 +1,83 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { parseAsString } from 'nuqs';
import { toast } from 'sonner';
import { MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
import type { RepairStatus } from '@vector/shared';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { ArrowRightLeft, Plus } from 'lucide-react';
import { Button } from '@vector/ui';
import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { RepairFormDialog } from '../components/repairs/RepairFormDialog.js';
import {
RepairStatusBadge,
repairStatusOptions,
} from '../components/repairs/RepairStatusBadge.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { deleteRepair, listRepairs } from '../lib/api/repairs.js';
import { ApiRequestError } from '../lib/api/client.js';
import type { RepairJob } from '../lib/api/types.js';
import { LogRepairDialog } from '../components/repairs/LogRepairDialog.js';
import { listRepairs } from '../lib/api/repairs.js';
import type { Repair } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js';
type RepairFilters = {
status: string | null;
};
const filterParsers = {
status: parseAsString,
};
const ALL = '__all__';
export default function Repairs() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false);
const [deleting, setDeleting] = useState<RepairJob | null>(null);
const [logOpen, setLogOpen] = useState(false);
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRepair(id),
onSuccess: () => {
toast.success('Repair removed');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
setDeleting(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
const columns = useMemo<ColumnDef<RepairJob>[]>(
const columns = useMemo<ColumnDef<Repair>[]>(
() => [
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => <RepairStatusBadge status={row.original.status} />,
},
{
id: 'assetId',
header: 'Asset ID',
id: 'performedAt',
header: 'When',
cell: ({ row }) => (
<Link
to={`/repairs/${row.original.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{row.original.host.assetId}
</Link>
<span className="text-xs text-muted-foreground">
{new Date(row.original.performedAt).toLocaleString()}
</span>
),
},
{
id: 'host',
header: 'Host',
cell: ({ row }) => (
<span className="text-sm">{row.original.host.name}</span>
<div className="flex flex-col">
<span className="font-mono text-xs">{row.original.host.assetId}</span>
<span className="text-xs text-muted-foreground">{row.original.host.name}</span>
</div>
),
},
{
id: 'problem',
header: 'Problem',
id: 'broken',
header: 'Broken',
cell: ({ row }) => (
<Link
to={`/repairs/${row.original.id}`}
className="line-clamp-1 max-w-sm text-sm text-foreground hover:underline"
to={`/parts/${row.original.brokenPart.id}`}
className="font-mono text-xs hover:underline"
>
{row.original.problem}
{row.original.brokenPart.serialNumber}
</Link>
),
},
{
accessorKey: 'openedAt',
header: 'Opened',
id: 'replacement',
header: 'Replacement',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{new Date(row.original.openedAt).toLocaleDateString()}
</span>
),
},
{
accessorKey: 'closedAt',
header: 'Closed',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{row.original.closedAt
? new Date(row.original.closedAt).toLocaleDateString()
: '—'}
</span>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 40,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
<Link
to={`/parts/${row.original.replacement.id}`}
className="font-mono text-xs hover:underline"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{row.original.replacement.serialNumber}
</Link>
),
},
{
id: 'performedBy',
header: 'By',
cell: ({ row }) => (
<span className="text-xs">{row.original.performedBy.username}</span>
),
},
{
id: 'fm',
header: 'FM',
cell: ({ row }) =>
row.original.fmId ? (
<Link
to={`/fms/${row.original.fmId}`}
className="text-xs text-foreground hover:underline"
>
View FM
</Link>
) : (
<span className="text-xs text-muted-foreground"></span>
),
},
],
@@ -147,79 +88,34 @@ export default function Repairs() {
<div className="space-y-5">
<PageHeader
title="Repairs"
description="Open work against hosts. Click a row to view and comment."
description="Physical part swaps. Logging a repair moves the broken part into your custody."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Button onClick={() => setLogOpen(true)}>
<Plus className="h-4 w-4" />
Open repair
Log repair
</Button>
}
/>
<DataTable<RepairJob, RepairFilters>
<DataTable<Repair, Record<string, never>>
columns={columns}
getRowId={(r) => r.id}
filterParsers={filterParsers}
queryKey={(params) =>
queryKeys.repairs.list({
page: params.page,
pageSize: params.pageSize,
status: params.filters.status,
})
queryKeys.repairs.list({ page: params.page, pageSize: params.pageSize })
}
queryFn={(params) =>
listRepairs({
page: params.page,
pageSize: params.pageSize,
status: (params.filters.status ?? undefined) as RepairStatus | undefined,
})
listRepairs({ page: params.page, pageSize: params.pageSize })
}
enableSearch={false}
toolbar={({ filters, setFilter }) => (
<Select
value={filters.status ?? ALL}
onValueChange={(v) => setFilter('status', v === ALL ? null : v)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Any status" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Any status</SelectItem>
{repairStatusOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
emptyState={
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
<Wrench className="h-6 w-6" />
<span className="text-sm">No repair jobs yet.</span>
<ArrowRightLeft className="h-6 w-6" />
<span className="text-sm">No repairs logged yet.</span>
</div>
}
/>
<RepairFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={(repair) => navigate(`/repairs/${repair.id}`)}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete repair?"
description={
deleting
? `Remove repair "${deleting.problem.slice(0, 60)}" on ${deleting.host.name}. This cannot be undone.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
<LogRepairDialog open={logOpen} onOpenChange={setLogOpen} />
</div>
);
}
@@ -0,0 +1,164 @@
/*
Warnings:
- You are about to drop the `RepairComment` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `RepairJob` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `RepairJobPart` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropIndex
DROP INDEX "RepairComment_repairJobId_createdAt_idx";
-- DropIndex
DROP INDEX "RepairJob_status_openedAt_idx";
-- DropIndex
DROP INDEX "RepairJob_assigneeId_idx";
-- DropIndex
DROP INDEX "RepairJob_hostId_idx";
-- DropIndex
DROP INDEX "RepairJob_status_idx";
-- DropIndex
DROP INDEX "RepairJobPart_partId_idx";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "RepairComment";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "RepairJob";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "RepairJobPart";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "fms" (
"id" TEXT NOT NULL PRIMARY KEY,
"hostId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'OPEN',
"problem" TEXT NOT NULL,
"openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "fms_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "fm_parts" (
"fmId" TEXT NOT NULL,
"partId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("fmId", "partId"),
CONSTRAINT "fm_parts_fmId_fkey" FOREIGN KEY ("fmId") REFERENCES "fms" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fm_parts_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "repairs" (
"id" TEXT NOT NULL PRIMARY KEY,
"hostId" TEXT NOT NULL,
"brokenPartId" TEXT NOT NULL,
"replacementPartId" TEXT NOT NULL,
"performedById" TEXT NOT NULL,
"performedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"fmId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "repairs_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_brokenPartId_fkey" FOREIGN KEY ("brokenPartId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_replacementPartId_fkey" FOREIGN KEY ("replacementPartId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_fmId_fkey" FOREIGN KEY ("fmId") REFERENCES "fms" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"serialNumber" TEXT NOT NULL,
"partModelId" TEXT NOT NULL,
"manufacturerId" TEXT NOT NULL,
"price" REAL,
"state" TEXT NOT NULL DEFAULT 'SPARE',
"binId" TEXT,
"categoryId" TEXT,
"hostId" TEXT,
"custodianId" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_partModelId_fkey" FOREIGN KEY ("partModelId") REFERENCES "PartModel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_custodianId_fkey" FOREIGN KEY ("custodianId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Part" ("binId", "categoryId", "createdAt", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt") SELECT "binId", "categoryId", "createdAt", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt" FROM "Part";
DROP TABLE "Part";
ALTER TABLE "new_Part" RENAME TO "Part";
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
CREATE INDEX "Part_state_idx" ON "Part"("state");
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
CREATE INDEX "Part_partModelId_idx" ON "Part"("partModelId");
CREATE INDEX "Part_categoryId_idx" ON "Part"("categoryId");
CREATE INDEX "Part_hostId_idx" ON "Part"("hostId");
CREATE INDEX "Part_custodianId_idx" ON "Part"("custodianId");
CREATE TABLE "new_PartModel" (
"id" TEXT NOT NULL PRIMARY KEY,
"manufacturerId" TEXT NOT NULL,
"mpn" TEXT NOT NULL,
"eolDate" DATETIME,
"destroyOnFail" BOOLEAN NOT NULL DEFAULT false,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PartModel_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_PartModel" ("createdAt", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt") SELECT "createdAt", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt" FROM "PartModel";
DROP TABLE "PartModel";
ALTER TABLE "new_PartModel" RENAME TO "PartModel";
CREATE INDEX "PartModel_manufacturerId_idx" ON "PartModel"("manufacturerId");
CREATE INDEX "PartModel_eolDate_idx" ON "PartModel"("eolDate");
CREATE UNIQUE INDEX "PartModel_manufacturerId_mpn_key" ON "PartModel"("manufacturerId", "mpn");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "fms_status_idx" ON "fms"("status");
-- CreateIndex
CREATE INDEX "fms_hostId_idx" ON "fms"("hostId");
-- CreateIndex
CREATE INDEX "fms_status_openedAt_idx" ON "fms"("status", "openedAt" DESC);
-- CreateIndex
CREATE INDEX "fm_parts_partId_idx" ON "fm_parts"("partId");
-- CreateIndex
CREATE INDEX "repairs_hostId_performedAt_idx" ON "repairs"("hostId", "performedAt" DESC);
-- CreateIndex
CREATE INDEX "repairs_fmId_idx" ON "repairs"("fmId");
-- CreateIndex
CREATE INDEX "repairs_performedById_performedAt_idx" ON "repairs"("performedById", "performedAt" DESC);
-- CreateIndex
CREATE INDEX "repairs_brokenPartId_idx" ON "repairs"("brokenPartId");
-- CreateIndex
CREATE INDEX "repairs_replacementPartId_idx" ON "repairs"("replacementPartId");
+40 -23
View File
@@ -25,8 +25,8 @@ model User {
updatedAt DateTime @updatedAt
partEvents PartEvent[]
refreshTokens RefreshToken[]
repairAssignments RepairJob[] @relation("RepairAssignee")
repairComments RepairComment[]
custodyParts Part[] @relation("Custody")
repairs Repair[]
savedViews SavedView[]
csvImportJobs CsvImportJob[]
}
@@ -60,6 +60,7 @@ model PartModel {
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
mpn String
eolDate DateTime?
destroyOnFail Boolean @default(false)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -128,12 +129,16 @@ model Part {
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
custodianId String?
custodian User? @relation("Custody", fields: [custodianId], references: [id], onDelete: SetNull)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events PartEvent[]
tags PartTag[]
problemInRepairs RepairJobPart[]
problemInFms FmPart[]
brokenRepairs Repair[] @relation("BrokenRepairs")
replacementRepairs Repair[] @relation("ReplacementRepairs")
@@index([state])
@@index([binId])
@@ -141,6 +146,7 @@ model Part {
@@index([partModelId])
@@index([categoryId])
@@index([hostId])
@@index([custodianId])
}
model PartEvent {
@@ -188,52 +194,63 @@ model Host {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
repairs RepairJob[]
fms Fm[]
repairs Repair[]
}
model RepairJob {
model Fm {
id String @id @default(uuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
assigneeId String?
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
status String @default("PENDING")
status String @default("OPEN")
problem String
openedAt DateTime @default(now())
closedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
problemParts RepairJobPart[]
comments RepairComment[]
problemParts FmPart[]
repairs Repair[]
@@index([status])
@@index([hostId])
@@index([assigneeId])
@@index([status, openedAt(sort: Desc)])
@@map("fms")
}
model RepairJobPart {
repairJobId String
model FmPart {
fmId String
partId String
repairJob RepairJob @relation(fields: [repairJobId], references: [id], onDelete: Cascade)
fm Fm @relation(fields: [fmId], references: [id], onDelete: Cascade)
part Part @relation(fields: [partId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
@@id([repairJobId, partId])
@@id([fmId, partId])
@@index([partId])
@@map("fm_parts")
}
model RepairComment {
model Repair {
id String @id @default(uuid())
repairJobId String
repairJob RepairJob @relation(fields: [repairJobId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
content String
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
brokenPartId String
brokenPart Part @relation("BrokenRepairs", fields: [brokenPartId], references: [id], onDelete: Restrict)
replacementPartId String
replacement Part @relation("ReplacementRepairs", fields: [replacementPartId], references: [id], onDelete: Restrict)
performedById String
performedBy User @relation(fields: [performedById], references: [id], onDelete: Restrict)
performedAt DateTime @default(now())
fmId String?
fm Fm? @relation(fields: [fmId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([repairJobId, createdAt])
@@index([hostId, performedAt(sort: Desc)])
@@index([fmId])
@@index([performedById, performedAt(sort: Desc)])
@@index([brokenPartId])
@@index([replacementPartId])
@@map("repairs")
}
model WebhookSubscription {
+1 -1
View File
@@ -33,5 +33,5 @@ export interface DashboardAnalytics {
ageBuckets: AgeBucket[];
topBins: BinCount[];
deployedPastEol: PartModelEolSummary[];
openRepairs: number;
openFms: number;
}
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod';
import { PaginationQuery } from './pagination.js';
export const DropOffRequest = z.object({
binId: z.string().uuid().nullable(),
});
export type DropOffRequest = z.infer<typeof DropOffRequest>;
export const CustodyListQuery = PaginationQuery.extend({
userId: z.string().uuid().optional(),
});
export type CustodyListQuery = z.infer<typeof CustodyListQuery>;
+16 -10
View File
@@ -1,6 +1,13 @@
import { z } from 'zod';
export const PartState = z.enum(['SPARE', 'DEPLOYED', 'BROKEN', 'PENDING_DESTRUCTION']);
export const PartState = z.enum([
'SPARE',
'DEPLOYED',
'BROKEN',
'PENDING_DESTRUCTION',
'PENDING_DROP_IN_CUSTODY',
'PENDING_DESTRUCTION_IN_CUSTODY',
]);
export type PartState = z.infer<typeof PartState>;
export const Role = z.enum(['ADMIN', 'TECHNICIAN']);
@@ -11,17 +18,16 @@ export const PartEventType = z.enum([
'STATE_CHANGED',
'LOCATION_CHANGED',
'FIELD_UPDATED',
'REPAIR_STARTED',
'REPAIR_COMPLETED',
'REPAIR_CANCELLED',
'REPAIR_COMMENTED',
'FM_OPENED',
'FM_CLOSED',
'PART_SWAPPED',
'TAG_ADDED',
'TAG_REMOVED',
]);
export type PartEventType = z.infer<typeof PartEventType>;
export const RepairStatus = z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']);
export type RepairStatus = z.infer<typeof RepairStatus>;
export const FmStatus = z.enum(['OPEN', 'CLOSED']);
export type FmStatus = z.infer<typeof FmStatus>;
export const CsvImportStatus = z.enum([
'PENDING',
@@ -39,9 +45,9 @@ export const WebhookEventName = z.enum([
'part.deleted',
'part.state_changed',
'part.location_changed',
'repair.started',
'repair.completed',
'repair.cancelled',
'fm.opened',
'fm.closed',
'repair.logged',
'tag.assigned',
'tag.removed',
]);
+52
View File
@@ -0,0 +1,52 @@
import { z } from 'zod';
import { FmStatus } from './enums.js';
import { PaginationQuery } from './pagination.js';
// Host lookup accepts either a uuid `hostId` or a string `assetId` — exactly one.
const hostSelector = {
hostId: z.string().uuid().optional(),
assetId: z.string().trim().min(1).max(128).optional(),
};
function hostSelectorRefine<T extends { hostId?: string; assetId?: string }>(
v: T,
ctx: z.RefinementCtx,
) {
const has = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
if (has !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide exactly one of hostId or assetId',
path: ['hostId'],
});
}
}
export const CreateFmRequest = z
.object({
...hostSelector,
problem: z.string().trim().min(1, 'Problem is required').max(2000),
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
})
.superRefine(hostSelectorRefine);
export type CreateFmRequest = z.infer<typeof CreateFmRequest>;
export const UpdateFmRequest = z
.object({
status: FmStatus.optional(),
problem: z.string().trim().min(1).max(2000).optional(),
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
export type UpdateFmRequest = z.infer<typeof UpdateFmRequest>;
export const FmListQuery = PaginationQuery.extend({
status: FmStatus.optional(),
hostId: z.string().uuid().optional(),
problemPartId: z.string().uuid().optional(),
openOnly: z
.union([z.literal('true'), z.literal('false'), z.boolean()])
.transform((v) => v === true || v === 'true')
.optional(),
});
export type FmListQuery = z.infer<typeof FmListQuery>;
+2
View File
@@ -8,7 +8,9 @@ export * from './parts.js';
export * from './env.js';
export * from './pagination.js';
export * from './hosts.js';
export * from './fms.js';
export * from './repairs.js';
export * from './custody.js';
export * from './tags.js';
export * from './categories.js';
export * from './webhooks.js';
+2
View File
@@ -7,6 +7,7 @@ export const CreatePartModelRequest = z.object({
manufacturerId: z.string().uuid(),
mpn: z.string().min(1).max(128),
eolDate: IsoDate.nullable().optional(),
destroyOnFail: z.boolean().optional(),
notes: z.string().max(4096).nullable().optional(),
});
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
@@ -16,6 +17,7 @@ export const UpdatePartModelRequest = z
manufacturerId: z.string().uuid().optional(),
mpn: z.string().min(1).max(128).optional(),
eolDate: IsoDate.nullable().optional(),
destroyOnFail: z.boolean().optional(),
notes: z.string().max(4096).nullable().optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
+81 -12
View File
@@ -15,6 +15,28 @@ const modelSelector = z
{ message: 'Provide partModelId or both manufacturerId and mpn' },
);
// Which of hostId / binId / custodianId may be set for a given state.
// `null` counts as "not set" for the purposes of these checks — callers are expected
// to treat undefined / null consistently when wiring a request.
export function allowedLocationFieldsForState(state: PartState): {
hostId: 'required' | 'forbidden';
binId: 'optional' | 'forbidden';
custodianId: 'required' | 'forbidden';
} {
switch (state) {
case 'DEPLOYED':
return { hostId: 'required', binId: 'forbidden', custodianId: 'forbidden' };
case 'PENDING_DROP_IN_CUSTODY':
case 'PENDING_DESTRUCTION_IN_CUSTODY':
return { hostId: 'forbidden', binId: 'forbidden', custodianId: 'required' };
case 'SPARE':
case 'BROKEN':
case 'PENDING_DESTRUCTION':
default:
return { hostId: 'forbidden', binId: 'optional', custodianId: 'forbidden' };
}
}
export const CreatePartRequest = z
.object({
serialNumber: z.string().min(1).max(128),
@@ -25,6 +47,7 @@ export const CreatePartRequest = z
state: PartState.optional(),
binId: z.string().uuid().optional().nullable(),
hostId: z.string().uuid().optional().nullable(),
custodianId: z.string().uuid().optional().nullable(),
notes: z.string().max(4096).optional().nullable(),
categoryId: z.string().uuid().optional().nullable(),
tagIds: z.array(z.string().uuid()).max(32).optional(),
@@ -42,30 +65,43 @@ export const CreatePartRequest = z
path: ['partModelId'],
});
}
// State/location coupling: DEPLOYED parts live on a host; every other state lives in a bin.
const state = v.state ?? 'SPARE';
if (state === 'DEPLOYED') {
if (!v.hostId) {
const rules = allowedLocationFieldsForState(state);
if (rules.hostId === 'required' && !v.hostId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'A deployed part must be assigned to a host',
path: ['hostId'],
});
}
if (v.binId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'A deployed part cannot also be in a storage bin',
path: ['binId'],
});
}
} else if (v.hostId) {
if (rules.hostId === 'forbidden' && v.hostId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Only deployed parts can be assigned to a host',
path: ['hostId'],
});
}
if (rules.binId === 'forbidden' && v.binId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'This state cannot have a bin assignment',
path: ['binId'],
});
}
if (rules.custodianId === 'required' && !v.custodianId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'A part in custody must name a custodian',
path: ['custodianId'],
});
}
if (rules.custodianId === 'forbidden' && v.custodianId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Only parts in a custody state can have a custodian',
path: ['custodianId'],
});
}
});
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
@@ -77,15 +113,47 @@ export const UpdatePartRequest = z
state: PartState.optional(),
binId: z.string().uuid().nullable().optional(),
hostId: z.string().uuid().nullable().optional(),
custodianId: z.string().uuid().nullable().optional(),
notes: z.string().max(4096).nullable().optional(),
categoryId: z.string().uuid().nullable().optional(),
tagIds: z.array(z.string().uuid()).max(32).optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' })
.refine((v) => !(v.binId && v.hostId), {
.superRefine((v, ctx) => {
// When state is supplied we can enforce the full matrix against the input fields.
// When state is absent the server resolver still enforces the invariant using
// current-row state + input overlay, so we keep zod to input-level sanity checks.
if (v.state) {
const rules = allowedLocationFieldsForState(v.state);
if (rules.hostId === 'forbidden' && v.hostId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Only deployed parts can be assigned to a host',
path: ['hostId'],
});
}
if (rules.binId === 'forbidden' && v.binId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'This state cannot have a bin assignment',
path: ['binId'],
});
}
if (rules.custodianId === 'forbidden' && v.custodianId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Only parts in a custody state can have a custodian',
path: ['custodianId'],
});
}
} else if (v.binId && v.hostId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'A part cannot be assigned to both a host and a bin',
path: ['binId'],
});
}
});
export type UpdatePartRequest = z.infer<typeof UpdatePartRequest>;
export const PartListQuery = PaginationQuery.extend({
@@ -94,6 +162,7 @@ export const PartListQuery = PaginationQuery.extend({
manufacturerId: z.string().uuid().optional(),
partModelId: z.string().uuid().optional(),
hostId: z.string().uuid().optional(),
custodianId: z.string().uuid().optional(),
mpn: z.string().max(128).optional(),
serialNumber: z.string().max(128).optional(),
q: z.string().max(128).optional(),
+26 -35
View File
@@ -1,43 +1,34 @@
import { z } from 'zod';
import { RepairStatus } from './enums.js';
import { PaginationQuery } from './pagination.js';
export const CreateRepairJobRequest = z.object({
hostId: z.string().uuid(),
problem: z.string().trim().min(1, 'Problem is required').max(2000),
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
assigneeId: z.string().uuid().optional().nullable(),
notes: z.string().max(4096).optional().nullable(),
});
export type CreateRepairJobRequest = z.infer<typeof CreateRepairJobRequest>;
export const UpdateRepairJobRequest = z
// Repair = a physical part-swap log entry. Tech enters host + broken serial/mpn + replacement serial.
// If the broken part isn't in the catalog yet it gets auto-ingested (requires mpn + manufacturer).
export const LogRepairRequest = z
.object({
status: RepairStatus.optional(),
problem: z.string().trim().min(1).max(2000).optional(),
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
assigneeId: z.string().uuid().nullable().optional(),
notes: z.string().max(4096).nullable().optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
export type UpdateRepairJobRequest = z.infer<typeof UpdateRepairJobRequest>;
export const RepairJobListQuery = PaginationQuery.extend({
status: RepairStatus.optional(),
hostId: z.string().uuid().optional(),
problemPartId: z.string().uuid().optional(),
assigneeId: z.string().uuid().optional(),
openOnly: z
.union([z.literal('true'), z.literal('false'), z.boolean()])
.transform((v) => v === true || v === 'true')
.optional(),
assetId: z.string().trim().min(1).max(128).optional(),
brokenSerial: z.string().trim().min(1).max(128),
brokenMpn: z.string().trim().min(1).max(128),
brokenManufacturerId: z.string().uuid(),
replacementSerial: z.string().trim().min(1).max(128),
fmId: z.string().uuid().optional(),
})
.superRefine((v, ctx) => {
const has = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
if (has !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide exactly one of hostId or assetId',
path: ['hostId'],
});
export type RepairJobListQuery = z.infer<typeof RepairJobListQuery>;
export const CreateRepairCommentRequest = z.object({
content: z.string().trim().min(1, 'Comment cannot be empty').max(4000),
}
});
export type CreateRepairCommentRequest = z.infer<typeof CreateRepairCommentRequest>;
export type LogRepairRequest = z.infer<typeof LogRepairRequest>;
export const RepairCommentListQuery = PaginationQuery;
export type RepairCommentListQuery = z.infer<typeof RepairCommentListQuery>;
export const RepairListQuery = PaginationQuery.extend({
hostId: z.string().uuid().optional(),
performedById: z.string().uuid().optional(),
fmId: z.string().uuid().optional(),
since: z.string().datetime().optional(),
});
export type RepairListQuery = z.infer<typeof RepairListQuery>;