Files
toanhoang 2a5792df67 fix: retrofit AssetItem.model to modelId FK
AssetItem.model was a free-text string with no referential integrity.
Now a required FK to AssetModel, mirroring the Request.employeeId and
License.vendorId retrofit precedent (no backfill needed, no real
AssetItem data existed).
2026-08-02 21:07:12 -04:00

364 lines
19 KiB
Plaintext

// 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")
}