feat(ux/sms/engine): complete Persian UX writing overhaul, Wolog SMS integration and respin support

This commit is contained in:
s.esmaeili
2026-10-10 01:22:48 +03:30
parent c29d0e31b1
commit 0aafd098f8
223 changed files with 14421 additions and 1846 deletions
+2259 -575
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,118 @@
-- CreateEnum
CREATE TYPE "AccessScopeType" AS ENUM ('ALL', 'CUSTOM_CAMPAIGNS');
-- AlterTable
ALTER TABLE "admins" ADD COLUMN "is_super_admin" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "last_login_at" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "campaigns" ADD COLUMN "workspace_id" TEXT;
-- CreateTable
CREATE TABLE "workspaces" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"is_system" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"module" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"role_id" TEXT NOT NULL,
"permission_id" TEXT NOT NULL,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id")
);
-- CreateTable
CREATE TABLE "admin_role_assignments" (
"id" TEXT NOT NULL,
"admin_id" TEXT NOT NULL,
"role_id" TEXT NOT NULL,
"assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "admin_role_assignments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "admin_workspace_assignments" (
"id" TEXT NOT NULL,
"admin_id" TEXT NOT NULL,
"workspace_id" TEXT NOT NULL,
"scope_type" "AccessScopeType" NOT NULL DEFAULT 'ALL',
"specific_campaign_ids" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "admin_workspace_assignments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "workspaces_slug_key" ON "workspaces"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "roles_code_key" ON "roles"("code");
-- CreateIndex
CREATE UNIQUE INDEX "permissions_code_key" ON "permissions"("code");
-- CreateIndex
CREATE INDEX "permissions_module_idx" ON "permissions"("module");
-- CreateIndex
CREATE UNIQUE INDEX "admin_role_assignments_admin_id_role_id_key" ON "admin_role_assignments"("admin_id", "role_id");
-- CreateIndex
CREATE UNIQUE INDEX "admin_workspace_assignments_admin_id_workspace_id_key" ON "admin_workspace_assignments"("admin_id", "workspace_id");
-- CreateIndex
CREATE INDEX "campaigns_workspace_id_idx" ON "campaigns"("workspace_id");
-- AddForeignKey
ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "admin_role_assignments" ADD CONSTRAINT "admin_role_assignments_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "admin_role_assignments" ADD CONSTRAINT "admin_role_assignments_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "admin_workspace_assignments" ADD CONSTRAINT "admin_workspace_assignments_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "admin_workspace_assignments" ADD CONSTRAINT "admin_workspace_assignments_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+1882 -92
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+64 -1
View File
@@ -130,6 +130,8 @@ exports.Prisma.AdminScalarFieldEnum = {
passwordHash: 'passwordHash',
role: 'role',
status: 'status',
isSuperAdmin: 'isSuperAdmin',
lastLoginAt: 'lastLoginAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -171,6 +173,7 @@ exports.Prisma.CampaignScalarFieldEnum = {
ctaText: 'ctaText',
wheelDesign: 'wheelDesign',
settings: 'settings',
workspaceId: 'workspaceId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -266,6 +269,55 @@ exports.Prisma.AdminLogScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.WorkspaceScalarFieldEnum = {
id: 'id',
name: 'name',
slug: 'slug',
description: 'description',
status: 'status',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RoleScalarFieldEnum = {
id: 'id',
code: 'code',
name: 'name',
description: 'description',
isSystem: 'isSystem',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.PermissionScalarFieldEnum = {
id: 'id',
code: 'code',
module: 'module',
name: 'name',
description: 'description'
};
exports.Prisma.RolePermissionScalarFieldEnum = {
roleId: 'roleId',
permissionId: 'permissionId'
};
exports.Prisma.AdminRoleAssignmentScalarFieldEnum = {
id: 'id',
adminId: 'adminId',
roleId: 'roleId',
assignedAt: 'assignedAt'
};
exports.Prisma.AdminWorkspaceAssignmentScalarFieldEnum = {
id: 'id',
adminId: 'adminId',
workspaceId: 'workspaceId',
scopeType: 'scopeType',
specificCampaignIds: 'specificCampaignIds',
createdAt: 'createdAt'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -382,6 +434,11 @@ exports.SmsStatus = exports.$Enums.SmsStatus = {
FAILED: 'FAILED'
};
exports.AccessScopeType = exports.$Enums.AccessScopeType = {
ALL: 'ALL',
CUSTOM_CAMPAIGNS: 'CUSTOM_CAMPAIGNS'
};
exports.Prisma.ModelName = {
Admin: 'Admin',
User: 'User',
@@ -393,7 +450,13 @@ exports.Prisma.ModelName = {
Spin: 'Spin',
UserPrize: 'UserPrize',
SmsLog: 'SmsLog',
AdminLog: 'AdminLog'
AdminLog: 'AdminLog',
Workspace: 'Workspace',
Role: 'Role',
Permission: 'Permission',
RolePermission: 'RolePermission',
AdminRoleAssignment: 'AdminRoleAssignment',
AdminWorkspaceAssignment: 'AdminWorkspaceAssignment'
};
/**
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "prisma-client-9c4b98c054c4413303a6fe7479f16a8ffbbc92ed96b3de050b8208db40acf7bd",
"name": "prisma-client-729d66798105c30ac604d26a6c005aea0a1618ad5abaf33082e4e12fa468731d",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",
+95 -2
View File
@@ -22,6 +22,11 @@ enum AdminRole {
SUPPORT
}
enum AccessScopeType {
ALL
CUSTOM_CAMPAIGNS
}
enum UserStatus {
ACTIVE
BLOCKED
@@ -112,11 +117,15 @@ model Admin {
passwordHash String @map("password_hash")
role AdminRole @default(OPERATOR)
status AdminStatus @default(ACTIVE)
isSuperAdmin Boolean @default(false) @map("is_super_admin")
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
auditLogs AdminLog[]
chanceTxns ChanceTransaction[] @relation("AdminCreatedTxn")
auditLogs AdminLog[]
chanceTxns ChanceTransaction[] @relation("AdminCreatedTxn")
roles AdminRoleAssignment[]
workspaceAssignments AdminWorkspaceAssignment[]
@@map("admins")
}
@@ -172,6 +181,8 @@ model Campaign {
ctaText String? @map("cta_text")
wheelDesign WheelDesign @default(CLASSIC_CASINO) @map("wheel_design")
settings Json? // primaryColor, secondaryColor, headerText, buttonText, headerImage, celebrationEffect...
workspaceId String? @map("workspace_id")
workspace Workspace? @relation(fields: [workspaceId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -182,6 +193,7 @@ model Campaign {
userPrizes UserPrize[]
@@index([status, startAt, endAt])
@@index([workspaceId])
@@map("campaigns")
}
@@ -318,3 +330,84 @@ model AdminLog {
@@index([entityType, entityId])
@@map("admin_logs")
}
// ==================== WORKSPACE & RBAC MODELS ====================
model Workspace {
id String @id @default(uuid())
name String
slug String @unique
description String?
status String @default("ACTIVE")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
campaigns Campaign[]
adminAssignments AdminWorkspaceAssignment[]
@@map("workspaces")
}
model Role {
id String @id @default(uuid())
code String @unique // e.g. "SUPER_ADMIN", "OPERATOR", "SUPPORT"
name String // نام فارسی
description String?
isSystem Boolean @default(false) @map("is_system")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
permissions RolePermission[]
admins AdminRoleAssignment[]
@@map("roles")
}
model Permission {
id String @id @default(uuid())
code String @unique // e.g. "campaigns:read", "campaigns:create"
module String // e.g. "campaigns", "users", "prizes", "reports", "admins"
name String // نام فارسی
description String?
roles RolePermission[]
@@index([module])
@@map("permissions")
}
model RolePermission {
roleId String @map("role_id")
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permissionId String @map("permission_id")
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
@@map("role_permissions")
}
model AdminRoleAssignment {
id String @id @default(uuid())
adminId String @map("admin_id")
admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade)
roleId String @map("role_id")
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
assignedAt DateTime @default(now()) @map("assigned_at")
@@unique([adminId, roleId])
@@map("admin_role_assignments")
}
model AdminWorkspaceAssignment {
id String @id @default(uuid())
adminId String @map("admin_id")
admin Admin @relation(fields: [adminId], references: [id], onDelete: Cascade)
workspaceId String @map("workspace_id")
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
scopeType AccessScopeType @default(ALL) @map("scope_type")
specificCampaignIds Json? @map("specific_campaign_ids")
createdAt DateTime @default(now()) @map("created_at")
@@unique([adminId, workspaceId])
@@map("admin_workspace_assignments")
}
+64 -1
View File
@@ -130,6 +130,8 @@ exports.Prisma.AdminScalarFieldEnum = {
passwordHash: 'passwordHash',
role: 'role',
status: 'status',
isSuperAdmin: 'isSuperAdmin',
lastLoginAt: 'lastLoginAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -171,6 +173,7 @@ exports.Prisma.CampaignScalarFieldEnum = {
ctaText: 'ctaText',
wheelDesign: 'wheelDesign',
settings: 'settings',
workspaceId: 'workspaceId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
@@ -266,6 +269,55 @@ exports.Prisma.AdminLogScalarFieldEnum = {
createdAt: 'createdAt'
};
exports.Prisma.WorkspaceScalarFieldEnum = {
id: 'id',
name: 'name',
slug: 'slug',
description: 'description',
status: 'status',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.RoleScalarFieldEnum = {
id: 'id',
code: 'code',
name: 'name',
description: 'description',
isSystem: 'isSystem',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
};
exports.Prisma.PermissionScalarFieldEnum = {
id: 'id',
code: 'code',
module: 'module',
name: 'name',
description: 'description'
};
exports.Prisma.RolePermissionScalarFieldEnum = {
roleId: 'roleId',
permissionId: 'permissionId'
};
exports.Prisma.AdminRoleAssignmentScalarFieldEnum = {
id: 'id',
adminId: 'adminId',
roleId: 'roleId',
assignedAt: 'assignedAt'
};
exports.Prisma.AdminWorkspaceAssignmentScalarFieldEnum = {
id: 'id',
adminId: 'adminId',
workspaceId: 'workspaceId',
scopeType: 'scopeType',
specificCampaignIds: 'specificCampaignIds',
createdAt: 'createdAt'
};
exports.Prisma.SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -382,6 +434,11 @@ exports.SmsStatus = exports.$Enums.SmsStatus = {
FAILED: 'FAILED'
};
exports.AccessScopeType = exports.$Enums.AccessScopeType = {
ALL: 'ALL',
CUSTOM_CAMPAIGNS: 'CUSTOM_CAMPAIGNS'
};
exports.Prisma.ModelName = {
Admin: 'Admin',
User: 'User',
@@ -393,7 +450,13 @@ exports.Prisma.ModelName = {
Spin: 'Spin',
UserPrize: 'UserPrize',
SmsLog: 'SmsLog',
AdminLog: 'AdminLog'
AdminLog: 'AdminLog',
Workspace: 'Workspace',
Role: 'Role',
Permission: 'Permission',
RolePermission: 'RolePermission',
AdminRoleAssignment: 'AdminRoleAssignment',
AdminWorkspaceAssignment: 'AdminWorkspaceAssignment'
};
/**
@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { AccessScopeType, AdminRole, AdminStatus } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateAdminDto } from './dto/create-admin.dto';
import { CreateRoleDto } from './dto/create-role.dto';
+2
View File
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtUserStrategy } from './jwt-user.strategy';
import { SmsModule } from '../sms/sms.module';
@Module({
imports: [
@@ -19,6 +20,7 @@ import { JwtUserStrategy } from './jwt-user.strategy';
}),
inject: [ConfigService],
}),
SmsModule,
],
controllers: [AuthController],
providers: [AuthService, JwtUserStrategy],
@@ -4,11 +4,17 @@ import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaService } from '../../prisma/prisma.service';
import { AuthService } from './auth.service';
import { SmsService } from '../sms/sms.service';
describe('AuthService (OTP Logic Unit Tests)', () => {
let service: AuthService;
let prisma: PrismaService;
const mockSmsService = {
enqueueOtp: jest.fn().mockResolvedValue(true),
enqueueSpinResult: jest.fn().mockResolvedValue(true),
};
const mockPrisma = {
otpCode: {
findFirst: jest.fn(),
@@ -41,6 +47,7 @@ describe('AuthService (OTP Logic Unit Tests)', () => {
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwt },
{ provide: ConfigService, useValue: mockConfig },
{ provide: SmsService, useValue: mockSmsService },
],
}).compile();
+5 -9
View File
@@ -11,6 +11,7 @@ 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';
import { SmsService } from '../sms/sms.service';
@Injectable()
export class AuthService {
@@ -20,6 +21,7 @@ export class AuthService {
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly smsService: SmsService,
) {}
/**
@@ -30,15 +32,6 @@ export class AuthService {
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: {
@@ -87,6 +80,9 @@ export class AuthService {
this.logger.log(`[DEV OTP] Mobile: ${mobile} -> Code: ${rawCode}`);
}
// Call SmsService to actually send the OTP
await this.smsService.enqueueOtp(mobile, rawCode);
return {
success: true,
expiresIn: 120,
@@ -59,7 +59,7 @@ export class AdminCampaignController {
@Delete(':id')
@RequirePermission('campaigns:delete')
@ApiOperation({ summary: 'حذف کمپین (در صورت عدم وجود اسپین ثبت‌شده)' })
@ApiOperation({ summary: 'حذف کمپین (در صورت عدم وجود چرخش ثبت‌شده)' })
async remove(@Param('id') id: string) {
return this.campaignService.remove(id);
}
@@ -272,7 +272,7 @@ export class CampaignService {
if (spinCount > 0) {
// Disallow hard delete if spins exist
throw new BadRequestException(
'این کمپین دارای رکورد قرعه‌کشی و اسپین است و امکان حذف فیزیکی آن وجود ندارد. وضعیت آن را به پایان‌یافته یا متوقف تغییر دهید.',
'این گردونه دارای سابقه چرخش و قرعه‌کشی است و امکان حذف فیزیکی آن وجود ندارد. وضعیت آن را به پایان‌یافته یا متوقف تغییر دهید.',
);
}
return this.prisma.campaign.delete({ where: { id } });
@@ -0,0 +1,56 @@
import { Injectable, Logger } from '@nestjs/common';
import { SmsProviderInterface } from '../sms.interface';
import * as os from 'os';
@Injectable()
export class WologSmsProvider implements SmsProviderInterface {
private readonly logger = new Logger(WologSmsProvider.name);
private readonly baseUrl = 'http://185.231.112.101';
private readonly secretKey = 'f21debfb-83ac-4624-a2f4-f08375043181';
async send(mobile: string, message: string): Promise<{ providerMessageId: string; raw: unknown }> {
try {
const response = await fetch(`${this.baseUrl}/api/createsms`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'secret_key': this.secretKey,
},
body: JSON.stringify({
phone: mobile,
host: process.env.SMS_WOLOG_HOST || 'montak',
name: process.env.SMS_WOLOG_NAME || 'montak',
text: message.trim(),
}),
});
let data;
const text = await response.text();
try {
// The API returns debug HTML text before the JSON object sometimes
const jsonMatch = text.match(/({.*})/s);
if (jsonMatch) {
data = JSON.parse(jsonMatch[1]);
} else {
data = { response: text };
}
} catch (e) {
data = { response: text };
}
if (!response.ok && !text.includes('"sent"')) {
throw new Error(`API responded with status ${response.status}: ${JSON.stringify(data)}`);
}
const messageId = (data as any)?.id || (data as any)?.messageId || `WOLOG-${Date.now()}`;
return {
providerMessageId: String(messageId),
raw: data,
};
} catch (err: any) {
this.logger.error(`WologSMS send failure to ${mobile}: ${err.message}`);
throw err;
}
}
}
+4 -1
View File
@@ -4,6 +4,7 @@ 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 { WologSmsProvider } from './providers/wolog-sms.provider';
import { SmsProcessor } from './sms.processor';
import { SmsService } from './sms.service';
@@ -21,7 +22,9 @@ import { SmsService } from './sms.service';
provide: 'SMS_PROVIDER',
useFactory: (config: ConfigService) => {
const driver = config.get<string>('SMS_DRIVER', 'mock');
return driver === 'raygansms' ? new RayganSmsProvider(config) : new MockSmsProvider();
if (driver === 'wolog') return new WologSmsProvider();
if (driver === 'raygansms') return new RayganSmsProvider(config);
return new MockSmsProvider();
},
inject: [ConfigService],
},
+1 -1
View File
@@ -26,7 +26,7 @@ export class SmsProcessor extends WorkerHost {
mobile,
type,
message,
provider: 'raygansms',
provider: 'wolog',
status: SmsStatus.PENDING,
},
});
+46 -10
View File
@@ -14,7 +14,7 @@ export class SmsService {
) {}
async enqueueOtp(mobile: string, code: string) {
const message = `کد ورود شما به سامانه گردونه شانس: ${code}`;
const message = `کد ورود شما به سامانه گردونه شانس منتک: ${code}\nمنتک`;
try {
await this.smsQueue.add(
'otp',
@@ -31,8 +31,25 @@ export class SmsService {
}
}
async enqueueSpinResult(spinId: string) {
async enqueueSpinResult(spinId: string, options?: { delayMs?: number }) {
try {
const jobId = `spin-result-${spinId}`;
// De-duplicate / promote delayed job if called upon modal appearance
const existingJob = await this.smsQueue.getJob(jobId);
if (existingJob) {
const state = await existingJob.getState();
if (state === 'active' || state === 'completed') {
return;
}
if ((!options?.delayMs || options.delayMs === 0) && state === 'delayed') {
await existingJob.promote();
this.logger.log(`Promoted delayed SMS job ${jobId} to send immediately upon modal appearance.`);
return;
}
return;
}
const spin = await this.prisma.spin.findUnique({
where: { id: spinId },
include: {
@@ -53,17 +70,33 @@ export class SmsService {
if (!spin.prize || spin.prize.type === 'NO_PRIZE') {
type = SmsType.NO_PRIZE;
message = `همراه گرامی، متأسفانه در این دور از گردونه ${spin.campaign.title} برنده نشدید. شانس‌های دیگر خود را امتحان کنید!`;
message = `همراه گرامی، متأسفانه در این دور از گردونه ${spin.campaign.title} منتک برنده نشدید. شانس‌های دیگر خود را امتحان کنید!\nمنتک`;
} 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);
const isRespin =
spin.prize.type === 'OTHER' ||
Boolean(
spin.prize.title &&
(spin.prize.title.includes('شانس مجدد') ||
spin.prize.title.includes('چرخش مجدد') ||
spin.prize.title.includes('اسپین مجدد') ||
spin.prize.title.includes('فرصت مجدد') ||
spin.prize.title.toLowerCase().includes('respin') ||
spin.prize.title.toLowerCase().includes('free spin')),
);
if (isRespin) {
message = `تبریک! شما در گردونه ${spin.campaign.title} منتک یک «شانس مجدد» برنده شدید. همین حالا گردونه را دوباره بچرخانید!\nمنتک`;
} else {
message = `تبریک! شما در گردونه ${spin.campaign.title} برنده «${spin.prize.title}» شدید.${codeText}`;
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}\nمنتک`;
}
}
}
@@ -74,8 +107,11 @@ export class SmsService {
mobile,
message,
type,
spinId,
},
{
jobId,
delay: options?.delayMs || 0,
attempts: 3,
backoff: { type: 'exponential', delay: 3000 },
removeOnComplete: true,
@@ -64,4 +64,16 @@ export class SpinEngineController {
userAgent,
});
}
@Post('spins/:spinId/notify')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'ارسال فوری پیامک نتیجه گردونه پس از توقف انیمیشن و نمایش مودال جایزه',
})
async notifySpinResult(
@Param('spinId') spinId: string,
@CurrentUser('id') userId: string,
) {
return this.spinEngineService.notifySpinResult(spinId, userId);
}
}
@@ -48,8 +48,10 @@ export interface SpinResultOutput {
code: string | null;
type: PrizeType;
image: string | null;
isRespin?: boolean;
} | null;
remainingChances: number;
isRespin?: boolean;
}
@Injectable()
@@ -123,7 +125,7 @@ export class SpinEngineService {
}
}
throw new InternalServerErrorException('خطا در پردازش اسپین بر اثر ترافیک بالا. لطفاً مجدداً تلاش کنید.');
throw new InternalServerErrorException('خطا در ثبت چرخش گردونه به دلیل ترافیک بالا. لطفاً مجدداً تلاش کنید.');
}
private async executeSpinTransaction(
@@ -248,6 +250,19 @@ export class SpinEngineService {
});
}
// Check if this prize is RESPIN / EXTRA CHANCE (شانس مجدد)
const isRespin =
selected.type === PrizeType.OTHER ||
Boolean(
selected.title &&
(selected.title.includes('شانس مجدد') ||
selected.title.includes('چرخش مجدد') ||
selected.title.includes('اسپین مجدد') ||
selected.title.includes('فرصت مجدد') ||
selected.title.toLowerCase().includes('respin') ||
selected.title.toLowerCase().includes('free spin')),
);
// 7) Consume 1 Chance (Ledger + Snapshot atomically) (AC-03)
await tx.chanceTransaction.create({
data: {
@@ -260,10 +275,33 @@ export class SpinEngineService {
},
});
await tx.userCampaign.update({
where: { id: userCampaign.id },
data: { usedChances: { increment: 1 } },
});
if (isRespin) {
// Grant +1 extra chance immediately (Respin gives another turn)
await tx.chanceTransaction.create({
data: {
userId,
campaignId,
amount: 1,
type: 'CAMPAIGN_BONUS',
referenceType: 'spin_respin_prize',
description: `اهدای شانس مجدد گردونه (${selected.title})`,
},
});
// Increment both usedChances and totalChances so remainingChances is preserved!
await tx.userCampaign.update({
where: { id: userCampaign.id },
data: {
usedChances: { increment: 1 },
totalChances: { increment: 1 },
},
});
} else {
await tx.userCampaign.update({
where: { id: userCampaign.id },
data: { usedChances: { increment: 1 } },
});
}
// 8) Record the Spin
const spin = await tx.spin.create({
@@ -278,10 +316,10 @@ export class SpinEngineService {
},
});
// 9) If prize is NOT NO_PRIZE, generate unique voucher code and record user_prizes
// 9) If prize is NOT NO_PRIZE, record user_prizes
let userPrize: UserPrize | null = null;
if (selected.type !== PrizeType.NO_PRIZE) {
const code = await this.prizeCodeGenerator.generateUnique(tx as any);
const code = isRespin ? null : await this.prizeCodeGenerator.generateUnique(tx as any);
userPrize = await tx.userPrize.create({
data: {
userId,
@@ -289,17 +327,20 @@ export class SpinEngineService {
prizeId: selected.id,
spinId: spin.id,
code,
status: 'NEW',
status: isRespin ? 'USED' : 'NEW',
wonAt: new Date(),
},
});
}
const calculatedRemainingChances = isRespin ? remainingChances : remainingChances - 1;
return {
spin,
selectedPrize: selected,
userPrize,
remainingChances: remainingChances - 1,
remainingChances: calculatedRemainingChances,
isRespin,
};
},
{
@@ -308,10 +349,10 @@ export class SpinEngineService {
},
)
.then(async (result) => {
// 10) Enqueue SMS notification asynchronously after successful commit (AC-12)
// Failure here NEVER rolls back or impacts the spin!
// Enqueue fallback SMS with 45s delay in case user closes tab before animation finishes.
// When modal pops up in frontend, it immediately calls /notify to promote this job instantly.
try {
await this.smsService.enqueueSpinResult(result.spin.id);
await this.smsService.enqueueSpinResult(result.spin.id, { delayMs: 45000 });
} catch (smsErr) {
this.logger.error(`Failed to enqueue spin SMS (non-blocking): ${smsErr}`);
}
@@ -326,8 +367,10 @@ export class SpinEngineService {
code: result.userPrize?.code || null,
type: result.selectedPrize.type,
image: result.selectedPrize.image,
isRespin: result.isRespin,
},
remainingChances: result.remainingChances,
isRespin: result.isRespin,
};
});
}
@@ -343,6 +386,18 @@ export class SpinEngineService {
const remainingChances =
(userCampaign?.totalChances ?? 0) - (userCampaign?.usedChances ?? 0);
const isRespin =
spin.prize?.type === PrizeType.OTHER ||
Boolean(
spin.prize?.title &&
(spin.prize.title.includes('شانس مجدد') ||
spin.prize.title.includes('چرخش مجدد') ||
spin.prize.title.includes('اسپین مجدد') ||
spin.prize.title.includes('فرصت مجدد') ||
spin.prize.title.toLowerCase().includes('respin') ||
spin.prize.title.toLowerCase().includes('free spin')),
);
return {
success: true,
spinId: spin.id,
@@ -354,9 +409,22 @@ export class SpinEngineService {
code: spin.userPrize?.code || null,
type: spin.prize.type,
image: spin.prize.image,
isRespin,
}
: null,
remainingChances,
isRespin,
};
}
async notifySpinResult(spinId: string, userId: string): Promise<{ success: boolean }> {
const spin = await this.prisma.spin.findFirst({
where: { id: spinId, userId },
});
if (!spin) {
return { success: false };
}
await this.smsService.enqueueSpinResult(spinId, { delayMs: 0 });
return { success: true };
}
}
@@ -37,7 +37,7 @@ export class AdminUserController {
@Get(':id')
@RequirePermission('users:read')
@ApiOperation({ summary: 'مشاهده پروفایل کامل، کمپین‌ها، شانس‌ها، اسپین‌ها و جوایز کاربر' })
@ApiOperation({ summary: 'مشاهده پروفایل کامل، کمپین‌ها، شانس‌ها، چرخش‌ها و جوایز کاربر' })
async findOne(@Param('id') id: string) {
return this.userService.findOneForAdmin(id);
}
@@ -15,7 +15,7 @@ import { RequirePermission } from '../../common/decorators/require-permission.de
import { AuditInterceptor } from '../../common/interceptors/audit.interceptor';
import { WinnerService } from './winner.service';
@ApiTags('Admin Spins (تاریخچه اسپین‌ها)')
@ApiTags('Admin Spins (تاریخچه چرخش‌ها)')
@Controller('admin/spins')
@UseGuards(JwtAdminGuard, PermissionsGuard)
@UseInterceptors(AuditInterceptor)
@@ -37,7 +37,7 @@ export class AdminSpinController {
@Get('export')
@RequirePermission('reports:export')
@ApiOperation({ summary: 'خروجی فایل اکسل از تاریخچه تمام اسپین‌ها' })
@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) {
@@ -273,7 +273,7 @@ export class WinnerService {
const worksheet = workbook.addWorksheet('Spins');
worksheet.columns = [
{ header: 'شناسه اسپین', key: 'id', width: 36 },
{ header: 'شناسه چرخش', key: 'id', width: 36 },
{ header: 'موبایل کاربر', key: 'mobile', width: 16 },
{ header: 'کمپین', key: 'campaign', width: 25 },
{ header: 'جایزه تعیین‌شده', key: 'prize', width: 25 },