feat: remove FM feature from Vector
CI / Lint · Typecheck · Test · Build (push) Failing after 36s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Has been skipped

FMs move to a separate application. Drops Fm/FmPart tables + Repair.fmId
column, deletes FM_OPENED/FM_CLOSED PartEvent rows, strips FM enums +
webhook events + shared contracts, removes FM routes/services/pages/UI,
and collapses dashboard admin ops to Repairs 7d/30d + trend + custody
backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 18:46:40 -04:00
parent d739411510
commit db8e86b749
32 changed files with 137 additions and 2192 deletions
-2
View File
@@ -22,7 +22,6 @@ 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';
@@ -90,7 +89,6 @@ 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);
-56
View File
@@ -1,56 +0,0 @@
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);
}
}
-15
View File
@@ -1,15 +0,0 @@
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;
+8 -58
View File
@@ -20,17 +20,12 @@ type FakeArgs = {
createdAt: Date;
partModelId: string;
}[];
openFms: number;
pastEolModels: EolPartModel[];
upcomingEolModels: EolPartModel[];
bins: { id: string; name: string; room: { name: string; site: { name: string } } }[];
// Admin-only inputs. Ignored when isAdmin=false path is exercised.
repairs?: { performedAt: Date }[];
fmsClosed?: { openedAt: Date; closedAt: Date | null }[];
newFms7d?: number;
openFmGroups?: { hostId: string; count: number }[];
custodyGroups?: { custodianId: string | null; count: number }[];
hosts?: { id: string; name: string }[];
users?: { id: string; username: string }[];
};
@@ -53,18 +48,6 @@ function makeTx(args: FakeArgs): Tx {
},
findMany: async () => args.parts,
},
fm: {
count: async (q: { where?: { status?: string; openedAt?: { gte: Date } } }) => {
if (q.where?.openedAt) return args.newFms7d ?? 0;
return args.openFms;
},
findMany: async () => args.fmsClosed ?? [],
groupBy: async () =>
(args.openFmGroups ?? []).map((g) => ({
hostId: g.hostId,
_count: { _all: g.count },
})),
},
partModel: {
findMany: async (q: { where?: { eolDate?: { gt?: Date; lte?: Date; not?: unknown } } }) => {
const gt = q.where?.eolDate?.gt;
@@ -87,9 +70,6 @@ function makeTx(args: FakeArgs): Tx {
return (args.repairs ?? []).filter((r) => r.performedAt >= gte);
},
},
host: {
findMany: async () => args.hosts ?? [],
},
user: {
findMany: async () => args.users ?? [],
},
@@ -100,20 +80,18 @@ function makeTx(args: FakeArgs): Tx {
const now = new Date('2026-04-16T00:00:00.000Z');
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
const daysAhead = (n: number) => new Date(now.getTime() + n * 24 * 60 * 60 * 1000);
const HOUR_MS = 60 * 60 * 1000;
const EMPTY: FakeArgs = {
partCount: 0,
stateRows: [],
parts: [],
openFms: 0,
pastEolModels: [],
upcomingEolModels: [],
bins: [],
};
describe('analytics.dashboard — base fields', () => {
it('aggregates totals, state counts and open FMs', async () => {
it('aggregates totals and state counts', async () => {
const tx = makeTx({
...EMPTY,
partCount: 5,
@@ -121,12 +99,10 @@ describe('analytics.dashboard — base fields', () => {
{ state: 'SPARE', count: 3, totalPrice: 1500 },
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
],
openFms: 4,
});
const r = await dashboard(tx, { isAdmin: false });
expect(r.totalParts).toBe(5);
expect(r.openFms).toBe(4);
expect(r.byState).toEqual([
{ state: 'SPARE', count: 3, totalPrice: 1500 },
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
@@ -279,11 +255,7 @@ describe('analytics.dashboard — isAdmin gating', () => {
const tx = makeTx({
...EMPTY,
repairs: [{ performedAt: daysAgo(1) }],
fmsClosed: [{ openedAt: daysAgo(2), closedAt: daysAgo(1) }],
newFms7d: 3,
openFmGroups: [{ hostId: 'h1', count: 2 }],
custodyGroups: [{ custodianId: 'u1', count: 1 }],
hosts: [{ id: 'h1', name: 'host-1' }],
users: [{ id: 'u1', username: 'alice' }],
});
@@ -292,10 +264,8 @@ describe('analytics.dashboard — isAdmin gating', () => {
expect(r.operations).toMatchObject({
repairs7d: 1,
repairs30d: 1,
newFms7d: 3,
});
expect(r.operations!.repairsTrend30d).toHaveLength(30);
expect(r.operations!.openFmsByHost).toEqual([{ hostId: 'h1', hostName: 'host-1', count: 2 }]);
expect(r.operations!.custodyBacklog).toEqual([
{ userId: 'u1', username: 'alice', count: 1 },
]);
@@ -304,43 +274,23 @@ describe('analytics.dashboard — isAdmin gating', () => {
describe('analytics.dashboard — operations fields', () => {
it('repairsTrend30d has 30 entries and zero-fills empty days', async () => {
// Anchor the repairs to real "now" so they land inside the dashboard's
// 30-day window regardless of when the test runs.
const realNow = new Date();
const realDaysAgo = (n: number) => new Date(realNow.getTime() - n * 24 * 60 * 60 * 1000);
const tx = makeTx({
...EMPTY,
repairs: [{ performedAt: daysAgo(5) }, { performedAt: daysAgo(28) }],
repairs: [{ performedAt: realDaysAgo(2) }, { performedAt: realDaysAgo(10) }],
});
const r = await dashboard(tx, { isAdmin: true });
const trend = r.operations!.repairsTrend30d;
expect(trend).toHaveLength(30);
expect(trend.filter((d) => d.count === 0)).toHaveLength(28);
expect(trend.filter((d) => d.count === 1)).toHaveLength(2);
const totalCount = trend.reduce((s, d) => s + d.count, 0);
expect(totalCount).toBe(2);
// Chronological order: earliest first, today last
for (let i = 1; i < trend.length; i++) {
expect(trend[i]!.date >= trend[i - 1]!.date).toBe(true);
}
});
it('avgFmCloseHours30d is null when no FMs closed in window', async () => {
const tx = makeTx({ ...EMPTY, fmsClosed: [] });
const r = await dashboard(tx, { isAdmin: true });
expect(r.operations!.avgFmCloseHours30d).toBeNull();
});
it('avgFmCloseHours30d averages close durations in hours', async () => {
const tx = makeTx({
...EMPTY,
fmsClosed: [
{
openedAt: new Date(now.getTime() - 4 * HOUR_MS),
closedAt: new Date(now.getTime() - 2 * HOUR_MS),
},
{
openedAt: new Date(now.getTime() - 10 * HOUR_MS),
closedAt: new Date(now.getTime() - 4 * HOUR_MS),
},
],
});
const r = await dashboard(tx, { isAdmin: true });
expect(r.operations!.avgFmCloseHours30d).toBe(4);
});
});
+2 -53
View File
@@ -2,7 +2,6 @@ import type { DashboardAnalytics, OperationsAnalytics } from '@vector/shared';
import type { Tx } from './types.js';
const DAY = 24 * 60 * 60 * 1000;
const HOUR = 60 * 60 * 1000;
const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
{ label: '030d', maxDays: 30 },
@@ -30,7 +29,7 @@ export async function dashboard(
const now = new Date();
const upcomingEolCutoff = new Date(now.getTime() + 180 * DAY);
const [totalParts, stateRows, parts, openFms, pastEolModels, upcomingEolModels] =
const [totalParts, stateRows, parts, pastEolModels, upcomingEolModels] =
await Promise.all([
tx.part.count(),
tx.part.groupBy({
@@ -41,7 +40,6 @@ export async function dashboard(
tx.part.findMany({
select: { id: true, state: true, binId: true, createdAt: true, partModelId: true },
}),
tx.fm.count({ where: { status: 'OPEN' } }),
tx.partModel.findMany({
where: { eolDate: { not: null, lte: now } },
select: {
@@ -139,7 +137,6 @@ export async function dashboard(
topBins,
deployedPastEol,
upcomingEol,
openFms,
};
if (!opts.isAdmin) return base;
@@ -147,31 +144,13 @@ export async function dashboard(
const sevenDaysAgo = new Date(nowMs - 7 * DAY);
const thirtyDaysAgo = new Date(nowMs - 30 * DAY);
const [
repairs7d,
repairs30d,
newFms7d,
closedFms,
recentRepairs,
openFmGroups,
custodyGroups,
] = await Promise.all([
const [repairs7d, repairs30d, recentRepairs, custodyGroups] = await Promise.all([
tx.repair.count({ where: { performedAt: { gte: sevenDaysAgo } } }),
tx.repair.count({ where: { performedAt: { gte: thirtyDaysAgo } } }),
tx.fm.count({ where: { openedAt: { gte: sevenDaysAgo } } }),
tx.fm.findMany({
where: { closedAt: { gte: thirtyDaysAgo } },
select: { openedAt: true, closedAt: true },
}),
tx.repair.findMany({
where: { performedAt: { gte: thirtyDaysAgo } },
select: { performedAt: true },
}),
tx.fm.groupBy({
by: ['hostId'],
where: { status: 'OPEN' },
_count: { _all: true },
}),
tx.part.groupBy({
by: ['custodianId'],
where: {
@@ -182,17 +161,6 @@ export async function dashboard(
}),
]);
const closedWithDates = closedFms.filter(
(f): f is { openedAt: Date; closedAt: Date } => f.closedAt !== null,
);
const avgFmCloseHours30d =
closedWithDates.length === 0
? null
: closedWithDates.reduce(
(sum, f) => sum + (f.closedAt.getTime() - f.openedAt.getTime()) / HOUR,
0,
) / closedWithDates.length;
const trendByDay = new Map<string, number>();
for (const r of recentRepairs) {
const key = utcDateKey(r.performedAt);
@@ -205,22 +173,6 @@ export async function dashboard(
repairsTrend30d.push({ date: key, count: trendByDay.get(key) ?? 0 });
}
const topOpenFmHostIds = [...openFmGroups]
.sort((a, b) => b._count._all - a._count._all)
.slice(0, 8);
const openFmHostRows = topOpenFmHostIds.length
? await tx.host.findMany({
where: { id: { in: topOpenFmHostIds.map((g) => g.hostId) } },
select: { id: true, name: true },
})
: [];
const openFmHostNames = new Map(openFmHostRows.map((h) => [h.id, h.name]));
const openFmsByHost = topOpenFmHostIds.map((g) => ({
hostId: g.hostId,
hostName: openFmHostNames.get(g.hostId) ?? 'Unknown',
count: g._count._all,
}));
const topCustodians = [...custodyGroups]
.filter((g): g is typeof g & { custodianId: string } => g.custodianId !== null)
.sort((a, b) => b._count._all - a._count._all)
@@ -241,10 +193,7 @@ export async function dashboard(
const operations: OperationsAnalytics = {
repairs7d,
repairs30d,
newFms7d,
avgFmCloseHours30d,
repairsTrend30d,
openFmsByHost,
custodyBacklog,
};
-279
View File
@@ -1,279 +0,0 @@
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
@@ -1,231 +0,0 @@
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 -49
View File
@@ -192,7 +192,7 @@ describe('hosts.update', () => {
});
describe('hosts.getTimeline', () => {
it('merges HostEvents, Fms, Repairs, PartEvents in reverse-chronological order', async () => {
it('merges HostEvents, Repairs, PartEvents in reverse-chronological order', async () => {
const hostId = 'host-1';
const hostName = 'Vela';
@@ -216,17 +216,6 @@ describe('hosts.getTimeline', () => {
},
]),
},
fm: {
findMany: vi.fn(async () => [
{
id: 'fm-1',
status: 'OPEN',
problem: 'bad disk',
openedAt: t(30),
closedAt: null,
},
]),
},
repair: {
findMany: vi.fn(async () => [
{
@@ -253,43 +242,8 @@ describe('hosts.getTimeline', () => {
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
expect(result.total).toBe(3);
expect(result.data.map((e) => e.type)).toEqual([
'HOST_EVENT',
'REPAIR',
'FM_OPENED',
]);
});
it('emits FM_CLOSED only when closedAt is set', async () => {
const hostId = 'host-1';
const hostName = 'Vela';
const now = Date.now();
const tx = {
host: {
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
},
hostEvent: { findMany: vi.fn(async () => []) },
fm: {
findMany: vi.fn(async () => [
{
id: 'fm-1',
status: 'CLOSED',
problem: 'p',
openedAt: new Date(now - 60_000),
closedAt: new Date(now - 30_000),
},
]),
},
repair: { findMany: vi.fn(async () => []) },
partEvent: { findMany: vi.fn(async () => []) },
} as unknown as Tx;
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.data.map((e) => e.type)).toEqual(['FM_CLOSED', 'FM_OPENED']);
expect(result.data.map((e) => e.type)).toEqual(['HOST_EVENT', 'REPAIR']);
});
it('classifies PartEvents as ARRIVED/DEPARTED by host name match', async () => {
@@ -302,7 +256,6 @@ describe('hosts.getTimeline', () => {
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
},
hostEvent: { findMany: vi.fn(async () => []) },
fm: { findMany: vi.fn(async () => []) },
repair: { findMany: vi.fn(async () => []) },
partEvent: {
findMany: vi.fn(async () => [
+23 -32
View File
@@ -13,6 +13,25 @@ function mapUniqueViolation(target: unknown): string {
return 'Host name already exists';
}
// 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');
}
export async function list(tx: Tx, q: HostListQuery) {
const { page, pageSize, q: search } = q;
const where: Prisma.HostWhereInput = {};
@@ -202,20 +221,16 @@ export async function remove(tx: Tx, id: string) {
}
}
// Unified host timeline. Merges four sources:
// Unified host timeline. Merges three sources:
// - HostEvents (state/stack/field changes on the host)
// - Fms (FM_OPENED at openedAt, FM_CLOSED at closedAt when present)
// - Repairs on this host (captures broken/replacement part swaps)
// - PartEvents where a part's host field changed to or from this host
// (covers ad-hoc arrivals/departures outside the repair flow).
//
// The four sources are merged in memory and paginated after the sort; the resulting page
// will be small because we cap each source fetch at a safe upper bound. This avoids the
// complexity of a UNION query while still giving correct reverse-chronological ordering.
// Sources are merged in memory and paginated after the sort; the resulting page
// will be small because we cap each source fetch at a safe upper bound.
export type HostTimelineEntry =
| { type: 'HOST_EVENT'; at: Date; hostEvent: HostEventPayload }
| { type: 'FM_OPENED'; at: Date; fm: FmSummary }
| { type: 'FM_CLOSED'; at: Date; fm: FmSummary }
| { type: 'REPAIR'; at: Date; repair: RepairSummary }
| { type: 'PART_ARRIVED'; at: Date; part: PartRef; partEventId: string }
| { type: 'PART_DEPARTED'; at: Date; part: PartRef; partEventId: string };
@@ -230,14 +245,6 @@ interface HostEventPayload {
user: { username: string } | null;
}
interface FmSummary {
id: string;
status: string;
problem: string;
openedAt: Date;
closedAt: Date | null;
}
interface RepairSummary {
id: string;
performedAt: Date;
@@ -264,18 +271,13 @@ export async function getTimeline(tx: Tx, hostId: string, q: HostTimelineQuery)
if (!host) throw errors.notFound('Host');
// PartEvent stores the host's name in oldValue/newValue (see parts.ts), not the id.
const [hostEvents, fms, repairs, partEventRows] = await Promise.all([
const [hostEvents, repairs, partEventRows] = await Promise.all([
tx.hostEvent.findMany({
where: { hostId },
orderBy: { createdAt: 'desc' },
take: TIMELINE_SOURCE_CAP,
include: { user: { select: { username: true } } },
}),
tx.fm.findMany({
where: { hostId },
orderBy: { openedAt: 'desc' },
take: TIMELINE_SOURCE_CAP,
}),
tx.repair.findMany({
where: { hostId },
orderBy: { performedAt: 'desc' },
@@ -319,17 +321,6 @@ export async function getTimeline(tx: Tx, hostId: string, q: HostTimelineQuery)
},
});
}
for (const f of fms) {
const summary: FmSummary = {
id: f.id,
status: f.status,
problem: f.problem,
openedAt: f.openedAt,
closedAt: f.closedAt,
};
entries.push({ type: 'FM_OPENED', at: f.openedAt, fm: summary });
if (f.closedAt) entries.push({ type: 'FM_CLOSED', at: f.closedAt, fm: summary });
}
for (const r of repairs) {
entries.push({
type: 'REPAIR',
-90
View File
@@ -65,7 +65,6 @@ function partRow(overrides: Partial<Record<string, unknown>>) {
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]));
@@ -167,12 +166,6 @@ function buildTx(options: {
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',
@@ -180,13 +173,11 @@ function buildTx(options: {
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: {
@@ -433,87 +424,6 @@ describe('repairs.log — validation failures', () => {
).rejects.toMatchObject({ status: 400 });
});
it('rejects when fmId belongs to a different host', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: brokenModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
host: host1,
partModel: brokenModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'SPARE',
partModel: replacementModel,
});
const { tx } = buildTx({
parts: [broken, replacement],
hosts: [host1, host2],
fm: { id: 'fm-other', hostId: 'host-2' },
});
await expect(
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('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',
},
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('accepts a PENDING_REPAIR replacement held by the actor', async () => {
const broken = partRow({
id: 'p-broken',
+5 -18
View File
@@ -4,7 +4,7 @@ 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 { resolveHost } from './hosts.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
@@ -15,7 +15,6 @@ const repairInclude = {
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;
export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>;
@@ -24,7 +23,6 @@ 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;
}
@@ -67,7 +65,6 @@ function repairPayload(r: RepairWithRelations) {
},
performedBy: r.performedBy,
performedAt: r.performedAt.toISOString(),
fmId: r.fmId,
};
}
@@ -143,21 +140,12 @@ export async function log(
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.
// 3. 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
// 4. 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(
@@ -173,19 +161,18 @@ export async function log(
actor,
);
// 6. Persist the Repair row.
// 5. 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,
});
// 7. Swap event on each part — so the part timeline shows the repair link.
// 6. Swap event on each part — so the part timeline shows the repair link.
await tx.partEvent.createMany({
data: [
{