feat: rework EOL, repairs, and hosts for real workflow
Four domain-model changes driven by exercising the deployed 2.0 build: - EOL moves from manufacturer to MPN via new PartModel catalog table, so alerts fire on the thing that actually ages. - Repairs re-home to Host (required hostId + problem text) with an optional RepairJobPart join for affected parts; drop Part.replacementPartId. - New /repairs/:id detail page with editable problem, part list, and a RepairComment thread (REPAIR_COMMENTED events fan out to each problem part's timeline). - Host.assetId (required, unique) surfaces prominently on the repair page so techs can confirm they're touching the right box. Single destructive migration reshapes existing dev data. All 7 packages typecheck clean; 30 API tests pass (9 new covering host membership, upsertByMpn idempotency + race, assetId 409, comment userId stamping). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+204
@@ -0,0 +1,204 @@
|
||||
-- Domain rework: EOL moves from Manufacturer to a new PartModel catalog (keyed by manufacturerId+mpn);
|
||||
-- repairs move from Part-scoped to Host-scoped with an optional problem-parts join; Host gains a
|
||||
-- required+unique assetId; RepairJob gains a `problem` field and loses its direct `partId` FK.
|
||||
--
|
||||
-- Data-preserving reshape for SQLite. Steps:
|
||||
-- 1. Create new catalog + join + comment tables.
|
||||
-- 2. Seed PartModel from DISTINCT (manufacturerId, mpn) on Part, carrying Manufacturer.eolDate forward.
|
||||
-- 3. Snapshot existing RepairJob.partId into RepairJobPart so historic repairs still remember the part.
|
||||
-- 4. Ensure every RepairJob has a hostId (synthesize "__Unassigned__" host if needed) before hostId NOT NULL.
|
||||
-- 5. Rebuild Host (add assetId NOT NULL+unique, backfilled with "H-<short>"), Manufacturer (drop eolDate),
|
||||
-- Part (add partModelId via lookup + hostId nullable, drop mpn + replacementPartId), and RepairJob
|
||||
-- (drop partId, add problem with COALESCE(notes, fallback), hostId NOT NULL).
|
||||
|
||||
-- CreateTable: PartModel catalog
|
||||
CREATE TABLE "PartModel" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"manufacturerId" TEXT NOT NULL,
|
||||
"mpn" TEXT NOT NULL,
|
||||
"eolDate" DATETIME,
|
||||
"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
|
||||
);
|
||||
|
||||
-- Backfill PartModel from DISTINCT (manufacturerId, mpn) on existing Part rows, carrying the
|
||||
-- current Manufacturer.eolDate into the new per-MPN eolDate. Admins re-tune per MPN afterward.
|
||||
INSERT INTO "PartModel" ("id", "manufacturerId", "mpn", "eolDate", "createdAt", "updatedAt")
|
||||
SELECT
|
||||
lower(hex(randomblob(16))),
|
||||
d."manufacturerId",
|
||||
d."mpn",
|
||||
m."eolDate",
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM (SELECT DISTINCT "manufacturerId", "mpn" FROM "Part") d
|
||||
LEFT JOIN "Manufacturer" m ON m."id" = d."manufacturerId";
|
||||
|
||||
-- CreateTable: RepairJobPart join
|
||||
CREATE TABLE "RepairJobPart" (
|
||||
"repairJobId" TEXT NOT NULL,
|
||||
"partId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY ("repairJobId", "partId"),
|
||||
CONSTRAINT "RepairJobPart_repairJobId_fkey" FOREIGN KEY ("repairJobId") REFERENCES "RepairJob" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "RepairJobPart_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Snapshot old single-part repairs into the new join table so each historic repair still points at
|
||||
-- its original problem part.
|
||||
INSERT INTO "RepairJobPart" ("repairJobId", "partId", "createdAt")
|
||||
SELECT "id", "partId", CURRENT_TIMESTAMP FROM "RepairJob";
|
||||
|
||||
-- CreateTable: RepairComment
|
||||
CREATE TABLE "RepairComment" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"repairJobId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "RepairComment_repairJobId_fkey" FOREIGN KEY ("repairJobId") REFERENCES "RepairJob" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "RepairComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Ensure every RepairJob has a hostId before we tighten the column to NOT NULL. Repairs with a
|
||||
-- NULL hostId get attached to a synthetic "__Unassigned__" host so no rows are lost; admins can
|
||||
-- reassign them afterward.
|
||||
INSERT INTO "Host" ("id", "name", "createdAt", "updatedAt")
|
||||
SELECT lower(hex(randomblob(16))), '__Unassigned__', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
WHERE EXISTS (SELECT 1 FROM "RepairJob" WHERE "hostId" IS NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM "Host" WHERE "name" = '__Unassigned__');
|
||||
|
||||
UPDATE "RepairJob"
|
||||
SET "hostId" = (SELECT "id" FROM "Host" WHERE "name" = '__Unassigned__')
|
||||
WHERE "hostId" IS NULL;
|
||||
|
||||
-- RedefineTables: destructive reshape of Host / Manufacturer / Part / RepairJob.
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
-- Host: add assetId NOT NULL + unique, backfilled with "H-<first-8-chars-of-id>" so existing rows
|
||||
-- carry a deterministic placeholder admins can rename.
|
||||
CREATE TABLE "new_Host" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"assetId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"location" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_Host" ("id", "assetId", "name", "location", "notes", "createdAt", "updatedAt")
|
||||
SELECT "id", 'H-' || substr("id", 1, 8), "name", "location", "notes", "createdAt", "updatedAt"
|
||||
FROM "Host";
|
||||
DROP TABLE "Host";
|
||||
ALTER TABLE "new_Host" RENAME TO "Host";
|
||||
CREATE UNIQUE INDEX "Host_assetId_key" ON "Host"("assetId");
|
||||
CREATE UNIQUE INDEX "Host_name_key" ON "Host"("name");
|
||||
|
||||
-- Manufacturer: drop eolDate (EOL now lives on PartModel).
|
||||
CREATE TABLE "new_Manufacturer" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_Manufacturer" ("id", "name", "createdAt", "updatedAt")
|
||||
SELECT "id", "name", "createdAt", "updatedAt" FROM "Manufacturer";
|
||||
DROP TABLE "Manufacturer";
|
||||
ALTER TABLE "new_Manufacturer" RENAME TO "Manufacturer";
|
||||
CREATE UNIQUE INDEX "Manufacturer_name_key" ON "Manufacturer"("name");
|
||||
|
||||
-- Part: add partModelId (looked up via the backfilled PartModel rows), add hostId (nullable;
|
||||
-- admins populate when deploying parts). Drop the free-text mpn column and the unused
|
||||
-- replacementPartId self-relation.
|
||||
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,
|
||||
"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
|
||||
);
|
||||
INSERT INTO "new_Part" ("id", "serialNumber", "partModelId", "manufacturerId", "price", "state", "binId", "categoryId", "hostId", "notes", "createdAt", "updatedAt")
|
||||
SELECT
|
||||
p."id",
|
||||
p."serialNumber",
|
||||
(SELECT pm."id" FROM "PartModel" pm WHERE pm."manufacturerId" = p."manufacturerId" AND pm."mpn" = p."mpn" LIMIT 1),
|
||||
p."manufacturerId",
|
||||
p."price",
|
||||
p."state",
|
||||
p."binId",
|
||||
p."categoryId",
|
||||
NULL,
|
||||
p."notes",
|
||||
p."createdAt",
|
||||
p."updatedAt"
|
||||
FROM "Part" p;
|
||||
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");
|
||||
|
||||
-- RepairJob: drop partId (problem part now lives in RepairJobPart), require hostId, add problem
|
||||
-- (carried over from notes as a best-effort fallback for historical rows).
|
||||
CREATE TABLE "new_RepairJob" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"hostId" TEXT NOT NULL,
|
||||
"assigneeId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"problem" TEXT NOT NULL,
|
||||
"openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"closedAt" DATETIME,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "RepairJob_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "RepairJob_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_RepairJob" ("id", "hostId", "assigneeId", "status", "problem", "openedAt", "closedAt", "notes", "createdAt", "updatedAt")
|
||||
SELECT
|
||||
r."id",
|
||||
r."hostId",
|
||||
r."assigneeId",
|
||||
r."status",
|
||||
COALESCE(r."notes", 'Imported repair — problem not recorded'),
|
||||
r."openedAt",
|
||||
r."closedAt",
|
||||
r."notes",
|
||||
r."createdAt",
|
||||
r."updatedAt"
|
||||
FROM "RepairJob" r;
|
||||
DROP TABLE "RepairJob";
|
||||
ALTER TABLE "new_RepairJob" RENAME TO "RepairJob";
|
||||
CREATE INDEX "RepairJob_status_idx" ON "RepairJob"("status");
|
||||
CREATE INDEX "RepairJob_hostId_idx" ON "RepairJob"("hostId");
|
||||
CREATE INDEX "RepairJob_assigneeId_idx" ON "RepairJob"("assigneeId");
|
||||
CREATE INDEX "RepairJob_status_openedAt_idx" ON "RepairJob"("status", "openedAt" DESC);
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- Indexes for the new catalog + join + comment tables.
|
||||
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");
|
||||
CREATE INDEX "RepairJobPart_partId_idx" ON "RepairJobPart"("partId");
|
||||
CREATE INDEX "RepairComment_repairJobId_createdAt_idx" ON "RepairComment"("repairJobId", "createdAt");
|
||||
Reference in New Issue
Block a user