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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user