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 { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/auth"; 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 // First dynamic-segment ([id]) route in the codebase — see
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)" section, // 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 }); 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 }); const validation = validateReturnAssignmentInput({ returnedAt });
if (!validation.ok) { if (!validation.ok) {
+127 -3
View File
@@ -43,15 +43,21 @@ function dateString(message: string) {
.transform((value) => new Date(value)); .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 const createAssignmentSchema = z
.object({ .object({
// FK to Employee — only a UUID *format* check here, whether it // FK to Employee — only a UUID *format* check here, whether it
// references a real employee is left to Prisma's own FK constraint at // references a real employee is left to Prisma's own FK constraint at
// insert time (see createAssignment()'s P2003 handling below). Mirrors // insert time (see createAssignment()'s P2003 handling below). Mirrors
// src/lib/requests.ts's employee_id handling. // src/lib/requests.ts's employee_id handling.
employeeId: z employeeId: employeeIdSchema,
.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() // Exactly one of assetItemId/licenseId must be set — see the .refine()
// below for the XOR check. Unlike other nullable FKs in this codebase // below for the XOR check. Unlike other nullable FKs in this codebase
// (e.g. AssetItem.purchaseOrderId), whether these reference a real row // (e.g. AssetItem.purchaseOrderId), whether these reference a real row
@@ -137,6 +143,35 @@ export function validateReturnAssignmentInput(input: {
return { ok: true, value: result.data }; 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: { function toRecord(row: {
id: string; id: string;
employeeId: string; employeeId: string;
@@ -407,3 +442,92 @@ export async function returnAssignment(
throw error; 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;
}
}
+192
View File
@@ -5,8 +5,10 @@ import {
createAssignment, createAssignment,
listAssignments, listAssignments,
returnAssignment, returnAssignment,
transferAssignment,
validateCreateAssignmentInput, validateCreateAssignmentInput,
validateReturnAssignmentInput, validateReturnAssignmentInput,
validateTransferAssignmentInput,
} from "@/lib/assignments"; } from "@/lib/assignments";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -542,6 +544,149 @@ describe("returnAssignment", () => {
}); });
}); });
describe("transferAssignment", () => {
it("transfers an AssetItem assignment to a new employee, keeping status assigned throughout", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
expect(transferred.ok).toBe(true);
if (!transferred.ok) return;
expect(transferred.value.employee_id).toBe(employee2.id);
expect(transferred.value.asset_item_id).toBe(assetItem.id);
expect(transferred.value.returned_at).toBeNull();
// The AssetItem must remain "assigned" throughout the transfer — never
// flipped to in_stock mid-way (that's the whole point of doing this in
// one transaction rather than a separate return + create).
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
expect(original.returnedAt).not.toBeNull();
const rows = await prisma.assignment.findMany({ where: { assetItemId: assetItem.id } });
expect(rows).toHaveLength(2);
const newRow = rows.find((row) => row.id !== created.value.id);
expect(newRow).toBeDefined();
expect(newRow?.employeeId).toBe(employee2.id);
expect(newRow?.returnedAt).toBeNull();
});
it("transfers a License assignment to a new employee without changing seatsUsed", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 });
const created = await createAssignment({ employeeId: employee1.id, licenseId });
expect(created.ok).toBe(true);
if (!created.ok) return;
let license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
expect(transferred.ok).toBe(true);
if (!transferred.ok) return;
expect(transferred.value.employee_id).toBe(employee2.id);
expect(transferred.value.license_id).toBe(licenseId);
license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
expect(original.returnedAt).not.toBeNull();
});
it("rejects transferring an already-returned assignment", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const returned = await returnAssignment(created.value.id, {});
expect(returned.ok).toBe(true);
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
expect(transferred.ok).toBe(false);
if (transferred.ok) return;
expect(transferred.status).toBe(400);
expect(transferred.error).toMatch(/already been returned/i);
});
it("returns 404 when transferring a nonexistent assignment", async () => {
const employee = await createTestEmployee();
const result = await transferAssignment("00000000-0000-0000-0000-000000000000", {
employeeId: employee.id,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(404);
expect(result.error).toMatch(/not found/i);
});
it("returns a clean 400 when transferring to a nonexistent employeeId", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const transferred = await transferAssignment(created.value.id, {
employeeId: "00000000-0000-0000-0000-000000000000",
});
expect(transferred.ok).toBe(false);
if (transferred.ok) return;
expect(transferred.status).toBe(400);
expect(transferred.error).toMatch(/employeeId/i);
expect(transferred.error).toMatch(/does not reference an existing employee/i);
// Original assignment must not have been closed — the whole transfer
// must have rolled back.
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
expect(original.returnedAt).toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
});
});
describe("validateTransferAssignmentInput", () => {
it("accepts a valid employeeId", () => {
const result = validateTransferAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
});
expect(result.ok).toBe(true);
});
it("rejects a missing employeeId", () => {
const result = validateTransferAssignmentInput({});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/employeeId/i);
}
});
it("rejects a non-UUID employeeId", () => {
const result = validateTransferAssignmentInput({ employeeId: "not-a-uuid" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/employeeId/i);
}
});
});
describe("listAssignments", () => { describe("listAssignments", () => {
it("lists created assignments, newest first", async () => { it("lists created assignments, newest first", async () => {
const employee = await createTestEmployee(); const employee = await createTestEmployee();
@@ -736,6 +881,53 @@ describe("PATCH /api/assignments/[id]", () => {
const body = await res.json(); const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" }); expect(body).toEqual({ error: "Unauthorized" });
}); });
it("transfers an assignment to a new employee on valid employeeId input", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(postRequest({ employeeId: employee1.id, assetItemId: assetItem.id }));
const created = await createRes.json();
const res = await PATCH(patchRequest({ employeeId: employee2.id }), { params: { id: created.id } });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.employee_id).toBe(employee2.id);
expect(body.asset_item_id).toBe(assetItem.id);
expect(body.returned_at).toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
});
it("rejects a body with both employeeId and returnedAt with a mutual-exclusivity 400", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(postRequest({ employeeId: employee1.id, assetItemId: assetItem.id }));
const created = await createRes.json();
const res = await PATCH(
patchRequest({ employeeId: employee2.id, returnedAt: "2026-08-01" }),
{ params: { id: created.id } }
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/either employeeId.*or returnedAt/i);
});
it("returns 401 for a transfer request without a session", async () => {
const employee = await createTestEmployee();
mockedAuth.mockResolvedValue(null);
const res = await PATCH(patchRequest({ employeeId: employee.id }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
}); });
describe("auth gating on /api/assignments", () => { describe("auth gating on /api/assignments", () => {