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
@@ -0,0 +1,204 @@
-- Domain rework: EOL moves from Manufacturer to a new PartModel catalog (keyed by manufacturerId+mpn);
-- repairs move from Part-scoped to Host-scoped with an optional problem-parts join; Host gains a
-- required+unique assetId; RepairJob gains a `problem` field and loses its direct `partId` FK.
--
-- Data-preserving reshape for SQLite. Steps:
-- 1. Create new catalog + join + comment tables.
-- 2. Seed PartModel from DISTINCT (manufacturerId, mpn) on Part, carrying Manufacturer.eolDate forward.
-- 3. Snapshot existing RepairJob.partId into RepairJobPart so historic repairs still remember the part.
-- 4. Ensure every RepairJob has a hostId (synthesize "__Unassigned__" host if needed) before hostId NOT NULL.
-- 5. Rebuild Host (add assetId NOT NULL+unique, backfilled with "H-<short>"), Manufacturer (drop eolDate),
-- Part (add partModelId via lookup + hostId nullable, drop mpn + replacementPartId), and RepairJob
-- (drop partId, add problem with COALESCE(notes, fallback), hostId NOT NULL).
-- CreateTable: PartModel catalog
CREATE TABLE "PartModel" (
"id" TEXT NOT NULL PRIMARY KEY,
"manufacturerId" TEXT NOT NULL,
"mpn" TEXT NOT NULL,
"eolDate" DATETIME,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PartModel_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- Backfill PartModel from DISTINCT (manufacturerId, mpn) on existing Part rows, carrying the
-- current Manufacturer.eolDate into the new per-MPN eolDate. Admins re-tune per MPN afterward.
INSERT INTO "PartModel" ("id", "manufacturerId", "mpn", "eolDate", "createdAt", "updatedAt")
SELECT
lower(hex(randomblob(16))),
d."manufacturerId",
d."mpn",
m."eolDate",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM (SELECT DISTINCT "manufacturerId", "mpn" FROM "Part") d
LEFT JOIN "Manufacturer" m ON m."id" = d."manufacturerId";
-- CreateTable: RepairJobPart join
CREATE TABLE "RepairJobPart" (
"repairJobId" TEXT NOT NULL,
"partId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("repairJobId", "partId"),
CONSTRAINT "RepairJobPart_repairJobId_fkey" FOREIGN KEY ("repairJobId") REFERENCES "RepairJob" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RepairJobPart_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- Snapshot old single-part repairs into the new join table so each historic repair still points at
-- its original problem part.
INSERT INTO "RepairJobPart" ("repairJobId", "partId", "createdAt")
SELECT "id", "partId", CURRENT_TIMESTAMP FROM "RepairJob";
-- CreateTable: RepairComment
CREATE TABLE "RepairComment" (
"id" TEXT NOT NULL PRIMARY KEY,
"repairJobId" TEXT NOT NULL,
"userId" TEXT,
"content" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RepairComment_repairJobId_fkey" FOREIGN KEY ("repairJobId") REFERENCES "RepairJob" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RepairComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- Ensure every RepairJob has a hostId before we tighten the column to NOT NULL. Repairs with a
-- NULL hostId get attached to a synthetic "__Unassigned__" host so no rows are lost; admins can
-- reassign them afterward.
INSERT INTO "Host" ("id", "name", "createdAt", "updatedAt")
SELECT lower(hex(randomblob(16))), '__Unassigned__', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE EXISTS (SELECT 1 FROM "RepairJob" WHERE "hostId" IS NULL)
AND NOT EXISTS (SELECT 1 FROM "Host" WHERE "name" = '__Unassigned__');
UPDATE "RepairJob"
SET "hostId" = (SELECT "id" FROM "Host" WHERE "name" = '__Unassigned__')
WHERE "hostId" IS NULL;
-- RedefineTables: destructive reshape of Host / Manufacturer / Part / RepairJob.
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
-- Host: add assetId NOT NULL + unique, backfilled with "H-<first-8-chars-of-id>" so existing rows
-- carry a deterministic placeholder admins can rename.
CREATE TABLE "new_Host" (
"id" TEXT NOT NULL PRIMARY KEY,
"assetId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"location" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Host" ("id", "assetId", "name", "location", "notes", "createdAt", "updatedAt")
SELECT "id", 'H-' || substr("id", 1, 8), "name", "location", "notes", "createdAt", "updatedAt"
FROM "Host";
DROP TABLE "Host";
ALTER TABLE "new_Host" RENAME TO "Host";
CREATE UNIQUE INDEX "Host_assetId_key" ON "Host"("assetId");
CREATE UNIQUE INDEX "Host_name_key" ON "Host"("name");
-- Manufacturer: drop eolDate (EOL now lives on PartModel).
CREATE TABLE "new_Manufacturer" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Manufacturer" ("id", "name", "createdAt", "updatedAt")
SELECT "id", "name", "createdAt", "updatedAt" FROM "Manufacturer";
DROP TABLE "Manufacturer";
ALTER TABLE "new_Manufacturer" RENAME TO "Manufacturer";
CREATE UNIQUE INDEX "Manufacturer_name_key" ON "Manufacturer"("name");
-- Part: add partModelId (looked up via the backfilled PartModel rows), add hostId (nullable;
-- admins populate when deploying parts). Drop the free-text mpn column and the unused
-- replacementPartId self-relation.
CREATE TABLE "new_Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"serialNumber" TEXT NOT NULL,
"partModelId" TEXT NOT NULL,
"manufacturerId" TEXT NOT NULL,
"price" REAL,
"state" TEXT NOT NULL DEFAULT 'SPARE',
"binId" TEXT,
"categoryId" TEXT,
"hostId" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_partModelId_fkey" FOREIGN KEY ("partModelId") REFERENCES "PartModel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Part" ("id", "serialNumber", "partModelId", "manufacturerId", "price", "state", "binId", "categoryId", "hostId", "notes", "createdAt", "updatedAt")
SELECT
p."id",
p."serialNumber",
(SELECT pm."id" FROM "PartModel" pm WHERE pm."manufacturerId" = p."manufacturerId" AND pm."mpn" = p."mpn" LIMIT 1),
p."manufacturerId",
p."price",
p."state",
p."binId",
p."categoryId",
NULL,
p."notes",
p."createdAt",
p."updatedAt"
FROM "Part" p;
DROP TABLE "Part";
ALTER TABLE "new_Part" RENAME TO "Part";
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
CREATE INDEX "Part_state_idx" ON "Part"("state");
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
CREATE INDEX "Part_partModelId_idx" ON "Part"("partModelId");
CREATE INDEX "Part_categoryId_idx" ON "Part"("categoryId");
CREATE INDEX "Part_hostId_idx" ON "Part"("hostId");
-- RepairJob: drop partId (problem part now lives in RepairJobPart), require hostId, add problem
-- (carried over from notes as a best-effort fallback for historical rows).
CREATE TABLE "new_RepairJob" (
"id" TEXT NOT NULL PRIMARY KEY,
"hostId" TEXT NOT NULL,
"assigneeId" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"problem" TEXT NOT NULL,
"openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" DATETIME,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "RepairJob_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "RepairJob_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_RepairJob" ("id", "hostId", "assigneeId", "status", "problem", "openedAt", "closedAt", "notes", "createdAt", "updatedAt")
SELECT
r."id",
r."hostId",
r."assigneeId",
r."status",
COALESCE(r."notes", 'Imported repair — problem not recorded'),
r."openedAt",
r."closedAt",
r."notes",
r."createdAt",
r."updatedAt"
FROM "RepairJob" r;
DROP TABLE "RepairJob";
ALTER TABLE "new_RepairJob" RENAME TO "RepairJob";
CREATE INDEX "RepairJob_status_idx" ON "RepairJob"("status");
CREATE INDEX "RepairJob_hostId_idx" ON "RepairJob"("hostId");
CREATE INDEX "RepairJob_assigneeId_idx" ON "RepairJob"("assigneeId");
CREATE INDEX "RepairJob_status_openedAt_idx" ON "RepairJob"("status", "openedAt" DESC);
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- Indexes for the new catalog + join + comment tables.
CREATE INDEX "PartModel_manufacturerId_idx" ON "PartModel"("manufacturerId");
CREATE INDEX "PartModel_eolDate_idx" ON "PartModel"("eolDate");
CREATE UNIQUE INDEX "PartModel_manufacturerId_mpn_key" ON "PartModel"("manufacturerId", "mpn");
CREATE INDEX "RepairJobPart_partId_idx" ON "RepairJobPart"("partId");
CREATE INDEX "RepairComment_repairJobId_createdAt_idx" ON "RepairComment"("repairJobId", "createdAt");
+91 -49
View File
@@ -16,16 +16,17 @@ datasource db {
}
model User {
id String @id @default(uuid())
username String @unique
email String @unique
id String @id @default(uuid())
username String @unique
email String @unique
passwordHash String
role String @default("TECHNICIAN")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
role String @default("TECHNICIAN")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
partEvents PartEvent[]
refreshTokens RefreshToken[]
repairAssignments RepairJob[] @relation("RepairAssignee")
repairAssignments RepairJob[] @relation("RepairAssignee")
repairComments RepairComment[]
savedViews SavedView[]
csvImportJobs CsvImportJob[]
}
@@ -45,12 +46,28 @@ model RefreshToken {
}
model Manufacturer {
id String @id @default(uuid())
name String @unique
eolDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
id String @id @default(uuid())
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
partModels PartModel[]
}
model PartModel {
id String @id @default(uuid())
manufacturerId String
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
mpn String
eolDate DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
@@unique([manufacturerId, mpn])
@@index([manufacturerId])
@@index([eolDate])
}
model Site {
@@ -97,33 +114,33 @@ model Category {
}
model Part {
id String @id @default(uuid())
serialNumber String @unique
mpn String
manufacturerId String
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
price Float?
state String @default("SPARE")
binId String?
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
replacementPartId String?
replacement Part? @relation("PartReplacement", fields: [replacementPartId], references: [id], onDelete: SetNull)
replacedBy Part[] @relation("PartReplacement")
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events PartEvent[]
tags PartTag[]
repairs RepairJob[]
id String @id @default(uuid())
serialNumber String @unique
partModelId String
partModel PartModel @relation(fields: [partModelId], references: [id], onDelete: Restrict)
manufacturerId String
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
price Float?
state String @default("SPARE")
binId String?
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events PartEvent[]
tags PartTag[]
problemInRepairs RepairJobPart[]
@@index([state])
@@index([binId])
@@index([manufacturerId])
@@index([mpn])
@@index([partModelId])
@@index([categoryId])
@@index([replacementPartId])
@@index([hostId])
}
model PartEvent {
@@ -164,36 +181,61 @@ model PartTag {
model Host {
id String @id @default(uuid())
assetId String @unique
name String @unique
location String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
repairs RepairJob[]
}
model RepairJob {
id String @id @default(uuid())
partId String
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
assigneeId String?
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
status String @default("PENDING")
openedAt DateTime @default(now())
closedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
assigneeId String?
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
status String @default("PENDING")
problem String
openedAt DateTime @default(now())
closedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
problemParts RepairJobPart[]
comments RepairComment[]
@@index([partId])
@@index([status])
@@index([hostId])
@@index([assigneeId])
@@index([status, openedAt(sort: Desc)])
}
model RepairJobPart {
repairJobId String
partId String
repairJob RepairJob @relation(fields: [repairJobId], references: [id], onDelete: Cascade)
part Part @relation(fields: [partId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
@@id([repairJobId, partId])
@@index([partId])
}
model RepairComment {
id String @id @default(uuid())
repairJobId String
repairJob RepairJob @relation(fields: [repairJobId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
content String
createdAt DateTime @default(now())
@@index([repairJobId, createdAt])
}
model WebhookSubscription {
id String @id @default(uuid())
url String
+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>;