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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user