Files
Vector/packages/shared/src/fms.ts
T
josh 3d77f2846d
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m0s
feat: split Repairs into FM, Repair, and Custody workflows
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>
2026-04-17 12:22:56 -04:00

53 lines
1.7 KiB
TypeScript

import { z } from 'zod';
import { FmStatus } from './enums.js';
import { PaginationQuery } from './pagination.js';
// Host lookup accepts either a uuid `hostId` or a string `assetId` — exactly one.
const hostSelector = {
hostId: z.string().uuid().optional(),
assetId: z.string().trim().min(1).max(128).optional(),
};
function hostSelectorRefine<T extends { hostId?: string; assetId?: string }>(
v: T,
ctx: z.RefinementCtx,
) {
const has = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
if (has !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide exactly one of hostId or assetId',
path: ['hostId'],
});
}
}
export const CreateFmRequest = z
.object({
...hostSelector,
problem: z.string().trim().min(1, 'Problem is required').max(2000),
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
})
.superRefine(hostSelectorRefine);
export type CreateFmRequest = z.infer<typeof CreateFmRequest>;
export const UpdateFmRequest = z
.object({
status: FmStatus.optional(),
problem: z.string().trim().min(1).max(2000).optional(),
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
export type UpdateFmRequest = z.infer<typeof UpdateFmRequest>;
export const FmListQuery = PaginationQuery.extend({
status: FmStatus.optional(),
hostId: z.string().uuid().optional(),
problemPartId: z.string().uuid().optional(),
openOnly: z
.union([z.literal('true'), z.literal('false'), z.boolean()])
.transform((v) => v === true || v === 'true')
.optional(),
});
export type FmListQuery = z.infer<typeof FmListQuery>;