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