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 2 AssetItemStatus values a maintenance-record close is // allowed to land on — deliberately NOT the full 4-value AssetItemStatus // enum (see closeMaintenanceRecordSchema's targetStatus comment below for // why "assigned"/"repair" are excluded). const closeableTargetStatusValues = ["in_stock", "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." // Restricted to "in_stock" | "retired" only (NOT the full 4-value // AssetItemStatus enum) — those are the only two outcomes a repair can // actually resolve to (fixed → back to stock, unfixable → write off). // "assigned" is deliberately rejected: setting AssetItem.status to // "assigned" here would NOT create a corresponding Assignment row, so // Assignment (the source of truth for "who currently holds what") and // AssetItem.status would go out of sync with no way back — createAssignment() // only accepts a "in_stock" AssetItem, and there is no standalone // AssetItem-status-edit endpoint to undo it by hand. "repair" is // rejected too: it would leave the record's own AssetItem "in repair" // despite this exact call being the one that closes out its maintenance // record — a closed record whose item is still marked "repair" is a // contradiction, and it would also make the item wrongly ineligible for // a new maintenance record's own "no other open record" precondition to // ever apply cleanly (a new one could still be opened as a *different* // record row while the old one sits closed but the item is still // "repair"). targetStatus: z.enum(closeableTargetStatusValues, { errorMap: () => ({ message: "targetStatus is required and must be one of 'in_stock', '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: "in_stock" }`) rather than a * plain read-then-write. Pinning the exact required prior value (not * excluding "retired") matters: an excluding guard would still match an * item another concurrent transaction just flipped to "assigned" or * "repair", letting two concurrent opens (or an open racing an Assignment * create) both win. 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. Pins the // exact prior value ("in_stock") rather than excluding a value // ("not retired"), mirroring createAssignment()'s own status guard // (src/lib/assignments.ts) — the excluding form would still match an // AssetItem another concurrent transaction just flipped to "assigned" // or "repair" (neither equals "retired"), letting two opens (or an // open racing an Assignment create) both win. const statusUpdate = await tx.assetItem.updateMany({ where: { id: input.assetItemId, status: "in_stock" }, data: { status: "repair" }, }); if (statusUpdate.count === 0) { throw new MaintenanceRecordBusinessError( "assetItemId refers to an AssetItem that is not in_stock (lost a race to a concurrent status change)" ); } 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; } }