createMaintenanceRecord()'s AssetItem status guard pinned status:{not:"retired"}
instead of status:"in_stock", so two concurrent opens (or an open racing a
concurrent createAssignment()) could both pass the guard, producing two open
MaintenanceRecords or an orphaned active Assignment. Guard now pins "in_stock"
exactly, mirroring createAssignment()'s own atomic guard.
closeMaintenanceRecord()'s targetStatus accepted any of the 4 AssetItemStatus
values, including "assigned" — which sets AssetItem.status without creating a
corresponding Assignment row, desyncing Assignment (source of truth for who
holds what) from AssetItem.status with no way back. Restricted to
in_stock|retired, the only two outcomes a closed repair can actually resolve
to.
439 lines
18 KiB
TypeScript
439 lines
18 KiB
TypeScript
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<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. 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<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;
|
|
}
|
|
}
|