feat: rework EOL, repairs, and hosts for real workflow
Four domain-model changes driven by exercising the deployed 2.0 build: - EOL moves from manufacturer to MPN via new PartModel catalog table, so alerts fire on the thing that actually ages. - Repairs re-home to Host (required hostId + problem text) with an optional RepairJobPart join for affected parts; drop Part.replacementPartId. - New /repairs/:id detail page with editable problem, part list, and a RepairComment thread (REPAIR_COMMENTED events fan out to each problem part's timeline). - Host.assetId (required, unique) surfaces prominently on the repair page so techs can confirm they're touching the right box. Single destructive migration reshapes existing dev data. All 7 packages typecheck clean; 30 API tests pass (9 new covering host membership, upsertByMpn idempotency + race, assetId 409, comment userId stamping). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,31 +2,59 @@ import { z } from 'zod';
|
||||
import { PartState } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
export const CreatePartRequest = z.object({
|
||||
serialNumber: z.string().min(1).max(128),
|
||||
mpn: z.string().min(1).max(128),
|
||||
manufacturerId: z.string().uuid(),
|
||||
price: z.number().nonnegative().optional().nullable(),
|
||||
state: PartState.optional(),
|
||||
binId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
categoryId: z.string().uuid().optional().nullable(),
|
||||
replacementPartId: z.string().uuid().optional().nullable(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
});
|
||||
// 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' },
|
||||
);
|
||||
|
||||
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(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
categoryId: z.string().uuid().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'],
|
||||
});
|
||||
}
|
||||
});
|
||||
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
|
||||
|
||||
export const UpdatePartRequest = z
|
||||
.object({
|
||||
serialNumber: z.string().min(1).max(128).optional(),
|
||||
mpn: z.string().min(1).max(128).optional(),
|
||||
manufacturerId: z.string().uuid().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(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
replacementPartId: 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' });
|
||||
@@ -36,6 +64,8 @@ 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(),
|
||||
mpn: z.string().max(128).optional(),
|
||||
serialNumber: z.string().max(128).optional(),
|
||||
q: z.string().max(128).optional(),
|
||||
@@ -56,6 +86,7 @@ export const BulkPartsRequest = z
|
||||
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(),
|
||||
})
|
||||
@@ -63,6 +94,7 @@ export const BulkPartsRequest = z
|
||||
(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' },
|
||||
|
||||
Reference in New Issue
Block a user