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:
toanhoang
2026-08-03 19:46:43 -04:00
parent e951a14cc5
commit a9dafcc140
3 changed files with 353 additions and 5 deletions
+34 -2
View File
@@ -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) {