diff --git a/prisma/migrations/20260802232754_add_vendor/migration.sql b/prisma/migrations/20260802232754_add_vendor/migration.sql new file mode 100644 index 0000000..b30f4f5 --- /dev/null +++ b/prisma/migrations/20260802232754_add_vendor/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "vendors" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "contact_email" TEXT NOT NULL, + "contact_phone" TEXT NOT NULL, + "address" TEXT NOT NULL, + "tax_code" TEXT, + "notes" TEXT, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "vendors_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 63c54fa..02d35a0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -122,6 +122,28 @@ model License { @@map("licenses") } +// Purchasing counterparty — see docs/asset-license-management.md's "## Vendor +// (chốt 2026-08-02)". Deliberately independent of AssetModel.manufacturer +// (OEM, e.g. "Dell") — Vendor is the reseller/distributor actually bought +// from (e.g. "FPT Trading"), a different concept that isn't always the same +// company. No FKs on this entity yet: it's the starting point for later +// FKs from PurchaseOrder/License (License.vendor string -> License.vendorId +// FK retrofit is noted in the doc as a separate follow-up, not done here). +// taxCode/notes are nullable — not every vendor record has a known VN tax +// code or extra notes at creation time. +model Vendor { + id String @id @default(uuid()) @db.Uuid + name String + contactEmail String @map("contact_email") + contactPhone String @map("contact_phone") + address String + taxCode String? @map("tax_code") + notes String? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("vendors") +} + // Minimal internal auth accounts — NOT company SSO (deliberately ruled out, // see CLAUDE.md / auth decision notes). A handful of trusted IT staff // accounts, created by hand via scripts/create-user.ts, no self-registration. diff --git a/src/app/api/vendors/route.ts b/src/app/api/vendors/route.ts new file mode 100644 index 0000000..fcb5130 --- /dev/null +++ b/src/app/api/vendors/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { createVendor, listVendors, validateCreateVendorInput } from "@/lib/vendors"; + +// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes +// /api from route protection, so this checks the session itself. Flat +// permission model (CLAUDE.md): only "logged in or not", no role check. +export async function POST(request: Request): Promise { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); + } + + if (typeof body !== "object" || body === null) { + return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); + } + + const { name, contactEmail, contactPhone, address, taxCode, notes } = + body as Record; + const validation = validateCreateVendorInput({ + name, + contactEmail, + contactPhone, + address, + taxCode, + notes, + }); + + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const created = await createVendor(validation.value); + return NextResponse.json(created, { status: 201 }); +} + +export async function GET(): Promise { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const vendors = await listVendors(); + return NextResponse.json(vendors, { status: 200 }); +} diff --git a/src/lib/vendors.ts b/src/lib/vendors.ts new file mode 100644 index 0000000..1763603 --- /dev/null +++ b/src/lib/vendors.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; +import { prisma } from "./prisma"; + +export interface VendorRecord { + id: string; + name: string; + contact_email: string; + contact_phone: string; + address: string; + tax_code: string | null; + notes: string | null; + created_at: string; +} + +export interface CreateVendorInput { + name: string; + contactEmail: string; + contactPhone: string; + address: string; + taxCode?: string | null; + notes?: string | null; +} + +export type ValidationResult = + | { ok: true; value: CreateVendorInput } + | { ok: false; error: string }; + +// Mirrors src/lib/licenses.ts's nonEmptyString helper. +function nonEmptyString(message: string) { + return z.string({ required_error: message, invalid_type_error: message }).refine( + (value) => value.trim() !== "", + { message } + ); +} + +const createVendorSchema = z.object({ + name: nonEmptyString("name is required and must be a non-empty string"), + contactEmail: z + .string({ + required_error: "contactEmail is required and must be a valid email", + invalid_type_error: "contactEmail is required and must be a valid email", + }) + .email("contactEmail is required and must be a valid email"), + contactPhone: nonEmptyString("contactPhone is required and must be a non-empty string"), + address: nonEmptyString("address is required and must be a non-empty string"), + // Nullable, per docs/asset-license-management.md's "## Vendor" spec — not + // every vendor record has a known VN tax code at creation time. + taxCode: nonEmptyString("taxCode must be a non-empty string").optional().nullable(), + notes: nonEmptyString("notes must be a non-empty string").optional().nullable(), +}); + +/** + * Validates raw (untyped) vendor-creation input. name/contactPhone/address + * must be non-empty strings, contactEmail must be a valid email string, + * taxCode/notes are optional/nullable (per the "## Vendor" spec in + * docs/asset-license-management.md) but must be non-empty strings if + * present. + */ +export function validateCreateVendorInput(input: { + name?: unknown; + contactEmail?: unknown; + contactPhone?: unknown; + address?: unknown; + taxCode?: unknown; + notes?: unknown; +}): ValidationResult { + const result = createVendorSchema.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; + name: string; + contactEmail: string; + contactPhone: string; + address: string; + taxCode: string | null; + notes: string | null; + createdAt: Date; +}): VendorRecord { + return { + id: row.id, + name: row.name, + contact_email: row.contactEmail, + contact_phone: row.contactPhone, + address: row.address, + tax_code: row.taxCode, + notes: row.notes, + created_at: row.createdAt.toISOString(), + }; +} + +/** + * Creates a Vendor. No unique constraint exists on any field, no FKs + * either (see docs/asset-license-management.md's "## Vendor" spec — this + * is a standalone entity, same starting shape as Employee/License before + * they grew relations) — so unlike createAssetItem()/createEmployee() + * there's no known Prisma error code to catch/translate here. + */ +export async function createVendor(input: CreateVendorInput): Promise { + const created = await prisma.vendor.create({ + data: { + name: input.name, + contactEmail: input.contactEmail, + contactPhone: input.contactPhone, + address: input.address, + taxCode: input.taxCode ?? null, + notes: input.notes ?? null, + }, + }); + return toRecord(created); +} + +export async function listVendors(): Promise { + const rows = await prisma.vendor.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +} diff --git a/tests/vendors.test.ts b/tests/vendors.test.ts new file mode 100644 index 0000000..98b315b --- /dev/null +++ b/tests/vendors.test.ts @@ -0,0 +1,476 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import type { Session } from "next-auth"; +import { + createVendor, + listVendors, + validateCreateVendorInput, +} from "@/lib/vendors"; +import { prisma } from "@/lib/prisma"; + +// Note: this file has two layers of coverage, mirroring tests/licenses.test.ts: +// - lib-level tests below exercise src/lib/vendors.ts directly (validation, +// nullable taxCode/notes, list ordering). +// - route-level tests (further down) exercise src/app/api/vendors/route.ts +// over HTTP, including the auth() gate — mirrors tests/licenses.test.ts's +// @/auth mocking pattern. +vi.mock("@/auth", () => ({ auth: vi.fn() })); + +import { GET, POST } from "@/app/api/vendors/route"; +import { auth } from "@/auth"; + +const mockedAuth = auth as unknown as Mock<() => Promise>; + +const testSession: Session = { + user: { id: "test-user-id" }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), +}; + +function postRequest(body: unknown): Request { + return new Request("http://localhost/api/vendors", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + // Each test starts from an empty table so listing/order assertions are + // deterministic. + await prisma.vendor.deleteMany(); + // Default to a logged-in session — the lib-level tests below don't go + // through the route at all (so this is a no-op for them), and the + // route-level tests exercise business logic, not auth gating (that's + // covered by the "auth gating" describe block further down). + mockedAuth.mockResolvedValue(testSession); +}); + +describe("validateCreateVendorInput", () => { + it("accepts valid input with taxCode and notes", () => { + const result = validateCreateVendorInput({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + taxCode: "0301234567", + notes: "Preferred reseller for Dell laptops", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toMatchObject({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + taxCode: "0301234567", + notes: "Preferred reseller for Dell laptops", + }); + } + }); + + it("accepts valid input with no taxCode/notes", () => { + const result = validateCreateVendorInput({ + name: "Synnex FPT", + contactEmail: "contact@synnexfpt.com", + contactPhone: "0287654321", + address: "456 Le Van Viet, Q9, HCMC", + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.taxCode).toBeUndefined(); + expect(result.value.notes).toBeUndefined(); + } + }); + + it("rejects an empty name", () => { + const result = validateCreateVendorInput({ + name: "", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/name/i); + } + }); + + it("rejects an invalid contactEmail", () => { + const result = validateCreateVendorInput({ + name: "FPT Trading", + contactEmail: "not-an-email", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/contactEmail/i); + } + }); + + it("rejects a missing contactEmail", () => { + const result = validateCreateVendorInput({ + name: "FPT Trading", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/contactEmail/i); + } + }); + + it("rejects an empty contactPhone", () => { + const result = validateCreateVendorInput({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "", + address: "123 Nguyen Van Linh, Q7, HCMC", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/contactPhone/i); + } + }); + + it("rejects an empty address", () => { + const result = validateCreateVendorInput({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/address/i); + } + }); + + it("rejects an empty taxCode when provided", () => { + const result = validateCreateVendorInput({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + taxCode: "", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/taxCode/i); + } + }); + + it("accepts a null taxCode/notes explicitly", () => { + const result = validateCreateVendorInput({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + taxCode: null, + notes: null, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.taxCode).toBeNull(); + expect(result.value.notes).toBeNull(); + } + }); +}); + +describe("createVendor", () => { + it("creates a vendor with taxCode and notes", async () => { + const result = validateCreateVendorInput({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + taxCode: "0301234567", + notes: "Preferred reseller for Dell laptops", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const record = await createVendor(result.value); + + expect(record).toMatchObject({ + name: "FPT Trading", + contact_email: "sales@fpt.com.vn", + contact_phone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + tax_code: "0301234567", + notes: "Preferred reseller for Dell laptops", + }); + expect(typeof record.id).toBe("string"); + expect(record.id.length).toBeGreaterThan(0); + expect(Number.isNaN(Date.parse(record.created_at))).toBe(false); + }); + + it("creates a vendor with no taxCode/notes (both null)", async () => { + const result = validateCreateVendorInput({ + name: "Synnex FPT", + contactEmail: "contact@synnexfpt.com", + contactPhone: "0287654321", + address: "456 Le Van Viet, Q9, HCMC", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const record = await createVendor(result.value); + + expect(record.tax_code).toBeNull(); + expect(record.notes).toBeNull(); + }); +}); + +describe("listVendors", () => { + it("lists created vendors, newest first", async () => { + const first = validateCreateVendorInput({ + name: "Synnex FPT", + contactEmail: "contact@synnexfpt.com", + contactPhone: "0287654321", + address: "456 Le Van Viet, Q9, HCMC", + }); + expect(first.ok).toBe(true); + if (first.ok) await createVendor(first.value); + + // Ensure a distinct created_at ordering even on fast filesystems/clocks. + await new Promise((resolve) => setTimeout(resolve, 5)); + + const second = validateCreateVendorInput({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + }); + expect(second.ok).toBe(true); + if (second.ok) await createVendor(second.value); + + const records = await listVendors(); + + expect(records).toHaveLength(2); + // newest first + expect(records[0]).toMatchObject({ name: "FPT Trading" }); + expect(records[1]).toMatchObject({ name: "Synnex FPT" }); + }); + + it("returns an empty list when there are no vendors", async () => { + const records = await listVendors(); + expect(records).toEqual([]); + }); +}); + +describe("POST /api/vendors", () => { + it("creates a vendor on valid input", async () => { + const res = await POST( + postRequest({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + taxCode: "0301234567", + notes: "Preferred reseller for Dell laptops", + }) + ); + + expect(res.status).toBe(201); + const body = await res.json(); + + expect(body).toMatchObject({ + name: "FPT Trading", + contact_email: "sales@fpt.com.vn", + contact_phone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + tax_code: "0301234567", + notes: "Preferred reseller for Dell laptops", + }); + expect(typeof body.id).toBe("string"); + expect(body.id.length).toBeGreaterThan(0); + expect(Number.isNaN(Date.parse(body.created_at))).toBe(false); + }); + + it("creates a vendor with no taxCode/notes", async () => { + const res = await POST( + postRequest({ + name: "Synnex FPT", + contactEmail: "contact@synnexfpt.com", + contactPhone: "0287654321", + address: "456 Le Van Viet, Q9, HCMC", + }) + ); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.tax_code).toBeNull(); + expect(body.notes).toBeNull(); + }); + + it("rejects an empty name", async () => { + const res = await POST( + postRequest({ + name: "", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/name/i); + }); + + it("rejects an invalid contactEmail", async () => { + const res = await POST( + postRequest({ + name: "FPT Trading", + contactEmail: "not-an-email", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/contactEmail/i); + }); + + it("rejects an empty contactPhone", async () => { + const res = await POST( + postRequest({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "", + address: "123 Nguyen Van Linh, Q7, HCMC", + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/contactPhone/i); + }); + + it("rejects an empty address", async () => { + const res = await POST( + postRequest({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "", + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/address/i); + }); + + it("rejects an invalid JSON body", async () => { + const req = new Request("http://localhost/api/vendors", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + + const res = await POST(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/JSON/i); + }); + + it("rejects a non-object JSON body", async () => { + // Note: arrays are `typeof "object"` in JS, so they pass the route's + // object check (same behavior as /api/licenses) — use a JSON string + // primitive here to actually exercise the "not an object" branch. + const res = await POST(postRequest("not an object")); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/JSON object/i); + }); +}); + +describe("GET /api/vendors", () => { + it("lists created vendors, newest first", async () => { + await POST( + postRequest({ + name: "Synnex FPT", + contactEmail: "contact@synnexfpt.com", + contactPhone: "0287654321", + address: "456 Le Van Viet, Q9, HCMC", + }) + ); + // Ensure a distinct created_at ordering even on fast filesystems/clocks. + await new Promise((resolve) => setTimeout(resolve, 5)); + await POST( + postRequest({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + }) + ); + + const res = await GET(); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(2); + + // newest first + expect(body[0]).toMatchObject({ name: "FPT Trading" }); + expect(body[1]).toMatchObject({ name: "Synnex FPT" }); + }); + + it("returns an empty list when there are no vendors", async () => { + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); +}); + +describe("auth gating on /api/vendors", () => { + // Mirrors tests/licenses.test.ts's "auth gating" block — only checks + // session presence, per the flat permission model (no role/permission + // logic). + it("POST /api/vendors returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + + const res = await POST( + postRequest({ + name: "FPT Trading", + contactEmail: "sales@fpt.com.vn", + contactPhone: "0281234567", + address: "123 Nguyen Van Linh, Q7, HCMC", + }) + ); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: "Unauthorized" }); + + // And it must not have created anything. + const remaining = await prisma.vendor.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("GET /api/vendors returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + + const res = await GET(); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: "Unauthorized" }); + }); +});