feat: laundry-list polish pass
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 59s

Seven bundled improvements:
- PartModel combobox on Add Part + Log Repair (known MPN auto-fills;
  unknown reveals manufacturer picker for catalog upsert).
- Host lifecycle: state (DEPLOYED/DEGRADED/TESTING) and stack
  (PRODUCTION/VETTING) fields, driven by external clients via the API.
- Locations page redesigned as a 2-pane tree + bin grid with breadcrumb.
- PENDING_REPAIR custody state: tech takes a SPARE into custody for a
  future swap; resolves to DEPLOYED via Repair or back to SPARE via a
  bin-required drop-off.
- Move Category from Part to PartModel; seed common categories
  (GPU/RAM/SSD/HDD/NIC/CPU/PSU/MOBO). Parts table gets a Category
  column and filter sourced from the model.
- Fix Deployed Value 100x bug on the Dashboard (price is stored as
  dollars, not cents).
- PartModels table shows "No" instead of "--" when destroyOnFail=false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 13:36:11 -04:00
parent 3d77f2846d
commit 60255f20bb
39 changed files with 1731 additions and 630 deletions
+16
View File
@@ -31,3 +31,19 @@ export async function dropOff(
next(err);
}
}
export async function takeForRepair(
req: Request<{ partId: string }>,
res: Response,
next: NextFunction,
) {
try {
if (!req.user) throw errors.unauthorized();
const part = await prisma.$transaction((tx) =>
svc.takeForRepair(tx, req.params.partId, req.user!),
);
res.json(part);
} catch (err) {
next(err);
}
}
+1
View File
@@ -13,5 +13,6 @@ router.post(
validate('body', DropOffRequest),
ctrl.dropOff,
);
router.post('/:partId/take-for-repair', requireAuth, ctrl.takeForRepair);
export default router;
+57 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { Tx, Actor } from './types.js';
import { dropOff } from './custody.js';
import { dropOff, takeForRepair } from './custody.js';
const custodian: Actor = { id: 'user-1', username: 'tech', role: 'TECHNICIAN' };
const admin: Actor = { id: 'user-admin', username: 'boss', role: 'ADMIN' };
@@ -166,4 +166,60 @@ describe('custody.dropOff', () => {
dropOff(tx, 'p-missing', { binId: null }, custodian),
).rejects.toMatchObject({ status: 404 });
});
it('PENDING_REPAIR → SPARE when returned with a bin; custodian cleared', async () => {
const { tx, current } = buildTx(
custodyPart({ state: 'PENDING_REPAIR', binId: null }),
);
await dropOff(tx, 'p-1', { binId: 'bin-7' }, custodian);
expect(current.state).toBe('SPARE');
expect(current.binId).toBe('bin-7');
expect(current.custodianId).toBeNull();
});
it('rejects PENDING_REPAIR drop-off without a bin with 400', async () => {
const { tx, partUpdate } = buildTx(
custodyPart({ state: 'PENDING_REPAIR', binId: null }),
);
await expect(
dropOff(tx, 'p-1', { binId: null }, custodian),
).rejects.toMatchObject({ status: 400 });
expect(partUpdate).not.toHaveBeenCalled();
});
});
describe('custody.takeForRepair', () => {
it('SPARE → PENDING_REPAIR with the actor as custodian', async () => {
const { tx, current } = buildTx(
custodyPart({ state: 'SPARE', custodianId: null, custodian: null, binId: 'bin-1' }),
);
await takeForRepair(tx, 'p-1', custodian);
expect(current.state).toBe('PENDING_REPAIR');
expect(current.custodianId).toBe('user-1');
});
it('rejects take-for-repair on a non-SPARE part with 400', async () => {
const { tx, partUpdate } = buildTx(custodyPart({ state: 'DEPLOYED' }));
await expect(takeForRepair(tx, 'p-1', custodian)).rejects.toMatchObject({
status: 400,
});
expect(partUpdate).not.toHaveBeenCalled();
});
it('rejects take-for-repair on a missing part with 404', async () => {
const tx = {
part: { findUnique: async () => null, update: vi.fn() },
partEvent: { createMany: vi.fn() },
} as unknown as Tx;
await expect(takeForRepair(tx, 'p-missing', custodian)).rejects.toMatchObject({
status: 404,
});
});
});
+32 -10
View File
@@ -27,6 +27,16 @@ export async function listMine(tx: Tx, userId: string, q: CustodyListQuery) {
return { data, page, pageSize, total };
}
// Map of custody-state → state the part returns to when dropped off.
// PENDING_REPAIR is a spare the tech picked up for a future swap; on return it goes back to
// SPARE and must land in a bin (no useful "in-limbo" state for spares). The broken-part
// paths continue to allow binId=null since techs sometimes hold broken parts without a bin.
const DROP_OFF_TARGET: Record<string, { next: 'BROKEN' | 'PENDING_DESTRUCTION' | 'SPARE'; binRequired: boolean }> = {
PENDING_DROP_IN_CUSTODY: { next: 'BROKEN', binRequired: false },
PENDING_DESTRUCTION_IN_CUSTODY: { next: 'PENDING_DESTRUCTION', binRequired: false },
PENDING_REPAIR: { next: 'SPARE', binRequired: true },
};
export async function dropOff(
tx: Tx,
partId: string,
@@ -36,23 +46,35 @@ export async function dropOff(
const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part');
if (
part.state !== 'PENDING_DROP_IN_CUSTODY' &&
part.state !== 'PENDING_DESTRUCTION_IN_CUSTODY'
) {
throw errors.badRequest('Part is not in custody');
}
const target = DROP_OFF_TARGET[part.state];
if (!target) throw errors.badRequest('Part is not in custody');
if (part.custodianId !== actor.id && actor.role !== 'ADMIN') {
throw errors.forbidden('Only the current custodian can drop off this part');
}
const nextState =
part.state === 'PENDING_DROP_IN_CUSTODY' ? 'BROKEN' : 'PENDING_DESTRUCTION';
if (target.binRequired && !input.binId) {
throw errors.badRequest('A bin is required when returning a spare to inventory');
}
return partsSvc.update(
tx,
partId,
{ state: nextState, binId: input.binId ?? null, custodianId: null },
{ state: target.next, binId: input.binId ?? null, custodianId: null },
actor,
);
}
// A tech takes a SPARE into their custody for a future repair. The part waits in
// PENDING_REPAIR until it's used in a Repair (→ DEPLOYED) or dropped back into a bin (→ SPARE).
export async function takeForRepair(tx: Tx, partId: string, actor: Actor) {
const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part');
if (part.state !== 'SPARE') {
throw errors.badRequest('Only SPARE parts can be taken for a repair');
}
return partsSvc.update(
tx,
partId,
{ state: 'PENDING_REPAIR', custodianId: actor.id },
actor,
);
}
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest';
import type { Tx } from './types.js';
import { create, update } from './hosts.js';
interface HostRow {
id: string;
assetId: string;
name: string;
location: string | null;
notes: string | null;
state: string;
stack: string;
}
function buildTx(seed: HostRow[] = []) {
const registry = new Map(seed.map((h) => [h.id, h]));
const tx = {
host: {
create: vi.fn(async (args: { data: Record<string, unknown> }) => {
const row: HostRow = {
id: `host-${registry.size + 1}`,
assetId: String(args.data.assetId),
name: String(args.data.name),
location: (args.data.location as string | null) ?? null,
notes: (args.data.notes as string | null) ?? null,
state: String(args.data.state ?? 'DEPLOYED'),
stack: String(args.data.stack ?? 'PRODUCTION'),
};
registry.set(row.id, row);
return row;
}),
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
const current = registry.get(args.where.id);
if (!current) throw new Error(`No host ${args.where.id}`);
const d = args.data;
if (d.assetId !== undefined) current.assetId = String(d.assetId);
if (d.name !== undefined) current.name = String(d.name);
if (d.location !== undefined) current.location = (d.location as string | null) ?? null;
if (d.notes !== undefined) current.notes = (d.notes as string | null) ?? null;
if (d.state !== undefined) current.state = String(d.state);
if (d.stack !== undefined) current.stack = String(d.stack);
return current;
}),
},
} as unknown as Tx;
return { tx, registry };
}
describe('hosts.create', () => {
it('defaults state=DEPLOYED and stack=PRODUCTION when not supplied', async () => {
const { tx } = buildTx();
const host = await create(tx, { assetId: 'A-1', name: 'rack-1' });
expect(host.state).toBe('DEPLOYED');
expect(host.stack).toBe('PRODUCTION');
});
it('persists explicit state and stack', async () => {
const { tx } = buildTx();
const host = await create(tx, {
assetId: 'A-2',
name: 'rack-2',
state: 'TESTING',
stack: 'VETTING',
});
expect(host.state).toBe('TESTING');
expect(host.stack).toBe('VETTING');
});
});
describe('hosts.update', () => {
it('updates state and stack when provided', async () => {
const { tx, registry } = buildTx([
{
id: 'host-1',
assetId: 'A-1',
name: 'rack-1',
location: null,
notes: null,
state: 'DEPLOYED',
stack: 'PRODUCTION',
},
]);
await update(tx, 'host-1', { state: 'DEGRADED', stack: 'VETTING' });
const row = registry.get('host-1')!;
expect(row.state).toBe('DEGRADED');
expect(row.stack).toBe('VETTING');
});
it('leaves state/stack untouched when not provided', async () => {
const { tx, registry } = buildTx([
{
id: 'host-1',
assetId: 'A-1',
name: 'rack-1',
location: null,
notes: null,
state: 'TESTING',
stack: 'VETTING',
},
]);
await update(tx, 'host-1', { name: 'rack-1-renamed' });
const row = registry.get('host-1')!;
expect(row.state).toBe('TESTING');
expect(row.stack).toBe('VETTING');
expect(row.name).toBe('rack-1-renamed');
});
});
+14 -9
View File
@@ -14,15 +14,16 @@ function mapUniqueViolation(target: unknown): string {
export async function list(tx: Tx, q: HostListQuery) {
const { page, pageSize, q: search } = q;
const where: Prisma.HostWhereInput = search
? {
OR: [
{ name: { contains: search } },
{ assetId: { contains: search } },
{ location: { contains: search } },
],
}
: {};
const where: Prisma.HostWhereInput = {};
if (search) {
where.OR = [
{ name: { contains: search } },
{ assetId: { contains: search } },
{ location: { contains: search } },
];
}
if (q.state) where.state = q.state;
if (q.stack) where.stack = q.stack;
const [data, total] = await Promise.all([
tx.host.findMany({
where,
@@ -55,6 +56,8 @@ export async function create(tx: Tx, input: CreateHostRequest) {
name: input.name,
location: input.location ?? null,
notes: input.notes ?? null,
state: input.state ?? 'DEPLOYED',
stack: input.stack ?? 'PRODUCTION',
},
});
} catch (err) {
@@ -71,6 +74,8 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
if (input.name !== undefined) data.name = input.name;
if (input.location !== undefined) data.location = input.location;
if (input.notes !== undefined) data.notes = input.notes;
if (input.state !== undefined) data.state = input.state;
if (input.stack !== undefined) data.stack = input.stack;
try {
return await tx.host.update({ where: { id }, data });
} catch (err) {
+11 -2
View File
@@ -9,13 +9,15 @@ import type { Tx } from './types.js';
const partModelInclude = {
manufacturer: true,
category: true,
_count: { select: { parts: true } },
} satisfies Prisma.PartModelInclude;
export async function list(tx: Tx, q: PartModelListQuery) {
const { page, pageSize, manufacturerId, q: search, eolBefore } = q;
const { page, pageSize, manufacturerId, categoryId, q: search, eolBefore } = q;
const where: Prisma.PartModelWhereInput = {};
if (manufacturerId) where.manufacturerId = manufacturerId;
if (categoryId) where.categoryId = categoryId;
if (search) where.mpn = { contains: search };
if (eolBefore) where.eolDate = { lte: new Date(eolBefore) };
@@ -42,6 +44,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
data: {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
categoryId: input.categoryId ?? null,
eolDate: input.eolDate ? new Date(input.eolDate) : null,
destroyOnFail: input.destroyOnFail ?? false,
notes: input.notes ?? null,
@@ -51,7 +54,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
if (err.code === 'P2003') throw errors.badRequest('Manufacturer does not exist');
if (err.code === 'P2003') throw errors.badRequest('Manufacturer or category does not exist');
}
throw err;
}
@@ -63,6 +66,11 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest)
data.manufacturer = { connect: { id: input.manufacturerId } };
}
if (input.mpn !== undefined) data.mpn = input.mpn;
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { disconnect: true };
}
if (input.eolDate !== undefined) {
data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
}
@@ -74,6 +82,7 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest)
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part model');
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
if (err.code === 'P2003') throw errors.badRequest('Manufacturer or category does not exist');
}
throw err;
}
+8 -21
View File
@@ -15,7 +15,7 @@ import type { Actor, Tx } from './types.js';
// The matrix is:
// DEPLOYED — hostId required, binId forbidden, custodianId forbidden
// SPARE / BROKEN / PENDING_DESTRUCTION — binId optional, hostId + custodian forbidden
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY / PENDING_REPAIR
// — custodianId required, host + bin forbidden
// Callers only need to pass what's changing; anything omitted is inherited from `current`.
function resolveLocation(
@@ -43,7 +43,11 @@ function resolveLocation(
return { binId: null, hostId, custodianId: null };
}
if (state === 'PENDING_DROP_IN_CUSTODY' || state === 'PENDING_DESTRUCTION_IN_CUSTODY') {
if (
state === 'PENDING_DROP_IN_CUSTODY' ||
state === 'PENDING_DESTRUCTION_IN_CUSTODY' ||
state === 'PENDING_REPAIR'
) {
const custodianId =
input.custodianId !== undefined ? input.custodianId : current.custodianId;
if (!custodianId) throw errors.badRequest('A part in custody must name a custodian');
@@ -65,9 +69,8 @@ function resolveLocation(
const partInclude = {
manufacturer: true,
partModel: true,
partModel: { include: { category: true } },
bin: { include: { room: { include: { site: true } } } },
category: true,
host: true,
custodian: { select: { id: true, username: true } },
tags: { include: { tag: true } },
@@ -127,7 +130,6 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
if (q.custodianId) where.custodianId = q.custodianId;
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
if (q.partModelId) where.partModelId = q.partModelId;
if (q.categoryId) where.categoryId = q.categoryId;
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
if (q.q) {
where.OR = [
@@ -141,6 +143,7 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
const partModelFilter: Prisma.PartModelWhereInput = {};
if (q.mpn) partModelFilter.mpn = { contains: q.mpn };
if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() };
if (q.categoryId) partModelFilter.categoryId = q.categoryId;
if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter;
return where;
@@ -196,7 +199,6 @@ export async function create(
binId: location.binId,
hostId: location.hostId,
custodianId: location.custodianId,
categoryId: input.categoryId ?? null,
notes: input.notes ?? null,
},
include: partInclude,
@@ -276,11 +278,6 @@ export async function update(
: { disconnect: true };
}
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { disconnect: true };
}
if (input.notes !== undefined) data.notes = input.notes;
let part: PartWithRelations;
@@ -357,16 +354,6 @@ export async function update(
newValue: input.serialNumber,
});
}
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'category',
oldValue: current.category?.name ?? null,
newValue: part.category?.name ?? null,
});
}
if (input.price !== undefined && input.price !== current.price) {
events.push({
partId: part.id,
+76
View File
@@ -514,6 +514,82 @@ describe('repairs.log — validation failures', () => {
expect(events).toEqual(['repair.logged']);
});
it('accepts a PENDING_REPAIR replacement held by the actor', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: brokenModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
host: host1,
partModel: brokenModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'PENDING_REPAIR',
custodianId: actor.id,
custodian: { id: actor.id, username: actor.username },
partModel: replacementModel,
});
const { tx, registry } = buildTx({ parts: [broken, replacement], hosts: [host1] });
const r = await log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
);
expect(r.id).toBe('repair-1');
const updatedReplacement = registry.get('p-replacement')!;
expect(updatedReplacement.state).toBe('DEPLOYED');
expect(updatedReplacement.hostId).toBe('host-1');
expect(updatedReplacement.custodianId).toBeNull();
});
it('rejects a PENDING_REPAIR replacement held by someone else with 400', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: brokenModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
host: host1,
partModel: brokenModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'PENDING_REPAIR',
custodianId: 'user-other',
custodian: { id: 'user-other', username: 'someone' },
partModel: replacementModel,
});
const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1] });
await expect(
log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
),
).rejects.toMatchObject({ status: 400 });
});
it('rejects when broken part is on a different host than the repair', async () => {
const broken = partRow({
id: 'p-broken',
+21 -7
View File
@@ -78,7 +78,7 @@ export async function log(
): Promise<RepairWithRelations> {
const host = await resolveHost(tx, input);
// 1. Resolve replacement — must exist, must be SPARE.
// 1. Resolve replacement — must exist; must be SPARE, or a PENDING_REPAIR held by the actor.
const replacement = await tx.part.findUnique({
where: { serialNumber: input.replacementSerial },
include: { partModel: true },
@@ -86,9 +86,11 @@ export async function log(
if (!replacement) {
throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`);
}
if (replacement.state !== 'SPARE') {
const heldForRepairByActor =
replacement.state === 'PENDING_REPAIR' && replacement.custodianId === actor.id;
if (replacement.state !== 'SPARE' && !heldForRepairByActor) {
throw errors.badRequest(
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE`,
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE or PENDING_REPAIR held by you`,
);
}
@@ -104,10 +106,22 @@ export async function log(
);
}
} else {
const pm = await partModelsSvc.upsertByMpn(tx, {
manufacturerId: input.brokenManufacturerId,
mpn: input.brokenMpn,
});
let pm: { id: string; manufacturerId: string };
if (input.brokenPartModelId) {
const existing = await tx.partModel.findUnique({ where: { id: input.brokenPartModelId } });
if (!existing) throw errors.badRequest('Broken part model does not exist');
pm = { id: existing.id, manufacturerId: existing.manufacturerId };
} else {
if (!input.brokenMpn || !input.brokenManufacturerId) {
throw errors.badRequest(
'Provide brokenPartModelId or both brokenMpn and brokenManufacturerId',
);
}
pm = await partModelsSvc.upsertByMpn(tx, {
manufacturerId: input.brokenManufacturerId,
mpn: input.brokenMpn,
});
}
const created = await tx.part.create({
data: {
serialNumber: input.brokenSerial,