fix: add atomic Assignment transfer (close old + create new in one transaction)
The only way to move an Assignment to a new employee was two separate calls (PATCH return + POST create), which is not atomic and left a race window where a third party could grab the now-in_stock AssetItem in between, plus no rollback if the second call failed. Adds transferAssignment() which closes the old Assignment and creates the new one inside a single prisma.$transaction(), per docs/asset-license-management.md's "Assignment (chốt 2026-08-02)" spec. Does not touch AssetItem.status or License.seatsUsed — the resource never becomes free mid-transfer. Wired into PATCH /api/assignments/[id]: an employeeId key in the body dispatches to transfer, a returnedAt key to return, both together is rejected as mutually exclusive.
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { returnAssignment, validateReturnAssignmentInput } from "@/lib/assignments";
|
||||
import {
|
||||
returnAssignment,
|
||||
transferAssignment,
|
||||
validateReturnAssignmentInput,
|
||||
validateTransferAssignmentInput,
|
||||
} from "@/lib/assignments";
|
||||
|
||||
// First dynamic-segment ([id]) route in the codebase — see
|
||||
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)" section,
|
||||
@@ -39,7 +44,34 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { returnedAt } = body as Record<string, unknown>;
|
||||
const { returnedAt, employeeId } = body as Record<string, unknown>;
|
||||
|
||||
// Dispatch on body shape — a body carrying employeeId is a transfer
|
||||
// request, everything else is the existing return behavior. The two are
|
||||
// mutually exclusive: don't guess intent if a caller sends both (see
|
||||
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)"
|
||||
// section, bullet right after the transfer rule, for why this stays a
|
||||
// single PATCH route rather than an action-based sub-route).
|
||||
if (employeeId !== undefined && returnedAt !== undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide either employeeId (transfer) or returnedAt (return), not both" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (employeeId !== undefined) {
|
||||
const transferValidation = validateTransferAssignmentInput({ employeeId });
|
||||
if (!transferValidation.ok) {
|
||||
return NextResponse.json({ error: transferValidation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const transferred = await transferAssignment(params.id, transferValidation.value);
|
||||
if (!transferred.ok) {
|
||||
return NextResponse.json({ error: transferred.error }, { status: transferred.status });
|
||||
}
|
||||
return NextResponse.json(transferred.value, { status: 200 });
|
||||
}
|
||||
|
||||
const validation = validateReturnAssignmentInput({ returnedAt });
|
||||
|
||||
if (!validation.ok) {
|
||||
|
||||
+127
-3
@@ -43,15 +43,21 @@ function dateString(message: string) {
|
||||
.transform((value) => new Date(value));
|
||||
}
|
||||
|
||||
// Shared between createAssignmentSchema's employeeId field and
|
||||
// transferAssignmentSchema below — kept as a standalone Zod schema (rather
|
||||
// than inlined in both places) so the two stay byte-for-byte in sync on the
|
||||
// exact error message, instead of two copies drifting apart over time.
|
||||
const employeeIdSchema = 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");
|
||||
|
||||
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"),
|
||||
employeeId: employeeIdSchema,
|
||||
// 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
|
||||
@@ -137,6 +143,35 @@ export function validateReturnAssignmentInput(input: {
|
||||
return { ok: true, value: result.data };
|
||||
}
|
||||
|
||||
const transferAssignmentSchema = z.object({
|
||||
// Reuses employeeIdSchema (see its doc comment above) — the new employee
|
||||
// to hand the same AssetItem/License over to. assetItemId/licenseId are
|
||||
// deliberately NOT accepted here: transferAssignment() reuses whichever
|
||||
// one the original Assignment already had (see that function's doc
|
||||
// comment), the caller doesn't get to change it via a transfer.
|
||||
employeeId: employeeIdSchema,
|
||||
});
|
||||
|
||||
export type TransferValidationResult =
|
||||
| { ok: true; value: { employeeId: string } }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) assignment-transfer input. employeeId must be a
|
||||
* valid UUID string (same format check as validateCreateAssignmentInput's
|
||||
* employeeId field).
|
||||
*/
|
||||
export function validateTransferAssignmentInput(input: {
|
||||
employeeId?: unknown;
|
||||
}): TransferValidationResult {
|
||||
const result = transferAssignmentSchema.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;
|
||||
@@ -407,3 +442,92 @@ export async function returnAssignment(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type TransferAssignmentResult =
|
||||
| { ok: true; value: AssignmentRecord }
|
||||
| { ok: false; status: 404 | 400; error: string };
|
||||
|
||||
/**
|
||||
* Transfers an Assignment's underlying AssetItem/License to a new employee —
|
||||
* docs/asset-license-management.md's "## Assignment (chốt 2026-08-02)":
|
||||
* "Chuyển tài sản giữa nhân viên ... = đóng Assignment cũ (returnedAt = now)
|
||||
* + tạo Assignment mới, cả hai trong 1 transaction." Closes the existing
|
||||
* Assignment row (returnedAt = now) and creates a brand-new one for the new
|
||||
* employee, reusing the SAME assetItemId/licenseId as the original, all
|
||||
* inside a single `prisma.$transaction()`.
|
||||
*
|
||||
* Deliberately does NOT touch AssetItem.status or License.seatsUsed — unlike
|
||||
* createAssignment()/returnAssignment(), a transfer is a same-resource
|
||||
* handover, not a release-then-reacquire: the AssetItem never becomes
|
||||
* "in_stock" and the License seat is never freed at any point during a
|
||||
* transfer (no race window for a third party to grab either one mid-transfer
|
||||
* — that's the whole point of doing this in one transaction instead of a
|
||||
* separate PATCH-return followed by a POST-create, which is exactly the gap
|
||||
* this function closes). AssetItem.status stays "assigned" throughout;
|
||||
* seatsUsed is net zero (same seat, new holder).
|
||||
*
|
||||
* Race-condition note: mirrors returnAssignment()'s exact race-closing
|
||||
* pattern — double-transfer (or transfer-racing-return) is closed by the
|
||||
* same atomic, WHERE-guarded `updateMany` (`where: { id, returnedAt: null }`)
|
||||
* on the OLD Assignment row. Only the first caller to commit gets
|
||||
* `count: 1`; a second, concurrent call against the same id gets `count: 0`
|
||||
* and is rejected as "already returned" — same semantics as
|
||||
* returnAssignment(), since from the old Assignment row's point of view a
|
||||
* transfer's first half *is* a return. The `findUnique` right before the
|
||||
* `updateMany` is NOT the race-closing check by itself (same caveat as
|
||||
* createAssignment()/returnAssignment()'s doc comments) — it only exists to
|
||||
* read assetItemId/licenseId for the new row and to distinguish "not found"
|
||||
* from "already returned" in the common (non-racing) case.
|
||||
*/
|
||||
export async function transferAssignment(
|
||||
id: string,
|
||||
input: { employeeId: string }
|
||||
): Promise<TransferAssignmentResult> {
|
||||
try {
|
||||
const created = await prisma.$transaction(async (tx) => {
|
||||
const assignment = await tx.assignment.findUnique({ where: { id } });
|
||||
if (!assignment) {
|
||||
throw new AssignmentNotFoundError();
|
||||
}
|
||||
|
||||
// Atomic race-closing gate — see this function's doc comment. Also
|
||||
// doubles as the "already returned" rejection in the non-racing case.
|
||||
const claim = await tx.assignment.updateMany({
|
||||
where: { id, returnedAt: null },
|
||||
data: { returnedAt: new Date() },
|
||||
});
|
||||
if (claim.count === 0) {
|
||||
throw new AssignmentBusinessError("Assignment has already been returned");
|
||||
}
|
||||
|
||||
return tx.assignment.create({
|
||||
data: {
|
||||
employeeId: input.employeeId,
|
||||
assetItemId: assignment.assetItemId,
|
||||
licenseId: assignment.licenseId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { ok: true, value: toRecord(created) };
|
||||
} 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 };
|
||||
}
|
||||
// Only employeeId can fail with a raw FK violation here — assetItemId/
|
||||
// licenseId are reused verbatim from an already-valid Assignment row,
|
||||
// so they're guaranteed to still reference existing rows (nothing in
|
||||
// this codebase deletes AssetItem/License rows that have Assignments).
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: "employeeId does not reference an existing employee",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user