diff --git a/apps/api/prisma/migrations/0001_init.sql b/apps/api/prisma/migrations/0001_init.sql new file mode 100644 index 0000000..c12ff02 --- /dev/null +++ b/apps/api/prisma/migrations/0001_init.sql @@ -0,0 +1,431 @@ +-- CreateEnum +CREATE TYPE "AdminRole" AS ENUM ('SUPER_ADMIN', 'SYSTEM_APPROVER', 'SALES_EXPERT', 'WORKSHOP_SUPERVISOR', 'ACCOUNTANT'); + +-- CreateEnum +CREATE TYPE "VerificationStep" AS ENUM ('UNVERIFIED', 'STEP_ONE_SUBMITTED', 'STEP_TWO_SUBMITTED', 'VERIFIED'); + +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('SUSPENDED', 'PLACED', 'ADMIN_APPROVED', 'SOURCING', 'PREPARING', 'DELIVERED_TO_AGENT', 'DELIVERED_TO_CUSTOMER', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "PreOrderStatus" AS ENUM ('PLACED', 'ADMIN_APPROVED', 'WORKSHOP_APPROVED', 'READY_TO_SHIP', 'DELIVERED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "LedgerCurrency" AS ENUM ('GOLD_GRAM', 'RIAL'); + +-- CreateEnum +CREATE TYPE "LedgerDirection" AS ENUM ('DEBIT', 'CREDIT'); + +-- CreateEnum +CREATE TYPE "LedgerSourceType" AS ENUM ('ORDER_STOCK', 'ORDER_PRE_ORDER', 'REPAYMENT_MELTED_GOLD', 'REPAYMENT_TRANSFER', 'REPAYMENT_CHEQUE', 'REPAYMENT_CASH', 'WALLET_BONUS', 'ADJUSTMENT'); + +-- CreateTable +CREATE TABLE "City" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "province" TEXT, + + CONSTRAINT "City_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AdminUser" ( + "id" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "mobile" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "role" "AdminRole" NOT NULL, + "branchId" TEXT, + "wagePercent" DECIMAL(6,3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Customer" ( + "id" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "mobile" TEXT NOT NULL, + "nationalCode" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "cityId" TEXT, + "referredByAgentId" TEXT, + "goldCreditLimitGrams" DECIMAL(14,4) NOT NULL DEFAULT 0, + "goldDebtGrams" DECIMAL(14,4) NOT NULL DEFAULT 0, + "fiatDebtRial" DECIMAL(20,2) NOT NULL DEFAULT 0, + "verificationStep" "VerificationStep" NOT NULL DEFAULT 'UNVERIFIED', + "nationalIdImageUrl" TEXT, + "businessLicenseUrl" TEXT, + "shopImageUrl" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CustomerAddress" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "cityId" TEXT, + "addressLine" TEXT NOT NULL, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "CustomerAddress_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Branch" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "cityId" TEXT, + + CONSTRAINT "Branch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Workshop" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Workshop_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Category" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "parentId" TEXT, + + CONSTRAINT "Category_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Brand" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + + CONSTRAINT "Brand_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Collection" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Collection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CollectionProduct" ( + "collectionId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + + CONSTRAINT "CollectionProduct_pkey" PRIMARY KEY ("collectionId","productId") +); + +-- CreateTable +CREATE TABLE "Product" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "categoryId" TEXT, + "brandId" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductImage" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductVariant" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "sku" TEXT, + "rashWeightGrams" DECIMAL(14,4) NOT NULL, + "defaultWagePercent" DECIMAL(6,3) NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Order" ( + "id" TEXT NOT NULL, + "orderNumber" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "branchId" TEXT, + "status" "OrderStatus" NOT NULL DEFAULT 'PLACED', + "totalGoldWeightGrams" DECIMAL(14,4) NOT NULL, + "wageWeightGrams" DECIMAL(14,4) NOT NULL, + "flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0, + "isSuspendedOverCreditLimit" BOOLEAN NOT NULL DEFAULT false, + "deliveredAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Order_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderItem" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "productVariantId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "rashWeightGramsSnapshot" DECIMAL(14,4) NOT NULL, + "wagePercentSnapshot" DECIMAL(6,3) NOT NULL, + "discountPercentSnapshot" DECIMAL(6,3) NOT NULL DEFAULT 0, + "computedWeightGrams" DECIMAL(14,4) NOT NULL, + "flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0, + + CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderStatusEvent" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "fromStatus" "OrderStatus", + "toStatus" "OrderStatus" NOT NULL, + "note" TEXT, + "actorAdminUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OrderStatusEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PreOrder" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "cityId" TEXT, + "workshopId" TEXT, + "status" "PreOrderStatus" NOT NULL DEFAULT 'PLACED', + "designImageUrl" TEXT, + "colorName" TEXT, + "sizeSpec" TEXT, + "stoneColor" TEXT, + "minaColor" TEXT, + "estimatedWeightGrams" DECIMAL(14,4), + "estimatedPriceRial" DECIMAL(20,2), + "description" TEXT, + "convertedOrderId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PreOrder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PreOrderStatusEvent" ( + "id" TEXT NOT NULL, + "preOrderId" TEXT NOT NULL, + "changes" JSONB NOT NULL, + "actorAdminUserId" TEXT, + "ipAddress" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PreOrderStatusEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LedgerEntry" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "currency" "LedgerCurrency" NOT NULL, + "direction" "LedgerDirection" NOT NULL, + "sourceType" "LedgerSourceType" NOT NULL, + "amount" DECIMAL(20,4) NOT NULL, + "karatPerMille" INTEGER, + "pricePerGramRialAtEntry" DECIMAL(20,2), + "orderId" TEXT, + "confirmed" BOOLEAN NOT NULL DEFAULT false, + "confirmedAt" TIMESTAMP(3), + "postedByAdminUserId" TEXT, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LedgerEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GoldPriceQuote" ( + "id" TEXT NOT NULL, + "karatPerMille" INTEGER NOT NULL, + "pricePerGramRial" DECIMAL(20,2) NOT NULL, + "source" TEXT NOT NULL, + "quotedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GoldPriceQuote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletBonus" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "orderId" TEXT, + "goldGrams" DECIMAL(14,4) NOT NULL, + "grantedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "WalletBonus_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminUser_mobile_key" ON "AdminUser"("mobile"); + +-- CreateIndex +CREATE INDEX "AdminUser_role_idx" ON "AdminUser"("role"); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_mobile_key" ON "Customer"("mobile"); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_nationalCode_key" ON "Customer"("nationalCode"); + +-- CreateIndex +CREATE INDEX "Customer_referredByAgentId_idx" ON "Customer"("referredByAgentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProductVariant_sku_key" ON "ProductVariant"("sku"); + +-- CreateIndex +CREATE UNIQUE INDEX "Order_orderNumber_key" ON "Order"("orderNumber"); + +-- CreateIndex +CREATE INDEX "Order_customerId_idx" ON "Order"("customerId"); + +-- CreateIndex +CREATE INDEX "Order_status_idx" ON "Order"("status"); + +-- CreateIndex +CREATE INDEX "OrderStatusEvent_orderId_idx" ON "OrderStatusEvent"("orderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PreOrder_convertedOrderId_key" ON "PreOrder"("convertedOrderId"); + +-- CreateIndex +CREATE INDEX "PreOrder_customerId_idx" ON "PreOrder"("customerId"); + +-- CreateIndex +CREATE INDEX "PreOrderStatusEvent_preOrderId_idx" ON "PreOrderStatusEvent"("preOrderId"); + +-- CreateIndex +CREATE INDEX "LedgerEntry_customerId_currency_idx" ON "LedgerEntry"("customerId", "currency"); + +-- CreateIndex +CREATE INDEX "LedgerEntry_orderId_idx" ON "LedgerEntry"("orderId"); + +-- CreateIndex +CREATE INDEX "GoldPriceQuote_karatPerMille_quotedAt_idx" ON "GoldPriceQuote"("karatPerMille", "quotedAt"); + +-- CreateIndex +CREATE INDEX "WalletBonus_customerId_isActive_idx" ON "WalletBonus"("customerId", "isActive"); + +-- AddForeignKey +ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Customer" ADD CONSTRAINT "Customer_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Customer" ADD CONSTRAINT "Customer_referredByAgentId_fkey" FOREIGN KEY ("referredByAgentId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Branch" ADD CONSTRAINT "Branch_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CollectionProduct" ADD CONSTRAINT "CollectionProduct_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CollectionProduct" ADD CONSTRAINT "CollectionProduct_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productVariantId_fkey" FOREIGN KEY ("productVariantId") REFERENCES "ProductVariant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_actorAdminUserId_fkey" FOREIGN KEY ("actorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_convertedOrderId_fkey" FOREIGN KEY ("convertedOrderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_preOrderId_fkey" FOREIGN KEY ("preOrderId") REFERENCES "PreOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_actorAdminUserId_fkey" FOREIGN KEY ("actorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_postedByAdminUserId_fkey" FOREIGN KEY ("postedByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletBonus" ADD CONSTRAINT "WalletBonus_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/apps/api/prisma/migrations/20260910113720_first/migration.sql b/apps/api/prisma/migrations/20260910113720_first/migration.sql new file mode 100644 index 0000000..fa63cf4 --- /dev/null +++ b/apps/api/prisma/migrations/20260910113720_first/migration.sql @@ -0,0 +1,430 @@ +-- CreateEnum +CREATE TYPE "AdminRole" AS ENUM ('SUPER_ADMIN', 'SYSTEM_APPROVER', 'SALES_EXPERT', 'WORKSHOP_SUPERVISOR', 'ACCOUNTANT'); + +-- CreateEnum +CREATE TYPE "VerificationStep" AS ENUM ('UNVERIFIED', 'STEP_ONE_SUBMITTED', 'STEP_TWO_SUBMITTED', 'VERIFIED'); + +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('SUSPENDED', 'PLACED', 'ADMIN_APPROVED', 'SOURCING', 'PREPARING', 'DELIVERED_TO_AGENT', 'DELIVERED_TO_CUSTOMER', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "PreOrderStatus" AS ENUM ('PLACED', 'ADMIN_APPROVED', 'WORKSHOP_APPROVED', 'READY_TO_SHIP', 'DELIVERED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "LedgerCurrency" AS ENUM ('GOLD_GRAM', 'RIAL'); + +-- CreateEnum +CREATE TYPE "LedgerDirection" AS ENUM ('DEBIT', 'CREDIT'); + +-- CreateEnum +CREATE TYPE "LedgerSourceType" AS ENUM ('ORDER_STOCK', 'ORDER_PRE_ORDER', 'REPAYMENT_MELTED_GOLD', 'REPAYMENT_TRANSFER', 'REPAYMENT_CHEQUE', 'REPAYMENT_CASH', 'WALLET_BONUS', 'ADJUSTMENT'); + +-- CreateTable +CREATE TABLE "City" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "province" TEXT, + + CONSTRAINT "City_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AdminUser" ( + "id" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "mobile" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "role" "AdminRole" NOT NULL, + "branchId" TEXT, + "wagePercent" DECIMAL(6,3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Customer" ( + "id" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "mobile" TEXT NOT NULL, + "nationalCode" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "cityId" TEXT, + "referredByAgentId" TEXT, + "goldCreditLimitGrams" DECIMAL(14,4) NOT NULL DEFAULT 0, + "goldDebtGrams" DECIMAL(14,4) NOT NULL DEFAULT 0, + "fiatDebtRial" DECIMAL(20,2) NOT NULL DEFAULT 0, + "verificationStep" "VerificationStep" NOT NULL DEFAULT 'UNVERIFIED', + "nationalIdImageUrl" TEXT, + "businessLicenseUrl" TEXT, + "shopImageUrl" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CustomerAddress" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "cityId" TEXT, + "addressLine" TEXT NOT NULL, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "CustomerAddress_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Branch" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "cityId" TEXT, + + CONSTRAINT "Branch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Workshop" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Workshop_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Category" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "parentId" TEXT, + + CONSTRAINT "Category_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Brand" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + + CONSTRAINT "Brand_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Collection" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Collection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CollectionProduct" ( + "collectionId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + + CONSTRAINT "CollectionProduct_pkey" PRIMARY KEY ("collectionId","productId") +); + +-- CreateTable +CREATE TABLE "Product" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "categoryId" TEXT, + "brandId" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductImage" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductVariant" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "sku" TEXT, + "rashWeightGrams" DECIMAL(14,4) NOT NULL, + "defaultWagePercent" DECIMAL(6,3) NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Order" ( + "id" TEXT NOT NULL, + "orderNumber" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "branchId" TEXT, + "status" "OrderStatus" NOT NULL DEFAULT 'PLACED', + "totalGoldWeightGrams" DECIMAL(14,4) NOT NULL, + "wageWeightGrams" DECIMAL(14,4) NOT NULL, + "flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0, + "isSuspendedOverCreditLimit" BOOLEAN NOT NULL DEFAULT false, + "deliveredAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Order_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderItem" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "productVariantId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "rashWeightGramsSnapshot" DECIMAL(14,4) NOT NULL, + "wagePercentSnapshot" DECIMAL(6,3) NOT NULL, + "discountPercentSnapshot" DECIMAL(6,3) NOT NULL DEFAULT 0, + "computedWeightGrams" DECIMAL(14,4) NOT NULL, + "flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0, + + CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderStatusEvent" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "fromStatus" "OrderStatus", + "toStatus" "OrderStatus" NOT NULL, + "note" TEXT, + "actorAdminUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OrderStatusEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PreOrder" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "cityId" TEXT, + "workshopId" TEXT, + "status" "PreOrderStatus" NOT NULL DEFAULT 'PLACED', + "designImageUrl" TEXT, + "colorName" TEXT, + "sizeSpec" TEXT, + "stoneColor" TEXT, + "minaColor" TEXT, + "estimatedWeightGrams" DECIMAL(14,4), + "estimatedPriceRial" DECIMAL(20,2), + "description" TEXT, + "convertedOrderId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PreOrder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PreOrderStatusEvent" ( + "id" TEXT NOT NULL, + "preOrderId" TEXT NOT NULL, + "changes" JSONB NOT NULL, + "actorAdminUserId" TEXT, + "ipAddress" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PreOrderStatusEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LedgerEntry" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "currency" "LedgerCurrency" NOT NULL, + "direction" "LedgerDirection" NOT NULL, + "sourceType" "LedgerSourceType" NOT NULL, + "amount" DECIMAL(20,4) NOT NULL, + "karatPerMille" INTEGER, + "pricePerGramRialAtEntry" DECIMAL(20,2), + "orderId" TEXT, + "confirmed" BOOLEAN NOT NULL DEFAULT false, + "confirmedAt" TIMESTAMP(3), + "postedByAdminUserId" TEXT, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LedgerEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GoldPriceQuote" ( + "id" TEXT NOT NULL, + "karatPerMille" INTEGER NOT NULL, + "pricePerGramRial" DECIMAL(20,2) NOT NULL, + "source" TEXT NOT NULL, + "quotedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GoldPriceQuote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletBonus" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "orderId" TEXT, + "goldGrams" DECIMAL(14,4) NOT NULL, + "grantedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "WalletBonus_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminUser_mobile_key" ON "AdminUser"("mobile"); + +-- CreateIndex +CREATE INDEX "AdminUser_role_idx" ON "AdminUser"("role"); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_mobile_key" ON "Customer"("mobile"); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_nationalCode_key" ON "Customer"("nationalCode"); + +-- CreateIndex +CREATE INDEX "Customer_referredByAgentId_idx" ON "Customer"("referredByAgentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProductVariant_sku_key" ON "ProductVariant"("sku"); + +-- CreateIndex +CREATE UNIQUE INDEX "Order_orderNumber_key" ON "Order"("orderNumber"); + +-- CreateIndex +CREATE INDEX "Order_customerId_idx" ON "Order"("customerId"); + +-- CreateIndex +CREATE INDEX "Order_status_idx" ON "Order"("status"); + +-- CreateIndex +CREATE INDEX "OrderStatusEvent_orderId_idx" ON "OrderStatusEvent"("orderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PreOrder_convertedOrderId_key" ON "PreOrder"("convertedOrderId"); + +-- CreateIndex +CREATE INDEX "PreOrder_customerId_idx" ON "PreOrder"("customerId"); + +-- CreateIndex +CREATE INDEX "PreOrderStatusEvent_preOrderId_idx" ON "PreOrderStatusEvent"("preOrderId"); + +-- CreateIndex +CREATE INDEX "LedgerEntry_customerId_currency_idx" ON "LedgerEntry"("customerId", "currency"); + +-- CreateIndex +CREATE INDEX "LedgerEntry_orderId_idx" ON "LedgerEntry"("orderId"); + +-- CreateIndex +CREATE INDEX "GoldPriceQuote_karatPerMille_quotedAt_idx" ON "GoldPriceQuote"("karatPerMille", "quotedAt"); + +-- CreateIndex +CREATE INDEX "WalletBonus_customerId_isActive_idx" ON "WalletBonus"("customerId", "isActive"); + +-- AddForeignKey +ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Customer" ADD CONSTRAINT "Customer_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Customer" ADD CONSTRAINT "Customer_referredByAgentId_fkey" FOREIGN KEY ("referredByAgentId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Branch" ADD CONSTRAINT "Branch_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CollectionProduct" ADD CONSTRAINT "CollectionProduct_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CollectionProduct" ADD CONSTRAINT "CollectionProduct_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productVariantId_fkey" FOREIGN KEY ("productVariantId") REFERENCES "ProductVariant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_actorAdminUserId_fkey" FOREIGN KEY ("actorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_convertedOrderId_fkey" FOREIGN KEY ("convertedOrderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_preOrderId_fkey" FOREIGN KEY ("preOrderId") REFERENCES "PreOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_actorAdminUserId_fkey" FOREIGN KEY ("actorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_postedByAdminUserId_fkey" FOREIGN KEY ("postedByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletBonus" ADD CONSTRAINT "WalletBonus_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/api/release/app.js b/apps/api/release/app.js index 049b712..2d13f5e 100644 --- a/apps/api/release/app.js +++ b/apps/api/release/app.js @@ -77552,6 +77552,190 @@ var require_app_module = __commonJS({ } }); +// dist/prisma/migrate.js +var require_migrate = __commonJS({ + "dist/prisma/migrate.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.splitSqlStatements = splitSqlStatements; + exports2.runMigrations = runMigrations; + exports2.runOnce = runOnce; + var fs_1 = require("fs"); + var path_1 = require("path"); + var TRACKING_TABLE = "_goldbank_migrations"; + function resolveMigrationsDir() { + const candidates = [ + process.env.MIGRATIONS_DIR, + (0, path_1.join)(process.cwd(), "migrations"), + (0, path_1.join)(__dirname, "..", "..", "migrations"), + (0, path_1.join)(__dirname, "..", "..", "prisma", "migrations"), + (0, path_1.join)(process.cwd(), "prisma", "migrations") + ].filter((p) => Boolean(p)); + const found = candidates.find((dir) => (0, fs_1.existsSync)(dir)); + if (!found) { + throw new Error(`\u067E\u0648\u0634\u0647 migrations \u067E\u06CC\u062F\u0627 \u0646\u0634\u062F. \u0645\u0633\u06CC\u0631\u0647\u0627\u06CC \u0628\u0631\u0631\u0633\u06CC\u200C\u0634\u062F\u0647: ${candidates.join(", ")}`); + } + return found; + } + function splitSqlStatements(sql) { + const statements = []; + let current = ""; + let i = 0; + while (i < sql.length) { + const char = sql[i]; + const rest = sql.slice(i); + if (rest.startsWith("--")) { + const lineEnd = sql.indexOf("\n", i); + i = lineEnd === -1 ? sql.length : lineEnd + 1; + continue; + } + if (rest.startsWith("/*")) { + const commentEnd = sql.indexOf("*/", i + 2); + i = commentEnd === -1 ? sql.length : commentEnd + 2; + continue; + } + if (char === "'" || char === '"') { + const quote = char; + current += char; + i += 1; + while (i < sql.length) { + current += sql[i]; + if (sql[i] === quote) { + if (sql[i + 1] === quote) { + current += sql[i + 1]; + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + continue; + } + const dollarTag = /^\$[A-Za-z_]*\$/.exec(rest); + if (dollarTag) { + const tag = dollarTag[0]; + const closing = sql.indexOf(tag, i + tag.length); + const end = closing === -1 ? sql.length : closing + tag.length; + current += sql.slice(i, end); + i = end; + continue; + } + if (char === ";") { + if (current.trim()) + statements.push(current.trim()); + current = ""; + i += 1; + continue; + } + current += char; + i += 1; + } + if (current.trim()) + statements.push(current.trim()); + return statements; + } + async function runMigrations(prisma) { + const dir = resolveMigrationsDir(); + const files = (0, fs_1.readdirSync)(dir).filter((name) => name.endsWith(".sql")).sort(); + const trackingExisted = await prisma.$queryRawUnsafe(`SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = $1 + ) AS "exists"`, TRACKING_TABLE); + const freshDatabase = !trackingExisted[0]?.exists; + await prisma.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS "${TRACKING_TABLE}" ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`); + const alreadyApplied = await prisma.$queryRawUnsafe(`SELECT name FROM "${TRACKING_TABLE}"`); + const appliedNames = new Set(alreadyApplied.map((row) => row.name)); + const applied = []; + for (const file2 of files) { + if (appliedNames.has(file2)) + continue; + const statements = splitSqlStatements((0, fs_1.readFileSync)((0, path_1.join)(dir, file2), "utf8")); + await prisma.$transaction( + async (tx) => { + for (const statement of statements) { + await tx.$executeRawUnsafe(statement); + } + await tx.$executeRawUnsafe(`INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)`, file2); + }, + // The baseline migration is hundreds of DDL statements; the 5s default is + // not enough, especially against a remote database. + { maxWait: 6e4, timeout: 6e5 } + ); + applied.push(file2); + } + return { freshDatabase, applied }; + } + async function runOnce(prisma, name, task) { + const rows = await prisma.$queryRawUnsafe(`SELECT name FROM "${TRACKING_TABLE}" WHERE name = $1`, name); + if (rows.length > 0) + return false; + await task(); + await prisma.$executeRawUnsafe(`INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)`, name); + return true; + } + } +}); + +// dist/prisma/bootstrap.js +var require_bootstrap = __commonJS({ + "dist/prisma/bootstrap.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.prepareDatabase = prepareDatabase; + var child_process_1 = require("child_process"); + var fs_1 = require("fs"); + var path_1 = require("path"); + var util_1 = require("util"); + var common_12 = require_common(); + var client_1 = require("@prisma/client"); + var migrate_1 = require_migrate(); + var execFileAsync = (0, util_1.promisify)(child_process_1.execFile); + function resolveSeedScript() { + const candidates = [ + process.env.SEED_SCRIPT, + (0, path_1.join)(process.cwd(), "seed.js"), + (0, path_1.join)(__dirname, "..", "..", "seed.js") + ].filter((p) => Boolean(p)); + return candidates.find((file2) => (0, fs_1.existsSync)(file2)); + } + async function prepareDatabase() { + if (process.env.RUN_MIGRATIONS === "false") + return; + const logger = new common_12.Logger("DatabaseBootstrap"); + const prisma = new client_1.PrismaClient(); + try { + const { applied } = await (0, migrate_1.runMigrations)(prisma); + if (applied.length > 0) { + logger.log(`Applied migrations: ${applied.join(", ")}`); + } else { + logger.log("No pending migrations"); + } + if (process.env.RUN_SEED === "false") + return; + const seedScript = resolveSeedScript(); + if (!seedScript) { + logger.warn("No seed script found \u2014 skipping seed"); + return; + } + const seeded = await (0, migrate_1.runOnce)(prisma, "seed:initial", async () => { + const { stdout } = await execFileAsync(process.execPath, [seedScript]); + if (stdout.trim()) + logger.log(stdout.trim()); + }); + if (seeded) + logger.log("Seed data inserted"); + } finally { + await prisma.$disconnect(); + } + } + } +}); + // dist/prisma/prisma-exception.filter.js var require_prisma_exception_filter = __commonJS({ "dist/prisma/prisma-exception.filter.js"(exports2) { @@ -77626,8 +77810,10 @@ require_Reflect(); var core_1 = require_core3(); var common_1 = require_common(); var app_module_1 = require_app_module(); +var bootstrap_1 = require_bootstrap(); var prisma_exception_filter_1 = require_prisma_exception_filter(); async function bootstrap() { + await (0, bootstrap_1.prepareDatabase)(); const app = await core_1.NestFactory.create(app_module_1.AppModule); app.setGlobalPrefix("api"); app.useGlobalPipes(new common_1.ValidationPipe({ whitelist: true, transform: true })); diff --git a/apps/api/release/migrations/0001_init.sql b/apps/api/release/migrations/0001_init.sql new file mode 100644 index 0000000..c12ff02 --- /dev/null +++ b/apps/api/release/migrations/0001_init.sql @@ -0,0 +1,431 @@ +-- CreateEnum +CREATE TYPE "AdminRole" AS ENUM ('SUPER_ADMIN', 'SYSTEM_APPROVER', 'SALES_EXPERT', 'WORKSHOP_SUPERVISOR', 'ACCOUNTANT'); + +-- CreateEnum +CREATE TYPE "VerificationStep" AS ENUM ('UNVERIFIED', 'STEP_ONE_SUBMITTED', 'STEP_TWO_SUBMITTED', 'VERIFIED'); + +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('SUSPENDED', 'PLACED', 'ADMIN_APPROVED', 'SOURCING', 'PREPARING', 'DELIVERED_TO_AGENT', 'DELIVERED_TO_CUSTOMER', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "PreOrderStatus" AS ENUM ('PLACED', 'ADMIN_APPROVED', 'WORKSHOP_APPROVED', 'READY_TO_SHIP', 'DELIVERED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "LedgerCurrency" AS ENUM ('GOLD_GRAM', 'RIAL'); + +-- CreateEnum +CREATE TYPE "LedgerDirection" AS ENUM ('DEBIT', 'CREDIT'); + +-- CreateEnum +CREATE TYPE "LedgerSourceType" AS ENUM ('ORDER_STOCK', 'ORDER_PRE_ORDER', 'REPAYMENT_MELTED_GOLD', 'REPAYMENT_TRANSFER', 'REPAYMENT_CHEQUE', 'REPAYMENT_CASH', 'WALLET_BONUS', 'ADJUSTMENT'); + +-- CreateTable +CREATE TABLE "City" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "province" TEXT, + + CONSTRAINT "City_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AdminUser" ( + "id" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "mobile" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "role" "AdminRole" NOT NULL, + "branchId" TEXT, + "wagePercent" DECIMAL(6,3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Customer" ( + "id" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "mobile" TEXT NOT NULL, + "nationalCode" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "cityId" TEXT, + "referredByAgentId" TEXT, + "goldCreditLimitGrams" DECIMAL(14,4) NOT NULL DEFAULT 0, + "goldDebtGrams" DECIMAL(14,4) NOT NULL DEFAULT 0, + "fiatDebtRial" DECIMAL(20,2) NOT NULL DEFAULT 0, + "verificationStep" "VerificationStep" NOT NULL DEFAULT 'UNVERIFIED', + "nationalIdImageUrl" TEXT, + "businessLicenseUrl" TEXT, + "shopImageUrl" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CustomerAddress" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "cityId" TEXT, + "addressLine" TEXT NOT NULL, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "CustomerAddress_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Branch" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "cityId" TEXT, + + CONSTRAINT "Branch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Workshop" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Workshop_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Category" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "parentId" TEXT, + + CONSTRAINT "Category_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Brand" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + + CONSTRAINT "Brand_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Collection" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Collection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CollectionProduct" ( + "collectionId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + + CONSTRAINT "CollectionProduct_pkey" PRIMARY KEY ("collectionId","productId") +); + +-- CreateTable +CREATE TABLE "Product" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "categoryId" TEXT, + "brandId" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductImage" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductVariant" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "sku" TEXT, + "rashWeightGrams" DECIMAL(14,4) NOT NULL, + "defaultWagePercent" DECIMAL(6,3) NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Order" ( + "id" TEXT NOT NULL, + "orderNumber" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "branchId" TEXT, + "status" "OrderStatus" NOT NULL DEFAULT 'PLACED', + "totalGoldWeightGrams" DECIMAL(14,4) NOT NULL, + "wageWeightGrams" DECIMAL(14,4) NOT NULL, + "flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0, + "isSuspendedOverCreditLimit" BOOLEAN NOT NULL DEFAULT false, + "deliveredAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Order_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderItem" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "productVariantId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "rashWeightGramsSnapshot" DECIMAL(14,4) NOT NULL, + "wagePercentSnapshot" DECIMAL(6,3) NOT NULL, + "discountPercentSnapshot" DECIMAL(6,3) NOT NULL DEFAULT 0, + "computedWeightGrams" DECIMAL(14,4) NOT NULL, + "flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0, + + CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderStatusEvent" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "fromStatus" "OrderStatus", + "toStatus" "OrderStatus" NOT NULL, + "note" TEXT, + "actorAdminUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OrderStatusEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PreOrder" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "cityId" TEXT, + "workshopId" TEXT, + "status" "PreOrderStatus" NOT NULL DEFAULT 'PLACED', + "designImageUrl" TEXT, + "colorName" TEXT, + "sizeSpec" TEXT, + "stoneColor" TEXT, + "minaColor" TEXT, + "estimatedWeightGrams" DECIMAL(14,4), + "estimatedPriceRial" DECIMAL(20,2), + "description" TEXT, + "convertedOrderId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PreOrder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PreOrderStatusEvent" ( + "id" TEXT NOT NULL, + "preOrderId" TEXT NOT NULL, + "changes" JSONB NOT NULL, + "actorAdminUserId" TEXT, + "ipAddress" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PreOrderStatusEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LedgerEntry" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "currency" "LedgerCurrency" NOT NULL, + "direction" "LedgerDirection" NOT NULL, + "sourceType" "LedgerSourceType" NOT NULL, + "amount" DECIMAL(20,4) NOT NULL, + "karatPerMille" INTEGER, + "pricePerGramRialAtEntry" DECIMAL(20,2), + "orderId" TEXT, + "confirmed" BOOLEAN NOT NULL DEFAULT false, + "confirmedAt" TIMESTAMP(3), + "postedByAdminUserId" TEXT, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LedgerEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GoldPriceQuote" ( + "id" TEXT NOT NULL, + "karatPerMille" INTEGER NOT NULL, + "pricePerGramRial" DECIMAL(20,2) NOT NULL, + "source" TEXT NOT NULL, + "quotedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GoldPriceQuote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletBonus" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "orderId" TEXT, + "goldGrams" DECIMAL(14,4) NOT NULL, + "grantedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "WalletBonus_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminUser_mobile_key" ON "AdminUser"("mobile"); + +-- CreateIndex +CREATE INDEX "AdminUser_role_idx" ON "AdminUser"("role"); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_mobile_key" ON "Customer"("mobile"); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_nationalCode_key" ON "Customer"("nationalCode"); + +-- CreateIndex +CREATE INDEX "Customer_referredByAgentId_idx" ON "Customer"("referredByAgentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProductVariant_sku_key" ON "ProductVariant"("sku"); + +-- CreateIndex +CREATE UNIQUE INDEX "Order_orderNumber_key" ON "Order"("orderNumber"); + +-- CreateIndex +CREATE INDEX "Order_customerId_idx" ON "Order"("customerId"); + +-- CreateIndex +CREATE INDEX "Order_status_idx" ON "Order"("status"); + +-- CreateIndex +CREATE INDEX "OrderStatusEvent_orderId_idx" ON "OrderStatusEvent"("orderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PreOrder_convertedOrderId_key" ON "PreOrder"("convertedOrderId"); + +-- CreateIndex +CREATE INDEX "PreOrder_customerId_idx" ON "PreOrder"("customerId"); + +-- CreateIndex +CREATE INDEX "PreOrderStatusEvent_preOrderId_idx" ON "PreOrderStatusEvent"("preOrderId"); + +-- CreateIndex +CREATE INDEX "LedgerEntry_customerId_currency_idx" ON "LedgerEntry"("customerId", "currency"); + +-- CreateIndex +CREATE INDEX "LedgerEntry_orderId_idx" ON "LedgerEntry"("orderId"); + +-- CreateIndex +CREATE INDEX "GoldPriceQuote_karatPerMille_quotedAt_idx" ON "GoldPriceQuote"("karatPerMille", "quotedAt"); + +-- CreateIndex +CREATE INDEX "WalletBonus_customerId_isActive_idx" ON "WalletBonus"("customerId", "isActive"); + +-- AddForeignKey +ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Customer" ADD CONSTRAINT "Customer_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Customer" ADD CONSTRAINT "Customer_referredByAgentId_fkey" FOREIGN KEY ("referredByAgentId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Branch" ADD CONSTRAINT "Branch_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CollectionProduct" ADD CONSTRAINT "CollectionProduct_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CollectionProduct" ADD CONSTRAINT "CollectionProduct_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productVariantId_fkey" FOREIGN KEY ("productVariantId") REFERENCES "ProductVariant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_actorAdminUserId_fkey" FOREIGN KEY ("actorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_convertedOrderId_fkey" FOREIGN KEY ("convertedOrderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_preOrderId_fkey" FOREIGN KEY ("preOrderId") REFERENCES "PreOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_actorAdminUserId_fkey" FOREIGN KEY ("actorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_postedByAdminUserId_fkey" FOREIGN KEY ("postedByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletBonus" ADD CONSTRAINT "WalletBonus_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/apps/api/release/migrations/20260910113720_first/migration.sql b/apps/api/release/migrations/20260910113720_first/migration.sql new file mode 100644 index 0000000..fa63cf4 --- /dev/null +++ b/apps/api/release/migrations/20260910113720_first/migration.sql @@ -0,0 +1,430 @@ +-- CreateEnum +CREATE TYPE "AdminRole" AS ENUM ('SUPER_ADMIN', 'SYSTEM_APPROVER', 'SALES_EXPERT', 'WORKSHOP_SUPERVISOR', 'ACCOUNTANT'); + +-- CreateEnum +CREATE TYPE "VerificationStep" AS ENUM ('UNVERIFIED', 'STEP_ONE_SUBMITTED', 'STEP_TWO_SUBMITTED', 'VERIFIED'); + +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('SUSPENDED', 'PLACED', 'ADMIN_APPROVED', 'SOURCING', 'PREPARING', 'DELIVERED_TO_AGENT', 'DELIVERED_TO_CUSTOMER', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "PreOrderStatus" AS ENUM ('PLACED', 'ADMIN_APPROVED', 'WORKSHOP_APPROVED', 'READY_TO_SHIP', 'DELIVERED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "LedgerCurrency" AS ENUM ('GOLD_GRAM', 'RIAL'); + +-- CreateEnum +CREATE TYPE "LedgerDirection" AS ENUM ('DEBIT', 'CREDIT'); + +-- CreateEnum +CREATE TYPE "LedgerSourceType" AS ENUM ('ORDER_STOCK', 'ORDER_PRE_ORDER', 'REPAYMENT_MELTED_GOLD', 'REPAYMENT_TRANSFER', 'REPAYMENT_CHEQUE', 'REPAYMENT_CASH', 'WALLET_BONUS', 'ADJUSTMENT'); + +-- CreateTable +CREATE TABLE "City" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "province" TEXT, + + CONSTRAINT "City_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AdminUser" ( + "id" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "mobile" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "role" "AdminRole" NOT NULL, + "branchId" TEXT, + "wagePercent" DECIMAL(6,3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Customer" ( + "id" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "mobile" TEXT NOT NULL, + "nationalCode" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "cityId" TEXT, + "referredByAgentId" TEXT, + "goldCreditLimitGrams" DECIMAL(14,4) NOT NULL DEFAULT 0, + "goldDebtGrams" DECIMAL(14,4) NOT NULL DEFAULT 0, + "fiatDebtRial" DECIMAL(20,2) NOT NULL DEFAULT 0, + "verificationStep" "VerificationStep" NOT NULL DEFAULT 'UNVERIFIED', + "nationalIdImageUrl" TEXT, + "businessLicenseUrl" TEXT, + "shopImageUrl" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CustomerAddress" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "cityId" TEXT, + "addressLine" TEXT NOT NULL, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "CustomerAddress_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Branch" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "cityId" TEXT, + + CONSTRAINT "Branch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Workshop" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Workshop_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Category" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "parentId" TEXT, + + CONSTRAINT "Category_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Brand" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + + CONSTRAINT "Brand_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Collection" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Collection_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CollectionProduct" ( + "collectionId" TEXT NOT NULL, + "productId" TEXT NOT NULL, + + CONSTRAINT "CollectionProduct_pkey" PRIMARY KEY ("collectionId","productId") +); + +-- CreateTable +CREATE TABLE "Product" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "categoryId" TEXT, + "brandId" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductImage" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProductVariant" ( + "id" TEXT NOT NULL, + "productId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "sku" TEXT, + "rashWeightGrams" DECIMAL(14,4) NOT NULL, + "defaultWagePercent" DECIMAL(6,3) NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "ProductVariant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Order" ( + "id" TEXT NOT NULL, + "orderNumber" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "branchId" TEXT, + "status" "OrderStatus" NOT NULL DEFAULT 'PLACED', + "totalGoldWeightGrams" DECIMAL(14,4) NOT NULL, + "wageWeightGrams" DECIMAL(14,4) NOT NULL, + "flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0, + "isSuspendedOverCreditLimit" BOOLEAN NOT NULL DEFAULT false, + "deliveredAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Order_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderItem" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "productVariantId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "rashWeightGramsSnapshot" DECIMAL(14,4) NOT NULL, + "wagePercentSnapshot" DECIMAL(6,3) NOT NULL, + "discountPercentSnapshot" DECIMAL(6,3) NOT NULL DEFAULT 0, + "computedWeightGrams" DECIMAL(14,4) NOT NULL, + "flatRialCharge" DECIMAL(20,2) NOT NULL DEFAULT 0, + + CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OrderStatusEvent" ( + "id" TEXT NOT NULL, + "orderId" TEXT NOT NULL, + "fromStatus" "OrderStatus", + "toStatus" "OrderStatus" NOT NULL, + "note" TEXT, + "actorAdminUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OrderStatusEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PreOrder" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "cityId" TEXT, + "workshopId" TEXT, + "status" "PreOrderStatus" NOT NULL DEFAULT 'PLACED', + "designImageUrl" TEXT, + "colorName" TEXT, + "sizeSpec" TEXT, + "stoneColor" TEXT, + "minaColor" TEXT, + "estimatedWeightGrams" DECIMAL(14,4), + "estimatedPriceRial" DECIMAL(20,2), + "description" TEXT, + "convertedOrderId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PreOrder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PreOrderStatusEvent" ( + "id" TEXT NOT NULL, + "preOrderId" TEXT NOT NULL, + "changes" JSONB NOT NULL, + "actorAdminUserId" TEXT, + "ipAddress" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PreOrderStatusEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "LedgerEntry" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "currency" "LedgerCurrency" NOT NULL, + "direction" "LedgerDirection" NOT NULL, + "sourceType" "LedgerSourceType" NOT NULL, + "amount" DECIMAL(20,4) NOT NULL, + "karatPerMille" INTEGER, + "pricePerGramRialAtEntry" DECIMAL(20,2), + "orderId" TEXT, + "confirmed" BOOLEAN NOT NULL DEFAULT false, + "confirmedAt" TIMESTAMP(3), + "postedByAdminUserId" TEXT, + "note" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LedgerEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GoldPriceQuote" ( + "id" TEXT NOT NULL, + "karatPerMille" INTEGER NOT NULL, + "pricePerGramRial" DECIMAL(20,2) NOT NULL, + "source" TEXT NOT NULL, + "quotedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GoldPriceQuote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletBonus" ( + "id" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "orderId" TEXT, + "goldGrams" DECIMAL(14,4) NOT NULL, + "grantedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "WalletBonus_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminUser_mobile_key" ON "AdminUser"("mobile"); + +-- CreateIndex +CREATE INDEX "AdminUser_role_idx" ON "AdminUser"("role"); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_mobile_key" ON "Customer"("mobile"); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_nationalCode_key" ON "Customer"("nationalCode"); + +-- CreateIndex +CREATE INDEX "Customer_referredByAgentId_idx" ON "Customer"("referredByAgentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProductVariant_sku_key" ON "ProductVariant"("sku"); + +-- CreateIndex +CREATE UNIQUE INDEX "Order_orderNumber_key" ON "Order"("orderNumber"); + +-- CreateIndex +CREATE INDEX "Order_customerId_idx" ON "Order"("customerId"); + +-- CreateIndex +CREATE INDEX "Order_status_idx" ON "Order"("status"); + +-- CreateIndex +CREATE INDEX "OrderStatusEvent_orderId_idx" ON "OrderStatusEvent"("orderId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PreOrder_convertedOrderId_key" ON "PreOrder"("convertedOrderId"); + +-- CreateIndex +CREATE INDEX "PreOrder_customerId_idx" ON "PreOrder"("customerId"); + +-- CreateIndex +CREATE INDEX "PreOrderStatusEvent_preOrderId_idx" ON "PreOrderStatusEvent"("preOrderId"); + +-- CreateIndex +CREATE INDEX "LedgerEntry_customerId_currency_idx" ON "LedgerEntry"("customerId", "currency"); + +-- CreateIndex +CREATE INDEX "LedgerEntry_orderId_idx" ON "LedgerEntry"("orderId"); + +-- CreateIndex +CREATE INDEX "GoldPriceQuote_karatPerMille_quotedAt_idx" ON "GoldPriceQuote"("karatPerMille", "quotedAt"); + +-- CreateIndex +CREATE INDEX "WalletBonus_customerId_isActive_idx" ON "WalletBonus"("customerId", "isActive"); + +-- AddForeignKey +ALTER TABLE "AdminUser" ADD CONSTRAINT "AdminUser_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Customer" ADD CONSTRAINT "Customer_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Customer" ADD CONSTRAINT "Customer_referredByAgentId_fkey" FOREIGN KEY ("referredByAgentId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerAddress" ADD CONSTRAINT "CustomerAddress_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Branch" ADD CONSTRAINT "Branch_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Category" ADD CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CollectionProduct" ADD CONSTRAINT "CollectionProduct_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CollectionProduct" ADD CONSTRAINT "CollectionProduct_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductImage" ADD CONSTRAINT "ProductImage_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProductVariant" ADD CONSTRAINT "ProductVariant_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Order" ADD CONSTRAINT "Order_branchId_fkey" FOREIGN KEY ("branchId") REFERENCES "Branch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productVariantId_fkey" FOREIGN KEY ("productVariantId") REFERENCES "ProductVariant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OrderStatusEvent" ADD CONSTRAINT "OrderStatusEvent_actorAdminUserId_fkey" FOREIGN KEY ("actorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrder" ADD CONSTRAINT "PreOrder_convertedOrderId_fkey" FOREIGN KEY ("convertedOrderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_preOrderId_fkey" FOREIGN KEY ("preOrderId") REFERENCES "PreOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PreOrderStatusEvent" ADD CONSTRAINT "PreOrderStatusEvent_actorAdminUserId_fkey" FOREIGN KEY ("actorAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_postedByAdminUserId_fkey" FOREIGN KEY ("postedByAdminUserId") REFERENCES "AdminUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletBonus" ADD CONSTRAINT "WalletBonus_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/release/migrations/migration_lock.toml b/apps/api/release/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/apps/api/release/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/api/release/seed.js b/apps/api/release/seed.js new file mode 100644 index 0000000..9bd9f26 --- /dev/null +++ b/apps/api/release/seed.js @@ -0,0 +1,402 @@ +"use strict"; + +// prisma/seed.ts +var import_client = require("@prisma/client"); +var prisma = new import_client.PrismaClient(); +var PLACEHOLDER_PASSWORD_HASH = "placeholder-hash-not-for-production"; +var PLACEHOLDER_PRODUCT_IMAGE = "/img/no-items.png"; +async function main() { + const [tehran, mashhad, isfahan, tabriz] = await Promise.all([ + prisma.city.upsert({ where: { id: "city-tehran" }, update: {}, create: { id: "city-tehran", name: "\u062A\u0647\u0631\u0627\u0646", province: "\u062A\u0647\u0631\u0627\u0646" } }), + prisma.city.upsert({ where: { id: "city-mashhad" }, update: {}, create: { id: "city-mashhad", name: "\u0645\u0634\u0647\u062F", province: "\u062E\u0631\u0627\u0633\u0627\u0646 \u0631\u0636\u0648\u06CC" } }), + prisma.city.upsert({ where: { id: "city-isfahan" }, update: {}, create: { id: "city-isfahan", name: "\u0627\u0635\u0641\u0647\u0627\u0646", province: "\u0627\u0635\u0641\u0647\u0627\u0646" } }), + prisma.city.upsert({ where: { id: "city-tabriz" }, update: {}, create: { id: "city-tabriz", name: "\u062A\u0628\u0631\u06CC\u0632", province: "\u0622\u0630\u0631\u0628\u0627\u06CC\u062C\u0627\u0646 \u0634\u0631\u0642\u06CC" } }) + ]); + const branchTehran = await prisma.branch.upsert({ + where: { id: "branch-tehran-bazaar" }, + update: {}, + create: { id: "branch-tehran-bazaar", name: "\u0634\u0639\u0628\u0647 \u0628\u0627\u0632\u0627\u0631 \u0628\u0632\u0631\u06AF \u062A\u0647\u0631\u0627\u0646", cityId: tehran.id } + }); + const branchMashhad = await prisma.branch.upsert({ + where: { id: "branch-mashhad-main" }, + update: {}, + create: { id: "branch-mashhad-main", name: "\u0634\u0639\u0628\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0645\u0634\u0647\u062F", cityId: mashhad.id } + }); + await prisma.workshop.upsert({ + where: { id: "workshop-central" }, + update: {}, + create: { id: "workshop-central", name: "\u06A9\u0627\u0631\u06AF\u0627\u0647 \u0645\u0631\u06A9\u0632\u06CC \u0633\u0627\u062E\u062A \u0637\u0644\u0627" } + }); + await prisma.adminUser.upsert({ + where: { mobile: "09120000001" }, + update: {}, + create: { + mobile: "09120000001", + fullName: "\u0645\u062F\u06CC\u0631 \u0627\u0631\u0634\u062F \u0633\u06CC\u0633\u062A\u0645", + passwordHash: PLACEHOLDER_PASSWORD_HASH, + role: import_client.AdminRole.SUPER_ADMIN, + branchId: branchTehran.id + } + }); + const agentReza = await prisma.adminUser.upsert({ + where: { mobile: "09121234567" }, + update: {}, + create: { + mobile: "09121234567", + fullName: "\u0631\u0636\u0627 \u0627\u062D\u0645\u062F\u06CC", + passwordHash: PLACEHOLDER_PASSWORD_HASH, + role: import_client.AdminRole.SALES_EXPERT, + branchId: branchTehran.id, + wagePercent: 1.5 + } + }); + const agentSara = await prisma.adminUser.upsert({ + where: { mobile: "09129876543" }, + update: {}, + create: { + mobile: "09129876543", + fullName: "\u0633\u0627\u0631\u0627 \u06A9\u0631\u06CC\u0645\u06CC", + passwordHash: PLACEHOLDER_PASSWORD_HASH, + role: import_client.AdminRole.SALES_EXPERT, + branchId: branchMashhad.id, + wagePercent: 1.2 + } + }); + await prisma.adminUser.upsert({ + where: { mobile: "09123334444" }, + update: {}, + create: { + mobile: "09123334444", + fullName: "\u0647\u0648\u0645\u0646 \u06CC\u0632\u062F\u0627\u0646\u06CC", + passwordHash: PLACEHOLDER_PASSWORD_HASH, + role: import_client.AdminRole.WORKSHOP_SUPERVISOR + } + }); + await prisma.adminUser.upsert({ + where: { mobile: "09125556666" }, + update: {}, + create: { + mobile: "09125556666", + fullName: "\u0641\u0631\u0632\u0627\u0646\u0647 \u0645\u062D\u0645\u0648\u062F\u06CC", + passwordHash: PLACEHOLDER_PASSWORD_HASH, + role: import_client.AdminRole.ACCOUNTANT, + branchId: branchTehran.id + } + }); + const customerData = [ + { + id: "customer-1", + fullName: "\u0639\u0644\u06CC \u0645\u062D\u0645\u062F\u06CC", + mobile: "09151112233", + nationalCode: "0012345678", + cityId: tehran.id, + referredByAgentId: agentReza.id, + goldCreditLimitGrams: "200", + verificationStep: import_client.VerificationStep.VERIFIED + }, + { + id: "customer-2", + fullName: "\u0645\u0631\u06CC\u0645 \u062D\u0633\u06CC\u0646\u06CC", + mobile: "09151112234", + nationalCode: "0012345679", + cityId: tehran.id, + referredByAgentId: agentReza.id, + goldCreditLimitGrams: "100", + verificationStep: import_client.VerificationStep.VERIFIED + }, + { + id: "customer-3", + fullName: "\u062C\u0648\u0627\u062F \u0646\u0648\u0631\u0648\u0632\u06CC", + mobile: "09151112235", + nationalCode: "0012345680", + cityId: mashhad.id, + referredByAgentId: agentSara.id, + goldCreditLimitGrams: "50", + verificationStep: import_client.VerificationStep.STEP_TWO_SUBMITTED + }, + { + id: "customer-4", + fullName: "\u0646\u0631\u06AF\u0633 \u0627\u0628\u0631\u0627\u0647\u06CC\u0645\u06CC", + mobile: "09151112236", + nationalCode: "0012345681", + cityId: isfahan.id, + referredByAgentId: null, + goldCreditLimitGrams: "30", + verificationStep: import_client.VerificationStep.UNVERIFIED + }, + { + id: "customer-5", + fullName: "\u06A9\u0627\u0645\u0631\u0627\u0646 \u0635\u0627\u062F\u0642\u06CC", + mobile: "09151112237", + nationalCode: "0012345682", + cityId: tehran.id, + referredByAgentId: agentReza.id, + goldCreditLimitGrams: "500", + verificationStep: import_client.VerificationStep.VERIFIED + }, + { + id: "customer-6", + fullName: "\u0632\u0647\u0631\u0627 \u0642\u0627\u0633\u0645\u06CC", + mobile: "09151112238", + nationalCode: "0012345683", + cityId: tabriz.id, + referredByAgentId: agentSara.id, + goldCreditLimitGrams: "80", + verificationStep: import_client.VerificationStep.STEP_ONE_SUBMITTED + } + ]; + const customers = []; + for (const c of customerData) { + customers.push( + await prisma.customer.upsert({ + where: { id: c.id }, + update: {}, + create: { + id: c.id, + fullName: c.fullName, + mobile: c.mobile, + nationalCode: c.nationalCode, + passwordHash: PLACEHOLDER_PASSWORD_HASH, + cityId: c.cityId, + referredByAgentId: c.referredByAgentId, + goldCreditLimitGrams: c.goldCreditLimitGrams, + verificationStep: c.verificationStep + } + }) + ); + } + const [customer1, customer2, , , customer5] = customers; + const [ringsCategory, braceletsCategory, necklacesCategory, earringsCategory, coinsCategory] = await Promise.all([ + prisma.category.upsert({ where: { id: "cat-rings" }, update: {}, create: { id: "cat-rings", title: "\u0627\u0646\u06AF\u0634\u062A\u0631" } }), + prisma.category.upsert({ where: { id: "cat-bracelets" }, update: {}, create: { id: "cat-bracelets", title: "\u062F\u0633\u062A\u0628\u0646\u062F" } }), + prisma.category.upsert({ where: { id: "cat-necklaces" }, update: {}, create: { id: "cat-necklaces", title: "\u06AF\u0631\u062F\u0646\u0628\u0646\u062F" } }), + prisma.category.upsert({ where: { id: "cat-earrings" }, update: {}, create: { id: "cat-earrings", title: "\u06AF\u0648\u0634\u0648\u0627\u0631\u0647" } }), + prisma.category.upsert({ where: { id: "cat-coins" }, update: {}, create: { id: "cat-coins", title: "\u0633\u06A9\u0647 \u0648 \u0634\u0645\u0634" } }) + ]); + const brandHenza = await prisma.brand.upsert({ + where: { id: "brand-henza" }, + update: {}, + create: { id: "brand-henza", title: "\u0647\u0646\u0632\u0627 \u0637\u0644\u0627" } + }); + const brandArtisan = await prisma.brand.upsert({ + where: { id: "brand-artisan" }, + update: {}, + create: { id: "brand-artisan", title: "\u0645\u062C\u0645\u0648\u0639\u0647 \u0635\u0646\u0639\u062A\u06AF\u0631" } + }); + const productDefs = [ + { + id: "product-classic-ring", + title: "\u0627\u0646\u06AF\u0634\u062A\u0631 \u062A\u06A9 \u0646\u06AF\u06CC\u0646 \u06A9\u0644\u0627\u0633\u06CC\u06A9", + description: "\u0627\u0646\u06AF\u0634\u062A\u0631 \u0637\u0644\u0627\u06CC \u06F1\u06F8 \u0639\u06CC\u0627\u0631 \u0628\u0627 \u0637\u0631\u0627\u062D\u06CC \u0633\u0627\u062F\u0647 \u0648 \u0645\u06CC\u0646\u06CC\u0645\u0627\u0644.", + categoryId: ringsCategory.id, + brandId: brandHenza.id, + variants: [ + { id: "variant-ring-size-6", title: "\u0633\u0627\u06CC\u0632 \u06F6", sku: "RING-CLASSIC-6", rashWeightGrams: "2.850", defaultWagePercent: "9.0" }, + { id: "variant-ring-size-7", title: "\u0633\u0627\u06CC\u0632 \u06F7", sku: "RING-CLASSIC-7", rashWeightGrams: "3.100", defaultWagePercent: "9.0" } + ] + }, + { + id: "product-woven-bracelet", + title: "\u062F\u0633\u062A\u0628\u0646\u062F \u0628\u0627\u0641\u062A \u0632\u0646\u062C\u06CC\u0631\u06CC", + description: "\u062F\u0633\u062A\u0628\u0646\u062F \u0628\u0627\u0641\u062A\u200C\u062F\u0627\u0631 \u062F\u0633\u062A\u200C\u0633\u0627\u0632 \u0627\u0632 \u0637\u0644\u0627\u06CC \u06F1\u06F8 \u0639\u06CC\u0627\u0631.", + categoryId: braceletsCategory.id, + brandId: brandArtisan.id, + variants: [ + { id: "variant-bracelet-standard", title: "\u0627\u0633\u062A\u0627\u0646\u062F\u0627\u0631\u062F \u06F1\u06F8 \u0633\u0627\u0646\u062A\u06CC", sku: "BRC-WOVEN-18", rashWeightGrams: "14.200", defaultWagePercent: "11.5" } + ] + }, + { + id: "product-pendant-necklace", + title: "\u06AF\u0631\u062F\u0646\u0628\u0646\u062F \u0622\u0648\u06CC\u0632 \u0627\u0634\u06A9\u06CC", + description: "\u06AF\u0631\u062F\u0646\u0628\u0646\u062F \u0638\u0631\u06CC\u0641 \u0628\u0627 \u0622\u0648\u06CC\u0632 \u0627\u0634\u06A9\u06CC \u0648 \u0632\u0646\u062C\u06CC\u0631 \u0646\u0627\u0632\u06A9.", + categoryId: necklacesCategory.id, + brandId: brandHenza.id, + variants: [ + { id: "variant-necklace-40cm", title: "\u0632\u0646\u062C\u06CC\u0631 \u06F4\u06F0 \u0633\u0627\u0646\u062A\u06CC", sku: "NCK-TEARDROP-40", rashWeightGrams: "5.600", defaultWagePercent: "10.0" }, + { id: "variant-necklace-45cm", title: "\u0632\u0646\u062C\u06CC\u0631 \u06F4\u06F5 \u0633\u0627\u0646\u062A\u06CC", sku: "NCK-TEARDROP-45", rashWeightGrams: "6.100", defaultWagePercent: "10.0" } + ] + }, + { + id: "product-hoop-earrings", + title: "\u06AF\u0648\u0634\u0648\u0627\u0631\u0647 \u062D\u0644\u0642\u0647\u200C\u0627\u06CC \u06A9\u0644\u0627\u0633\u06CC\u06A9", + description: "\u06AF\u0648\u0634\u0648\u0627\u0631\u0647 \u062D\u0644\u0642\u0647\u200C\u0627\u06CC \u0637\u0644\u0627\u06CC \u06F1\u06F8 \u0639\u06CC\u0627\u0631 \u0645\u0646\u0627\u0633\u0628 \u0627\u0633\u062A\u0641\u0627\u062F\u0647 \u0631\u0648\u0632\u0645\u0631\u0647.", + categoryId: earringsCategory.id, + brandId: brandArtisan.id, + variants: [ + { id: "variant-hoop-small", title: "\u06A9\u0648\u0686\u06A9", sku: "ERG-HOOP-S", rashWeightGrams: "1.900", defaultWagePercent: "8.5" }, + { id: "variant-hoop-large", title: "\u0628\u0632\u0631\u06AF", sku: "ERG-HOOP-L", rashWeightGrams: "3.400", defaultWagePercent: "8.5" } + ] + }, + { + id: "product-full-coin", + title: "\u0633\u06A9\u0647 \u062A\u0645\u0627\u0645 \u0628\u0647\u0627\u0631 \u0622\u0632\u0627\u062F\u06CC", + description: "\u0633\u06A9\u0647 \u0637\u0644\u0627\u06CC \u062A\u0645\u0627\u0645 \u0628\u0647\u0627\u0631 \u0622\u0632\u0627\u062F\u06CC\u060C \u0639\u06CC\u0627\u0631 \u0627\u0633\u062A\u0627\u0646\u062F\u0627\u0631\u062F.", + categoryId: coinsCategory.id, + brandId: brandHenza.id, + variants: [ + { id: "variant-coin-full", title: "\u062A\u0645\u0627\u0645 \u0633\u06A9\u0647", sku: "COIN-FULL", rashWeightGrams: "8.133", defaultWagePercent: "2.0" }, + { id: "variant-coin-half", title: "\u0646\u06CC\u0645 \u0633\u06A9\u0647", sku: "COIN-HALF", rashWeightGrams: "4.066", defaultWagePercent: "2.5" } + ] + } + ]; + const createdVariantIds = {}; + let imageCount = 0; + for (const p of productDefs) { + const product = await prisma.product.upsert({ + where: { id: p.id }, + update: {}, + create: { + id: p.id, + title: p.title, + description: p.description, + categoryId: p.categoryId, + brandId: p.brandId + } + }); + for (const v of p.variants) { + await prisma.productVariant.upsert({ + where: { id: v.id }, + update: {}, + create: { + id: v.id, + productId: product.id, + title: v.title, + sku: v.sku, + rashWeightGrams: v.rashWeightGrams, + defaultWagePercent: v.defaultWagePercent + } + }); + createdVariantIds[v.id] = v.id; + } + await prisma.productImage.upsert({ + where: { id: `${p.id}-img-1` }, + update: { url: PLACEHOLDER_PRODUCT_IMAGE }, + create: { + id: `${p.id}-img-1`, + productId: product.id, + url: PLACEHOLDER_PRODUCT_IMAGE, + sortOrder: 0 + } + }); + imageCount += 1; + } + const featured = await prisma.collection.upsert({ + where: { id: "collection-featured" }, + update: {}, + create: { id: "collection-featured", title: "\u067E\u0631\u0641\u0631\u0648\u0634\u200C\u0647\u0627\u06CC \u0627\u06CC\u0646 \u0647\u0641\u062A\u0647", description: "\u0645\u0646\u062A\u062E\u0628\u06CC \u0627\u0632 \u0645\u062D\u0628\u0648\u0628\u200C\u062A\u0631\u06CC\u0646 \u0645\u062D\u0635\u0648\u0644\u0627\u062A \u0641\u0631\u0648\u0634\u06AF\u0627\u0647." } + }); + await prisma.collectionProduct.upsert({ + where: { collectionId_productId: { collectionId: featured.id, productId: "product-classic-ring" } }, + update: {}, + create: { collectionId: featured.id, productId: "product-classic-ring" } + }); + await prisma.collectionProduct.upsert({ + where: { collectionId_productId: { collectionId: featured.id, productId: "product-pendant-necklace" } }, + update: {}, + create: { collectionId: featured.id, productId: "product-pendant-necklace" } + }); + await prisma.collectionProduct.upsert({ + where: { collectionId_productId: { collectionId: featured.id, productId: "product-full-coin" } }, + update: {}, + create: { collectionId: featured.id, productId: "product-full-coin" } + }); + await prisma.goldPriceQuote.create({ + data: { karatPerMille: 750, pricePerGramRial: "61500000", source: "seed-illustrative" } + }); + const existingDemoOrder = await prisma.order.findUnique({ where: { orderNumber: "ORD-SEED-0001" } }); + if (!existingDemoOrder) { + const rashWeight = "3.100"; + const wagePercent = 9; + const discountPercent = 0; + const computedWeight = (parseFloat(rashWeight) * (1 + (wagePercent - discountPercent) / 100)).toFixed(4); + const quantity = 1; + const flatRialChargePerUnit = "500000"; + const order = await prisma.order.create({ + data: { + orderNumber: "ORD-SEED-0001", + customerId: customer1.id, + branchId: branchTehran.id, + status: "DELIVERED_TO_CUSTOMER", + totalGoldWeightGrams: computedWeight, + wageWeightGrams: (parseFloat(computedWeight) - parseFloat(rashWeight)).toFixed(4), + flatRialCharge: flatRialChargePerUnit, + deliveredAt: /* @__PURE__ */ new Date(), + items: { + create: [ + { + productVariantId: "variant-ring-size-7", + quantity, + rashWeightGramsSnapshot: rashWeight, + wagePercentSnapshot: wagePercent.toString(), + discountPercentSnapshot: discountPercent.toString(), + computedWeightGrams: computedWeight, + flatRialCharge: flatRialChargePerUnit + } + ] + }, + statusEvents: { + create: [{ toStatus: "PLACED" }, { toStatus: "ADMIN_APPROVED" }, { toStatus: "DELIVERED_TO_CUSTOMER" }] + } + } + }); + await prisma.ledgerEntry.create({ + data: { + customerId: customer1.id, + currency: "GOLD_GRAM", + direction: "DEBIT", + sourceType: "ORDER_STOCK", + amount: computedWeight, + orderId: order.id, + confirmed: true, + confirmedAt: /* @__PURE__ */ new Date(), + note: "\u0628\u062F\u0647\u06CC \u0648\u0632\u0646\u06CC \u0637\u0644\u0627 \u0647\u0646\u06AF\u0627\u0645 \u062A\u062D\u0648\u06CC\u0644 (\u062F\u0627\u062F\u0647 \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC)" + } + }); + await prisma.ledgerEntry.create({ + data: { + customerId: customer1.id, + currency: "RIAL", + direction: "DEBIT", + sourceType: "ORDER_STOCK", + amount: flatRialChargePerUnit, + orderId: order.id, + confirmed: true, + confirmedAt: /* @__PURE__ */ new Date(), + note: "\u0647\u0632\u06CC\u0646\u0647 \u062B\u0627\u0628\u062A \u0631\u06CC\u0627\u0644\u06CC \u0647\u0646\u06AF\u0627\u0645 \u062A\u062D\u0648\u06CC\u0644 (\u062F\u0627\u062F\u0647 \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC)" + } + }); + await prisma.customer.update({ + where: { id: customer1.id }, + data: { goldDebtGrams: { increment: computedWeight }, fiatDebtRial: { increment: flatRialChargePerUnit } } + }); + await prisma.ledgerEntry.create({ + data: { + customerId: customer1.id, + currency: "GOLD_GRAM", + direction: "CREDIT", + sourceType: "REPAYMENT_MELTED_GOLD", + amount: "1.5000", + karatPerMille: 750, + confirmed: false, + note: "\u062A\u0633\u0648\u06CC\u0647 \u0628\u0627 \u0637\u0644\u0627\u06CC \u0622\u0628\u200C\u0634\u062F\u0647\u060C \u062F\u0631 \u0627\u0646\u062A\u0638\u0627\u0631 \u062A\u0627\u06CC\u06CC\u062F (\u062F\u0627\u062F\u0647 \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC)" + } + }); + } + await prisma.customer.update({ where: { id: customer2.id }, data: { goldDebtGrams: "8.2500", fiatDebtRial: "2000000" } }); + await prisma.customer.update({ where: { id: customer5.id }, data: { goldDebtGrams: "0", fiatDebtRial: "0" } }); + console.log("Seed complete:", { + cities: 4, + branches: 2, + adminUsers: 5, + customers: customers.length, + products: productDefs.length, + variants: Object.keys(createdVariantIds).length, + productImages: imageCount + }); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}).finally(async () => { + await prisma.$disconnect(); +}); diff --git a/apps/api/scripts/bundle-release.cjs b/apps/api/scripts/bundle-release.cjs index 2c6f560..7f1db1d 100644 --- a/apps/api/scripts/bundle-release.cjs +++ b/apps/api/scripts/bundle-release.cjs @@ -83,8 +83,21 @@ async function main() { await esbuild.build(config); copyPrismaIntoRelease(); + // SQL migrations are read at runtime by src/prisma/migrate.ts + cpSync(path.join(root, 'prisma', 'migrations'), path.join(releaseDir, 'migrations'), { + recursive: true, + }); + + // Seeding runs as a separate process on a fresh database (src/prisma/bootstrap.ts) + await esbuild.build({ + ...config, + entryPoints: [path.join(root, 'prisma', 'seed.ts')], + outfile: path.join(releaseDir, 'seed.js'), + }); + console.log('نوشت:', config.outfile); console.log('Prisma client در release/vendor کپی شد.'); + console.log('migrations و seed.js در release کپی شدند.'); } main().catch((err) => { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 9aaa04e..c79281d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,9 +2,12 @@ import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; +import { prepareDatabase } from './prisma/bootstrap'; import { PrismaExceptionFilter } from './prisma/prisma-exception.filter'; async function bootstrap() { + await prepareDatabase(); + const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); diff --git a/apps/api/src/prisma/bootstrap.ts b/apps/api/src/prisma/bootstrap.ts new file mode 100644 index 0000000..42c6c94 --- /dev/null +++ b/apps/api/src/prisma/bootstrap.ts @@ -0,0 +1,59 @@ +import { execFile } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { promisify } from 'util'; +import { Logger } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { runMigrations, runOnce } from './migrate'; + +const execFileAsync = promisify(execFile); + +function resolveSeedScript(): string | undefined { + const candidates = [ + process.env.SEED_SCRIPT, + join(process.cwd(), 'seed.js'), + join(__dirname, '..', '..', 'seed.js'), + ].filter((p): p is string => Boolean(p)); + + return candidates.find((file) => existsSync(file)); +} + +/** + * Runs pending SQL migrations before Nest boots, then seeds once. + * + * Seeding is gated on a marker row written only after the seed succeeds, so an + * interrupted seed is retried on the next boot (the seed itself upserts). + * Set RUN_MIGRATIONS=false or RUN_SEED=false to skip. + */ +export async function prepareDatabase(): Promise { + if (process.env.RUN_MIGRATIONS === 'false') return; + + const logger = new Logger('DatabaseBootstrap'); + const prisma = new PrismaClient(); + + try { + const { applied } = await runMigrations(prisma); + + if (applied.length > 0) { + logger.log(`Applied migrations: ${applied.join(', ')}`); + } else { + logger.log('No pending migrations'); + } + + if (process.env.RUN_SEED === 'false') return; + + const seedScript = resolveSeedScript(); + if (!seedScript) { + logger.warn('No seed script found — skipping seed'); + return; + } + + const seeded = await runOnce(prisma, 'seed:initial', async () => { + const { stdout } = await execFileAsync(process.execPath, [seedScript]); + if (stdout.trim()) logger.log(stdout.trim()); + }); + if (seeded) logger.log('Seed data inserted'); + } finally { + await prisma.$disconnect(); + } +} diff --git a/apps/api/src/prisma/migrate.ts b/apps/api/src/prisma/migrate.ts new file mode 100644 index 0000000..88ba531 --- /dev/null +++ b/apps/api/src/prisma/migrate.ts @@ -0,0 +1,178 @@ +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { PrismaClient } from '@prisma/client'; + +const TRACKING_TABLE = '_goldbank_migrations'; + +/** + * Applies the .sql files in prisma/migrations (copied to ./migrations in the + * Docker release) and records them so each one runs once. + * + * The deploy image is copy-only and has no Prisma CLI, so `migrate deploy` is + * not an option — this runs the same SQL through the client instead. + */ +export type MigrationResult = { + /** True when the tracking table was created by this run (empty database). */ + freshDatabase: boolean; + applied: string[]; +}; + +function resolveMigrationsDir(): string { + const candidates = [ + process.env.MIGRATIONS_DIR, + join(process.cwd(), 'migrations'), + join(__dirname, '..', '..', 'migrations'), + join(__dirname, '..', '..', 'prisma', 'migrations'), + join(process.cwd(), 'prisma', 'migrations'), + ].filter((p): p is string => Boolean(p)); + + const found = candidates.find((dir) => existsSync(dir)); + if (!found) { + throw new Error( + `پوشه migrations پیدا نشد. مسیرهای بررسی‌شده: ${candidates.join(', ')}`, + ); + } + return found; +} + +/** + * Postgres rejects multiple statements in one parameterised query, so each file + * is split first. Quoting rules matter: identifiers/strings may contain ';'. + */ +export function splitSqlStatements(sql: string): string[] { + const statements: string[] = []; + let current = ''; + let i = 0; + + while (i < sql.length) { + const char = sql[i]; + const rest = sql.slice(i); + + if (rest.startsWith('--')) { + const lineEnd = sql.indexOf('\n', i); + i = lineEnd === -1 ? sql.length : lineEnd + 1; + continue; + } + if (rest.startsWith('/*')) { + const commentEnd = sql.indexOf('*/', i + 2); + i = commentEnd === -1 ? sql.length : commentEnd + 2; + continue; + } + + if (char === "'" || char === '"') { + const quote = char; + current += char; + i += 1; + while (i < sql.length) { + current += sql[i]; + if (sql[i] === quote) { + // Doubled quote is an escaped quote, not the end of the literal. + if (sql[i + 1] === quote) { + current += sql[i + 1]; + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + continue; + } + + const dollarTag = /^\$[A-Za-z_]*\$/.exec(rest); + if (dollarTag) { + const tag = dollarTag[0]; + const closing = sql.indexOf(tag, i + tag.length); + const end = closing === -1 ? sql.length : closing + tag.length; + current += sql.slice(i, end); + i = end; + continue; + } + + if (char === ';') { + if (current.trim()) statements.push(current.trim()); + current = ''; + i += 1; + continue; + } + + current += char; + i += 1; + } + + if (current.trim()) statements.push(current.trim()); + return statements; +} + +export async function runMigrations(prisma: PrismaClient): Promise { + const dir = resolveMigrationsDir(); + const files = readdirSync(dir) + .filter((name) => name.endsWith('.sql')) + .sort(); + + const trackingExisted = await prisma.$queryRawUnsafe<{ exists: boolean }[]>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = $1 + ) AS "exists"`, + TRACKING_TABLE, + ); + const freshDatabase = !trackingExisted[0]?.exists; + + await prisma.$executeRawUnsafe( + `CREATE TABLE IF NOT EXISTS "${TRACKING_TABLE}" ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + ); + + const alreadyApplied = await prisma.$queryRawUnsafe<{ name: string }[]>( + `SELECT name FROM "${TRACKING_TABLE}"`, + ); + const appliedNames = new Set(alreadyApplied.map((row) => row.name)); + + const applied: string[] = []; + for (const file of files) { + if (appliedNames.has(file)) continue; + + const statements = splitSqlStatements(readFileSync(join(dir, file), 'utf8')); + await prisma.$transaction( + async (tx) => { + for (const statement of statements) { + await tx.$executeRawUnsafe(statement); + } + await tx.$executeRawUnsafe( + `INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)`, + file, + ); + }, + // The baseline migration is hundreds of DDL statements; the 5s default is + // not enough, especially against a remote database. + { maxWait: 60_000, timeout: 600_000 }, + ); + applied.push(file); + } + + return { freshDatabase, applied }; +} + +/** Records a one-off task (e.g. seeding) in the same table so it never repeats. */ +export async function runOnce( + prisma: PrismaClient, + name: string, + task: () => Promise, +): Promise { + const rows = await prisma.$queryRawUnsafe<{ name: string }[]>( + `SELECT name FROM "${TRACKING_TABLE}" WHERE name = $1`, + name, + ); + if (rows.length > 0) return false; + + await task(); + await prisma.$executeRawUnsafe( + `INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)`, + name, + ); + return true; +}