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
@@ -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");