feat: split Repairs into FM, Repair, and Custody workflows
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m0s

The old Repairs module had grown ticketing-system features (status lifecycle,
comments, assignee, notes) that duplicate what the external ticketing tool
already owns. Vector only needs to track whether maintenance is open or closed.

- Rename RepairJob -> Fm (OPEN/CLOSED only), drop RepairComment, assignee, notes
- New Repair table: persistent log of physical part swaps, with ingest on
  unknown broken MPN via partModels.upsertByMpn
- New custody model: PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
  states + Part.custodianId, with a "My Custody" page for drop-off
- PartModel.destroyOnFail routes broken parts to the destruction path
- Host lookup on /fms and /repairs accepts hostId XOR assetId
- Wire the dormant webhook emitter: fm.opened, fm.closed, repair.logged
- Single fresh Prisma migration (dev DB was wiped, no backfill)

Tests: 60 passing (custody transitions in parts.test.ts; new fms.test.ts,
repairs.test.ts, custody.test.ts covering happy paths, validation failures,
webhook emissions, and ingest-on-unknown-MPN).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 12:22:56 -04:00
parent 6690d8a5dd
commit 3d77f2846d
54 changed files with 3304 additions and 1287 deletions
+13
View File
@@ -0,0 +1,13 @@
import type { DropOffRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Part } from './types.js';
export function listMyCustody(filters: { page?: number; pageSize?: number } = {}) {
return getList<Part>('/custody/mine', filters);
}
export async function dropOff(partId: string, input: DropOffRequest): Promise<Part> {
const res = await api.post<Part>(`/custody/${partId}/drop-off`, input);
return res.data;
}
+36
View File
@@ -0,0 +1,36 @@
import type { CreateFmRequest, FmStatus, UpdateFmRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Fm } from './types.js';
export type FmListFilters = {
page?: number;
pageSize?: number;
status?: FmStatus;
hostId?: string;
problemPartId?: string;
openOnly?: boolean;
};
export function listFms(filters: FmListFilters = {}) {
return getList<Fm>('/fms', filters);
}
export async function getFm(id: string): Promise<Fm> {
const res = await api.get<Fm>(`/fms/${id}`);
return res.data;
}
export async function createFm(input: CreateFmRequest): Promise<Fm> {
const res = await api.post<Fm>('/fms', input);
return res.data;
}
export async function updateFm(id: string, input: UpdateFmRequest): Promise<Fm> {
const res = await api.patch<Fm>(`/fms/${id}`, input);
return res.data;
}
export async function deleteFm(id: string): Promise<void> {
await api.delete(`/fms/${id}`);
}
+2
View File
@@ -18,6 +18,8 @@ export type PartListFilters = {
binId?: string;
tagId?: string;
eolOnly?: boolean;
serialNumber?: string;
custodianId?: string;
};
export function listParts(filters: PartListFilters) {
+10 -43
View File
@@ -1,60 +1,27 @@
import type {
CreateRepairCommentRequest,
CreateRepairJobRequest,
RepairStatus,
UpdateRepairJobRequest,
} from '@vector/shared';
import type { LogRepairRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { RepairComment, RepairJob } from './types.js';
import type { Repair } from './types.js';
export type RepairListFilters = {
page?: number;
pageSize?: number;
status?: RepairStatus;
hostId?: string;
problemPartId?: string;
assigneeId?: string;
openOnly?: boolean;
performedById?: string;
fmId?: string;
since?: string;
};
export function listRepairs(filters: RepairListFilters = {}) {
return getList<RepairJob>('/repairs', filters);
return getList<Repair>('/repairs', filters);
}
export async function getRepair(id: string): Promise<RepairJob> {
const res = await api.get<RepairJob>(`/repairs/${id}`);
export async function getRepair(id: string): Promise<Repair> {
const res = await api.get<Repair>(`/repairs/${id}`);
return res.data;
}
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
const res = await api.post<RepairJob>('/repairs', input);
return res.data;
}
export async function updateRepair(
id: string,
input: UpdateRepairJobRequest,
): Promise<RepairJob> {
const res = await api.patch<RepairJob>(`/repairs/${id}`, input);
return res.data;
}
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);
export async function logRepair(input: LogRepairRequest): Promise<Repair> {
const res = await api.post<Repair>('/repairs', input);
return res.data;
}
+23 -15
View File
@@ -1,4 +1,4 @@
import type { PartEventType, PartState, RepairStatus, Role } from '@vector/shared';
import type { FmStatus, PartEventType, PartState, Role } from '@vector/shared';
// Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
// Keep these in sync with apps/api/src/services responses.
@@ -15,6 +15,7 @@ export interface PartModel {
manufacturerId: string;
mpn: string;
eolDate: string | null;
destroyOnFail: boolean;
notes: string | null;
createdAt: string;
updatedAt: string;
@@ -60,6 +61,7 @@ export interface Part {
binId: string | null;
categoryId: string | null;
hostId: string | null;
custodianId: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
@@ -67,6 +69,7 @@ export interface Part {
partModel: PartModel;
bin: BinWithPath | null;
host: Host | null;
custodian: Pick<User, 'id' | 'username'> | null;
}
export interface PartEvent {
@@ -115,42 +118,47 @@ export interface Category {
updatedAt: string;
}
export interface RepairJobProblemPart {
repairJobId: string;
export interface FmProblemPart {
fmId: string;
partId: string;
createdAt: string;
part: Part;
}
export interface RepairJob {
export interface Fm {
id: string;
hostId: string;
assigneeId: string | null;
status: RepairStatus;
status: FmStatus;
problem: string;
notes: string | null;
openedAt: string;
closedAt: string | null;
createdAt: string;
updatedAt: string;
host: Host;
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
problemParts: RepairJobProblemPart[];
problemParts: FmProblemPart[];
}
export interface RepairComment {
export interface Repair {
id: string;
repairJobId: string;
userId: string | null;
content: string;
hostId: string;
brokenPartId: string;
replacementPartId: string;
performedById: string;
performedAt: string;
fmId: string | null;
createdAt: string;
user: Pick<User, 'id' | 'username'> | null;
updatedAt: string;
host: Host;
brokenPart: Part;
replacement: Part;
performedBy: Pick<User, 'id' | 'username'>;
fm: { id: string; status: FmStatus } | null;
}
export interface SavedView {
id: string;
userId: string;
resource: 'parts' | 'repairs' | 'hosts' | 'manufacturers';
resource: 'parts' | 'fms' | 'repairs' | 'hosts' | 'manufacturers';
name: string;
filterJson: unknown;
createdAt: string;
+11 -1
View File
@@ -49,12 +49,22 @@ export const queryKeys = {
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
},
fms: {
all: ['fms'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.fms.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.fms.all, 'detail', 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,
},
custody: {
all: ['custody'] as const,
mine: (filters?: Record<string, unknown>) =>
[...queryKeys.custody.all, 'mine', filters ?? {}] as const,
},
partModels: {
all: ['part-models'] as const,