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
+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);