From f7b9566b04d90208c44262d1d6515e676865347c Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:38:06 -0400 Subject: [PATCH] feat: add AssetCategoryAttributeDef Prisma model + migration Per-category dynamic attribute definitions (key/label/dataType/required/ sortOrder), FK to AssetCategory, composite unique (categoryId, key) so the same key is allowed across different categories but not duplicated within one. dataType is a new closed Prisma enum (AssetAttributeDataType), a dev-defined technical set distinct from AssetCategoryValueGroup. --- .../migration.sql | 22 ++++++++ prisma/schema.prisma | 56 +++++++++++++++++-- 2 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20260803003747_add_asset_category_attribute_def/migration.sql diff --git a/prisma/migrations/20260803003747_add_asset_category_attribute_def/migration.sql b/prisma/migrations/20260803003747_add_asset_category_attribute_def/migration.sql new file mode 100644 index 0000000..cf3657e --- /dev/null +++ b/prisma/migrations/20260803003747_add_asset_category_attribute_def/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "AssetAttributeDataType" AS ENUM ('string', 'number', 'boolean', 'date'); + +-- CreateTable +CREATE TABLE "asset_category_attribute_defs" ( + "id" UUID NOT NULL, + "category_id" UUID NOT NULL, + "key" TEXT NOT NULL, + "label" TEXT NOT NULL, + "data_type" "AssetAttributeDataType" NOT NULL, + "required" BOOLEAN NOT NULL, + "sort_order" INTEGER NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "asset_category_attribute_defs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "asset_category_attribute_defs_category_id_key_key" ON "asset_category_attribute_defs"("category_id", "key"); + +-- AddForeignKey +ALTER TABLE "asset_category_attribute_defs" ADD CONSTRAINT "asset_category_attribute_defs_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ef20bf..e5f45f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -237,14 +237,62 @@ enum AssetCategoryValueGroup { // (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) + 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[] @@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") +} + // 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.