feat: add MaintenanceRecord validation + CRUD (src/lib/maintenance-records.ts)

createMaintenanceRecord() opens a record and flips AssetItem.status to
"repair" inside one transaction, rejecting on missing/retired AssetItem,
an active Assignment, or an already-open record for the same item.
closeMaintenanceRecord() sets endDate/resolutionNotes and applies a
caller-supplied targetStatus (never defaulted) to the AssetItem, guarding
double-close and endDate>=startDate. Mirrors src/lib/assignments.ts's
Zod-validate + $transaction + WHERE-guarded updateMany race-closing pattern.
This commit is contained in:
toanhoang
2026-08-04 05:29:26 -04:00
parent 5089bd6dfc
commit 67fe335e76
+413
View File
@@ -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<CreateMaintenanceRecordResult> {
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<MaintenanceRecordRecord[]> {
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<CloseMaintenanceRecordResult> {
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;
}
}