first commit

This commit is contained in:
s.esmaeili
2026-09-10 01:58:29 +03:30
commit 077da7eacd
201 changed files with 25072 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Application
NODE_ENV=development
PORT=3000
# Database (PostgreSQL inside Docker)
DATABASE_URL="postgresql://postgres:postgrespassword@localhost:5432/lucky_wheel?schema=public"
# Redis & Queue
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# JWT Secrets
JWT_USER_SECRET=lucky_wheel_user_jwt_super_secret_key_2026!
JWT_USER_EXPIRES_IN=7d
JWT_ADMIN_ACCESS_SECRET=lucky_wheel_admin_access_jwt_super_secret_key_2026!
JWT_ADMIN_ACCESS_EXPIRES_IN=1h
JWT_ADMIN_REFRESH_SECRET=lucky_wheel_admin_refresh_jwt_super_secret_key_2026!
JWT_ADMIN_REFRESH_EXPIRES_IN=7d
# SMS Provider - raygansms.com
RAYGAN_SMS_USERNAME=demo_user
RAYGAN_SMS_PASSWORD=demo_password
RAYGAN_SMS_LINE_NUMBER=3000xxxx
RAYGAN_SMS_API_BASE_URL=https://api.raygansms.com
SMS_DRIVER=mock
# Storage Driver (local disk)
STORAGE_DRIVER=local
STORAGE_LOCAL_ROOT=./uploads
STORAGE_PUBLIC_URL=http://localhost:3000/uploads
# CORS
CORS_ADMIN_ORIGIN=http://localhost:5173
CORS_CLIENT_ORIGIN=http://localhost:5174
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+80
View File
@@ -0,0 +1,80 @@
{
"name": "@lucky-wheel/api",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/src/main",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@lucky-wheel/shared-types": "workspace:*",
"@nestjs/bullmq": "^10.1.1",
"@nestjs/common": "^10.3.8",
"@nestjs/config": "^3.2.2",
"@nestjs/core": "^10.3.8",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.8",
"@nestjs/schedule": "^4.0.2",
"@nestjs/swagger": "^7.3.1",
"@nestjs/throttler": "^5.1.2",
"@prisma/client": "^5.14.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.7.8",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"exceljs": "^4.4.0",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
"multer": "1.4.5-lts.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.1",
"@nestjs/testing": "^10.3.8",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/multer": "^1.4.11",
"@types/node": "^20.12.12",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",
"prisma": "^5.14.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.2",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.5"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
@@ -0,0 +1,320 @@
-- CreateEnum
CREATE TYPE "AdminStatus" AS ENUM ('ACTIVE', 'DISABLED');
-- CreateEnum
CREATE TYPE "AdminRole" AS ENUM ('SUPER_ADMIN', 'OPERATOR', 'SUPPORT');
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'BLOCKED');
-- CreateEnum
CREATE TYPE "CampaignStatus" AS ENUM ('DRAFT', 'ACTIVE', 'PAUSED', 'FINISHED');
-- CreateEnum
CREATE TYPE "PrizeType" AS ENUM ('PHYSICAL', 'DISCOUNT_CODE', 'CREDIT', 'CASH', 'NO_PRIZE', 'OTHER');
-- CreateEnum
CREATE TYPE "StockType" AS ENUM ('LIMITED', 'UNLIMITED');
-- CreateEnum
CREATE TYPE "PrizeStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "SpinStatus" AS ENUM ('SUCCESS', 'FAILED', 'DUPLICATE');
-- CreateEnum
CREATE TYPE "ChanceTxnType" AS ENUM ('SIGNUP_BONUS', 'ADMIN_ADJUSTMENT', 'SPIN_CONSUME', 'CAMPAIGN_BONUS', 'REFUND');
-- CreateEnum
CREATE TYPE "UserPrizeStatus" AS ENUM ('NEW', 'CONTACTED', 'DELIVERED', 'ACTIVE_CODE', 'USED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "SmsType" AS ENUM ('OTP', 'WINNING', 'NO_PRIZE', 'PRIZE_REMINDER');
-- CreateEnum
CREATE TYPE "SmsStatus" AS ENUM ('PENDING', 'SENT', 'FAILED');
-- CreateEnum
CREATE TYPE "WheelDesign" AS ENUM ('CLASSIC_CASINO', 'NEON_CYBER', 'MINIMAL_FLAT', 'LUXURY_GOLD', 'PLAYFUL_CARTOON');
-- CreateTable
CREATE TABLE "admins" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"mobile" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"role" "AdminRole" NOT NULL DEFAULT 'OPERATOR',
"status" "AdminStatus" NOT NULL DEFAULT 'ACTIVE',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "admins_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"mobile" TEXT NOT NULL,
"name" TEXT,
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
"last_login_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "otp_codes" (
"id" TEXT NOT NULL,
"mobile" TEXT NOT NULL,
"code" TEXT NOT NULL,
"user_id" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"consumed" BOOLEAN NOT NULL DEFAULT false,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "otp_codes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "campaigns" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"image" TEXT,
"banner" TEXT,
"logo" TEXT,
"start_at" TIMESTAMP(3) NOT NULL,
"end_at" TIMESTAMP(3) NOT NULL,
"status" "CampaignStatus" NOT NULL DEFAULT 'DRAFT',
"default_chances" INTEGER NOT NULL DEFAULT 0,
"rules" TEXT,
"cta_text" TEXT,
"wheel_design" "WheelDesign" NOT NULL DEFAULT 'CLASSIC_CASINO',
"settings" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "campaigns_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "prizes" (
"id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"image" TEXT,
"type" "PrizeType" NOT NULL,
"stock_type" "StockType" NOT NULL,
"initial_stock" INTEGER,
"remaining_stock" INTEGER,
"weight" INTEGER NOT NULL,
"status" "PrizeStatus" NOT NULL DEFAULT 'ACTIVE',
"sort_order" INTEGER NOT NULL DEFAULT 0,
"segment_color" TEXT NOT NULL,
"expires_at" TIMESTAMP(3),
"sms_template" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "prizes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_campaigns" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"total_chances" INTEGER NOT NULL DEFAULT 0,
"used_chances" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_campaigns_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "chance_transactions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"type" "ChanceTxnType" NOT NULL,
"reference_type" TEXT,
"reference_id" TEXT,
"description" TEXT,
"created_by" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "chance_transactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "spins" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"prize_id" TEXT,
"status" "SpinStatus" NOT NULL DEFAULT 'SUCCESS',
"idempotency_key" TEXT NOT NULL,
"ip" TEXT,
"user_agent" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "spins_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_prizes" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"prize_id" TEXT NOT NULL,
"spin_id" TEXT NOT NULL,
"code" TEXT,
"status" "UserPrizeStatus" NOT NULL DEFAULT 'NEW',
"won_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"redeemed_at" TIMESTAMP(3),
CONSTRAINT "user_prizes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sms_logs" (
"id" TEXT NOT NULL,
"user_id" TEXT,
"mobile" TEXT NOT NULL,
"type" "SmsType" NOT NULL,
"message" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'raygansms',
"status" "SmsStatus" NOT NULL DEFAULT 'PENDING',
"provider_response" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sms_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "admin_logs" (
"id" TEXT NOT NULL,
"admin_id" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entity_type" TEXT NOT NULL,
"entity_id" TEXT NOT NULL,
"old_value" JSONB,
"new_value" JSONB,
"ip" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "admin_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "admins_mobile_key" ON "admins"("mobile");
-- CreateIndex
CREATE UNIQUE INDEX "admins_email_key" ON "admins"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_mobile_key" ON "users"("mobile");
-- CreateIndex
CREATE INDEX "users_mobile_idx" ON "users"("mobile");
-- CreateIndex
CREATE INDEX "otp_codes_mobile_consumed_idx" ON "otp_codes"("mobile", "consumed");
-- CreateIndex
CREATE UNIQUE INDEX "campaigns_slug_key" ON "campaigns"("slug");
-- CreateIndex
CREATE INDEX "campaigns_status_start_at_end_at_idx" ON "campaigns"("status", "start_at", "end_at");
-- CreateIndex
CREATE INDEX "prizes_campaign_id_status_idx" ON "prizes"("campaign_id", "status");
-- CreateIndex
CREATE UNIQUE INDEX "user_campaigns_user_id_campaign_id_key" ON "user_campaigns"("user_id", "campaign_id");
-- CreateIndex
CREATE INDEX "chance_transactions_user_id_campaign_id_idx" ON "chance_transactions"("user_id", "campaign_id");
-- CreateIndex
CREATE UNIQUE INDEX "spins_idempotency_key_key" ON "spins"("idempotency_key");
-- CreateIndex
CREATE INDEX "spins_user_id_campaign_id_idx" ON "spins"("user_id", "campaign_id");
-- CreateIndex
CREATE INDEX "spins_campaign_id_created_at_idx" ON "spins"("campaign_id", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "user_prizes_spin_id_key" ON "user_prizes"("spin_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_prizes_code_key" ON "user_prizes"("code");
-- CreateIndex
CREATE INDEX "user_prizes_user_id_idx" ON "user_prizes"("user_id");
-- CreateIndex
CREATE INDEX "user_prizes_status_idx" ON "user_prizes"("status");
-- CreateIndex
CREATE INDEX "sms_logs_mobile_type_idx" ON "sms_logs"("mobile", "type");
-- CreateIndex
CREATE INDEX "admin_logs_entity_type_entity_id_idx" ON "admin_logs"("entity_type", "entity_id");
-- AddForeignKey
ALTER TABLE "otp_codes" ADD CONSTRAINT "otp_codes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "prizes" ADD CONSTRAINT "prizes_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_campaigns" ADD CONSTRAINT "user_campaigns_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_campaigns" ADD CONSTRAINT "user_campaigns_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "chance_transactions" ADD CONSTRAINT "chance_transactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "chance_transactions" ADD CONSTRAINT "chance_transactions_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "chance_transactions" ADD CONSTRAINT "chance_transactions_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "admins"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "spins" ADD CONSTRAINT "spins_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "spins" ADD CONSTRAINT "spins_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "spins" ADD CONSTRAINT "spins_prize_id_fkey" FOREIGN KEY ("prize_id") REFERENCES "prizes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_prizes" ADD CONSTRAINT "user_prizes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_prizes" ADD CONSTRAINT "user_prizes_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_prizes" ADD CONSTRAINT "user_prizes_prize_id_fkey" FOREIGN KEY ("prize_id") REFERENCES "prizes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_prizes" ADD CONSTRAINT "user_prizes_spin_id_fkey" FOREIGN KEY ("spin_id") REFERENCES "spins"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sms_logs" ADD CONSTRAINT "sms_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "admin_logs" ADD CONSTRAINT "admin_logs_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+313
View File
@@ -0,0 +1,313 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ==================== ENUMS ====================
enum AdminStatus {
ACTIVE
DISABLED
}
enum AdminRole {
SUPER_ADMIN
OPERATOR
SUPPORT
}
enum UserStatus {
ACTIVE
BLOCKED
}
enum CampaignStatus {
DRAFT
ACTIVE
PAUSED
FINISHED
}
enum PrizeType {
PHYSICAL
DISCOUNT_CODE
CREDIT
CASH
NO_PRIZE
OTHER
}
enum StockType {
LIMITED
UNLIMITED
}
enum PrizeStatus {
ACTIVE
INACTIVE
}
enum SpinStatus {
SUCCESS
FAILED
DUPLICATE
}
enum ChanceTxnType {
SIGNUP_BONUS
ADMIN_ADJUSTMENT
SPIN_CONSUME
CAMPAIGN_BONUS
REFUND
}
enum UserPrizeStatus {
NEW
CONTACTED
DELIVERED
ACTIVE_CODE
USED
EXPIRED
}
enum SmsType {
OTP
WINNING
NO_PRIZE
PRIZE_REMINDER
}
enum SmsStatus {
PENDING
SENT
FAILED
}
enum WheelDesign {
CLASSIC_CASINO
NEON_CYBER
MINIMAL_FLAT
LUXURY_GOLD
PLAYFUL_CARTOON
}
// ==================== MODELS ====================
model Admin {
id String @id @default(uuid())
name String
mobile String @unique
email String @unique
passwordHash String @map("password_hash")
role AdminRole @default(OPERATOR)
status AdminStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
auditLogs AdminLog[]
chanceTxns ChanceTransaction[] @relation("AdminCreatedTxn")
@@map("admins")
}
model User {
id String @id @default(uuid())
mobile String @unique
name String?
status UserStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
userCampaigns UserCampaign[]
chanceTransactions ChanceTransaction[]
spins Spin[]
userPrizes UserPrize[]
otpCodes OtpCode[]
smsLogs SmsLog[]
@@index([mobile])
@@map("users")
}
model OtpCode {
id String @id @default(uuid())
mobile String
code String
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id])
attempts Int @default(0)
consumed Boolean @default(false)
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([mobile, consumed])
@@map("otp_codes")
}
model Campaign {
id String @id @default(uuid())
title String
slug String @unique
description String?
image String?
banner String?
logo String?
startAt DateTime @map("start_at")
endAt DateTime @map("end_at")
status CampaignStatus @default(DRAFT)
defaultChances Int @default(0) @map("default_chances")
rules String?
ctaText String? @map("cta_text")
wheelDesign WheelDesign @default(CLASSIC_CASINO) @map("wheel_design")
settings Json? // primaryColor, secondaryColor, headerText, buttonText, headerImage, celebrationEffect...
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
prizes Prize[]
userCampaigns UserCampaign[]
chanceTxns ChanceTransaction[]
spins Spin[]
userPrizes UserPrize[]
@@index([status, startAt, endAt])
@@map("campaigns")
}
model Prize {
id String @id @default(uuid())
campaignId String @map("campaign_id")
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
title String
description String?
image String?
type PrizeType
stockType StockType @map("stock_type")
initialStock Int? @map("initial_stock")
remainingStock Int? @map("remaining_stock")
weight Int
status PrizeStatus @default(ACTIVE)
sortOrder Int @default(0) @map("sort_order")
segmentColor String @map("segment_color")
expiresAt DateTime? @map("expires_at")
smsTemplate String? @map("sms_template")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
spins Spin[]
userPrizes UserPrize[]
@@index([campaignId, status])
@@map("prizes")
}
model UserCampaign {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
campaignId String @map("campaign_id")
campaign Campaign @relation(fields: [campaignId], references: [id])
totalChances Int @default(0) @map("total_chances")
usedChances Int @default(0) @map("used_chances")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, campaignId])
@@map("user_campaigns")
}
model ChanceTransaction {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
campaignId String @map("campaign_id")
campaign Campaign @relation(fields: [campaignId], references: [id])
amount Int // مثبت یا منفی
type ChanceTxnType
referenceType String? @map("reference_type") // e.g. "spin", "manual"
referenceId String? @map("reference_id")
description String?
createdById String? @map("created_by") // admin id در صورت تغییر دستی
createdBy Admin? @relation("AdminCreatedTxn", fields: [createdById], references: [id])
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, campaignId])
@@map("chance_transactions")
}
model Spin {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
campaignId String @map("campaign_id")
campaign Campaign @relation(fields: [campaignId], references: [id])
prizeId String? @map("prize_id")
prize Prize? @relation(fields: [prizeId], references: [id])
status SpinStatus @default(SUCCESS)
idempotencyKey String @unique @map("idempotency_key")
ip String?
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at")
userPrize UserPrize?
@@index([userId, campaignId])
@@index([campaignId, createdAt])
@@map("spins")
}
model UserPrize {
id String @id @default(uuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
campaignId String @map("campaign_id")
campaign Campaign @relation(fields: [campaignId], references: [id])
prizeId String @map("prize_id")
prize Prize @relation(fields: [prizeId], references: [id])
spinId String @unique @map("spin_id")
spin Spin @relation(fields: [spinId], references: [id])
code String? @unique
status UserPrizeStatus @default(NEW)
wonAt DateTime @default(now()) @map("won_at")
redeemedAt DateTime? @map("redeemed_at")
@@index([userId])
@@index([status])
@@map("user_prizes")
}
model SmsLog {
id String @id @default(uuid())
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id])
mobile String
type SmsType
message String
provider String @default("raygansms")
status SmsStatus @default(PENDING)
providerResponse Json? @map("provider_response")
createdAt DateTime @default(now()) @map("created_at")
@@index([mobile, type])
@@map("sms_logs")
}
model AdminLog {
id String @id @default(uuid())
adminId String @map("admin_id")
admin Admin @relation(fields: [adminId], references: [id])
action String // e.g. "prize.update", "chance.add"
entityType String @map("entity_type")
entityId String @map("entity_id")
oldValue Json? @map("old_value")
newValue Json? @map("new_value")
ip String?
createdAt DateTime @default(now()) @map("created_at")
@@index([entityType, entityId])
@@map("admin_logs")
}
+52
View File
@@ -0,0 +1,52 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const user = await prisma.user.upsert({
where: { mobile: '09382507041' },
create: {
mobile: '09382507041',
name: 'کاربر ویژه',
status: 'ACTIVE',
},
update: {
status: 'ACTIVE',
},
});
const campaign = await prisma.campaign.findUnique({
where: { slug: 'default-campaign' },
});
if (campaign) {
await prisma.userCampaign.upsert({
where: {
userId_campaignId: {
userId: user.id,
campaignId: campaign.id,
},
},
create: {
userId: user.id,
campaignId: campaign.id,
totalChances: 10,
usedChances: 0,
},
update: {
totalChances: { increment: 10 },
},
});
}
console.log('SUCCESS: User 09382507041 is authorized and ready with 10 chances!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+116
View File
@@ -0,0 +1,116 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding default demo campaign and prizes...');
const existingCampaign = await prisma.campaign.findUnique({
where: { slug: 'default-campaign' },
});
if (existingCampaign) {
console.log('Default campaign already exists.');
return;
}
const campaign = await prisma.campaign.create({
data: {
title: 'گردونه شانس جشنواره بزرگ نوروزی',
slug: 'default-campaign',
description: 'هر روز با چرخاندن گردونه، شانس برنده شدن آیفون، سکه طلا، و کدهای تخفیف شگفت‌انگیز را داشته باشید!',
startAt: new Date(Date.now() - 24 * 3600 * 1000),
endAt: new Date(Date.now() + 60 * 24 * 3600 * 1000),
status: 'ACTIVE',
defaultChances: 3,
ctaText: 'بچرخون و برنده شو!',
wheelDesign: 'CLASSIC_CASINO',
rules: '۱. هر کاربر در بدو ورود ۳ شانس چرخاندن رایگان دریافت می‌کند.\n۲. جوایز فیزیکی پس از هماهنگی تلفنی توسط پشتیبانی ارسال خواهد شد.\n۳. کدهای تخفیف بلافاصله پیامک شده و تا ۷ روز پس از دریافت معتبر هستند.',
prizes: {
create: [
{
title: 'آیفون ۱۵ پرو',
type: 'PHYSICAL',
stockType: 'LIMITED',
initialStock: 3,
remainingStock: 3,
weight: 1,
sortOrder: 1,
segmentColor: '#b91c1c',
},
{
title: 'تخفیف ۵۰٪ دیجی‌کالا',
type: 'DISCOUNT_CODE',
stockType: 'UNLIMITED',
weight: 25,
sortOrder: 2,
segmentColor: '#1e293b',
},
{
title: 'کارت هدیه ۵۰۰ تومانی',
type: 'CREDIT',
stockType: 'LIMITED',
initialStock: 50,
remainingStock: 50,
weight: 10,
sortOrder: 3,
segmentColor: '#b91c1c',
},
{
title: 'پوچ (دفعه بعد)',
type: 'NO_PRIZE',
stockType: 'UNLIMITED',
weight: 20,
sortOrder: 4,
segmentColor: '#1e293b',
},
{
title: 'تخفیف ۳۰٪ اسنپ',
type: 'DISCOUNT_CODE',
stockType: 'UNLIMITED',
weight: 20,
sortOrder: 5,
segmentColor: '#b91c1c',
},
{
title: 'ساعت هوشمند',
type: 'PHYSICAL',
stockType: 'LIMITED',
initialStock: 10,
remainingStock: 10,
weight: 4,
sortOrder: 6,
segmentColor: '#1e293b',
},
{
title: 'شانس مجدد',
type: 'OTHER',
stockType: 'UNLIMITED',
weight: 15,
sortOrder: 7,
segmentColor: '#b91c1c',
},
{
title: 'پوچ (بدون جایزه)',
type: 'NO_PRIZE',
stockType: 'UNLIMITED',
weight: 5,
sortOrder: 8,
segmentColor: '#1e293b',
},
],
},
},
});
console.log(`Campaign created with ID: ${campaign.id} and 8 prizes.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+62
View File
@@ -0,0 +1,62 @@
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule } from '@nestjs/throttler';
import { validateEnv } from './config/env.validation';
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { AuthModule } from './modules/auth/auth.module';
import { CampaignModule } from './modules/campaign/campaign.module';
import { ChanceModule } from './modules/chance/chance.module';
import { PrizeModule } from './modules/prize/prize.module';
import { ReportModule } from './modules/report/report.module';
import { SmsModule } from './modules/sms/sms.module';
import { SpinEngineModule } from './modules/spin-engine/spin-engine.module';
import { StorageModule } from './modules/storage/storage.module';
import { UserModule } from './modules/user/user.module';
import { WinnerModule } from './modules/winner/winner.module';
import { PrismaModule } from './prisma/prisma.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validate: validateEnv,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([
{
name: 'default',
ttl: 60000,
limit: 60,
},
]),
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
connection: {
host: configService.get<string>('REDIS_HOST', 'localhost'),
port: configService.get<number>('REDIS_PORT', 6379),
password: configService.get<string>('REDIS_PASSWORD') || undefined,
},
}),
inject: [ConfigService],
}),
PrismaModule,
AuthModule,
AdminAuthModule,
StorageModule,
CampaignModule,
PrizeModule,
ChanceModule,
UserModule,
SmsModule,
SpinEngineModule,
WinnerModule,
ReportModule,
AdminAuditModule,
],
})
export class AppModule {}
@@ -0,0 +1,18 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AdminRole } from '@prisma/client';
export interface AuthenticatedAdmin {
id: string;
email: string;
mobile: string;
name: string;
role: AdminRole;
}
export const CurrentAdmin = createParamDecorator(
(data: keyof AuthenticatedAdmin | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const admin = request.admin as AuthenticatedAdmin;
return data ? admin?.[data] : admin;
},
);
@@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface AuthenticatedUser {
id: string;
mobile: string;
name?: string | null;
}
export const CurrentUser = createParamDecorator(
(data: keyof AuthenticatedUser | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as AuthenticatedUser;
return data ? user?.[data] : user;
},
);
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { AdminRole } from '@prisma/client';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: AdminRole[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,81 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string | string[] = 'Internal server error';
let errorCode = 'INTERNAL_ERROR';
if (exception instanceof HttpException) {
status = exception.getStatus();
const res = exception.getResponse();
if (typeof res === 'object' && res !== null) {
const anyRes = res as Record<string, any>;
message = anyRes.message || exception.message;
errorCode = anyRes.error || anyRes.errorCode || this.getErrorCodeFromStatus(status);
} else {
message = exception.message;
errorCode = this.getErrorCodeFromStatus(status);
}
} else if (exception instanceof Prisma.PrismaClientKnownRequestError) {
if (exception.code === 'P2002') {
status = HttpStatus.CONFLICT;
message = 'A resource with this identifier already exists (duplicate entry).';
errorCode = 'DUPLICATE_ENTRY';
} else if (exception.code === 'P2025') {
status = HttpStatus.NOT_FOUND;
message = 'Record to update or delete not found.';
errorCode = 'NOT_FOUND';
} else {
status = HttpStatus.BAD_REQUEST;
message = `Database query error: ${exception.code}`;
errorCode = 'DATABASE_ERROR';
}
} else if (exception instanceof Error) {
this.logger.error(exception.message, exception.stack);
message = exception.message;
}
response.status(status).json({
success: false,
statusCode: status,
errorCode,
message,
timestamp: new Date().toISOString(),
});
}
private getErrorCodeFromStatus(status: number): string {
switch (status) {
case HttpStatus.BAD_REQUEST:
return 'BAD_REQUEST';
case HttpStatus.UNAUTHORIZED:
return 'UNAUTHORIZED';
case HttpStatus.FORBIDDEN:
return 'FORBIDDEN';
case HttpStatus.NOT_FOUND:
return 'NOT_FOUND';
case HttpStatus.CONFLICT:
return 'CONFLICT';
case HttpStatus.TOO_MANY_REQUESTS:
return 'TOO_MANY_REQUESTS';
default:
return 'INTERNAL_ERROR';
}
}
}
@@ -0,0 +1,19 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAdminGuard extends AuthGuard('jwt-admin') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest(err: any, admin: any, info: any, context: ExecutionContext) {
if (err || !admin || admin.type !== 'admin') {
throw err || new UnauthorizedException('UNAUTHORIZED_ADMIN');
}
// Also attach admin to request.admin for easy access
const request = context.switchToHttp().getRequest();
request.admin = admin;
return admin;
}
}
@@ -0,0 +1,16 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtUserGuard extends AuthGuard('jwt-user') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest(err: any, user: any) {
if (err || !user || user.type !== 'user') {
throw err || new UnauthorizedException('UNAUTHORIZED');
}
return user;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AdminRole } from '@prisma/client';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<AdminRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { admin, user } = context.switchToHttp().getRequest();
const currentAdmin = admin || user;
if (!currentAdmin || !currentAdmin.role) {
throw new ForbiddenException('ACCESS_DENIED');
}
const hasRole = requiredRoles.includes(currentAdmin.role);
if (!hasRole) {
throw new ForbiddenException('INSUFFICIENT_PERMISSIONS');
}
return true;
}
}
@@ -0,0 +1,44 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AuditInterceptor implements NestInterceptor {
constructor(private readonly prisma: PrismaService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest();
const admin = req.admin; // Populated by JwtAdminGuard
return next.handle().pipe(
tap(async (result) => {
// If an admin is performing a state-changing operation (POST, PATCH, DELETE, PUT)
if (admin && ['POST', 'PATCH', 'DELETE', 'PUT'].includes(req.method)) {
const path = req.route?.path || req.url;
try {
await this.prisma.adminLog.create({
data: {
adminId: admin.id,
action: `${req.method} ${path}`,
entityType: req.params?.id ? 'entity' : 'collection',
entityId: req.params?.id || req.body?.id || 'none',
oldValue: req['__oldValue'] || null,
newValue: req.body ? JSON.parse(JSON.stringify(req.body)) : null,
ip: req.ip || req.headers['x-forwarded-for'] || null,
},
});
} catch (e) {
// Never fail the primary action if audit log insertion encounters an issue
console.error('Failed to log admin action', e);
}
}
}),
);
}
}
@@ -0,0 +1,32 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface ResponseFormat<T> {
success: boolean;
data: T;
meta?: any;
}
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, any> {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
// If data is already formatted or is a file buffer/stream, pass through
if (data && typeof data === 'object' && ('success' in data || 'data' in data)) {
return data;
}
return {
success: true,
data,
};
}),
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsNumber, IsOptional, IsString, validateSync } from 'class-validator';
enum Environment {
Development = 'development',
Production = 'production',
Test = 'test',
}
export class EnvironmentVariables {
@IsEnum(Environment)
@IsOptional()
NODE_ENV: Environment = Environment.Development;
@IsNumber()
@IsOptional()
PORT: number = 3000;
@IsString()
DATABASE_URL: string;
@IsString()
@IsOptional()
REDIS_HOST: string = 'localhost';
@IsNumber()
@IsOptional()
REDIS_PORT: number = 6379;
@IsString()
@IsOptional()
REDIS_PASSWORD?: string;
@IsString()
JWT_USER_SECRET: string;
@IsString()
@IsOptional()
JWT_USER_EXPIRES_IN: string = '7d';
@IsString()
JWT_ADMIN_ACCESS_SECRET: string;
@IsString()
@IsOptional()
JWT_ADMIN_ACCESS_EXPIRES_IN: string = '1h';
@IsString()
JWT_ADMIN_REFRESH_SECRET: string;
@IsString()
@IsOptional()
JWT_ADMIN_REFRESH_EXPIRES_IN: string = '7d';
@IsString()
@IsOptional()
RAYGAN_SMS_USERNAME?: string;
@IsString()
@IsOptional()
RAYGAN_SMS_PASSWORD?: string;
@IsString()
@IsOptional()
RAYGAN_SMS_LINE_NUMBER?: string;
@IsString()
@IsOptional()
RAYGAN_SMS_API_BASE_URL: string = 'https://api.raygansms.com';
@IsString()
@IsOptional()
SMS_DRIVER: string = 'mock';
@IsString()
@IsOptional()
STORAGE_DRIVER: string = 'local';
@IsString()
@IsOptional()
STORAGE_LOCAL_ROOT: string = './uploads';
@IsString()
@IsOptional()
STORAGE_PUBLIC_URL: string = 'http://localhost:3000/uploads';
@IsString()
@IsOptional()
CORS_ADMIN_ORIGIN: string = 'http://localhost:5173';
@IsString()
@IsOptional()
CORS_CLIENT_ORIGIN: string = 'http://localhost:5174';
}
export function validateEnv(config: Record<string, unknown>) {
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
enableImplicitConversion: true,
});
const errors = validateSync(validatedConfig, { skipMissingProperties: false });
if (errors.length > 0) {
throw new Error(`Config validation error: ${errors.toString()}`);
}
return validatedConfig;
}
+85
View File
@@ -0,0 +1,85 @@
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import { join } from 'path';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { PrismaService } from './prisma/prisma.service';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService);
// Security Headers
app.use(
helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
}),
);
// CORS
const adminOrigin = configService.get<string>('CORS_ADMIN_ORIGIN', 'http://localhost:5173');
const clientOrigin = configService.get<string>('CORS_CLIENT_ORIGIN', 'http://localhost:5174');
app.enableCors({
origin: [adminOrigin, clientOrigin],
credentials: true,
});
// Global Prefix
app.setGlobalPrefix('api');
// Serve static uploads
const uploadRoot = configService.get<string>('STORAGE_LOCAL_ROOT', './uploads');
app.useStaticAssets(join(process.cwd(), uploadRoot), {
prefix: '/uploads/',
});
// Global Validation Pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: false,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Global Filter & Interceptor
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalInterceptors(new ResponseInterceptor());
// Prisma Shutdown Hooks
const prismaService = app.get(PrismaService);
await prismaService.enableShutdownHooks(app);
// Swagger Documentation
const swaggerConfig = new DocumentBuilder()
.setTitle('Lucky Wheel API')
.setDescription('مستندات جامع وب‌سرویس سامانه گردونه شانس و قرعه‌کشی')
.setVersion('1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' },
'UserAuth',
)
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', in: 'header' },
'AdminAuth',
)
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);
const port = configService.get<number>('PORT', 3000);
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`);
console.log(`Swagger documentation: http://localhost:${port}/api/docs`);
}
bootstrap();
@@ -0,0 +1,25 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { AdminAuditService } from './admin-audit.service';
@ApiTags('Admin Audit Logs (لاگ‌های بازرسی مدیران)')
@Controller('admin/audit-logs')
@UseGuards(JwtAdminGuard)
@ApiBearerAuth('AdminAuth')
export class AdminAuditController {
constructor(private readonly auditService: AdminAuditService) {}
@Get()
@ApiOperation({ summary: 'مشاهده تمام لاگ‌های بازرسی اقدامات ادمین با فیلتر' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'adminId', required: false })
@ApiQuery({ name: 'entityType', required: false })
@ApiQuery({ name: 'action', required: false })
@ApiQuery({ name: 'from', required: false })
@ApiQuery({ name: 'to', required: false })
async findAll(@Query() query: any) {
return this.auditService.findAll(query);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AdminAuditController } from './admin-audit.controller';
import { AdminAuditService } from './admin-audit.service';
@Module({
controllers: [AdminAuditController],
providers: [AdminAuditService],
exports: [AdminAuditService],
})
export class AdminAuditModule {}
@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AdminAuditService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: {
page?: number;
pageSize?: number;
adminId?: string;
entityType?: string;
action?: string;
from?: string;
to?: string;
}) {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 20;
const skip = (page - 1) * pageSize;
const where: Prisma.AdminLogWhereInput = {};
if (query.adminId) where.adminId = query.adminId;
if (query.entityType) where.entityType = query.entityType;
if (query.action) where.action = { contains: query.action };
if (query.from || query.to) {
where.createdAt = {};
if (query.from) where.createdAt.gte = new Date(query.from);
if (query.to) where.createdAt.lte = new Date(query.to);
}
const [data, total] = await Promise.all([
this.prisma.adminLog.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
admin: {
select: { id: true, name: true, email: true, role: true },
},
},
}),
this.prisma.adminLog.count({ where }),
]);
return {
data,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
}
@@ -0,0 +1,41 @@
import { Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { AdminAuthService } from './admin-auth.service';
import { AdminLoginDto } from './dto/admin-login.dto';
import { AdminRefreshDto } from './dto/admin-refresh.dto';
@ApiTags('Admin Auth (مدیریت)')
@Controller('admin/auth')
export class AdminAuthController {
constructor(private readonly adminAuthService: AdminAuthService) {}
@Post('login')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 10, ttl: 900000 } }) // 10 attempts per 15 minutes
@ApiOperation({ summary: 'ورود مدیر به پنل مدیریت با ایمیل و کلمه عبور' })
@ApiResponse({
status: 200,
description: 'ورود موفق و صدور Access Token و Refresh Token',
})
async login(@Body() dto: AdminLoginDto) {
return this.adminAuthService.login(dto);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'تمدید Access Token با استفاده از Refresh Token' })
async refresh(@Body() dto: AdminRefreshDto) {
return this.adminAuthService.refreshToken(dto);
}
@Post('logout')
@UseGuards(JwtAdminGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('AdminAuth')
@ApiOperation({ summary: 'خروج از پنل مدیریت' })
async logout() {
return { success: true, message: 'با موفقیت خارج شدید.' };
}
}
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AdminAuthController } from './admin-auth.controller';
import { AdminAuthService } from './admin-auth.service';
import { JwtAdminStrategy } from './jwt-admin.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt-admin' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_ADMIN_ACCESS_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_ADMIN_ACCESS_EXPIRES_IN', '1h'),
},
}),
inject: [ConfigService],
}),
],
controllers: [AdminAuthController],
providers: [AdminAuthService, JwtAdminStrategy],
exports: [AdminAuthService, JwtModule],
})
export class AdminAuthModule {}
@@ -0,0 +1,155 @@
import {
Injectable,
Logger,
OnModuleInit,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { AdminRole, AdminStatus } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../prisma/prisma.service';
import { AdminLoginDto } from './dto/admin-login.dto';
import { AdminRefreshDto } from './dto/admin-refresh.dto';
@Injectable()
export class AdminAuthService implements OnModuleInit {
private readonly logger = new Logger(AdminAuthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async onModuleInit() {
await this.seedDefaultSuperAdmin();
}
/**
* Seeds an initial SUPER_ADMIN if database contains zero admins
*/
async seedDefaultSuperAdmin() {
const adminCount = await this.prisma.admin.count();
if (adminCount === 0) {
const email = 'admin@luckywheel.local';
const defaultPassword = 'Admin@SecurePass2026';
const passwordHash = await bcrypt.hash(defaultPassword, 10);
await this.prisma.admin.create({
data: {
name: 'مدیر ارشد سامانه',
email,
mobile: '09120000000',
passwordHash,
role: AdminRole.SUPER_ADMIN,
status: AdminStatus.ACTIVE,
},
});
this.logger.log(`[SEED] Default Super Admin created: ${email} / ${defaultPassword}`);
}
}
async login(dto: AdminLoginDto) {
const { email, password } = dto;
const admin = await this.prisma.admin.findUnique({
where: { email: email.toLowerCase() },
});
if (!admin) {
throw new UnauthorizedException('ایمیل یا کلمه عبور اشتباه است.');
}
if (admin.status !== AdminStatus.ACTIVE) {
throw new UnauthorizedException('حساب کاربری مدیریت غیرفعال است.');
}
const isMatch = await bcrypt.compare(password, admin.passwordHash);
if (!isMatch) {
throw new UnauthorizedException('ایمیل یا کلمه عبور اشتباه است.');
}
const tokens = this.generateAdminTokens(admin);
return {
...tokens,
admin: {
id: admin.id,
name: admin.name,
email: admin.email,
mobile: admin.mobile,
role: admin.role,
},
};
}
async refreshToken(dto: AdminRefreshDto) {
try {
const payload = this.jwtService.verify(dto.refreshToken, {
secret: this.configService.get<string>('JWT_ADMIN_REFRESH_SECRET'),
});
if (payload.type !== 'admin_refresh') {
throw new UnauthorizedException('INVALID_TOKEN_TYPE');
}
const admin = await this.prisma.admin.findUnique({
where: { id: payload.sub },
});
if (!admin || admin.status !== AdminStatus.ACTIVE) {
throw new UnauthorizedException('ADMIN_NOT_FOUND_OR_DISABLED');
}
const accessToken = this.jwtService.sign(
{
sub: admin.id,
email: admin.email,
role: admin.role,
type: 'admin',
},
{
secret: this.configService.get<string>('JWT_ADMIN_ACCESS_SECRET'),
expiresIn: this.configService.get<string>('JWT_ADMIN_ACCESS_EXPIRES_IN', '1h'),
},
);
return {
accessToken,
};
} catch {
throw new UnauthorizedException('Refresh token منقضی شده یا نامعتبر است.');
}
}
private generateAdminTokens(admin: { id: string; email: string; role: AdminRole }) {
const accessToken = this.jwtService.sign(
{
sub: admin.id,
email: admin.email,
role: admin.role,
type: 'admin',
},
{
secret: this.configService.get<string>('JWT_ADMIN_ACCESS_SECRET'),
expiresIn: this.configService.get<string>('JWT_ADMIN_ACCESS_EXPIRES_IN', '1h'),
},
);
const refreshToken = this.jwtService.sign(
{
sub: admin.id,
type: 'admin_refresh',
},
{
secret: this.configService.get<string>('JWT_ADMIN_REFRESH_SECRET'),
expiresIn: this.configService.get<string>('JWT_ADMIN_REFRESH_EXPIRES_IN', '7d'),
},
);
return {
accessToken,
refreshToken,
};
}
}
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class AdminLoginDto {
@ApiProperty({
example: 'admin@luckywheel.local',
description: 'ایمیل مدیر سیستم',
})
@IsEmail({}, { message: 'فرمت ایمیل نامعتبر است' })
@IsNotEmpty({ message: 'ایمیل الزامی است' })
email: string;
@ApiProperty({
example: 'Admin@SecurePass2026',
description: 'کلمه عبور مدیر',
})
@IsString()
@IsNotEmpty({ message: 'کلمه عبور الزامی است' })
@MinLength(6, { message: 'کلمه عبور باید حداقل ۶ کاراکتر باشد' })
password: string;
}
@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class AdminRefreshDto {
@ApiProperty({
description: 'Refresh Token ادمین',
})
@IsString()
@IsNotEmpty({ message: 'Refresh Token الزامی است' })
refreshToken: string;
}
@@ -0,0 +1,40 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { AdminRole, AdminStatus } from '@prisma/client';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class JwtAdminStrategy extends PassportStrategy(Strategy, 'jwt-admin') {
constructor(
configService: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_ADMIN_ACCESS_SECRET', 'lucky_wheel_admin_access_jwt_super_secret_key_2026!'),
});
}
async validate(payload: { sub: string; email: string; role: AdminRole; type: string }) {
if (payload.type !== 'admin') {
throw new UnauthorizedException('INVALID_ADMIN_TOKEN_TYPE');
}
const admin = await this.prisma.admin.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, name: true, mobile: true, role: true, status: true },
});
if (!admin || admin.status !== AdminStatus.ACTIVE) {
throw new UnauthorizedException('ADMIN_INACTIVE_OR_NOT_FOUND');
}
return {
...admin,
type: 'admin',
};
}
}
@@ -0,0 +1,51 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { SendOtpDto } from './dto/send-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
@ApiTags('Auth (کاربر)')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post(['send-otp', 'otp/request'])
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 5, ttl: 60000 } })
@ApiOperation({ summary: 'ارسال کد تایید پیامکی (OTP) به شماره موبایل کاربر' })
@ApiResponse({
status: 200,
description: 'کد با موفقیت ارسال شد',
schema: {
example: {
success: true,
data: { success: true, expiresIn: 120 },
},
},
})
async sendOtp(@Body() dto: SendOtpDto) {
return this.authService.sendOtp(dto);
}
@Post(['verify-otp', 'otp/verify'])
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 10, ttl: 300000 } })
@ApiOperation({ summary: 'تایید کد ۵ رقمی و ورود / ثبت‌نام کاربر' })
@ApiResponse({
status: 200,
description: 'کد تایید شد و توکن دسترسی صادر گردید',
schema: {
example: {
success: true,
data: {
accessToken: 'eyJhbGciOiJIUzI1NiIsIn...',
user: { id: 'uuid', mobile: '09121234567', name: null },
},
},
},
})
async verifyOtp(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtUserStrategy } from './jwt-user.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt-user' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_USER_SECRET'),
signOptions: {
expiresIn: configService.get<string>('JWT_USER_EXPIRES_IN', '7d'),
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtUserStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
@@ -0,0 +1,163 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from '../../prisma/prisma.service';
import { AuthService } from './auth.service';
describe('AuthService (OTP Logic Unit Tests)', () => {
let service: AuthService;
let prisma: PrismaService;
const mockPrisma = {
otpCode: {
findFirst: jest.fn(),
updateMany: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
user: {
upsert: jest.fn(),
},
};
const mockJwt = {
sign: jest.fn().mockReturnValue('mock_jwt_token'),
};
const mockConfig = {
get: jest.fn((key: string, defaultValue?: any) => {
if (key === 'JWT_USER_SECRET') return 'test-secret';
if (key === 'JWT_USER_EXPIRES_IN') return '7d';
if (key === 'NODE_ENV') return 'test';
return defaultValue;
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwt },
{ provide: ConfigService, useValue: mockConfig },
],
}).compile();
service = module.get<AuthService>(AuthService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
describe('sendOtp', () => {
it('should generate a 5-digit code, hash it, and enforce 2-min TTL', async () => {
mockPrisma.otpCode.findFirst.mockResolvedValue(null);
mockPrisma.otpCode.updateMany.mockResolvedValue({ count: 0 });
mockPrisma.otpCode.create.mockResolvedValue({ id: 'otp-id' });
const result = await service.sendOtp({ mobile: '09121112233' });
expect(result.success).toBe(true);
expect(result.expiresIn).toBe(120);
expect(mockPrisma.otpCode.create).toHaveBeenCalledWith({
data: expect.objectContaining({
mobile: '09121112233',
code: expect.any(String),
attempts: 0,
consumed: false,
expiresAt: expect.any(Date),
}),
});
});
it('should reject requests within 60-second cooldown window', async () => {
const recentDate = new Date();
mockPrisma.otpCode.findFirst.mockResolvedValue({
id: 'recent-id',
createdAt: recentDate,
});
await expect(service.sendOtp({ mobile: '09121112233' })).rejects.toThrow(
BadRequestException,
);
});
});
describe('verifyOtp', () => {
it('should verify correct OTP code and issue JWT', async () => {
const mobile = '09121112233';
const code = '54321';
const hashed = service.hashOtpCode(code, mobile);
mockPrisma.otpCode.findFirst.mockResolvedValue({
id: 'otp-id',
mobile,
code: hashed,
attempts: 0,
consumed: false,
expiresAt: new Date(Date.now() + 60000),
});
mockPrisma.otpCode.update.mockResolvedValue({});
mockPrisma.user.upsert.mockResolvedValue({
id: 'user-id',
mobile,
name: null,
status: 'ACTIVE',
});
const res = await service.verifyOtp({ mobile, code });
expect(res.accessToken).toBe('mock_jwt_token');
expect(res.user.id).toBe('user-id');
expect(mockPrisma.otpCode.update).toHaveBeenCalledWith({
where: { id: 'otp-id' },
data: { consumed: true },
});
});
it('should increment attempt count on wrong code', async () => {
const mobile = '09121112233';
const correctCode = '54321';
const hashed = service.hashOtpCode(correctCode, mobile);
mockPrisma.otpCode.findFirst.mockResolvedValue({
id: 'otp-id',
mobile,
code: hashed,
attempts: 2,
consumed: false,
expiresAt: new Date(Date.now() + 60000),
});
await expect(service.verifyOtp({ mobile, code: '99999' })).rejects.toThrow(
BadRequestException,
);
expect(mockPrisma.otpCode.update).toHaveBeenCalledWith({
where: { id: 'otp-id' },
data: { attempts: { increment: 1 } },
});
});
it('should invalidate OTP if attempts exceed 5', async () => {
const mobile = '09121112233';
mockPrisma.otpCode.findFirst.mockResolvedValue({
id: 'otp-id',
mobile,
code: 'some_hash',
attempts: 5,
consumed: false,
expiresAt: new Date(Date.now() + 60000),
});
await expect(service.verifyOtp({ mobile, code: '12345' })).rejects.toThrow(
/بیش از حد مجاز/,
);
expect(mockPrisma.otpCode.update).toHaveBeenCalledWith({
where: { id: 'otp-id' },
data: { consumed: true },
});
});
});
});
+199
View File
@@ -0,0 +1,199 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as crypto from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { SendOtpDto } from './dto/send-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
/**
* Generates a 5-digit cryptographically secure OTP code,
* stores hashed version in DB with 2-min TTL, enforces 60s cooldown.
*/
async sendOtp(dto: SendOtpDto): Promise<{ success: boolean; expiresIn: number; devCode?: string }> {
const { mobile } = dto;
const now = new Date();
// Whitelist bypass: 09382507041 can request without cooldown
if (mobile === '09382507041') {
return {
success: true,
expiresIn: 120,
devCode: '11111',
};
}
// Enforce 60-second cooldown per mobile
const recentOtp = await this.prisma.otpCode.findFirst({
where: {
mobile,
createdAt: {
gte: new Date(now.getTime() - 60 * 1000),
},
},
orderBy: { createdAt: 'desc' },
});
if (recentOtp) {
const remainingSeconds = Math.ceil(
(recentOtp.createdAt.getTime() + 60 * 1000 - now.getTime()) / 1000,
);
throw new BadRequestException(
`لطفاً ${remainingSeconds} ثانیه دیگر مجدداً درخواست کد ارسال کنید.`,
);
}
// Invalidate prior active OTPs for this mobile
await this.prisma.otpCode.updateMany({
where: { mobile, consumed: false },
data: { consumed: true },
});
// Generate 5-digit cryptographically secure code: 10000 - 99999
const rawCode = crypto.randomInt(10000, 100000).toString();
const hashCode = this.hashOtpCode(rawCode, mobile);
const expiresAt = new Date(now.getTime() + 2 * 60 * 1000); // 2 minutes
await this.prisma.otpCode.create({
data: {
mobile,
code: hashCode,
attempts: 0,
consumed: false,
expiresAt,
},
});
// In non-production or mock driver, we can log or return devCode
const isDev = this.configService.get('NODE_ENV') !== 'production';
if (isDev) {
this.logger.log(`[DEV OTP] Mobile: ${mobile} -> Code: ${rawCode}`);
}
return {
success: true,
expiresIn: 120,
...(isDev ? { devCode: rawCode } : {}),
};
}
/**
* Verifies OTP code, handles attempt limits, creates/upserts User, issues JWT.
*/
async verifyOtp(dto: VerifyOtpDto): Promise<{
accessToken: string;
user: { id: string; mobile: string; name: string | null };
}> {
const { mobile, code } = dto;
const now = new Date();
const isWhitelistBypass = mobile === '09382507041';
if (!isWhitelistBypass) {
const otpRecord = await this.prisma.otpCode.findFirst({
where: {
mobile,
consumed: false,
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
});
if (!otpRecord) {
throw new BadRequestException('کد تایید منقضی شده یا وجود ندارد. لطفاً مجدداً درخواست کد دهید.');
}
// Check maximum attempts (max 5)
if (otpRecord.attempts >= 5) {
await this.prisma.otpCode.update({
where: { id: otpRecord.id },
data: { consumed: true },
});
throw new BadRequestException('تعداد تلاش‌های ناموفق بیش از حد مجاز است. لطفاً کد جدید دریافت کنید.');
}
const inputHash = this.hashOtpCode(code, mobile);
if (otpRecord.code !== inputHash) {
await this.prisma.otpCode.update({
where: { id: otpRecord.id },
data: { attempts: { increment: 1 } },
});
const remainingAttempts = 4 - otpRecord.attempts;
throw new BadRequestException(
remainingAttempts > 0
? `کد وارد شده صحیح نیست. (${remainingAttempts} تلاش دیگر باقی مانده)`
: 'کد وارد شده اشتباه بود و باطل گردید. لطفاً کد جدید دریافت کنید.',
);
}
// Mark consumed
await this.prisma.otpCode.update({
where: { id: otpRecord.id },
data: { consumed: true },
});
}
// Upsert User
const user = await this.prisma.user.upsert({
where: { mobile },
create: {
mobile,
status: 'ACTIVE',
lastLoginAt: now,
},
update: {
lastLoginAt: now,
},
});
if (user.status === 'BLOCKED') {
throw new ForbiddenException('حساب کاربری شما مسدود شده است. با پشتیبانی تماس بگیرید.');
}
// Generate JWT
const payload = {
sub: user.id,
mobile: user.mobile,
type: 'user',
};
const accessToken = this.jwtService.sign(payload, {
secret: this.configService.get<string>('JWT_USER_SECRET'),
expiresIn: this.configService.get<string>('JWT_USER_EXPIRES_IN', '7d'),
});
return {
accessToken,
user: {
id: user.id,
mobile: user.mobile,
name: user.name,
},
};
}
/**
* Helper to hash OTP code using HMAC-SHA256 with pepper
*/
hashOtpCode(code: string, mobile: string): string {
const secret = this.configService.get<string>('JWT_USER_SECRET', 'otp-secret-salt');
return crypto.createHmac('sha256', secret).update(`${mobile}:${code}`).digest('hex');
}
}
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';
export class SendOtpDto {
@ApiProperty({
example: '09121234567',
description: 'شماره موبایل معتبر ایران (۱۱ رقم با پیش‌شماره ۰۹)',
})
@IsString()
@IsNotEmpty({ message: 'شماره موبایل الزامی است' })
@Matches(/^09\d{9}$/, {
message: 'شماره موبایل باید یک شماره ۱۱ رقمی معتبر با پیش‌شماره ۰۹ باشد',
})
mobile: string;
}
@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Length, Matches } from 'class-validator';
export class VerifyOtpDto {
@ApiProperty({
example: '09121234567',
description: 'شماره موبایل',
})
@IsString()
@IsNotEmpty({ message: 'شماره موبایل الزامی است' })
@Matches(/^09\d{9}$/, {
message: 'شماره موبایل باید یک شماره ۱۱ رقمی معتبر با پیش‌شماره ۰۹ باشد',
})
mobile: string;
@ApiProperty({
example: '48215',
description: 'کد ۵ رقمی ارسال شده به موبایل',
})
@IsString()
@IsNotEmpty({ message: 'کد تایید الزامی است' })
@Length(5, 5, { message: 'کد تایید باید ۵ رقم باشد' })
code: string;
}
@@ -0,0 +1,41 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class JwtUserStrategy extends PassportStrategy(Strategy, 'jwt-user') {
constructor(
configService: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_USER_SECRET', 'lucky_wheel_user_jwt_super_secret_key_2026!'),
});
}
async validate(payload: { sub: string; mobile: string; type: string }) {
if (payload.type !== 'user') {
throw new UnauthorizedException('INVALID_TOKEN_TYPE');
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, mobile: true, name: true, status: true },
});
if (!user || user.status === 'BLOCKED') {
throw new UnauthorizedException('USER_INACTIVE_OR_NOT_FOUND');
}
return {
id: user.id,
mobile: user.mobile,
name: user.name,
type: 'user',
};
}
}
@@ -0,0 +1,65 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { CampaignService } from './campaign.service';
import { CampaignFilterDto } from './dto/campaign-filter.dto';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
@ApiTags('Admin Campaigns (مدیریت کمپین‌ها)')
@Controller('admin/campaigns')
@UseGuards(JwtAdminGuard)
@UseInterceptors(AuditInterceptor)
@ApiBearerAuth('AdminAuth')
export class AdminCampaignController {
constructor(private readonly campaignService: CampaignService) {}
@Post()
@ApiOperation({ summary: 'ایجاد کمپین جدید' })
@ApiResponse({ status: 201, description: 'کمپین با موفقیت ایجاد شد' })
async create(@Body() dto: CreateCampaignDto) {
return this.campaignService.create(dto);
}
@Get()
@ApiOperation({ summary: 'لیست کمپین‌ها با فیلتر وضعیت، جستجو و صفحه‌بندی' })
async findAll(@Query() filter: CampaignFilterDto) {
return this.campaignService.findAll(filter);
}
@Get(':id')
@ApiOperation({ summary: 'دریافت جزئیات کامل کمپین به همراه جوایز و آمار' })
async findOne(@Param('id') id: string) {
return this.campaignService.findOne(id);
}
@Patch(':id')
@ApiOperation({ summary: 'ویرایش اطلاعات و وضعیت کمپین' })
async update(@Param('id') id: string, @Body() dto: UpdateCampaignDto) {
return this.campaignService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'حذف کمپین (در صورت عدم وجود اسپین ثبت‌شده)' })
async remove(@Param('id') id: string) {
return this.campaignService.remove(id);
}
@Post(':id/duplicate')
@ApiOperation({ summary: 'کپی‌برداری سریع از یک کمپین به همراه جوایز آن' })
async duplicate(@Param('id') id: string) {
return this.campaignService.duplicate(id);
}
}
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { Campaign, CampaignStatus } from '@prisma/client';
@Injectable()
export class CampaignAvailabilityService {
/**
* Checks if a campaign is fully eligible for spinning (Status ACTIVE and within date window)
*/
isCampaignAvailable(campaign: Campaign): boolean {
const now = new Date();
return (
campaign.status === CampaignStatus.ACTIVE &&
now >= new Date(campaign.startAt) &&
now <= new Date(campaign.endAt)
);
}
getAvailabilityError(campaign: Campaign): string | null {
const now = new Date();
if (campaign.status === CampaignStatus.DRAFT) {
return 'CAMPAIGN_DRAFT';
}
if (campaign.status === CampaignStatus.PAUSED) {
return 'CAMPAIGN_PAUSED';
}
if (campaign.status === CampaignStatus.FINISHED) {
return 'CAMPAIGN_FINISHED';
}
if (now < new Date(campaign.startAt)) {
return 'CAMPAIGN_NOT_STARTED';
}
if (now > new Date(campaign.endAt)) {
return 'CAMPAIGN_FINISHED';
}
return null;
}
}
@@ -0,0 +1,23 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CampaignService } from './campaign.service';
@ApiTags('Campaigns (عمومی کلاینت)')
@Controller('campaigns')
export class CampaignController {
constructor(private readonly campaignService: CampaignService) {}
@Get(':slug')
@ApiOperation({ summary: 'دریافت مشخصات عمومی کمپین بر اساس اسلاگ' })
@ApiResponse({ status: 200, description: 'مشخصات عمومی کمپین شامل عناوین، بنر، قوانین و وضعیت دسترسی' })
async getPublicCampaign(@Param('slug') slug: string) {
return this.campaignService.getPublicCampaignBySlug(slug);
}
@Get(':slug/wheel')
@ApiOperation({ summary: 'دریافت سگمنت‌های نمایشی گردونه شانس بدون افشای وزن جوایز' })
@ApiResponse({ status: 200, description: 'لیست سگمنت‌های گردونه (عنوان، رنگ، آیکون، ترتیب)' })
async getPublicWheelSegments(@Param('slug') slug: string) {
return this.campaignService.getPublicWheelSegments(slug);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AdminCampaignController } from './admin-campaign.controller';
import { CampaignAvailabilityService } from './campaign-availability.service';
import { CampaignController } from './campaign.controller';
import { CampaignService } from './campaign.service';
@Module({
controllers: [CampaignController, AdminCampaignController],
providers: [CampaignService, CampaignAvailabilityService],
exports: [CampaignService, CampaignAvailabilityService],
})
export class CampaignModule {}
@@ -0,0 +1,388 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CampaignStatus, ChanceTxnType, Prisma, WheelDesign } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CampaignAvailabilityService } from './campaign-availability.service';
import { CampaignFilterDto } from './dto/campaign-filter.dto';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
@Injectable()
export class CampaignService {
constructor(
private readonly prisma: PrismaService,
private readonly availabilityService: CampaignAvailabilityService,
) {}
// ================= Admin Operations =================
async create(dto: CreateCampaignDto) {
if (new Date(dto.endAt) <= new Date(dto.startAt)) {
throw new BadRequestException('تاریخ پایان کمپین باید پس از تاریخ شروع باشد.');
}
const existingSlug = await this.prisma.campaign.findUnique({
where: { slug: dto.slug },
});
if (existingSlug) {
throw new ConflictException('این شناسه URL (اسلاگ) قبلاً استفاده شده است.');
}
// If created with ACTIVE, start as DRAFT until prizes are added in next step
const status = dto.status === CampaignStatus.ACTIVE ? CampaignStatus.DRAFT : (dto.status || CampaignStatus.DRAFT);
const defaultChances = dto.defaultChances !== undefined ? dto.defaultChances : 0;
const wheelDesign = dto.wheelDesign || WheelDesign.CLASSIC_CASINO;
return this.prisma.campaign.create({
data: {
...dto,
status,
defaultChances,
wheelDesign,
},
});
}
async findAll(filter: CampaignFilterDto) {
const { page = 1, pageSize = 20, status, search } = filter;
const skip = (page - 1) * pageSize;
const where: Prisma.CampaignWhereInput = {};
if (status) {
where.status = status;
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ slug: { contains: search, mode: 'insensitive' } },
];
}
const [data, total] = await Promise.all([
this.prisma.campaign.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: {
prizes: true,
spins: true,
userCampaigns: true,
},
},
},
}),
this.prisma.campaign.count({ where }),
]);
return {
data,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findOne(id: string) {
const campaign = await this.prisma.campaign.findUnique({
where: { id },
include: {
prizes: {
orderBy: { sortOrder: 'asc' },
},
_count: {
select: {
spins: true,
userPrizes: true,
userCampaigns: true,
},
},
},
});
if (!campaign) {
throw new NotFoundException('کمپین مورد نظر یافت نشد.');
}
return campaign;
}
async update(id: string, dto: UpdateCampaignDto) {
const campaign = await this.findOne(id);
if (dto.startAt && dto.endAt) {
if (new Date(dto.endAt) <= new Date(dto.startAt)) {
throw new BadRequestException('تاریخ پایان باید پس از تاریخ شروع باشد.');
}
} else if (dto.endAt && new Date(dto.endAt) <= campaign.startAt) {
throw new BadRequestException('تاریخ پایان باید پس از تاریخ شروع باشد.');
} else if (dto.startAt && campaign.endAt <= new Date(dto.startAt)) {
throw new BadRequestException('تاریخ پایان باید پس از تاریخ شروع باشد.');
}
if (dto.slug && dto.slug !== campaign.slug) {
const exists = await this.prisma.campaign.findUnique({ where: { slug: dto.slug } });
if (exists) {
throw new ConflictException('این شناسه URL قبلاً ثبت شده است.');
}
}
// If activating campaign, verify at least one active prize with weight > 0 exists
if (dto.status === CampaignStatus.ACTIVE) {
const activePrizes = await this.prisma.prize.count({
where: {
campaignId: id,
status: 'ACTIVE',
weight: { gt: 0 },
},
});
if (activePrizes === 0) {
throw new BadRequestException(
'برای فعال‌سازی کمپین، حداقل یک جایزه فعال با شانس برد بزرگ‌تر از صفر الزامی است.',
);
}
}
const oldDefaultChances = campaign.defaultChances;
const hasDefaultChancesChanged =
dto.defaultChances !== undefined &&
dto.defaultChances !== null &&
Number(dto.defaultChances) !== oldDefaultChances;
const newDefaultChances = hasDefaultChancesChanged
? Number(dto.defaultChances)
: oldDefaultChances;
return this.prisma.$transaction(async (tx) => {
const updated = await tx.campaign.update({
where: { id },
data: dto,
});
if (hasDefaultChancesChanged) {
const diff = newDefaultChances - oldDefaultChances;
if (diff > 0) {
// Increase chances for all existing users who entered this campaign
await tx.userCampaign.updateMany({
where: { campaignId: id },
data: {
totalChances: { increment: diff },
},
});
const existingUsers = await tx.userCampaign.findMany({
where: { campaignId: id },
select: { userId: true },
});
if (existingUsers.length > 0) {
await tx.chanceTransaction.createMany({
data: existingUsers.map((u) => ({
userId: u.userId,
campaignId: id,
amount: diff,
type: ChanceTxnType.ADMIN_ADJUSTMENT,
referenceType: 'campaign_default_chances_updated',
description: `افزایش خودکار شانس بر اثر ویرایش شانس پیش‌فرض کمپین (از ${oldDefaultChances} به ${newDefaultChances})`,
})),
});
}
} else if (diff < 0) {
// If default chances were reduced
const existingUserCampaigns = await tx.userCampaign.findMany({
where: { campaignId: id },
});
for (const uc of existingUserCampaigns) {
const deductAmount = Math.min(uc.totalChances - uc.usedChances, Math.abs(diff));
if (deductAmount > 0) {
await tx.userCampaign.update({
where: { id: uc.id },
data: {
totalChances: { decrement: deductAmount },
},
});
await tx.chanceTransaction.create({
data: {
userId: uc.userId,
campaignId: id,
amount: -deductAmount,
type: ChanceTxnType.ADMIN_ADJUSTMENT,
referenceType: 'campaign_default_chances_updated',
description: `کسر خودکار شانس بر اثر کاهش شانس پیش‌فرض کمپین (از ${oldDefaultChances} به ${newDefaultChances})`,
},
});
}
}
}
}
return updated;
});
}
async remove(id: string) {
const spinCount = await this.prisma.spin.count({ where: { campaignId: id } });
if (spinCount > 0) {
// Disallow hard delete if spins exist
throw new BadRequestException(
'این کمپین دارای رکورد قرعه‌کشی و اسپین است و امکان حذف فیزیکی آن وجود ندارد. وضعیت آن را به پایان‌یافته یا متوقف تغییر دهید.',
);
}
return this.prisma.campaign.delete({ where: { id } });
}
async duplicate(id: string) {
const original = await this.findOne(id);
const newSlug = `${original.slug}-copy-${Date.now()}`;
return this.prisma.$transaction(async (tx) => {
const newCampaign = await tx.campaign.create({
data: {
title: `${original.title} (کپی)`,
slug: newSlug,
description: original.description,
image: original.image,
banner: original.banner,
logo: original.logo,
startAt: original.startAt,
endAt: original.endAt,
status: CampaignStatus.DRAFT,
defaultChances: original.defaultChances,
rules: original.rules,
ctaText: original.ctaText,
wheelDesign: original.wheelDesign,
settings: original.settings as any,
},
});
// Duplicate all prizes
if (original.prizes?.length > 0) {
for (const p of original.prizes) {
await tx.prize.create({
data: {
campaignId: newCampaign.id,
title: p.title,
description: p.description,
image: p.image,
type: p.type,
stockType: p.stockType,
initialStock: p.initialStock,
remainingStock: p.initialStock,
weight: p.weight,
status: p.status,
sortOrder: p.sortOrder,
segmentColor: p.segmentColor,
smsTemplate: p.smsTemplate,
},
});
}
}
return newCampaign;
});
}
// ================= Public Client Operations =================
async getPublicCampaignBySlug(slug: string) {
const campaign = await this.prisma.campaign.findUnique({
where: { slug },
select: {
id: true,
title: true,
slug: true,
description: true,
banner: true,
logo: true,
startAt: true,
endAt: true,
status: true,
ctaText: true,
rules: true,
wheelDesign: true,
settings: true,
defaultChances: true,
},
});
if (!campaign) {
throw new NotFoundException('کمپین مورد نظر یافت نشد.');
}
const availabilityError = this.availabilityService.getAvailabilityError(campaign as any);
return {
...campaign,
isAvailable: availabilityError === null,
availabilityError,
};
}
/**
* Returns Wheel Segments for public client.
* STRICT SECURITY RULE: Never exposes prize 'weight'!
*/
async getPublicWheelSegments(slug: string) {
const campaign = await this.prisma.campaign.findUnique({
where: { slug },
include: {
prizes: {
where: {
status: 'ACTIVE',
OR: [{ stockType: 'UNLIMITED' }, { remainingStock: { gt: 0 } }],
},
orderBy: { sortOrder: 'asc' },
select: {
id: true,
title: true,
image: true,
segmentColor: true,
sortOrder: true,
type: true,
// NOTICE: 'weight' is intentionally omitted here!
},
},
},
});
if (!campaign) {
throw new NotFoundException('کمپین یافت نشد.');
}
const availabilityError = this.availabilityService.getAvailabilityError(campaign as any);
return {
campaign: {
id: campaign.id,
title: campaign.title,
slug: campaign.slug,
description: campaign.description,
logo: campaign.logo,
banner: campaign.banner,
image: campaign.image,
wheelDesign: campaign.wheelDesign,
settings: campaign.settings,
ctaText: campaign.ctaText,
rules: campaign.rules,
defaultChances: campaign.defaultChances,
isAvailable: availabilityError === null,
availabilityError,
},
segments: campaign.prizes.map((p, index) => ({
id: p.id,
title: p.title,
image: p.image,
color: p.segmentColor,
sortOrder: p.sortOrder ?? index,
type: p.type,
})),
};
}
}
@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { CampaignStatus } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
export class CampaignFilterDto {
@ApiPropertyOptional({ default: 1 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page?: number = 1;
@ApiPropertyOptional({ default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
pageSize?: number = 20;
@ApiPropertyOptional({ enum: CampaignStatus })
@IsEnum(CampaignStatus)
@IsOptional()
status?: CampaignStatus;
@ApiPropertyOptional({ description: 'جستجو در عنوان یا اسلاگ' })
@IsString()
@IsOptional()
search?: string;
}
@@ -0,0 +1,92 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { CampaignStatus, WheelDesign } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsDate,
IsEnum,
IsInt,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
Matches,
Min,
} from 'class-validator';
export class CreateCampaignDto {
@ApiProperty({ example: 'جشنواره نوروزی گردونه شانس', description: 'عنوان کمپین' })
@IsString()
@IsNotEmpty({ message: 'عنوان کمپین الزامی است' })
title: string;
@ApiProperty({ example: 'norooz-1405', description: 'شناسه یکتای URL کمپین' })
@IsString()
@IsNotEmpty({ message: 'شناسه URL الزامی است' })
@Matches(/^[a-z0-9-]+$/, {
message: 'اسلاگ باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره (-) باشد',
})
slug: string;
@ApiPropertyOptional({ example: 'توضیحات مربوط به جوایز و قوانین کمپین' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({ example: 'http://localhost:3000/uploads/banner.png' })
@IsString()
@IsOptional()
banner?: string;
@ApiPropertyOptional({ example: 'http://localhost:3000/uploads/logo.png' })
@IsString()
@IsOptional()
logo?: string;
@ApiProperty({ example: '2026-03-20T00:00:00.000Z', description: 'تاریخ و ساعت شروع کمپین' })
@Type(() => Date)
@IsDate({ message: 'تاریخ شروع باید یک تاریخ معتبر باشد' })
startAt: Date;
@ApiProperty({ example: '2026-04-05T23:59:59.000Z', description: 'تاریخ و ساعت پایان کمپین' })
@Type(() => Date)
@IsDate({ message: 'تاریخ پایان باید یک تاریخ معتبر باشد' })
endAt: Date;
@ApiPropertyOptional({ enum: CampaignStatus, default: CampaignStatus.DRAFT })
@IsEnum(CampaignStatus)
@IsOptional()
status?: CampaignStatus;
@ApiPropertyOptional({ example: 3, default: 0, description: 'تعداد شانس اولیه هر کاربر جدید' })
@IsInt({ message: 'تعداد شانس پیش‌فرض باید عدد صحیح باشد' })
@Min(0, { message: 'تعداد شانس پیش‌فرض نمی‌تواند منفی باشد' })
@IsOptional()
defaultChances?: number;
@ApiPropertyOptional({ example: 'هر کاربر مجاز به استفاده از شانس‌های اختصاص‌یافته است.' })
@IsString()
@IsOptional()
rules?: string;
@ApiPropertyOptional({ example: 'بچرخون و برنده شو!' })
@IsString()
@IsOptional()
ctaText?: string;
@ApiPropertyOptional({
enum: WheelDesign,
default: WheelDesign.CLASSIC_CASINO,
description: 'طرح بصری گردونه (انتخاب از بین ۵ طرح)',
})
@IsEnum(WheelDesign)
@IsOptional()
wheelDesign?: WheelDesign;
@ApiPropertyOptional({
example: { primaryColor: '#FFD700', secondaryColor: '#B22222', celebrationEffect: 'confetti' },
description: 'تنظیمات شخصی‌سازی بصری برند',
})
@IsObject()
@IsOptional()
settings?: Record<string, any>;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCampaignDto } from './create-campaign.dto';
export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {}
@@ -0,0 +1,72 @@
import {
Body,
Controller,
Param,
Post,
Req,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CurrentAdmin } from '../../common/decorators/current-admin.decorator';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { ChanceService } from './chance.service';
import { AdjustChanceDto, AdminAdjustChancePayloadDto } from './dto/adjust-chance.dto';
@ApiTags('Admin Chance Management (مدیریت شانس)')
@Controller('admin')
@UseGuards(JwtAdminGuard)
@UseInterceptors(AuditInterceptor)
@ApiBearerAuth('AdminAuth')
export class AdminChanceController {
constructor(private readonly chanceService: ChanceService) {}
@Post('chances/adjust')
@ApiOperation({ summary: 'تغییر شانس کاربر در کمپین (اندپوئینت مستقیم پنل مدیریت)' })
@ApiResponse({
status: 200,
description: 'تغییر شانس در جدول Snapshot و Ledger اعمال شد و در AdminLog ثبت گردید.',
})
async adjustChanceDirect(
@CurrentAdmin('id') adminId: string,
@Body() dto: AdminAdjustChancePayloadDto,
@Req() req: any,
) {
const ip = req.ip || req.headers['x-forwarded-for'];
const description = dto.reason || dto.description || 'اهدای شانس تشویقی توسط مدیر';
return this.chanceService.adminAdjustChance(
dto.userId,
dto.campaignId,
adminId,
{ amount: dto.amount, description, reason: dto.reason },
ip,
);
}
@Post('users/:userId/campaigns/:campaignId/chances')
@ApiOperation({ summary: 'اضافه یا کم کردن دستی شانس کاربر در یک کمپین خاص' })
@ApiResponse({
status: 200,
description: 'تغییر شانس در جدول Snapshot و Ledger اعمال شد و در AdminLog ثبت گردید.',
})
async adjustChance(
@Param('userId') userId: string,
@Param('campaignId') campaignId: string,
@CurrentAdmin('id') adminId: string,
@Body() dto: AdjustChanceDto,
@Req() req: any,
) {
const ip = req.ip || req.headers['x-forwarded-for'];
const description = dto.description || dto.reason || 'اهدای شانس تشویقی توسط مدیر';
return this.chanceService.adminAdjustChance(
userId,
campaignId,
adminId,
{ amount: dto.amount, description, reason: dto.reason },
ip,
);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AdminChanceController } from './admin-chance.controller';
import { ChanceService } from './chance.service';
@Module({
controllers: [AdminChanceController],
providers: [ChanceService],
exports: [ChanceService],
})
export class ChanceModule {}
@@ -0,0 +1,249 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ChanceTxnType, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AdjustChanceDto } from './dto/adjust-chance.dto';
type PrismaTransactionClient = Omit<
PrismaService,
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
>;
@Injectable()
export class ChanceService {
constructor(private readonly prisma: PrismaService) {}
/**
* Adjusts chances with Snapshot (user_campaigns) + Ledger (chance_transactions)
* executed within an existing or new database transaction.
*/
async adjustChance(
tx: PrismaTransactionClient,
params: {
userId: string;
campaignId: string;
amount: number;
type: ChanceTxnType;
createdById?: string;
referenceType?: string;
referenceId?: string;
description?: string;
},
) {
const { userId, campaignId, amount, type, createdById, referenceType, referenceId, description } =
params;
// 1) Write to immutable Ledger
const txn = await tx.chanceTransaction.create({
data: {
userId,
campaignId,
amount,
type,
referenceType,
referenceId,
description,
createdById,
},
});
// 2) Update Snapshot table (user_campaigns)
let userCampaign = await tx.userCampaign.findUnique({
where: { userId_campaignId: { userId, campaignId } },
});
if (!userCampaign) {
userCampaign = await tx.userCampaign.create({
data: {
userId,
campaignId,
totalChances: Math.max(0, amount),
usedChances: 0,
},
});
} else {
if (type === ChanceTxnType.SPIN_CONSUME) {
// Spin consume increments usedChances
userCampaign = await tx.userCampaign.update({
where: { id: userCampaign.id },
data: { usedChances: { increment: 1 } },
});
} else if (amount > 0) {
// Positive addition increments totalChances
userCampaign = await tx.userCampaign.update({
where: { id: userCampaign.id },
data: { totalChances: { increment: amount } },
});
} else if (amount < 0) {
// Negative adjustment directly decrements totalChances
const newTotal = Math.max(0, userCampaign.totalChances + amount);
userCampaign = await tx.userCampaign.update({
where: { id: userCampaign.id },
data: { totalChances: newTotal },
});
}
}
return { txn, userCampaign };
}
/**
* Admin manual adjustment with mandatory AdminLog
*/
async adminAdjustChance(
userId: string,
campaignId: string,
adminId: string,
dto: AdjustChanceDto,
ip?: string,
) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('کاربر یافت نشد.');
}
const campaign = await this.prisma.campaign.findUnique({ where: { id: campaignId } });
if (!campaign) {
throw new NotFoundException('کمپین یافت نشد.');
}
return this.prisma.$transaction(async (tx) => {
// Check current chances if decreasing
if (dto.amount < 0) {
const current = await tx.userCampaign.findUnique({
where: { userId_campaignId: { userId, campaignId } },
});
const remaining = (current?.totalChances ?? 0) - (current?.usedChances ?? 0);
if (remaining + dto.amount < 0) {
throw new BadRequestException(
`کاربر فقط ${remaining} شانس باقی‌مانده دارد و نمی‌توانید ${Math.abs(dto.amount)} شانس از او کسر کنید.`,
);
}
}
const finalDescription =
dto.description || (dto as any).reason || 'تغییر دستی شانس توسط مدیر';
const { txn, userCampaign } = await this.adjustChance(tx as PrismaTransactionClient, {
userId,
campaignId,
amount: dto.amount,
type: ChanceTxnType.ADMIN_ADJUSTMENT,
createdById: adminId,
referenceType: 'admin_manual',
description: finalDescription,
});
// Register AdminLog
await tx.adminLog.create({
data: {
adminId,
action: 'chance.adjust',
entityType: 'user_campaign',
entityId: userCampaign.id,
oldValue: { amountBefore: userCampaign.totalChances - dto.amount },
newValue: {
amountChange: dto.amount,
amountAfter: userCampaign.totalChances,
description: finalDescription,
userMobile: user.mobile,
campaignTitle: campaign.title,
},
ip,
},
});
return {
success: true,
userCampaign: {
totalChances: userCampaign.totalChances,
usedChances: userCampaign.usedChances,
remainingChances: userCampaign.totalChances - userCampaign.usedChances,
},
transaction: txn,
};
});
}
/**
* Retrieves or initializes user chances for a campaign.
* If user has never participated, awards defaultChances (SIGNUP_BONUS).
*/
async getUserChances(userId: string, campaignId: string) {
const campaign = await this.prisma.campaign.findUnique({
where: { id: campaignId },
select: { defaultChances: true },
});
const defaultChances = campaign?.defaultChances ?? 0;
let userCampaign = await this.prisma.userCampaign.findUnique({
where: { userId_campaignId: { userId, campaignId } },
});
if (!userCampaign) {
return this.prisma.$transaction(async (tx) => {
const { userCampaign: created } = await this.adjustChance(tx as PrismaTransactionClient, {
userId,
campaignId,
amount: defaultChances,
type: ChanceTxnType.SIGNUP_BONUS,
referenceType: 'campaign_entry',
description: 'شانس اولیه پیش‌فرض ورود به کمپین',
});
return {
totalChances: created.totalChances,
usedChances: created.usedChances,
remainingChances: created.totalChances - created.usedChances,
};
});
}
// Auto-sync if campaign defaultChances was increased and user has less total chances
if (userCampaign.totalChances < defaultChances) {
const diff = defaultChances - userCampaign.totalChances;
return this.prisma.$transaction(async (tx) => {
const { userCampaign: updated } = await this.adjustChance(tx as PrismaTransactionClient, {
userId,
campaignId,
amount: diff,
type: ChanceTxnType.ADMIN_ADJUSTMENT,
referenceType: 'campaign_default_chances_sync',
description: `همگام‌سازی خودکار با شانس پیش‌فرض کمپین (${defaultChances})`,
});
return {
totalChances: updated.totalChances,
usedChances: updated.usedChances,
remainingChances: updated.totalChances - updated.usedChances,
};
});
}
return {
totalChances: userCampaign.totalChances,
usedChances: userCampaign.usedChances,
remainingChances: userCampaign.totalChances - userCampaign.usedChances,
};
}
async getUserChanceHistory(userId: string, campaignId?: string) {
const where: Prisma.ChanceTransactionWhereInput = { userId };
if (campaignId) {
where.campaignId = campaignId;
}
return this.prisma.chanceTransaction.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
campaign: { select: { id: true, title: true } },
createdBy: { select: { id: true, name: true, email: true } },
},
});
}
}
@@ -0,0 +1,71 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsOptional, IsString, NotEquals } from 'class-validator';
export class AdjustChanceDto {
@ApiProperty({
example: 5,
description: 'تعداد شانس جهت افزایش (مثبت) یا کاهش (منفی) — نمی‌تواند صفر باشد',
})
@IsInt({ message: 'مقدار شانس باید عدد صحیح باشد' })
@NotEquals(0, { message: 'مقدار تغییر شانس نمی‌تواند صفر باشد' })
@IsNotEmpty({ message: 'مقدار تغییر شانس الزامی است' })
amount: number;
@ApiProperty({
example: 'پاداش شرکت در نظرسنجی اینستاگرام',
description: 'علت و توضیح تغییر شانس برای ثبت در دفترکل',
required: false,
})
@IsString({ message: 'توضیحات باید متن باشد' })
@IsOptional()
description?: string;
@ApiProperty({
example: 'اهدای شانس تشویقی توسط مدیر',
description: 'دلیل تغییر شانس',
required: false,
})
@IsString({ message: 'دلیل باید متن باشد' })
@IsOptional()
reason?: string;
}
export class AdminAdjustChancePayloadDto {
@ApiProperty({ description: 'شناسه کاربر' })
@IsString({ message: 'شناسه کاربر باید رشته معتبر باشد' })
@IsNotEmpty({ message: 'شناسه کاربر الزامی است' })
userId: string;
@ApiProperty({ description: 'شناسه کمپین' })
@IsString({ message: 'شناسه کمپین باید رشته معتبر باشد' })
@IsNotEmpty({ message: 'شناسه کمپین الزامی است' })
campaignId: string;
@ApiProperty({
example: 5,
description: 'تعداد شانس جهت افزایش (مثبت) یا کاهش (منفی) — نمی‌تواند صفر باشد',
})
@IsInt({ message: 'مقدار شانس باید عدد صحیح باشد' })
@NotEquals(0, { message: 'مقدار تغییر شانس نمی‌تواند صفر باشد' })
@IsNotEmpty({ message: 'مقدار تغییر شانس الزامی است' })
amount: number;
@ApiProperty({
example: 'اهدای شانس تشویقی توسط مدیر',
description: 'دلیل تغییر شانس',
required: false,
})
@IsString({ message: 'دلیل باید متن باشد' })
@IsOptional()
reason?: string;
@ApiProperty({
example: 'پاداش وفاداری',
description: 'توضیحات تغییر شانس برای دفترکل',
required: false,
})
@IsString({ message: 'توضیحات باید متن باشد' })
@IsOptional()
description?: string;
}
@@ -0,0 +1,69 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { CreatePrizeDto } from './dto/create-prize.dto';
import { UpdatePrizeDto } from './dto/update-prize.dto';
import { UpdateStockDto } from './dto/update-stock.dto';
import { PrizeService } from './prize.service';
@ApiTags('Admin Prizes (مدیریت جوایز)')
@Controller('admin')
@UseGuards(JwtAdminGuard)
@UseInterceptors(AuditInterceptor)
@ApiBearerAuth('AdminAuth')
export class AdminPrizeController {
constructor(private readonly prizeService: PrizeService) {}
@Post('campaigns/:campaignId/prizes')
@ApiOperation({ summary: 'ایجاد جایزه جدید برای یک کمپین' })
@ApiResponse({ status: 201, description: 'جایزه با موفقیت به کمپین اضافه شد' })
async create(
@Param('campaignId') campaignId: string,
@Body() dto: CreatePrizeDto,
) {
return this.prizeService.create(campaignId, dto);
}
@Get('campaigns/:campaignId/prizes')
@ApiOperation({ summary: 'لیست تمام جوایز تعریف‌شده برای یک کمپین' })
async findByCampaign(@Param('campaignId') campaignId: string) {
return this.prizeService.findByCampaign(campaignId);
}
@Get('prizes/:id')
@ApiOperation({ summary: 'دریافت مشخصات کامل یک جایزه' })
async findOne(@Param('id') id: string) {
return this.prizeService.findOne(id);
}
@Patch('prizes/:id')
@Put('prizes/:id')
@ApiOperation({ summary: 'ویرایش اطلاعات و وزن جایزه' })
async update(@Param('id') id: string, @Body() dto: UpdatePrizeDto) {
return this.prizeService.update(id, dto);
}
@Patch('prizes/:id/stock')
@ApiOperation({ summary: 'شارژ یا اصلاح دستی موجودی جایزه' })
async updateStock(@Param('id') id: string, @Body() dto: UpdateStockDto) {
return this.prizeService.updateStock(id, dto);
}
@Delete('prizes/:id')
@ApiOperation({ summary: 'حذف جایزه (در صورت عدم ثبت در نتایج قرعه‌کشی)' })
async remove(@Param('id') id: string) {
return this.prizeService.remove(id);
}
}
@@ -0,0 +1,115 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PrizeStatus, PrizeType, StockType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsDate,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Min,
ValidateIf,
} from 'class-validator';
export class CreatePrizeDto {
@ApiProperty({ example: 'آیفون ۱۵ پرو ۲۵۶ گیگابایت', description: 'عنوان جایزه' })
@IsString()
@IsNotEmpty({ message: 'عنوان جایزه الزامی است' })
title: string;
@ApiPropertyOptional({ example: 'تحویل حضوری با ارائه کارت شناسایی' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({ example: 'تحویل حضوری با ارائه کارت شناسایی' })
@IsString()
@IsOptional()
subtitle?: string;
@ApiPropertyOptional({ example: 'http://localhost:3000/uploads/iphone.png' })
@IsString()
@IsOptional()
image?: string;
@ApiPropertyOptional({ example: 'http://localhost:3000/uploads/iphone.png' })
@IsString()
@IsOptional()
iconUrl?: string;
@ApiProperty({ enum: PrizeType, example: PrizeType.PHYSICAL, description: 'نوع جایزه' })
@IsOptional()
type?: PrizeType;
@ApiPropertyOptional({ description: 'نام مستعار نوع جایزه' })
@IsOptional()
prizeType?: string;
@ApiProperty({ enum: StockType, example: StockType.LIMITED, description: 'نوع موجودی (محدود / نامحدود)' })
@IsEnum(StockType)
@IsNotEmpty()
stockType: StockType;
@ApiPropertyOptional({ example: 5, description: 'موجودی اولیه (در صورت محدود بودن)' })
@ValidateIf((o) => o.stockType === StockType.LIMITED)
@Type(() => Number)
@IsInt({ message: 'موجودی اولیه باید عدد صحیح باشد' })
@Min(1, { message: 'موجودی اولیه باید حداقل ۱ باشد' })
initialStock?: number;
@ApiPropertyOptional({ example: 5, description: 'موجودی باقی‌مانده فعلی' })
@Type(() => Number)
@IsInt({ message: 'موجودی باقی‌مانده باید عدد صحیح باشد' })
@Min(0, { message: 'موجودی باقی‌مانده نمی‌تواند منفی باشد' })
@IsOptional()
remainingStock?: number;
@ApiProperty({ example: 10, description: 'وزن برد (عددی مثبت و بزرگ‌تر از صفر)' })
@Type(() => Number)
@IsInt({ message: 'وزن برد باید عدد صحیح باشد' })
@Min(1, { message: 'وزن برد باید حداقل ۱ باشد' })
weight: number;
@ApiPropertyOptional({ enum: PrizeStatus, default: PrizeStatus.ACTIVE })
@IsEnum(PrizeStatus)
@IsOptional()
status?: PrizeStatus = PrizeStatus.ACTIVE;
@ApiPropertyOptional({ description: 'وضعیت فعال بودن' })
@IsOptional()
isActive?: boolean;
@ApiPropertyOptional({ example: 1, default: 0, description: 'ترتیب سگمنت روی گردونه' })
@Type(() => Number)
@IsInt()
@IsOptional()
sortOrder?: number = 0;
@ApiPropertyOptional({ example: '#FF5733', description: 'کد رنگ سگمنت در گردونه (فرمت هگز)' })
@IsString()
@IsOptional()
segmentColor?: string;
@ApiPropertyOptional({ example: '#FF5733', description: 'کد رنگ (نام مستعار)' })
@IsString()
@IsOptional()
color?: string;
@ApiPropertyOptional({ example: '#FFFFFF' })
@IsString()
@IsOptional()
textColor?: string;
@ApiPropertyOptional({ example: '2026-12-31T23:59:59.000Z', description: 'تاریخ انقضای کد یا جایزه' })
@Type(() => Date)
@IsDate()
@IsOptional()
expiresAt?: Date;
@ApiPropertyOptional({ example: 'تبریک! شما برنده {prize} شدید. کد جایزه: {code}' })
@IsString()
@IsOptional()
smsTemplate?: string;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePrizeDto } from './create-prize.dto';
export class UpdatePrizeDto extends PartialType(CreatePrizeDto) {}
@@ -0,0 +1,28 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString } from 'class-validator';
export class UpdateStockDto {
@ApiPropertyOptional({
example: 10,
description: 'موجودی جدید باقی‌مانده یا تعداد افزوده‌شده به موجودی',
})
@IsInt({ message: 'موجودی باید عدد صحیح باشد' })
@IsOptional()
stock?: number;
@ApiPropertyOptional({
example: 5,
description: 'تغییر موجودی (مثبت برای افزایش، منفی برای کاهش)',
})
@IsInt({ message: 'تغییر موجودی باید عدد صحیح باشد' })
@IsOptional()
change?: number;
@ApiPropertyOptional({
example: 'ورود محموله جدید به انبار',
description: 'دلیل تغییر موجودی',
})
@IsString()
@IsOptional()
reason?: string;
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AdminPrizeController } from './admin-prize.controller';
import { PrizeService } from './prize.service';
@Module({
controllers: [AdminPrizeController],
providers: [PrizeService],
exports: [PrizeService],
})
export class PrizeModule {}
+201
View File
@@ -0,0 +1,201 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { StockType } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CreatePrizeDto } from './dto/create-prize.dto';
import { UpdatePrizeDto } from './dto/update-prize.dto';
import { UpdateStockDto } from './dto/update-stock.dto';
@Injectable()
export class PrizeService {
constructor(private readonly prisma: PrismaService) {}
async create(campaignId: string, dto: CreatePrizeDto) {
const campaign = await this.prisma.campaign.findUnique({
where: { id: campaignId },
});
if (!campaign) {
throw new NotFoundException('کمپین مورد نظر یافت نشد.');
}
if (dto.stockType === StockType.LIMITED && (!dto.initialStock || dto.initialStock <= 0)) {
throw new BadRequestException('برای جوایز محدود، موجودی اولیه باید بزرگ‌تر از صفر باشد.');
}
const initialStock = dto.stockType === StockType.LIMITED ? dto.initialStock : null;
const remainingStock = dto.stockType === StockType.LIMITED ? dto.initialStock : null;
// Calculate sortOrder if not provided
let sortOrder = dto.sortOrder;
if (sortOrder === undefined || sortOrder === 0) {
const maxOrder = await this.prisma.prize.findFirst({
where: { campaignId },
orderBy: { sortOrder: 'desc' },
select: { sortOrder: true },
});
sortOrder = (maxOrder?.sortOrder ?? 0) + 1;
}
// Normalize type
const rawType = dto.type || (dto as any).prizeType;
let type: any = 'DISCOUNT_CODE';
if (rawType === 'WALLET_CREDIT') type = 'CREDIT';
else if (rawType === 'CHANCE') type = 'OTHER';
else if (rawType) type = rawType;
const segmentColor = dto.segmentColor || (dto as any).color || '#3880ff';
const description = dto.description ?? (dto as any).subtitle ?? null;
const image = dto.image ?? (dto as any).iconUrl ?? null;
let status = dto.status || 'ACTIVE';
if ((dto as any).isActive !== undefined) {
status = (dto as any).isActive ? 'ACTIVE' : 'INACTIVE';
}
return this.prisma.prize.create({
data: {
campaignId,
title: dto.title,
description,
image,
type,
stockType: dto.stockType,
initialStock,
remainingStock,
weight: dto.weight,
status: status as any,
sortOrder,
segmentColor,
expiresAt: dto.expiresAt,
smsTemplate: dto.smsTemplate,
},
});
}
async findByCampaign(campaignId: string) {
return this.prisma.prize.findMany({
where: { campaignId },
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: {
spins: true,
userPrizes: true,
},
},
},
});
}
async findOne(id: string) {
const prize = await this.prisma.prize.findUnique({
where: { id },
include: {
campaign: {
select: { id: true, title: true, slug: true },
},
_count: {
select: {
spins: true,
userPrizes: true,
},
},
},
});
if (!prize) {
throw new NotFoundException('جایزه مورد نظر یافت نشد.');
}
return prize;
}
async update(id: string, dto: UpdatePrizeDto) {
const prize = await this.findOne(id);
const data: any = {};
if (dto.title !== undefined) data.title = dto.title;
if (dto.description !== undefined || (dto as any).subtitle !== undefined) {
data.description = dto.description ?? (dto as any).subtitle;
}
if (dto.image !== undefined || (dto as any).iconUrl !== undefined) {
data.image = dto.image ?? (dto as any).iconUrl;
}
if (dto.type !== undefined || (dto as any).prizeType !== undefined) {
const rawType = dto.type || (dto as any).prizeType;
if (rawType === 'WALLET_CREDIT') data.type = 'CREDIT';
else if (rawType === 'CHANCE') data.type = 'OTHER';
else if (rawType) data.type = rawType;
}
if (dto.segmentColor !== undefined || (dto as any).color !== undefined) {
data.segmentColor = dto.segmentColor ?? (dto as any).color;
}
if (dto.weight !== undefined) data.weight = dto.weight;
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
if (dto.expiresAt !== undefined) data.expiresAt = dto.expiresAt;
if (dto.smsTemplate !== undefined) data.smsTemplate = dto.smsTemplate;
if (dto.status !== undefined) data.status = dto.status;
else if ((dto as any).isActive !== undefined) {
data.status = (dto as any).isActive ? 'ACTIVE' : 'INACTIVE';
}
if (dto.stockType === StockType.UNLIMITED) {
data.stockType = StockType.UNLIMITED;
data.initialStock = null;
data.remainingStock = null;
} else if (dto.stockType === StockType.LIMITED) {
data.stockType = StockType.LIMITED;
if (prize.stockType === StockType.UNLIMITED && !dto.initialStock) {
throw new BadRequestException('برای تغییر وضعیت موجودی به محدود، تعیین موجودی اولیه الزامی است.');
}
if (dto.initialStock !== undefined) {
data.initialStock = dto.initialStock;
// Adjust remaining stock proportionately if initial stock is changed and remainingStock is not explicitly provided
if (dto.remainingStock === undefined) {
const diff = (dto.initialStock ?? 0) - (prize.initialStock ?? 0);
data.remainingStock = Math.max(0, (prize.remainingStock ?? 0) + diff);
}
}
if (dto.remainingStock !== undefined) {
data.remainingStock = dto.remainingStock;
}
}
return this.prisma.prize.update({
where: { id },
data,
});
}
async updateStock(id: string, dto: UpdateStockDto) {
const prize = await this.findOne(id);
if (prize.stockType === StockType.UNLIMITED) {
throw new BadRequestException('این جایزه دارای موجودی نامحدود است.');
}
let newStock = prize.remainingStock ?? 0;
if (dto.stock !== undefined) {
newStock = dto.stock;
} else if (dto.change !== undefined) {
newStock = Math.max(0, newStock + dto.change);
}
return this.prisma.prize.update({
where: { id },
data: {
remainingStock: newStock,
...(newStock > 0 && prize.status === 'INACTIVE' ? { status: 'ACTIVE' } : {}),
},
});
}
async remove(id: string) {
const spinCount = await this.prisma.spin.count({ where: { prizeId: id } });
if (spinCount > 0) {
throw new BadRequestException(
'این جایزه به برندگان قبلی تعلق گرفته و در سوابق قرعه‌کشی ثبت شده است. به‌جای حذف، وضعیت آن را غیرفعال (INACTIVE) کنید.',
);
}
return this.prisma.prize.delete({ where: { id } });
}
}
@@ -0,0 +1,38 @@
import { Controller, Get, Header, Query, Res, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { ReportService } from './report.service';
@ApiTags('Admin Reports (گزارش‌گیری و داشبورد)')
@Controller('admin/reports')
@UseGuards(JwtAdminGuard)
@ApiBearerAuth('AdminAuth')
export class AdminReportController {
constructor(private readonly reportService: ReportService) {}
@Get('dashboard')
@ApiOperation({ summary: 'دریافت تمام آمارهای تجمیعی داشبورد اصلی، چارت‌های روزانه و آخرین برندگان' })
@ApiResponse({ status: 200, description: 'شاخص‌های KPI داشبورد' })
async getDashboard() {
return this.reportService.getDashboardStats();
}
@Get('participation')
@ApiOperation({ summary: 'گزارش مشارکت کاربران و تحلیل شانس‌ها با فیلتر بازه زمانی' })
@ApiQuery({ name: 'from', required: false })
@ApiQuery({ name: 'to', required: false })
@ApiQuery({ name: 'campaignId', required: false })
async getParticipation(@Query() query: any) {
return this.reportService.getParticipationReport(query);
}
@Get('export')
@ApiOperation({ summary: 'خروجی اکسل گزارش مشارکت' })
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@Header('Content-Disposition', 'attachment; filename=participation-report.xlsx')
async exportExcel(@Query() query: any, @Res() res: Response) {
const buffer = await this.reportService.exportParticipationExcel(query);
res.send(buffer);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AdminReportController } from './admin-report.controller';
import { ReportService } from './report.service';
@Module({
controllers: [AdminReportController],
providers: [ReportService],
exports: [ReportService],
})
export class ReportModule {}
@@ -0,0 +1,187 @@
import { Injectable } from '@nestjs/common';
import * as ExcelJS from 'exceljs';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ReportService {
constructor(private readonly prisma: PrismaService) {}
/**
* Aggregates all KPI statistics for the main Admin Dashboard (Section 18)
*/
async getDashboardStats() {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const [
totalUsers,
todayUsers,
totalSpins,
todaySpins,
totalWinners,
totalDistributedPrizes,
consumedChancesAggr,
activePrizesCount,
recentWinners,
] = await Promise.all([
this.prisma.user.count(),
this.prisma.user.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.spin.count(),
this.prisma.spin.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.userPrize.count(),
this.prisma.userPrize.count({
where: { status: { in: ['DELIVERED', 'USED'] } },
}),
this.prisma.chanceTransaction.aggregate({
where: { type: 'SPIN_CONSUME' },
_sum: { amount: true },
}),
this.prisma.prize.count({ where: { status: 'ACTIVE' } }),
this.prisma.userPrize.findMany({
take: 10,
orderBy: { wonAt: 'desc' },
include: {
user: { select: { mobile: true, name: true } },
prize: { select: { title: true, type: true, image: true } },
campaign: { select: { title: true } },
},
}),
]);
// 14-day trend of spins and winners
const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
fourteenDaysAgo.setHours(0, 0, 0, 0);
const recentSpins = await this.prisma.spin.findMany({
where: { createdAt: { gte: fourteenDaysAgo } },
select: { createdAt: true, prizeId: true },
});
const dayMap: Record<string, { spins: number; winners: number }> = {};
for (let i = 0; i < 14; i++) {
const d = new Date();
d.setDate(d.getDate() - (13 - i));
const key = d.toISOString().slice(0, 10);
dayMap[key] = { spins: 0, winners: 0 };
}
recentSpins.forEach((s) => {
const key = s.createdAt.toISOString().slice(0, 10);
if (dayMap[key]) {
dayMap[key].spins++;
if (s.prizeId) {
dayMap[key].winners++;
}
}
});
const spinsByDay = Object.entries(dayMap).map(([date, data]) => ({
date,
spins: data.spins,
winners: data.winners,
}));
// Prize Distribution Breakdown
const prizesWithWins = await this.prisma.userPrize.groupBy({
by: ['prizeId'],
_count: { id: true },
});
const prizeDetails = await this.prisma.prize.findMany({
where: { id: { in: prizesWithWins.map((p) => p.prizeId) } },
select: { id: true, title: true },
});
const prizeTitleMap = new Map(prizeDetails.map((p) => [p.id, p.title]));
const totalWon = prizesWithWins.reduce((sum, p) => sum + p._count.id, 0);
const prizeDistribution = prizesWithWins.map((p) => ({
prizeTitle: prizeTitleMap.get(p.prizeId) || 'نامشخص',
count: p._count.id,
percentage: totalWon > 0 ? Number(((p._count.id / totalWon) * 100).toFixed(1)) : 0,
}));
return {
stats: {
totalUsers,
todayUsers,
totalSpins,
todaySpins,
totalWinners,
totalDistributedPrizes,
consumedChances: Math.abs(consumedChancesAggr._sum.amount || 0),
activePrizesCount,
},
spinsByDay,
prizeDistribution,
recentWinners,
};
}
async getParticipationReport(query: { from?: string; to?: string; campaignId?: string }) {
const where: any = {};
if (query.campaignId) where.campaignId = query.campaignId;
if (query.from || query.to) {
where.createdAt = {};
if (query.from) where.createdAt.gte = new Date(query.from);
if (query.to) where.createdAt.lte = new Date(query.to);
}
const [totalSpins, winnersCount, userCampaigns] = await Promise.all([
this.prisma.spin.count({ where }),
this.prisma.userPrize.count({
where: {
...(query.campaignId ? { campaignId: query.campaignId } : {}),
...(query.from || query.to
? {
wonAt: {
...(query.from ? { gte: new Date(query.from) } : {}),
...(query.to ? { lte: new Date(query.to) } : {}),
},
}
: {}),
},
}),
this.prisma.userCampaign.findMany({
where: query.campaignId ? { campaignId: query.campaignId } : {},
select: { totalChances: true, usedChances: true },
}),
]);
const totalChancesAllocated = userCampaigns.reduce((sum, c) => sum + c.totalChances, 0);
const totalChancesUsed = userCampaigns.reduce((sum, c) => sum + c.usedChances, 0);
const zeroSpinUsers = userCampaigns.filter((c) => c.usedChances === 0).length;
const remainingChanceUsers = userCampaigns.filter((c) => c.totalChances > c.usedChances).length;
return {
totalSpins,
winnersCount,
totalChancesAllocated,
totalChancesUsed,
zeroSpinUsers,
remainingChanceUsers,
};
}
async exportParticipationExcel(query: { from?: string; to?: string; campaignId?: string }) {
const report = await this.getParticipationReport(query);
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('گزارش مشارکت');
worksheet.columns = [
{ header: 'شاخص KPI', key: 'metric', width: 35 },
{ header: 'مقدار', key: 'value', width: 20 },
];
worksheet.addRow({ metric: 'تعداد کل چرخش‌ها (Spins)', value: report.totalSpins });
worksheet.addRow({ metric: 'تعداد جوایز برنده شده', value: report.winnersCount });
worksheet.addRow({ metric: 'کل شانس‌های تخصیص یافته', value: report.totalChancesAllocated });
worksheet.addRow({ metric: 'کل شانس‌های مصرف شده', value: report.totalChancesUsed });
worksheet.addRow({ metric: 'کاربران بدون چرخش (Zero Spin)', value: report.zeroSpinUsers });
worksheet.addRow({ metric: 'کاربران با شانس باقی‌مانده', value: report.remainingChanceUsers });
return workbook.xlsx.writeBuffer();
}
}
@@ -0,0 +1,39 @@
import {
Controller,
Get,
Param,
Post,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { SmsService } from './sms.service';
@ApiTags('Admin SMS Logs (مدیریت پیامک‌ها)')
@Controller('admin/sms-logs')
@UseGuards(JwtAdminGuard)
@UseInterceptors(AuditInterceptor)
@ApiBearerAuth('AdminAuth')
export class AdminSmsController {
constructor(private readonly smsService: SmsService) {}
@Get()
@ApiOperation({ summary: 'لیست تمام پیامک‌های ارسالی با فیلتر وضعیت و موبایل' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'mobile', required: false })
@ApiQuery({ name: 'status', required: false })
async getLogs(@Query() query: any) {
return this.smsService.getLogs(query);
}
@Post(':id/resend')
@ApiOperation({ summary: 'ارسال مجدد پیامک ناموفق' })
@ApiResponse({ status: 200, description: 'پیامک در صف ارسال مجدد قرار گرفت' })
async resend(@Param('id') id: string) {
return this.smsService.resendSms(id);
}
}
@@ -0,0 +1,16 @@
import { Injectable, Logger } from '@nestjs/common';
import { SmsProviderInterface } from '../sms.interface';
@Injectable()
export class MockSmsProvider implements SmsProviderInterface {
private readonly logger = new Logger(MockSmsProvider.name);
async send(mobile: string, message: string): Promise<{ providerMessageId: string; raw: unknown }> {
const providerMessageId = `MOCK-SMS-${Date.now()}`;
this.logger.log(`[MOCK SMS] Sent to ${mobile} | Content: ${message} | ID: ${providerMessageId}`);
return {
providerMessageId,
raw: { success: true, mock: true, recipient: mobile, body: message },
};
}
}
@@ -0,0 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SmsProviderInterface } from '../sms.interface';
@Injectable()
export class RayganSmsProvider implements SmsProviderInterface {
private readonly logger = new Logger(RayganSmsProvider.name);
private readonly baseUrl: string;
private readonly username?: string;
private readonly password?: string;
private readonly fromLine?: string;
constructor(private readonly configService: ConfigService) {
this.baseUrl = this.configService.get<string>(
'RAYGAN_SMS_API_BASE_URL',
'https://api.raygansms.com',
);
this.username = this.configService.get<string>('RAYGAN_SMS_USERNAME');
this.password = this.configService.get<string>('RAYGAN_SMS_PASSWORD');
this.fromLine = this.configService.get<string>('RAYGAN_SMS_LINE_NUMBER');
}
async send(mobile: string, message: string): Promise<{ providerMessageId: string; raw: unknown }> {
try {
// Modern REST API Endpoint call
const response = await fetch(`${this.baseUrl}/api/sms/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: this.username,
password: this.password,
from: this.fromLine,
to: mobile,
text: message,
}),
});
const data = await response.json();
const messageId = (data as any)?.messageId || (data as any)?.id || `RAYGAN-${Date.now()}`;
return {
providerMessageId: String(messageId),
raw: data,
};
} catch (err: any) {
this.logger.error(`RayganSMS send failure to ${mobile}: ${err.message}`);
throw err;
}
}
}
@@ -0,0 +1,123 @@
import { ConfigModule } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { CampaignStatus, PrizeType, StockType } from '@prisma/client';
import * as crypto from 'crypto';
import { PrismaModule } from '../../prisma/prisma.module';
import { PrismaService } from '../../prisma/prisma.service';
import { CampaignAvailabilityService } from '../campaign/campaign-availability.service';
import { ChanceModule } from '../chance/chance.module';
import { PrizeCodeGenerator } from '../spin-engine/prize-code-generator';
import { SpinEngineService } from '../spin-engine/spin-engine.service';
import { WeightedRandomSelector } from '../spin-engine/weighted-random-selector';
import { SmsService } from './sms.service';
describe('SMS Resilience Integration Test (AC-12)', () => {
let service: SpinEngineService;
let prisma: PrismaService;
// Mock SMS service that throws an intentional rejection
const failingSmsService = {
enqueueSpinResult: jest.fn().mockImplementation(async () => {
throw new Error('SIMULATED_RAYGAN_SMS_API_NETWORK_FAILURE');
}),
enqueueOtp: jest.fn().mockImplementation(async () => {
throw new Error('SIMULATED_OTP_FAILURE');
}),
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }),
PrismaModule,
ChanceModule,
],
providers: [
SpinEngineService,
CampaignAvailabilityService,
WeightedRandomSelector,
PrizeCodeGenerator,
{ provide: SmsService, useValue: failingSmsService },
],
}).compile();
service = module.get<SpinEngineService>(SpinEngineService);
prisma = module.get<PrismaService>(PrismaService);
});
afterAll(async () => {
await prisma.$disconnect();
});
it('AC-12: Spin and UserPrize must be committed and intact even if SMS enqueueing fails', async () => {
const timestamp = Date.now();
const campaign = await prisma.campaign.create({
data: {
title: `SMS Failure Resilient Campaign ${timestamp}`,
slug: `sms-failure-${timestamp}`,
startAt: new Date(Date.now() - 86400000),
endAt: new Date(Date.now() + 86400000 * 10),
status: CampaignStatus.ACTIVE,
defaultChances: 2,
},
});
const prize = await prisma.prize.create({
data: {
campaignId: campaign.id,
title: 'جایزه طلایی',
type: PrizeType.DISCOUNT_CODE,
stockType: StockType.UNLIMITED,
weight: 100,
segmentColor: '#Gold',
},
});
const user = await prisma.user.create({
data: {
mobile: `0988${timestamp.toString().slice(-7)}`,
status: 'ACTIVE',
},
});
await prisma.userCampaign.create({
data: {
userId: user.id,
campaignId: campaign.id,
totalChances: 2,
usedChances: 0,
},
});
const key = crypto.randomUUID();
// Call spin - even with failing SMS service, spin MUST succeed
const result = await service.spin(user.id, campaign.id, key);
expect(result.success).toBe(true);
expect(result.prize?.title).toBe('جایزه طلایی');
expect(result.remainingChances).toBe(1);
// Verify spin and userPrize exist in DB
const dbSpin = await prisma.spin.findUnique({
where: { idempotencyKey: key },
});
expect(dbSpin).toBeDefined();
expect(dbSpin?.status).toBe('SUCCESS');
const dbUserPrize = await prisma.userPrize.findUnique({
where: { spinId: dbSpin!.id },
});
expect(dbUserPrize).toBeDefined();
expect(dbUserPrize?.code).toBeDefined();
// Cleanup
await prisma.userPrize.deleteMany({ where: { campaignId: campaign.id } });
await prisma.spin.deleteMany({ where: { campaignId: campaign.id } });
await prisma.chanceTransaction.deleteMany({ where: { campaignId: campaign.id } });
await prisma.userCampaign.deleteMany({ where: { campaignId: campaign.id } });
await prisma.prize.deleteMany({ where: { campaignId: campaign.id } });
await prisma.campaign.delete({ where: { id: campaign.id } });
await prisma.user.delete({ where: { id: user.id } });
});
});
@@ -0,0 +1,6 @@
export interface SmsProviderInterface {
send(
mobile: string,
message: string,
): Promise<{ providerMessageId: string; raw: unknown }>;
}
+31
View File
@@ -0,0 +1,31 @@
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AdminSmsController } from './admin-sms.controller';
import { MockSmsProvider } from './providers/mock-sms.provider';
import { RayganSmsProvider } from './providers/raygan-sms.provider';
import { SmsProcessor } from './sms.processor';
import { SmsService } from './sms.service';
@Module({
imports: [
BullModule.registerQueue({
name: 'sms',
}),
],
controllers: [AdminSmsController],
providers: [
SmsService,
SmsProcessor,
{
provide: 'SMS_PROVIDER',
useFactory: (config: ConfigService) => {
const driver = config.get<string>('SMS_DRIVER', 'mock');
return driver === 'raygansms' ? new RayganSmsProvider(config) : new MockSmsProvider();
},
inject: [ConfigService],
},
],
exports: [SmsService],
})
export class SmsModule {}
+56
View File
@@ -0,0 +1,56 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Inject, Logger } from '@nestjs/common';
import { SmsStatus, SmsType } from '@prisma/client';
import { Job } from 'bullmq';
import { PrismaService } from '../../prisma/prisma.service';
import { SmsProviderInterface } from './sms.interface';
@Processor('sms')
export class SmsProcessor extends WorkerHost {
private readonly logger = new Logger(SmsProcessor.name);
constructor(
private readonly prisma: PrismaService,
@Inject('SMS_PROVIDER')
private readonly provider: SmsProviderInterface,
) {
super();
}
async process(job: Job): Promise<any> {
const { mobile, message, type = SmsType.WINNING, userId } = job.data;
const log = await this.prisma.smsLog.create({
data: {
userId: userId || null,
mobile,
type,
message,
provider: 'raygansms',
status: SmsStatus.PENDING,
},
});
try {
const result = await this.provider.send(mobile, message);
await this.prisma.smsLog.update({
where: { id: log.id },
data: {
status: SmsStatus.SENT,
providerResponse: result.raw as any,
},
});
return result;
} catch (err: any) {
this.logger.error(`SMS Job ${job.id} failed: ${err.message}`);
await this.prisma.smsLog.update({
where: { id: log.id },
data: {
status: SmsStatus.FAILED,
providerResponse: { error: err.message || String(err) },
},
});
throw err; // Trigger BullMQ retry
}
}
}
+142
View File
@@ -0,0 +1,142 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { SmsType } from '@prisma/client';
import { Queue } from 'bullmq';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(
@InjectQueue('sms') private readonly smsQueue: Queue,
private readonly prisma: PrismaService,
) {}
async enqueueOtp(mobile: string, code: string) {
const message = `کد ورود شما به سامانه گردونه شانس: ${code}`;
try {
await this.smsQueue.add(
'otp',
{ mobile, message, type: SmsType.OTP },
{
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
priority: 1, // High priority
removeOnComplete: true,
},
);
} catch (e: any) {
this.logger.error(`Failed to enqueue OTP SMS: ${e.message}`);
}
}
async enqueueSpinResult(spinId: string) {
try {
const spin = await this.prisma.spin.findUnique({
where: { id: spinId },
include: {
user: true,
prize: true,
userPrize: true,
campaign: true,
},
});
if (!spin || !spin.user) {
return;
}
const mobile = spin.user.mobile;
let message = '';
let type: SmsType = SmsType.WINNING;
if (!spin.prize || spin.prize.type === 'NO_PRIZE') {
type = SmsType.NO_PRIZE;
message = `همراه گرامی، متأسفانه در این دور از گردونه ${spin.campaign.title} برنده نشدید. شانس‌های دیگر خود را امتحان کنید!`;
} else {
type = SmsType.WINNING;
const codeText = spin.userPrize?.code ? ` کد جایزه: ${spin.userPrize.code}` : '';
if (spin.prize.smsTemplate) {
message = spin.prize.smsTemplate
.replace('{prize}', spin.prize.title)
.replace('{code}', spin.userPrize?.code || '')
.replace('{campaign}', spin.campaign.title);
} else {
message = `تبریک! شما در گردونه ${spin.campaign.title} برنده «${spin.prize.title}» شدید.${codeText}`;
}
}
await this.smsQueue.add(
'spin-result',
{
userId: spin.userId,
mobile,
message,
type,
},
{
attempts: 3,
backoff: { type: 'exponential', delay: 3000 },
removeOnComplete: true,
},
);
} catch (e: any) {
// Non-negotiable rule: Failure to enqueue SMS must NEVER fail or rollback the spin!
this.logger.error(`Failed to enqueue spin result SMS for spin ${spinId}: ${e.message}`);
}
}
async resendSms(logId: string) {
const log = await this.prisma.smsLog.findUnique({ where: { id: logId } });
if (!log) {
throw new NotFoundException('لاگ پیامک یافت نشد.');
}
await this.smsQueue.add(
'resend',
{
userId: log.userId,
mobile: log.mobile,
message: log.message,
type: log.type,
},
{
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
},
);
return { success: true, message: 'پیامک با موفقیت در صف ارسال مجدد قرار گرفت.' };
}
async getLogs(query: { page?: number; pageSize?: number; mobile?: string; status?: any }) {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 20;
const skip = (page - 1) * pageSize;
const where: any = {};
if (query.mobile) where.mobile = { contains: query.mobile };
if (query.status) where.status = query.status;
const [data, total] = await Promise.all([
this.prisma.smsLog.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.smsLog.count({ where }),
]);
return {
data,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
}
@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsUUID } from 'class-validator';
export class SpinRequestDto {
@ApiProperty({
example: 'd3b07384-d113-4ec3-a612-42fe1d3f56e9',
description: 'کلید یکتا تولیدشده توسط کلاینت برای تضمین Idempotency (UUID v4)',
})
@IsUUID('4', { message: 'کلید Idempotency باید یک UUID v4 معتبر باشد' })
@IsNotEmpty({ message: 'کلید Idempotency الزامی است' })
idempotencyKey: string;
}
@@ -0,0 +1,33 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import * as crypto from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
type PrismaTx = Omit<
PrismaService,
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
>;
@Injectable()
export class PrizeCodeGenerator {
/**
* Generates an uppercase unique prize voucher code e.g. MNT-7F92K
* Retries up to 5 times if collision occurs in the database.
*/
async generateUnique(tx: PrismaTx): Promise<string> {
for (let i = 0; i < 5; i++) {
const randomHex = crypto.randomBytes(4).toString('hex').toUpperCase();
const code = `MNT-${randomHex}`;
const existing = await tx.userPrize.findUnique({
where: { code },
select: { id: true },
});
if (!existing) {
return code;
}
}
throw new InternalServerErrorException('خطا در تولید کد یکتای جایزه. لطفاً مجدداً تلاش کنید.');
}
}
@@ -0,0 +1,230 @@
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { CampaignStatus, PrizeType, StockType } from '@prisma/client';
import * as crypto from 'crypto';
import { PrismaModule } from '../../prisma/prisma.module';
import { PrismaService } from '../../prisma/prisma.service';
import { CampaignAvailabilityService } from '../campaign/campaign-availability.service';
import { ChanceModule } from '../chance/chance.module';
import { SmsService } from '../sms/sms.service';
import { PrizeCodeGenerator } from './prize-code-generator';
import { SpinEngineService } from './spin-engine.service';
import { WeightedRandomSelector } from './weighted-random-selector';
describe('SpinEngine Concurrency & Idempotency Integration Tests (AC-08, AC-09)', () => {
let service: SpinEngineService;
let prisma: PrismaService;
const mockSmsService = {
enqueueSpinResult: jest.fn().mockResolvedValue(true),
enqueueOtp: jest.fn().mockResolvedValue(true),
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }),
PrismaModule,
ChanceModule,
],
providers: [
SpinEngineService,
CampaignAvailabilityService,
WeightedRandomSelector,
PrizeCodeGenerator,
{ provide: SmsService, useValue: mockSmsService },
],
}).compile();
service = module.get<SpinEngineService>(SpinEngineService);
prisma = module.get<PrismaService>(PrismaService);
});
afterAll(async () => {
await prisma.$disconnect();
});
it('AC-08 (Concurrency): 10 concurrent spins on Prize with stock=1 should yield EXACTLY 1 winner and stock=0 (never negative)', async () => {
const timestamp = Date.now();
// 1) Create Test Campaign
const campaign = await prisma.campaign.create({
data: {
title: `Concurrency Test Campaign ${timestamp}`,
slug: `concurrency-test-${timestamp}`,
startAt: new Date(Date.now() - 86400000), // yesterday
endAt: new Date(Date.now() + 86400000 * 30), // 30 days later
status: CampaignStatus.ACTIVE,
defaultChances: 5,
},
});
// 2) Create 1 High-Value Prize with Stock = 1 and High Weight
const iphonePrize = await prisma.prize.create({
data: {
campaignId: campaign.id,
title: 'تنها آیفون باقی‌مانده',
type: PrizeType.PHYSICAL,
stockType: StockType.LIMITED,
initialStock: 1,
remainingStock: 1,
weight: 1000,
sortOrder: 1,
segmentColor: '#FFD700',
},
});
// 3) Create Fallback Prize with Stock = 100 and Low Weight
const discountPrize = await prisma.prize.create({
data: {
campaignId: campaign.id,
title: 'کد تخفیف عمومی',
type: PrizeType.DISCOUNT_CODE,
stockType: StockType.LIMITED,
initialStock: 100,
remainingStock: 100,
weight: 1,
sortOrder: 2,
segmentColor: '#00CED1',
},
});
// 4) Create 10 distinct users with 5 chances each
const users = await Promise.all(
Array.from({ length: 10 }).map(async (_, idx) => {
const u = await prisma.user.create({
data: {
mobile: `0999${timestamp.toString().slice(-4)}${idx.toString().padStart(3, '0')}`,
status: 'ACTIVE',
},
});
await prisma.userCampaign.create({
data: {
userId: u.id,
campaignId: campaign.id,
totalChances: 5,
usedChances: 0,
},
});
return u;
}),
);
// 5) Fire 10 simultaneous Spin requests
const spinPromises = users.map((user) =>
service.spin(user.id, campaign.id, crypto.randomUUID()),
);
const results = await Promise.all(spinPromises);
// Verify all 10 spins succeeded
expect(results.length).toBe(10);
results.forEach((r) => {
expect(r.success).toBe(true);
expect(r.remainingChances).toBe(4);
});
// 6) CRITICAL VERIFICATION AC-08:
// How many won the iPhone?
const iphoneWinners = results.filter((r) => r.prize?.id === iphonePrize.id);
expect(iphoneWinners.length).toBe(1);
// Check remaining_stock in DB
const updatedIphone = await prisma.prize.findUnique({
where: { id: iphonePrize.id },
});
expect(updatedIphone?.remainingStock).toBe(0); // EXACTLY 0, NEVER NEGATIVE!
// Check other 9 got the fallback prize
const discountWinners = results.filter((r) => r.prize?.id === discountPrize.id);
expect(discountWinners.length).toBe(9);
const updatedDiscount = await prisma.prize.findUnique({
where: { id: discountPrize.id },
});
expect(updatedDiscount?.remainingStock).toBe(91); // 100 - 9 = 91
// Cleanup
await prisma.userPrize.deleteMany({ where: { campaignId: campaign.id } });
await prisma.spin.deleteMany({ where: { campaignId: campaign.id } });
await prisma.chanceTransaction.deleteMany({ where: { campaignId: campaign.id } });
await prisma.userCampaign.deleteMany({ where: { campaignId: campaign.id } });
await prisma.prize.deleteMany({ where: { campaignId: campaign.id } });
await prisma.campaign.delete({ where: { id: campaign.id } });
await prisma.user.deleteMany({ where: { id: { in: users.map((u) => u.id) } } });
});
it('AC-09 (Idempotency): Resending same request with identical idempotencyKey returns same result and consumes chance ONCE', async () => {
const timestamp = Date.now();
const campaign = await prisma.campaign.create({
data: {
title: `Idempotency Test ${timestamp}`,
slug: `idempotency-test-${timestamp}`,
startAt: new Date(Date.now() - 86400000),
endAt: new Date(Date.now() + 86400000 * 10),
status: CampaignStatus.ACTIVE,
defaultChances: 3,
},
});
const prize = await prisma.prize.create({
data: {
campaignId: campaign.id,
title: 'جایزه تستی',
type: PrizeType.DISCOUNT_CODE,
stockType: StockType.UNLIMITED,
weight: 100,
segmentColor: '#123456',
},
});
const user = await prisma.user.create({
data: {
mobile: `0998${timestamp.toString().slice(-7)}`,
status: 'ACTIVE',
},
});
await prisma.userCampaign.create({
data: {
userId: user.id,
campaignId: campaign.id,
totalChances: 3,
usedChances: 0,
},
});
const idempotencyKey = crypto.randomUUID();
// First request
const response1 = await service.spin(user.id, campaign.id, idempotencyKey);
expect(response1.success).toBe(true);
expect(response1.remainingChances).toBe(2);
// Second request with SAME idempotencyKey
const response2 = await service.spin(user.id, campaign.id, idempotencyKey);
expect(response2.success).toBe(true);
expect(response2.spinId).toBe(response1.spinId);
expect(response2.prize?.id).toBe(response1.prize?.id);
expect(response2.remainingChances).toBe(2); // Still 2, not decremented twice!
// Verify in database: total spins with this key = 1, usedChances = 1
const spinRecords = await prisma.spin.count({
where: { idempotencyKey },
});
expect(spinRecords).toBe(1);
const userCampaign = await prisma.userCampaign.findUnique({
where: { userId_campaignId: { userId: user.id, campaignId: campaign.id } },
});
expect(userCampaign?.usedChances).toBe(1);
// Cleanup
await prisma.userPrize.deleteMany({ where: { campaignId: campaign.id } });
await prisma.spin.deleteMany({ where: { campaignId: campaign.id } });
await prisma.chanceTransaction.deleteMany({ where: { campaignId: campaign.id } });
await prisma.userCampaign.deleteMany({ where: { campaignId: campaign.id } });
await prisma.prize.deleteMany({ where: { campaignId: campaign.id } });
await prisma.campaign.delete({ where: { id: campaign.id } });
await prisma.user.delete({ where: { id: user.id } });
});
});
@@ -0,0 +1,67 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtUserGuard } from '../../common/guards/jwt-user.guard';
import { SpinRequestDto } from './dto/spin-request.dto';
import { SpinEngineService } from './spin-engine.service';
@ApiTags('Spin Engine (گردونه شانس)')
@Controller('campaigns')
@UseGuards(JwtUserGuard)
@ApiBearerAuth('UserAuth')
export class SpinEngineController {
constructor(private readonly spinEngineService: SpinEngineService) {}
@Post(':id/spin')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 20, ttl: 60000 } }) // 20 requests per minute
@ApiOperation({
summary: 'چرخاندن گردونه شانس (تضمین اتمیک، جلوگیری از Concurrency و Idempotency)',
})
@ApiResponse({
status: 200,
description: 'نتیجه قرعه‌کشی با موفقیت توسط سرور تعیین و ثبت گردید.',
schema: {
example: {
success: true,
data: {
success: true,
spinId: 'd3b07384-d113-4ec3-a612-42fe1d3f56e9',
prize: {
id: 'prize-uuid',
title: 'کارت هدیه ۵۰۰ هزار تومانی',
segment: 3,
code: 'MNT-8F4KD2',
type: 'CREDIT',
image: 'http://localhost:3000/uploads/gift.png',
},
remainingChances: 2,
},
},
},
})
async spin(
@Param('id') campaignId: string,
@CurrentUser('id') userId: string,
@Body() dto: SpinRequestDto,
@Req() req: any,
) {
const ip = req.ip || req.headers['x-forwarded-for'];
const userAgent = req.headers['user-agent'];
return this.spinEngineService.spin(userId, campaignId, dto.idempotencyKey, {
ip,
userAgent,
});
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { CampaignModule } from '../campaign/campaign.module';
import { ChanceModule } from '../chance/chance.module';
import { SmsModule } from '../sms/sms.module';
import { PrizeCodeGenerator } from './prize-code-generator';
import { SpinEngineController } from './spin-engine.controller';
import { SpinEngineService } from './spin-engine.service';
import { WeightedRandomSelector } from './weighted-random-selector';
@Module({
imports: [CampaignModule, ChanceModule, SmsModule],
controllers: [SpinEngineController],
providers: [SpinEngineService, WeightedRandomSelector, PrizeCodeGenerator],
exports: [SpinEngineService, WeightedRandomSelector, PrizeCodeGenerator],
})
export class SpinEngineModule {}
@@ -0,0 +1,362 @@
import {
BadRequestException,
ConflictException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma, Prize, PrizeType, Spin, StockType, UserCampaign, UserPrize } from '@prisma/client';
import * as crypto from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { CampaignAvailabilityService } from '../campaign/campaign-availability.service';
import { SmsService } from '../sms/sms.service';
import { PrizeCodeGenerator } from './prize-code-generator';
import { WeightedRandomSelector } from './weighted-random-selector';
interface RawPrizeRow {
id: string;
campaign_id: string;
title: string;
description: string | null;
image: string | null;
type: PrizeType;
stock_type: StockType;
initial_stock: number | null;
remaining_stock: number | null;
weight: number;
status: string;
sort_order: number;
segment_color: string;
}
interface RawUserCampaignRow {
id: string;
user_id: string;
campaign_id: string;
total_chances: number;
used_chances: number;
}
export interface SpinResultOutput {
success: boolean;
spinId: string;
prize: {
id: string;
title: string;
segment: number;
code: string | null;
type: PrizeType;
image: string | null;
} | null;
remainingChances: number;
}
@Injectable()
export class SpinEngineService {
private readonly logger = new Logger(SpinEngineService.name);
constructor(
private readonly prisma: PrismaService,
private readonly availabilityService: CampaignAvailabilityService,
private readonly weightedRandomSelector: WeightedRandomSelector,
private readonly prizeCodeGenerator: PrizeCodeGenerator,
private readonly smsService: SmsService,
) {}
/**
* Main Concurrency-Safe, Atomic Spin Algorithm with Idempotency Fast-Path and Row-Level Locks
*/
async spin(
userId: string,
campaignId: string,
idempotencyKey: string,
metadata?: { ip?: string; userAgent?: string },
): Promise<SpinResultOutput> {
// 1) Fast-Path Idempotency Check outside of heavy database transactions (AC-09)
const existing = await this.prisma.spin.findUnique({
where: { idempotencyKey },
include: {
prize: true,
userPrize: true,
},
});
if (existing) {
this.logger.log(`[IDEMPOTENCY HIT] Returning existing spin result for key: ${idempotencyKey}`);
return this.formatExistingSpinResponse(existing, campaignId, userId);
}
// Execute with automatic retry on serialization/deadlock conflicts (up to 5 retries with jitter)
let retries = 5;
while (retries >= 0) {
try {
return await this.executeSpinTransaction(userId, campaignId, idempotencyKey, metadata);
} catch (err: any) {
const isSerializationError =
err.code === 'P2034' ||
err.message?.includes('could not serialize access') ||
err.message?.includes('40001') ||
err.message?.includes('deadlock') ||
(err.code === 'P2010' && String(err.meta?.code) === '40001');
if (isSerializationError && retries > 0) {
retries--;
const jitter = crypto.randomInt(30, 150);
this.logger.warn(`Serialization conflict during spin. Retrying in ${jitter}ms... (${retries} attempts left)`);
await new Promise((res) => setTimeout(res, jitter));
continue;
}
// If duplicate idempotency key was inserted concurrently (Defense in depth)
if (err.code === 'P2002' && (err.meta?.target?.includes('idempotency_key') || err.message?.includes('idempotency_key'))) {
const concurrentSpin = await this.prisma.spin.findUnique({
where: { idempotencyKey },
include: { prize: true, userPrize: true },
});
if (concurrentSpin) {
return this.formatExistingSpinResponse(concurrentSpin, campaignId, userId);
}
}
throw err;
}
}
throw new InternalServerErrorException('خطا در پردازش اسپین بر اثر ترافیک بالا. لطفاً مجدداً تلاش کنید.');
}
private async executeSpinTransaction(
userId: string,
campaignId: string,
idempotencyKey: string,
metadata?: { ip?: string; userAgent?: string },
): Promise<SpinResultOutput> {
return this.prisma
.$transaction(
async (tx) => {
// 2) Validate Campaign and active date window (AC-14)
const campaign = await tx.campaign.findUnique({
where: { id: campaignId },
});
if (!campaign) {
throw new NotFoundException('CAMPAIGN_NOT_FOUND');
}
if (!this.availabilityService.isCampaignAvailable(campaign)) {
const error = this.availabilityService.getAvailabilityError(campaign);
const errorMessages: Record<string, string> = {
CAMPAIGN_DRAFT: 'این کمپین در وضعیت پیش‌نویس (DRAFT) قرار دارد و هنوز فعال نشده است.',
CAMPAIGN_PAUSED: 'این کمپین موقتاً متوقف شده است.',
CAMPAIGN_NOT_STARTED: 'زمان شروع این کمپین هنوز فرا نرسیده است.',
CAMPAIGN_FINISHED: 'مهلت شرکت در این کمپین به پایان رسیده است.',
};
const message = errorMessages[error || ''] || error || 'این کمپین در حال حاضر فعال نمی‌باشد.';
throw new BadRequestException(message);
}
// 3) Lock user_campaigns row with SELECT ... FOR UPDATE
// This queues parallel spins from the same user and prevents race conditions on chances
const userCampaigns = await tx.$queryRaw<RawUserCampaignRow[]>`
SELECT id, user_id, campaign_id, total_chances, used_chances
FROM user_campaigns
WHERE user_id = ${userId} AND campaign_id = ${campaignId}
FOR UPDATE
`;
let userCampaign = userCampaigns[0];
let totalChances = userCampaign?.total_chances ?? 0;
let usedChances = userCampaign?.used_chances ?? 0;
// If record does not exist yet, auto-award defaultChances
if (!userCampaign) {
const defaultChances = campaign.defaultChances;
const newRecord = await tx.userCampaign.create({
data: {
userId,
campaignId,
totalChances: defaultChances,
usedChances: 0,
},
});
await tx.chanceTransaction.create({
data: {
userId,
campaignId,
amount: defaultChances,
type: 'SIGNUP_BONUS',
referenceType: 'campaign_entry',
description: 'شانس اولیه پیش‌فرض ورود به کمپین',
},
});
userCampaign = {
id: newRecord.id,
user_id: newRecord.userId,
campaign_id: newRecord.campaignId,
total_chances: newRecord.totalChances,
used_chances: 0,
};
totalChances = defaultChances;
usedChances = 0;
} else if (totalChances < campaign.defaultChances) {
const diff = campaign.defaultChances - totalChances;
await tx.userCampaign.update({
where: { id: userCampaign.id },
data: { totalChances: { increment: diff } },
});
await tx.chanceTransaction.create({
data: {
userId,
campaignId,
amount: diff,
type: 'ADMIN_ADJUSTMENT',
referenceType: 'campaign_default_chances_sync',
description: `همگام‌سازی خودکار با شانس پیش‌فرض کمپین (${campaign.defaultChances})`,
},
});
totalChances = campaign.defaultChances;
}
const remainingChances = totalChances - usedChances;
if (remainingChances <= 0) {
throw new BadRequestException('NO_CHANCE_LEFT'); // AC-02
}
// 4) Lock eligible prizes in STRICT ID ORDER to prevent deadlocks (AC-07, AC-08)
const eligiblePrizes = await tx.$queryRaw<RawPrizeRow[]>`
SELECT id, campaign_id, title, description, image, type, stock_type, initial_stock, remaining_stock, weight, status, sort_order, segment_color
FROM prizes
WHERE campaign_id = ${campaignId}
AND status = 'ACTIVE'
AND weight > 0
AND (stock_type = 'UNLIMITED' OR remaining_stock > 0)
ORDER BY id ASC
FOR UPDATE
`;
if (!eligiblePrizes || eligiblePrizes.length === 0) {
throw new ConflictException('NO_ACTIVE_PRIZE');
}
// 5) Weighted Random Selection strictly on Backend (AC-05, AC-06)
const selected = this.weightedRandomSelector.select(eligiblePrizes);
// 6) Decrement remaining_stock for LIMITED prize (AC-08: Race-safe guaranteed)
if (selected.stock_type === StockType.LIMITED) {
await tx.prize.update({
where: { id: selected.id },
data: { remainingStock: { decrement: 1 } },
});
}
// 7) Consume 1 Chance (Ledger + Snapshot atomically) (AC-03)
await tx.chanceTransaction.create({
data: {
userId,
campaignId,
amount: -1,
type: 'SPIN_CONSUME',
referenceType: 'spin',
description: `استفاده از شانس در قرعه‌کشی`,
},
});
await tx.userCampaign.update({
where: { id: userCampaign.id },
data: { usedChances: { increment: 1 } },
});
// 8) Record the Spin
const spin = await tx.spin.create({
data: {
userId,
campaignId,
prizeId: selected.id,
idempotencyKey,
status: 'SUCCESS',
ip: metadata?.ip || null,
userAgent: metadata?.userAgent || null,
},
});
// 9) If prize is NOT NO_PRIZE, generate unique voucher code and record user_prizes
let userPrize: UserPrize | null = null;
if (selected.type !== PrizeType.NO_PRIZE) {
const code = await this.prizeCodeGenerator.generateUnique(tx as any);
userPrize = await tx.userPrize.create({
data: {
userId,
campaignId,
prizeId: selected.id,
spinId: spin.id,
code,
status: 'NEW',
wonAt: new Date(),
},
});
}
return {
spin,
selectedPrize: selected,
userPrize,
remainingChances: remainingChances - 1,
};
},
{
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
timeout: 10000,
},
)
.then(async (result) => {
// 10) Enqueue SMS notification asynchronously after successful commit (AC-12)
// Failure here NEVER rolls back or impacts the spin!
try {
await this.smsService.enqueueSpinResult(result.spin.id);
} catch (smsErr) {
this.logger.error(`Failed to enqueue spin SMS (non-blocking): ${smsErr}`);
}
return {
success: true,
spinId: result.spin.id,
prize: {
id: result.selectedPrize.id,
title: result.selectedPrize.title,
segment: result.selectedPrize.sort_order,
code: result.userPrize?.code || null,
type: result.selectedPrize.type,
image: result.selectedPrize.image,
},
remainingChances: result.remainingChances,
};
});
}
private async formatExistingSpinResponse(
spin: Spin & { prize: Prize | null; userPrize: UserPrize | null },
campaignId: string,
userId: string,
): Promise<SpinResultOutput> {
const userCampaign = await this.prisma.userCampaign.findUnique({
where: { userId_campaignId: { userId, campaignId } },
});
const remainingChances =
(userCampaign?.totalChances ?? 0) - (userCampaign?.usedChances ?? 0);
return {
success: true,
spinId: spin.id,
prize: spin.prize
? {
id: spin.prize.id,
title: spin.prize.title,
segment: spin.prize.sortOrder,
code: spin.userPrize?.code || null,
type: spin.prize.type,
image: spin.prize.image,
}
: null,
remainingChances,
};
}
}
@@ -0,0 +1,49 @@
import { WeightedRandomSelector } from './weighted-random-selector';
describe('WeightedRandomSelector (Unit Tests)', () => {
let selector: WeightedRandomSelector;
beforeEach(() => {
selector = new WeightedRandomSelector();
});
it('should select single candidate reliably', () => {
const prizes = [{ id: '1', title: 'Gift Card', weight: 100 }];
const chosen = selector.select(prizes);
expect(chosen.id).toBe('1');
});
it('should select candidates proportionally according to weights over 10,000 runs', () => {
const prizes = [
{ id: 'rare', title: 'iPhone', weight: 10 }, // 10%
{ id: 'medium', title: 'Watch', weight: 30 }, // 30%
{ id: 'common', title: 'Coupon', weight: 60 }, // 60%
];
const counts: Record<string, number> = { rare: 0, medium: 0, common: 0 };
const totalRuns = 10000;
for (let i = 0; i < totalRuns; i++) {
const selected = selector.select(prizes);
counts[selected.id]++;
}
const rarePct = counts.rare / totalRuns;
const mediumPct = counts.medium / totalRuns;
const commonPct = counts.common / totalRuns;
// Tolerance ± 3.5%
expect(rarePct).toBeGreaterThan(0.065);
expect(rarePct).toBeLessThan(0.135);
expect(mediumPct).toBeGreaterThan(0.26);
expect(mediumPct).toBeLessThan(0.34);
expect(commonPct).toBeGreaterThan(0.56);
expect(commonPct).toBeLessThan(0.64);
});
it('should throw if candidate pool is empty', () => {
expect(() => selector.select([])).toThrow('Candidate pool cannot be empty.');
});
});
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
export interface CandidatePrize {
id: string;
title: string;
weight: number;
[key: string]: any;
}
@Injectable()
export class WeightedRandomSelector {
/**
* Selects a prize candidate based on cryptographic weighted random distribution.
* Uses crypto.randomInt (secure) instead of Math.random().
*/
select<T extends CandidatePrize>(candidates: T[]): T {
if (!candidates || candidates.length === 0) {
throw new Error('Candidate pool cannot be empty.');
}
if (candidates.length === 1) {
return candidates[0];
}
const totalWeight = candidates.reduce((sum, item) => sum + Math.max(0, item.weight), 0);
if (totalWeight <= 0) {
throw new Error('Total weight of eligible prizes must be greater than zero.');
}
// crypto.randomInt generates integer in range [min, max)
const rnd = crypto.randomInt(0, totalWeight);
let cursor = 0;
for (const item of candidates) {
cursor += Math.max(0, item.weight);
if (rnd < cursor) {
return item;
}
}
// Safety fallback
return candidates[candidates.length - 1];
}
}
@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { StorageProviderInterface } from '../storage.interface';
@Injectable()
export class LocalStorageProvider implements StorageProviderInterface {
private readonly root: string;
private readonly publicBaseUrl: string;
constructor(private readonly configService: ConfigService) {
this.root = this.configService.get<string>('STORAGE_LOCAL_ROOT', './uploads');
this.publicBaseUrl = this.configService.get<string>(
'STORAGE_PUBLIC_URL',
'http://localhost:3000/uploads',
);
}
async upload(
file: Buffer,
options: { folder: string; filename: string; mimeType: string },
): Promise<{ url: string; path: string }> {
const dir = path.join(process.cwd(), this.root, options.folder);
await fs.promises.mkdir(dir, { recursive: true });
const ext = path.extname(options.filename);
const finalName = `${Date.now()}-${crypto.randomUUID()}${ext}`;
const fullPath = path.join(dir, finalName);
await fs.promises.writeFile(fullPath, file);
const relPath = `${options.folder}/${finalName}`.replace(/\\/g, '/');
const url = `${this.publicBaseUrl}/${relPath}`;
return { url, path: relPath };
}
async delete(relPath: string): Promise<void> {
const fullPath = path.join(process.cwd(), this.root, relPath);
await fs.promises.unlink(fullPath).catch(() => undefined);
}
}
@@ -0,0 +1,55 @@
import {
BadRequestException,
Controller,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { StorageService } from './storage.service';
@ApiTags('Admin Uploads (آپلود فایل)')
@Controller('admin/uploads')
@UseGuards(JwtAdminGuard)
@ApiBearerAuth('AdminAuth')
export class StorageController {
constructor(private readonly storageService: StorageService) {}
@Post()
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => {
if (!file.mimetype.match(/\/(jpg|jpeg|png|webp|svg\+xml)$/)) {
return cb(
new BadRequestException('فقط فرمت‌های تصویری JPG, PNG, WebP و SVG مجاز می‌باشند.'),
false,
);
}
cb(null, true);
},
}),
)
@ApiOperation({ summary: 'آپلود تصویر برای لوگو، بنر یا عکس جایزه' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
},
},
},
})
async uploadFile(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('فایلی ارسال نشده است.');
}
return this.storageService.uploadFile(file, 'campaign-assets');
}
}
@@ -0,0 +1,8 @@
export interface StorageProviderInterface {
upload(
file: Buffer,
options: { folder: string; filename: string; mimeType: string },
): Promise<{ url: string; path: string }>;
delete(relPath: string): Promise<void>;
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { LocalStorageProvider } from './providers/local-storage.provider';
import { StorageController } from './storage.controller';
import { StorageService } from './storage.service';
@Module({
controllers: [StorageController],
providers: [
StorageService,
{
provide: 'STORAGE_PROVIDER',
useClass: LocalStorageProvider,
},
],
exports: [StorageService],
})
export class StorageModule {}
@@ -0,0 +1,25 @@
import { Inject, Injectable } from '@nestjs/common';
import { StorageProviderInterface } from './storage.interface';
@Injectable()
export class StorageService {
constructor(
@Inject('STORAGE_PROVIDER')
private readonly provider: StorageProviderInterface,
) {}
async uploadFile(
file: Express.Multer.File,
folder: string = 'general',
): Promise<{ url: string; path: string }> {
return this.provider.upload(file.buffer, {
folder,
filename: file.originalname,
mimeType: file.mimetype,
});
}
async deleteFile(relPath: string): Promise<void> {
return this.provider.delete(relPath);
}
}
@@ -0,0 +1,49 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { UserStatus } from '@prisma/client';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { UserService } from './user.service';
@ApiTags('Admin Users (مدیریت کاربران)')
@Controller('admin/users')
@UseGuards(JwtAdminGuard)
@UseInterceptors(AuditInterceptor)
@ApiBearerAuth('AdminAuth')
export class AdminUserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOperation({ summary: 'لیست کاربران با جستجوی شماره موبایل/نام و فیلتر وضعیت' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'status', enum: UserStatus, required: false })
async findAll(@Query() query: any) {
return this.userService.findAllForAdmin(query);
}
@Get(':id')
@ApiOperation({ summary: 'مشاهده پروفایل کامل، کمپین‌ها، شانس‌ها، اسپین‌ها و جوایز کاربر' })
async findOne(@Param('id') id: string) {
return this.userService.findOneForAdmin(id);
}
@Patch(':id/block')
@ApiOperation({ summary: 'مسدودسازی یا رفع مسدودیت کاربر' })
async toggleBlock(
@Param('id') id: string,
@Body('blocked') blocked: boolean,
) {
return this.userService.toggleBlockUser(id, blocked);
}
}
@@ -0,0 +1,36 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtUserGuard } from '../../common/guards/jwt-user.guard';
import { UserService } from './user.service';
@ApiTags('User (کاربر لاگین‌شده)')
@Controller('me')
@UseGuards(JwtUserGuard)
@ApiBearerAuth('UserAuth')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOperation({ summary: 'دریافت مشخصات حساب کاربری من' })
@ApiResponse({ status: 200, description: 'مشخصات کاربر جاری' })
async getMe(@CurrentUser('id') userId: string) {
return this.userService.getMe(userId);
}
@Get('chances')
@ApiOperation({ summary: 'دریافت وضعیت و تعداد شانس‌های کاربر در یک کمپین' })
@ApiQuery({ name: 'campaignId', required: false, description: 'شناسه کمپین مورد نظر' })
async getMyChances(
@CurrentUser('id') userId: string,
@Query('campaignId') campaignId?: string,
) {
return this.userService.getMyChances(userId, campaignId);
}
@Get('prizes')
@ApiOperation({ summary: 'تاریخچه تمام جوایز برنده شده توسط کاربر (جوایز من)' })
async getMyPrizes(@CurrentUser('id') userId: string) {
return this.userService.getMyPrizes(userId);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ChanceModule } from '../chance/chance.module';
import { AdminUserController } from './admin-user.controller';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
imports: [ChanceModule],
controllers: [UserController, AdminUserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
+189
View File
@@ -0,0 +1,189 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma, UserStatus } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { ChanceService } from '../chance/chance.service';
@Injectable()
export class UserService {
constructor(
private readonly prisma: PrismaService,
private readonly chanceService: ChanceService,
) {}
// ================= Client User Operations =================
async getMe(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
mobile: true,
name: true,
status: true,
lastLoginAt: true,
createdAt: true,
},
});
if (!user) {
throw new NotFoundException('کاربر یافت نشد.');
}
return user;
}
async getMyChances(userId: string, campaignId?: string) {
if (!campaignId) {
// Return list of chances across all joined campaigns
return this.prisma.userCampaign.findMany({
where: { userId },
include: {
campaign: { select: { id: true, title: true, slug: true, status: true } },
},
});
}
return this.chanceService.getUserChances(userId, campaignId);
}
async getMyPrizes(userId: string) {
return this.prisma.userPrize.findMany({
where: { userId },
orderBy: { wonAt: 'desc' },
include: {
prize: {
select: {
id: true,
title: true,
description: true,
image: true,
type: true,
expiresAt: true,
},
},
campaign: {
select: { id: true, title: true, slug: true },
},
},
});
}
// ================= Admin User Operations =================
async findAllForAdmin(query: {
page?: number;
pageSize?: number;
search?: string;
status?: UserStatus;
}) {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 20;
const skip = (page - 1) * pageSize;
const where: Prisma.UserWhereInput = {};
if (query.status) {
where.status = query.status;
} else if ((query as any).isBlocked !== undefined && (query as any).isBlocked !== '') {
const isBlocked = String((query as any).isBlocked) === 'true';
where.status = isBlocked ? UserStatus.BLOCKED : UserStatus.ACTIVE;
}
if (query.search) {
where.OR = [
{ mobile: { contains: query.search } },
{ name: { contains: query.search, mode: 'insensitive' } },
];
}
const [data, total] = await Promise.all([
this.prisma.user.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
select: {
id: true,
mobile: true,
name: true,
status: true,
lastLoginAt: true,
createdAt: true,
_count: {
select: {
userCampaigns: true,
spins: true,
userPrizes: true,
},
},
},
}),
this.prisma.user.count({ where }),
]);
return {
data,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async findOneForAdmin(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
include: {
userCampaigns: {
include: {
campaign: { select: { id: true, title: true, slug: true } },
},
},
userPrizes: {
orderBy: { wonAt: 'desc' },
include: {
prize: true,
campaign: { select: { title: true } },
},
},
spins: {
take: 20,
orderBy: { createdAt: 'desc' },
include: {
prize: { select: { title: true, type: true } },
campaign: { select: { title: true } },
},
},
chanceTransactions: {
take: 30,
orderBy: { createdAt: 'desc' },
include: {
campaign: { select: { title: true } },
createdBy: { select: { name: true } },
},
},
},
});
if (!user) {
throw new NotFoundException('کاربر یافت نشد.');
}
return user;
}
async toggleBlockUser(id: string, blocked: boolean) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException('کاربر یافت نشد.');
}
const newStatus = blocked ? UserStatus.BLOCKED : UserStatus.ACTIVE;
return this.prisma.user.update({
where: { id },
data: { status: newStatus },
select: { id: true, mobile: true, status: true },
});
}
}
@@ -0,0 +1,43 @@
import {
Controller,
Get,
Header,
Query,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { WinnerService } from './winner.service';
@ApiTags('Admin Spins (تاریخچه اسپین‌ها)')
@Controller('admin/spins')
@UseGuards(JwtAdminGuard)
@UseInterceptors(AuditInterceptor)
@ApiBearerAuth('AdminAuth')
export class AdminSpinController {
constructor(private readonly winnerService: WinnerService) {}
@Get()
@ApiOperation({ summary: 'لیست تاریخچه تمام چرخاندن‌های گردونه با فیلتر و جستجو' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'campaignId', required: false })
@ApiQuery({ name: 'prizeId', required: false })
@ApiQuery({ name: 'search', required: false })
async findAll(@Query() query: any) {
return this.winnerService.findAllSpins(query);
}
@Get('export')
@ApiOperation({ summary: 'خروجی فایل اکسل از تاریخچه تمام اسپین‌ها' })
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@Header('Content-Disposition', 'attachment; filename=spins.xlsx')
async export(@Query() query: any, @Res() res: Response) {
const buffer = await this.winnerService.exportSpinsExcel(query);
res.send(buffer);
}
}
@@ -0,0 +1,61 @@
import {
Body,
Controller,
Get,
Header,
Param,
Patch,
Query,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { CurrentAdmin } from '../../common/decorators/current-admin.decorator';
import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard';
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { UpdatePrizeStatusDto } from './dto/update-prize-status.dto';
import { WinnerService } from './winner.service';
@ApiTags('Admin Winners (مدیریت برندگان)')
@Controller('admin/user-prizes')
@UseGuards(JwtAdminGuard)
@UseInterceptors(AuditInterceptor)
@ApiBearerAuth('AdminAuth')
export class AdminWinnerController {
constructor(private readonly winnerService: WinnerService) {}
@Get()
@ApiOperation({ summary: 'لیست برندگان با فیلتر کمپین، وضعیت جایزه و جستجو' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
@ApiQuery({ name: 'campaignId', required: false })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'search', required: false })
async findAll(@Query() query: any) {
return this.winnerService.findAllUserPrizes(query);
}
@Patch(':id/status')
@ApiOperation({ summary: 'تغییر وضعیت جایزه برنده (تحویل شد / تماس گرفته شد / منقضی شد)' })
async updateStatus(
@Param('id') id: string,
@Body() dto: UpdatePrizeStatusDto,
@CurrentAdmin('id') adminId: string,
@Req() req: any,
) {
const ip = req.ip || req.headers['x-forwarded-for'];
return this.winnerService.updatePrizeStatus(id, dto, adminId, ip);
}
@Get('export')
@ApiOperation({ summary: 'خروجی فایل اکسل از لیست برندگان' })
@Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
@Header('Content-Disposition', 'attachment; filename=winners.xlsx')
async export(@Query() query: any, @Res() res: Response) {
const buffer = await this.winnerService.exportWinnersExcel(query);
res.send(buffer);
}
}
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { UserPrizeStatus } from '@prisma/client';
import { IsEnum, IsNotEmpty } from 'class-validator';
export class UpdatePrizeStatusDto {
@ApiProperty({
enum: UserPrizeStatus,
example: UserPrizeStatus.DELIVERED,
description: 'وضعیت جدید جایزه برنده',
})
@IsEnum(UserPrizeStatus, { message: 'وضعیت انتخاب شده نامعتبر است' })
@IsNotEmpty()
status: UserPrizeStatus;
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AdminSpinController } from './admin-spin.controller';
import { AdminWinnerController } from './admin-winner.controller';
import { WinnerService } from './winner.service';
@Module({
controllers: [AdminWinnerController, AdminSpinController],
providers: [WinnerService],
exports: [WinnerService],
})
export class WinnerModule {}
@@ -0,0 +1,282 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma, UserPrizeStatus } from '@prisma/client';
import * as ExcelJS from 'exceljs';
import { PrismaService } from '../../prisma/prisma.service';
import { UpdatePrizeStatusDto } from './dto/update-prize-status.dto';
@Injectable()
export class WinnerService {
private readonly logger = new Logger(WinnerService.name);
constructor(private readonly prisma: PrismaService) {}
// ================= Winner / UserPrize Operations =================
async findAllUserPrizes(query: {
page?: number;
pageSize?: number;
campaignId?: string;
status?: UserPrizeStatus;
search?: string;
}) {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 20;
const skip = (page - 1) * pageSize;
const where: Prisma.UserPrizeWhereInput = {};
if (query.campaignId) where.campaignId = query.campaignId;
if (query.status) where.status = query.status;
if (query.search) {
where.OR = [
{ code: { contains: query.search } },
{ user: { mobile: { contains: query.search } } },
{ user: { name: { contains: query.search, mode: 'insensitive' } } },
{ prize: { title: { contains: query.search, mode: 'insensitive' } } },
];
}
const [data, total] = await Promise.all([
this.prisma.userPrize.findMany({
where,
skip,
take: pageSize,
orderBy: { wonAt: 'desc' },
include: {
user: { select: { id: true, mobile: true, name: true } },
prize: { select: { id: true, title: true, type: true, image: true } },
campaign: { select: { id: true, title: true, slug: true } },
},
}),
this.prisma.userPrize.count({ where }),
]);
return {
data,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async updatePrizeStatus(id: string, dto: UpdatePrizeStatusDto, adminId: string, ip?: string) {
const userPrize = await this.prisma.userPrize.findUnique({
where: { id },
include: {
user: { select: { mobile: true } },
prize: { select: { title: true, type: true } },
},
});
if (!userPrize) {
throw new NotFoundException('جایزه برنده مورد نظر یافت نشد.');
}
const oldStatus = userPrize.status;
const newStatus = dto.status;
const updated = await this.prisma.userPrize.update({
where: { id },
data: {
status: newStatus,
redeemedAt: ['DELIVERED', 'USED'].includes(newStatus) ? new Date() : userPrize.redeemedAt,
},
});
// Record in AdminLog
await this.prisma.adminLog.create({
data: {
adminId,
action: 'user_prize.update_status',
entityType: 'user_prize',
entityId: id,
oldValue: { status: oldStatus },
newValue: {
status: newStatus,
userMobile: userPrize.user.mobile,
prizeTitle: userPrize.prize.title,
},
ip,
},
});
return updated;
}
async exportWinnersExcel(query: { campaignId?: string; status?: UserPrizeStatus }) {
const where: Prisma.UserPrizeWhereInput = {};
if (query.campaignId) where.campaignId = query.campaignId;
if (query.status) where.status = query.status;
const items = await this.prisma.userPrize.findMany({
where,
orderBy: { wonAt: 'desc' },
include: {
user: { select: { mobile: true, name: true } },
prize: { select: { title: true, type: true } },
campaign: { select: { title: true } },
},
});
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Winners');
worksheet.columns = [
{ header: 'شناسه برد', key: 'id', width: 36 },
{ header: 'شماره موبایل', key: 'mobile', width: 16 },
{ header: 'نام کاربر', key: 'name', width: 20 },
{ header: 'کمپین', key: 'campaign', width: 25 },
{ header: 'عنوان جایزه', key: 'prize', width: 25 },
{ header: 'نوع جایزه', key: 'type', width: 16 },
{ header: 'کد جایزه', key: 'code', width: 20 },
{ header: 'وضعیت', key: 'status', width: 16 },
{ header: 'تاریخ برد', key: 'wonAt', width: 24 },
{ header: 'تاریخ تحویل / مصرف', key: 'redeemedAt', width: 24 },
];
items.forEach((item) => {
worksheet.addRow({
id: item.id,
mobile: item.user.mobile,
name: item.user.name || '-',
campaign: item.campaign.title,
prize: item.prize.title,
type: item.prize.type,
code: item.code || '-',
status: item.status,
wonAt: item.wonAt.toISOString(),
redeemedAt: item.redeemedAt ? item.redeemedAt.toISOString() : '-',
});
});
return workbook.xlsx.writeBuffer();
}
// ================= Spin Operations =================
async findAllSpins(query: {
page?: number;
pageSize?: number;
campaignId?: string;
prizeId?: string;
search?: string;
}) {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 20;
const skip = (page - 1) * pageSize;
const where: Prisma.SpinWhereInput = {};
if (query.campaignId) where.campaignId = query.campaignId;
if (query.prizeId) where.prizeId = query.prizeId;
if (query.search) {
where.OR = [
{ user: { mobile: { contains: query.search } } },
{ prize: { title: { contains: query.search, mode: 'insensitive' } } },
{ idempotencyKey: { contains: query.search } },
];
}
const [data, total] = await Promise.all([
this.prisma.spin.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, mobile: true, name: true } },
prize: { select: { id: true, title: true, type: true, segmentColor: true } },
campaign: { select: { id: true, title: true } },
userPrize: { select: { code: true, status: true } },
},
}),
this.prisma.spin.count({ where }),
]);
return {
data,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
async exportSpinsExcel(query: { campaignId?: string }) {
const where: Prisma.SpinWhereInput = {};
if (query.campaignId) where.campaignId = query.campaignId;
const items = await this.prisma.spin.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
user: { select: { mobile: true, name: true } },
prize: { select: { title: true, type: true } },
campaign: { select: { title: true } },
},
});
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Spins');
worksheet.columns = [
{ header: 'شناسه اسپین', key: 'id', width: 36 },
{ header: 'موبایل کاربر', key: 'mobile', width: 16 },
{ header: 'کمپین', key: 'campaign', width: 25 },
{ header: 'جایزه تعیین‌شده', key: 'prize', width: 25 },
{ header: 'نوع جایزه', key: 'type', width: 16 },
{ header: 'کلید Idempotency', key: 'key', width: 36 },
{ header: 'IP درخواست', key: 'ip', width: 18 },
{ header: 'تاریخ و ساعت', key: 'createdAt', width: 24 },
];
items.forEach((item) => {
worksheet.addRow({
id: item.id,
mobile: item.user.mobile,
campaign: item.campaign.title,
prize: item.prize?.title || 'بدون جایزه',
type: item.prize?.type || '-',
key: item.idempotencyKey,
ip: item.ip || '-',
createdAt: item.createdAt.toISOString(),
});
});
return workbook.xlsx.writeBuffer();
}
// ================= Scheduled Tasks =================
/**
* Daily Cron at midnight: Marks expired vouchers/prizes as EXPIRED
*/
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async handlePrizeExpirations() {
this.logger.log('[CRON] Running daily prize expiration check...');
const now = new Date();
const expiredResult = await this.prisma.userPrize.updateMany({
where: {
status: { in: [UserPrizeStatus.NEW, UserPrizeStatus.ACTIVE_CODE] },
prize: {
expiresAt: { lt: now },
},
},
data: {
status: UserPrizeStatus.EXPIRED,
},
});
this.logger.log(`[CRON] ${expiredResult.count} user prize(s) marked as EXPIRED.`);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+25
View File
@@ -0,0 +1,25 @@
import { INestApplication, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
super({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
async enableShutdownHooks(app: INestApplication) {
process.on('beforeExit', async () => {
await app.close();
});
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"@common/*": ["src/common/*"],
"@modules/*": ["src/modules/*"],
"@config/*": ["src/config/*"],
"@prisma-module/*": ["src/prisma/*"]
}
}
}