feat(ux/sms/engine): complete Persian UX writing overhaul, Wolog SMS integration and respin support
This commit is contained in:
+2259
-575
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
File diff suppressed because it is too large
Load Diff
+71
-7
File diff suppressed because one or more lines are too long
+64
-1
@@ -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'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+9238
-110
File diff suppressed because it is too large
Load Diff
+71
-7
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,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],
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ export class SmsProcessor extends WorkerHost {
|
||||
mobile,
|
||||
type,
|
||||
message,
|
||||
provider: 'raygansms',
|
||||
provider: 'wolog',
|
||||
status: SmsStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user