feat: split Repairs into FM, Repair, and Custody workflows
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:
@@ -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");
|
||||
@@ -16,19 +16,19 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role String @default("TECHNICIAN")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
partEvents PartEvent[]
|
||||
refreshTokens RefreshToken[]
|
||||
repairAssignments RepairJob[] @relation("RepairAssignee")
|
||||
repairComments RepairComment[]
|
||||
savedViews SavedView[]
|
||||
csvImportJobs CsvImportJob[]
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role String @default("TECHNICIAN")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
partEvents PartEvent[]
|
||||
refreshTokens RefreshToken[]
|
||||
custodyParts Part[] @relation("Custody")
|
||||
repairs Repair[]
|
||||
savedViews SavedView[]
|
||||
csvImportJobs CsvImportJob[]
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
@@ -60,6 +60,7 @@ model PartModel {
|
||||
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
|
||||
mpn String
|
||||
eolDate DateTime?
|
||||
destroyOnFail Boolean @default(false)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -114,26 +115,30 @@ model Category {
|
||||
}
|
||||
|
||||
model Part {
|
||||
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[]
|
||||
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)
|
||||
custodianId String?
|
||||
custodian User? @relation("Custody", fields: [custodianId], references: [id], onDelete: SetNull)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events PartEvent[]
|
||||
tags PartTag[]
|
||||
problemInFms FmPart[]
|
||||
brokenRepairs Repair[] @relation("BrokenRepairs")
|
||||
replacementRepairs Repair[] @relation("ReplacementRepairs")
|
||||
|
||||
@@index([state])
|
||||
@@index([binId])
|
||||
@@ -141,6 +146,7 @@ model Part {
|
||||
@@index([partModelId])
|
||||
@@index([categoryId])
|
||||
@@index([hostId])
|
||||
@@index([custodianId])
|
||||
}
|
||||
|
||||
model PartEvent {
|
||||
@@ -180,60 +186,71 @@ model PartTag {
|
||||
}
|
||||
|
||||
model Host {
|
||||
id String @id @default(uuid())
|
||||
assetId String @unique
|
||||
name String @unique
|
||||
id String @id @default(uuid())
|
||||
assetId String @unique
|
||||
name String @unique
|
||||
location String?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
repairs RepairJob[]
|
||||
fms Fm[]
|
||||
repairs Repair[]
|
||||
}
|
||||
|
||||
model RepairJob {
|
||||
id String @id @default(uuid())
|
||||
model Fm {
|
||||
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")
|
||||
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
|
||||
status String @default("OPEN")
|
||||
problem String
|
||||
openedAt DateTime @default(now())
|
||||
openedAt DateTime @default(now())
|
||||
closedAt DateTime?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
problemParts RepairJobPart[]
|
||||
comments RepairComment[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
problemParts FmPart[]
|
||||
repairs Repair[]
|
||||
|
||||
@@index([status])
|
||||
@@index([hostId])
|
||||
@@index([assigneeId])
|
||||
@@index([status, openedAt(sort: Desc)])
|
||||
@@map("fms")
|
||||
}
|
||||
|
||||
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())
|
||||
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([repairJobId, partId])
|
||||
@@id([fmId, partId])
|
||||
@@index([partId])
|
||||
@@map("fm_parts")
|
||||
}
|
||||
|
||||
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())
|
||||
model Repair {
|
||||
id String @id @default(uuid())
|
||||
hostId String
|
||||
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
|
||||
brokenPartId String
|
||||
brokenPart Part @relation("BrokenRepairs", fields: [brokenPartId], references: [id], onDelete: Restrict)
|
||||
replacementPartId String
|
||||
replacement Part @relation("ReplacementRepairs", fields: [replacementPartId], references: [id], onDelete: Restrict)
|
||||
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([repairJobId, createdAt])
|
||||
@@index([hostId, performedAt(sort: Desc)])
|
||||
@@index([fmId])
|
||||
@@index([performedById, performedAt(sort: Desc)])
|
||||
@@index([brokenPartId])
|
||||
@@index([replacementPartId])
|
||||
@@map("repairs")
|
||||
}
|
||||
|
||||
model WebhookSubscription {
|
||||
|
||||
@@ -33,5 +33,5 @@ export interface DashboardAnalytics {
|
||||
ageBuckets: AgeBucket[];
|
||||
topBins: BinCount[];
|
||||
deployedPastEol: PartModelEolSummary[];
|
||||
openRepairs: number;
|
||||
openFms: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const DropOffRequest = z.object({
|
||||
binId: z.string().uuid().nullable(),
|
||||
});
|
||||
export type DropOffRequest = z.infer<typeof DropOffRequest>;
|
||||
|
||||
export const CustodyListQuery = PaginationQuery.extend({
|
||||
userId: z.string().uuid().optional(),
|
||||
});
|
||||
export type CustodyListQuery = z.infer<typeof CustodyListQuery>;
|
||||
@@ -1,6 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PartState = z.enum(['SPARE', 'DEPLOYED', 'BROKEN', 'PENDING_DESTRUCTION']);
|
||||
export const PartState = z.enum([
|
||||
'SPARE',
|
||||
'DEPLOYED',
|
||||
'BROKEN',
|
||||
'PENDING_DESTRUCTION',
|
||||
'PENDING_DROP_IN_CUSTODY',
|
||||
'PENDING_DESTRUCTION_IN_CUSTODY',
|
||||
]);
|
||||
export type PartState = z.infer<typeof PartState>;
|
||||
|
||||
export const Role = z.enum(['ADMIN', 'TECHNICIAN']);
|
||||
@@ -11,17 +18,16 @@ export const PartEventType = z.enum([
|
||||
'STATE_CHANGED',
|
||||
'LOCATION_CHANGED',
|
||||
'FIELD_UPDATED',
|
||||
'REPAIR_STARTED',
|
||||
'REPAIR_COMPLETED',
|
||||
'REPAIR_CANCELLED',
|
||||
'REPAIR_COMMENTED',
|
||||
'FM_OPENED',
|
||||
'FM_CLOSED',
|
||||
'PART_SWAPPED',
|
||||
'TAG_ADDED',
|
||||
'TAG_REMOVED',
|
||||
]);
|
||||
export type PartEventType = z.infer<typeof PartEventType>;
|
||||
|
||||
export const RepairStatus = z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']);
|
||||
export type RepairStatus = z.infer<typeof RepairStatus>;
|
||||
export const FmStatus = z.enum(['OPEN', 'CLOSED']);
|
||||
export type FmStatus = z.infer<typeof FmStatus>;
|
||||
|
||||
export const CsvImportStatus = z.enum([
|
||||
'PENDING',
|
||||
@@ -39,9 +45,9 @@ export const WebhookEventName = z.enum([
|
||||
'part.deleted',
|
||||
'part.state_changed',
|
||||
'part.location_changed',
|
||||
'repair.started',
|
||||
'repair.completed',
|
||||
'repair.cancelled',
|
||||
'fm.opened',
|
||||
'fm.closed',
|
||||
'repair.logged',
|
||||
'tag.assigned',
|
||||
'tag.removed',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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>;
|
||||
@@ -8,7 +8,9 @@ export * from './parts.js';
|
||||
export * from './env.js';
|
||||
export * from './pagination.js';
|
||||
export * from './hosts.js';
|
||||
export * from './fms.js';
|
||||
export * from './repairs.js';
|
||||
export * from './custody.js';
|
||||
export * from './tags.js';
|
||||
export * from './categories.js';
|
||||
export * from './webhooks.js';
|
||||
|
||||
@@ -7,6 +7,7 @@ export const CreatePartModelRequest = z.object({
|
||||
manufacturerId: z.string().uuid(),
|
||||
mpn: z.string().min(1).max(128),
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
destroyOnFail: z.boolean().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
});
|
||||
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
|
||||
@@ -16,6 +17,7 @@ export const UpdatePartModelRequest = z
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
mpn: z.string().min(1).max(128).optional(),
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
destroyOnFail: z.boolean().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
|
||||
@@ -15,6 +15,28 @@ const modelSelector = z
|
||||
{ message: 'Provide partModelId or both manufacturerId and mpn' },
|
||||
);
|
||||
|
||||
// Which of hostId / binId / custodianId may be set for a given state.
|
||||
// `null` counts as "not set" for the purposes of these checks — callers are expected
|
||||
// to treat undefined / null consistently when wiring a request.
|
||||
export function allowedLocationFieldsForState(state: PartState): {
|
||||
hostId: 'required' | 'forbidden';
|
||||
binId: 'optional' | 'forbidden';
|
||||
custodianId: 'required' | 'forbidden';
|
||||
} {
|
||||
switch (state) {
|
||||
case 'DEPLOYED':
|
||||
return { hostId: 'required', binId: 'forbidden', custodianId: 'forbidden' };
|
||||
case 'PENDING_DROP_IN_CUSTODY':
|
||||
case 'PENDING_DESTRUCTION_IN_CUSTODY':
|
||||
return { hostId: 'forbidden', binId: 'forbidden', custodianId: 'required' };
|
||||
case 'SPARE':
|
||||
case 'BROKEN':
|
||||
case 'PENDING_DESTRUCTION':
|
||||
default:
|
||||
return { hostId: 'forbidden', binId: 'optional', custodianId: 'forbidden' };
|
||||
}
|
||||
}
|
||||
|
||||
export const CreatePartRequest = z
|
||||
.object({
|
||||
serialNumber: z.string().min(1).max(128),
|
||||
@@ -25,6 +47,7 @@ export const CreatePartRequest = z
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().optional().nullable(),
|
||||
hostId: z.string().uuid().optional().nullable(),
|
||||
custodianId: 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(),
|
||||
@@ -42,30 +65,43 @@ export const CreatePartRequest = z
|
||||
path: ['partModelId'],
|
||||
});
|
||||
}
|
||||
// State/location coupling: DEPLOYED parts live on a host; every other state lives in a bin.
|
||||
const state = v.state ?? 'SPARE';
|
||||
if (state === 'DEPLOYED') {
|
||||
if (!v.hostId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'A deployed part must be assigned to a host',
|
||||
path: ['hostId'],
|
||||
});
|
||||
}
|
||||
if (v.binId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'A deployed part cannot also be in a storage bin',
|
||||
path: ['binId'],
|
||||
});
|
||||
}
|
||||
} else if (v.hostId) {
|
||||
const rules = allowedLocationFieldsForState(state);
|
||||
if (rules.hostId === 'required' && !v.hostId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'A deployed part must be assigned to a host',
|
||||
path: ['hostId'],
|
||||
});
|
||||
}
|
||||
if (rules.hostId === 'forbidden' && v.hostId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Only deployed parts can be assigned to a host',
|
||||
path: ['hostId'],
|
||||
});
|
||||
}
|
||||
if (rules.binId === 'forbidden' && v.binId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'This state cannot have a bin assignment',
|
||||
path: ['binId'],
|
||||
});
|
||||
}
|
||||
if (rules.custodianId === 'required' && !v.custodianId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'A part in custody must name a custodian',
|
||||
path: ['custodianId'],
|
||||
});
|
||||
}
|
||||
if (rules.custodianId === 'forbidden' && v.custodianId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Only parts in a custody state can have a custodian',
|
||||
path: ['custodianId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
|
||||
|
||||
@@ -77,14 +113,46 @@ export const UpdatePartRequest = z
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().nullable().optional(),
|
||||
hostId: z.string().uuid().nullable().optional(),
|
||||
custodianId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: 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' })
|
||||
.refine((v) => !(v.binId && v.hostId), {
|
||||
message: 'A part cannot be assigned to both a host and a bin',
|
||||
path: ['binId'],
|
||||
.superRefine((v, ctx) => {
|
||||
// When state is supplied we can enforce the full matrix against the input fields.
|
||||
// When state is absent the server resolver still enforces the invariant using
|
||||
// current-row state + input overlay, so we keep zod to input-level sanity checks.
|
||||
if (v.state) {
|
||||
const rules = allowedLocationFieldsForState(v.state);
|
||||
if (rules.hostId === 'forbidden' && v.hostId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Only deployed parts can be assigned to a host',
|
||||
path: ['hostId'],
|
||||
});
|
||||
}
|
||||
if (rules.binId === 'forbidden' && v.binId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'This state cannot have a bin assignment',
|
||||
path: ['binId'],
|
||||
});
|
||||
}
|
||||
if (rules.custodianId === 'forbidden' && v.custodianId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Only parts in a custody state can have a custodian',
|
||||
path: ['custodianId'],
|
||||
});
|
||||
}
|
||||
} else if (v.binId && v.hostId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'A part cannot be assigned to both a host and a bin',
|
||||
path: ['binId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
export type UpdatePartRequest = z.infer<typeof UpdatePartRequest>;
|
||||
|
||||
@@ -94,6 +162,7 @@ export const PartListQuery = PaginationQuery.extend({
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
partModelId: z.string().uuid().optional(),
|
||||
hostId: z.string().uuid().optional(),
|
||||
custodianId: z.string().uuid().optional(),
|
||||
mpn: z.string().max(128).optional(),
|
||||
serialNumber: z.string().max(128).optional(),
|
||||
q: z.string().max(128).optional(),
|
||||
|
||||
@@ -1,43 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
import { RepairStatus } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreateRepairJobRequest = z.object({
|
||||
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(),
|
||||
});
|
||||
export type CreateRepairJobRequest = z.infer<typeof CreateRepairJobRequest>;
|
||||
|
||||
export const UpdateRepairJobRequest = z
|
||||
// Repair = a physical part-swap log entry. Tech enters host + broken serial/mpn + replacement serial.
|
||||
// If the broken part isn't in the catalog yet it gets auto-ingested (requires mpn + manufacturer).
|
||||
export const LogRepairRequest = z
|
||||
.object({
|
||||
status: RepairStatus.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(),
|
||||
hostId: z.string().uuid().optional(),
|
||||
assetId: z.string().trim().min(1).max(128).optional(),
|
||||
brokenSerial: z.string().trim().min(1).max(128),
|
||||
brokenMpn: z.string().trim().min(1).max(128),
|
||||
brokenManufacturerId: z.string().uuid(),
|
||||
replacementSerial: z.string().trim().min(1).max(128),
|
||||
fmId: z.string().uuid().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateRepairJobRequest = z.infer<typeof UpdateRepairJobRequest>;
|
||||
.superRefine((v, ctx) => {
|
||||
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 type LogRepairRequest = z.infer<typeof LogRepairRequest>;
|
||||
|
||||
export const RepairJobListQuery = PaginationQuery.extend({
|
||||
status: RepairStatus.optional(),
|
||||
export const RepairListQuery = PaginationQuery.extend({
|
||||
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()])
|
||||
.transform((v) => v === true || v === 'true')
|
||||
.optional(),
|
||||
performedById: z.string().uuid().optional(),
|
||||
fmId: z.string().uuid().optional(),
|
||||
since: z.string().datetime().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>;
|
||||
export type RepairListQuery = z.infer<typeof RepairListQuery>;
|
||||
|
||||
Reference in New Issue
Block a user