import { z } from 'zod'; import { PartState } from './enums.js'; import { PaginationQuery } from './pagination.js'; // 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' }, ); // 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': case 'PENDING_REPAIR': 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), 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(), custodianId: z.string().uuid().optional().nullable(), notes: z.string().max(4096).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'], }); } const state = v.state ?? 'SPARE'; 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; export const UpdatePartRequest = z .object({ serialNumber: z.string().min(1).max(128).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(), custodianId: z.string().uuid().nullable().optional(), notes: z.string().max(4096).nullable().optional(), tagIds: z.array(z.string().uuid()).max(32).optional(), }) .refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' }) .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; 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(), custodianId: z.string().uuid().optional(), mpn: z.string().max(128).optional(), serialNumber: z.string().max(128).optional(), q: z.string().max(128).optional(), categoryId: z.string().uuid().optional(), tagId: z.string().uuid().optional(), eolOnly: z .union([z.literal('true'), z.literal('false'), z.boolean()]) .transform((v) => v === true || v === 'true') .optional(), }); export type PartListQuery = z.infer; export const PartEventsQuery = PaginationQuery; export type PartEventsQuery = z.infer; export const BulkPartsRequest = z .object({ 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(), }) .refine( (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' }, ); export type BulkPartsRequest = z.infer;