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");
|
||||
@@ -16,16 +16,17 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
email String @unique
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role String @default("TECHNICIAN")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
role String @default("TECHNICIAN")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
partEvents PartEvent[]
|
||||
refreshTokens RefreshToken[]
|
||||
repairAssignments RepairJob[] @relation("RepairAssignee")
|
||||
repairAssignments RepairJob[] @relation("RepairAssignee")
|
||||
repairComments RepairComment[]
|
||||
savedViews SavedView[]
|
||||
csvImportJobs CsvImportJob[]
|
||||
}
|
||||
@@ -45,12 +46,28 @@ model RefreshToken {
|
||||
}
|
||||
|
||||
model Manufacturer {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
eolDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
partModels PartModel[]
|
||||
}
|
||||
|
||||
model PartModel {
|
||||
id String @id @default(uuid())
|
||||
manufacturerId String
|
||||
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
|
||||
mpn String
|
||||
eolDate DateTime?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
|
||||
@@unique([manufacturerId, mpn])
|
||||
@@index([manufacturerId])
|
||||
@@index([eolDate])
|
||||
}
|
||||
|
||||
model Site {
|
||||
@@ -97,33 +114,33 @@ model Category {
|
||||
}
|
||||
|
||||
model Part {
|
||||
id String @id @default(uuid())
|
||||
serialNumber String @unique
|
||||
mpn String
|
||||
manufacturerId String
|
||||
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
|
||||
price Float?
|
||||
state String @default("SPARE")
|
||||
binId String?
|
||||
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
replacementPartId String?
|
||||
replacement Part? @relation("PartReplacement", fields: [replacementPartId], references: [id], onDelete: SetNull)
|
||||
replacedBy Part[] @relation("PartReplacement")
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events PartEvent[]
|
||||
tags PartTag[]
|
||||
repairs RepairJob[]
|
||||
id String @id @default(uuid())
|
||||
serialNumber String @unique
|
||||
partModelId String
|
||||
partModel PartModel @relation(fields: [partModelId], references: [id], onDelete: Restrict)
|
||||
manufacturerId String
|
||||
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
|
||||
price Float?
|
||||
state String @default("SPARE")
|
||||
binId String?
|
||||
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
hostId String?
|
||||
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events PartEvent[]
|
||||
tags PartTag[]
|
||||
problemInRepairs RepairJobPart[]
|
||||
|
||||
@@index([state])
|
||||
@@index([binId])
|
||||
@@index([manufacturerId])
|
||||
@@index([mpn])
|
||||
@@index([partModelId])
|
||||
@@index([categoryId])
|
||||
@@index([replacementPartId])
|
||||
@@index([hostId])
|
||||
}
|
||||
|
||||
model PartEvent {
|
||||
@@ -164,36 +181,61 @@ model PartTag {
|
||||
|
||||
model Host {
|
||||
id String @id @default(uuid())
|
||||
assetId String @unique
|
||||
name String @unique
|
||||
location String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
repairs RepairJob[]
|
||||
}
|
||||
|
||||
model RepairJob {
|
||||
id String @id @default(uuid())
|
||||
partId String
|
||||
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||
hostId String?
|
||||
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||||
assigneeId String?
|
||||
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||
status String @default("PENDING")
|
||||
openedAt DateTime @default(now())
|
||||
closedAt DateTime?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
hostId String
|
||||
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
|
||||
assigneeId String?
|
||||
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||
status String @default("PENDING")
|
||||
problem String
|
||||
openedAt DateTime @default(now())
|
||||
closedAt DateTime?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
problemParts RepairJobPart[]
|
||||
comments RepairComment[]
|
||||
|
||||
@@index([partId])
|
||||
@@index([status])
|
||||
@@index([hostId])
|
||||
@@index([assigneeId])
|
||||
@@index([status, openedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
model RepairJobPart {
|
||||
repairJobId String
|
||||
partId String
|
||||
repairJob RepairJob @relation(fields: [repairJobId], references: [id], onDelete: Cascade)
|
||||
part Part @relation(fields: [partId], references: [id], onDelete: Restrict)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([repairJobId, partId])
|
||||
@@index([partId])
|
||||
}
|
||||
|
||||
model RepairComment {
|
||||
id String @id @default(uuid())
|
||||
repairJobId String
|
||||
repairJob RepairJob @relation(fields: [repairJobId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([repairJobId, createdAt])
|
||||
}
|
||||
|
||||
model WebhookSubscription {
|
||||
id String @id @default(uuid())
|
||||
url String
|
||||
|
||||
@@ -18,10 +18,12 @@ export interface BinCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ManufacturerEolSummary {
|
||||
export interface PartModelEolSummary {
|
||||
partModelId: string;
|
||||
mpn: string;
|
||||
manufacturerId: string;
|
||||
name: string;
|
||||
eolDate: string | null;
|
||||
manufacturerName: string;
|
||||
eolDate: string;
|
||||
deployedCount: number;
|
||||
}
|
||||
|
||||
@@ -30,6 +32,6 @@ export interface DashboardAnalytics {
|
||||
byState: StateCount[];
|
||||
ageBuckets: AgeBucket[];
|
||||
topBins: BinCount[];
|
||||
deployedPastEol: ManufacturerEolSummary[];
|
||||
deployedPastEol: PartModelEolSummary[];
|
||||
openRepairs: number;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export const PartEventType = z.enum([
|
||||
'FIELD_UPDATED',
|
||||
'REPAIR_STARTED',
|
||||
'REPAIR_COMPLETED',
|
||||
'REPAIR_CANCELLED',
|
||||
'REPAIR_COMMENTED',
|
||||
'TAG_ADDED',
|
||||
'TAG_REMOVED',
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
const AssetId = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Asset ID is required')
|
||||
.max(64, 'Asset ID must be 64 characters or fewer');
|
||||
|
||||
export const CreateHostRequest = z.object({
|
||||
assetId: AssetId,
|
||||
name: z.string().min(1).max(128),
|
||||
location: z.string().max(256).optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
@@ -10,6 +17,7 @@ export type CreateHostRequest = z.infer<typeof CreateHostRequest>;
|
||||
|
||||
export const UpdateHostRequest = z
|
||||
.object({
|
||||
assetId: AssetId.optional(),
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
location: z.string().max(256).nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './enums.js';
|
||||
export * from './auth.js';
|
||||
export * from './users.js';
|
||||
export * from './manufacturers.js';
|
||||
export * from './part-models.js';
|
||||
export * from './locations.js';
|
||||
export * from './parts.js';
|
||||
export * from './env.js';
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ISO datetime string (e.g. "2027-12-31T00:00:00.000Z"). Clients may send date-only "2027-12-31";
|
||||
// API layer is expected to coerce to Date.
|
||||
const IsoDate = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/));
|
||||
|
||||
export const CreateManufacturerRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
eolDate: IsoDate.optional().nullable(),
|
||||
});
|
||||
export type CreateManufacturerRequest = z.infer<typeof CreateManufacturerRequest>;
|
||||
|
||||
export const UpdateManufacturerRequest = z
|
||||
.object({
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateManufacturerRequest = z.infer<typeof UpdateManufacturerRequest>;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
const IsoDate = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/));
|
||||
|
||||
export const CreatePartModelRequest = z.object({
|
||||
manufacturerId: z.string().uuid(),
|
||||
mpn: z.string().min(1).max(128),
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
});
|
||||
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
|
||||
|
||||
export const UpdatePartModelRequest = z
|
||||
.object({
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
mpn: z.string().min(1).max(128).optional(),
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdatePartModelRequest = z.infer<typeof UpdatePartModelRequest>;
|
||||
|
||||
export const PartModelListQuery = PaginationQuery.extend({
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
q: z.string().max(128).optional(),
|
||||
eolBefore: IsoDate.optional(),
|
||||
});
|
||||
export type PartModelListQuery = z.infer<typeof PartModelListQuery>;
|
||||
@@ -3,50 +3,54 @@ import { BulkPartsRequest, CreatePartRequest, PartListQuery, UpdatePartRequest }
|
||||
|
||||
const mfgId = '11111111-1111-4111-8111-111111111111';
|
||||
const binId = '22222222-2222-4222-8222-222222222222';
|
||||
const modelId = '44444444-4444-4444-8444-444444444444';
|
||||
|
||||
describe('CreatePartRequest', () => {
|
||||
it('accepts a minimal valid payload', () => {
|
||||
it('accepts a partModelId-based payload', () => {
|
||||
const r = CreatePartRequest.parse({
|
||||
serialNumber: 'SN-1',
|
||||
mpn: 'MPN-1',
|
||||
manufacturerId: mfgId,
|
||||
partModelId: modelId,
|
||||
});
|
||||
expect(r.serialNumber).toBe('SN-1');
|
||||
});
|
||||
|
||||
it('rejects empty serial / mpn', () => {
|
||||
it('accepts a manufacturerId + mpn payload (auto-upsert path)', () => {
|
||||
const r = CreatePartRequest.parse({
|
||||
serialNumber: 'SN-1',
|
||||
manufacturerId: mfgId,
|
||||
mpn: 'MPN-1',
|
||||
});
|
||||
expect(r.mpn).toBe('MPN-1');
|
||||
});
|
||||
|
||||
it('rejects when neither partModelId nor (manufacturerId + mpn) is provided', () => {
|
||||
expect(
|
||||
CreatePartRequest.safeParse({ serialNumber: '', mpn: 'X', manufacturerId: mfgId }).success,
|
||||
CreatePartRequest.safeParse({ serialNumber: 'SN-1', manufacturerId: mfgId }).success,
|
||||
).toBe(false);
|
||||
expect(CreatePartRequest.safeParse({ serialNumber: 'SN-1' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty serial', () => {
|
||||
expect(
|
||||
CreatePartRequest.safeParse({ serialNumber: 'X', mpn: '', manufacturerId: mfgId }).success,
|
||||
CreatePartRequest.safeParse({ serialNumber: '', partModelId: modelId }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative price', () => {
|
||||
const res = CreatePartRequest.safeParse({
|
||||
serialNumber: 'X',
|
||||
mpn: 'Y',
|
||||
manufacturerId: mfgId,
|
||||
partModelId: modelId,
|
||||
price: -1,
|
||||
});
|
||||
expect(res.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-uuid manufacturer id', () => {
|
||||
expect(
|
||||
CreatePartRequest.safeParse({ serialNumber: 'X', mpn: 'Y', manufacturerId: 'not-uuid' })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('caps tagIds at 32', () => {
|
||||
const tagIds = Array.from({ length: 33 }, () => '33333333-3333-4333-8333-333333333333');
|
||||
expect(
|
||||
CreatePartRequest.safeParse({
|
||||
serialNumber: 'X',
|
||||
mpn: 'Y',
|
||||
manufacturerId: mfgId,
|
||||
partModelId: modelId,
|
||||
tagIds,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
@@ -66,6 +70,11 @@ describe('UpdatePartRequest', () => {
|
||||
const r = UpdatePartRequest.parse({ binId: null });
|
||||
expect(r.binId).toBeNull();
|
||||
});
|
||||
|
||||
it('permits nullable hostId to clear host assignment', () => {
|
||||
const r = UpdatePartRequest.parse({ hostId: null });
|
||||
expect(r.hostId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PartListQuery', () => {
|
||||
@@ -106,6 +115,11 @@ describe('BulkPartsRequest', () => {
|
||||
expect(r.binId).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts hostId=null to unassign', () => {
|
||||
const r = BulkPartsRequest.parse({ ids: [mfgId], hostId: null });
|
||||
expect(r.hostId).toBeNull();
|
||||
});
|
||||
|
||||
it('caps ids at 500', () => {
|
||||
const ids = Array.from({ length: 501 }, () => binId);
|
||||
expect(BulkPartsRequest.safeParse({ ids, state: 'SPARE' }).success).toBe(false);
|
||||
|
||||
@@ -2,31 +2,59 @@ import { z } from 'zod';
|
||||
import { PartState } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreatePartRequest = z.object({
|
||||
serialNumber: z.string().min(1).max(128),
|
||||
mpn: z.string().min(1).max(128),
|
||||
manufacturerId: z.string().uuid(),
|
||||
price: z.number().nonnegative().optional().nullable(),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
categoryId: z.string().uuid().optional().nullable(),
|
||||
replacementPartId: z.string().uuid().optional().nullable(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
});
|
||||
// A part create/update can either reference an existing PartModel directly (partModelId)
|
||||
// or auto-provision one via manufacturerId + mpn. Exactly one form is required on create.
|
||||
const modelSelector = z
|
||||
.object({
|
||||
partModelId: z.string().uuid().optional(),
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
mpn: z.string().min(1).max(128).optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) => v.partModelId !== undefined || (v.manufacturerId !== undefined && v.mpn !== undefined),
|
||||
{ message: 'Provide partModelId or both manufacturerId and mpn' },
|
||||
);
|
||||
|
||||
export const CreatePartRequest = z
|
||||
.object({
|
||||
serialNumber: z.string().min(1).max(128),
|
||||
partModelId: z.string().uuid().optional(),
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
mpn: z.string().min(1).max(128).optional(),
|
||||
price: z.number().nonnegative().optional().nullable(),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().optional().nullable(),
|
||||
hostId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
categoryId: z.string().uuid().optional().nullable(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const parsed = modelSelector.safeParse({
|
||||
partModelId: v.partModelId,
|
||||
manufacturerId: v.manufacturerId,
|
||||
mpn: v.mpn,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Provide partModelId or both manufacturerId and mpn',
|
||||
path: ['partModelId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
|
||||
|
||||
export const UpdatePartRequest = z
|
||||
.object({
|
||||
serialNumber: z.string().min(1).max(128).optional(),
|
||||
mpn: z.string().min(1).max(128).optional(),
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
partModelId: z.string().uuid().optional(),
|
||||
price: z.number().nonnegative().nullable().optional(),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().nullable().optional(),
|
||||
hostId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
replacementPartId: z.string().uuid().nullable().optional(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
@@ -36,6 +64,8 @@ export const PartListQuery = PaginationQuery.extend({
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().optional(),
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
partModelId: z.string().uuid().optional(),
|
||||
hostId: z.string().uuid().optional(),
|
||||
mpn: z.string().max(128).optional(),
|
||||
serialNumber: z.string().max(128).optional(),
|
||||
q: z.string().max(128).optional(),
|
||||
@@ -56,6 +86,7 @@ export const BulkPartsRequest = z
|
||||
ids: z.array(z.string().uuid()).min(1).max(500),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().nullable().optional(),
|
||||
hostId: z.string().uuid().nullable().optional(),
|
||||
addTagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
removeTagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
@@ -63,6 +94,7 @@ export const BulkPartsRequest = z
|
||||
(v) =>
|
||||
v.state !== undefined ||
|
||||
v.binId !== undefined ||
|
||||
v.hostId !== undefined ||
|
||||
(v.addTagIds && v.addTagIds.length > 0) ||
|
||||
(v.removeTagIds && v.removeTagIds.length > 0),
|
||||
{ message: 'At least one mutation field is required' },
|
||||
|
||||
@@ -3,8 +3,9 @@ import { RepairStatus } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreateRepairJobRequest = z.object({
|
||||
partId: z.string().uuid(),
|
||||
hostId: z.string().uuid().optional().nullable(),
|
||||
hostId: z.string().uuid(),
|
||||
problem: z.string().trim().min(1, 'Problem is required').max(2000),
|
||||
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
assigneeId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
});
|
||||
@@ -13,7 +14,8 @@ export type CreateRepairJobRequest = z.infer<typeof CreateRepairJobRequest>;
|
||||
export const UpdateRepairJobRequest = z
|
||||
.object({
|
||||
status: RepairStatus.optional(),
|
||||
hostId: z.string().uuid().nullable().optional(),
|
||||
problem: z.string().trim().min(1).max(2000).optional(),
|
||||
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
|
||||
assigneeId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
})
|
||||
@@ -22,8 +24,8 @@ export type UpdateRepairJobRequest = z.infer<typeof UpdateRepairJobRequest>;
|
||||
|
||||
export const RepairJobListQuery = PaginationQuery.extend({
|
||||
status: RepairStatus.optional(),
|
||||
partId: z.string().uuid().optional(),
|
||||
hostId: z.string().uuid().optional(),
|
||||
problemPartId: z.string().uuid().optional(),
|
||||
assigneeId: z.string().uuid().optional(),
|
||||
openOnly: z
|
||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||
@@ -31,3 +33,11 @@ export const RepairJobListQuery = PaginationQuery.extend({
|
||||
.optional(),
|
||||
});
|
||||
export type RepairJobListQuery = z.infer<typeof RepairJobListQuery>;
|
||||
|
||||
export const CreateRepairCommentRequest = z.object({
|
||||
content: z.string().trim().min(1, 'Comment cannot be empty').max(4000),
|
||||
});
|
||||
export type CreateRepairCommentRequest = z.infer<typeof CreateRepairCommentRequest>;
|
||||
|
||||
export const RepairCommentListQuery = PaginationQuery;
|
||||
export type RepairCommentListQuery = z.infer<typeof RepairCommentListQuery>;
|
||||
|
||||
Reference in New Issue
Block a user