diff --git a/src/lib/maintenance-records.ts b/src/lib/maintenance-records.ts new file mode 100644 index 0000000..eb0e73a --- /dev/null +++ b/src/lib/maintenance-records.ts @@ -0,0 +1,413 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma"; +import type { AssetItemStatus } from "./assets"; + +export type MaintenanceType = "repair" | "warranty" | "preventive"; + +export interface MaintenanceRecordRecord { + id: string; + asset_item_id: string; + type: MaintenanceType; + vendor_id: string | null; + issue_description: string; + resolution_notes: string | null; + start_date: string; + end_date: string | null; + created_at: string; +} + +// resolutionNotes/endDate are deliberately NOT accepted from the client at +// creation time — same precedent as Assignment.returnedAt not being +// accepted at creation (see src/lib/assignments.ts's CreateAssignmentInput +// doc comment): a MaintenanceRecord always opens with endDate/ +// resolutionNotes null, only closeMaintenanceRecord() sets them later. +export interface CreateMaintenanceRecordInput { + assetItemId: string; + type: MaintenanceType; + vendorId?: string | null; + issueDescription: string; + startDate: Date; +} + +export type ValidationResult = + | { ok: true; value: CreateMaintenanceRecordInput } + | { ok: false; error: string }; + +// Mirrors src/lib/assets.ts's nonEmptyString helper. +function nonEmptyString(message: string) { + return z.string({ required_error: message, invalid_type_error: message }).refine( + (value) => value.trim() !== "", + { message } + ); +} + +// Mirrors src/lib/assignments.ts's dateString helper — accepts any string +// Date.parse() can handle and transforms it into a Date for Prisma. +function dateString(message: string) { + return z + .string({ required_error: message, invalid_type_error: message }) + .refine((value) => !Number.isNaN(Date.parse(value)), { message }) + .transform((value) => new Date(value)); +} + +const createMaintenanceRecordSchema = z.object({ + // FK to AssetItem — only a UUID *format* check here. Unlike a plain + // supply-side FK (e.g. AssetItem.purchaseOrderId), existence is checked + // explicitly inside the transaction below (not deferred entirely to + // Prisma's insert-time FK constraint), because createMaintenanceRecord() + // needs to read the referenced row anyway to enforce the status/active- + // assignment/already-open business rules. Mirrors + // src/lib/assignments.ts's assetItemId handling. + assetItemId: z + .string({ required_error: "assetItemId is required and must be a valid UUID string" }) + .uuid("assetItemId is required and must be a valid UUID string"), + // Mirrors src/lib/requests.ts's type z.enum() pattern. + type: z.enum(["repair", "warranty", "preventive"], { + errorMap: () => ({ + message: "type is required and must be one of 'repair', 'warranty', 'preventive'", + }), + }), + // FK to Vendor — nullable/optional, null means an internal repair with no + // outside vendor involved (see schema.prisma's MaintenanceRecord comment). + // Only a UUID *format* check here, whether it references a real vendor is + // left to Prisma's own FK constraint at insert time (see + // createMaintenanceRecord()'s P2003 handling below). Mirrors + // src/lib/licenses.ts's vendorId handling (except nullable here). + vendorId: z + .string({ invalid_type_error: "vendorId must be a valid UUID string" }) + .uuid("vendorId must be a valid UUID string") + .optional() + .nullable(), + issueDescription: nonEmptyString( + "issueDescription is required and must be a non-empty string" + ), + startDate: dateString("startDate is required and must be a valid date string"), +}); + +/** + * Validates raw (untyped) maintenance-record-creation input. assetItemId + * must be a valid UUID string, type must be one of the three enum values, + * vendorId is optional/nullable but must be a valid UUID string if present, + * issueDescription must be a non-empty string, startDate must be a valid + * date string. resolutionNotes/endDate are not accepted here — see + * CreateMaintenanceRecordInput's doc comment for why. + */ +export function validateCreateMaintenanceRecordInput(input: { + assetItemId?: unknown; + type?: unknown; + vendorId?: unknown; + issueDescription?: unknown; + startDate?: unknown; +}): ValidationResult { + const result = createMaintenanceRecordSchema.safeParse(input); + if (!result.success) { + const message = result.error.issues[0]?.message ?? "Invalid input"; + return { ok: false, error: message }; + } + return { ok: true, value: result.data }; +} + +// Closed set of the 4 AssetItemStatus enum values — reused verbatim (not +// re-derived) as the z.enum() literal list backing targetStatus below. +const assetItemStatusValues = ["in_stock", "assigned", "repair", "retired"] as const; + +const closeMaintenanceRecordSchema = z.object({ + endDate: dateString("endDate is required and must be a valid date string"), + // Nullable/optional — a closed record doesn't strictly require notes (e.g. + // a warranty swap with nothing more to say than "resolved"). Mirrors the + // optional().nullable() shape used for other nullable-in-DB text/FK + // fields throughout this codebase (e.g. AssetItem.purchaseOrderId). + resolutionNotes: z + .string({ invalid_type_error: "resolutionNotes must be a string" }) + .optional() + .nullable(), + // Required — deliberately NOT defaulted to "in_stock", per docs/ + // asset-license-management.md's "MaintenanceRecord" section: "không tự + // động mặc định về in_stock, vì sửa không phải lúc nào cũng thành công." + // Any of the 4 AssetItemStatus values is accepted at the Zod layer (the + // doc's examples are in_stock/retired, but nothing in the spec restricts + // the set further) — see feature-implementer report's "Assumed" section. + targetStatus: z.enum(assetItemStatusValues, { + errorMap: () => ({ + message: + "targetStatus is required and must be one of 'in_stock', 'assigned', 'repair', 'retired'", + }), + }), +}); + +export type CloseMaintenanceRecordInput = { + endDate: Date; + resolutionNotes?: string | null; + targetStatus: AssetItemStatus; +}; + +export type CloseValidationResult = + | { ok: true; value: CloseMaintenanceRecordInput } + | { ok: false; error: string }; + +/** + * Validates raw (untyped) maintenance-record-close input. endDate must be a + * valid date string, resolutionNotes is optional/nullable but must be a + * string if present, targetStatus is required and must be a valid + * AssetItemStatus value. endDate >= startDate cannot be checked here (it + * needs the already-saved record's startDate from the DB) — that's enforced + * inside closeMaintenanceRecord() itself. + */ +export function validateCloseMaintenanceRecordInput(input: { + endDate?: unknown; + resolutionNotes?: unknown; + targetStatus?: unknown; +}): CloseValidationResult { + const result = closeMaintenanceRecordSchema.safeParse(input); + if (!result.success) { + const message = result.error.issues[0]?.message ?? "Invalid input"; + return { ok: false, error: message }; + } + return { ok: true, value: result.data }; +} + +function toRecord(row: { + id: string; + assetItemId: string; + type: MaintenanceType; + vendorId: string | null; + issueDescription: string; + resolutionNotes: string | null; + startDate: Date; + endDate: Date | null; + createdAt: Date; +}): MaintenanceRecordRecord { + return { + id: row.id, + asset_item_id: row.assetItemId, + type: row.type, + vendor_id: row.vendorId, + issue_description: row.issueDescription, + resolution_notes: row.resolutionNotes, + start_date: row.startDate.toISOString(), + end_date: row.endDate ? row.endDate.toISOString() : null, + created_at: row.createdAt.toISOString(), + }; +} + +// Internal error type used to carry a clean, already-user-facing message out +// of the `prisma.$transaction()` callback below, distinguishing +// application-level business-rule rejections (400) from unexpected/infra +// errors (rethrown as-is). Not exported — callers only ever see the +// {ok,error} result shape. Mirrors src/lib/assignments.ts's +// AssignmentBusinessError. +class MaintenanceRecordBusinessError extends Error { + constructor(message: string) { + super(message); + this.name = "MaintenanceRecordBusinessError"; + } +} + +class MaintenanceRecordNotFoundError extends Error { + constructor() { + super("MaintenanceRecord not found"); + this.name = "MaintenanceRecordNotFoundError"; + } +} + +export type CreateMaintenanceRecordResult = + | { ok: true; value: MaintenanceRecordRecord } + | { ok: false; error: string }; + +/** + * Opens a MaintenanceRecord for an AssetItem, enforcing every business rule + * from docs/asset-license-management.md's "## MaintenanceRecord (spec BA + + * stakeholder, chốt 2026-08-04)" section inside a single + * `prisma.$transaction()`: + * + * - Rejects if the AssetItem doesn't exist. + * - Rejects if the AssetItem is already "retired" (thanh lý thì gửi sửa + * không còn ý nghĩa). + * - Rejects if the AssetItem has an ACTIVE Assignment (returnedAt IS NULL) + * — must be returned first, mirrors createAssignment()'s own + * status precondition check. + * - Rejects if the AssetItem already has another OPEN MaintenanceRecord + * (endDate IS NULL) — at most one open record per item at a time (a + * physical item can't be in two repair places at once). + * - Atomically flips AssetItem.status to "repair". + * + * Race-condition note: mirrors src/lib/assignments.ts's createAssignment() + * pattern — the AssetItem.status flip uses a WHERE-guarded `updateMany` + * (`where: { id: assetItemId, status: { not: "retired" } }`) rather than a + * plain read-then-write. That `updateMany`'s `count` is the actual + * race-closing gate: two concurrent opens racing on the same AssetItem + * serialize on Postgres's row lock for that UPDATE, the first to commit + * wins, and the second's WHERE clause no longer matches (status is no + * longer what it read), so its `updateMany` returns `count: 0`, treated + * here as "lost the race". The `findUnique` above it is NOT itself the + * race-closing check (same TOCTOU caveat as createAssignment()'s doc + * comment) — it only produces a specific, friendly error message + * (not-found vs. retired vs. active-assignment vs. already-open) for the + * common (non-racing) case. + */ +export async function createMaintenanceRecord( + input: CreateMaintenanceRecordInput +): Promise { + try { + const created = await prisma.$transaction(async (tx) => { + const assetItem = await tx.assetItem.findUnique({ where: { id: input.assetItemId } }); + if (!assetItem) { + throw new MaintenanceRecordBusinessError( + "assetItemId does not reference an existing asset item" + ); + } + if (assetItem.status === "retired") { + throw new MaintenanceRecordBusinessError( + "assetItemId refers to an AssetItem that is retired and cannot be sent for maintenance" + ); + } + + const activeAssignment = await tx.assignment.findFirst({ + where: { assetItemId: input.assetItemId, returnedAt: null }, + }); + if (activeAssignment) { + throw new MaintenanceRecordBusinessError( + "assetItemId has an active assignment (returnedAt IS NULL) — return it before creating a maintenance record" + ); + } + + const openRecord = await tx.maintenanceRecord.findFirst({ + where: { assetItemId: input.assetItemId, endDate: null }, + }); + if (openRecord) { + throw new MaintenanceRecordBusinessError( + "assetItemId already has an open maintenance record (endDate IS NULL)" + ); + } + + // Atomic race-closing gate — see this function's doc comment. + const statusUpdate = await tx.assetItem.updateMany({ + where: { id: input.assetItemId, status: { not: "retired" } }, + data: { status: "repair" }, + }); + if (statusUpdate.count === 0) { + throw new MaintenanceRecordBusinessError( + "assetItemId refers to an AssetItem that is retired (lost a race to a concurrent retire)" + ); + } + + return tx.maintenanceRecord.create({ + data: { + assetItemId: input.assetItemId, + type: input.type, + vendorId: input.vendorId ?? null, + issueDescription: input.issueDescription, + startDate: input.startDate, + endDate: null, + resolutionNotes: null, + }, + }); + }); + + return { ok: true, value: toRecord(created) }; + } catch (error) { + if (error instanceof MaintenanceRecordBusinessError) { + return { ok: false, error: error.message }; + } + // Only vendorId can still fail with a raw FK violation here — + // assetItemId existence is already fully resolved above (found-or- + // rejected before the transaction ever reaches + // tx.maintenanceRecord.create()). + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + return { ok: false, error: "vendorId does not reference an existing vendor" }; + } + throw error; + } +} + +export async function listMaintenanceRecords(): Promise { + const rows = await prisma.maintenanceRecord.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +} + +export type CloseMaintenanceRecordResult = + | { ok: true; value: MaintenanceRecordRecord } + | { ok: false; status: 404 | 400; error: string }; + +/** + * Closes a MaintenanceRecord (sets endDate + resolutionNotes) and applies + * the caller-chosen `targetStatus` to its AssetItem, inside a single + * `prisma.$transaction()` — docs/asset-license-management.md's + * "MaintenanceRecord" section, "Đóng MaintenanceRecord" bullet. + * targetStatus is never defaulted (e.g. to "in_stock") — repair doesn't + * always succeed, the caller must say explicitly what state the item ends + * up in (e.g. "in_stock" if fixed, "retired" if the repair failed). + * + * Race-condition note: mirrors src/lib/assignments.ts's + * returnAssignment()/transferAssignment() pattern exactly — double-close is + * closed by a single atomic, WHERE-guarded `updateMany` + * (`where: { id, endDate: null }`) that both checks AND writes endDate/ + * resolutionNotes in one statement, rather than a separate guard check + * followed by a plain `update()`. Two concurrent close calls against the + * same MaintenanceRecord race on that row's UPDATE lock; only the first to + * commit gets `count: 1`, the second gets `count: 0` and is rejected as + * "already closed". The initial `findUnique` (used to read startDate for + * the endDate>=startDate check, and to distinguish "not found" from + * "already closed" in the common case) is NOT itself the race-closing check + * — same caveat as the Assignment lib's doc comments. The endDate>=startDate + * business-rule check is deliberately done BEFORE the atomic updateMany + * (using the record read by the preceding findUnique) since it only needs + * the record's already-immutable startDate, not anything the race could + * change. The subsequent AssetItem.status write does not need its own + * atomic guard — same rationale as returnAssignment()'s plain + * `tx.assetItem.update()` for its own status flip: targetStatus is applied + * unconditionally, there's no upper/lower bound to race on the way there + * was for License.seatsUsed at create time. + */ +export async function closeMaintenanceRecord( + id: string, + input: CloseMaintenanceRecordInput +): Promise { + try { + const updated = await prisma.$transaction(async (tx) => { + const record = await tx.maintenanceRecord.findUnique({ where: { id } }); + if (!record) { + throw new MaintenanceRecordNotFoundError(); + } + + if (input.endDate.getTime() < record.startDate.getTime()) { + throw new MaintenanceRecordBusinessError("endDate must be on or after startDate"); + } + + // Atomic race-closing gate — see this function's doc comment. Also + // doubles as the "already closed" rejection in the non-racing case, + // since `record.endDate !== null` implies this WHERE clause won't + // match either. + const claim = await tx.maintenanceRecord.updateMany({ + where: { id, endDate: null }, + data: { + endDate: input.endDate, + resolutionNotes: input.resolutionNotes ?? null, + }, + }); + if (claim.count === 0) { + throw new MaintenanceRecordBusinessError("MaintenanceRecord has already been closed"); + } + + await tx.assetItem.update({ + where: { id: record.assetItemId }, + data: { status: input.targetStatus }, + }); + + return tx.maintenanceRecord.findUniqueOrThrow({ where: { id } }); + }); + + return { ok: true, value: toRecord(updated) }; + } catch (error) { + if (error instanceof MaintenanceRecordNotFoundError) { + return { ok: false, status: 404, error: "MaintenanceRecord not found" }; + } + if (error instanceof MaintenanceRecordBusinessError) { + return { ok: false, status: 400, error: error.message }; + } + throw error; + } +}