Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9dafcc140 |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user