feat: rework EOL, repairs, and hosts for real workflow
CI / Lint · Typecheck · Test · Build (push) Successful in 48s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m1s

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:
2026-04-17 10:17:29 -04:00
parent 23bd0f0c6a
commit 0f952d6c1b
50 changed files with 2665 additions and 602 deletions
+47 -15
View File
@@ -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' },