From a9dafcc1406af614e00d74732223a17de92add5a Mon Sep 17 00:00:00 2001 From: toanhoang Date: Mon, 3 Aug 2026 19:46:43 -0400 Subject: [PATCH] fix: add atomic Assignment transfer (close old + create new in one transaction) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/assignments/[id]/route.ts | 36 ++++- src/lib/assignments.ts | 130 ++++++++++++++++- tests/assignments.test.ts | 192 ++++++++++++++++++++++++++ 3 files changed, 353 insertions(+), 5 deletions(-) diff --git a/src/app/api/assignments/[id]/route.ts b/src/app/api/assignments/[id]/route.ts index 08cf615..a315091 100644 --- a/src/app/api/assignments/[id]/route.ts +++ b/src/app/api/assignments/[id]/route.ts @@ -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; + const { returnedAt, employeeId } = body as Record; + + // 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) { diff --git a/src/lib/assignments.ts b/src/lib/assignments.ts index bcb6bfe..0d2c14a 100644 --- a/src/lib/assignments.ts +++ b/src/lib/assignments.ts @@ -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 { + 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; + } +} diff --git a/tests/assignments.test.ts b/tests/assignments.test.ts index 290ce12..93f84f3 100644 --- a/tests/assignments.test.ts +++ b/tests/assignments.test.ts @@ -5,8 +5,10 @@ import { createAssignment, listAssignments, returnAssignment, + transferAssignment, validateCreateAssignmentInput, validateReturnAssignmentInput, + validateTransferAssignmentInput, } from "@/lib/assignments"; 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", () => { it("lists created assignments, newest first", async () => { const employee = await createTestEmployee(); @@ -736,6 +881,53 @@ describe("PATCH /api/assignments/[id]", () => { const body = await res.json(); 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", () => {