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.
This commit is contained in:
@@ -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<CreateAssignmentResult> {
|
||||||
|
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<AssignmentRecord[]> {
|
||||||
|
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<ReturnAssignmentResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user