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
@@ -0,0 +1,59 @@
/*
Warnings:
- You are about to drop the `fm_parts` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `fms` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the column `fmId` on the `repairs` table. All the data in the column will be lost.
*/
-- Drop orphan part_events referencing the retired FM event types.
DELETE FROM "part_events" WHERE "type" IN ('FM_OPENED', 'FM_CLOSED');
-- DropIndex
DROP INDEX "fm_parts_partId_idx";
-- DropIndex
DROP INDEX "fms_status_openedAt_idx";
-- DropIndex
DROP INDEX "fms_hostId_idx";
-- DropIndex
DROP INDEX "fms_status_idx";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "fm_parts";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "fms";
PRAGMA foreign_keys=on;
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_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,
"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
);
INSERT INTO "new_repairs" ("brokenPartId", "createdAt", "hostId", "id", "performedAt", "performedById", "replacementPartId", "updatedAt") SELECT "brokenPartId", "createdAt", "hostId", "id", "performedAt", "performedById", "replacementPartId", "updatedAt" FROM "repairs";
DROP TABLE "repairs";
ALTER TABLE "new_repairs" RENAME TO "repairs";
CREATE INDEX "repairs_hostId_performedAt_idx" ON "repairs"("hostId", "performedAt" DESC);
CREATE INDEX "repairs_performedById_performedAt_idx" ON "repairs"("performedById", "performedAt" DESC);
CREATE INDEX "repairs_brokenPartId_idx" ON "repairs"("brokenPartId");
CREATE INDEX "repairs_replacementPartId_idx" ON "repairs"("replacementPartId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-36
View File
@@ -138,7 +138,6 @@ model Part {
updatedAt DateTime @updatedAt
events PartEvent[]
tags PartTag[]
problemInFms FmPart[]
brokenRepairs Repair[] @relation("BrokenRepairs")
replacementRepairs Repair[] @relation("ReplacementRepairs")
@@ -197,7 +196,6 @@ model Host {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
fms Fm[]
repairs Repair[]
events HostEvent[]
@@ -221,37 +219,6 @@ model HostEvent {
@@index([userId])
}
model Fm {
id String @id @default(uuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
status String @default("OPEN")
problem String
openedAt DateTime @default(now())
closedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
problemParts FmPart[]
repairs Repair[]
@@index([status])
@@index([hostId])
@@index([status, openedAt(sort: Desc)])
@@map("fms")
}
model FmPart {
fmId String
partId String
fm Fm @relation(fields: [fmId], references: [id], onDelete: Cascade)
part Part @relation(fields: [partId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
@@id([fmId, partId])
@@index([partId])
@@map("fm_parts")
}
model Repair {
id String @id @default(uuid())
hostId String
@@ -263,13 +230,10 @@ model Repair {
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([hostId, performedAt(sort: Desc)])
@@index([fmId])
@@index([performedById, performedAt(sort: Desc)])
@@index([brokenPartId])
@@index([replacementPartId])
-4
View File
@@ -30,10 +30,7 @@ export interface PartModelEolSummary {
export interface OperationsAnalytics {
repairs7d: number;
repairs30d: number;
newFms7d: number;
avgFmCloseHours30d: number | null;
repairsTrend30d: { date: string; count: number }[];
openFmsByHost: { hostId: string; hostName: string; count: number }[];
custodyBacklog: { userId: string; username: string; count: number }[];
}
@@ -44,6 +41,5 @@ export interface DashboardAnalytics {
topBins: BinCount[];
deployedPastEol: PartModelEolSummary[];
upcomingEol: PartModelEolSummary[];
openFms: number;
operations?: OperationsAnalytics;
}
-7
View File
@@ -25,8 +25,6 @@ export const PartEventType = z.enum([
'STATE_CHANGED',
'LOCATION_CHANGED',
'FIELD_UPDATED',
'FM_OPENED',
'FM_CLOSED',
'PART_SWAPPED',
'TAG_ADDED',
'TAG_REMOVED',
@@ -41,9 +39,6 @@ export const HostEventType = z.enum([
]);
export type HostEventType = z.infer<typeof HostEventType>;
export const FmStatus = z.enum(['OPEN', 'CLOSED']);
export type FmStatus = z.infer<typeof FmStatus>;
export const CsvImportStatus = z.enum([
'PENDING',
'STAGED',
@@ -60,8 +55,6 @@ export const WebhookEventName = z.enum([
'part.deleted',
'part.state_changed',
'part.location_changed',
'fm.opened',
'fm.closed',
'repair.logged',
'tag.assigned',
'tag.removed',
-52
View File
@@ -1,52 +0,0 @@
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>;
-1
View File
@@ -9,7 +9,6 @@ export * from './env.js';
export * from './pagination.js';
export * from './hosts.js';
export * from './host-events.js';
export * from './fms.js';
export * from './repairs.js';
export * from './custody.js';
export * from './tags.js';
-2
View File
@@ -14,7 +14,6 @@ export const LogRepairRequest = z
brokenMpn: z.string().trim().min(1).max(128).optional(),
brokenManufacturerId: z.string().uuid().optional(),
replacementSerial: z.string().trim().min(1).max(128),
fmId: z.string().uuid().optional(),
})
.superRefine((v, ctx) => {
const hostHas = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
@@ -33,7 +32,6 @@ export type LogRepairRequest = z.infer<typeof LogRepairRequest>;
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>;