Basic Architecture of Sport Web
This commit is contained in:
@@ -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"
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user