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
+6 -4
View File
@@ -18,10 +18,12 @@ export interface BinCount {
count: number;
}
export interface ManufacturerEolSummary {
export interface PartModelEolSummary {
partModelId: string;
mpn: string;
manufacturerId: string;
name: string;
eolDate: string | null;
manufacturerName: string;
eolDate: string;
deployedCount: number;
}
@@ -30,6 +32,6 @@ export interface DashboardAnalytics {
byState: StateCount[];
ageBuckets: AgeBucket[];
topBins: BinCount[];
deployedPastEol: ManufacturerEolSummary[];
deployedPastEol: PartModelEolSummary[];
openRepairs: number;
}
+2
View File
@@ -13,6 +13,8 @@ export const PartEventType = z.enum([
'FIELD_UPDATED',
'REPAIR_STARTED',
'REPAIR_COMPLETED',
'REPAIR_CANCELLED',
'REPAIR_COMMENTED',
'TAG_ADDED',
'TAG_REMOVED',
]);
+8
View File
@@ -1,7 +1,14 @@
import { z } from 'zod';
import { PaginationQuery } from './pagination.js';
const AssetId = z
.string()
.trim()
.min(1, 'Asset ID is required')
.max(64, 'Asset ID must be 64 characters or fewer');
export const CreateHostRequest = z.object({
assetId: AssetId,
name: z.string().min(1).max(128),
location: z.string().max(256).optional().nullable(),
notes: z.string().max(4096).optional().nullable(),
@@ -10,6 +17,7 @@ export type CreateHostRequest = z.infer<typeof CreateHostRequest>;
export const UpdateHostRequest = z
.object({
assetId: AssetId.optional(),
name: z.string().min(1).max(128).optional(),
location: z.string().max(256).nullable().optional(),
notes: z.string().max(4096).nullable().optional(),
+1
View File
@@ -2,6 +2,7 @@ export * from './enums.js';
export * from './auth.js';
export * from './users.js';
export * from './manufacturers.js';
export * from './part-models.js';
export * from './locations.js';
export * from './parts.js';
export * from './env.js';
-6
View File
@@ -1,19 +1,13 @@
import { z } from 'zod';
// ISO datetime string (e.g. "2027-12-31T00:00:00.000Z"). Clients may send date-only "2027-12-31";
// API layer is expected to coerce to Date.
const IsoDate = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/));
export const CreateManufacturerRequest = z.object({
name: z.string().min(1).max(128),
eolDate: IsoDate.optional().nullable(),
});
export type CreateManufacturerRequest = z.infer<typeof CreateManufacturerRequest>;
export const UpdateManufacturerRequest = z
.object({
name: z.string().min(1).max(128).optional(),
eolDate: IsoDate.nullable().optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
export type UpdateManufacturerRequest = z.infer<typeof UpdateManufacturerRequest>;
+29
View File
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { PaginationQuery } from './pagination.js';
const IsoDate = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/));
export const CreatePartModelRequest = z.object({
manufacturerId: z.string().uuid(),
mpn: z.string().min(1).max(128),
eolDate: IsoDate.nullable().optional(),
notes: z.string().max(4096).nullable().optional(),
});
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
export const UpdatePartModelRequest = z
.object({
manufacturerId: z.string().uuid().optional(),
mpn: z.string().min(1).max(128).optional(),
eolDate: IsoDate.nullable().optional(),
notes: z.string().max(4096).nullable().optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
export type UpdatePartModelRequest = z.infer<typeof UpdatePartModelRequest>;
export const PartModelListQuery = PaginationQuery.extend({
manufacturerId: z.string().uuid().optional(),
q: z.string().max(128).optional(),
eolBefore: IsoDate.optional(),
});
export type PartModelListQuery = z.infer<typeof PartModelListQuery>;
+31 -17
View File
@@ -3,50 +3,54 @@ import { BulkPartsRequest, CreatePartRequest, PartListQuery, UpdatePartRequest }
const mfgId = '11111111-1111-4111-8111-111111111111';
const binId = '22222222-2222-4222-8222-222222222222';
const modelId = '44444444-4444-4444-8444-444444444444';
describe('CreatePartRequest', () => {
it('accepts a minimal valid payload', () => {
it('accepts a partModelId-based payload', () => {
const r = CreatePartRequest.parse({
serialNumber: 'SN-1',
mpn: 'MPN-1',
manufacturerId: mfgId,
partModelId: modelId,
});
expect(r.serialNumber).toBe('SN-1');
});
it('rejects empty serial / mpn', () => {
it('accepts a manufacturerId + mpn payload (auto-upsert path)', () => {
const r = CreatePartRequest.parse({
serialNumber: 'SN-1',
manufacturerId: mfgId,
mpn: 'MPN-1',
});
expect(r.mpn).toBe('MPN-1');
});
it('rejects when neither partModelId nor (manufacturerId + mpn) is provided', () => {
expect(
CreatePartRequest.safeParse({ serialNumber: '', mpn: 'X', manufacturerId: mfgId }).success,
CreatePartRequest.safeParse({ serialNumber: 'SN-1', manufacturerId: mfgId }).success,
).toBe(false);
expect(CreatePartRequest.safeParse({ serialNumber: 'SN-1' }).success).toBe(false);
});
it('rejects empty serial', () => {
expect(
CreatePartRequest.safeParse({ serialNumber: 'X', mpn: '', manufacturerId: mfgId }).success,
CreatePartRequest.safeParse({ serialNumber: '', partModelId: modelId }).success,
).toBe(false);
});
it('rejects negative price', () => {
const res = CreatePartRequest.safeParse({
serialNumber: 'X',
mpn: 'Y',
manufacturerId: mfgId,
partModelId: modelId,
price: -1,
});
expect(res.success).toBe(false);
});
it('rejects non-uuid manufacturer id', () => {
expect(
CreatePartRequest.safeParse({ serialNumber: 'X', mpn: 'Y', manufacturerId: 'not-uuid' })
.success,
).toBe(false);
});
it('caps tagIds at 32', () => {
const tagIds = Array.from({ length: 33 }, () => '33333333-3333-4333-8333-333333333333');
expect(
CreatePartRequest.safeParse({
serialNumber: 'X',
mpn: 'Y',
manufacturerId: mfgId,
partModelId: modelId,
tagIds,
}).success,
).toBe(false);
@@ -66,6 +70,11 @@ describe('UpdatePartRequest', () => {
const r = UpdatePartRequest.parse({ binId: null });
expect(r.binId).toBeNull();
});
it('permits nullable hostId to clear host assignment', () => {
const r = UpdatePartRequest.parse({ hostId: null });
expect(r.hostId).toBeNull();
});
});
describe('PartListQuery', () => {
@@ -106,6 +115,11 @@ describe('BulkPartsRequest', () => {
expect(r.binId).toBeNull();
});
it('accepts hostId=null to unassign', () => {
const r = BulkPartsRequest.parse({ ids: [mfgId], hostId: null });
expect(r.hostId).toBeNull();
});
it('caps ids at 500', () => {
const ids = Array.from({ length: 501 }, () => binId);
expect(BulkPartsRequest.safeParse({ ids, state: 'SPARE' }).success).toBe(false);
+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' },
+14 -4
View File
@@ -3,8 +3,9 @@ import { RepairStatus } from './enums.js';
import { PaginationQuery } from './pagination.js';
export const CreateRepairJobRequest = z.object({
partId: z.string().uuid(),
hostId: z.string().uuid().optional().nullable(),
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(),
});
@@ -13,7 +14,8 @@ export type CreateRepairJobRequest = z.infer<typeof CreateRepairJobRequest>;
export const UpdateRepairJobRequest = z
.object({
status: RepairStatus.optional(),
hostId: z.string().uuid().nullable().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(),
})
@@ -22,8 +24,8 @@ export type UpdateRepairJobRequest = z.infer<typeof UpdateRepairJobRequest>;
export const RepairJobListQuery = PaginationQuery.extend({
status: RepairStatus.optional(),
partId: z.string().uuid().optional(),
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()])
@@ -31,3 +33,11 @@ export const RepairJobListQuery = PaginationQuery.extend({
.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>;