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
+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>;