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
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
build/
.env
.env.local
*.log
uploads/
coverage/
.DS_Store
Thumbs.db
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>پنل مدیریت گردونه شانس و قرعه‌کشی</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
* {
font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
margin: 0;
background-color: #f0f2f5;
direction: rtl;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@lucky-wheel/admin",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@lucky-wheel/shared-types": "workspace:*",
"ant-design-vue": "^4.2.1",
"axios": "^1.7.2",
"dayjs": "^1.11.11",
"echarts": "^5.5.0",
"pinia": "^2.1.7",
"vue": "^3.4.27",
"vue-router": "^4.3.2"
},
"devDependencies": {
"@types/node": "^20.12.12",
"@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vue-tsc": "^2.0.19"
}
}
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import faIR from 'ant-design-vue/es/locale/fa_IR';
import dayjs from 'dayjs';
import 'dayjs/locale/fa';
dayjs.locale('fa');
const theme = {
token: {
colorPrimary: '#1677ff',
fontFamily: 'IRANSans, Tahoma, sans-serif',
borderRadius: 6,
},
};
</script>
<template>
<a-config-provider direction="rtl" :locale="faIR" :theme="theme">
<router-view />
</a-config-provider>
</template>
<style>
body {
margin: 0;
padding: 0;
direction: rtl;
font-family: 'IRANSans', Tahoma, sans-serif;
background-color: #f0f2f5;
}
</style>
Binary file not shown.
Binary file not shown.
+90
View File
@@ -0,0 +1,90 @@
/* IRANSans FaNum (Farsi Numerals) */
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 200;
font-display: swap;
src: url('./IRANSansWeb(FaNum)_UltraLight.woff2') format('woff2'),
url('./IRANSansWeb(FaNum)_UltraLight.woff') format('woff');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('./IRANSansWeb(FaNum)_Light.woff2') format('woff2'),
url('./IRANSansWeb(FaNum)_Light.woff') format('woff');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./IRANSansWeb(FaNum).woff2') format('woff2'),
url('./IRANSansWeb(FaNum).woff') format('woff');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./IRANSansWeb(FaNum)_Medium.woff2') format('woff2'),
url('./IRANSansWeb(FaNum)_Medium.woff') format('woff');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./IRANSansWeb(FaNum)_Bold.woff2') format('woff2'),
url('./IRANSansWeb(FaNum)_Bold.woff') format('woff');
}
@font-face {
font-family: 'IRANSans';
font-style: normal;
font-weight: 900;
font-display: swap;
src: url('./IRANSansWeb(FaNum)_Black.woff2') format('woff2'),
url('./IRANSansWeb(FaNum)_Black.woff') format('woff');
}
/* Alias for IRANSansWeb(FaNum) */
@font-face {
font-family: 'IRANSansWeb(FaNum)';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./IRANSansWeb(FaNum).woff2') format('woff2'),
url('./IRANSansWeb(FaNum).woff') format('woff');
}
@font-face {
font-family: 'IRANSansWeb(FaNum)';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./IRANSansWeb(FaNum)_Bold.woff2') format('woff2'),
url('./IRANSansWeb(FaNum)_Bold.woff') format('woff');
}
/* Universal Persian Font and Numbers Enforcement in Admin */
*, *::before, *::after,
body, button, input, select, textarea,
.ant-typography, .ant-btn, .ant-input, .ant-table, .ant-statistic,
.ant-statistic-content-value, .ant-statistic-content-value-int, .ant-statistic-content-value-decimal,
.ant-pagination, .ant-pagination-item, .ant-pagination-total-text,
.ant-badge, .ant-tag, .ant-card, .ant-modal, .ant-drawer,
.ant-form-item-label, .ant-select, .ant-picker, .ant-dropdown {
font-family: 'IRANSans', 'IRANSansWeb(FaNum)', Tahoma, sans-serif !important;
font-variant-numeric: normal !important;
}
.ant-table-cell, .ant-statistic-content, .ant-statistic-title {
font-family: 'IRANSans', 'IRANSansWeb(FaNum)', Tahoma, sans-serif !important;
font-variant-numeric: normal !important;
}
+166
View File
@@ -0,0 +1,166 @@
<script setup lang="ts">
import {
AuditOutlined,
DashboardOutlined,
GiftOutlined,
LineChartOutlined,
LogoutOutlined,
MessageOutlined,
PieChartOutlined,
ReloadOutlined,
TrophyOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth.store';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const collapsed = ref(false);
const selectedKeys = ref<string[]>([route.name as string || 'dashboard']);
watch(
() => route.name,
(val) => {
if (val) selectedKeys.value = [val as string];
},
);
const adminName = computed(() => authStore.admin?.name || 'مدیر سیستم');
const adminRole = computed(() => authStore.admin?.role || 'OPERATOR');
async function handleLogout() {
await authStore.logout();
router.push('/login');
}
</script>
<template>
<a-layout style="min-height: 100vh">
<a-layout-sider
v-model:collapsed="collapsed"
collapsible
width="240"
theme="dark"
style="box-shadow: 2px 0 8px rgba(0,0,0,0.15); z-index: 10"
>
<div class="logo-box">
<div class="logo-icon">🎡</div>
<h2 v-if="!collapsed" class="logo-text">گردونه شانس</h2>
</div>
<a-menu v-model:selectedKeys="selectedKeys" theme="dark" mode="inline">
<a-menu-item key="dashboard" @click="router.push('/dashboard')">
<template #icon><DashboardOutlined /></template>
<span>داشبورد آماری</span>
</a-menu-item>
<a-menu-item key="campaigns" @click="router.push('/campaigns')">
<template #icon><PieChartOutlined /></template>
<span>مدیریت کمپین‌ها</span>
</a-menu-item>
<a-menu-item key="users" @click="router.push('/users')">
<template #icon><UserOutlined /></template>
<span>مدیریت کاربران</span>
</a-menu-item>
<a-menu-item key="spins" @click="router.push('/spins')">
<template #icon><ReloadOutlined /></template>
<span>تاریخچه اسپین‌ها</span>
</a-menu-item>
<a-menu-item key="winners" @click="router.push('/winners')">
<template #icon><TrophyOutlined /></template>
<span>مدیریت برندگان</span>
</a-menu-item>
<a-menu-item key="sms" @click="router.push('/sms')">
<template #icon><MessageOutlined /></template>
<span>لاگ‌های پیامک</span>
</a-menu-item>
<a-menu-item key="reports" @click="router.push('/reports')">
<template #icon><LineChartOutlined /></template>
<span>گزارش‌ها و KPI</span>
</a-menu-item>
<a-menu-item key="audit" @click="router.push('/audit')">
<template #icon><AuditOutlined /></template>
<span>لاگ بازرسی (Audit)</span>
</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-header class="app-header">
<div class="header-left">
<span class="header-title">سامانه جامع قرعه‌کشی و گردونه شانس</span>
</div>
<div class="header-right">
<a-space size="middle">
<a-tag color="blue">{{ adminRole }}</a-tag>
<span class="user-greeting">
<UserOutlined /> {{ adminName }}
</span>
<a-button type="text" danger @click="handleLogout">
<template #icon><LogoutOutlined /></template>
خروج
</a-button>
</a-space>
</div>
</a-layout-header>
<a-layout-content style="margin: 20px 24px; min-height: 280px">
<router-view />
</a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
.logo-box {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.logo-icon {
font-size: 26px;
}
.logo-text {
color: #fff;
font-size: 16px;
font-weight: 700;
margin: 0;
white-space: nowrap;
}
.app-header {
background: #fff;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
}
.header-title {
font-size: 15px;
font-weight: 600;
color: #333;
}
.user-greeting {
font-size: 14px;
font-weight: 500;
}
</style>
+16
View File
@@ -0,0 +1,16 @@
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import './assets/fonts/fonts.css';
import { createPinia } from 'pinia';
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './router';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(Antd);
app.mount('#app');
+105
View File
@@ -0,0 +1,105 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import AdminLayout from '../layouts/AdminLayout.vue';
import { useAuthStore } from '../stores/auth.store';
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('../views/auth/LoginView.vue'),
meta: { guestOnly: true },
},
{
path: '/',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/dashboard',
},
{
path: 'dashboard',
name: 'dashboard',
component: () => import('../views/dashboard/DashboardView.vue'),
},
{
path: 'campaigns',
name: 'campaigns',
component: () => import('../views/campaigns/CampaignListView.vue'),
},
{
path: 'campaigns/create',
name: 'campaign-create',
component: () => import('../views/campaigns/CampaignFormView.vue'),
},
{
path: 'campaigns/:id/edit',
name: 'campaign-edit',
component: () => import('../views/campaigns/CampaignFormView.vue'),
},
{
path: 'campaigns/:campaignId/prizes',
name: 'campaign-prizes',
component: () => import('../views/prizes/PrizeListView.vue'),
},
{
path: 'users',
name: 'users',
component: () => import('../views/users/UserListView.vue'),
},
{
path: 'users/:id',
name: 'user-detail',
component: () => import('../views/users/UserDetailView.vue'),
},
{
path: 'spins',
name: 'spins',
component: () => import('../views/spins/SpinListView.vue'),
},
{
path: 'winners',
name: 'winners',
component: () => import('../views/winners/WinnerListView.vue'),
},
{
path: 'sms',
name: 'sms',
component: () => import('../views/sms/SmsLogListView.vue'),
},
{
path: 'reports',
name: 'reports',
component: () => import('../views/reports/ReportView.vue'),
},
{
path: 'audit',
name: 'audit',
component: () => import('../views/audit/AuditLogView.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/dashboard',
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
const token = localStorage.getItem('admin_access_token');
if (to.meta.requiresAuth && !token) {
next('/login');
} else if (to.meta.guestOnly && token) {
next('/dashboard');
} else {
next();
}
});
+124
View File
@@ -0,0 +1,124 @@
import { message } from 'ant-design-vue';
import axios from 'axios';
declare module 'axios' {
export interface AxiosResponse<T = any> {
meta?: {
total?: number;
page?: number;
pageSize?: number;
[key: string]: any;
};
}
}
export const apiClient = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('admin_access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: any) => void;
}> = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => {
// If backend wrapped response inside { success: true, data: ... } or paginated { data: [...], meta: ... }
if (response.data && typeof response.data === 'object') {
if (response.data.success !== undefined && response.data.data !== undefined) {
return { ...response, data: response.data.data, meta: response.data.meta };
}
if ('data' in response.data && 'meta' in response.data) {
return { ...response, data: response.data.data, meta: response.data.meta };
}
}
return response;
},
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (originalRequest.url?.includes('/admin/auth/login') || originalRequest.url?.includes('/admin/auth/refresh')) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
})
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem('admin_refresh_token');
if (!refreshToken) {
localStorage.removeItem('admin_access_token');
localStorage.removeItem('admin_info');
window.location.href = '/login';
return Promise.reject(error);
}
try {
const { data } = await axios.post('/api/admin/auth/refresh', { refreshToken });
const newToken = data.data?.accessToken || data.accessToken;
localStorage.setItem('admin_access_token', newToken);
apiClient.defaults.headers.common.Authorization = `Bearer ${newToken}`;
processQueue(null, newToken);
return apiClient(originalRequest);
} catch (refreshErr) {
processQueue(refreshErr, null);
localStorage.removeItem('admin_access_token');
localStorage.removeItem('admin_refresh_token');
localStorage.removeItem('admin_info');
window.location.href = '/login';
return Promise.reject(refreshErr);
} finally {
isRefreshing = false;
}
}
const errorMsg =
error.response?.data?.message ||
(Array.isArray(error.response?.data?.message)
? error.response?.data?.message.join(' - ')
: 'خطایی در ارتباط با سرور رخ داده است.');
if (error.response?.status !== 401) {
message.error(errorMsg);
}
return Promise.reject(error);
},
);
+66
View File
@@ -0,0 +1,66 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { apiClient } from '../services/api/client';
export interface AdminUser {
id: string;
name: string;
email: string;
mobile: string;
role: string;
}
export const useAuthStore = defineStore('adminAuth', () => {
const token = ref<string | null>(localStorage.getItem('admin_access_token'));
const admin = ref<AdminUser | null>(
localStorage.getItem('admin_info')
? JSON.parse(localStorage.getItem('admin_info')!)
: null,
);
const loading = ref(false);
const isAuthenticated = computed(() => !!token.value);
async function login(credentials: { email: string; password: string }) {
loading.value = true;
try {
const response = await apiClient.post('/admin/auth/login', credentials);
const data = response.data;
token.value = data.accessToken;
admin.value = data.admin;
localStorage.setItem('admin_access_token', data.accessToken);
localStorage.setItem('admin_refresh_token', data.refreshToken);
localStorage.setItem('admin_info', JSON.stringify(data.admin));
return data;
} finally {
loading.value = false;
}
}
async function logout() {
try {
await apiClient.post('/admin/auth/logout');
} catch {
// Ignore
} finally {
token.value = null;
admin.value = null;
localStorage.removeItem('admin_access_token');
localStorage.removeItem('admin_refresh_token');
localStorage.removeItem('admin_info');
}
}
return {
token,
admin,
loading,
isAuthenticated,
login,
logout,
};
});
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import {
AuditOutlined,
EyeOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { apiClient } from '../../services/api/client';
const loading = ref(false);
const logs = ref<any[]>([]);
const total = ref(0);
const filters = reactive({
page: 1,
pageSize: 10,
action: undefined as string | undefined,
entityType: undefined as string | undefined,
});
// JSON Details Modal
const isDetailsModalVisible = ref(false);
const currentLog = ref<any>(null);
async function loadLogs() {
loading.value = true;
try {
const res = await apiClient.get('/admin/audit-logs', {
params: {
page: filters.page,
pageSize: filters.pageSize,
action: filters.action || undefined,
entityType: filters.entityType || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
logs.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
function showDetails(record: any) {
currentLog.value = record;
isDetailsModalVisible.value = true;
}
const columns = [
{ title: 'مدیر انجام‌دهنده', dataIndex: ['admin', 'name'], key: 'adminName', width: 150 },
{ title: 'ایمیل مدیر', dataIndex: ['admin', 'email'], key: 'adminEmail', width: 180 },
{ title: 'نوع موجودیت', dataIndex: 'entityType', key: 'entityType', width: 140 },
{ title: 'عملیات', dataIndex: 'action', key: 'action', width: 160 },
{ title: 'شناسه موجودیت', dataIndex: 'entityId', key: 'entityId', ellipsis: true },
{ title: 'آی‌پی', dataIndex: 'ipAddress', key: 'ipAddress', width: 130 },
{ title: 'زمان ثبت', dataIndex: 'createdAt', key: 'createdAt', width: 160 },
{ title: 'جزئیات تغییرات', key: 'details', width: 120 },
];
function getActionColor(action: string) {
if (action.includes('CREATE') || action.includes('INSERT')) return 'green';
if (action.includes('UPDATE') || action.includes('EDIT') || action.includes('ADJUST')) return 'blue';
if (action.includes('DELETE') || action.includes('BLOCK')) return 'red';
return 'default';
}
onMounted(() => {
loadLogs();
});
</script>
<template>
<a-card :bordered="false">
<div class="table-header">
<div class="filters">
<a-select
v-model:value="filters.entityType"
placeholder="فیلتر بخش (موجودیت)"
allowClear
style="width: 180px"
@change="() => { filters.page = 1; loadLogs(); }"
>
<a-select-option value="Campaign">کمپین</a-select-option>
<a-select-option value="Prize">جایزه</a-select-option>
<a-select-option value="User">کاربر</a-select-option>
<a-select-option value="Chance">شانس</a-select-option>
<a-select-option value="UserPrize">وضعیت برنده</a-select-option>
</a-select>
</div>
<a-button @click="loadLogs">
<template #icon><ReloadOutlined /></template>
به‌روزرسانی
</a-button>
</div>
<a-table
:dataSource="logs"
:columns="columns"
:loading="loading"
rowKey="id"
:pagination="{
current: filters.page,
pageSize: filters.pageSize,
total: total,
onChange: (page: number) => {
filters.page = page;
loadLogs();
},
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'adminName'">
{{ record.admin?.name || 'مدیر سیستم' }}
</template>
<template v-else-if="column.key === 'action'">
<a-tag :color="getActionColor(record.action)">
{{ record.action }}
</a-tag>
</template>
<template v-else-if="column.key === 'entityId'">
{{ toFarsiDigits(record.entityId) }}
</template>
<template v-else-if="column.key === 'createdAt'">
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
</template>
<template v-else-if="column.key === 'details'">
<a-button size="small" type="link" @click="showDetails(record)">
<template #icon><EyeOutlined /></template>
مشاهده
</a-button>
</template>
</template>
</a-table>
<!-- Details Modal -->
<a-modal
v-model:open="isDetailsModalVisible"
title="جزئیات لاگ بازرسی (Audit Log)"
:footer="null"
width="700px"
>
<div v-if="currentLog">
<p><strong>اقدام:</strong> {{ currentLog.action }} بر روی {{ currentLog.entityType }} (شناسه: {{ currentLog.entityId }})</p>
<p><strong>توسط:</strong> {{ currentLog.admin?.name }} ({{ currentLog.admin?.email }}) | IP: {{ currentLog.ipAddress }}</p>
<a-divider>مقادیر قبلی (Old Values)</a-divider>
<pre class="json-box">{{ JSON.stringify(currentLog.oldValues, null, 2) || 'ندارد' }}</pre>
<a-divider>مقادیر جدید (New Values)</a-divider>
<pre class="json-box">{{ JSON.stringify(currentLog.newValues, null, 2) || 'ندارد' }}</pre>
</div>
</a-modal>
</a-card>
</template>
<style scoped>
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.filters {
display: flex;
gap: 12px;
}
.json-box {
background: #f8f9fa;
padding: 12px;
border-radius: 6px;
font-family: monospace;
font-size: 12px;
max-height: 240px;
overflow-y: auto;
direction: ltr;
text-align: left;
border: 1px solid #e9ecef;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { LockOutlined, MailOutlined } from '@ant-design/icons-vue';
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth.store';
const router = useRouter();
const authStore = useAuthStore();
const formState = reactive({
email: 'admin@luckywheel.local',
password: 'Admin@SecurePass2026',
});
const loading = ref(false);
async function onFinish() {
loading.value = true;
try {
await authStore.login({
email: formState.email,
password: formState.password,
});
router.push('/dashboard');
} catch (err) {
// Handled by axios interceptor
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-container">
<div class="login-card">
<div class="header">
<div class="logo">🎡</div>
<h1>ورود به پنل مدیریت</h1>
<p>سامانه گردونه شانس و قرعه‌کشی</p>
</div>
<a-form :model="formState" layout="vertical" @finish="onFinish">
<a-form-item
label="ایمیل مدیریت"
name="email"
:rules="[{ required: true, message: 'لطفاً ایمیل را وارد کنید' }, { type: 'email', message: 'فرمت ایمیل نامعتبر است' }]"
>
<a-input v-model:value="formState.email" size="large" placeholder="admin@example.com">
<template #prefix><MailOutlined /></template>
</a-input>
</a-form-item>
<a-form-item
label="کلمه عبور"
name="password"
:rules="[{ required: true, message: 'لطفاً کلمه عبور را وارد کنید' }]"
>
<a-input-password v-model:value="formState.password" size="large" placeholder="••••••••">
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-form-item style="margin-top: 24px">
<a-button type="primary" html-type="submit" size="large" block :loading="loading">
ورود به پنل
</a-button>
</a-form-item>
</a-form>
</div>
</div>
</template>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #2c5364 100%);
padding: 16px;
}
.login-card {
width: 100%;
max-width: 420px;
background: #ffffff;
border-radius: 12px;
padding: 36px 32px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.header {
text-align: center;
margin-bottom: 28px;
}
.logo {
font-size: 48px;
margin-bottom: 8px;
}
h1 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: #1f1f1f;
}
p {
font-size: 13px;
color: #888;
margin-top: 4px;
}
</style>
@@ -0,0 +1,299 @@
<script setup lang="ts">
import { ArrowRightOutlined, UploadOutlined } from '@ant-design/icons-vue';
import { WheelDesign } from '@lucky-wheel/shared-types';
import { message } from 'ant-design-vue';
import dayjs, { Dayjs } from 'dayjs';
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
import WheelDesignPicker from './WheelDesignPicker.vue';
const route = useRoute();
const router = useRouter();
const isEdit = ref(false);
const campaignId = ref<string | null>(null);
const loading = ref(false);
const submitting = ref(false);
const formState = reactive({
title: '',
slug: '',
description: '',
banner: '',
logo: '',
dateRange: [] as Dayjs[],
status: 'DRAFT',
defaultChances: 3,
rules: '',
ctaText: 'بچرخون و برنده شو!',
wheelDesign: WheelDesign.CLASSIC_CASINO,
primaryColor: '#FFD700',
secondaryColor: '#B22222',
});
async function loadCampaign() {
if (!campaignId.value) return;
loading.value = true;
try {
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}`);
const c = res.data;
formState.title = c.title;
formState.slug = c.slug;
formState.description = c.description || '';
formState.banner = c.banner || '';
formState.logo = c.logo || '';
formState.status = c.status;
formState.defaultChances = c.defaultChances;
formState.rules = c.rules || '';
formState.ctaText = c.ctaText || 'بچرخون و برنده شو!';
formState.wheelDesign = c.wheelDesign;
formState.dateRange = [dayjs(c.startAt), dayjs(c.endAt)];
if (c.settings) {
formState.primaryColor = c.settings.primaryColor || '#FFD700';
formState.secondaryColor = c.settings.secondaryColor || '#B22222';
}
} finally {
loading.value = false;
}
}
async function onSubmit() {
if (!formState.title || !formState.title.trim()) {
message.error('لطفاً عنوان کمپین را وارد کنید.');
return;
}
// Auto-clean slug: lowercase, replace spaces with hyphens
let slug = (formState.slug || '').trim().toLowerCase().replace(/\s+/g, '-');
if (!slug) {
slug = 'campaign-' + Math.random().toString(36).substring(2, 8);
}
formState.slug = slug;
if (!/^[a-z0-9-]+$/.test(slug)) {
message.error('شناسه URL (اسلاگ) باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره (-) باشد.');
return;
}
if (!formState.dateRange || formState.dateRange.length < 2) {
message.error('لطفاً بازه زمانی شروع و پایان کمپین را انتخاب کنید.');
return;
}
submitting.value = true;
try {
const payload = {
title: formState.title.trim(),
slug: formState.slug.trim(),
description: formState.description?.trim() || undefined,
banner: formState.banner ? formState.banner.trim() : null,
logo: formState.logo ? formState.logo.trim() : null,
startAt: formState.dateRange[0].toISOString(),
endAt: formState.dateRange[1].toISOString(),
status: formState.status,
defaultChances: Number(formState.defaultChances) || 0,
rules: formState.rules?.trim() || undefined,
ctaText: formState.ctaText || 'بچرخون و برنده شو!',
wheelDesign: formState.wheelDesign,
settings: {
primaryColor: formState.primaryColor,
secondaryColor: formState.secondaryColor,
},
};
if (isEdit.value && campaignId.value) {
await apiClient.patch(`/admin/campaigns/${campaignId.value}`, payload);
message.success('کمپین با موفقیت به‌روزرسانی شد.');
router.push('/campaigns');
} else {
const res = await apiClient.post('/admin/campaigns', payload);
message.success('کمپین با موفقیت ایجاد گردید.');
const createdId = res.data?.id || (res as any).id;
if (createdId) {
router.push(`/campaigns/${createdId}/prizes`);
} else {
router.push('/campaigns');
}
}
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || 'خطایی در ثبت اطلاعات کمپین رخ داد.';
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
} finally {
submitting.value = false;
}
}
async function handleFileUpload(file: File, field: 'banner' | 'logo') {
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiClient.post('/admin/uploads', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
formState[field] = res.data.url;
message.success('تصویر با موفقیت آپلود شد.');
} catch {
// Handled
}
return false;
}
onMounted(() => {
if (route.params.id) {
isEdit.value = true;
campaignId.value = route.params.id as string;
loadCampaign();
} else {
// Default dates: now to 1 month later
formState.dateRange = [dayjs(), dayjs().add(1, 'month')];
}
});
</script>
<template>
<a-card :bordered="false" :loading="loading">
<template #title>
<a-space>
<a-button type="text" @click="router.push('/campaigns')">
<template #icon><ArrowRightOutlined /></template>
</a-button>
<span>{{ isEdit ? 'ویرایش اطلاعات کمپین' : 'تعریف کمپین جدید گردونه شانس' }}</span>
</a-space>
</template>
<a-form :model="formState" layout="vertical" @submit.prevent="onSubmit">
<a-row :gutter="24">
<a-col :xs="24" :md="12">
<a-form-item
label="عنوان کمپین"
name="title"
required
:rules="[{ required: true, message: 'عنوان کمپین الزامی است' }]"
>
<a-input v-model:value="formState.title" placeholder="مثال: جشنواره عیدانه گردونه شانس" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item
label="شناسه URL (اسلاگ)"
name="slug"
required
extra="فقط حروف کوچک انگلیسی، اعداد و خط تیره (-) مانند: norooz-1405"
:rules="[{ required: true, message: 'اسلاگ الزامی است' }]"
>
<a-input v-model:value="formState.slug" placeholder="eid-1405" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="بازه زمانی اعتبار (شروع و پایان)" name="dateRange" required>
<a-range-picker
v-model:value="formState.dateRange"
show-time
format="YYYY/MM/DD HH:mm"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<a-form-item label="وضعیت کمپین">
<a-select v-model:value="formState.status">
<a-select-option value="DRAFT">پیش‌نویس</a-select-option>
<a-select-option value="ACTIVE">فعال</a-select-option>
<a-select-option value="PAUSED">متوقف</a-select-option>
<a-select-option value="FINISHED">پایان‌یافته</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<a-form-item label="تعداد شانس پیش‌فرض کاربران">
<a-input-number v-model:value="formState.defaultChances" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="متن دکمه چرخاندن (CTA)">
<a-input v-model:value="formState.ctaText" placeholder="بچرخون و برنده شو!" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<a-form-item label="لوگوی کمپین">
<div style="display: flex; flex-direction: column; gap: 8px;">
<div
v-if="formState.logo"
style="position: relative; width: 90px; height: 90px; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; background: #f8fafc; padding: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.05);"
>
<img :src="formState.logo" alt="لوگو" style="width: 100%; height: 100%; object-fit: contain;" />
<button
type="button"
style="position: absolute; top: 4px; left: 4px; width: 20px; height: 20px; border-radius: 50%; background: #ef4444; color: #fff; border: none; font-size: 11px; cursor: pointer; display: flex; items-center; justify-content: center; line-height: 1;"
title="حذف لوگو"
@click="formState.logo = ''"
>
✕
</button>
</div>
<a-upload :before-upload="(f: any) => handleFileUpload(f, 'logo')" :show-upload-list="false">
<a-button><template #icon><UploadOutlined /></template> {{ formState.logo ? 'تغییر لوگو' : 'انتخاب فایل لوگو' }}</a-button>
</a-upload>
</div>
</a-form-item>
</a-col>
<a-col :xs="24" :md="6">
<a-form-item label="بنر کمپین">
<div style="display: flex; flex-direction: column; gap: 8px;">
<div
v-if="formState.banner"
style="position: relative; width: 160px; height: 90px; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; background: #f8fafc; box-shadow: 0 2px 6px rgba(0,0,0,0.05);"
>
<img :src="formState.banner" alt="بنر" style="width: 100%; height: 100%; object-fit: cover;" />
<button
type="button"
style="position: absolute; top: 4px; left: 4px; width: 20px; height: 20px; border-radius: 50%; background: #ef4444; color: #fff; border: none; font-size: 11px; cursor: pointer; display: flex; items-center; justify-content: center; line-height: 1;"
title="حذف بنر"
@click="formState.banner = ''"
>
✕
</button>
</div>
<a-upload :before-upload="(f: any) => handleFileUpload(f, 'banner')" :show-upload-list="false">
<a-button><template #icon><UploadOutlined /></template> {{ formState.banner ? 'تغییر بنر' : 'انتخاب فایل بنر' }}</a-button>
</a-upload>
</div>
</a-form-item>
</a-col>
<a-col :xs="24">
<a-divider>طرح بصری گردونه شانس (انتخاب از بین ۵ طرح استاندار)</a-divider>
<a-form-item>
<WheelDesignPicker v-model="formState.wheelDesign" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="توضیحات کوتاه کمپین">
<a-textarea v-model:value="formState.description" :rows="3" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="قوانین و مقررات کمپین">
<a-textarea v-model:value="formState.rules" :rows="3" />
</a-form-item>
</a-col>
</a-row>
<div style="margin-top: 24px; display: flex; gap: 12px">
<a-button type="primary" html-type="submit" size="large" :loading="submitting" @click="onSubmit">
{{ isEdit ? 'ذخیره تغییرات' : 'ایجاد کمپین و ورود به تعریف جوایز' }}
</a-button>
<a-button size="large" @click="router.push('/campaigns')">انصراف</a-button>
</div>
</a-form>
</a-card>
</template>
@@ -0,0 +1,252 @@
<script setup lang="ts">
import {
CheckCircleOutlined,
CopyOutlined,
EditOutlined,
EyeOutlined,
GiftOutlined,
PauseCircleOutlined,
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
const router = useRouter();
const loading = ref(false);
const campaigns = ref<any[]>([]);
const total = ref(0);
const filters = reactive({
page: 1,
pageSize: 10,
status: undefined,
search: '',
});
async function loadCampaigns() {
loading.value = true;
try {
const res = await apiClient.get('/admin/campaigns', {
params: {
page: filters.page,
pageSize: filters.pageSize,
status: filters.status,
search: filters.search || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
campaigns.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
async function duplicateCampaign(id: string) {
try {
await apiClient.post(`/admin/campaigns/${id}/duplicate`);
message.success('کمپین و جوایز آن با موفقیت کپی شدند.');
loadCampaigns();
} catch (err) {
// Handled
}
}
async function quickSetStatus(id: string, newStatus: string) {
try {
await apiClient.patch(`/admin/campaigns/${id}`, { status: newStatus });
message.success(newStatus === 'ACTIVE' ? 'کمپین با موفقیت فعال و منتشر شد.' : 'وضعیت کمپین تغییر یافت.');
loadCampaigns();
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || 'خطا در تغییر وضعیت کمپین.';
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
}
}
const columns = [
{ title: 'عنوان کمپین', dataIndex: 'title', key: 'title' },
{ title: 'اسلاگ (URL)', dataIndex: 'slug', key: 'slug' },
{ title: 'طرح گردونه', dataIndex: 'wheelDesign', key: 'wheelDesign' },
{ title: 'وضعیت', dataIndex: 'status', key: 'status' },
{ title: 'شروع', dataIndex: 'startAt', key: 'startAt' },
{ title: 'پایان', dataIndex: 'endAt', key: 'endAt' },
{ title: 'تعداد جوایز', dataIndex: ['_count', 'prizes'], key: 'prizesCount' },
{ title: 'تعداد اسپین‌ها', dataIndex: ['_count', 'spins'], key: 'spinsCount' },
{ title: 'عملیات', key: 'actions', width: 250 },
];
function getStatusInfo(status: string) {
switch (status) {
case 'ACTIVE':
return { color: 'green', text: 'فعال' };
case 'PAUSED':
return { color: 'orange', text: 'متوقف' };
case 'FINISHED':
return { color: 'red', text: 'پایان‌یافته' };
case 'DRAFT':
return { color: 'gold', text: 'پیش‌نویس' };
default:
return { color: 'default', text: status };
}
}
onMounted(() => {
loadCampaigns();
});
</script>
<template>
<a-card :bordered="false">
<div class="table-header">
<div class="filters">
<a-input-search
v-model:value="filters.search"
placeholder="جستجوی عنوان یا اسلاگ..."
style="width: 240px"
@search="loadCampaigns"
/>
<a-select
v-model:value="filters.status"
placeholder="فیلتر وضعیت"
allowClear
style="width: 140px"
@change="loadCampaigns"
>
<a-select-option value="DRAFT">پیش‌نویس</a-select-option>
<a-select-option value="ACTIVE">فعال</a-select-option>
<a-select-option value="PAUSED">متوقف</a-select-option>
<a-select-option value="FINISHED">پایان‌یافته</a-select-option>
</a-select>
</div>
<a-button type="primary" @click="router.push('/campaigns/create')">
<template #icon><PlusOutlined /></template>
ایجاد کمپین جدید
</a-button>
</div>
<a-table
:dataSource="campaigns"
:columns="columns"
:loading="loading"
rowKey="id"
:pagination="{
current: filters.page,
pageSize: filters.pageSize,
total: total,
onChange: (page: number) => {
filters.page = page;
loadCampaigns();
},
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusInfo(record.status).color">
{{ getStatusInfo(record.status).text }}
</a-tag>
</template>
<template v-else-if="column.key === 'defaultChances'">
{{ toFarsiDigits(record.defaultChances) }}
</template>
<template v-else-if="column.key === 'startAt'">
{{ toFarsiDigits(new Date(record.startAt).toLocaleDateString('fa-IR')) }}
</template>
<template v-else-if="column.key === 'endAt'">
{{ toFarsiDigits(new Date(record.endAt).toLocaleDateString('fa-IR')) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space size="small">
<a-tooltip title="مشاهده گردونه کاربر">
<a-button
size="small"
:href="'http://localhost:5174/c/' + record.slug"
target="_blank"
>
<template #icon><EyeOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="record.status === 'DRAFT'" title="فعال‌سازی و انتشار">
<a-button
type="primary"
ghost
size="small"
style="color: #52c41a; border-color: #52c41a"
@click="quickSetStatus(record.id, 'ACTIVE')"
>
<template #icon><CheckCircleOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-else-if="record.status === 'ACTIVE'" title="توقف موقت">
<a-button
danger
ghost
size="small"
@click="quickSetStatus(record.id, 'PAUSED')"
>
<template #icon><PauseCircleOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="مدیریت جوایز">
<a-button
type="primary"
ghost
size="small"
@click="router.push(`/campaigns/${record.id}/prizes`)"
>
<template #icon><GiftOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="ویرایش اطلاعات">
<a-button size="small" @click="router.push(`/campaigns/${record.id}/edit`)">
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-popconfirm
title="آیا از کپی کردن این کمپین و تمام جوایز آن اطمینان دارید؟"
ok-text="بله، کپی کن"
cancel-text="انصراف"
@confirm="duplicateCampaign(record.id)"
>
<a-tooltip title="کپی‌برداری">
<a-button size="small">
<template #icon><CopyOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</template>
<style scoped>
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.filters {
display: flex;
gap: 12px;
}
</style>
@@ -0,0 +1,482 @@
<script setup lang="ts">
import {
CheckCircleFilled,
EyeOutlined,
ReloadOutlined,
} from '@ant-design/icons-vue';
import { WheelDesign } from '@lucky-wheel/shared-types';
import { computed, ref } from 'vue';
const props = defineProps<{
modelValue: WheelDesign;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: WheelDesign): void;
}>();
// Modal preview state
const previewModalVisible = ref(false);
const activePreviewDesign = ref<any>(null);
// Rotating state for cards
const spinningCardId = ref<string | null>(null);
const cardRotation = ref<Record<string, number>>({});
const designs = [
{
id: WheelDesign.CLASSIC_CASINO,
title: 'کلاسیک کازینویی (Classic Casino)',
badge: 'جشنواره‌ها و قرعه‌کشی',
badgeColor: 'gold',
desc: 'طرح سنتی و مجلل با حباب‌های نور طلایی چشمک‌زن، اسلایس‌های قرمز و مشکی و ستاره برنجی',
bg: 'radial-gradient(circle, #2d0a0a 0%, #150505 100%)',
accent: '#fbbf24',
border: '#d97706',
slices: ['#b91c1c', '#1e293b', '#b91c1c', '#1e293b', '#b91c1c', '#1e293b'],
pointerColor: '#f59e0b',
centerColor: '#fbbf24',
centerText: '★',
},
{
id: WheelDesign.NEON_CYBER,
title: 'نئون سایبری (Neon Cyber)',
badge: 'مدرن و گیمینگ',
badgeColor: 'cyan',
desc: 'تم سایبرپانک تیره با درخشش نور فیروزه‌ای و سرخابی، نشانگر لیزری و پالس نوری آینده‌نگرانه',
bg: 'radial-gradient(circle, #100e26 0%, #060512 100%)',
accent: '#00f0ff',
border: '#ec4899',
slices: ['#111827', '#1e1b4b', '#111827', '#1e1b4b', '#111827', '#1e1b4b'],
pointerColor: '#00f0ff',
centerColor: '#00f0ff',
centerText: '⚡',
},
{
id: WheelDesign.MINIMAL_FLAT,
title: 'مینیمال تخت (Minimal Flat)',
badge: 'فین‌تک و B2B',
badgeColor: 'blue',
desc: 'طراحی مدرن، بدون حاشیه سنگین، فوق‌العاده خوانا، شارپ و متناسب با برندهای رسمی',
bg: 'radial-gradient(circle, #1e293b 0%, #0f172a 100%)',
accent: '#38bdf8',
border: '#3b82f6',
slices: ['#3b82f6', '#6366f1', '#3b82f6', '#6366f1', '#3b82f6', '#6366f1'],
pointerColor: '#38bdf8',
centerColor: '#ffffff',
centerText: '●',
},
{
id: WheelDesign.LUXURY_GOLD,
title: 'لوکس سلطنتی (Luxury Gold)',
badge: 'VIP و پرمیوم',
badgeColor: 'orange',
desc: 'پس‌زمینه مشکی ابسیدین با شیب‌های طلای متالیک، حاشیه اشرافی و مدالیون کریستالی',
bg: 'radial-gradient(circle, #241c0e 0%, #0d0a06 100%)',
accent: '#facc15',
border: '#ca8a04',
slices: ['#1c1917', '#0c0a09', '#1c1917', '#0c0a09', '#1c1917', '#0c0a09'],
pointerColor: '#eab308',
centerColor: '#facc15',
centerText: 'VIP',
},
{
id: WheelDesign.PLAYFUL_CARTOON,
title: 'کارتونی فانتزی (Playful Cartoon)',
badge: 'خانواده و بازی',
badgeColor: 'magenta',
desc: 'رنگ‌های شاداب آب‌نباتی، بردر ضخیم و نرم، نشانگر جهنده و حس پویای بازی‌های موبایلی',
bg: 'radial-gradient(circle, #fef3c7 0%, #fde68a 100%)',
accent: '#f43f5e',
border: '#f59e0b',
slices: ['#f43f5e', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ec4899'],
pointerColor: '#ef4444',
centerColor: '#facc15',
centerText: '✨',
},
];
function selectDesign(id: WheelDesign) {
emit('update:modelValue', id);
}
function triggerTestSpin(id: string, event: Event) {
event.stopPropagation();
const current = cardRotation.value[id] || 0;
cardRotation.value[id] = current + 1440 + Math.floor(Math.random() * 360);
spinningCardId.value = id;
setTimeout(() => {
if (spinningCardId.value === id) {
spinningCardId.value = null;
}
}, 2500);
}
function openModalPreview(item: any, event: Event) {
event.stopPropagation();
activePreviewDesign.value = item;
previewModalVisible.value = true;
}
// Generate SVG Slice path for 6 slices
function getSectorPath(index: number, count = 6, radius = 46, cx = 50, cy = 50): string {
const sliceAngle = 360 / count;
const startAngle = (index * sliceAngle - 90) * (Math.PI / 180);
const endAngle = ((index + 1) * sliceAngle - 90) * (Math.PI / 180);
const x1 = cx + radius * Math.cos(startAngle);
const y1 = cy + radius * Math.sin(startAngle);
const x2 = cx + radius * Math.cos(endAngle);
const y2 = cy + radius * Math.sin(endAngle);
return `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 0 1 ${x2} ${y2} Z`;
}
function getSectorPathLarge(index: number, count = 6, radius = 110, cx = 130, cy = 130): string {
const sliceAngle = 360 / count;
const startAngle = (index * sliceAngle - 90) * (Math.PI / 180);
const endAngle = ((index + 1) * sliceAngle - 90) * (Math.PI / 180);
const x1 = cx + radius * Math.cos(startAngle);
const y1 = cy + radius * Math.sin(startAngle);
const x2 = cx + radius * Math.cos(endAngle);
const y2 = cy + radius * Math.sin(endAngle);
return `M ${cx} ${cy} L ${x1} ${y1} A ${radius} ${radius} 0 0 1 ${x2} ${y2} Z`;
}
</script>
<template>
<div class="wheel-picker-grid">
<div
v-for="item in designs"
:key="item.id"
class="design-card"
:class="{ 'is-selected': modelValue === item.id }"
@click="selectDesign(item.id)"
>
<!-- Visual Wheel Preview Header -->
<div class="preview-stage" :style="{ background: item.bg }">
<!-- Badge -->
<a-tag :color="item.badgeColor" class="theme-tag">
{{ item.badge }}
</a-tag>
<!-- Live SVG Wheel Representation -->
<div class="wheel-wrapper">
<!-- Pointer -->
<div class="pointer-wrapper">
<svg width="18" height="22" viewBox="0 0 18 22" fill="none">
<path
d="M9 22L2 6C0.5 3 2.5 0 6 0H12C15.5 0 17.5 3 16 6L9 22Z"
:fill="item.pointerColor"
stroke="#ffffff"
stroke-width="1.2"
/>
</svg>
</div>
<!-- Rotating Wheel SVG -->
<svg
class="mini-wheel-svg"
viewBox="0 0 100 100"
:style="{
transform: `rotate(${cardRotation[item.id] || 0}deg)`,
transition: spinningCardId === item.id ? 'transform 2.5s cubic-bezier(0.15, 0.9, 0.2, 1)' : 'none',
}"
>
<!-- Slices -->
<g v-for="(col, sIdx) in item.slices" :key="sIdx">
<path
:d="getSectorPath(sIdx, item.slices.length)"
:fill="col"
:stroke="item.accent"
stroke-width="0.8"
/>
</g>
<!-- Outer Ring Bulbs for Casino -->
<template v-if="item.id === WheelDesign.CLASSIC_CASINO">
<circle
v-for="b in 12"
:key="b"
:cx="50 + 46 * Math.cos((b * 30 - 90) * (Math.PI / 180))"
:cy="50 + 46 * Math.sin((b * 30 - 90) * (Math.PI / 180))"
r="1.8"
fill="#fef08a"
/>
</template>
<!-- Center Disc -->
<circle cx="50" cy="50" r="13" :fill="item.centerColor" :stroke="item.border" stroke-width="1" />
<circle cx="50" cy="50" r="8" fill="#1e293b" />
<text
x="50"
y="53"
text-anchor="middle"
:fill="item.accent"
font-size="6"
font-weight="bold"
>
{{ item.centerText }}
</text>
</svg>
</div>
<!-- Action Overlay Tools -->
<div class="stage-tools">
<a-tooltip title="تست چرخش آزمایشی">
<a-button
size="small"
shape="circle"
class="tool-btn"
@click="triggerTestSpin(item.id, $event)"
>
<template #icon><ReloadOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="مشاهده پیش‌نمایش بزرگ">
<a-button
size="small"
shape="circle"
class="tool-btn"
@click="openModalPreview(item, $event)"
>
<template #icon><EyeOutlined /></template>
</a-button>
</a-tooltip>
</div>
</div>
<!-- Card Information -->
<div class="card-body">
<div class="card-title-row">
<a-radio :checked="modelValue === item.id">
<span class="title-text">{{ item.title }}</span>
</a-radio>
<CheckCircleFilled v-if="modelValue === item.id" class="selected-icon" />
</div>
<p class="card-desc">{{ item.desc }}</p>
</div>
</div>
<!-- Full-Size Preview Modal -->
<a-modal
v-model:open="previewModalVisible"
:title="`پیش‌نمایش طرح: ${activePreviewDesign?.title || ''}`"
:footer="null"
width="420px"
centered
>
<div v-if="activePreviewDesign" class="modal-preview-container" :style="{ background: activePreviewDesign.bg }">
<div class="modal-wheel-box">
<!-- Large Pointer -->
<div class="large-pointer">
<svg width="28" height="34" viewBox="0 0 28 34" fill="none">
<path
d="M14 34L4 10C2 5 6 1 11 1H17C22 1 26 5 24 10L14 34Z"
:fill="activePreviewDesign.pointerColor"
stroke="#fff"
stroke-width="1.5"
/>
</svg>
</div>
<!-- Large Wheel SVG -->
<svg class="large-wheel-svg" viewBox="0 0 260 260">
<g v-for="(col, sIdx) in activePreviewDesign.slices" :key="sIdx">
<path
:d="getSectorPathLarge(sIdx, activePreviewDesign.slices.length)"
:fill="col"
:stroke="activePreviewDesign.accent"
stroke-width="1.5"
/>
</g>
<circle cx="130" cy="130" r="30" :fill="activePreviewDesign.centerColor" stroke="#fff" stroke-width="2" />
<circle cx="130" cy="130" r="18" fill="#0f172a" />
<text
x="130"
y="136"
text-anchor="middle"
fill="#fff"
font-size="14"
font-weight="bold"
>
{{ activePreviewDesign.centerText }}
</text>
</svg>
</div>
<div style="margin-top: 20px; text-align: center">
<a-button
type="primary"
size="large"
@click="selectDesign(activePreviewDesign.id); previewModalVisible = false;"
>
انتخاب این طرح برای کمپین
</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<style scoped>
.wheel-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
margin-top: 10px;
}
.design-card {
border: 2px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.25s ease;
background: #fff;
display: flex;
flex-direction: column;
}
.design-card:hover {
border-color: #1677ff;
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
}
.design-card.is-selected {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.25);
}
.preview-stage {
height: 140px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.theme-tag {
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
margin: 0;
font-weight: 600;
}
.stage-tools {
position: absolute;
bottom: 8px;
left: 8px;
display: flex;
gap: 6px;
z-index: 10;
}
.tool-btn {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(4px);
border: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.tool-btn:hover {
background: #fff;
color: #1677ff;
}
.wheel-wrapper {
position: relative;
width: 96px;
height: 96px;
display: flex;
align-items: center;
justify-content: center;
}
.pointer-wrapper {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
}
.mini-wheel-svg {
width: 100%;
height: 100%;
border-radius: 50%;
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.3));
}
.card-body {
padding: 14px 16px;
flex: 1;
display: flex;
flex-direction: column;
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.title-text {
font-weight: 700;
font-size: 13.5px;
color: #1e293b;
}
.selected-icon {
color: #1677ff;
font-size: 18px;
}
.card-desc {
font-size: 11.5px;
color: #64748b;
line-height: 1.6;
margin: 0;
}
.modal-preview-container {
padding: 30px 20px;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
}
.modal-wheel-box {
position: relative;
width: 240px;
height: 240px;
}
.large-pointer {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
z-index: 20;
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.5));
}
.large-wheel-svg {
width: 100%;
height: 100%;
border-radius: 50%;
filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.4));
}
</style>
@@ -0,0 +1,255 @@
<script setup lang="ts">
import {
CheckCircleOutlined,
GiftOutlined,
ReloadOutlined,
TrophyOutlined,
UserAddOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import * as echarts from 'echarts';
import { onMounted, onUnmounted, ref } from 'vue';
import { apiClient } from '../../services/api/client';
const loading = ref(true);
const dashboardData = ref<any>(null);
const chartRef = ref<HTMLElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
async function loadDashboard() {
loading.value = true;
try {
const res = await apiClient.get('/admin/reports/dashboard');
dashboardData.value = res.data;
initChart();
} finally {
loading.value = false;
}
}
function initChart() {
if (!chartRef.value || !dashboardData.value?.spinsByDay) return;
if (chartInstance) {
chartInstance.dispose();
}
chartInstance = echarts.init(chartRef.value);
const dates = dashboardData.value.spinsByDay.map((d: any) => toFarsiDigits(d.date));
const spins = dashboardData.value.spinsByDay.map((d: any) => d.spins);
const winners = dashboardData.value.spinsByDay.map((d: any) => d.winners);
chartInstance.setOption({
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
tooltip: {
trigger: 'axis',
textStyle: { fontFamily: 'IRANSans' },
valueFormatter: (val: any) => formatFarsiNumber(val),
},
legend: {
data: ['تعداد کل چرخش‌ها', 'تعداد برندگان'],
textStyle: { fontFamily: 'IRANSans' },
},
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
axisLabel: {
fontFamily: 'IRANSans',
formatter: (val: string) => toFarsiDigits(val),
},
},
yAxis: {
type: 'value',
axisLabel: {
fontFamily: 'IRANSans',
formatter: (val: number) => formatFarsiNumber(val),
},
},
series: [
{
name: 'تعداد کل چرخش‌ها',
type: 'line',
smooth: true,
data: spins,
itemStyle: { color: '#1890ff' },
areaStyle: { color: 'rgba(24, 144, 255, 0.1)' },
},
{
name: 'تعداد برندگان',
type: 'line',
smooth: true,
data: winners,
itemStyle: { color: '#52c41a' },
},
],
});
}
const columns = [
{ title: 'شماره موبایل', dataIndex: ['user', 'mobile'], key: 'mobile' },
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaign' },
{ title: 'جایزه برنده شده', dataIndex: ['prize', 'title'], key: 'prize' },
{ title: 'کد جایزه', dataIndex: 'code', key: 'code' },
{ title: 'وضعیت', dataIndex: 'status', key: 'status' },
{ title: 'تاریخ برد', dataIndex: 'wonAt', key: 'wonAt' },
];
onMounted(() => {
loadDashboard();
window.addEventListener('resize', () => chartInstance?.resize());
});
onUnmounted(() => {
chartInstance?.dispose();
});
</script>
<template>
<div class="dashboard-page">
<a-spin :spinning="loading">
<!-- KPI Cards -->
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false" class="kpi-card">
<a-statistic
title="کل کاربران ثبت‌نامی"
:value="dashboardData?.stats?.totalUsers || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
>
<template #prefix><UserOutlined style="color: #1890ff" /></template>
<template #suffix>
<span class="sub-stat">
(+{{ formatFarsiNumber(dashboardData?.stats?.todayUsers || 0) }} امروز)
</span>
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false" class="kpi-card">
<a-statistic
title="کل چرخش‌ها (Spins)"
:value="dashboardData?.stats?.totalSpins || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
>
<template #prefix><ReloadOutlined style="color: #faad14" /></template>
<template #suffix>
<span class="sub-stat">
(+{{ formatFarsiNumber(dashboardData?.stats?.todaySpins || 0) }} امروز)
</span>
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false" class="kpi-card">
<a-statistic
title="کل جوایز برنده شده"
:value="dashboardData?.stats?.totalWinners || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
>
<template #prefix><TrophyOutlined style="color: #52c41a" /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card :bordered="false" class="kpi-card">
<a-statistic
title="جوایز تحویل/مصرف شده"
:value="dashboardData?.stats?.totalDistributedPrizes || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
>
<template #prefix><CheckCircleOutlined style="color: #722ed1" /></template>
</a-statistic>
</a-card>
</a-col>
</a-row>
<!-- Chart and Prize Distribution -->
<a-row :gutter="[16, 16]" style="margin-top: 16px">
<a-col :xs="24" :lg="16">
<a-card title="روند چرخش‌ها و برندگان در ۱۴ روز گذشته" :bordered="false">
<div ref="chartRef" style="height: 320px; width: 100%"></div>
</a-card>
</a-col>
<a-col :xs="24" :lg="8">
<a-card title="توزیع جوایز برنده شده" :bordered="false">
<div v-if="dashboardData?.prizeDistribution?.length > 0" class="dist-list">
<div
v-for="item in dashboardData.prizeDistribution"
:key="item.prizeTitle"
class="dist-item"
>
<div class="dist-header">
<span>{{ item.prizeTitle }}</span>
<span>{{ formatFarsiNumber(item.count) }} عدد ({{ toFarsiDigits(item.percentage) }}٪)</span>
</div>
<a-progress :percent="item.percentage" size="small" status="active" />
</div>
</div>
<a-empty v-else description="هنوز جایزه‌ای توزیع نشده است" />
</a-card>
</a-col>
</a-row>
<!-- Recent Winners Table -->
<a-card title="آخرین برندگان گردونه" :bordered="false" style="margin-top: 16px">
<a-table
:dataSource="dashboardData?.recentWinners || []"
:columns="columns"
:pagination="false"
rowKey="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'mobile'">
{{ toFarsiDigits(record.user?.mobile) }}
</template>
<template v-else-if="column.key === 'wonAt'">
{{ toFarsiDigits(new Date(record.wonAt).toLocaleString('fa-IR')) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'DELIVERED' || record.status === 'USED' ? 'green' : 'blue'">
{{ record.status }}
</a-tag>
</template>
</template>
</a-table>
</a-card>
</a-spin>
</div>
</template>
<style scoped>
.kpi-card {
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.sub-stat {
font-size: 13px;
color: #888;
margin-right: 8px;
}
.dist-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.dist-header {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 4px;
}
</style>
@@ -0,0 +1,779 @@
<script setup lang="ts">
import {
ArrowRightOutlined,
CheckCircleOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
PauseCircleOutlined,
PlusOutlined,
ReloadOutlined,
UploadOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { formatFarsiNumber, parseFarsiNumber, toEnglishDigits, toFarsiDigits } from '@lucky-wheel/shared-types';
import { useRoute, useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
const route = useRoute();
const router = useRouter();
const campaignId = ref<string>((route.params.campaignId as string) || '');
const campaign = ref<any>(null);
const loading = ref(false);
const prizes = ref<any[]>([]);
// Modal states
const isFormModalVisible = ref(false);
const isStockModalVisible = ref(false);
const isSubmitting = ref(false);
const editingPrizeId = ref<string | null>(null);
const isNoPrize = ref(false);
const formState = reactive({
title: '',
description: '',
type: 'DISCOUNT_CODE',
stockType: 'LIMITED',
initialStock: 100,
remainingStock: 100,
weight: 10,
segmentColor: '#FF6B6B',
image: '',
sortOrder: 1,
status: 'ACTIVE',
smsTemplate: '',
});
const estimatedWinChance = computed(() => {
const currentWeight = parseFarsiNumber(formState.weight, 1);
const otherWeights = prizes.value.reduce((acc, p) => {
if (editingPrizeId.value && p.id === editingPrizeId.value) return acc;
return acc + (p.status === 'ACTIVE' || p.isActive ? (p.weight || 0) : 0);
}, 0);
const newTotal = otherWeights + currentWeight;
if (newTotal <= 0) return '۰';
const pct = ((currentWeight / newTotal) * 100).toFixed(1);
return toFarsiDigits(pct);
});
function handleNoPrizeChange(checked: boolean) {
isNoPrize.value = checked;
if (checked) {
formState.type = 'NO_PRIZE';
formState.stockType = 'UNLIMITED';
if (!formState.title || formState.title.trim() === '') {
formState.title = 'پوچ (دفعه بعد)';
}
if (!formState.description) {
formState.description = 'بدون جایزه - شانس بعدی';
}
} else {
if (formState.type === 'NO_PRIZE') {
formState.type = 'DISCOUNT_CODE';
}
if (formState.title === 'پوچ (دفعه بعد)' || formState.title === 'پوچ') {
formState.title = '';
}
}
}
function handleTypeChange(val: string) {
if (val === 'NO_PRIZE') {
isNoPrize.value = true;
formState.stockType = 'UNLIMITED';
if (!formState.title || formState.title.trim() === '') {
formState.title = 'پوچ (دفعه بعد)';
}
} else {
isNoPrize.value = false;
}
}
const stockState = reactive({
prizeId: '',
prizeTitle: '',
currentStock: 0,
change: 0,
reason: '',
});
const totalWeight = computed(() => {
return prizes.value.reduce((acc, p) => acc + (p.status === 'ACTIVE' || p.isActive ? p.weight : 0), 0);
});
async function loadCampaignInfo() {
if (!campaignId.value) return;
try {
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}`);
campaign.value = res.data;
} catch (err) {
// Handled
}
}
const isActivating = ref(false);
async function activateCampaign() {
if (!campaignId.value) return;
if (prizes.value.length < 1) {
message.warning('برای فعال‌سازی کمپین، حداقل یک جایزه فعال تعریف کنید.');
return;
}
isActivating.value = true;
try {
await apiClient.patch(`/admin/campaigns/${campaignId.value}`, { status: 'ACTIVE' });
message.success('کمپین با موفقیت فعال و منتشر شد! هم‌اکنون برای کاربران در دسترس است.');
await loadCampaignInfo();
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || 'خطا در فعال‌سازی کمپین.';
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
} finally {
isActivating.value = false;
}
}
async function pauseCampaign() {
if (!campaignId.value) return;
isActivating.value = true;
try {
await apiClient.patch(`/admin/campaigns/${campaignId.value}`, { status: 'PAUSED' });
message.info('کمپین متوقف شد.');
await loadCampaignInfo();
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || 'خطا در توقف کمپین.';
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
} finally {
isActivating.value = false;
}
}
async function loadPrizes() {
if (!campaignId.value) return;
loading.value = true;
try {
const res = await apiClient.get(`/admin/campaigns/${campaignId.value}/prizes`);
prizes.value = Array.isArray(res.data) ? res.data : (res.data?.data || []);
} finally {
loading.value = false;
}
}
function openCreateModal() {
editingPrizeId.value = null;
isNoPrize.value = false;
formState.title = '';
formState.description = '';
formState.type = 'DISCOUNT_CODE';
formState.stockType = 'LIMITED';
formState.initialStock = 100;
formState.remainingStock = 100;
formState.weight = 10;
formState.segmentColor = '#3880ff';
formState.image = '';
formState.sortOrder = prizes.value.length + 1;
formState.status = 'ACTIVE';
formState.smsTemplate = '';
isFormModalVisible.value = true;
}
function openEditModal(record: any) {
editingPrizeId.value = record.id;
const isNoPrizeRecord = record.type === 'NO_PRIZE' || record.prizeType === 'NO_PRIZE' || record.title === 'پوچ' || record.title?.includes('پوچ');
isNoPrize.value = isNoPrizeRecord;
formState.title = record.title;
formState.description = record.description || record.subtitle || '';
formState.type = record.type || record.prizeType || (isNoPrizeRecord ? 'NO_PRIZE' : 'DISCOUNT_CODE');
formState.stockType = record.stockType || (isNoPrizeRecord ? 'UNLIMITED' : 'LIMITED');
formState.initialStock = record.initialStock ?? 100;
formState.remainingStock = record.remainingStock ?? record.initialStock ?? 100;
formState.weight = record.weight ?? 10;
formState.segmentColor = record.segmentColor || record.color || '#3880ff';
formState.image = record.image || record.iconUrl || '';
formState.sortOrder = record.sortOrder ?? 1;
formState.status = record.status || (record.isActive === false ? 'INACTIVE' : 'ACTIVE');
formState.smsTemplate = record.smsTemplate || '';
isFormModalVisible.value = true;
}
function openStockModal(record: any) {
stockState.prizeId = record.id;
stockState.prizeTitle = record.title;
stockState.currentStock = record.remainingStock;
stockState.change = 0;
stockState.reason = '';
isStockModalVisible.value = true;
}
async function handleFormSubmit() {
if (!formState.title || !formState.title.trim()) {
message.error('عنوان جایزه الزامی است.');
return;
}
isSubmitting.value = true;
try {
if (isNoPrize.value) {
formState.type = 'NO_PRIZE';
formState.stockType = 'UNLIMITED';
}
const parsedWeight = parseFarsiNumber(formState.weight, 1);
const parsedInitialStock = parseFarsiNumber(formState.initialStock, 1);
const parsedRemainingStock = parseFarsiNumber(formState.remainingStock, parsedInitialStock);
const parsedSortOrder = parseFarsiNumber(formState.sortOrder, 1);
const payload: any = {
title: formState.title.trim(),
description: formState.description?.trim() || undefined,
type: formState.type,
stockType: formState.stockType,
weight: parsedWeight > 0 ? parsedWeight : 1,
segmentColor: formState.segmentColor || '#3880ff',
image: formState.image || undefined,
sortOrder: parsedSortOrder > 0 ? parsedSortOrder : 1,
status: formState.status,
smsTemplate: formState.smsTemplate?.trim() || undefined,
};
if (formState.stockType === 'LIMITED') {
payload.initialStock = parsedInitialStock;
payload.remainingStock = parsedRemainingStock;
}
if (editingPrizeId.value) {
await apiClient.patch(`/admin/prizes/${editingPrizeId.value}`, payload);
message.success('جایزه با موفقیت به‌روزرسانی شد.');
} else {
await apiClient.post(`/admin/campaigns/${campaignId.value}/prizes`, payload);
message.success('جایزه با موفقیت ایجاد شد.');
}
isFormModalVisible.value = false;
loadPrizes();
} catch (err: any) {
const errorMsg = err.response?.data?.message || err.message || 'خطا در ثبت جایزه.';
message.error(Array.isArray(errorMsg) ? errorMsg.join(' | ') : errorMsg);
} finally {
isSubmitting.value = false;
}
}
async function handleStockSubmit() {
const changeVal = parseFarsiNumber(stockState.change, 0);
if (changeVal === 0) {
message.warning('میزان تغییر باید غیر صفر باشد.');
return;
}
isSubmitting.value = true;
try {
await apiClient.patch(`/admin/prizes/${stockState.prizeId}/stock`, {
change: changeVal,
reason: stockState.reason || undefined,
});
message.success('موجودی جایزه با موفقیت تغییر یافت.');
isStockModalVisible.value = false;
loadPrizes();
} finally {
isSubmitting.value = false;
}
}
async function handleDelete(id: string) {
try {
await apiClient.delete(`/admin/prizes/${id}`);
message.success('جایزه با موفقیت حذف شد.');
loadPrizes();
} catch (err) {
// Handled
}
}
async function handleCustomUpload({ file, onSuccess, onError }: any) {
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiClient.post('/admin/uploads', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
formState.image = res.data.url;
onSuccess(res.data);
message.success('تصویر با موفقیت بارگذاری شد.');
} catch (err: any) {
onError(err);
message.error('خطا در بارگذاری تصویر');
}
}
const columns = [
{ title: 'ترتیب', dataIndex: 'sortOrder', key: 'sortOrder', width: 70 },
{ title: 'رنگ', key: 'color', width: 80 },
{ title: 'عنوان جایزه', dataIndex: 'title', key: 'title' },
{ title: 'نوع جایزه', key: 'type', width: 140 },
{ title: 'وضعیت پوچ', key: 'isNoPrize', width: 130 },
{ title: 'نوع موجودی', dataIndex: 'stockType', key: 'stockType' },
{ title: 'موجودی اولیه', dataIndex: 'initialStock', key: 'initialStock' },
{ title: 'موجودی مانده', key: 'remainingStock' },
{ title: 'وزن / شانس', key: 'weight' },
{ title: 'وضعیت', key: 'status', width: 90 },
{ title: 'عملیات', key: 'actions', width: 220 },
];
function getPrizeTypeTag(type: string) {
switch (type) {
case 'PHYSICAL':
return { text: 'کالای فیزیکی', color: 'blue' };
case 'DISCOUNT_CODE':
return { text: 'کد تخفیف', color: 'green' };
case 'CREDIT':
case 'WALLET_CREDIT':
return { text: 'شارژ کیف پول', color: 'purple' };
case 'CASH':
return { text: 'جایزه نقدی', color: 'gold' };
case 'NO_PRIZE':
return { text: 'پوچ / بدون جایزه', color: 'error' };
case 'OTHER':
case 'CHANCE':
return { text: 'شانس مجدد / سایر', color: 'orange' };
default:
return { text: type || '—', color: 'default' };
}
}
onMounted(() => {
loadCampaignInfo();
loadPrizes();
});
</script>
<template>
<div>
<a-page-header
style="background: #fff; margin-bottom: 20px; border-radius: 8px; padding: 16px 24px"
:title="`مدیریت جوایز کمپین: ${campaign?.title || 'در حال بارگذاری...'}`"
:sub-title="`طرح گردونه: ${campaign?.wheelDesign || '-'}`"
@back="router.push('/campaigns')"
>
<template #tags>
<a-tag v-if="campaign?.status === 'DRAFT'" color="orange">پیش‌نویس (DRAFT)</a-tag>
<a-tag v-else-if="campaign?.status === 'ACTIVE'" color="green">فعال و در دسترس (ACTIVE)</a-tag>
<a-tag v-else-if="campaign?.status === 'PAUSED'" color="red">متوقف (PAUSED)</a-tag>
<a-tag v-else>{{ campaign?.status }}</a-tag>
</template>
<template #extra>
<a-space>
<a-button
v-if="campaign?.slug"
:href="'http://localhost:5174/c/' + campaign.slug"
target="_blank"
>
<template #icon><EyeOutlined /></template>
مشاهده گردونه کاربر ↗
</a-button>
<a-button
v-if="campaign?.status === 'DRAFT'"
type="primary"
style="background: #52c41a; border-color: #52c41a"
:loading="isActivating"
@click="activateCampaign"
>
<template #icon><CheckCircleOutlined /></template>
🚀 فعال‌سازی و انتشار کمپین
</a-button>
<a-button
v-else-if="campaign?.status === 'ACTIVE'"
danger
:loading="isActivating"
@click="pauseCampaign"
>
<template #icon><PauseCircleOutlined /></template>
توقف موقت کمپین
</a-button>
<a-button @click="loadPrizes">
<template #icon><ReloadOutlined /></template>
به‌روزرسانی
</a-button>
<a-button type="primary" @click="openCreateModal">
<template #icon><PlusOutlined /></template>
افزودن جایزه جدید
</a-button>
</a-space>
</template>
</a-page-header>
<a-alert
v-if="campaign?.status === 'DRAFT'"
message="کمپین در وضعیت پیش‌نویس (DRAFT) قرار دارد"
description="جوایز خود را در جدول زیر تعریف یا ویرایش کنید. پس از پایان تنظیمات جوایز، برای فعال‌سازی و در دسترس قرار گرفتن گردونه برای کاربران، بر روی دکمه سبز رنگ «🚀 فعال‌سازی و انتشار کمپین» در بالا کلیک نمایید."
type="warning"
show-icon
style="margin-bottom: 20px"
/>
<a-alert
v-else-if="campaign?.status === 'ACTIVE'"
message="این کمپین فعال و منتشر شده است"
description="کاربران می‌توانند با مراجعه به صفحه گردونه، شانس خود را امتحان کنند."
type="success"
show-icon
style="margin-bottom: 20px"
/>
<a-card :bordered="false">
<div v-if="prizes.length === 0 && !loading" style="text-align: center; padding: 40px">
<a-empty description="هنوز جایزه‌ای برای این کمپین تعریف نشده است.">
<a-button type="primary" @click="openCreateModal">ایجاد اولین جایزه</a-button>
</a-empty>
</div>
<a-table
v-else
:dataSource="prizes"
:columns="columns"
:loading="loading"
rowKey="id"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'color'">
<div
:style="{
backgroundColor: record.segmentColor || record.color || '#ddd',
color: '#fff',
width: '40px',
height: '24px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 'bold',
border: '1px solid rgba(0,0,0,0.1)',
textShadow: '0 1px 2px rgba(0,0,0,0.6)',
}"
>
{{ toFarsiDigits(record.sortOrder) }}
</div>
</template>
<template v-else-if="column.key === 'type'">
<a-tag :color="getPrizeTypeTag(record.type || record.prizeType).color">
{{ getPrizeTypeTag(record.type || record.prizeType).text }}
</a-tag>
</template>
<template v-else-if="column.key === 'isNoPrize'">
<a-tag v-if="record.type === 'NO_PRIZE' || record.title?.includes('پوچ')" color="error">
🎭 پوچ (باخت)
</a-tag>
<a-tag v-else color="success">
🎁 جایزه‌دار
</a-tag>
</template>
<template v-else-if="column.key === 'stockType'">
<a-tag :color="record.stockType === 'LIMITED' ? 'cyan' : 'blue'">
{{ record.stockType === 'LIMITED' ? 'محدود' : 'نامحدود' }}
</a-tag>
</template>
<template v-else-if="column.key === 'initialStock'">
<span>{{ toFarsiDigits(record.initialStock) }}</span>
</template>
<template v-else-if="column.key === 'remainingStock'">
<span v-if="record.stockType === 'UNLIMITED'">∞</span>
<span
v-else
:style="{
color: record.remainingStock === 0 ? 'red' : record.remainingStock < 10 ? 'orange' : 'inherit',
fontWeight: record.remainingStock < 10 ? 700 : 'normal',
}"
>
{{ toFarsiDigits(record.remainingStock) }}
</span>
</template>
<template v-else-if="column.key === 'weight'">
<span>
{{ toFarsiDigits(record.weight) }}
<span v-if="totalWeight > 0" style="color: #888; font-size: 12px">
({{ toFarsiDigits(((record.weight / totalWeight) * 100).toFixed(1)) }}٪)
</span>
</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'ACTIVE' || record.isActive ? 'green' : 'default'">
{{ record.status === 'ACTIVE' || record.isActive ? 'فعال' : 'غیرفعال' }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space size="small">
<a-button
v-if="record.stockType === 'LIMITED'"
size="small"
@click="openStockModal(record)"
>
تنظیم موجودی
</a-button>
<a-button size="small" @click="openEditModal(record)">
<template #icon><EditOutlined /></template>
</a-button>
<a-popconfirm
title="آیا از حذف این جایزه اطمینان دارید؟ (در صورت وجود اسپین مجاز نیست)"
ok-text="بله، حذف کن"
cancel-text="انصراف"
@confirm="handleDelete(record.id)"
>
<a-button size="small" danger>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- Create / Edit Modal -->
<a-modal
v-model:open="isFormModalVisible"
:title="editingPrizeId ? 'ویرایش جایزه' : 'افزودن جایزه جدید'"
:confirmLoading="isSubmitting"
okText="ذخیره"
cancelText="انصراف"
width="680px"
@ok="handleFormSubmit"
>
<a-form layout="vertical">
<!-- وضعیت پوچ (بدون جایزه) Switch Card -->
<div
:style="{
background: isNoPrize ? '#fff1f0' : '#f8fafc',
border: isNoPrize ? '1px solid #ffa39e' : '1px solid #e2e8f0',
borderRadius: '8px',
padding: '12px 16px',
marginBottom: '18px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'all 0.3s ease',
}"
>
<div style="flex: 1; padding-left: 16px">
<div style="font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px">
<span>🎭 وضعیت پوچ (اسلایس بدون جایزه / باخت)</span>
<a-tag v-if="isNoPrize" color="error">پوچ فعال است (صدای باخت)</a-tag>
<a-tag v-else color="default">جایزه‌دار (برنده)</a-tag>
</div>
<div style="font-size: 12px; color: #64748b; margin-top: 4px; line-height: 1.6">
اگر این تیک روشن باشد، کاربر در صورت توقف گردونه روی این اسلایس برنده جایزه‌ای نمی‌شود و افکت و <strong>صدای معروف باخت (Sad Trombone)</strong> برای هیجان و طنز بیشتر پخش خواهد شد.
</div>
</div>
<a-switch
:checked="isNoPrize"
checked-children="پوچ"
un-checked-children="خیر"
@change="handleNoPrizeChange"
/>
</div>
<!-- بخش ۱: مشخصات پایه -->
<a-row :gutter="16">
<a-col :span="16">
<a-form-item label="عنوان جایزه" required>
<a-input v-model:value="formState.title" placeholder="مثال: آیفون ۱۵ یا کد تخفیف ۵۰٪" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="ترتیب در گردونه (۱ به بعد)" required tooltip="شماره موقعیت این اسلایس روی گردونه">
<a-input-number
v-model:value="formState.sortOrder"
:min="1"
:parser="(val: string) => toEnglishDigits(val).replace(/[^0-9]/g, '')"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="توضیحات جایزه">
<a-input v-model:value="formState.description" placeholder="مثال: خریدهای بالای ۵۰۰ هزار تومان یا تحویل حضوری" />
</a-form-item>
<!-- بخش ۲: نوع جایزه و وزن شانس (کنار هم) -->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="نوع جایزه" required>
<a-select v-model:value="formState.type" @change="handleTypeChange">
<a-select-option value="DISCOUNT_CODE">کد تخفیف</a-select-option>
<a-select-option value="PHYSICAL">کالای فیزیکی</a-select-option>
<a-select-option value="CREDIT">شارژ کیف پول (اعتبار)</a-select-option>
<a-select-option value="CASH">جایزه نقدی</a-select-option>
<a-select-option value="NO_PRIZE">پوچ (بدون جایزه)</a-select-option>
<a-select-option value="OTHER">سایر / شانس مجدد</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="وزن شانس (ضریب احتمالات)"
required
tooltip="هرچه وزن نسبت به سایر جوایز بالاتر باشد احتمال اصابت بیشتر خواهد بود"
>
<a-input-number
v-model:value="formState.weight"
:min="1"
:parser="(val: string) => toEnglishDigits(val).replace(/[^0-9]/g, '')"
style="width: 100%"
/>
<div style="margin-top: 4px; font-size: 12px; display: flex; align-items: center; justify-content: space-between">
<span style="color: #64748b">تخمین احتمال اصابت:</span>
<a-tag color="processing" style="font-weight: 600; margin: 0">
{{ estimatedWinChance }}٪ از کل شانس‌ها
</a-tag>
</div>
</a-form-item>
</a-col>
</a-row>
<!-- بخش ۳: مدیریت انبار و موجودی -->
<div style="background: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 8px; padding: 12px 14px; margin-bottom: 16px">
<div style="font-weight: 600; font-size: 13px; color: #334155; margin-bottom: 10px">
📦 تنظیمات موجودی و انبار
</div>
<a-row :gutter="16">
<a-col :span="formState.stockType === 'LIMITED' ? 8 : 24">
<a-form-item label="نوع موجودی" required style="margin-bottom: 0">
<a-select v-model:value="formState.stockType" :disabled="isNoPrize">
<a-select-option value="LIMITED">محدود (شمارش‌دار)</a-select-option>
<a-select-option value="UNLIMITED">نامحدود</a-select-option>
</a-select>
</a-form-item>
</a-col>
<template v-if="formState.stockType === 'LIMITED'">
<a-col :span="8">
<a-form-item label="موجودی اولیه" style="margin-bottom: 0">
<a-input-number
v-model:value="formState.initialStock"
:min="1"
:parser="(val: string) => toEnglishDigits(val).replace(/[^0-9]/g, '')"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item
label="موجودی باقی‌مانده"
tooltip="موجودی قابل استفاده فعلی در انبار"
style="margin-bottom: 0"
>
<a-input-number
v-model:value="formState.remainingStock"
:min="0"
:parser="(val: string) => toEnglishDigits(val).replace(/[^0-9]/g, '')"
style="width: 100%"
/>
</a-form-item>
</a-col>
</template>
</a-row>
</div>
<!-- بخش ۴: ظاهر و قالب پیامک -->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="رنگ پس‌زمینه اسلایس (Hex)" required>
<div style="display: flex; gap: 8px; align-items: center">
<input
v-model="formState.segmentColor"
type="color"
style="width: 40px; height: 32px; border: none; cursor: pointer; border-radius: 4px"
/>
<a-input v-model:value="formState.segmentColor" placeholder="#3880ff" style="flex: 1" />
</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="قالب پیامک برنده (اختیاری)" tooltip="متغیرها: {prize} و {code}">
<a-input v-model:value="formState.smsTemplate" placeholder="تبریک! برنده {prize} شدید. کد: {code}" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="آیکون یا تصویر جایزه">
<div style="display: flex; gap: 12px; align-items: center">
<a-upload
:customRequest="handleCustomUpload"
:showUploadList="false"
accept="image/*"
>
<a-button>
<template #icon><UploadOutlined /></template>
بارگذاری تصویر
</a-button>
</a-upload>
<a-input v-model:value="formState.image" placeholder="آدرس تصویر (URL)" style="flex: 1" />
</div>
<div v-if="formState.image" style="margin-top: 8px">
<img :src="formState.image" alt="icon preview" style="max-height: 48px; border-radius: 4px" />
</div>
</a-form-item>
<a-form-item label="وضعیت" style="margin-bottom: 0">
<a-switch
:checked="formState.status === 'ACTIVE'"
checked-children="فعال"
un-checked-children="غیرفعال"
@change="(checked: any) => formState.status = checked ? 'ACTIVE' : 'INACTIVE'"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- Adjust Stock Modal -->
<a-modal
v-model:open="isStockModalVisible"
title="تنظیم سریع موجودی جایزه"
:confirmLoading="isSubmitting"
okText="اعمال تغییرات"
cancelText="انصراف"
@ok="handleStockSubmit"
>
<a-form layout="vertical">
<a-alert
:message="`جایزه: ${stockState.prizeTitle}`"
:description="`موجودی فعلی باقی‌مانده: ${stockState.currentStock} عدد`"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-form-item
label="تغییر موجودی (+ برای افزایش، - برای کاهش)"
required
tooltip="مثال: برای اضافه کردن ۵۰ عدد مقدار 50+ و برای کاهش مقدار 20- وارد کنید"
>
<a-input-number
v-model:value="stockState.change"
:parser="(val: string) => toEnglishDigits(val).replace(/[^0-9.-]/g, '')"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="دلیل تغییر (جهت ثبت در لاگ بازرسی)">
<a-input v-model:value="stockState.reason" placeholder="مثال: ورود محموله جدید به انبار" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import {
BarChartOutlined,
DownloadOutlined,
GiftOutlined,
ReloadOutlined,
TeamOutlined,
TrophyOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { apiClient } from '../../services/api/client';
const loading = ref(false);
const exporting = ref(false);
const report = ref<any>(null);
const campaigns = ref<any[]>([]);
const filters = reactive({
campaignId: undefined as string | undefined,
from: undefined as string | undefined,
to: undefined as string | undefined,
});
async function loadCampaigns() {
try {
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
campaigns.value = res.data || [];
} catch (err) {
// Handled
}
}
async function loadReport() {
loading.value = true;
try {
const res = await apiClient.get('/admin/reports/participation', {
params: {
campaignId: filters.campaignId || undefined,
from: filters.from || undefined,
to: filters.to || undefined,
},
});
report.value = res.data;
} finally {
loading.value = false;
}
}
async function exportExcel() {
exporting.value = true;
try {
const response = await apiClient.get('/admin/reports/export', {
params: {
campaignId: filters.campaignId || undefined,
from: filters.from || undefined,
to: filters.to || undefined,
},
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response as any]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `participation-report-${Date.now()}.xlsx`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
message.success('گزارش اکسل با موفقیت دانلود شد.');
} catch (err) {
message.error('خطا در دریافت گزارش اکسل');
} finally {
exporting.value = false;
}
}
onMounted(() => {
loadCampaigns();
loadReport();
});
</script>
<template>
<div>
<a-card :bordered="false" style="margin-bottom: 20px">
<div class="filters-header">
<div class="filters">
<a-select
v-model:value="filters.campaignId"
placeholder="انتخاب کمپین (اختیاری)"
allowClear
style="width: 240px"
@change="loadReport"
>
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</div>
<a-space>
<a-button @click="loadReport">
<template #icon><ReloadOutlined /></template>
به‌روزرسانی
</a-button>
<a-button type="primary" :loading="exporting" @click="exportExcel">
<template #icon><DownloadOutlined /></template>
خروجی اکسل گزارش
</a-button>
</a-space>
</div>
</a-card>
<a-spin :spinning="loading">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="8">
<a-card hoverable>
<a-statistic
title="تعداد کل کاربران ثبت‌نامی"
:value="report?.totalUsers || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
:value-style="{ color: '#3f8600', fontWeight: 'bold' }"
>
<template #prefix><TeamOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-card hoverable>
<a-statistic
title="تعداد کل چرخش‌ها (Spins)"
:value="report?.totalSpins || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
:value-style="{ color: '#1890ff', fontWeight: 'bold' }"
>
<template #prefix><ReloadOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-card hoverable>
<a-statistic
title="تعداد کل جوایز برنده شده"
:value="report?.winnersCount || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
:value-style="{ color: '#52c41a', fontWeight: 'bold' }"
>
<template #prefix><TrophyOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-card hoverable>
<a-statistic
title="کل شانس‌های تخصیص یافته"
:value="report?.totalChancesAllocated || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
:value-style="{ color: '#722ed1', fontWeight: 'bold' }"
>
<template #prefix><GiftOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-card hoverable>
<a-statistic
title="کل شانس‌های مصرف شده"
:value="report?.totalChancesUsed || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
:value-style="{ color: '#fa8c16', fontWeight: 'bold' }"
>
<template #prefix><BarChartOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-card hoverable>
<a-statistic
title="کاربران بدون چرخش (Zero Spin)"
:value="report?.zeroSpinUsers || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
:value-style="{ color: '#f5222d', fontWeight: 'bold' }"
>
<template #prefix><TeamOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-card hoverable>
<a-statistic
title="کاربران دارای شانس باقی‌مانده"
:value="report?.remainingChanceUsers || 0"
:formatter="(val: any) => formatFarsiNumber(val)"
:value-style="{ color: '#13c2c2', fontWeight: 'bold' }"
>
<template #prefix><TeamOutlined /></template>
</a-statistic>
</a-card>
</a-col>
</a-row>
</a-spin>
</div>
</template>
<style scoped>
.filters-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.filters {
display: flex;
gap: 12px;
}
</style>
+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
import {
RedoOutlined,
ReloadOutlined,
SearchOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { apiClient } from '../../services/api/client';
const loading = ref(false);
const logs = ref<any[]>([]);
const total = ref(0);
const filters = reactive({
page: 1,
pageSize: 10,
mobile: '',
status: undefined as string | undefined,
});
async function loadLogs() {
loading.value = true;
try {
const res = await apiClient.get('/admin/sms-logs', {
params: {
page: filters.page,
pageSize: filters.pageSize,
mobile: filters.mobile || undefined,
status: filters.status || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
logs.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
async function resendSms(id: string) {
try {
await apiClient.post(`/admin/sms-logs/${id}/resend`);
message.success('پیامک مجدداً در صف ارسال قرار گرفت.');
loadLogs();
} catch (err) {
// Handled
}
}
const columns = [
{ title: 'شماره همراه', dataIndex: 'mobile', key: 'mobile', width: 140 },
{ title: 'متن پیامک', dataIndex: 'message', key: 'message' },
{ title: 'سرویس‌دهنده', dataIndex: 'provider', key: 'provider', width: 120 },
{ title: 'شناسه پیامک', dataIndex: 'providerMessageId', key: 'providerMessageId', width: 150 },
{ title: 'وضعیت', dataIndex: 'status', key: 'status', width: 110 },
{ title: 'خطا', dataIndex: 'errorMessage', key: 'errorMessage', ellipsis: true },
{ title: 'زمان ثبت', dataIndex: 'createdAt', key: 'createdAt', width: 160 },
{ title: 'عملیات', key: 'actions', width: 120 },
];
function getStatusTag(status: string) {
switch (status) {
case 'SENT':
return { text: 'ارسال شد', color: 'green' };
case 'QUEUED':
return { text: 'در صف ارسال', color: 'orange' };
case 'FAILED':
return { text: 'خطا در ارسال', color: 'red' };
default:
return { text: status, color: 'default' };
}
}
onMounted(() => {
loadLogs();
});
</script>
<template>
<a-card :bordered="false">
<div class="table-header">
<div class="filters">
<a-input-search
v-model:value="filters.mobile"
placeholder="جستجوی شماره همراه..."
style="width: 220px"
@search="() => { filters.page = 1; loadLogs(); }"
/>
<a-select
v-model:value="filters.status"
placeholder="فیلتر وضعیت"
allowClear
style="width: 150px"
@change="() => { filters.page = 1; loadLogs(); }"
>
<a-select-option value="SENT">ارسال شد</a-select-option>
<a-select-option value="QUEUED">در صف ارسال</a-select-option>
<a-select-option value="FAILED">خطا در ارسال</a-select-option>
</a-select>
</div>
<a-button @click="loadLogs">
<template #icon><ReloadOutlined /></template>
به‌روزرسانی
</a-button>
</div>
<a-table
:dataSource="logs"
:columns="columns"
:loading="loading"
rowKey="id"
:pagination="{
current: filters.page,
pageSize: filters.pageSize,
total: total,
onChange: (page: number) => {
filters.page = page;
loadLogs();
},
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'mobile'">
{{ toFarsiDigits(record.mobile) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusTag(record.status).color">
{{ getStatusTag(record.status).text }}
</a-tag>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-button
v-if="record.status === 'FAILED'"
size="small"
type="link"
@click="resendSms(record.id)"
>
<template #icon><RedoOutlined /></template>
ارسال مجدد
</a-button>
</template>
</template>
</a-table>
</a-card>
</template>
<style scoped>
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.filters {
display: flex;
gap: 12px;
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import {
DownloadOutlined,
ReloadOutlined,
SearchOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { apiClient } from '../../services/api/client';
const loading = ref(false);
const exporting = ref(false);
const spins = ref<any[]>([]);
const total = ref(0);
const campaigns = ref<any[]>([]);
const filters = reactive({
page: 1,
pageSize: 10,
campaignId: undefined as string | undefined,
search: '',
});
async function loadCampaigns() {
try {
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
campaigns.value = res.data || [];
} catch (err) {
// Handled
}
}
async function loadSpins() {
loading.value = true;
try {
const res = await apiClient.get('/admin/spins', {
params: {
page: filters.page,
pageSize: filters.pageSize,
campaignId: filters.campaignId || undefined,
search: filters.search || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
spins.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
async function exportExcel() {
exporting.value = true;
try {
const response = await apiClient.get('/admin/spins/export', {
params: {
campaignId: filters.campaignId || undefined,
search: filters.search || undefined,
},
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response as any]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `spins-${Date.now()}.xlsx`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
message.success('فایل اکسل با موفقیت دانلود شد.');
} catch (err) {
message.error('خطا در دریافت فایل اکسل');
} finally {
exporting.value = false;
}
}
const columns = [
{ title: 'شماره همراه', dataIndex: ['user', 'mobile'], key: 'userMobile' },
{ title: 'نام کاربر', dataIndex: ['user', 'name'], key: 'userName' },
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaignTitle' },
{ title: 'جایزه / نتیجه', dataIndex: ['prize', 'title'], key: 'prizeTitle' },
{ title: 'نوع جایزه', dataIndex: ['prize', 'prizeType'], key: 'prizeType' },
{ title: 'نتیجه', dataIndex: 'isWin', key: 'isWin' },
{ title: 'کلید تکرارناپذیری (Idempotency)', dataIndex: 'idempotencyKey', key: 'idempotencyKey', ellipsis: true },
{ title: 'آی‌پی کاربر', dataIndex: 'ipAddress', key: 'ipAddress' },
{ title: 'زمان اسپین', dataIndex: 'createdAt', key: 'createdAt' },
];
onMounted(() => {
loadCampaigns();
loadSpins();
});
</script>
<template>
<a-card :bordered="false">
<div class="table-header">
<div class="filters">
<a-input-search
v-model:value="filters.search"
placeholder="جستجوی شماره همراه یا نام..."
style="width: 240px"
@search="() => { filters.page = 1; loadSpins(); }"
/>
<a-select
v-model:value="filters.campaignId"
placeholder="فیلتر کمپین"
allowClear
style="width: 200px"
@change="() => { filters.page = 1; loadSpins(); }"
>
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</div>
<a-space>
<a-button @click="loadSpins">
<template #icon><ReloadOutlined /></template>
به‌روزرسانی
</a-button>
<a-button type="primary" :loading="exporting" @click="exportExcel">
<template #icon><DownloadOutlined /></template>
خروجی اکسل
</a-button>
</a-space>
</div>
<a-table
:dataSource="spins"
:columns="columns"
:loading="loading"
rowKey="id"
:pagination="{
current: filters.page,
pageSize: filters.pageSize,
total: total,
onChange: (page: number) => {
filters.page = page;
loadSpins();
},
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userMobile'">
{{ toFarsiDigits(record.user?.mobile) }}
</template>
<template v-else-if="column.key === 'userName'">
{{ record.user?.name || '—' }}
</template>
<template v-else-if="column.key === 'isWin'">
<a-tag :color="record.isWin ? 'green' : 'default'">
{{ record.isWin ? 'برنده' : 'پوچ' }}
</a-tag>
</template>
<template v-else-if="column.key === 'idempotencyKey'">
<code style="font-size: 11px; color: #666">{{ record.idempotencyKey }}</code>
</template>
<template v-else-if="column.key === 'ipAddress'">
{{ toFarsiDigits(record.ipAddress) }}
</template>
<template v-else-if="column.key === 'createdAt'">
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
</template>
</template>
</a-table>
</a-card>
</template>
<style scoped>
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.filters {
display: flex;
gap: 12px;
}
</style>
@@ -0,0 +1,272 @@
<script setup lang="ts">
import {
ArrowRightOutlined,
GiftOutlined,
HistoryOutlined,
PlusCircleOutlined,
ReloadOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
const route = useRoute();
const router = useRouter();
const userId = ref<string>((route.params.id as string) || '');
const loading = ref(false);
const user = ref<any>(null);
// Adjust chance modal
const isChanceModalVisible = ref(false);
const isSubmittingChance = ref(false);
const selectedCampaignId = ref('');
const chanceAmount = ref(1);
const chanceReason = ref('');
const allCampaigns = ref<any[]>([]);
async function loadCampaigns() {
try {
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
allCampaigns.value = Array.isArray(res.data) ? res.data : (res.data?.data || []);
} catch (err) {
// Handled
}
}
async function loadUser() {
if (!userId.value) return;
loading.value = true;
try {
const res = await apiClient.get(`/admin/users/${userId.value}`);
user.value = res.data;
} finally {
loading.value = false;
}
}
function openChanceModal(campaignId?: string) {
selectedCampaignId.value =
campaignId ||
user.value?.userCampaigns?.[0]?.campaignId ||
allCampaigns.value?.[0]?.id ||
'';
chanceAmount.value = 1;
chanceReason.value = 'اهدای شانس تشویقی توسط مدیر';
isChanceModalVisible.value = true;
}
async function submitAdjustChance() {
if (!selectedCampaignId.value) {
message.error('انتخاب کمپین الزامی است.');
return;
}
if (!chanceAmount.value) {
message.error('مقدار شانس نامعتبر است.');
return;
}
isSubmittingChance.value = true;
try {
await apiClient.post('/admin/chances/adjust', {
userId: userId.value,
campaignId: selectedCampaignId.value,
amount: chanceAmount.value,
reason: chanceReason.value,
});
message.success('شانس کاربر با موفقیت تغییر کرد.');
isChanceModalVisible.value = false;
loadUser();
} finally {
isSubmittingChance.value = false;
}
}
const campaignColumns = [
{ title: 'عنوان کمپین', dataIndex: ['campaign', 'title'], key: 'title' },
{ title: 'شانس باقی‌مانده', dataIndex: 'remainingChances', key: 'remainingChances' },
{ title: 'کل شانس‌ها', dataIndex: 'totalChances', key: 'totalChances' },
{ title: 'شانس مصرف‌شده', dataIndex: 'usedChances', key: 'usedChances' },
{ title: 'عملیات', key: 'actions', width: 140 },
];
const prizeColumns = [
{ title: 'عنوان جایزه', dataIndex: ['prize', 'title'], key: 'title' },
{ title: 'نوع جایزه', dataIndex: ['prize', 'prizeType'], key: 'prizeType' },
{ title: 'کد دیجیتال', dataIndex: 'code', key: 'code' },
{ title: 'وضعیت', dataIndex: 'status', key: 'status' },
{ title: 'تاریخ دریافت', dataIndex: 'createdAt', key: 'createdAt' },
];
const spinColumns = [
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaign' },
{ title: 'نتیجه / جایزه', dataIndex: ['prize', 'title'], key: 'prize' },
{ title: 'برد؟', dataIndex: 'isWin', key: 'isWin' },
{ title: 'آی‌پی', dataIndex: 'ipAddress', key: 'ipAddress' },
{ title: 'تاریخ اسپین', dataIndex: 'createdAt', key: 'createdAt' },
];
onMounted(() => {
loadUser();
loadCampaigns();
});
</script>
<template>
<div>
<a-page-header
style="background: #fff; margin-bottom: 20px; border-radius: 8px; padding: 16px 24px"
:title="`پروفایل کاربر: ${user?.mobile || 'در حال بارگذاری...'}`"
:sub-title="user?.name || 'بدون نام'"
@back="router.push('/users')"
>
<template #extra>
<a-space>
<a-button @click="loadUser">
<template #icon><ReloadOutlined /></template>
به‌روزرسانی
</a-button>
<a-button type="primary" @click="() => openChanceModal()">
<template #icon><PlusCircleOutlined /></template>
افزودن شانس
</a-button>
</a-space>
</template>
</a-page-header>
<a-row :gutter="16">
<a-col :span="24">
<a-card title="اطلاعات هویتی و حساب" :bordered="false" style="margin-bottom: 20px">
<a-descriptions bordered size="small" :column="{ xs: 1, sm: 2, md: 4 }">
<a-descriptions-item label="شماره موبایل">{{ toFarsiDigits(user?.mobile) }}</a-descriptions-item>
<a-descriptions-item label="نام و نام خانوادگی">{{ user?.name || '—' }}</a-descriptions-item>
<a-descriptions-item label="وضعیت مسدودی">
<a-tag :color="user?.isBlocked ? 'red' : 'green'">
{{ user?.isBlocked ? 'مسدود شده' : 'فعال' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت‌نام">
{{ user?.createdAt ? toFarsiDigits(new Date(user.createdAt).toLocaleDateString('fa-IR')) : '—' }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</a-row>
<a-card title="موجودی شانس در کمپین‌ها" :bordered="false" style="margin-bottom: 20px">
<a-table
:dataSource="user?.userCampaigns || []"
:columns="campaignColumns"
:loading="loading"
rowKey="id"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'remainingChances'">
<a-badge :count="toFarsiDigits(record.remainingChances)" :overflowCount="999" :number-style="{ backgroundColor: '#52c41a' }" />
</template>
<template v-else-if="column.key === 'totalChances'">
{{ toFarsiDigits(record.totalChances) }}
</template>
<template v-else-if="column.key === 'usedChances'">
{{ toFarsiDigits(record.usedChances) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-button size="small" type="primary" ghost @click="openChanceModal(record.campaignId)">
تغییر شانس
</a-button>
</template>
</template>
</a-table>
</a-card>
<a-tabs defaultActiveKey="prizes" type="card" style="background: #fff; padding: 16px; border-radius: 8px">
<a-tab-pane key="prizes" tab="جوایز دریافت‌شده">
<a-table
:dataSource="user?.prizes || []"
:columns="prizeColumns"
:loading="loading"
rowKey="id"
:pagination="{ pageSize: 5 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'code'">
<code v-if="record.code" style="background: #f5f5f5; padding: 2px 6px; border-radius: 4px">
{{ toFarsiDigits(record.code) }}
</code>
<span v-else>—</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag color="blue">{{ record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ toFarsiDigits(new Date(record.createdAt).toLocaleDateString('fa-IR')) }}
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="spins" tab="تاریخچه اسپین‌ها">
<a-table
:dataSource="user?.spins || []"
:columns="spinColumns"
:loading="loading"
rowKey="id"
:pagination="{ pageSize: 5 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'isWin'">
<a-tag :color="record.isWin ? 'green' : 'default'">
{{ record.isWin ? 'برنده' : 'پوچ' }}
</a-tag>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
<!-- Adjust Chance Modal -->
<a-modal
v-model:open="isChanceModalVisible"
title="تنظیم شانس کاربر در کمپین"
:confirmLoading="isSubmittingChance"
okText="ثبت تغییرات"
cancelText="انصراف"
@ok="submitAdjustChance"
>
<a-form layout="vertical">
<a-form-item label="کمپین مورد نظر" required>
<a-select
v-model:value="selectedCampaignId"
placeholder="انتخاب کمپین"
style="width: 100%"
>
<a-select-option
v-for="c in allCampaigns"
:key="c.id"
:value="c.id"
>
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="تغییر شانس (+ برای افزایش، - برای کسر)" required>
<a-input-number v-model:value="chanceAmount" style="width: 100%" />
</a-form-item>
<a-form-item label="دلیل / بابت" required>
<a-input v-model:value="chanceReason" placeholder="مثال: خرید مجدد مشتری" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
+302
View File
@@ -0,0 +1,302 @@
<script setup lang="ts">
import {
CheckCircleOutlined,
CloseCircleOutlined,
EyeOutlined,
LockOutlined,
PlusCircleOutlined,
SearchOutlined,
UnlockOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { onMounted, reactive, ref } from 'vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { useRouter } from 'vue-router';
import { apiClient } from '../../services/api/client';
const router = useRouter();
const loading = ref(false);
const users = ref<any[]>([]);
const total = ref(0);
const filters = reactive({
page: 1,
pageSize: 10,
search: '',
status: undefined as string | undefined,
});
// Adjust Chance Modal state
const isChanceModalVisible = ref(false);
const isSubmittingChance = ref(false);
const campaigns = ref<any[]>([]);
const chanceForm = reactive({
userId: '',
userMobile: '',
campaignId: '',
amount: 1,
reason: '',
});
async function loadCampaigns() {
try {
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
campaigns.value = Array.isArray(res.data) ? res.data : (res.data?.data || []);
} catch (err) {
// Handled
}
}
async function loadUsers() {
loading.value = true;
try {
const res = await apiClient.get('/admin/users', {
params: {
page: filters.page,
pageSize: filters.pageSize,
search: filters.search || undefined,
status: filters.status || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
users.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
async function toggleBlock(record: any) {
const isCurrentlyBlocked = record.status === 'BLOCKED';
const newBlockedState = !isCurrentlyBlocked;
try {
await apiClient.patch(`/admin/users/${record.id}/block`, {
blocked: newBlockedState,
reason: newBlockedState ? 'مسدودسازی توسط مدیر' : 'رفع مسدودی توسط مدیر',
});
message.success(newBlockedState ? 'کاربر با موفقیت مسدود شد.' : 'مسدودی کاربر رفع شد.');
loadUsers();
} catch (err) {
// Handled
}
}
function openAdjustChance(record: any) {
chanceForm.userId = record.id;
chanceForm.userMobile = record.mobile;
chanceForm.campaignId = campaigns.value[0]?.id || '';
chanceForm.amount = 1;
chanceForm.reason = 'اهدای شانس تشویقی توسط مدیر';
isChanceModalVisible.value = true;
}
async function submitAdjustChance() {
if (!chanceForm.campaignId) {
message.error('لطفاً کمپین مورد نظر را انتخاب کنید.');
return;
}
if (!chanceForm.amount || chanceForm.amount === 0) {
message.error('مقدار شانس باید غیر صفر باشد.');
return;
}
if (!chanceForm.reason) {
message.error('دلیل تغییر شانس الزامی است.');
return;
}
isSubmittingChance.value = true;
try {
await apiClient.post('/admin/chances/adjust', {
userId: chanceForm.userId,
campaignId: chanceForm.campaignId,
amount: chanceForm.amount,
reason: chanceForm.reason,
});
message.success('شانس کاربر با موفقیت تغییر یافت.');
isChanceModalVisible.value = false;
loadUsers();
} finally {
isSubmittingChance.value = false;
}
}
const columns = [
{ title: 'شماره موبایل', dataIndex: 'mobile', key: 'mobile' },
{ title: 'نام کاربر', dataIndex: 'name', key: 'name' },
{ title: 'تاریخ ثبت‌نام', dataIndex: 'createdAt', key: 'createdAt' },
{ title: 'آخرین ورود', dataIndex: 'lastLoginAt', key: 'lastLoginAt' },
{ title: 'تعداد اسپین', dataIndex: ['_count', 'spins'], key: 'spinsCount' },
{ title: 'تعداد جوایز برنده', key: 'prizesCount' },
{ title: 'وضعیت حساب', dataIndex: 'status', key: 'status' },
{ title: 'عملیات', key: 'actions', width: 220 },
];
onMounted(() => {
loadCampaigns();
loadUsers();
});
</script>
<template>
<a-card :bordered="false">
<div class="table-header">
<div class="filters">
<a-input-search
v-model:value="filters.search"
placeholder="جستجوی موبایل یا نام..."
style="width: 240px"
@search="() => { filters.page = 1; loadUsers(); }"
/>
<a-select
v-model:value="filters.status"
placeholder="وضعیت حساب"
allowClear
style="width: 140px"
@change="() => { filters.page = 1; loadUsers(); }"
>
<a-select-option value="ACTIVE">فعال</a-select-option>
<a-select-option value="BLOCKED">مسدود</a-select-option>
</a-select>
</div>
</div>
<a-table
:dataSource="users"
:columns="columns"
:loading="loading"
rowKey="id"
:pagination="{
current: filters.page,
pageSize: filters.pageSize,
total: total,
onChange: (page: number) => {
filters.page = page;
loadUsers();
},
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'mobile'">
{{ toFarsiDigits(record.mobile) }}
</template>
<template v-else-if="column.key === 'name'">
{{ record.name || '—' }}
</template>
<template v-else-if="column.key === 'createdAt'">
{{ toFarsiDigits(new Date(record.createdAt).toLocaleDateString('fa-IR')) }}
</template>
<template v-else-if="column.key === 'lastLoginAt'">
{{ record.lastLoginAt ? toFarsiDigits(new Date(record.lastLoginAt).toLocaleDateString('fa-IR')) : '—' }}
</template>
<template v-else-if="column.key === 'prizesCount'">
{{ toFarsiDigits(record._count?.userPrizes ?? record._count?.prizes ?? 0) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'BLOCKED' ? 'red' : 'green'">
{{ record.status === 'BLOCKED' ? 'مسدود' : 'فعال' }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space size="small">
<a-tooltip title="مشاهده پروفایل و جزییات">
<a-button
size="small"
@click="router.push(`/users/${record.id}`)"
>
<template #icon><EyeOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="تنظیم شانس">
<a-button
size="small"
type="primary"
ghost
@click="openAdjustChance(record)"
>
<template #icon><PlusCircleOutlined /></template>
</a-button>
</a-tooltip>
<a-popconfirm
:title="record.status === 'BLOCKED' ? 'رفع مسدودی کاربر؟' : 'آیا از مسدود کردن این کاربر اطمینان دارید؟'"
ok-text="تأیید"
cancel-text="انصراف"
@confirm="toggleBlock(record)"
>
<a-tooltip :title="record.status === 'BLOCKED' ? 'رفع مسدودی' : 'مسدودسازی'">
<a-button size="small" :danger="record.status !== 'BLOCKED'">
<template #icon>
<UnlockOutlined v-if="record.status === 'BLOCKED'" />
<LockOutlined v-else />
</template>
</a-button>
</a-tooltip>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- Adjust Chance Modal -->
<a-modal
v-model:open="isChanceModalVisible"
title="افزایش / کاهش شانس کاربر"
:confirmLoading="isSubmittingChance"
okText="ثبت تغییر شانس"
cancelText="انصراف"
@ok="submitAdjustChance"
>
<a-form layout="vertical">
<a-alert
:message="`کاربر: ${chanceForm.userMobile}`"
description="تغییر شانس مستقیماً در دفترکل تراکنش‌های شانس (Ledger) و اسنپ‌شات کاربر با لاگ بازرسی ثبت می‌گردد."
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-form-item label="کمپین مورد نظر" required>
<a-select v-model:value="chanceForm.campaignId" placeholder="انتخاب کمپین">
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="مقدار تغییر (+ برای افزایش، - برای کسر)"
required
tooltip="مثال: ۱ برای اهدای یک شانس، ۲- برای کسر دو شانس"
>
<a-input-number v-model:value="chanceForm.amount" style="width: 100%" />
</a-form-item>
<a-form-item label="دلیل / بابت" required>
<a-input v-model:value="chanceForm.reason" placeholder="مثال: خرید از فروشگاه / هدیه پشتیبانی" />
</a-form-item>
</a-form>
</a-modal>
</a-card>
</template>
<style scoped>
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.filters {
display: flex;
gap: 12px;
}
</style>
@@ -0,0 +1,305 @@
<script setup lang="ts">
import {
DownloadOutlined,
EditOutlined,
ReloadOutlined,
SearchOutlined,
} from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { formatFarsiNumber, toFarsiDigits } from '@lucky-wheel/shared-types';
import { onMounted, reactive, ref } from 'vue';
import { apiClient } from '../../services/api/client';
const loading = ref(false);
const exporting = ref(false);
const winners = ref<any[]>([]);
const total = ref(0);
const campaigns = ref<any[]>([]);
const filters = reactive({
page: 1,
pageSize: 10,
campaignId: undefined as string | undefined,
status: undefined as string | undefined,
search: '',
});
// Update Status Modal
const isStatusModalVisible = ref(false);
const isSubmittingStatus = ref(false);
const statusForm = reactive({
id: '',
winnerMobile: '',
prizeTitle: '',
status: 'CONTACTED',
note: '',
});
async function loadCampaigns() {
try {
const res = await apiClient.get('/admin/campaigns', { params: { pageSize: 100 } });
campaigns.value = res.data || [];
} catch (err) {
// Handled
}
}
async function loadWinners() {
loading.value = true;
try {
const res = await apiClient.get('/admin/user-prizes', {
params: {
page: filters.page,
pageSize: filters.pageSize,
campaignId: filters.campaignId || undefined,
status: filters.status || undefined,
search: filters.search || undefined,
},
});
const items = Array.isArray(res.data) ? res.data : (res.data?.data || []);
winners.value = items;
total.value = res.meta?.total || res.data?.meta?.total || items.length;
} finally {
loading.value = false;
}
}
function openStatusModal(record: any) {
statusForm.id = record.id;
statusForm.winnerMobile = record.user?.mobile;
statusForm.prizeTitle = record.prize?.title;
statusForm.status = record.status;
statusForm.note = '';
isStatusModalVisible.value = true;
}
async function submitStatusUpdate() {
isSubmittingStatus.value = true;
try {
await apiClient.patch(`/admin/user-prizes/${statusForm.id}/status`, {
status: statusForm.status,
note: statusForm.note || undefined,
});
message.success('وضعیت جایزه برنده با موفقیت به‌روزرسانی شد.');
isStatusModalVisible.value = false;
loadWinners();
} catch (err) {
// Handled
} finally {
isSubmittingStatus.value = false;
}
}
async function exportExcel() {
exporting.value = true;
try {
const response = await apiClient.get('/admin/user-prizes/export', {
params: {
campaignId: filters.campaignId || undefined,
status: filters.status || undefined,
search: filters.search || undefined,
},
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response as any]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `winners-${Date.now()}.xlsx`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
message.success('فایل اکسل برندگان با موفقیت دانلود شد.');
} catch (err) {
message.error('خطا در دریافت فایل اکسل برندگان');
} finally {
exporting.value = false;
}
}
const columns = [
{ title: 'شماره همراه', dataIndex: ['user', 'mobile'], key: 'mobile' },
{ title: 'نام کاربر', dataIndex: ['user', 'name'], key: 'name' },
{ title: 'کمپین', dataIndex: ['campaign', 'title'], key: 'campaign' },
{ title: 'عنوان جایزه', dataIndex: ['prize', 'title'], key: 'prizeTitle' },
{ title: 'نوع جایزه', dataIndex: ['prize', 'prizeType'], key: 'prizeType' },
{ title: 'کد دیجیتال / پیگیری', dataIndex: 'code', key: 'code' },
{ title: 'وضعیت پیگیری', dataIndex: 'status', key: 'status' },
{ title: 'تاریخ برنده شدن', dataIndex: 'createdAt', key: 'createdAt' },
{ title: 'عملیات', key: 'actions', width: 100 },
];
function getStatusTag(status: string) {
switch (status) {
case 'NEW':
return { text: 'جدید', color: 'blue' };
case 'CONTACTED':
return { text: 'تماس گرفته شده', color: 'orange' };
case 'DELIVERED':
return { text: 'تحویل داده شده', color: 'green' };
case 'ACTIVE_CODE':
return { text: 'کد فعال', color: 'cyan' };
case 'USED':
return { text: 'مصرف شده', color: 'purple' };
case 'EXPIRED':
return { text: 'منقضی شده', color: 'red' };
default:
return { text: status, color: 'default' };
}
}
onMounted(() => {
loadCampaigns();
loadWinners();
});
</script>
<template>
<a-card :bordered="false">
<div class="table-header">
<div class="filters">
<a-input-search
v-model:value="filters.search"
placeholder="جستجوی موبایل یا نام..."
style="width: 220px"
@search="() => { filters.page = 1; loadWinners(); }"
/>
<a-select
v-model:value="filters.campaignId"
placeholder="فیلتر کمپین"
allowClear
style="width: 180px"
@change="() => { filters.page = 1; loadWinners(); }"
>
<a-select-option v-for="c in campaigns" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
<a-select
v-model:value="filters.status"
placeholder="فیلتر وضعیت"
allowClear
style="width: 160px"
@change="() => { filters.page = 1; loadWinners(); }"
>
<a-select-option value="NEW">جدید</a-select-option>
<a-select-option value="CONTACTED">تماس گرفته شده</a-select-option>
<a-select-option value="DELIVERED">تحویل شده</a-select-option>
<a-select-option value="ACTIVE_CODE">کد فعال</a-select-option>
<a-select-option value="USED">مصرف شده</a-select-option>
<a-select-option value="EXPIRED">منقضی شده</a-select-option>
</a-select>
</div>
<a-space>
<a-button @click="loadWinners">
<template #icon><ReloadOutlined /></template>
به‌روزرسانی
</a-button>
<a-button type="primary" :loading="exporting" @click="exportExcel">
<template #icon><DownloadOutlined /></template>
خروجی اکسل برندگان
</a-button>
</a-space>
</div>
<a-table
:dataSource="winners"
:columns="columns"
:loading="loading"
rowKey="id"
:pagination="{
current: filters.page,
pageSize: filters.pageSize,
total: total,
onChange: (page: number) => {
filters.page = page;
loadWinners();
},
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'mobile'">
{{ toFarsiDigits(record.user?.mobile) }}
</template>
<template v-else-if="column.key === 'name'">
{{ record.user?.name || '—' }}
</template>
<template v-else-if="column.key === 'code'">
<code v-if="record.code" style="background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-weight: 600">
{{ toFarsiDigits(record.code) }}
</code>
<span v-else>—</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusTag(record.status).color">
{{ getStatusTag(record.status).text }}
</a-tag>
</template>
<template v-else-if="column.key === 'createdAt'">
{{ toFarsiDigits(new Date(record.createdAt).toLocaleString('fa-IR')) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-button size="small" type="link" @click="openStatusModal(record)">
<template #icon><EditOutlined /></template>
تغییر وضعیت
</a-button>
</template>
</template>
</a-table>
<!-- Update Status Modal -->
<a-modal
v-model:open="isStatusModalVisible"
title="تغییر وضعیت پیگیری جایزه برنده"
:confirmLoading="isSubmittingStatus"
okText="ثبت تغییر وضعیت"
cancelText="انصراف"
@ok="submitStatusUpdate"
>
<a-form layout="vertical">
<a-alert
:message="`برنده: ${statusForm.winnerMobile} | جایزه: ${statusForm.prizeTitle}`"
type="info"
show-icon
style="margin-bottom: 16px"
/>
<a-form-item label="وضعیت جدید" required>
<a-select v-model:value="statusForm.status">
<a-select-option value="NEW">جدید</a-select-option>
<a-select-option value="CONTACTED">تماس گرفته شده (هماهنگی آدرس)</a-select-option>
<a-select-option value="DELIVERED">ارسال و تحویل شد</a-select-option>
<a-select-option value="ACTIVE_CODE">کد دیجیتال فعال</a-select-option>
<a-select-option value="USED">کد مصرف شد</a-select-option>
<a-select-option value="EXPIRED">منقضی شد</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="یادداشت و توضیحات پیگیری">
<a-textarea v-model:value="statusForm.note" rows="3" placeholder="مثال: کد رهگیری پست: 123456789 یا هماهنگی با کاربر" />
</a-form-item>
</a-form>
</a-modal>
</a-card>
</template>
<style scoped>
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.filters {
display: flex;
gap: 12px;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
import vue from '@vitejs/plugin-vue';
import path from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@lucky-wheel/shared-types': path.resolve(__dirname, '../../packages/shared-types/src/index.ts'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});
+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 }>;
}

Some files were not shown because too many files have changed in this diff Show More