From a72b8dbbbecb714692d27d6ecee70bb4bf5cc704 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 21:19:28 -0400 Subject: [PATCH] feat: add Assignment validation + CRUD (src/lib/assignments.ts) createAssignment() enforces the XOR (assetItemId/licenseId) at the Zod layer and the AssetItem.status/License.seatsUsed business rules inside a single prisma.$transaction(), using atomic WHERE-guarded updateMany() calls (not plain read-then-write) to close the seat-overbooking and double-assignment race conditions flagged for License by code-reviewer. returnAssignment() reverses those side-effects the same way and closes the double-return race with the same pattern. --- src/lib/assignments.ts | 409 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 src/lib/assignments.ts diff --git a/src/lib/assignments.ts b/src/lib/assignments.ts new file mode 100644 index 0000000..bcb6bfe --- /dev/null +++ b/src/lib/assignments.ts @@ -0,0 +1,409 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma"; + +export interface AssignmentRecord { + id: string; + employee_id: string; + asset_item_id: string | null; + license_id: string | null; + assigned_at: string; + returned_at: string | null; + signed_at: string | null; + created_at: string; +} + +// assignedAt/returnedAt/signedAt are all deliberately NOT accepted from the +// client at creation time — same precedent as AssetItem.status always +// starting "in_stock" and License.seatsUsed always starting 0: +// assignedAt is a system-set "moment of assignment" timestamp +// (Prisma's @default(now()) on Assignment.assignedAt), returnedAt must be +// null on creation (an Assignment cannot be created already-returned — that +// would make no sense given the seat/status side-effects below), and +// signedAt (marking handover paperwork as signed) is not wired to any +// endpoint in this task at all — see feature-implementer report's "Out of +// scope" section. It stays null until a future "mark as signed" operation +// is built. +export interface CreateAssignmentInput { + employeeId: string; + assetItemId?: string | null; + licenseId?: string | null; +} + +export type ValidationResult = + | { ok: true; value: CreateAssignmentInput } + | { ok: false; error: string }; + +// Mirrors src/lib/purchase-orders.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 createAssignmentSchema = z + .object({ + // FK to Employee — only a UUID *format* check here, whether it + // references a real employee is left to Prisma's own FK constraint at + // insert time (see createAssignment()'s P2003 handling below). Mirrors + // src/lib/requests.ts's employee_id handling. + employeeId: z + .string({ required_error: "employeeId is required and must be a valid UUID string" }) + .uuid("employeeId is required and must be a valid UUID string"), + // Exactly one of assetItemId/licenseId must be set — see the .refine() + // below for the XOR check. Unlike other nullable FKs in this codebase + // (e.g. AssetItem.purchaseOrderId), whether these reference a real row + // is NOT deferred entirely to Prisma's insert-time FK constraint — + // createAssignment() needs to read the referenced row anyway (to check + // AssetItem.status / License.seatsUsed business rules), so existence is + // checked explicitly inside the transaction instead. Only format + // validation happens here at the Zod layer. + assetItemId: z + .string({ invalid_type_error: "assetItemId must be a valid UUID string" }) + .uuid("assetItemId must be a valid UUID string") + .optional() + .nullable(), + licenseId: z + .string({ invalid_type_error: "licenseId must be a valid UUID string" }) + .uuid("licenseId must be a valid UUID string") + .optional() + .nullable(), + }) + .refine( + (value) => { + const hasAssetItem = value.assetItemId !== undefined && value.assetItemId !== null; + const hasLicense = value.licenseId !== undefined && value.licenseId !== null; + // XOR: exactly one of the two must be set. + return hasAssetItem !== hasLicense; + }, + { + message: "Exactly one of assetItemId or licenseId must be set (not both, not neither)", + // Attach the error to both fields so it surfaces regardless of which + // one the caller looks at first — mirrors + // src/lib/purchase-orders.ts's receivedAt/receivedById co-nullability + // refine, which attaches to a single path for the same reason. + path: ["assetItemId"], + } + ); + +/** + * Validates raw (untyped) assignment-creation input. employeeId must be a + * valid UUID string. Exactly one of assetItemId/licenseId must be a valid + * UUID string (XOR — both set or both unset is rejected). assignedAt, + * returnedAt, and signedAt are not accepted here — see CreateAssignmentInput's + * doc comment for why. + */ +export function validateCreateAssignmentInput(input: { + employeeId?: unknown; + assetItemId?: unknown; + licenseId?: unknown; +}): ValidationResult { + const result = createAssignmentSchema.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 }; +} + +const returnAssignmentSchema = z.object({ + // Optional — defaults to "now" at the time returnAssignment() runs if not + // provided (mirrors Assignment.assignedAt's own default-now behavior at + // creation). Accepting an explicit value lets a caller backdate a return + // (e.g. recording a return that was physically handled yesterday but only + // entered into the system today) — same spirit as PurchaseOrder accepting + // an explicit receivedAt rather than always stamping "now". + returnedAt: dateString("returnedAt must be a valid date string").optional(), +}); + +export type ReturnValidationResult = + | { ok: true; value: { returnedAt?: Date } } + | { ok: false; error: string }; + +/** + * Validates raw (untyped) assignment-return input. returnedAt is optional + * but must be a valid date string if present. + */ +export function validateReturnAssignmentInput(input: { + returnedAt?: unknown; +}): ReturnValidationResult { + const result = returnAssignmentSchema.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; + employeeId: string; + assetItemId: string | null; + licenseId: string | null; + assignedAt: Date; + returnedAt: Date | null; + signedAt: Date | null; + createdAt: Date; +}): AssignmentRecord { + return { + id: row.id, + employee_id: row.employeeId, + asset_item_id: row.assetItemId, + license_id: row.licenseId, + assigned_at: row.assignedAt.toISOString(), + returned_at: row.returnedAt ? row.returnedAt.toISOString() : null, + signed_at: row.signedAt ? row.signedAt.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. +class AssignmentBusinessError extends Error { + constructor(message: string) { + super(message); + this.name = "AssignmentBusinessError"; + } +} + +class AssignmentNotFoundError extends Error { + constructor() { + super("Assignment not found"); + this.name = "AssignmentNotFoundError"; + } +} + +export type CreateAssignmentResult = + | { ok: true; value: AssignmentRecord } + | { ok: false; error: string }; + +/** + * Creates an Assignment (assigns an AssetItem or a License seat to an + * Employee), enforcing every business rule from docs/ + * asset-license-management.md's "## Assignment (chốt 2026-08-02)" section + * inside a single `prisma.$transaction()`: + * + * - AssetItem branch: rejects if the AssetItem doesn't exist, rejects if its + * current status isn't "in_stock" (stakeholder-confirmed precondition — + * see the doc's "chốt 2026-08-02 (2)" note), rejects if there's already an + * ACTIVE Assignment (returnedAt IS NULL) for it (explicit + * belt-and-suspenders invariant per the doc, on top of the status check), + * then atomically flips AssetItem.status to "assigned". + * - License branch: rejects if the License doesn't exist, rejects if + * seatsUsed >= seatsTotal (no overbooking), then atomically increments + * seatsUsed by 1. + * + * Race-condition note (read this before touching the transaction body): + * both branches use a two-step "read for validation, then conditional + * `updateMany` with a WHERE guard" pattern instead of a plain read-then-write + * (`findUnique` then `update`). The WHERE-guarded `updateMany` — e.g. + * `updateMany({ where: { id, status: "in_stock" }, data: { status: + * "assigned" } })` — is the actual race-closing mechanism: Postgres + * evaluates and applies that as a single atomic UPDATE statement, taking a + * row lock for its duration. Two concurrent transactions racing on the same + * AssetItem/License row serialize on that lock — the first to commit wins, + * and the second's WHERE clause is re-evaluated against the now-committed + * row and no longer matches, so its `updateMany` returns `count: 0`, which + * this function treats as a "lost the race" rejection. This is why the + * `findUnique` calls right before each `updateMany` are NOT the + * race-closing check by themselves (there's a real TOCTOU gap between a + * `findUnique` and a later `update` in isolation) — they only exist to + * produce a specific, friendly error message (not-found vs. wrong-status vs. + * no-seats) for the *common* (non-racing) case; the `updateMany`'s `count` + * is always re-checked afterwards and is what actually gets trusted. + * seatsTotal itself is read once via that same `findUnique` and reused in + * the `updateMany`'s WHERE clause (`seatsUsed: { lt: seatsTotal }`) — this + * is safe because nothing in this codebase ever mutates seatsTotal after + * creation (no update endpoint exists for it), so it can't go stale between + * the read and the write within the same transaction. + * + * The explicit "no existing active Assignment for this AssetItem" check + * (`tx.assignment.findFirst({ where: { assetItemId, returnedAt: null } })`) + * is intentionally layered BEFORE the atomic status update, as defense in + * depth against AssetItem.status/Assignment-row drift (e.g. manual DB + * intervention) — under normal operation this codebase always keeps + * `status === "assigned"` in exact sync with "has an active Assignment", so + * the atomic status-guarded `updateMany` above is what actually closes the + * concurrency race for the AssetItem branch; this check does not need its + * own atomic guard on top because the transaction's row lock (from the + * subsequent `updateMany`) is what makes the overall operation safe, not + * this read on its own. + */ +export async function createAssignment( + input: CreateAssignmentInput +): Promise { + try { + const created = await prisma.$transaction(async (tx) => { + if (input.assetItemId) { + const assetItem = await tx.assetItem.findUnique({ where: { id: input.assetItemId } }); + if (!assetItem) { + throw new AssignmentBusinessError( + "assetItemId does not reference an existing asset item" + ); + } + if (assetItem.status !== "in_stock") { + throw new AssignmentBusinessError( + `assetItemId refers to an AssetItem that is not in_stock (current status: ${assetItem.status})` + ); + } + const existingActive = await tx.assignment.findFirst({ + where: { assetItemId: input.assetItemId, returnedAt: null }, + }); + if (existingActive) { + throw new AssignmentBusinessError( + "assetItemId already has an active assignment (returnedAt IS NULL)" + ); + } + + // Atomic race-closing gate — see this function's doc comment. + const statusUpdate = await tx.assetItem.updateMany({ + where: { id: input.assetItemId, status: "in_stock" }, + data: { status: "assigned" }, + }); + if (statusUpdate.count === 0) { + throw new AssignmentBusinessError( + "assetItemId refers to an AssetItem that is not in_stock (lost a race to another concurrent assignment)" + ); + } + } + + if (input.licenseId) { + const license = await tx.license.findUnique({ where: { id: input.licenseId } }); + if (!license) { + throw new AssignmentBusinessError("licenseId does not reference an existing license"); + } + if (license.seatsUsed >= license.seatsTotal) { + throw new AssignmentBusinessError( + "licenseId has no seats remaining (seatsUsed >= seatsTotal)" + ); + } + + // Atomic race-closing gate — see this function's doc comment. + const seatUpdate = await tx.license.updateMany({ + where: { id: input.licenseId, seatsUsed: { lt: license.seatsTotal } }, + data: { seatsUsed: { increment: 1 } }, + }); + if (seatUpdate.count === 0) { + throw new AssignmentBusinessError( + "licenseId has no seats remaining (lost a race to another concurrent assignment)" + ); + } + } + + return tx.assignment.create({ + data: { + employeeId: input.employeeId, + assetItemId: input.assetItemId ?? null, + licenseId: input.licenseId ?? null, + }, + }); + }); + + return { ok: true, value: toRecord(created) }; + } catch (error) { + if (error instanceof AssignmentBusinessError) { + return { ok: false, error: error.message }; + } + // Only employeeId can still fail with a raw FK violation here — + // assetItemId/licenseId existence is already fully resolved above + // (found-or-rejected before the transaction ever reaches + // tx.assignment.create()). + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + return { ok: false, error: "employeeId does not reference an existing employee" }; + } + throw error; + } +} + +export async function listAssignments(): Promise { + const rows = await prisma.assignment.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +} + +export type ReturnAssignmentResult = + | { ok: true; value: AssignmentRecord } + | { ok: false; status: 404 | 400; error: string }; + +/** + * Marks an Assignment as returned (sets returnedAt), and reverses its + * side-effects from createAssignment() inside the same transaction: + * - If assetItemId is set: flips AssetItem.status back to "in_stock" — + * always, regardless of any RMA/repair path (there isn't one built yet, + * per the task's instructions; see feature-implementer report). + * - If licenseId is set: atomically decrements License.seatsUsed by 1. + * + * Race-condition note: double-return is closed the same way as + * createAssignment()'s races — an atomic, WHERE-guarded `updateMany` + * (`where: { id, returnedAt: null }`) is the actual gate. Two concurrent + * PATCH-return calls against the same Assignment 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 returned". The initial `findUnique` (used to + * read assetItemId/licenseId for the AssetItem/License side-effects, and to + * distinguish "not found" from "already returned" in the common case) is + * NOT itself the race-closing check, same caveat as createAssignment()'s + * doc comment. Decrementing seatsUsed does not need its own conditional + * `updateMany` guard the way incrementing did on the assign side — a bare + * `{ decrement: 1 }` is a single atomic UPDATE with no upper bound to + * enforce (unlike assign's seatsUsed < seatsTotal check), so a plain + * `update()` is race-safe on its own here. + */ +export async function returnAssignment( + id: string, + input: { returnedAt?: Date } +): Promise { + try { + const updated = await prisma.$transaction(async (tx) => { + const assignment = await tx.assignment.findUnique({ where: { id } }); + if (!assignment) { + throw new AssignmentNotFoundError(); + } + + const returnedAt = input.returnedAt ?? new Date(); + + // Atomic race-closing gate — see this function's doc comment. Also + // doubles as the "already returned" rejection in the non-racing case, + // since `assignment.returnedAt !== null` implies this WHERE clause + // won't match either. + const claim = await tx.assignment.updateMany({ + where: { id, returnedAt: null }, + data: { returnedAt }, + }); + if (claim.count === 0) { + throw new AssignmentBusinessError("Assignment has already been returned"); + } + + if (assignment.assetItemId) { + await tx.assetItem.update({ + where: { id: assignment.assetItemId }, + data: { status: "in_stock" }, + }); + } + + if (assignment.licenseId) { + await tx.license.update({ + where: { id: assignment.licenseId }, + data: { seatsUsed: { decrement: 1 } }, + }); + } + + return tx.assignment.findUniqueOrThrow({ where: { id } }); + }); + + return { ok: true, value: toRecord(updated) }; + } catch (error) { + if (error instanceof AssignmentNotFoundError) { + return { ok: false, status: 404, error: "Assignment not found" }; + } + if (error instanceof AssignmentBusinessError) { + return { ok: false, status: 400, error: error.message }; + } + throw error; + } +}