Basic Architecture of Sport Web

This commit is contained in:
Nông Đức Huy
2026-08-11 13:37:25 +07:00
commit 8032fff6ac
262 changed files with 20348 additions and 0 deletions
@@ -0,0 +1,636 @@
-- CreateEnum
CREATE TYPE "UserType" AS ENUM ('CUSTOMER', 'STAFF', 'ADMIN', 'SUPER_ADMIN');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'INVITED', 'SUSPENDED');
-- CreateEnum
CREATE TYPE "MediaKind" AS ENUM ('IMAGE', 'VIDEO', 'DOCUMENT');
-- CreateEnum
CREATE TYPE "CollectionType" AS ENUM ('MANUAL', 'AUTOMATED');
-- CreateEnum
CREATE TYPE "ProductStatus" AS ENUM ('DRAFT', 'ACTIVE', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "VariantStatus" AS ENUM ('ACTIVE', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "Currency" AS ENUM ('VND', 'USD');
-- CreateEnum
CREATE TYPE "GenderTarget" AS ENUM ('MEN', 'WOMEN', 'KIDS', 'UNISEX');
-- CreateEnum
CREATE TYPE "SportType" AS ENUM ('RUNNING', 'FOOTBALL', 'TRAINING', 'GYM', 'BADMINTON', 'LIFESTYLE');
-- CreateEnum
CREATE TYPE "StockMovementReason" AS ENUM ('PURCHASE_RECEIPT', 'SALE', 'RETURN', 'MANUAL_ADJUSTMENT', 'STOCK_TAKE', 'TRANSFER_IN', 'TRANSFER_OUT', 'DAMAGE');
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"email" VARCHAR(255) NOT NULL,
"password_hash" TEXT,
"type" "UserType" NOT NULL,
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"first_name" VARCHAR(80),
"last_name" VARCHAR(80),
"phone" VARCHAR(20),
"avatar_id" UUID,
"email_verified_at" TIMESTAMPTZ(3),
"last_login_at" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"deleted_at" TIMESTAMPTZ(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" UUID NOT NULL,
"key" VARCHAR(64) NOT NULL,
"name" VARCHAR(120) NOT NULL,
"description" TEXT,
"is_system" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" UUID NOT NULL,
"key" VARCHAR(64) NOT NULL,
"resource" VARCHAR(40) NOT NULL,
"action" VARCHAR(40) NOT NULL,
"description" TEXT,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"role_id" UUID NOT NULL,
"permission_id" UUID NOT NULL,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"user_id" UUID NOT NULL,
"role_id" UUID NOT NULL,
"assigned_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("user_id","role_id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"refresh_token_hash" VARCHAR(64) NOT NULL,
"family_id" UUID NOT NULL,
"replaced_by_id" UUID,
"expires_at" TIMESTAMPTZ(3) NOT NULL,
"revoked_at" TIMESTAMPTZ(3),
"user_agent" TEXT,
"ip_address" VARCHAR(45),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" UUID NOT NULL,
"actor_user_id" UUID,
"action" VARCHAR(80) NOT NULL,
"resource_type" VARCHAR(60) NOT NULL,
"resource_id" TEXT,
"changes" JSONB,
"ip_address" VARCHAR(45),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "customers" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"accepts_marketing" BOOLEAN NOT NULL DEFAULT false,
"date_of_birth" DATE,
"note" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "customers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "addresses" (
"id" UUID NOT NULL,
"customer_id" UUID NOT NULL,
"full_name" VARCHAR(160) NOT NULL,
"phone" VARCHAR(20) NOT NULL,
"line1" VARCHAR(255) NOT NULL,
"line2" VARCHAR(255),
"ward" VARCHAR(120),
"ward_code" VARCHAR(20),
"district" VARCHAR(120),
"district_code" VARCHAR(20),
"province" VARCHAR(120) NOT NULL,
"province_code" VARCHAR(20),
"country_code" CHAR(2) NOT NULL DEFAULT 'VN',
"postal_code" VARCHAR(20),
"is_default_shipping" BOOLEAN NOT NULL DEFAULT false,
"is_default_billing" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "addresses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "media_assets" (
"id" UUID NOT NULL,
"kind" "MediaKind" NOT NULL DEFAULT 'IMAGE',
"storage_key" VARCHAR(512) NOT NULL,
"mime_type" VARCHAR(120) NOT NULL,
"size_bytes" INTEGER NOT NULL,
"width" INTEGER,
"height" INTEGER,
"blur_data_url" TEXT,
"alt_text" VARCHAR(255),
"metadata" JSONB,
"uploaded_by_user_id" UUID,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "media_assets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "brands" (
"id" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"slug" VARCHAR(180) NOT NULL,
"description" TEXT,
"logo_id" UUID,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"meta_title" VARCHAR(255),
"meta_description" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "brands_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" UUID NOT NULL,
"parent_id" UUID,
"name" VARCHAR(160) NOT NULL,
"slug" VARCHAR(180) NOT NULL,
"path" VARCHAR(512) NOT NULL,
"depth" INTEGER NOT NULL DEFAULT 0,
"position" INTEGER NOT NULL DEFAULT 0,
"description" TEXT,
"image_id" UUID,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"meta_title" VARCHAR(255),
"meta_description" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "collections" (
"id" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"slug" VARCHAR(180) NOT NULL,
"type" "CollectionType" NOT NULL DEFAULT 'MANUAL',
"description" TEXT,
"banner_id" UUID,
"rules" JSONB,
"starts_at" TIMESTAMPTZ(3),
"ends_at" TIMESTAMPTZ(3),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER NOT NULL DEFAULT 0,
"meta_title" VARCHAR(255),
"meta_description" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "collections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_collections" (
"product_id" UUID NOT NULL,
"collection_id" UUID NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "product_collections_pkey" PRIMARY KEY ("product_id","collection_id")
);
-- CreateTable
CREATE TABLE "products" (
"id" UUID NOT NULL,
"name" VARCHAR(255) NOT NULL,
"slug" VARCHAR(280) NOT NULL,
"description" TEXT,
"short_description" VARCHAR(500),
"status" "ProductStatus" NOT NULL DEFAULT 'DRAFT',
"published_at" TIMESTAMPTZ(3),
"brand_id" UUID,
"primary_category_id" UUID,
"gender_targets" "GenderTarget"[],
"sport_types" "SportType"[],
"meta_title" VARCHAR(255),
"meta_description" TEXT,
"metadata" JSONB,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"deleted_at" TIMESTAMPTZ(3),
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_options" (
"id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"name" VARCHAR(60) NOT NULL,
"key" VARCHAR(40) NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "product_options_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_option_values" (
"id" UUID NOT NULL,
"option_id" UUID NOT NULL,
"label" VARCHAR(80) NOT NULL,
"value" VARCHAR(80) NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"swatch_hex" VARCHAR(9),
"swatch_image_id" UUID,
CONSTRAINT "product_option_values_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_variants" (
"id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"sku" VARCHAR(64) NOT NULL,
"barcode" VARCHAR(64),
"title" VARCHAR(255) NOT NULL,
"currency" "Currency" NOT NULL DEFAULT 'VND',
"price_amount" INTEGER NOT NULL,
"sale_price_amount" INTEGER,
"compare_at_amount" INTEGER,
"cost_amount" INTEGER,
"weight_grams" INTEGER,
"length_mm" INTEGER,
"width_mm" INTEGER,
"height_mm" INTEGER,
"status" "VariantStatus" NOT NULL DEFAULT 'ACTIVE',
"position" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"deleted_at" TIMESTAMPTZ(3),
CONSTRAINT "product_variants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_variant_option_values" (
"variant_id" UUID NOT NULL,
"option_id" UUID NOT NULL,
"option_value_id" UUID NOT NULL,
CONSTRAINT "product_variant_option_values_pkey" PRIMARY KEY ("variant_id","option_id")
);
-- CreateTable
CREATE TABLE "product_images" (
"id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"media_id" UUID NOT NULL,
"option_value_id" UUID,
"position" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "product_images_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_attributes" (
"id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"key" VARCHAR(60) NOT NULL,
"label" VARCHAR(120) NOT NULL,
"value" VARCHAR(500) NOT NULL,
"group" VARCHAR(60),
"position" INTEGER NOT NULL DEFAULT 0,
"is_filterable" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "product_attributes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "inventory_locations" (
"id" UUID NOT NULL,
"name" VARCHAR(120) NOT NULL,
"code" VARCHAR(40) NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "inventory_locations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stock_levels" (
"variant_id" UUID NOT NULL,
"location_id" UUID NOT NULL,
"on_hand" INTEGER NOT NULL DEFAULT 0,
"reserved" INTEGER NOT NULL DEFAULT 0,
"reorder_point" INTEGER,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "stock_levels_pkey" PRIMARY KEY ("variant_id","location_id")
);
-- CreateTable
CREATE TABLE "stock_movements" (
"id" UUID NOT NULL,
"variant_id" UUID NOT NULL,
"location_id" UUID NOT NULL,
"quantity_delta" INTEGER NOT NULL,
"reason" "StockMovementReason" NOT NULL,
"reference_id" TEXT,
"note" TEXT,
"created_by_user_id" UUID,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "stock_movements_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "users_type_status_idx" ON "users"("type", "status");
-- CreateIndex
CREATE INDEX "users_created_at_idx" ON "users"("created_at");
-- CreateIndex
CREATE UNIQUE INDEX "roles_key_key" ON "roles"("key");
-- CreateIndex
CREATE UNIQUE INDEX "permissions_key_key" ON "permissions"("key");
-- CreateIndex
CREATE INDEX "permissions_resource_idx" ON "permissions"("resource");
-- CreateIndex
CREATE INDEX "role_permissions_permission_id_idx" ON "role_permissions"("permission_id");
-- CreateIndex
CREATE INDEX "user_roles_role_id_idx" ON "user_roles"("role_id");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_refresh_token_hash_key" ON "sessions"("refresh_token_hash");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_replaced_by_id_key" ON "sessions"("replaced_by_id");
-- CreateIndex
CREATE INDEX "sessions_user_id_revoked_at_idx" ON "sessions"("user_id", "revoked_at");
-- CreateIndex
CREATE INDEX "sessions_family_id_idx" ON "sessions"("family_id");
-- CreateIndex
CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at");
-- CreateIndex
CREATE INDEX "audit_logs_resource_type_resource_id_idx" ON "audit_logs"("resource_type", "resource_id");
-- CreateIndex
CREATE INDEX "audit_logs_actor_user_id_created_at_idx" ON "audit_logs"("actor_user_id", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "customers_user_id_key" ON "customers"("user_id");
-- CreateIndex
CREATE INDEX "addresses_customer_id_idx" ON "addresses"("customer_id");
-- CreateIndex
CREATE UNIQUE INDEX "media_assets_storage_key_key" ON "media_assets"("storage_key");
-- CreateIndex
CREATE INDEX "media_assets_kind_created_at_idx" ON "media_assets"("kind", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "brands_slug_key" ON "brands"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "categories_path_key" ON "categories"("path");
-- CreateIndex
CREATE INDEX "categories_path_idx" ON "categories"("path");
-- CreateIndex
CREATE INDEX "categories_parent_id_position_idx" ON "categories"("parent_id", "position");
-- CreateIndex
CREATE UNIQUE INDEX "categories_parent_id_slug_key" ON "categories"("parent_id", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "collections_slug_key" ON "collections"("slug");
-- CreateIndex
CREATE INDEX "collections_is_active_starts_at_ends_at_idx" ON "collections"("is_active", "starts_at", "ends_at");
-- CreateIndex
CREATE INDEX "product_collections_collection_id_position_idx" ON "product_collections"("collection_id", "position");
-- CreateIndex
CREATE UNIQUE INDEX "products_slug_key" ON "products"("slug");
-- CreateIndex
CREATE INDEX "products_status_published_at_idx" ON "products"("status", "published_at");
-- CreateIndex
CREATE INDEX "products_brand_id_idx" ON "products"("brand_id");
-- CreateIndex
CREATE INDEX "products_primary_category_id_idx" ON "products"("primary_category_id");
-- CreateIndex
CREATE INDEX "products_gender_targets_idx" ON "products" USING GIN ("gender_targets");
-- CreateIndex
CREATE INDEX "products_sport_types_idx" ON "products" USING GIN ("sport_types");
-- CreateIndex
CREATE INDEX "product_options_product_id_position_idx" ON "product_options"("product_id", "position");
-- CreateIndex
CREATE UNIQUE INDEX "product_options_product_id_key_key" ON "product_options"("product_id", "key");
-- CreateIndex
CREATE INDEX "product_option_values_option_id_position_idx" ON "product_option_values"("option_id", "position");
-- CreateIndex
CREATE UNIQUE INDEX "product_option_values_option_id_value_key" ON "product_option_values"("option_id", "value");
-- CreateIndex
CREATE UNIQUE INDEX "product_variants_sku_key" ON "product_variants"("sku");
-- CreateIndex
CREATE UNIQUE INDEX "product_variants_barcode_key" ON "product_variants"("barcode");
-- CreateIndex
CREATE INDEX "product_variants_product_id_position_idx" ON "product_variants"("product_id", "position");
-- CreateIndex
CREATE INDEX "product_variants_status_idx" ON "product_variants"("status");
-- CreateIndex
CREATE INDEX "product_variant_option_values_option_value_id_idx" ON "product_variant_option_values"("option_value_id");
-- CreateIndex
CREATE INDEX "product_images_product_id_position_idx" ON "product_images"("product_id", "position");
-- CreateIndex
CREATE UNIQUE INDEX "product_images_product_id_media_id_option_value_id_key" ON "product_images"("product_id", "media_id", "option_value_id");
-- CreateIndex
CREATE INDEX "product_attributes_key_value_idx" ON "product_attributes"("key", "value");
-- CreateIndex
CREATE UNIQUE INDEX "product_attributes_product_id_key_key" ON "product_attributes"("product_id", "key");
-- CreateIndex
CREATE UNIQUE INDEX "inventory_locations_code_key" ON "inventory_locations"("code");
-- CreateIndex
CREATE INDEX "stock_levels_location_id_idx" ON "stock_levels"("location_id");
-- CreateIndex
CREATE INDEX "stock_movements_variant_id_created_at_idx" ON "stock_movements"("variant_id", "created_at");
-- CreateIndex
CREATE INDEX "stock_movements_reference_id_idx" ON "stock_movements"("reference_id");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_avatar_id_fkey" FOREIGN KEY ("avatar_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_replaced_by_id_fkey" FOREIGN KEY ("replaced_by_id") REFERENCES "sessions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_user_id_fkey" FOREIGN KEY ("actor_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customers" ADD CONSTRAINT "customers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "addresses" ADD CONSTRAINT "addresses_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "brands" ADD CONSTRAINT "brands_logo_id_fkey" FOREIGN KEY ("logo_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_image_id_fkey" FOREIGN KEY ("image_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "collections" ADD CONSTRAINT "collections_banner_id_fkey" FOREIGN KEY ("banner_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_collections" ADD CONSTRAINT "product_collections_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_collections" ADD CONSTRAINT "product_collections_collection_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "products" ADD CONSTRAINT "products_brand_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "brands"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "products" ADD CONSTRAINT "products_primary_category_id_fkey" FOREIGN KEY ("primary_category_id") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_options" ADD CONSTRAINT "product_options_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_option_values" ADD CONSTRAINT "product_option_values_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "product_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_option_values" ADD CONSTRAINT "product_option_values_swatch_image_id_fkey" FOREIGN KEY ("swatch_image_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_variants" ADD CONSTRAINT "product_variants_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_variant_option_values" ADD CONSTRAINT "product_variant_option_values_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "product_variants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_variant_option_values" ADD CONSTRAINT "product_variant_option_values_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "product_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_variant_option_values" ADD CONSTRAINT "product_variant_option_values_option_value_id_fkey" FOREIGN KEY ("option_value_id") REFERENCES "product_option_values"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_images" ADD CONSTRAINT "product_images_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_images" ADD CONSTRAINT "product_images_media_id_fkey" FOREIGN KEY ("media_id") REFERENCES "media_assets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_images" ADD CONSTRAINT "product_images_option_value_id_fkey" FOREIGN KEY ("option_value_id") REFERENCES "product_option_values"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_attributes" ADD CONSTRAINT "product_attributes_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_levels" ADD CONSTRAINT "stock_levels_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "product_variants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_levels" ADD CONSTRAINT "stock_levels_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "inventory_locations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_movements" ADD CONSTRAINT "stock_movements_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "product_variants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_movements" ADD CONSTRAINT "stock_movements_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "inventory_locations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+694
View File
@@ -0,0 +1,694 @@
// ---------------------------------------------------------------------------
// Sport Store — Prisma schema
//
// SCOPE OF THIS FILE (milestone 0)
// Identity + RBAC, catalog (Product / ProductVariant / options / images /
// attributes), taxonomy (brand / category / collection), media metadata and
// the inventory ledger.
//
// Cart, checkout, order, payment, promotion and review tables are milestone 1.
// They are intentionally absent so the first migration stays reviewable.
//
// CONVENTIONS
// - Table names: snake_case plural (@@map). Prisma models: PascalCase singular.
// - Ids: UUID v7 — time-sortable, so they index like a sequence but leak no
// row counts and stay safe to expose in URLs.
// - Money: INTEGER in the currency's minor unit. Never Float, never Decimal
// round-trips through JS. VND has no minor unit, so 250000 means ₫250.000.
// - Timestamps: `timestamptz`. The database always stores UTC.
// - Soft delete only where history matters (products, variants); everywhere
// else a hard delete is correct and simpler.
// ---------------------------------------------------------------------------
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ===========================================================================
// Identity & access control
// ===========================================================================
enum UserType {
CUSTOMER
STAFF
ADMIN
SUPER_ADMIN
}
enum UserStatus {
ACTIVE
INVITED
SUSPENDED
}
/// Every human in the system — shoppers and operators alike — is a User row.
/// The `type` column decides which token audience the account may authenticate
/// against; RBAC decides what it may then do.
model User {
id String @id @default(uuid(7)) @db.Uuid
/// Always normalised to lowercase before write (see @sport/validation), so a
/// plain unique index is enough and no citext extension is required.
email String @unique @db.VarChar(255)
passwordHash String? @map("password_hash")
type UserType
status UserStatus @default(ACTIVE)
firstName String? @map("first_name") @db.VarChar(80)
lastName String? @map("last_name") @db.VarChar(80)
phone String? @db.VarChar(20)
avatarId String? @map("avatar_id") @db.Uuid
emailVerifiedAt DateTime? @map("email_verified_at") @db.Timestamptz(3)
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
roles UserRole[]
sessions Session[]
customer Customer?
auditLogs AuditLog[]
@@index([type, status])
@@index([createdAt])
@@map("users")
}
/// Roles are DATA: a SUPER_ADMIN can create "Warehouse Supervisor" at runtime
/// without a deploy. Only `isSystem` roles are protected from deletion.
model Role {
id String @id @default(uuid(7)) @db.Uuid
key String @unique @db.VarChar(64)
name String @db.VarChar(120)
description String?
isSystem Boolean @default(false) @map("is_system")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
permissions RolePermission[]
users UserRole[]
@@map("roles")
}
/// Permissions are CODE: the catalog in @sport/types is the source of truth and
/// the seed reconciles this table against it. Nothing creates permissions at
/// runtime — that would let the database drift from the guards.
model Permission {
id String @id @default(uuid(7)) @db.Uuid
key String @unique @db.VarChar(64)
resource String @db.VarChar(40)
action String @db.VarChar(40)
description String?
roles RolePermission[]
@@index([resource])
@@map("permissions")
}
model RolePermission {
roleId String @map("role_id") @db.Uuid
permissionId String @map("permission_id") @db.Uuid
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
@@index([permissionId])
@@map("role_permissions")
}
model UserRole {
userId String @map("user_id") @db.Uuid
roleId String @map("role_id") @db.Uuid
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
@@index([roleId])
@@map("user_roles")
}
/// One row per refresh-token family (i.e. per signed-in device).
///
/// The token itself is never stored — only a SHA-256 hash. On refresh the row
/// is rotated: `replacedById` points at the successor. Presenting a token whose
/// row is already replaced means the token leaked, so the entire family is
/// revoked. This is why the column exists at all.
model Session {
id String @id @default(uuid(7)) @db.Uuid
userId String @map("user_id") @db.Uuid
refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(64)
familyId String @map("family_id") @db.Uuid
replacedById String? @unique @map("replaced_by_id") @db.Uuid
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
userAgent String? @map("user_agent")
ipAddress String? @map("ip_address") @db.VarChar(45)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
replacedBy Session? @relation("SessionRotation", fields: [replacedById], references: [id], onDelete: SetNull)
replaces Session? @relation("SessionRotation")
@@index([userId, revokedAt])
@@index([familyId])
@@index([expiresAt])
@@map("sessions")
}
model AuditLog {
id String @id @default(uuid(7)) @db.Uuid
actorUserId String? @map("actor_user_id") @db.Uuid
action String @db.VarChar(80)
resourceType String @map("resource_type") @db.VarChar(60)
resourceId String? @map("resource_id")
/// Before/after snapshot. Append-only; never updated.
changes Json?
ipAddress String? @map("ip_address") @db.VarChar(45)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
actor User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull)
@@index([resourceType, resourceId])
@@index([actorUserId, createdAt])
@@map("audit_logs")
}
// ===========================================================================
// Customers
// ===========================================================================
/// Shopper-specific profile, split from User so that back-office accounts carry
/// none of it and customer data can later move behind a stricter access policy.
model Customer {
id String @id @default(uuid(7)) @db.Uuid
userId String @unique @map("user_id") @db.Uuid
acceptsMarketing Boolean @default(false) @map("accepts_marketing")
dateOfBirth DateTime? @map("date_of_birth") @db.Date
note String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
addresses Address[]
@@map("customers")
}
model Address {
id String @id @default(uuid(7)) @db.Uuid
customerId String @map("customer_id") @db.Uuid
fullName String @map("full_name") @db.VarChar(160)
phone String @db.VarChar(20)
line1 String @db.VarChar(255)
line2 String? @db.VarChar(255)
/// Vietnamese administrative divisions. Codes are kept alongside names so a
/// later shipping-provider integration can map them without re-collecting.
ward String? @db.VarChar(120)
wardCode String? @map("ward_code") @db.VarChar(20)
district String? @db.VarChar(120)
districtCode String? @map("district_code") @db.VarChar(20)
province String @db.VarChar(120)
provinceCode String? @map("province_code") @db.VarChar(20)
countryCode String @default("VN") @map("country_code") @db.Char(2)
postalCode String? @map("postal_code") @db.VarChar(20)
isDefaultShipping Boolean @default(false) @map("is_default_shipping")
isDefaultBilling Boolean @default(false) @map("is_default_billing")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@index([customerId])
@@map("addresses")
}
// ===========================================================================
// Media
// ===========================================================================
enum MediaKind {
IMAGE
VIDEO
DOCUMENT
}
/// Metadata only. The bytes live in R2/S3 under `storageKey`; PostgreSQL never
/// stores binary. Public URLs are composed at read time from STORAGE_PUBLIC_URL
/// + storageKey, so changing CDN or bucket is config, not a data migration.
model MediaAsset {
id String @id @default(uuid(7)) @db.Uuid
kind MediaKind @default(IMAGE)
storageKey String @unique @map("storage_key") @db.VarChar(512)
mimeType String @map("mime_type") @db.VarChar(120)
sizeBytes Int @map("size_bytes")
width Int?
height Int?
/// Base64 LQIP, a few hundred bytes. Cheap enough to store inline.
blurDataUrl String? @map("blur_data_url")
altText String? @map("alt_text") @db.VarChar(255)
/// Free-form: original filename, uploader, EXIF subset, …
metadata Json?
uploadedByUserId String? @map("uploaded_by_user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
productImages ProductImage[]
brandLogos Brand[] @relation("BrandLogo")
categoryImages Category[] @relation("CategoryImage")
collectionBanners Collection[] @relation("CollectionBanner")
optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch")
userAvatars User[] @relation("UserAvatar")
@@index([kind, createdAt])
@@map("media_assets")
}
// ===========================================================================
// Taxonomy
// ===========================================================================
model Brand {
id String @id @default(uuid(7)) @db.Uuid
name String @db.VarChar(160)
slug String @unique @db.VarChar(180)
description String?
logoId String? @map("logo_id") @db.Uuid
isActive Boolean @default(true) @map("is_active")
metaTitle String? @map("meta_title") @db.VarChar(255)
metaDescription String? @map("meta_description")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
logo MediaAsset? @relation("BrandLogo", fields: [logoId], references: [id], onDelete: SetNull)
products Product[]
@@map("brands")
}
/// Hierarchical merchandising tree (Men > Running > Shoes).
///
/// `path` is a materialised path ("men/running/shoes") so an entire subtree is
/// one indexed `LIKE 'men/running%'` query instead of a recursive CTE per page
/// view. `depth` lets navigation queries stop at the level they render.
model Category {
id String @id @default(uuid(7)) @db.Uuid
parentId String? @map("parent_id") @db.Uuid
name String @db.VarChar(160)
slug String @db.VarChar(180)
path String @unique @db.VarChar(512)
depth Int @default(0)
position Int @default(0)
description String?
imageId String? @map("image_id") @db.Uuid
isActive Boolean @default(true) @map("is_active")
metaTitle String? @map("meta_title") @db.VarChar(255)
metaDescription String? @map("meta_description")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children Category[] @relation("CategoryTree")
image MediaAsset? @relation("CategoryImage", fields: [imageId], references: [id], onDelete: SetNull)
products Product[]
@@unique([parentId, slug])
@@index([path])
@@index([parentId, position])
@@map("categories")
}
enum CollectionType {
MANUAL
AUTOMATED
}
model Collection {
id String @id @default(uuid(7)) @db.Uuid
name String @db.VarChar(160)
slug String @unique @db.VarChar(180)
type CollectionType @default(MANUAL)
description String?
bannerId String? @map("banner_id") @db.Uuid
/// Rule set for AUTOMATED collections, evaluated by the catalog module.
rules Json?
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
isActive Boolean @default(true) @map("is_active")
position Int @default(0)
metaTitle String? @map("meta_title") @db.VarChar(255)
metaDescription String? @map("meta_description")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
products ProductCollection[]
@@index([isActive, startsAt, endsAt])
@@map("collections")
}
model ProductCollection {
productId String @map("product_id") @db.Uuid
collectionId String @map("collection_id") @db.Uuid
position Int @default(0)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
@@id([productId, collectionId])
@@index([collectionId, position])
@@map("product_collections")
}
// ===========================================================================
// Catalog: Product / ProductVariant
// ===========================================================================
enum ProductStatus {
DRAFT
ACTIVE
ARCHIVED
}
enum VariantStatus {
ACTIVE
ARCHIVED
}
enum Currency {
VND
USD
}
enum GenderTarget {
MEN
WOMEN
KIDS
UNISEX
}
enum SportType {
RUNNING
FOOTBALL
TRAINING
GYM
BADMINTON
LIFESTYLE
}
/// The marketing entity: it has a name, a URL and a page. It deliberately has
/// NO sku, NO price and NO stock — those belong to ProductVariant, because in
/// reality "Black / M" and "White / L" are different physical goods.
model Product {
id String @id @default(uuid(7)) @db.Uuid
name String @db.VarChar(255)
slug String @unique @db.VarChar(280)
description String?
shortDescription String? @map("short_description") @db.VarChar(500)
status ProductStatus @default(DRAFT)
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
brandId String? @map("brand_id") @db.Uuid
primaryCategoryId String? @map("primary_category_id") @db.Uuid
/// Facets driving /men, /women and /sports/*. Arrays rather than join tables:
/// they are small, bounded, always fetched with the product and filtered with
/// a GIN index — a join table would buy nothing here.
genderTargets GenderTarget[] @map("gender_targets")
sportTypes SportType[] @map("sport_types")
metaTitle String? @map("meta_title") @db.VarChar(255)
metaDescription String? @map("meta_description")
metadata Json?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
options ProductOption[]
variants ProductVariant[]
images ProductImage[]
attributes ProductAttribute[]
collections ProductCollection[]
@@index([status, publishedAt])
@@index([brandId])
@@index([primaryCategoryId])
@@index([genderTargets], type: Gin)
@@index([sportTypes], type: Gin)
@@map("products")
}
/// An axis of variation for one product: "Colour", "Size".
model ProductOption {
id String @id @default(uuid(7)) @db.Uuid
productId String @map("product_id") @db.Uuid
name String @db.VarChar(60)
/// Stable machine key (`colour`, `size`) used by URLs and integrations.
key String @db.VarChar(40)
position Int @default(0)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
values ProductOptionValue[]
variantLinks ProductVariantOptionValue[]
@@unique([productId, key])
@@index([productId, position])
@@map("product_options")
}
/// One allowed value on an axis: "Black", "M".
model ProductOptionValue {
id String @id @default(uuid(7)) @db.Uuid
optionId String @map("option_id") @db.Uuid
label String @db.VarChar(80)
value String @db.VarChar(80)
position Int @default(0)
swatchHex String? @map("swatch_hex") @db.VarChar(9)
swatchImageId String? @map("swatch_image_id") @db.Uuid
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
swatchImage MediaAsset? @relation("OptionValueSwatch", fields: [swatchImageId], references: [id], onDelete: SetNull)
variantLinks ProductVariantOptionValue[]
images ProductImage[]
@@unique([optionId, value])
@@index([optionId, position])
@@map("product_option_values")
}
/// The purchasable unit. Everything downstream — cart lines, order lines, stock
/// movements, marketplace listings — references THIS id, never a Product id.
model ProductVariant {
id String @id @default(uuid(7)) @db.Uuid
productId String @map("product_id") @db.Uuid
sku String @unique @db.VarChar(64)
barcode String? @unique @db.VarChar(64)
/// Denormalised "Black / M" for display and for order-line snapshots.
title String @db.VarChar(255)
currency Currency @default(VND)
/// All amounts are integers in the currency's minor unit.
priceAmount Int @map("price_amount")
salePriceAmount Int? @map("sale_price_amount")
compareAtAmount Int? @map("compare_at_amount")
/// Landed cost — admin only, never serialised to the storefront.
costAmount Int? @map("cost_amount")
weightGrams Int? @map("weight_grams")
lengthMm Int? @map("length_mm")
widthMm Int? @map("width_mm")
heightMm Int? @map("height_mm")
status VariantStatus @default(ACTIVE)
position Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
optionValues ProductVariantOptionValue[]
stockLevels StockLevel[]
stockMovements StockMovement[]
@@index([productId, position])
@@index([status])
@@map("product_variants")
}
/// Resolves a variant to exactly one value per product option.
///
/// The (variantId, optionId) primary key is what enforces "a variant cannot
/// have two colours" at the database level rather than in application code.
model ProductVariantOptionValue {
variantId String @map("variant_id") @db.Uuid
optionId String @map("option_id") @db.Uuid
optionValueId String @map("option_value_id") @db.Uuid
variant ProductVariant @relation(fields: [variantId], references: [id], onDelete: Cascade)
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
optionValue ProductOptionValue @relation(fields: [optionValueId], references: [id], onDelete: Restrict)
@@id([variantId, optionId])
@@index([optionValueId])
@@map("product_variant_option_values")
}
model ProductImage {
id String @id @default(uuid(7)) @db.Uuid
productId String @map("product_id") @db.Uuid
mediaId String @map("media_id") @db.Uuid
/// When set, the gallery swaps to these images once the shopper picks that
/// option value (in practice: the colour).
optionValueId String? @map("option_value_id") @db.Uuid
position Int @default(0)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
media MediaAsset @relation(fields: [mediaId], references: [id], onDelete: Restrict)
optionValue ProductOptionValue? @relation(fields: [optionValueId], references: [id], onDelete: SetNull)
@@unique([productId, mediaId, optionValueId])
@@index([productId, position])
@@map("product_images")
}
/// Spec rows ("Material: 92% polyester"). Free-form on purpose: merchandisers
/// add specs without a migration. Anything that must be *filtered* on graduates
/// to a real column or a facet instead.
model ProductAttribute {
id String @id @default(uuid(7)) @db.Uuid
productId String @map("product_id") @db.Uuid
key String @db.VarChar(60)
label String @db.VarChar(120)
value String @db.VarChar(500)
group String? @db.VarChar(60)
position Int @default(0)
isFilterable Boolean @default(false) @map("is_filterable")
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, key])
@@index([key, value])
@@map("product_attributes")
}
// ===========================================================================
// Inventory
// ===========================================================================
/// Stock is per (variant, location) from day one. A single warehouse today is
/// just one row here; adding a second store or a 3PL later needs no migration
/// of historical data.
model InventoryLocation {
id String @id @default(uuid(7)) @db.Uuid
name String @db.VarChar(120)
code String @unique @db.VarChar(40)
isDefault Boolean @default(false) @map("is_default")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
stockLevels StockLevel[]
stockMovements StockMovement[]
@@map("inventory_locations")
}
/// Current position. `available` is NOT stored — it is always onHand - reserved,
/// computed at read time so the two numbers can never disagree.
model StockLevel {
variantId String @map("variant_id") @db.Uuid
locationId String @map("location_id") @db.Uuid
onHand Int @default(0) @map("on_hand")
/// Held by in-flight checkouts. Released on payment failure or expiry.
reserved Int @default(0)
reorderPoint Int? @map("reorder_point")
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
variant ProductVariant @relation(fields: [variantId], references: [id], onDelete: Cascade)
location InventoryLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
@@id([variantId, locationId])
@@index([locationId])
@@map("stock_levels")
}
enum StockMovementReason {
PURCHASE_RECEIPT
SALE
RETURN
MANUAL_ADJUSTMENT
STOCK_TAKE
TRANSFER_IN
TRANSFER_OUT
DAMAGE
}
/// Append-only ledger. StockLevel is a projection of these rows, which is what
/// makes "why is this number wrong?" an answerable question — and what makes a
/// future extraction of Inventory into its own service straightforward.
model StockMovement {
id String @id @default(uuid(7)) @db.Uuid
variantId String @map("variant_id") @db.Uuid
locationId String @map("location_id") @db.Uuid
/// Signed: negative for outbound.
quantityDelta Int @map("quantity_delta")
reason StockMovementReason
referenceId String? @map("reference_id")
note String?
createdByUserId String? @map("created_by_user_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
variant ProductVariant @relation(fields: [variantId], references: [id], onDelete: Restrict)
location InventoryLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
@@index([variantId, createdAt])
@@index([referenceId])
@@map("stock_movements")
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Idempotent seed. Safe to run on every environment, including production.
*
* It reconciles the *code-owned* parts of the schema — the permission catalog
* and the system roles — with the database. It deliberately does NOT create
* users: account provisioning belongs to the auth milestone, and baking a
* default admin password into a repository is how stores get compromised.
*/
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/** Which permissions each system role starts with. Editable later at runtime. */
const ROLE_DEFINITIONS: Record<string, { name: string; permissions: readonly Permission[] }> = {
[SYSTEM_ROLES.SUPER_ADMIN]: {
name: 'Super Admin',
permissions: ALL_PERMISSIONS,
},
[SYSTEM_ROLES.ADMIN]: {
name: 'Admin',
permissions: ALL_PERMISSIONS.filter(
(permission) =>
permission !== PERMISSIONS.ROLE_MANAGE && permission !== PERMISSIONS.USER_MANAGE,
),
},
[SYSTEM_ROLES.CATALOG_MANAGER]: {
name: 'Catalog Manager',
permissions: [
PERMISSIONS.PRODUCT_READ,
PERMISSIONS.PRODUCT_CREATE,
PERMISSIONS.PRODUCT_UPDATE,
PERMISSIONS.PRODUCT_PUBLISH,
PERMISSIONS.CATEGORY_READ,
PERMISSIONS.CATEGORY_MANAGE,
PERMISSIONS.COLLECTION_READ,
PERMISSIONS.COLLECTION_MANAGE,
PERMISSIONS.BRAND_READ,
PERMISSIONS.BRAND_MANAGE,
PERMISSIONS.INVENTORY_READ,
PERMISSIONS.INVENTORY_UPDATE,
PERMISSIONS.MEDIA_READ,
PERMISSIONS.MEDIA_UPLOAD,
],
},
[SYSTEM_ROLES.ORDER_MANAGER]: {
name: 'Order Manager',
permissions: [
PERMISSIONS.ORDER_READ,
PERMISSIONS.ORDER_UPDATE,
PERMISSIONS.ORDER_CANCEL,
PERMISSIONS.PAYMENT_READ,
PERMISSIONS.CUSTOMER_READ,
PERMISSIONS.INVENTORY_READ,
PERMISSIONS.PRODUCT_READ,
],
},
[SYSTEM_ROLES.SUPPORT_AGENT]: {
name: 'Support Agent',
permissions: [
PERMISSIONS.ORDER_READ,
PERMISSIONS.CUSTOMER_READ,
PERMISSIONS.PRODUCT_READ,
PERMISSIONS.REVIEW_MODERATE,
],
},
[SYSTEM_ROLES.CUSTOMER]: {
name: 'Customer',
permissions: [],
},
};
async function seedPermissions(): Promise<Map<string, string>> {
for (const key of ALL_PERMISSIONS) {
const [resource = key, action = 'unknown'] = key.split('.');
await prisma.permission.upsert({
where: { key },
update: { resource, action },
create: { key, resource, action },
});
}
// Anything in the table but no longer in the catalog is dead configuration.
const removed = await prisma.permission.deleteMany({
where: { key: { notIn: [...ALL_PERMISSIONS] } },
});
if (removed.count > 0) {
console.log(`Removed ${removed.count} stale permission(s).`);
}
const rows = await prisma.permission.findMany({ select: { id: true, key: true } });
return new Map(rows.map((row) => [row.key, row.id]));
}
async function seedRoles(permissionIds: Map<string, string>): Promise<void> {
for (const [key, definition] of Object.entries(ROLE_DEFINITIONS)) {
const role = await prisma.role.upsert({
where: { key },
update: { name: definition.name, isSystem: true },
create: { key, name: definition.name, isSystem: true },
});
// Replace the grant set wholesale — the code definition wins for system roles.
await prisma.rolePermission.deleteMany({ where: { roleId: role.id } });
const grants = definition.permissions
.map((permission) => permissionIds.get(permission))
.filter((id): id is string => Boolean(id))
.map((permissionId) => ({ roleId: role.id, permissionId }));
if (grants.length > 0) {
await prisma.rolePermission.createMany({ data: grants, skipDuplicates: true });
}
}
}
async function seedInventoryLocation(): Promise<void> {
await prisma.inventoryLocation.upsert({
where: { code: 'MAIN' },
update: {},
create: { code: 'MAIN', name: 'Main Warehouse', isDefault: true },
});
}
async function main(): Promise<void> {
const permissionIds = await seedPermissions();
await seedRoles(permissionIds);
await seedInventoryLocation();
console.log(
`Seed complete: ${permissionIds.size} permissions, ${Object.keys(ROLE_DEFINITIONS).length} roles.`,
);
}
main()
.catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => {
void prisma.$disconnect();
});