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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export const PartEventType = z.enum([
|
||||
'FIELD_UPDATED',
|
||||
'REPAIR_STARTED',
|
||||
'REPAIR_COMPLETED',
|
||||
'REPAIR_CANCELLED',
|
||||
'REPAIR_COMMENTED',
|
||||
'TAG_ADDED',
|
||||
'TAG_REMOVED',
|
||||
]);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user