// Prisma schema — source of truth for the DB schema going forward, // replacing the hand-written `CREATE TABLE IF NOT EXISTS` migration that // used to live in src/lib/db.ts. Only the Request entity (Draft state only) // exists so far — see ../processes/asset-license-management.md for the full // data model / state machine this will grow into. generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // Fixed, closed set of 2 values tied to the domain (asset vs license // request) — a native Postgres enum is the idiomatic Prisma mapping here. // This replaces the old `type TEXT NOT NULL CHECK (type IN ('asset', // 'license'))` column with `type "RequestType" NOT NULL` backed by a // `CREATE TYPE "RequestType" AS ENUM ('asset', 'license')`. Same validation // guarantee (only these two values are ever storable), different mechanism. // See feature-implementer report for the enum-vs-TEXT+CHECK trade-off. enum RequestType { asset license } model Request { id String @id @default(uuid()) @db.Uuid // Was a free-text String with no referential integrity — retrofitted to a // real FK once Employee records existed to point to (see Employee model // above). No production data existed at the time of this change (`requests` // table was empty), so this was a plain column-type migration, not a // backfill. employeeId String @map("employee_id") @db.Uuid employee Employee @relation(fields: [employeeId], references: [id]) type RequestType reason String // Only the 'Draft' state is implemented so far. Deliberately kept as a // plain string (not an enum) until more states exist — see // src/lib/requests.ts's RequestStatus type. status String @default("Draft") // Nullable FK to PurchaseOrder — see docs/asset-license-management.md's // "## PurchaseOrder (gộp Receipt)" Traceability bullet. Semantically the // DEMAND side, unlike AssetItem.purchaseOrderId/License.purchaseOrderId // (supply-side intake): this records "which approved Request got grouped // into this PO", not "which PO did this item come in through". Shape is // identical to those two though — nullable FK, set at creation time only, // no update endpoint for this field yet, same P2003-on-insert handling. purchaseOrderId String? @map("purchase_order_id") @db.Uuid purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("requests") } // Employee directory — see docs/asset-license-management.md's "Data model // (sơ bộ)" table (id, tên, phòng ban, manager). `department` is deliberately // a plain String for now, not a separate Department entity — that's a // possible future normalization, not built yet. `managerId` is a nullable // self-relation: top-level employees (no manager) have `managerId = null`. model Employee { id String @id @default(uuid()) @db.Uuid name String department String managerId String? @map("manager_id") @db.Uuid manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id]) reports Employee[] @relation("EmployeeManager") requests Request[] // Back-relation for PurchaseOrder.receivedById — an Employee can be the // receiver on multiple POs over time. purchaseOrdersReceived PurchaseOrder[] createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("employees") } // Fixed, closed set of 4 values tied to the domain (see docs/ // asset-license-management.md's "Data model (sơ bộ)" table) — native // Postgres enum, same rationale as RequestType above. enum AssetItemStatus { in_stock assigned repair retired } // Hardware inventory — see docs/asset-license-management.md's "Data model // (sơ bộ)" table (serial number, model, ngày mua, bảo hành, trạng thái). // No Assignment relation yet — that entity doesn't exist yet (see "Việc còn // dở" in CLAUDE.md). warrantyEndDate is nullable: not every asset has known // warranty terms at intake time (smallest sensible assumption — doesn't // block creation). purchaseOrderId is a nullable FK to PurchaseOrder — see // docs/asset-license-management.md's "## PurchaseOrder (gộp Receipt)" // Traceability bullet: supply-side traceability ("which PO did this asset // item come in through"). Nullable because not every AssetItem goes through // a formal PO (e.g. historical stock predating the system). Set at // intake/creation time only — no update endpoint for this field yet, same // precedent as License.vendorId being set-at-creation with no PATCH. // `modelId` was a free-text `model` String until the "Cập nhật 2026-08-02 // (2)" retrofit (docs/asset-license-management.md's "Ràng buộc dữ liệu" // bullet) — same precedent as Request.employeeId/License.vendorId (string // -> FK once the referenced entity existed). No production `AssetItem` data // existed at the time of this change, so this was a plain column-type // migration, not a backfill. Required (not nullable) — every AssetItem must // belong to a real AssetModel. AssetItem deliberately does NOT carry its own // `categoryId` — category is derived via `modelId -> AssetModel.categoryId` // to avoid two sources of truth that could drift apart. model AssetItem { id String @id @default(uuid()) @db.Uuid serialNumber String @unique @map("serial_number") modelId String @map("model_id") @db.Uuid model AssetModel @relation(fields: [modelId], references: [id]) purchaseDate DateTime @map("purchase_date") @db.Date warrantyEndDate DateTime? @map("warranty_end_date") @db.Date status AssetItemStatus @default(in_stock) purchaseOrderId String? @map("purchase_order_id") @db.Uuid purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("asset_items") } // Fixed, closed set of 2 values tied to the domain (see docs/ // asset-license-management.md's "Data model (sơ bộ)" table) — native // Postgres enum, same rationale as RequestType/AssetItemStatus above. enum LicenseType { perpetual subscription } // Software license inventory — see docs/asset-license-management.md's "Data // model (sơ bộ)" table (tên, vendor, tổng seat, seat đã dùng, ngày hết hạn, // loại). `vendorId` was a free-text `vendor` String until the "## Vendor // (chốt 2026-08-02)" retrofit — same precedent as Request.employeeId // (string -> FK once Employee existed). No production `License` data // existed at the time of this change, so this was a plain column-type // migration, not a backfill. `seatsUsed` is a stored counter (defaults to 0 // at creation), not a computed value — it will only ever be set to 0 here // since Assignment (the thing that would consume seats) doesn't exist yet. // `expiryDate` is nullable: perpetual licenses never expire, only // subscription licenses usually have one. `purchaseOrderId` is a nullable FK // to PurchaseOrder — see docs/asset-license-management.md's "## PurchaseOrder // (gộp Receipt)" Traceability bullet: supply-side traceability ("which PO // did this license come in through"). Nullable because not every License // goes through a formal PO. Mirrors AssetItem.purchaseOrderId's just-merged // pattern exactly — same nullable-FK shape, set at creation time only, no // update endpoint for this field yet. model License { id String @id @default(uuid()) @db.Uuid name String vendorId String @map("vendor_id") @db.Uuid vendor Vendor @relation(fields: [vendorId], references: [id]) seatsTotal Int @map("seats_total") seatsUsed Int @default(0) @map("seats_used") expiryDate DateTime? @map("expiry_date") @db.Date licenseType LicenseType @map("license_type") purchaseOrderId String? @map("purchase_order_id") @db.Uuid purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@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. `licenses` is the back-relation from the License.vendor -> // License.vendorId retrofit (same doc section). `purchaseOrders` is the // back-relation from PurchaseOrder.vendorId (see "## PurchaseOrder (gộp // Receipt) (chốt 2026-08-02)"). 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) licenses License[] purchaseOrders PurchaseOrder[] @@map("vendors") } // Purchasing document — see docs/asset-license-management.md's "## // PurchaseOrder (gộp Receipt) (chốt 2026-08-02)". Deliberately absorbs what // would otherwise be a separate `Receipt` entity: receiving is just part of // this row's own state (`receivedAt`/`receivedBy`), not a separate table to // reconcile against. No `status` field — status is inferred from linked // `Request`s (Ordered -> Received -> Delivered), not stored here, to avoid // two sources of truth. No cost/price fields — stakeholder said explicitly // PurchaseOrder doesn't need to track money, only "which asset/license // belongs to which PO". Receiving is all-or-nothing per PO (no partial // receipt tracking) — a deliberate simplification, see doc for the // stakeholder-accepted tradeoff. `receivedAt`/`receivedBy` are both // nullable and must be co-null/co-set together (both-null = not yet // received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a // DB constraint, same approach as other cross-field invariants in this // codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet). // `assetItems` is the back-relation from the AssetItem.purchaseOrderId // retrofit (supply-side traceability, same doc section). `licenses` is the // back-relation from the License.purchaseOrderId retrofit (same doc // section, same rationale). `requests` is the back-relation from the // Request.purchaseOrderId retrofit — the DEMAND side ("which approved // Request got grouped into this PO"), as opposed to assetItems/licenses // which are the SUPPLY side (intake traceability) — see Request model's // comment above for the distinction. model PurchaseOrder { id String @id @default(uuid()) @db.Uuid vendorId String @map("vendor_id") @db.Uuid vendor Vendor @relation(fields: [vendorId], references: [id]) orderDate DateTime @map("order_date") @db.Timestamptz(6) expectedDate DateTime @map("expected_date") @db.Timestamptz(6) receivedAt DateTime? @map("received_at") @db.Timestamptz(6) receivedById String? @map("received_by") @db.Uuid receivedBy Employee? @relation(fields: [receivedById], references: [id]) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) assetItems AssetItem[] licenses License[] requests Request[] @@map("purchase_orders") } // Fixed, closed set of 2 values tied to the domain (see docs/ // asset-license-management.md's "## Phân loại tài sản: High-value vs // Low-value/Consumable" section) — native Postgres enum, same rationale as // RequestType/AssetItemStatus/LicenseType above. Determines downstream // rules for entities that will attach to a category later (not built in // this task): AssetModel.categoryId must point to a `high_value` category, // ConsumableItem.categoryId must point to a `low_value` category. enum AssetCategoryValueGroup { high_value low_value } // Asset classification catalog — see docs/asset-license-management.md's // "## Phân loại tài sản" section and the `AssetCategory` row in the "Data // model (sơ bộ)" table. Standalone entity, no FKs (same starting shape as // Vendor before it grew relations) — `AssetModel` and // `AssetCategoryAttributeDef` will attach to this via `categoryId` in // later tasks (not this one). IT admin defines the concrete categories // (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value // set. model AssetCategory { id String @id @default(uuid()) @db.Uuid name String valueGroup AssetCategoryValueGroup @map("value_group") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) attributeDefs AssetCategoryAttributeDef[] // Back-relation for AssetModel.categoryId — see "Cập nhật 2026-08-02 (2)" // in docs/asset-license-management.md. Only categories with // valueGroup=high_value are ever actually referenced by an AssetModel (a // business rule enforced in src/lib/asset-models.ts's createAssetModel(), // not expressible as a Prisma-level constraint), but the relation itself // is not restricted to high_value categories at the schema level. assetModels AssetModel[] @@map("asset_categories") } // Fixed, closed set of 4 values — a DEV-defined technical set (which Zod // validator to build dynamically for AssetModel.specs at write time: string // -> z.string(), number -> z.number(), boolean -> z.boolean(), date -> // z.coerce.date()). Deliberately a different kind of "fixed set" than // AssetCategoryValueGroup above: that one is a closed business classification // IT admins never extend; this one is a closed set of *primitive types* the // app's dynamic-attribute machinery knows how to handle. Still modeled as a // native Postgres enum per the same codebase convention (RequestType / // AssetItemStatus / LicenseType / AssetCategoryValueGroup), not a String + // hand-rolled check. enum AssetAttributeDataType { string number boolean date } // Per-category dynamic attribute definitions — see docs/ // asset-license-management.md's "Cập nhật 2026-08-02 (3): thêm // AssetCategoryAttributeDef" section and the `AssetCategoryAttributeDef` row // in the "Data model (sơ bộ)" table. Defines the *shape* of attributes a // category cares about (e.g. category "Laptop" -> key `ram_gb`, label // "RAM (GB)", dataType number); the actual *values* live in // AssetModel.specs (JSONB), not built in this task. Each AssetCategory owns // its own independent set of attribute defs — deliberately NOT a shared // attribute library across categories (stakeholder explicitly rejected that // design to keep things simple at current scale), so the same `key` string // existing under two different categoryId rows is normal, not a collision — // only unique *within* a category, hence the composite unique below (not a // plain @unique on key alone). `sortOrder` is the display order on the // dynamic form, no uniqueness constraint of its own (ties/gaps are fine, // it's just a sort key). model AssetCategoryAttributeDef { id String @id @default(uuid()) @db.Uuid categoryId String @map("category_id") @db.Uuid category AssetCategory @relation(fields: [categoryId], references: [id]) key String label String dataType AssetAttributeDataType @map("data_type") required Boolean sortOrder Int @map("sort_order") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@unique([categoryId, key]) @@map("asset_category_attribute_defs") } // Sits between AssetCategory and AssetItem — see docs/ // asset-license-management.md's "Cập nhật 2026-08-02 (2)" section and the // `AssetModel` row in the "Data model (sơ bộ)" table. 1 AssetModel has many // AssetItem serials (AssetItem.modelId FK, not built in this task). // `manufacturer`/`name` are deliberately two separate String fields (not // merged into one), per the doc, to allow filtering/reporting by // manufacturer alone (e.g. "Dell") without string-parsing a combined field. // `categoryId` is a required FK to AssetCategory — the DB layer doesn't // enforce that the referenced category is `high_value` (Prisma/Postgres FK // constraints can't express that), that business rule is checked in // src/lib/asset-models.ts's createAssetModel() via an explicit lookup // before the create, per the doc's "Ràng buộc dữ liệu" section. `specs` is // JSONB, defaulting to an empty object — the real per-category dynamic // attribute *values* (whose *shape* is defined by // AssetCategoryAttributeDef rows for this model's category). This task // deliberately does NOT validate specs against the category's attribute // defs (loose z.record(z.unknown()) validation only, see // src/lib/asset-models.ts) — that dynamic cross-validation is a separate // follow-up task per the doc's "Cập nhật 2026-08-02 (3)" section. model AssetModel { id String @id @default(uuid()) @db.Uuid categoryId String @map("category_id") @db.Uuid category AssetCategory @relation(fields: [categoryId], references: [id]) manufacturer String name String specs Json @default("{}") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) // Back-relation for the AssetItem.model -> AssetItem.modelId retrofit — // see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md. assetItems AssetItem[] @@map("asset_models") } // 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. // Auth.js v5 Credentials provider + JWT session strategy is used, so this is // intentionally just a plain user table — no `Account`/`Session`/ // `VerificationToken` models, those exist for OAuth account linking / DB // session strategy, neither of which applies here. No `role` field either: // permission model is flat for now (any logged-in user can do anything). model User { id String @id @default(uuid()) @db.Uuid email String @unique passwordHash String @map("password_hash") name String createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("users") }