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
+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();
});
});