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");
+85 -68
View File
@@ -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 {
+1 -1
View File
@@ -33,5 +33,5 @@ export interface DashboardAnalytics {
ageBuckets: AgeBucket[];
topBins: BinCount[];
deployedPastEol: PartModelEolSummary[];
openRepairs: number;
openFms: number;
}
+12
View File
@@ -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>;
+16 -10
View File
@@ -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',
]);
+52
View File
@@ -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>;
+2
View File
@@ -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';
+2
View File
@@ -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' });
+89 -20
View File
@@ -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(),
+26 -35
View File
@@ -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>;