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 -1
View File
@@ -1,7 +1,7 @@
import type { CreateHostRequest, UpdateHostRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Host } from './types.js';
import type { Host, Part } from './types.js';
export type HostListFilters = {
page?: number;
@@ -18,6 +18,11 @@ export async function getHost(id: string): Promise<Host> {
return res.data;
}
export async function listHostDeployedParts(id: string): Promise<Part[]> {
const res = await api.get<Part[]>(`/hosts/${id}/deployed-parts`);
return res.data;
}
export async function createHost(input: CreateHostRequest): Promise<Host> {
const res = await api.post<Host>('/hosts', input);
return res.data;
+41
View File
@@ -0,0 +1,41 @@
import type {
CreatePartModelRequest,
UpdatePartModelRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { PartModel } from './types.js';
export type PartModelListFilters = {
page?: number;
pageSize?: number;
manufacturerId?: string;
q?: string;
eolBefore?: string;
};
export function listPartModels(filters: PartModelListFilters = {}) {
return getList<PartModel>('/part-models', filters);
}
export async function getPartModel(id: string): Promise<PartModel> {
const res = await api.get<PartModel>(`/part-models/${id}`);
return res.data;
}
export async function createPartModel(input: CreatePartModelRequest): Promise<PartModel> {
const res = await api.post<PartModel>('/part-models', input);
return res.data;
}
export async function updatePartModel(
id: string,
input: UpdatePartModelRequest,
): Promise<PartModel> {
const res = await api.patch<PartModel>(`/part-models/${id}`, input);
return res.data;
}
export async function deletePartModel(id: string): Promise<void> {
await api.delete(`/part-models/${id}`);
}
+18 -7
View File
@@ -1,18 +1,19 @@
import type {
CreateRepairCommentRequest,
CreateRepairJobRequest,
RepairStatus,
UpdateRepairJobRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { RepairJob } from './types.js';
import type { RepairComment, RepairJob } from './types.js';
export type RepairListFilters = {
page?: number;
pageSize?: number;
status?: RepairStatus;
partId?: string;
hostId?: string;
problemPartId?: string;
assigneeId?: string;
openOnly?: boolean;
};
@@ -26,11 +27,6 @@ export async function getRepair(id: string): Promise<RepairJob> {
return res.data;
}
export async function listRepairsForPart(partId: string): Promise<RepairJob[]> {
const res = await api.get<RepairJob[]>(`/parts/${partId}/repairs`);
return res.data;
}
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
const res = await api.post<RepairJob>('/repairs', input);
return res.data;
@@ -47,3 +43,18 @@ export async function updateRepair(
export async function deleteRepair(id: string): Promise<void> {
await api.delete(`/repairs/${id}`);
}
export function listRepairComments(
id: string,
filters: { page?: number; pageSize?: number } = {},
) {
return getList<RepairComment>(`/repairs/${id}/comments`, filters);
}
export async function addRepairComment(
id: string,
input: CreateRepairCommentRequest,
): Promise<RepairComment> {
const res = await api.post<RepairComment>(`/repairs/${id}/comments`, input);
return res.data;
}
+37 -7
View File
@@ -6,11 +6,22 @@ import type { PartEventType, PartState, RepairStatus, Role } from '@vector/share
export interface Manufacturer {
id: string;
name: string;
eolDate: string | null;
createdAt: string;
updatedAt: string;
}
export interface PartModel {
id: string;
manufacturerId: string;
mpn: string;
eolDate: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
manufacturer?: Manufacturer;
_count?: { parts: number };
}
export interface Site {
id: string;
name: string;
@@ -42,18 +53,20 @@ export interface BinWithPath extends Bin {
export interface Part {
id: string;
serialNumber: string;
mpn: string;
partModelId: string;
manufacturerId: string;
price: number | null;
state: PartState;
binId: string | null;
categoryId: string | null;
replacementPartId: string | null;
hostId: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
manufacturer: Manufacturer;
partModel: PartModel;
bin: BinWithPath | null;
host: Host | null;
}
export interface PartEvent {
@@ -79,6 +92,7 @@ export interface User {
export interface Host {
id: string;
assetId: string;
name: string;
location: string | null;
notes: string | null;
@@ -101,20 +115,36 @@ export interface Category {
updatedAt: string;
}
export interface RepairJobProblemPart {
repairJobId: string;
partId: string;
createdAt: string;
part: Part;
}
export interface RepairJob {
id: string;
partId: string;
hostId: string | null;
hostId: string;
assigneeId: string | null;
status: RepairStatus;
problem: string;
notes: string | null;
openedAt: string;
closedAt: string | null;
createdAt: string;
updatedAt: string;
part: Part;
host: Host | null;
host: Host;
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
problemParts: RepairJobProblemPart[];
}
export interface RepairComment {
id: string;
repairJobId: string;
userId: string | null;
content: string;
createdAt: string;
user: Pick<User, 'id' | 'username'> | null;
}
export interface SavedView {
+8
View File
@@ -47,12 +47,20 @@ export const queryKeys = {
list: (filters?: Record<string, unknown>) =>
[...queryKeys.hosts.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
},
repairs: {
all: ['repairs'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.repairs.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const,
comments: (id: string) => [...queryKeys.repairs.all, 'comments', id] as const,
},
partModels: {
all: ['part-models'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.partModels.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.partModels.all, 'detail', id] as const,
},
tags: {
all: ['tags'] as const,