From 07b01cd707c2ed87df0312a89a142694859a484b Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 21:13:46 -0400 Subject: [PATCH] feat: add Assignment Prisma model + migration Links Employee to exactly one of AssetItem/License (XOR enforced at the Zod layer, not built yet) via nullable FKs, plus assignedAt/returnedAt/ signedAt timestamps. See docs/asset-license-management.md's "Assignment" section for the full spec. --- .../migration.sql | 22 +++++++ prisma/schema.prisma | 62 ++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260803011331_add_assignment/migration.sql diff --git a/prisma/migrations/20260803011331_add_assignment/migration.sql b/prisma/migrations/20260803011331_add_assignment/migration.sql new file mode 100644 index 0000000..6db2f7d --- /dev/null +++ b/prisma/migrations/20260803011331_add_assignment/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "assignments" ( + "id" UUID NOT NULL, + "employee_id" UUID NOT NULL, + "asset_item_id" UUID, + "license_id" UUID, + "assigned_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "returned_at" TIMESTAMPTZ(6), + "signed_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "assignments_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "assignments" ADD CONSTRAINT "assignments_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "assignments" ADD CONSTRAINT "assignments_asset_item_id_fkey" FOREIGN KEY ("asset_item_id") REFERENCES "asset_items"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "assignments" ADD CONSTRAINT "assignments_license_id_fkey" FOREIGN KEY ("license_id") REFERENCES "licenses"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 22f0dce..85f75d2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -70,6 +70,10 @@ model Employee { // Back-relation for PurchaseOrder.receivedById — an Employee can be the // receiver on multiple POs over time. purchaseOrdersReceived PurchaseOrder[] + // Back-relation for Assignment.employeeId — an Employee can hold multiple + // Assignments (assets/licenses) over time, including several active ones + // at once (e.g. a laptop + a license seat simultaneously). + assignments Assignment[] createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("employees") @@ -117,6 +121,10 @@ model AssetItem { 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) + // Back-relation for Assignment.assetItemId — an AssetItem can have + // multiple Assignment rows over its lifetime (historical + at most one + // active, see Assignment model's comment), not built until this task. + assignments Assignment[] @@map("asset_items") } @@ -158,6 +166,10 @@ model License { 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) + // Back-relation for Assignment.licenseId — a License can have multiple + // Assignment rows (one per seat handed out, plus historical returns), not + // built until this task. + assignments Assignment[] @@map("licenses") } @@ -247,10 +259,10 @@ 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 + id String @id @default(uuid()) @db.Uuid name String - valueGroup AssetCategoryValueGroup @map("value_group") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + 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 @@ -344,6 +356,50 @@ model AssetModel { @@map("asset_models") } +// Assigns an AssetItem (high-value hardware) or a License seat to a specific +// Employee — see docs/asset-license-management.md's "## Assignment (chốt +// 2026-08-02)" section and the `Assignment` row in the "Data model (sơ bộ)" +// table. Exactly one of assetItemId/licenseId must be set (XOR) — enforced +// at the Zod layer (src/lib/assignments.ts's `.refine()`), NOT a DB +// constraint, per the same convention as PurchaseOrder's +// receivedAt/receivedById co-nullability check: Prisma schema can't express +// an XOR between two nullable FKs natively. Both FKs stay plain nullable +// columns here for that reason. `assignedAt` defaults to now() but is a +// real stored column (not derived from createdAt) because a future +// "transfer" operation (out of scope for this task, see doc) will create a +// new Assignment row whose assignedAt should reflect the transfer moment, +// not necessarily row-insert time. `returnedAt` null means still held by +// the employee — this is also what "active assignment" means throughout +// the codebase (see the uniqueness rule below). `signedAt` is deliberately +// just a nullable timestamp marking "handover paperwork signed" — NO file +// upload, stakeholder explicitly decided against storing a scanned file at +// this stage (see doc). No `@@unique` on assetItemId here for "only one +// active assignment at a time" — a plain unique constraint would also +// forbid a *second, already-returned* historical Assignment for the same +// AssetItem (e.g. item reassigned after being returned), which is valid and +// expected. The "at most one ACTIVE (returnedAt IS NULL) assignment per +// AssetItem" invariant is instead enforced in application code +// (src/lib/assignments.ts's createAssignment(), inside the same transaction +// that mutates AssetItem.status/License.seatsUsed) — a partial unique index +// (`WHERE returned_at IS NULL`) would express this at the DB level too, but +// that's raw SQL outside a Prisma-expressible `@@unique`, so deliberately +// not added here; see feature-implementer report for this trade-off. +model Assignment { + id String @id @default(uuid()) @db.Uuid + employeeId String @map("employee_id") @db.Uuid + employee Employee @relation(fields: [employeeId], references: [id]) + assetItemId String? @map("asset_item_id") @db.Uuid + assetItem AssetItem? @relation(fields: [assetItemId], references: [id]) + licenseId String? @map("license_id") @db.Uuid + license License? @relation(fields: [licenseId], references: [id]) + assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(6) + returnedAt DateTime? @map("returned_at") @db.Timestamptz(6) + signedAt DateTime? @map("signed_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("assignments") +} + // 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.