commit 077da7eacd3870eb74dc4c5f9be76e95fd6571da Author: s.esmaeili Date: Thu Sep 10 01:58:29 2026 +0330 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da76eec --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +build/ +.env +.env.local +*.log +uploads/ +coverage/ +.DS_Store +Thumbs.db diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..76b8e7e --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,26 @@ + + + + + + + پنل مدیریت گردونه شانس و قرعه‌کشی + + + + + + +
+ + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..bf6f80b --- /dev/null +++ b/apps/admin/package.json @@ -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" + } +} diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue new file mode 100644 index 0000000..a5bbd84 --- /dev/null +++ b/apps/admin/src/App.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum).woff b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum).woff new file mode 100644 index 0000000..1e6eb36 Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum).woff differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum).woff2 b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum).woff2 new file mode 100644 index 0000000..c3dcbab Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum).woff2 differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff new file mode 100644 index 0000000..019c556 Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff2 b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff2 new file mode 100644 index 0000000..fc008ef Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff2 differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff new file mode 100644 index 0000000..7008b60 Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff2 b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff2 new file mode 100644 index 0000000..69f7c66 Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff2 differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff new file mode 100644 index 0000000..ecea926 Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff2 b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff2 new file mode 100644 index 0000000..40f5b09 Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff2 differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff new file mode 100644 index 0000000..587883a Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff2 b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff2 new file mode 100644 index 0000000..8273b0a Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff2 differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff new file mode 100644 index 0000000..c815b45 Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff differ diff --git a/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff2 b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff2 new file mode 100644 index 0000000..8b77d17 Binary files /dev/null and b/apps/admin/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff2 differ diff --git a/apps/admin/src/assets/fonts/fonts.css b/apps/admin/src/assets/fonts/fonts.css new file mode 100644 index 0000000..04da134 --- /dev/null +++ b/apps/admin/src/assets/fonts/fonts.css @@ -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; +} diff --git a/apps/admin/src/layouts/AdminLayout.vue b/apps/admin/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..8817a3a --- /dev/null +++ b/apps/admin/src/layouts/AdminLayout.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/apps/admin/src/main.ts b/apps/admin/src/main.ts new file mode 100644 index 0000000..85e72ea --- /dev/null +++ b/apps/admin/src/main.ts @@ -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'); diff --git a/apps/admin/src/router/index.ts b/apps/admin/src/router/index.ts new file mode 100644 index 0000000..66672c6 --- /dev/null +++ b/apps/admin/src/router/index.ts @@ -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(); + } +}); diff --git a/apps/admin/src/services/api/client.ts b/apps/admin/src/services/api/client.ts new file mode 100644 index 0000000..a651841 --- /dev/null +++ b/apps/admin/src/services/api/client.ts @@ -0,0 +1,124 @@ +import { message } from 'ant-design-vue'; +import axios from 'axios'; + +declare module 'axios' { + export interface AxiosResponse { + 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); + }, +); diff --git a/apps/admin/src/stores/auth.store.ts b/apps/admin/src/stores/auth.store.ts new file mode 100644 index 0000000..ecd0fb7 --- /dev/null +++ b/apps/admin/src/stores/auth.store.ts @@ -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(localStorage.getItem('admin_access_token')); + const admin = ref( + 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, + }; +}); diff --git a/apps/admin/src/views/audit/AuditLogView.vue b/apps/admin/src/views/audit/AuditLogView.vue new file mode 100644 index 0000000..ad58ed4 --- /dev/null +++ b/apps/admin/src/views/audit/AuditLogView.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/apps/admin/src/views/auth/LoginView.vue b/apps/admin/src/views/auth/LoginView.vue new file mode 100644 index 0000000..a322367 --- /dev/null +++ b/apps/admin/src/views/auth/LoginView.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/apps/admin/src/views/campaigns/CampaignFormView.vue b/apps/admin/src/views/campaigns/CampaignFormView.vue new file mode 100644 index 0000000..d853792 --- /dev/null +++ b/apps/admin/src/views/campaigns/CampaignFormView.vue @@ -0,0 +1,299 @@ + + + diff --git a/apps/admin/src/views/campaigns/CampaignListView.vue b/apps/admin/src/views/campaigns/CampaignListView.vue new file mode 100644 index 0000000..f6c5b79 --- /dev/null +++ b/apps/admin/src/views/campaigns/CampaignListView.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/apps/admin/src/views/campaigns/WheelDesignPicker.vue b/apps/admin/src/views/campaigns/WheelDesignPicker.vue new file mode 100644 index 0000000..7432ab4 --- /dev/null +++ b/apps/admin/src/views/campaigns/WheelDesignPicker.vue @@ -0,0 +1,482 @@ + + + + + diff --git a/apps/admin/src/views/dashboard/DashboardView.vue b/apps/admin/src/views/dashboard/DashboardView.vue new file mode 100644 index 0000000..73aacd3 --- /dev/null +++ b/apps/admin/src/views/dashboard/DashboardView.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/apps/admin/src/views/prizes/PrizeListView.vue b/apps/admin/src/views/prizes/PrizeListView.vue new file mode 100644 index 0000000..2eee9e7 --- /dev/null +++ b/apps/admin/src/views/prizes/PrizeListView.vue @@ -0,0 +1,779 @@ + + + diff --git a/apps/admin/src/views/reports/ReportView.vue b/apps/admin/src/views/reports/ReportView.vue new file mode 100644 index 0000000..ccb4cbe --- /dev/null +++ b/apps/admin/src/views/reports/ReportView.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/apps/admin/src/views/sms/SmsLogListView.vue b/apps/admin/src/views/sms/SmsLogListView.vue new file mode 100644 index 0000000..ccc3998 --- /dev/null +++ b/apps/admin/src/views/sms/SmsLogListView.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/apps/admin/src/views/spins/SpinListView.vue b/apps/admin/src/views/spins/SpinListView.vue new file mode 100644 index 0000000..bf49b73 --- /dev/null +++ b/apps/admin/src/views/spins/SpinListView.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/apps/admin/src/views/users/UserDetailView.vue b/apps/admin/src/views/users/UserDetailView.vue new file mode 100644 index 0000000..80477d6 --- /dev/null +++ b/apps/admin/src/views/users/UserDetailView.vue @@ -0,0 +1,272 @@ + + + diff --git a/apps/admin/src/views/users/UserListView.vue b/apps/admin/src/views/users/UserListView.vue new file mode 100644 index 0000000..a41e89f --- /dev/null +++ b/apps/admin/src/views/users/UserListView.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/apps/admin/src/views/winners/WinnerListView.vue b/apps/admin/src/views/winners/WinnerListView.vue new file mode 100644 index 0000000..cd86a9c --- /dev/null +++ b/apps/admin/src/views/winners/WinnerListView.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..5ded9a5 --- /dev/null +++ b/apps/admin/tsconfig.json @@ -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" }] +} diff --git a/apps/admin/tsconfig.node.json b/apps/admin/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/apps/admin/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts new file mode 100644 index 0000000..2aacc83 --- /dev/null +++ b/apps/admin/vite.config.ts @@ -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, + }, + }, + }, +}); diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..0eb0f58 --- /dev/null +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..63a43b1 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/prisma/migrations/20260909192201_init/migration.sql b/apps/api/prisma/migrations/20260909192201_init/migration.sql new file mode 100644 index 0000000..bc82dae --- /dev/null +++ b/apps/api/prisma/migrations/20260909192201_init/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..95aa39d --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -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") +} diff --git a/apps/api/prisma/seed-user.ts b/apps/api/prisma/seed-user.ts new file mode 100644 index 0000000..e1ee101 --- /dev/null +++ b/apps/api/prisma/seed-user.ts @@ -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(); + }); diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..2595e9a --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -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(); + }); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..01b49db --- /dev/null +++ b/apps/api/src/app.module.ts @@ -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('REDIS_HOST', 'localhost'), + port: configService.get('REDIS_PORT', 6379), + password: configService.get('REDIS_PASSWORD') || undefined, + }, + }), + inject: [ConfigService], + }), + PrismaModule, + AuthModule, + AdminAuthModule, + StorageModule, + CampaignModule, + PrizeModule, + ChanceModule, + UserModule, + SmsModule, + SpinEngineModule, + WinnerModule, + ReportModule, + AdminAuditModule, + ], +}) +export class AppModule {} diff --git a/apps/api/src/common/decorators/current-admin.decorator.ts b/apps/api/src/common/decorators/current-admin.decorator.ts new file mode 100644 index 0000000..fbdb221 --- /dev/null +++ b/apps/api/src/common/decorators/current-admin.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/common/decorators/current-user.decorator.ts b/apps/api/src/common/decorators/current-user.decorator.ts new file mode 100644 index 0000000..de3cb3c --- /dev/null +++ b/apps/api/src/common/decorators/current-user.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/common/decorators/roles.decorator.ts b/apps/api/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..1fe424c --- /dev/null +++ b/apps/api/src/common/decorators/roles.decorator.ts @@ -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); diff --git a/apps/api/src/common/filters/all-exceptions.filter.ts b/apps/api/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..427a775 --- /dev/null +++ b/apps/api/src/common/filters/all-exceptions.filter.ts @@ -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(); + + 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; + 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'; + } + } +} diff --git a/apps/api/src/common/guards/jwt-admin.guard.ts b/apps/api/src/common/guards/jwt-admin.guard.ts new file mode 100644 index 0000000..997b6a5 --- /dev/null +++ b/apps/api/src/common/guards/jwt-admin.guard.ts @@ -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; + } +} diff --git a/apps/api/src/common/guards/jwt-user.guard.ts b/apps/api/src/common/guards/jwt-user.guard.ts new file mode 100644 index 0000000..c670fcc --- /dev/null +++ b/apps/api/src/common/guards/jwt-user.guard.ts @@ -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; + } +} diff --git a/apps/api/src/common/guards/roles.guard.ts b/apps/api/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..4d998fd --- /dev/null +++ b/apps/api/src/common/guards/roles.guard.ts @@ -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(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; + } +} diff --git a/apps/api/src/common/interceptors/audit.interceptor.ts b/apps/api/src/common/interceptors/audit.interceptor.ts new file mode 100644 index 0000000..cd41ffc --- /dev/null +++ b/apps/api/src/common/interceptors/audit.interceptor.ts @@ -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 { + 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); + } + } + }), + ); + } +} diff --git a/apps/api/src/common/interceptors/response.interceptor.ts b/apps/api/src/common/interceptors/response.interceptor.ts new file mode 100644 index 0000000..ea6d90a --- /dev/null +++ b/apps/api/src/common/interceptors/response.interceptor.ts @@ -0,0 +1,32 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export interface ResponseFormat { + success: boolean; + data: T; + meta?: any; +} + +@Injectable() +export class ResponseInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + 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, + }; + }), + ); + } +} diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts new file mode 100644 index 0000000..ec36787 --- /dev/null +++ b/apps/api/src/config/env.validation.ts @@ -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) { + 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; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..2825753 --- /dev/null +++ b/apps/api/src/main.ts @@ -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(AppModule); + const configService = app.get(ConfigService); + + // Security Headers + app.use( + helmet({ + crossOriginResourcePolicy: { policy: 'cross-origin' }, + }), + ); + + // CORS + const adminOrigin = configService.get('CORS_ADMIN_ORIGIN', 'http://localhost:5173'); + const clientOrigin = configService.get('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('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('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(); diff --git a/apps/api/src/modules/admin-audit/admin-audit.controller.ts b/apps/api/src/modules/admin-audit/admin-audit.controller.ts new file mode 100644 index 0000000..d3516b5 --- /dev/null +++ b/apps/api/src/modules/admin-audit/admin-audit.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/admin-audit/admin-audit.module.ts b/apps/api/src/modules/admin-audit/admin-audit.module.ts new file mode 100644 index 0000000..e83f789 --- /dev/null +++ b/apps/api/src/modules/admin-audit/admin-audit.module.ts @@ -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 {} diff --git a/apps/api/src/modules/admin-audit/admin-audit.service.ts b/apps/api/src/modules/admin-audit/admin-audit.service.ts new file mode 100644 index 0000000..aaf408e --- /dev/null +++ b/apps/api/src/modules/admin-audit/admin-audit.service.ts @@ -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), + }, + }; + } +} diff --git a/apps/api/src/modules/admin-auth/admin-auth.controller.ts b/apps/api/src/modules/admin-auth/admin-auth.controller.ts new file mode 100644 index 0000000..a7f80e7 --- /dev/null +++ b/apps/api/src/modules/admin-auth/admin-auth.controller.ts @@ -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: 'با موفقیت خارج شدید.' }; + } +} diff --git a/apps/api/src/modules/admin-auth/admin-auth.module.ts b/apps/api/src/modules/admin-auth/admin-auth.module.ts new file mode 100644 index 0000000..2d5f995 --- /dev/null +++ b/apps/api/src/modules/admin-auth/admin-auth.module.ts @@ -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('JWT_ADMIN_ACCESS_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_ADMIN_ACCESS_EXPIRES_IN', '1h'), + }, + }), + inject: [ConfigService], + }), + ], + controllers: [AdminAuthController], + providers: [AdminAuthService, JwtAdminStrategy], + exports: [AdminAuthService, JwtModule], +}) +export class AdminAuthModule {} diff --git a/apps/api/src/modules/admin-auth/admin-auth.service.ts b/apps/api/src/modules/admin-auth/admin-auth.service.ts new file mode 100644 index 0000000..fc7014f --- /dev/null +++ b/apps/api/src/modules/admin-auth/admin-auth.service.ts @@ -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('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('JWT_ADMIN_ACCESS_SECRET'), + expiresIn: this.configService.get('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('JWT_ADMIN_ACCESS_SECRET'), + expiresIn: this.configService.get('JWT_ADMIN_ACCESS_EXPIRES_IN', '1h'), + }, + ); + + const refreshToken = this.jwtService.sign( + { + sub: admin.id, + type: 'admin_refresh', + }, + { + secret: this.configService.get('JWT_ADMIN_REFRESH_SECRET'), + expiresIn: this.configService.get('JWT_ADMIN_REFRESH_EXPIRES_IN', '7d'), + }, + ); + + return { + accessToken, + refreshToken, + }; + } +} diff --git a/apps/api/src/modules/admin-auth/dto/admin-login.dto.ts b/apps/api/src/modules/admin-auth/dto/admin-login.dto.ts new file mode 100644 index 0000000..9ce35f7 --- /dev/null +++ b/apps/api/src/modules/admin-auth/dto/admin-login.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/admin-auth/dto/admin-refresh.dto.ts b/apps/api/src/modules/admin-auth/dto/admin-refresh.dto.ts new file mode 100644 index 0000000..1c134b1 --- /dev/null +++ b/apps/api/src/modules/admin-auth/dto/admin-refresh.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/admin-auth/jwt-admin.strategy.ts b/apps/api/src/modules/admin-auth/jwt-admin.strategy.ts new file mode 100644 index 0000000..cc3c692 --- /dev/null +++ b/apps/api/src/modules/admin-auth/jwt-admin.strategy.ts @@ -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('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', + }; + } +} diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..cf23b7f --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..e144acb --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -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('JWT_USER_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_USER_EXPIRES_IN', '7d'), + }, + }), + inject: [ConfigService], + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtUserStrategy], + exports: [AuthService, JwtModule], +}) +export class AuthModule {} diff --git a/apps/api/src/modules/auth/auth.service.spec.ts b/apps/api/src/modules/auth/auth.service.spec.ts new file mode 100644 index 0000000..06ce967 --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.spec.ts @@ -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); + prisma = module.get(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 }, + }); + }); + }); +}); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..0bcca8d --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.ts @@ -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('JWT_USER_SECRET'), + expiresIn: this.configService.get('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('JWT_USER_SECRET', 'otp-secret-salt'); + return crypto.createHmac('sha256', secret).update(`${mobile}:${code}`).digest('hex'); + } +} diff --git a/apps/api/src/modules/auth/dto/send-otp.dto.ts b/apps/api/src/modules/auth/dto/send-otp.dto.ts new file mode 100644 index 0000000..e781ade --- /dev/null +++ b/apps/api/src/modules/auth/dto/send-otp.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/dto/verify-otp.dto.ts b/apps/api/src/modules/auth/dto/verify-otp.dto.ts new file mode 100644 index 0000000..93ae413 --- /dev/null +++ b/apps/api/src/modules/auth/dto/verify-otp.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/auth/jwt-user.strategy.ts b/apps/api/src/modules/auth/jwt-user.strategy.ts new file mode 100644 index 0000000..2984075 --- /dev/null +++ b/apps/api/src/modules/auth/jwt-user.strategy.ts @@ -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('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', + }; + } +} diff --git a/apps/api/src/modules/campaign/admin-campaign.controller.ts b/apps/api/src/modules/campaign/admin-campaign.controller.ts new file mode 100644 index 0000000..b154884 --- /dev/null +++ b/apps/api/src/modules/campaign/admin-campaign.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/campaign/campaign-availability.service.ts b/apps/api/src/modules/campaign/campaign-availability.service.ts new file mode 100644 index 0000000..f841285 --- /dev/null +++ b/apps/api/src/modules/campaign/campaign-availability.service.ts @@ -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; + } +} diff --git a/apps/api/src/modules/campaign/campaign.controller.ts b/apps/api/src/modules/campaign/campaign.controller.ts new file mode 100644 index 0000000..72284f1 --- /dev/null +++ b/apps/api/src/modules/campaign/campaign.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/campaign/campaign.module.ts b/apps/api/src/modules/campaign/campaign.module.ts new file mode 100644 index 0000000..6f6119e --- /dev/null +++ b/apps/api/src/modules/campaign/campaign.module.ts @@ -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 {} diff --git a/apps/api/src/modules/campaign/campaign.service.ts b/apps/api/src/modules/campaign/campaign.service.ts new file mode 100644 index 0000000..dd85516 --- /dev/null +++ b/apps/api/src/modules/campaign/campaign.service.ts @@ -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, + })), + }; + } +} diff --git a/apps/api/src/modules/campaign/dto/campaign-filter.dto.ts b/apps/api/src/modules/campaign/dto/campaign-filter.dto.ts new file mode 100644 index 0000000..6b77a87 --- /dev/null +++ b/apps/api/src/modules/campaign/dto/campaign-filter.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/campaign/dto/create-campaign.dto.ts b/apps/api/src/modules/campaign/dto/create-campaign.dto.ts new file mode 100644 index 0000000..babc82e --- /dev/null +++ b/apps/api/src/modules/campaign/dto/create-campaign.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/campaign/dto/update-campaign.dto.ts b/apps/api/src/modules/campaign/dto/update-campaign.dto.ts new file mode 100644 index 0000000..e5a8ecd --- /dev/null +++ b/apps/api/src/modules/campaign/dto/update-campaign.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCampaignDto } from './create-campaign.dto'; + +export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {} diff --git a/apps/api/src/modules/chance/admin-chance.controller.ts b/apps/api/src/modules/chance/admin-chance.controller.ts new file mode 100644 index 0000000..ef00236 --- /dev/null +++ b/apps/api/src/modules/chance/admin-chance.controller.ts @@ -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, + ); + } +} + diff --git a/apps/api/src/modules/chance/chance.module.ts b/apps/api/src/modules/chance/chance.module.ts new file mode 100644 index 0000000..e511275 --- /dev/null +++ b/apps/api/src/modules/chance/chance.module.ts @@ -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 {} diff --git a/apps/api/src/modules/chance/chance.service.ts b/apps/api/src/modules/chance/chance.service.ts new file mode 100644 index 0000000..dca4acb --- /dev/null +++ b/apps/api/src/modules/chance/chance.service.ts @@ -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 } }, + }, + }); + } +} diff --git a/apps/api/src/modules/chance/dto/adjust-chance.dto.ts b/apps/api/src/modules/chance/dto/adjust-chance.dto.ts new file mode 100644 index 0000000..84cb3fe --- /dev/null +++ b/apps/api/src/modules/chance/dto/adjust-chance.dto.ts @@ -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; +} + diff --git a/apps/api/src/modules/prize/admin-prize.controller.ts b/apps/api/src/modules/prize/admin-prize.controller.ts new file mode 100644 index 0000000..2d93e1c --- /dev/null +++ b/apps/api/src/modules/prize/admin-prize.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/prize/dto/create-prize.dto.ts b/apps/api/src/modules/prize/dto/create-prize.dto.ts new file mode 100644 index 0000000..45c10c4 --- /dev/null +++ b/apps/api/src/modules/prize/dto/create-prize.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/prize/dto/update-prize.dto.ts b/apps/api/src/modules/prize/dto/update-prize.dto.ts new file mode 100644 index 0000000..d8060ca --- /dev/null +++ b/apps/api/src/modules/prize/dto/update-prize.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreatePrizeDto } from './create-prize.dto'; + +export class UpdatePrizeDto extends PartialType(CreatePrizeDto) {} diff --git a/apps/api/src/modules/prize/dto/update-stock.dto.ts b/apps/api/src/modules/prize/dto/update-stock.dto.ts new file mode 100644 index 0000000..5640763 --- /dev/null +++ b/apps/api/src/modules/prize/dto/update-stock.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/prize/prize.module.ts b/apps/api/src/modules/prize/prize.module.ts new file mode 100644 index 0000000..be1f9c8 --- /dev/null +++ b/apps/api/src/modules/prize/prize.module.ts @@ -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 {} diff --git a/apps/api/src/modules/prize/prize.service.ts b/apps/api/src/modules/prize/prize.service.ts new file mode 100644 index 0000000..a396d2c --- /dev/null +++ b/apps/api/src/modules/prize/prize.service.ts @@ -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 } }); + } +} diff --git a/apps/api/src/modules/report/admin-report.controller.ts b/apps/api/src/modules/report/admin-report.controller.ts new file mode 100644 index 0000000..584d0f8 --- /dev/null +++ b/apps/api/src/modules/report/admin-report.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/report/report.module.ts b/apps/api/src/modules/report/report.module.ts new file mode 100644 index 0000000..215156b --- /dev/null +++ b/apps/api/src/modules/report/report.module.ts @@ -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 {} diff --git a/apps/api/src/modules/report/report.service.ts b/apps/api/src/modules/report/report.service.ts new file mode 100644 index 0000000..ddbd312 --- /dev/null +++ b/apps/api/src/modules/report/report.service.ts @@ -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 = {}; + 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(); + } +} diff --git a/apps/api/src/modules/sms/admin-sms.controller.ts b/apps/api/src/modules/sms/admin-sms.controller.ts new file mode 100644 index 0000000..f3de7b2 --- /dev/null +++ b/apps/api/src/modules/sms/admin-sms.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/sms/providers/mock-sms.provider.ts b/apps/api/src/modules/sms/providers/mock-sms.provider.ts new file mode 100644 index 0000000..e3b1b0a --- /dev/null +++ b/apps/api/src/modules/sms/providers/mock-sms.provider.ts @@ -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 }, + }; + } +} diff --git a/apps/api/src/modules/sms/providers/raygan-sms.provider.ts b/apps/api/src/modules/sms/providers/raygan-sms.provider.ts new file mode 100644 index 0000000..3f053b0 --- /dev/null +++ b/apps/api/src/modules/sms/providers/raygan-sms.provider.ts @@ -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( + 'RAYGAN_SMS_API_BASE_URL', + 'https://api.raygansms.com', + ); + this.username = this.configService.get('RAYGAN_SMS_USERNAME'); + this.password = this.configService.get('RAYGAN_SMS_PASSWORD'); + this.fromLine = this.configService.get('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; + } + } +} diff --git a/apps/api/src/modules/sms/sms-failure.spec.ts b/apps/api/src/modules/sms/sms-failure.spec.ts new file mode 100644 index 0000000..2c62ff2 --- /dev/null +++ b/apps/api/src/modules/sms/sms-failure.spec.ts @@ -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); + prisma = module.get(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 } }); + }); +}); diff --git a/apps/api/src/modules/sms/sms.interface.ts b/apps/api/src/modules/sms/sms.interface.ts new file mode 100644 index 0000000..aaac318 --- /dev/null +++ b/apps/api/src/modules/sms/sms.interface.ts @@ -0,0 +1,6 @@ +export interface SmsProviderInterface { + send( + mobile: string, + message: string, + ): Promise<{ providerMessageId: string; raw: unknown }>; +} diff --git a/apps/api/src/modules/sms/sms.module.ts b/apps/api/src/modules/sms/sms.module.ts new file mode 100644 index 0000000..1db43d0 --- /dev/null +++ b/apps/api/src/modules/sms/sms.module.ts @@ -0,0 +1,31 @@ +import { BullModule } from '@nestjs/bullmq'; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { AdminSmsController } from './admin-sms.controller'; +import { MockSmsProvider } from './providers/mock-sms.provider'; +import { RayganSmsProvider } from './providers/raygan-sms.provider'; +import { SmsProcessor } from './sms.processor'; +import { SmsService } from './sms.service'; + +@Module({ + imports: [ + BullModule.registerQueue({ + name: 'sms', + }), + ], + controllers: [AdminSmsController], + providers: [ + SmsService, + SmsProcessor, + { + provide: 'SMS_PROVIDER', + useFactory: (config: ConfigService) => { + const driver = config.get('SMS_DRIVER', 'mock'); + return driver === 'raygansms' ? new RayganSmsProvider(config) : new MockSmsProvider(); + }, + inject: [ConfigService], + }, + ], + exports: [SmsService], +}) +export class SmsModule {} diff --git a/apps/api/src/modules/sms/sms.processor.ts b/apps/api/src/modules/sms/sms.processor.ts new file mode 100644 index 0000000..287f889 --- /dev/null +++ b/apps/api/src/modules/sms/sms.processor.ts @@ -0,0 +1,56 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Inject, Logger } from '@nestjs/common'; +import { SmsStatus, SmsType } from '@prisma/client'; +import { Job } from 'bullmq'; +import { PrismaService } from '../../prisma/prisma.service'; +import { SmsProviderInterface } from './sms.interface'; + +@Processor('sms') +export class SmsProcessor extends WorkerHost { + private readonly logger = new Logger(SmsProcessor.name); + + constructor( + private readonly prisma: PrismaService, + @Inject('SMS_PROVIDER') + private readonly provider: SmsProviderInterface, + ) { + super(); + } + + async process(job: Job): Promise { + const { mobile, message, type = SmsType.WINNING, userId } = job.data; + + const log = await this.prisma.smsLog.create({ + data: { + userId: userId || null, + mobile, + type, + message, + provider: 'raygansms', + status: SmsStatus.PENDING, + }, + }); + + try { + const result = await this.provider.send(mobile, message); + await this.prisma.smsLog.update({ + where: { id: log.id }, + data: { + status: SmsStatus.SENT, + providerResponse: result.raw as any, + }, + }); + return result; + } catch (err: any) { + this.logger.error(`SMS Job ${job.id} failed: ${err.message}`); + await this.prisma.smsLog.update({ + where: { id: log.id }, + data: { + status: SmsStatus.FAILED, + providerResponse: { error: err.message || String(err) }, + }, + }); + throw err; // Trigger BullMQ retry + } + } +} diff --git a/apps/api/src/modules/sms/sms.service.ts b/apps/api/src/modules/sms/sms.service.ts new file mode 100644 index 0000000..f7269e3 --- /dev/null +++ b/apps/api/src/modules/sms/sms.service.ts @@ -0,0 +1,142 @@ +import { InjectQueue } from '@nestjs/bullmq'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { SmsType } from '@prisma/client'; +import { Queue } from 'bullmq'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class SmsService { + private readonly logger = new Logger(SmsService.name); + + constructor( + @InjectQueue('sms') private readonly smsQueue: Queue, + private readonly prisma: PrismaService, + ) {} + + async enqueueOtp(mobile: string, code: string) { + const message = `کد ورود شما به سامانه گردونه شانس: ${code}`; + try { + await this.smsQueue.add( + 'otp', + { mobile, message, type: SmsType.OTP }, + { + attempts: 3, + backoff: { type: 'exponential', delay: 2000 }, + priority: 1, // High priority + removeOnComplete: true, + }, + ); + } catch (e: any) { + this.logger.error(`Failed to enqueue OTP SMS: ${e.message}`); + } + } + + async enqueueSpinResult(spinId: string) { + try { + const spin = await this.prisma.spin.findUnique({ + where: { id: spinId }, + include: { + user: true, + prize: true, + userPrize: true, + campaign: true, + }, + }); + + if (!spin || !spin.user) { + return; + } + + const mobile = spin.user.mobile; + let message = ''; + let type: SmsType = SmsType.WINNING; + + if (!spin.prize || spin.prize.type === 'NO_PRIZE') { + type = SmsType.NO_PRIZE; + message = `همراه گرامی، متأسفانه در این دور از گردونه ${spin.campaign.title} برنده نشدید. شانس‌های دیگر خود را امتحان کنید!`; + } else { + type = SmsType.WINNING; + const codeText = spin.userPrize?.code ? ` کد جایزه: ${spin.userPrize.code}` : ''; + if (spin.prize.smsTemplate) { + message = spin.prize.smsTemplate + .replace('{prize}', spin.prize.title) + .replace('{code}', spin.userPrize?.code || '') + .replace('{campaign}', spin.campaign.title); + } else { + message = `تبریک! شما در گردونه ${spin.campaign.title} برنده «${spin.prize.title}» شدید.${codeText}`; + } + } + + await this.smsQueue.add( + 'spin-result', + { + userId: spin.userId, + mobile, + message, + type, + }, + { + attempts: 3, + backoff: { type: 'exponential', delay: 3000 }, + removeOnComplete: true, + }, + ); + } catch (e: any) { + // Non-negotiable rule: Failure to enqueue SMS must NEVER fail or rollback the spin! + this.logger.error(`Failed to enqueue spin result SMS for spin ${spinId}: ${e.message}`); + } + } + + async resendSms(logId: string) { + const log = await this.prisma.smsLog.findUnique({ where: { id: logId } }); + if (!log) { + throw new NotFoundException('لاگ پیامک یافت نشد.'); + } + + await this.smsQueue.add( + 'resend', + { + userId: log.userId, + mobile: log.mobile, + message: log.message, + type: log.type, + }, + { + attempts: 3, + backoff: { type: 'exponential', delay: 2000 }, + }, + ); + + return { success: true, message: 'پیامک با موفقیت در صف ارسال مجدد قرار گرفت.' }; + } + + async getLogs(query: { page?: number; pageSize?: number; mobile?: string; status?: any }) { + const page = Number(query.page) || 1; + const pageSize = Number(query.pageSize) || 20; + const skip = (page - 1) * pageSize; + + const where: any = {}; + if (query.mobile) where.mobile = { contains: query.mobile }; + if (query.status) where.status = query.status; + + const [data, total] = await Promise.all([ + this.prisma.smsLog.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.smsLog.count({ where }), + ]); + + return { + data, + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } +} diff --git a/apps/api/src/modules/spin-engine/dto/spin-request.dto.ts b/apps/api/src/modules/spin-engine/dto/spin-request.dto.ts new file mode 100644 index 0000000..32fdb82 --- /dev/null +++ b/apps/api/src/modules/spin-engine/dto/spin-request.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsUUID } from 'class-validator'; + +export class SpinRequestDto { + @ApiProperty({ + example: 'd3b07384-d113-4ec3-a612-42fe1d3f56e9', + description: 'کلید یکتا تولیدشده توسط کلاینت برای تضمین Idempotency (UUID v4)', + }) + @IsUUID('4', { message: 'کلید Idempotency باید یک UUID v4 معتبر باشد' }) + @IsNotEmpty({ message: 'کلید Idempotency الزامی است' }) + idempotencyKey: string; +} diff --git a/apps/api/src/modules/spin-engine/prize-code-generator.ts b/apps/api/src/modules/spin-engine/prize-code-generator.ts new file mode 100644 index 0000000..acca62b --- /dev/null +++ b/apps/api/src/modules/spin-engine/prize-code-generator.ts @@ -0,0 +1,33 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import * as crypto from 'crypto'; +import { PrismaService } from '../../prisma/prisma.service'; + +type PrismaTx = Omit< + PrismaService, + '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' +>; + +@Injectable() +export class PrizeCodeGenerator { + /** + * Generates an uppercase unique prize voucher code e.g. MNT-7F92K + * Retries up to 5 times if collision occurs in the database. + */ + async generateUnique(tx: PrismaTx): Promise { + for (let i = 0; i < 5; i++) { + const randomHex = crypto.randomBytes(4).toString('hex').toUpperCase(); + const code = `MNT-${randomHex}`; + + const existing = await tx.userPrize.findUnique({ + where: { code }, + select: { id: true }, + }); + + if (!existing) { + return code; + } + } + + throw new InternalServerErrorException('خطا در تولید کد یکتای جایزه. لطفاً مجدداً تلاش کنید.'); + } +} diff --git a/apps/api/src/modules/spin-engine/spin-concurrency.spec.ts b/apps/api/src/modules/spin-engine/spin-concurrency.spec.ts new file mode 100644 index 0000000..4a7c1c6 --- /dev/null +++ b/apps/api/src/modules/spin-engine/spin-concurrency.spec.ts @@ -0,0 +1,230 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CampaignStatus, PrizeType, StockType } from '@prisma/client'; +import * as crypto from 'crypto'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CampaignAvailabilityService } from '../campaign/campaign-availability.service'; +import { ChanceModule } from '../chance/chance.module'; +import { SmsService } from '../sms/sms.service'; +import { PrizeCodeGenerator } from './prize-code-generator'; +import { SpinEngineService } from './spin-engine.service'; +import { WeightedRandomSelector } from './weighted-random-selector'; + +describe('SpinEngine Concurrency & Idempotency Integration Tests (AC-08, AC-09)', () => { + let service: SpinEngineService; + let prisma: PrismaService; + + const mockSmsService = { + enqueueSpinResult: jest.fn().mockResolvedValue(true), + enqueueOtp: jest.fn().mockResolvedValue(true), + }; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }), + PrismaModule, + ChanceModule, + ], + providers: [ + SpinEngineService, + CampaignAvailabilityService, + WeightedRandomSelector, + PrizeCodeGenerator, + { provide: SmsService, useValue: mockSmsService }, + ], + }).compile(); + + service = module.get(SpinEngineService); + prisma = module.get(PrismaService); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it('AC-08 (Concurrency): 10 concurrent spins on Prize with stock=1 should yield EXACTLY 1 winner and stock=0 (never negative)', async () => { + const timestamp = Date.now(); + // 1) Create Test Campaign + const campaign = await prisma.campaign.create({ + data: { + title: `Concurrency Test Campaign ${timestamp}`, + slug: `concurrency-test-${timestamp}`, + startAt: new Date(Date.now() - 86400000), // yesterday + endAt: new Date(Date.now() + 86400000 * 30), // 30 days later + status: CampaignStatus.ACTIVE, + defaultChances: 5, + }, + }); + + // 2) Create 1 High-Value Prize with Stock = 1 and High Weight + const iphonePrize = await prisma.prize.create({ + data: { + campaignId: campaign.id, + title: 'تنها آیفون باقی‌مانده', + type: PrizeType.PHYSICAL, + stockType: StockType.LIMITED, + initialStock: 1, + remainingStock: 1, + weight: 1000, + sortOrder: 1, + segmentColor: '#FFD700', + }, + }); + + // 3) Create Fallback Prize with Stock = 100 and Low Weight + const discountPrize = await prisma.prize.create({ + data: { + campaignId: campaign.id, + title: 'کد تخفیف عمومی', + type: PrizeType.DISCOUNT_CODE, + stockType: StockType.LIMITED, + initialStock: 100, + remainingStock: 100, + weight: 1, + sortOrder: 2, + segmentColor: '#00CED1', + }, + }); + + // 4) Create 10 distinct users with 5 chances each + const users = await Promise.all( + Array.from({ length: 10 }).map(async (_, idx) => { + const u = await prisma.user.create({ + data: { + mobile: `0999${timestamp.toString().slice(-4)}${idx.toString().padStart(3, '0')}`, + status: 'ACTIVE', + }, + }); + await prisma.userCampaign.create({ + data: { + userId: u.id, + campaignId: campaign.id, + totalChances: 5, + usedChances: 0, + }, + }); + return u; + }), + ); + + // 5) Fire 10 simultaneous Spin requests + const spinPromises = users.map((user) => + service.spin(user.id, campaign.id, crypto.randomUUID()), + ); + + const results = await Promise.all(spinPromises); + + // Verify all 10 spins succeeded + expect(results.length).toBe(10); + results.forEach((r) => { + expect(r.success).toBe(true); + expect(r.remainingChances).toBe(4); + }); + + // 6) CRITICAL VERIFICATION AC-08: + // How many won the iPhone? + const iphoneWinners = results.filter((r) => r.prize?.id === iphonePrize.id); + expect(iphoneWinners.length).toBe(1); + + // Check remaining_stock in DB + const updatedIphone = await prisma.prize.findUnique({ + where: { id: iphonePrize.id }, + }); + expect(updatedIphone?.remainingStock).toBe(0); // EXACTLY 0, NEVER NEGATIVE! + + // Check other 9 got the fallback prize + const discountWinners = results.filter((r) => r.prize?.id === discountPrize.id); + expect(discountWinners.length).toBe(9); + + const updatedDiscount = await prisma.prize.findUnique({ + where: { id: discountPrize.id }, + }); + expect(updatedDiscount?.remainingStock).toBe(91); // 100 - 9 = 91 + + // Cleanup + await prisma.userPrize.deleteMany({ where: { campaignId: campaign.id } }); + await prisma.spin.deleteMany({ where: { campaignId: campaign.id } }); + await prisma.chanceTransaction.deleteMany({ where: { campaignId: campaign.id } }); + await prisma.userCampaign.deleteMany({ where: { campaignId: campaign.id } }); + await prisma.prize.deleteMany({ where: { campaignId: campaign.id } }); + await prisma.campaign.delete({ where: { id: campaign.id } }); + await prisma.user.deleteMany({ where: { id: { in: users.map((u) => u.id) } } }); + }); + + it('AC-09 (Idempotency): Resending same request with identical idempotencyKey returns same result and consumes chance ONCE', async () => { + const timestamp = Date.now(); + const campaign = await prisma.campaign.create({ + data: { + title: `Idempotency Test ${timestamp}`, + slug: `idempotency-test-${timestamp}`, + startAt: new Date(Date.now() - 86400000), + endAt: new Date(Date.now() + 86400000 * 10), + status: CampaignStatus.ACTIVE, + defaultChances: 3, + }, + }); + + const prize = await prisma.prize.create({ + data: { + campaignId: campaign.id, + title: 'جایزه تستی', + type: PrizeType.DISCOUNT_CODE, + stockType: StockType.UNLIMITED, + weight: 100, + segmentColor: '#123456', + }, + }); + + const user = await prisma.user.create({ + data: { + mobile: `0998${timestamp.toString().slice(-7)}`, + status: 'ACTIVE', + }, + }); + + await prisma.userCampaign.create({ + data: { + userId: user.id, + campaignId: campaign.id, + totalChances: 3, + usedChances: 0, + }, + }); + + const idempotencyKey = crypto.randomUUID(); + + // First request + const response1 = await service.spin(user.id, campaign.id, idempotencyKey); + expect(response1.success).toBe(true); + expect(response1.remainingChances).toBe(2); + + // Second request with SAME idempotencyKey + const response2 = await service.spin(user.id, campaign.id, idempotencyKey); + expect(response2.success).toBe(true); + expect(response2.spinId).toBe(response1.spinId); + expect(response2.prize?.id).toBe(response1.prize?.id); + expect(response2.remainingChances).toBe(2); // Still 2, not decremented twice! + + // Verify in database: total spins with this key = 1, usedChances = 1 + const spinRecords = await prisma.spin.count({ + where: { idempotencyKey }, + }); + expect(spinRecords).toBe(1); + + const userCampaign = await prisma.userCampaign.findUnique({ + where: { userId_campaignId: { userId: user.id, campaignId: campaign.id } }, + }); + expect(userCampaign?.usedChances).toBe(1); + + // Cleanup + await prisma.userPrize.deleteMany({ where: { campaignId: campaign.id } }); + await prisma.spin.deleteMany({ where: { campaignId: campaign.id } }); + await prisma.chanceTransaction.deleteMany({ where: { campaignId: campaign.id } }); + await prisma.userCampaign.deleteMany({ where: { campaignId: campaign.id } }); + await prisma.prize.deleteMany({ where: { campaignId: campaign.id } }); + await prisma.campaign.delete({ where: { id: campaign.id } }); + await prisma.user.delete({ where: { id: user.id } }); + }); +}); diff --git a/apps/api/src/modules/spin-engine/spin-engine.controller.ts b/apps/api/src/modules/spin-engine/spin-engine.controller.ts new file mode 100644 index 0000000..16b0289 --- /dev/null +++ b/apps/api/src/modules/spin-engine/spin-engine.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { JwtUserGuard } from '../../common/guards/jwt-user.guard'; +import { SpinRequestDto } from './dto/spin-request.dto'; +import { SpinEngineService } from './spin-engine.service'; + +@ApiTags('Spin Engine (گردونه شانس)') +@Controller('campaigns') +@UseGuards(JwtUserGuard) +@ApiBearerAuth('UserAuth') +export class SpinEngineController { + constructor(private readonly spinEngineService: SpinEngineService) {} + + @Post(':id/spin') + @HttpCode(HttpStatus.OK) + @Throttle({ default: { limit: 20, ttl: 60000 } }) // 20 requests per minute + @ApiOperation({ + summary: 'چرخاندن گردونه شانس (تضمین اتمیک، جلوگیری از Concurrency و Idempotency)', + }) + @ApiResponse({ + status: 200, + description: 'نتیجه قرعه‌کشی با موفقیت توسط سرور تعیین و ثبت گردید.', + schema: { + example: { + success: true, + data: { + success: true, + spinId: 'd3b07384-d113-4ec3-a612-42fe1d3f56e9', + prize: { + id: 'prize-uuid', + title: 'کارت هدیه ۵۰۰ هزار تومانی', + segment: 3, + code: 'MNT-8F4KD2', + type: 'CREDIT', + image: 'http://localhost:3000/uploads/gift.png', + }, + remainingChances: 2, + }, + }, + }, + }) + async spin( + @Param('id') campaignId: string, + @CurrentUser('id') userId: string, + @Body() dto: SpinRequestDto, + @Req() req: any, + ) { + const ip = req.ip || req.headers['x-forwarded-for']; + const userAgent = req.headers['user-agent']; + + return this.spinEngineService.spin(userId, campaignId, dto.idempotencyKey, { + ip, + userAgent, + }); + } +} diff --git a/apps/api/src/modules/spin-engine/spin-engine.module.ts b/apps/api/src/modules/spin-engine/spin-engine.module.ts new file mode 100644 index 0000000..d3bfd5f --- /dev/null +++ b/apps/api/src/modules/spin-engine/spin-engine.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { CampaignModule } from '../campaign/campaign.module'; +import { ChanceModule } from '../chance/chance.module'; +import { SmsModule } from '../sms/sms.module'; +import { PrizeCodeGenerator } from './prize-code-generator'; +import { SpinEngineController } from './spin-engine.controller'; +import { SpinEngineService } from './spin-engine.service'; +import { WeightedRandomSelector } from './weighted-random-selector'; + +@Module({ + imports: [CampaignModule, ChanceModule, SmsModule], + controllers: [SpinEngineController], + providers: [SpinEngineService, WeightedRandomSelector, PrizeCodeGenerator], + exports: [SpinEngineService, WeightedRandomSelector, PrizeCodeGenerator], +}) +export class SpinEngineModule {} diff --git a/apps/api/src/modules/spin-engine/spin-engine.service.ts b/apps/api/src/modules/spin-engine/spin-engine.service.ts new file mode 100644 index 0000000..4b35e80 --- /dev/null +++ b/apps/api/src/modules/spin-engine/spin-engine.service.ts @@ -0,0 +1,362 @@ +import { + BadRequestException, + ConflictException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, Prize, PrizeType, Spin, StockType, UserCampaign, UserPrize } from '@prisma/client'; +import * as crypto from 'crypto'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CampaignAvailabilityService } from '../campaign/campaign-availability.service'; +import { SmsService } from '../sms/sms.service'; +import { PrizeCodeGenerator } from './prize-code-generator'; +import { WeightedRandomSelector } from './weighted-random-selector'; + +interface RawPrizeRow { + id: string; + campaign_id: string; + title: string; + description: string | null; + image: string | null; + type: PrizeType; + stock_type: StockType; + initial_stock: number | null; + remaining_stock: number | null; + weight: number; + status: string; + sort_order: number; + segment_color: string; +} + +interface RawUserCampaignRow { + id: string; + user_id: string; + campaign_id: string; + total_chances: number; + used_chances: number; +} + +export interface SpinResultOutput { + success: boolean; + spinId: string; + prize: { + id: string; + title: string; + segment: number; + code: string | null; + type: PrizeType; + image: string | null; + } | null; + remainingChances: number; +} + +@Injectable() +export class SpinEngineService { + private readonly logger = new Logger(SpinEngineService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly availabilityService: CampaignAvailabilityService, + private readonly weightedRandomSelector: WeightedRandomSelector, + private readonly prizeCodeGenerator: PrizeCodeGenerator, + private readonly smsService: SmsService, + ) {} + + /** + * Main Concurrency-Safe, Atomic Spin Algorithm with Idempotency Fast-Path and Row-Level Locks + */ + async spin( + userId: string, + campaignId: string, + idempotencyKey: string, + metadata?: { ip?: string; userAgent?: string }, + ): Promise { + // 1) Fast-Path Idempotency Check outside of heavy database transactions (AC-09) + const existing = await this.prisma.spin.findUnique({ + where: { idempotencyKey }, + include: { + prize: true, + userPrize: true, + }, + }); + + if (existing) { + this.logger.log(`[IDEMPOTENCY HIT] Returning existing spin result for key: ${idempotencyKey}`); + return this.formatExistingSpinResponse(existing, campaignId, userId); + } + + // Execute with automatic retry on serialization/deadlock conflicts (up to 5 retries with jitter) + let retries = 5; + while (retries >= 0) { + try { + return await this.executeSpinTransaction(userId, campaignId, idempotencyKey, metadata); + } catch (err: any) { + const isSerializationError = + err.code === 'P2034' || + err.message?.includes('could not serialize access') || + err.message?.includes('40001') || + err.message?.includes('deadlock') || + (err.code === 'P2010' && String(err.meta?.code) === '40001'); + + if (isSerializationError && retries > 0) { + retries--; + const jitter = crypto.randomInt(30, 150); + this.logger.warn(`Serialization conflict during spin. Retrying in ${jitter}ms... (${retries} attempts left)`); + await new Promise((res) => setTimeout(res, jitter)); + continue; + } + + // If duplicate idempotency key was inserted concurrently (Defense in depth) + if (err.code === 'P2002' && (err.meta?.target?.includes('idempotency_key') || err.message?.includes('idempotency_key'))) { + const concurrentSpin = await this.prisma.spin.findUnique({ + where: { idempotencyKey }, + include: { prize: true, userPrize: true }, + }); + if (concurrentSpin) { + return this.formatExistingSpinResponse(concurrentSpin, campaignId, userId); + } + } + + throw err; + } + } + + throw new InternalServerErrorException('خطا در پردازش اسپین بر اثر ترافیک بالا. لطفاً مجدداً تلاش کنید.'); + } + + private async executeSpinTransaction( + userId: string, + campaignId: string, + idempotencyKey: string, + metadata?: { ip?: string; userAgent?: string }, + ): Promise { + return this.prisma + .$transaction( + async (tx) => { + // 2) Validate Campaign and active date window (AC-14) + const campaign = await tx.campaign.findUnique({ + where: { id: campaignId }, + }); + if (!campaign) { + throw new NotFoundException('CAMPAIGN_NOT_FOUND'); + } + if (!this.availabilityService.isCampaignAvailable(campaign)) { + const error = this.availabilityService.getAvailabilityError(campaign); + const errorMessages: Record = { + CAMPAIGN_DRAFT: 'این کمپین در وضعیت پیش‌نویس (DRAFT) قرار دارد و هنوز فعال نشده است.', + CAMPAIGN_PAUSED: 'این کمپین موقتاً متوقف شده است.', + CAMPAIGN_NOT_STARTED: 'زمان شروع این کمپین هنوز فرا نرسیده است.', + CAMPAIGN_FINISHED: 'مهلت شرکت در این کمپین به پایان رسیده است.', + }; + const message = errorMessages[error || ''] || error || 'این کمپین در حال حاضر فعال نمی‌باشد.'; + throw new BadRequestException(message); + } + + // 3) Lock user_campaigns row with SELECT ... FOR UPDATE + // This queues parallel spins from the same user and prevents race conditions on chances + const userCampaigns = await tx.$queryRaw` + SELECT id, user_id, campaign_id, total_chances, used_chances + FROM user_campaigns + WHERE user_id = ${userId} AND campaign_id = ${campaignId} + FOR UPDATE + `; + + let userCampaign = userCampaigns[0]; + let totalChances = userCampaign?.total_chances ?? 0; + let usedChances = userCampaign?.used_chances ?? 0; + + // If record does not exist yet, auto-award defaultChances + if (!userCampaign) { + const defaultChances = campaign.defaultChances; + const newRecord = await tx.userCampaign.create({ + data: { + userId, + campaignId, + totalChances: defaultChances, + usedChances: 0, + }, + }); + await tx.chanceTransaction.create({ + data: { + userId, + campaignId, + amount: defaultChances, + type: 'SIGNUP_BONUS', + referenceType: 'campaign_entry', + description: 'شانس اولیه پیش‌فرض ورود به کمپین', + }, + }); + userCampaign = { + id: newRecord.id, + user_id: newRecord.userId, + campaign_id: newRecord.campaignId, + total_chances: newRecord.totalChances, + used_chances: 0, + }; + totalChances = defaultChances; + usedChances = 0; + } else if (totalChances < campaign.defaultChances) { + const diff = campaign.defaultChances - totalChances; + await tx.userCampaign.update({ + where: { id: userCampaign.id }, + data: { totalChances: { increment: diff } }, + }); + await tx.chanceTransaction.create({ + data: { + userId, + campaignId, + amount: diff, + type: 'ADMIN_ADJUSTMENT', + referenceType: 'campaign_default_chances_sync', + description: `همگام‌سازی خودکار با شانس پیش‌فرض کمپین (${campaign.defaultChances})`, + }, + }); + totalChances = campaign.defaultChances; + } + + const remainingChances = totalChances - usedChances; + if (remainingChances <= 0) { + throw new BadRequestException('NO_CHANCE_LEFT'); // AC-02 + } + + // 4) Lock eligible prizes in STRICT ID ORDER to prevent deadlocks (AC-07, AC-08) + const eligiblePrizes = await tx.$queryRaw` + SELECT id, campaign_id, title, description, image, type, stock_type, initial_stock, remaining_stock, weight, status, sort_order, segment_color + FROM prizes + WHERE campaign_id = ${campaignId} + AND status = 'ACTIVE' + AND weight > 0 + AND (stock_type = 'UNLIMITED' OR remaining_stock > 0) + ORDER BY id ASC + FOR UPDATE + `; + + if (!eligiblePrizes || eligiblePrizes.length === 0) { + throw new ConflictException('NO_ACTIVE_PRIZE'); + } + + // 5) Weighted Random Selection strictly on Backend (AC-05, AC-06) + const selected = this.weightedRandomSelector.select(eligiblePrizes); + + // 6) Decrement remaining_stock for LIMITED prize (AC-08: Race-safe guaranteed) + if (selected.stock_type === StockType.LIMITED) { + await tx.prize.update({ + where: { id: selected.id }, + data: { remainingStock: { decrement: 1 } }, + }); + } + + // 7) Consume 1 Chance (Ledger + Snapshot atomically) (AC-03) + await tx.chanceTransaction.create({ + data: { + userId, + campaignId, + amount: -1, + type: 'SPIN_CONSUME', + referenceType: 'spin', + description: `استفاده از شانس در قرعه‌کشی`, + }, + }); + + await tx.userCampaign.update({ + where: { id: userCampaign.id }, + data: { usedChances: { increment: 1 } }, + }); + + // 8) Record the Spin + const spin = await tx.spin.create({ + data: { + userId, + campaignId, + prizeId: selected.id, + idempotencyKey, + status: 'SUCCESS', + ip: metadata?.ip || null, + userAgent: metadata?.userAgent || null, + }, + }); + + // 9) If prize is NOT NO_PRIZE, generate unique voucher code and record user_prizes + let userPrize: UserPrize | null = null; + if (selected.type !== PrizeType.NO_PRIZE) { + const code = await this.prizeCodeGenerator.generateUnique(tx as any); + userPrize = await tx.userPrize.create({ + data: { + userId, + campaignId, + prizeId: selected.id, + spinId: spin.id, + code, + status: 'NEW', + wonAt: new Date(), + }, + }); + } + + return { + spin, + selectedPrize: selected, + userPrize, + remainingChances: remainingChances - 1, + }; + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, + timeout: 10000, + }, + ) + .then(async (result) => { + // 10) Enqueue SMS notification asynchronously after successful commit (AC-12) + // Failure here NEVER rolls back or impacts the spin! + try { + await this.smsService.enqueueSpinResult(result.spin.id); + } catch (smsErr) { + this.logger.error(`Failed to enqueue spin SMS (non-blocking): ${smsErr}`); + } + + return { + success: true, + spinId: result.spin.id, + prize: { + id: result.selectedPrize.id, + title: result.selectedPrize.title, + segment: result.selectedPrize.sort_order, + code: result.userPrize?.code || null, + type: result.selectedPrize.type, + image: result.selectedPrize.image, + }, + remainingChances: result.remainingChances, + }; + }); + } + + private async formatExistingSpinResponse( + spin: Spin & { prize: Prize | null; userPrize: UserPrize | null }, + campaignId: string, + userId: string, + ): Promise { + const userCampaign = await this.prisma.userCampaign.findUnique({ + where: { userId_campaignId: { userId, campaignId } }, + }); + const remainingChances = + (userCampaign?.totalChances ?? 0) - (userCampaign?.usedChances ?? 0); + + return { + success: true, + spinId: spin.id, + prize: spin.prize + ? { + id: spin.prize.id, + title: spin.prize.title, + segment: spin.prize.sortOrder, + code: spin.userPrize?.code || null, + type: spin.prize.type, + image: spin.prize.image, + } + : null, + remainingChances, + }; + } +} diff --git a/apps/api/src/modules/spin-engine/weighted-random-selector.spec.ts b/apps/api/src/modules/spin-engine/weighted-random-selector.spec.ts new file mode 100644 index 0000000..4c3a371 --- /dev/null +++ b/apps/api/src/modules/spin-engine/weighted-random-selector.spec.ts @@ -0,0 +1,49 @@ +import { WeightedRandomSelector } from './weighted-random-selector'; + +describe('WeightedRandomSelector (Unit Tests)', () => { + let selector: WeightedRandomSelector; + + beforeEach(() => { + selector = new WeightedRandomSelector(); + }); + + it('should select single candidate reliably', () => { + const prizes = [{ id: '1', title: 'Gift Card', weight: 100 }]; + const chosen = selector.select(prizes); + expect(chosen.id).toBe('1'); + }); + + it('should select candidates proportionally according to weights over 10,000 runs', () => { + const prizes = [ + { id: 'rare', title: 'iPhone', weight: 10 }, // 10% + { id: 'medium', title: 'Watch', weight: 30 }, // 30% + { id: 'common', title: 'Coupon', weight: 60 }, // 60% + ]; + + const counts: Record = { rare: 0, medium: 0, common: 0 }; + const totalRuns = 10000; + + for (let i = 0; i < totalRuns; i++) { + const selected = selector.select(prizes); + counts[selected.id]++; + } + + const rarePct = counts.rare / totalRuns; + const mediumPct = counts.medium / totalRuns; + const commonPct = counts.common / totalRuns; + + // Tolerance ± 3.5% + expect(rarePct).toBeGreaterThan(0.065); + expect(rarePct).toBeLessThan(0.135); + + expect(mediumPct).toBeGreaterThan(0.26); + expect(mediumPct).toBeLessThan(0.34); + + expect(commonPct).toBeGreaterThan(0.56); + expect(commonPct).toBeLessThan(0.64); + }); + + it('should throw if candidate pool is empty', () => { + expect(() => selector.select([])).toThrow('Candidate pool cannot be empty.'); + }); +}); diff --git a/apps/api/src/modules/spin-engine/weighted-random-selector.ts b/apps/api/src/modules/spin-engine/weighted-random-selector.ts new file mode 100644 index 0000000..6d08e9e --- /dev/null +++ b/apps/api/src/modules/spin-engine/weighted-random-selector.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import * as crypto from 'crypto'; + +export interface CandidatePrize { + id: string; + title: string; + weight: number; + [key: string]: any; +} + +@Injectable() +export class WeightedRandomSelector { + /** + * Selects a prize candidate based on cryptographic weighted random distribution. + * Uses crypto.randomInt (secure) instead of Math.random(). + */ + select(candidates: T[]): T { + if (!candidates || candidates.length === 0) { + throw new Error('Candidate pool cannot be empty.'); + } + + if (candidates.length === 1) { + return candidates[0]; + } + + const totalWeight = candidates.reduce((sum, item) => sum + Math.max(0, item.weight), 0); + if (totalWeight <= 0) { + throw new Error('Total weight of eligible prizes must be greater than zero.'); + } + + // crypto.randomInt generates integer in range [min, max) + const rnd = crypto.randomInt(0, totalWeight); + let cursor = 0; + + for (const item of candidates) { + cursor += Math.max(0, item.weight); + if (rnd < cursor) { + return item; + } + } + + // Safety fallback + return candidates[candidates.length - 1]; + } +} diff --git a/apps/api/src/modules/storage/providers/local-storage.provider.ts b/apps/api/src/modules/storage/providers/local-storage.provider.ts new file mode 100644 index 0000000..360e9f5 --- /dev/null +++ b/apps/api/src/modules/storage/providers/local-storage.provider.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { StorageProviderInterface } from '../storage.interface'; + +@Injectable() +export class LocalStorageProvider implements StorageProviderInterface { + private readonly root: string; + private readonly publicBaseUrl: string; + + constructor(private readonly configService: ConfigService) { + this.root = this.configService.get('STORAGE_LOCAL_ROOT', './uploads'); + this.publicBaseUrl = this.configService.get( + 'STORAGE_PUBLIC_URL', + 'http://localhost:3000/uploads', + ); + } + + async upload( + file: Buffer, + options: { folder: string; filename: string; mimeType: string }, + ): Promise<{ url: string; path: string }> { + const dir = path.join(process.cwd(), this.root, options.folder); + await fs.promises.mkdir(dir, { recursive: true }); + + const ext = path.extname(options.filename); + const finalName = `${Date.now()}-${crypto.randomUUID()}${ext}`; + const fullPath = path.join(dir, finalName); + + await fs.promises.writeFile(fullPath, file); + + const relPath = `${options.folder}/${finalName}`.replace(/\\/g, '/'); + const url = `${this.publicBaseUrl}/${relPath}`; + + return { url, path: relPath }; + } + + async delete(relPath: string): Promise { + const fullPath = path.join(process.cwd(), this.root, relPath); + await fs.promises.unlink(fullPath).catch(() => undefined); + } +} diff --git a/apps/api/src/modules/storage/storage.controller.ts b/apps/api/src/modules/storage/storage.controller.ts new file mode 100644 index 0000000..f0a6e71 --- /dev/null +++ b/apps/api/src/modules/storage/storage.controller.ts @@ -0,0 +1,55 @@ +import { + BadRequestException, + Controller, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { StorageService } from './storage.service'; + +@ApiTags('Admin Uploads (آپلود فایل)') +@Controller('admin/uploads') +@UseGuards(JwtAdminGuard) +@ApiBearerAuth('AdminAuth') +export class StorageController { + constructor(private readonly storageService: StorageService) {} + + @Post() + @UseInterceptors( + FileInterceptor('file', { + limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB + fileFilter: (req, file, cb) => { + if (!file.mimetype.match(/\/(jpg|jpeg|png|webp|svg\+xml)$/)) { + return cb( + new BadRequestException('فقط فرمت‌های تصویری JPG, PNG, WebP و SVG مجاز می‌باشند.'), + false, + ); + } + cb(null, true); + }, + }), + ) + @ApiOperation({ summary: 'آپلود تصویر برای لوگو، بنر یا عکس جایزه' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }) + async uploadFile(@UploadedFile() file: Express.Multer.File) { + if (!file) { + throw new BadRequestException('فایلی ارسال نشده است.'); + } + return this.storageService.uploadFile(file, 'campaign-assets'); + } +} diff --git a/apps/api/src/modules/storage/storage.interface.ts b/apps/api/src/modules/storage/storage.interface.ts new file mode 100644 index 0000000..c404e4b --- /dev/null +++ b/apps/api/src/modules/storage/storage.interface.ts @@ -0,0 +1,8 @@ +export interface StorageProviderInterface { + upload( + file: Buffer, + options: { folder: string; filename: string; mimeType: string }, + ): Promise<{ url: string; path: string }>; + + delete(relPath: string): Promise; +} diff --git a/apps/api/src/modules/storage/storage.module.ts b/apps/api/src/modules/storage/storage.module.ts new file mode 100644 index 0000000..d3dff22 --- /dev/null +++ b/apps/api/src/modules/storage/storage.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { LocalStorageProvider } from './providers/local-storage.provider'; +import { StorageController } from './storage.controller'; +import { StorageService } from './storage.service'; + +@Module({ + controllers: [StorageController], + providers: [ + StorageService, + { + provide: 'STORAGE_PROVIDER', + useClass: LocalStorageProvider, + }, + ], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/apps/api/src/modules/storage/storage.service.ts b/apps/api/src/modules/storage/storage.service.ts new file mode 100644 index 0000000..aeabd7d --- /dev/null +++ b/apps/api/src/modules/storage/storage.service.ts @@ -0,0 +1,25 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { StorageProviderInterface } from './storage.interface'; + +@Injectable() +export class StorageService { + constructor( + @Inject('STORAGE_PROVIDER') + private readonly provider: StorageProviderInterface, + ) {} + + async uploadFile( + file: Express.Multer.File, + folder: string = 'general', + ): Promise<{ url: string; path: string }> { + return this.provider.upload(file.buffer, { + folder, + filename: file.originalname, + mimeType: file.mimetype, + }); + } + + async deleteFile(relPath: string): Promise { + return this.provider.delete(relPath); + } +} diff --git a/apps/api/src/modules/user/admin-user.controller.ts b/apps/api/src/modules/user/admin-user.controller.ts new file mode 100644 index 0000000..36a21ee --- /dev/null +++ b/apps/api/src/modules/user/admin-user.controller.ts @@ -0,0 +1,49 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Query, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UserStatus } from '@prisma/client'; +import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; +import { UserService } from './user.service'; + +@ApiTags('Admin Users (مدیریت کاربران)') +@Controller('admin/users') +@UseGuards(JwtAdminGuard) +@UseInterceptors(AuditInterceptor) +@ApiBearerAuth('AdminAuth') +export class AdminUserController { + constructor(private readonly userService: UserService) {} + + @Get() + @ApiOperation({ summary: 'لیست کاربران با جستجوی شماره موبایل/نام و فیلتر وضعیت' }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'status', enum: UserStatus, required: false }) + async findAll(@Query() query: any) { + return this.userService.findAllForAdmin(query); + } + + @Get(':id') + @ApiOperation({ summary: 'مشاهده پروفایل کامل، کمپین‌ها، شانس‌ها، اسپین‌ها و جوایز کاربر' }) + async findOne(@Param('id') id: string) { + return this.userService.findOneForAdmin(id); + } + + @Patch(':id/block') + @ApiOperation({ summary: 'مسدودسازی یا رفع مسدودیت کاربر' }) + async toggleBlock( + @Param('id') id: string, + @Body('blocked') blocked: boolean, + ) { + return this.userService.toggleBlockUser(id, blocked); + } +} diff --git a/apps/api/src/modules/user/user.controller.ts b/apps/api/src/modules/user/user.controller.ts new file mode 100644 index 0000000..3ffbb11 --- /dev/null +++ b/apps/api/src/modules/user/user.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { JwtUserGuard } from '../../common/guards/jwt-user.guard'; +import { UserService } from './user.service'; + +@ApiTags('User (کاربر لاگین‌شده)') +@Controller('me') +@UseGuards(JwtUserGuard) +@ApiBearerAuth('UserAuth') +export class UserController { + constructor(private readonly userService: UserService) {} + + @Get() + @ApiOperation({ summary: 'دریافت مشخصات حساب کاربری من' }) + @ApiResponse({ status: 200, description: 'مشخصات کاربر جاری' }) + async getMe(@CurrentUser('id') userId: string) { + return this.userService.getMe(userId); + } + + @Get('chances') + @ApiOperation({ summary: 'دریافت وضعیت و تعداد شانس‌های کاربر در یک کمپین' }) + @ApiQuery({ name: 'campaignId', required: false, description: 'شناسه کمپین مورد نظر' }) + async getMyChances( + @CurrentUser('id') userId: string, + @Query('campaignId') campaignId?: string, + ) { + return this.userService.getMyChances(userId, campaignId); + } + + @Get('prizes') + @ApiOperation({ summary: 'تاریخچه تمام جوایز برنده شده توسط کاربر (جوایز من)' }) + async getMyPrizes(@CurrentUser('id') userId: string) { + return this.userService.getMyPrizes(userId); + } +} diff --git a/apps/api/src/modules/user/user.module.ts b/apps/api/src/modules/user/user.module.ts new file mode 100644 index 0000000..1cf77b2 --- /dev/null +++ b/apps/api/src/modules/user/user.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ChanceModule } from '../chance/chance.module'; +import { AdminUserController } from './admin-user.controller'; +import { UserController } from './user.controller'; +import { UserService } from './user.service'; + +@Module({ + imports: [ChanceModule], + controllers: [UserController, AdminUserController], + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} diff --git a/apps/api/src/modules/user/user.service.ts b/apps/api/src/modules/user/user.service.ts new file mode 100644 index 0000000..8946db4 --- /dev/null +++ b/apps/api/src/modules/user/user.service.ts @@ -0,0 +1,189 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, UserStatus } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { ChanceService } from '../chance/chance.service'; + +@Injectable() +export class UserService { + constructor( + private readonly prisma: PrismaService, + private readonly chanceService: ChanceService, + ) {} + + // ================= Client User Operations ================= + + async getMe(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + mobile: true, + name: true, + status: true, + lastLoginAt: true, + createdAt: true, + }, + }); + if (!user) { + throw new NotFoundException('کاربر یافت نشد.'); + } + return user; + } + + async getMyChances(userId: string, campaignId?: string) { + if (!campaignId) { + // Return list of chances across all joined campaigns + return this.prisma.userCampaign.findMany({ + where: { userId }, + include: { + campaign: { select: { id: true, title: true, slug: true, status: true } }, + }, + }); + } + + return this.chanceService.getUserChances(userId, campaignId); + } + + async getMyPrizes(userId: string) { + return this.prisma.userPrize.findMany({ + where: { userId }, + orderBy: { wonAt: 'desc' }, + include: { + prize: { + select: { + id: true, + title: true, + description: true, + image: true, + type: true, + expiresAt: true, + }, + }, + campaign: { + select: { id: true, title: true, slug: true }, + }, + }, + }); + } + + // ================= Admin User Operations ================= + + async findAllForAdmin(query: { + page?: number; + pageSize?: number; + search?: string; + status?: UserStatus; + }) { + const page = Number(query.page) || 1; + const pageSize = Number(query.pageSize) || 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.UserWhereInput = {}; + if (query.status) { + where.status = query.status; + } else if ((query as any).isBlocked !== undefined && (query as any).isBlocked !== '') { + const isBlocked = String((query as any).isBlocked) === 'true'; + where.status = isBlocked ? UserStatus.BLOCKED : UserStatus.ACTIVE; + } + if (query.search) { + where.OR = [ + { mobile: { contains: query.search } }, + { name: { contains: query.search, mode: 'insensitive' } }, + ]; + } + + const [data, total] = await Promise.all([ + this.prisma.user.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + mobile: true, + name: true, + status: true, + lastLoginAt: true, + createdAt: true, + _count: { + select: { + userCampaigns: true, + spins: true, + userPrizes: true, + }, + }, + }, + }), + this.prisma.user.count({ where }), + ]); + + return { + data, + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } + + async findOneForAdmin(id: string) { + const user = await this.prisma.user.findUnique({ + where: { id }, + include: { + userCampaigns: { + include: { + campaign: { select: { id: true, title: true, slug: true } }, + }, + }, + userPrizes: { + orderBy: { wonAt: 'desc' }, + include: { + prize: true, + campaign: { select: { title: true } }, + }, + }, + spins: { + take: 20, + orderBy: { createdAt: 'desc' }, + include: { + prize: { select: { title: true, type: true } }, + campaign: { select: { title: true } }, + }, + }, + chanceTransactions: { + take: 30, + orderBy: { createdAt: 'desc' }, + include: { + campaign: { select: { title: true } }, + createdBy: { select: { name: true } }, + }, + }, + }, + }); + + if (!user) { + throw new NotFoundException('کاربر یافت نشد.'); + } + return user; + } + + async toggleBlockUser(id: string, blocked: boolean) { + const user = await this.prisma.user.findUnique({ where: { id } }); + if (!user) { + throw new NotFoundException('کاربر یافت نشد.'); + } + + const newStatus = blocked ? UserStatus.BLOCKED : UserStatus.ACTIVE; + return this.prisma.user.update({ + where: { id }, + data: { status: newStatus }, + select: { id: true, mobile: true, status: true }, + }); + } +} diff --git a/apps/api/src/modules/winner/admin-spin.controller.ts b/apps/api/src/modules/winner/admin-spin.controller.ts new file mode 100644 index 0000000..16f72f5 --- /dev/null +++ b/apps/api/src/modules/winner/admin-spin.controller.ts @@ -0,0 +1,43 @@ +import { + Controller, + Get, + Header, + Query, + Res, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; +import { WinnerService } from './winner.service'; + +@ApiTags('Admin Spins (تاریخچه اسپین‌ها)') +@Controller('admin/spins') +@UseGuards(JwtAdminGuard) +@UseInterceptors(AuditInterceptor) +@ApiBearerAuth('AdminAuth') +export class AdminSpinController { + constructor(private readonly winnerService: WinnerService) {} + + @Get() + @ApiOperation({ summary: 'لیست تاریخچه تمام چرخاندن‌های گردونه با فیلتر و جستجو' }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) + @ApiQuery({ name: 'campaignId', required: false }) + @ApiQuery({ name: 'prizeId', required: false }) + @ApiQuery({ name: 'search', required: false }) + async findAll(@Query() query: any) { + return this.winnerService.findAllSpins(query); + } + + @Get('export') + @ApiOperation({ summary: 'خروجی فایل اکسل از تاریخچه تمام اسپین‌ها' }) + @Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + @Header('Content-Disposition', 'attachment; filename=spins.xlsx') + async export(@Query() query: any, @Res() res: Response) { + const buffer = await this.winnerService.exportSpinsExcel(query); + res.send(buffer); + } +} diff --git a/apps/api/src/modules/winner/admin-winner.controller.ts b/apps/api/src/modules/winner/admin-winner.controller.ts new file mode 100644 index 0000000..a0da948 --- /dev/null +++ b/apps/api/src/modules/winner/admin-winner.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Get, + Header, + Param, + Patch, + Query, + Req, + Res, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { CurrentAdmin } from '../../common/decorators/current-admin.decorator'; +import { JwtAdminGuard } from '../../common/guards/jwt-admin.guard'; +import { AuditInterceptor } from '../../common/interceptors/audit.interceptor'; +import { UpdatePrizeStatusDto } from './dto/update-prize-status.dto'; +import { WinnerService } from './winner.service'; + +@ApiTags('Admin Winners (مدیریت برندگان)') +@Controller('admin/user-prizes') +@UseGuards(JwtAdminGuard) +@UseInterceptors(AuditInterceptor) +@ApiBearerAuth('AdminAuth') +export class AdminWinnerController { + constructor(private readonly winnerService: WinnerService) {} + + @Get() + @ApiOperation({ summary: 'لیست برندگان با فیلتر کمپین، وضعیت جایزه و جستجو' }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) + @ApiQuery({ name: 'campaignId', required: false }) + @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'search', required: false }) + async findAll(@Query() query: any) { + return this.winnerService.findAllUserPrizes(query); + } + + @Patch(':id/status') + @ApiOperation({ summary: 'تغییر وضعیت جایزه برنده (تحویل شد / تماس گرفته شد / منقضی شد)' }) + async updateStatus( + @Param('id') id: string, + @Body() dto: UpdatePrizeStatusDto, + @CurrentAdmin('id') adminId: string, + @Req() req: any, + ) { + const ip = req.ip || req.headers['x-forwarded-for']; + return this.winnerService.updatePrizeStatus(id, dto, adminId, ip); + } + + @Get('export') + @ApiOperation({ summary: 'خروجی فایل اکسل از لیست برندگان' }) + @Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + @Header('Content-Disposition', 'attachment; filename=winners.xlsx') + async export(@Query() query: any, @Res() res: Response) { + const buffer = await this.winnerService.exportWinnersExcel(query); + res.send(buffer); + } +} diff --git a/apps/api/src/modules/winner/dto/update-prize-status.dto.ts b/apps/api/src/modules/winner/dto/update-prize-status.dto.ts new file mode 100644 index 0000000..bb53aca --- /dev/null +++ b/apps/api/src/modules/winner/dto/update-prize-status.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserPrizeStatus } from '@prisma/client'; +import { IsEnum, IsNotEmpty } from 'class-validator'; + +export class UpdatePrizeStatusDto { + @ApiProperty({ + enum: UserPrizeStatus, + example: UserPrizeStatus.DELIVERED, + description: 'وضعیت جدید جایزه برنده', + }) + @IsEnum(UserPrizeStatus, { message: 'وضعیت انتخاب شده نامعتبر است' }) + @IsNotEmpty() + status: UserPrizeStatus; +} diff --git a/apps/api/src/modules/winner/winner.module.ts b/apps/api/src/modules/winner/winner.module.ts new file mode 100644 index 0000000..0795b35 --- /dev/null +++ b/apps/api/src/modules/winner/winner.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AdminSpinController } from './admin-spin.controller'; +import { AdminWinnerController } from './admin-winner.controller'; +import { WinnerService } from './winner.service'; + +@Module({ + controllers: [AdminWinnerController, AdminSpinController], + providers: [WinnerService], + exports: [WinnerService], +}) +export class WinnerModule {} diff --git a/apps/api/src/modules/winner/winner.service.ts b/apps/api/src/modules/winner/winner.service.ts new file mode 100644 index 0000000..c90602f --- /dev/null +++ b/apps/api/src/modules/winner/winner.service.ts @@ -0,0 +1,282 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Prisma, UserPrizeStatus } from '@prisma/client'; +import * as ExcelJS from 'exceljs'; +import { PrismaService } from '../../prisma/prisma.service'; +import { UpdatePrizeStatusDto } from './dto/update-prize-status.dto'; + +@Injectable() +export class WinnerService { + private readonly logger = new Logger(WinnerService.name); + + constructor(private readonly prisma: PrismaService) {} + + // ================= Winner / UserPrize Operations ================= + + async findAllUserPrizes(query: { + page?: number; + pageSize?: number; + campaignId?: string; + status?: UserPrizeStatus; + search?: string; + }) { + const page = Number(query.page) || 1; + const pageSize = Number(query.pageSize) || 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.UserPrizeWhereInput = {}; + if (query.campaignId) where.campaignId = query.campaignId; + if (query.status) where.status = query.status; + if (query.search) { + where.OR = [ + { code: { contains: query.search } }, + { user: { mobile: { contains: query.search } } }, + { user: { name: { contains: query.search, mode: 'insensitive' } } }, + { prize: { title: { contains: query.search, mode: 'insensitive' } } }, + ]; + } + + const [data, total] = await Promise.all([ + this.prisma.userPrize.findMany({ + where, + skip, + take: pageSize, + orderBy: { wonAt: 'desc' }, + include: { + user: { select: { id: true, mobile: true, name: true } }, + prize: { select: { id: true, title: true, type: true, image: true } }, + campaign: { select: { id: true, title: true, slug: true } }, + }, + }), + this.prisma.userPrize.count({ where }), + ]); + + return { + data, + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } + + async updatePrizeStatus(id: string, dto: UpdatePrizeStatusDto, adminId: string, ip?: string) { + const userPrize = await this.prisma.userPrize.findUnique({ + where: { id }, + include: { + user: { select: { mobile: true } }, + prize: { select: { title: true, type: true } }, + }, + }); + + if (!userPrize) { + throw new NotFoundException('جایزه برنده مورد نظر یافت نشد.'); + } + + const oldStatus = userPrize.status; + const newStatus = dto.status; + + const updated = await this.prisma.userPrize.update({ + where: { id }, + data: { + status: newStatus, + redeemedAt: ['DELIVERED', 'USED'].includes(newStatus) ? new Date() : userPrize.redeemedAt, + }, + }); + + // Record in AdminLog + await this.prisma.adminLog.create({ + data: { + adminId, + action: 'user_prize.update_status', + entityType: 'user_prize', + entityId: id, + oldValue: { status: oldStatus }, + newValue: { + status: newStatus, + userMobile: userPrize.user.mobile, + prizeTitle: userPrize.prize.title, + }, + ip, + }, + }); + + return updated; + } + + async exportWinnersExcel(query: { campaignId?: string; status?: UserPrizeStatus }) { + const where: Prisma.UserPrizeWhereInput = {}; + if (query.campaignId) where.campaignId = query.campaignId; + if (query.status) where.status = query.status; + + const items = await this.prisma.userPrize.findMany({ + where, + orderBy: { wonAt: 'desc' }, + include: { + user: { select: { mobile: true, name: true } }, + prize: { select: { title: true, type: true } }, + campaign: { select: { title: true } }, + }, + }); + + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Winners'); + + worksheet.columns = [ + { header: 'شناسه برد', key: 'id', width: 36 }, + { header: 'شماره موبایل', key: 'mobile', width: 16 }, + { header: 'نام کاربر', key: 'name', width: 20 }, + { header: 'کمپین', key: 'campaign', width: 25 }, + { header: 'عنوان جایزه', key: 'prize', width: 25 }, + { header: 'نوع جایزه', key: 'type', width: 16 }, + { header: 'کد جایزه', key: 'code', width: 20 }, + { header: 'وضعیت', key: 'status', width: 16 }, + { header: 'تاریخ برد', key: 'wonAt', width: 24 }, + { header: 'تاریخ تحویل / مصرف', key: 'redeemedAt', width: 24 }, + ]; + + items.forEach((item) => { + worksheet.addRow({ + id: item.id, + mobile: item.user.mobile, + name: item.user.name || '-', + campaign: item.campaign.title, + prize: item.prize.title, + type: item.prize.type, + code: item.code || '-', + status: item.status, + wonAt: item.wonAt.toISOString(), + redeemedAt: item.redeemedAt ? item.redeemedAt.toISOString() : '-', + }); + }); + + return workbook.xlsx.writeBuffer(); + } + + // ================= Spin Operations ================= + + async findAllSpins(query: { + page?: number; + pageSize?: number; + campaignId?: string; + prizeId?: string; + search?: string; + }) { + const page = Number(query.page) || 1; + const pageSize = Number(query.pageSize) || 20; + const skip = (page - 1) * pageSize; + + const where: Prisma.SpinWhereInput = {}; + if (query.campaignId) where.campaignId = query.campaignId; + if (query.prizeId) where.prizeId = query.prizeId; + if (query.search) { + where.OR = [ + { user: { mobile: { contains: query.search } } }, + { prize: { title: { contains: query.search, mode: 'insensitive' } } }, + { idempotencyKey: { contains: query.search } }, + ]; + } + + const [data, total] = await Promise.all([ + this.prisma.spin.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + user: { select: { id: true, mobile: true, name: true } }, + prize: { select: { id: true, title: true, type: true, segmentColor: true } }, + campaign: { select: { id: true, title: true } }, + userPrize: { select: { code: true, status: true } }, + }, + }), + this.prisma.spin.count({ where }), + ]); + + return { + data, + meta: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }; + } + + async exportSpinsExcel(query: { campaignId?: string }) { + const where: Prisma.SpinWhereInput = {}; + if (query.campaignId) where.campaignId = query.campaignId; + + const items = await this.prisma.spin.findMany({ + where, + orderBy: { createdAt: 'desc' }, + include: { + user: { select: { mobile: true, name: true } }, + prize: { select: { title: true, type: true } }, + campaign: { select: { title: true } }, + }, + }); + + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Spins'); + + worksheet.columns = [ + { header: 'شناسه اسپین', key: 'id', width: 36 }, + { header: 'موبایل کاربر', key: 'mobile', width: 16 }, + { header: 'کمپین', key: 'campaign', width: 25 }, + { header: 'جایزه تعیین‌شده', key: 'prize', width: 25 }, + { header: 'نوع جایزه', key: 'type', width: 16 }, + { header: 'کلید Idempotency', key: 'key', width: 36 }, + { header: 'IP درخواست', key: 'ip', width: 18 }, + { header: 'تاریخ و ساعت', key: 'createdAt', width: 24 }, + ]; + + items.forEach((item) => { + worksheet.addRow({ + id: item.id, + mobile: item.user.mobile, + campaign: item.campaign.title, + prize: item.prize?.title || 'بدون جایزه', + type: item.prize?.type || '-', + key: item.idempotencyKey, + ip: item.ip || '-', + createdAt: item.createdAt.toISOString(), + }); + }); + + return workbook.xlsx.writeBuffer(); + } + + // ================= Scheduled Tasks ================= + + /** + * Daily Cron at midnight: Marks expired vouchers/prizes as EXPIRED + */ + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async handlePrizeExpirations() { + this.logger.log('[CRON] Running daily prize expiration check...'); + const now = new Date(); + + const expiredResult = await this.prisma.userPrize.updateMany({ + where: { + status: { in: [UserPrizeStatus.NEW, UserPrizeStatus.ACTIVE_CODE] }, + prize: { + expiresAt: { lt: now }, + }, + }, + data: { + status: UserPrizeStatus.EXPIRED, + }, + }); + + this.logger.log(`[CRON] ${expiredResult.count} user prize(s) marked as EXPIRED.`); + } +} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..f2dc0fb --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -0,0 +1,25 @@ +import { INestApplication, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + super({ + log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], + }); + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } + + async enableShutdownHooks(app: INestApplication) { + process.on('beforeExit', async () => { + await app.close(); + }); + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..017b374 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": false, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "paths": { + "@common/*": ["src/common/*"], + "@modules/*": ["src/modules/*"], + "@config/*": ["src/config/*"], + "@prisma-module/*": ["src/prisma/*"] + } + } +} diff --git a/apps/client/index.html b/apps/client/index.html new file mode 100644 index 0000000..7a6570c --- /dev/null +++ b/apps/client/index.html @@ -0,0 +1,17 @@ + + + + + + + گردونه شانس و قرعه‌کشی + + + + + + +
+ + + diff --git a/apps/client/package.json b/apps/client/package.json new file mode 100644 index 0000000..1769346 --- /dev/null +++ b/apps/client/package.json @@ -0,0 +1,31 @@ +{ + "name": "@lucky-wheel/client", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@lucky-wheel/shared-types": "workspace:*", + "axios": "^1.7.2", + "canvas-confetti": "^1.9.3", + "lucide-vue-next": "^0.378.0", + "pinia": "^2.1.7", + "vue": "^3.4.27", + "vue-router": "^4.3.2" + }, + "devDependencies": { + "@types/canvas-confetti": "^1.9.0", + "@types/node": "^20.12.12", + "@vitejs/plugin-vue": "^5.0.4", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.3", + "typescript": "^5.4.5", + "vite": "^5.2.11", + "vue-tsc": "^2.0.19" + } +} diff --git a/apps/client/postcss.config.js b/apps/client/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/apps/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/client/src/App.vue b/apps/client/src/App.vue new file mode 100644 index 0000000..3df84fb --- /dev/null +++ b/apps/client/src/App.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum).woff b/apps/client/src/assets/fonts/IRANSansWeb(FaNum).woff new file mode 100644 index 0000000..1e6eb36 Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum).woff differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum).woff2 b/apps/client/src/assets/fonts/IRANSansWeb(FaNum).woff2 new file mode 100644 index 0000000..c3dcbab Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum).woff2 differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff new file mode 100644 index 0000000..019c556 Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff2 b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff2 new file mode 100644 index 0000000..fc008ef Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Black.woff2 differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff new file mode 100644 index 0000000..7008b60 Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff2 b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff2 new file mode 100644 index 0000000..69f7c66 Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Bold.woff2 differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff new file mode 100644 index 0000000..ecea926 Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff2 b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff2 new file mode 100644 index 0000000..40f5b09 Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Light.woff2 differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff new file mode 100644 index 0000000..587883a Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff2 b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff2 new file mode 100644 index 0000000..8273b0a Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_Medium.woff2 differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff new file mode 100644 index 0000000..c815b45 Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff differ diff --git a/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff2 b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff2 new file mode 100644 index 0000000..8b77d17 Binary files /dev/null and b/apps/client/src/assets/fonts/IRANSansWeb(FaNum)_UltraLight.woff2 differ diff --git a/apps/client/src/assets/fonts/fonts.css b/apps/client/src/assets/fonts/fonts.css new file mode 100644 index 0000000..1f5bb52 --- /dev/null +++ b/apps/client/src/assets/fonts/fonts.css @@ -0,0 +1,84 @@ +/* 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'); +} + +/* Global Farsi font & digits enforcement */ +*, *::before, *::after { + font-family: 'IRANSans', 'IRANSansWeb(FaNum)', Tahoma, sans-serif !important; + font-variant-numeric: normal !important; +} + +svg text, .wheel-slice-text { + font-family: 'IRANSans', 'IRANSansWeb(FaNum)', Tahoma, sans-serif !important; + font-variant-numeric: normal !important; +} diff --git a/apps/client/src/components/modals/AuthModal.vue b/apps/client/src/components/modals/AuthModal.vue new file mode 100644 index 0000000..4bde4f7 --- /dev/null +++ b/apps/client/src/components/modals/AuthModal.vue @@ -0,0 +1,228 @@ + + + diff --git a/apps/client/src/components/modals/ResultModal.vue b/apps/client/src/components/modals/ResultModal.vue new file mode 100644 index 0000000..faffd9a --- /dev/null +++ b/apps/client/src/components/modals/ResultModal.vue @@ -0,0 +1,163 @@ + + + diff --git a/apps/client/src/components/wheels/ClassicCasinoWheel.vue b/apps/client/src/components/wheels/ClassicCasinoWheel.vue new file mode 100644 index 0000000..519695a --- /dev/null +++ b/apps/client/src/components/wheels/ClassicCasinoWheel.vue @@ -0,0 +1,214 @@ + + + diff --git a/apps/client/src/components/wheels/LuxuryGoldWheel.vue b/apps/client/src/components/wheels/LuxuryGoldWheel.vue new file mode 100644 index 0000000..7b0ab9c --- /dev/null +++ b/apps/client/src/components/wheels/LuxuryGoldWheel.vue @@ -0,0 +1,174 @@ + + + diff --git a/apps/client/src/components/wheels/MinimalFlatWheel.vue b/apps/client/src/components/wheels/MinimalFlatWheel.vue new file mode 100644 index 0000000..fbe631c --- /dev/null +++ b/apps/client/src/components/wheels/MinimalFlatWheel.vue @@ -0,0 +1,140 @@ + + + diff --git a/apps/client/src/components/wheels/NeonCyberWheel.vue b/apps/client/src/components/wheels/NeonCyberWheel.vue new file mode 100644 index 0000000..fca529f --- /dev/null +++ b/apps/client/src/components/wheels/NeonCyberWheel.vue @@ -0,0 +1,140 @@ + + + diff --git a/apps/client/src/components/wheels/PlayfulCartoonWheel.vue b/apps/client/src/components/wheels/PlayfulCartoonWheel.vue new file mode 100644 index 0000000..b16af8b --- /dev/null +++ b/apps/client/src/components/wheels/PlayfulCartoonWheel.vue @@ -0,0 +1,142 @@ + + + diff --git a/apps/client/src/composables/useSoundEffects.ts b/apps/client/src/composables/useSoundEffects.ts new file mode 100644 index 0000000..cd8db90 --- /dev/null +++ b/apps/client/src/composables/useSoundEffects.ts @@ -0,0 +1,258 @@ +import { ref } from 'vue'; + +const soundEnabled = ref(localStorage.getItem('wheel_sound_enabled') !== 'false'); + +let audioCtx: AudioContext | null = null; + +function getAudioContext(): AudioContext { + if (!audioCtx) { + const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; + audioCtx = new AudioContextClass(); + } + if (audioCtx.state === 'suspended') { + audioCtx.resume(); + } + return audioCtx; +} + +let sharedNoiseBuffer: AudioBuffer | null = null; +function getSharedNoiseBuffer(ctx: AudioContext): AudioBuffer { + if (!sharedNoiseBuffer || sharedNoiseBuffer.sampleRate !== ctx.sampleRate) { + const len = Math.ceil(ctx.sampleRate * 0.05); + sharedNoiseBuffer = ctx.createBuffer(1, len, ctx.sampleRate); + const data = sharedNoiseBuffer.getChannelData(0); + for (let i = 0; i < len; i++) { + data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (ctx.sampleRate * 0.015)); + } + } + return sharedNoiseBuffer; +} + +export function useSoundEffects() { + function toggleSound() { + soundEnabled.value = !soundEnabled.value; + localStorage.setItem('wheel_sound_enabled', String(soundEnabled.value)); + } + + /** + * Plays a realistic mechanical peg/flapper tick. + * @param intensity 0..1 — controls volume and pitch (higher = faster spin = louder & sharper) + */ + function playTick(intensity: number = 0.6) { + if (!soundEnabled.value) return; + try { + const ctx = getAudioContext(); + const now = ctx.currentTime; + + // Clamp intensity + const t = Math.max(0.05, Math.min(1, intensity)); + + // --- Layer 1: Short noise burst (the "click") --- + const noiseLen = 0.012 + (1 - t) * 0.008; // shorter at high speed + const noiseSrc = ctx.createBufferSource(); + noiseSrc.buffer = getSharedNoiseBuffer(ctx); + + // Bandpass filter to shape the click + const clickFilter = ctx.createBiquadFilter(); + clickFilter.type = 'bandpass'; + clickFilter.frequency.value = 2800 + t * 1200; // sharper at high speed + clickFilter.Q.value = 1.5; + + const noiseGain = ctx.createGain(); + noiseGain.gain.setValueAtTime(0.18 * t + 0.06, now); + noiseGain.gain.exponentialRampToValueAtTime(0.001, now + noiseLen); + + noiseSrc.connect(clickFilter); + clickFilter.connect(noiseGain); + noiseGain.connect(ctx.destination); + noiseSrc.start(now); + noiseSrc.stop(now + noiseLen + 0.005); + + // --- Layer 2: Resonant body tone (the "thunk") --- + const bodyOsc = ctx.createOscillator(); + bodyOsc.type = 'triangle'; + const baseFreq = 600 + t * 400; + bodyOsc.frequency.setValueAtTime(baseFreq, now); + bodyOsc.frequency.exponentialRampToValueAtTime(baseFreq * 0.3, now + 0.04); + + const bodyGain = ctx.createGain(); + bodyGain.gain.setValueAtTime(0.10 * t + 0.03, now); + bodyGain.gain.exponentialRampToValueAtTime(0.001, now + 0.04); + + bodyOsc.connect(bodyGain); + bodyGain.connect(ctx.destination); + bodyOsc.start(now); + bodyOsc.stop(now + 0.05); + } catch { + // Audio context might be restricted before interaction + } + } + + /** + * Plays a celebratory ascending arpeggio fanfare for winning. + */ + function playWin() { + if (!soundEnabled.value) return; + try { + const ctx = getAudioContext(); + const now = ctx.currentTime; + + // Celebration arpeggio: C5 → E5 → G5 → C6, with shimmer + const notes = [523.25, 659.25, 783.99, 1046.5]; + const delay = 0.10; + + notes.forEach((freq, i) => { + // Main bell tone + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.value = freq; + + // Harmonic shimmer + const shimmer = ctx.createOscillator(); + shimmer.type = 'sine'; + shimmer.frequency.value = freq * 2.01; // slight detune for shimmer + + const gain = ctx.createGain(); + const t0 = now + i * delay; + gain.gain.setValueAtTime(0, t0); + gain.gain.linearRampToValueAtTime(0.22, t0 + 0.03); + gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.7); + + const shimGain = ctx.createGain(); + shimGain.gain.setValueAtTime(0, t0); + shimGain.gain.linearRampToValueAtTime(0.06, t0 + 0.03); + shimGain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.5); + + osc.connect(gain); + shimmer.connect(shimGain); + gain.connect(ctx.destination); + shimGain.connect(ctx.destination); + + osc.start(t0); + osc.stop(t0 + 0.75); + shimmer.start(t0); + shimmer.stop(t0 + 0.55); + }); + + // Final chime accent + const chime = ctx.createOscillator(); + chime.type = 'sine'; + chime.frequency.value = 2093; // C7 + + const chimeGain = ctx.createGain(); + const ct = now + notes.length * delay + 0.05; + chimeGain.gain.setValueAtTime(0, ct); + chimeGain.gain.linearRampToValueAtTime(0.10, ct + 0.01); + chimeGain.gain.exponentialRampToValueAtTime(0.001, ct + 0.6); + + chime.connect(chimeGain); + chimeGain.connect(ctx.destination); + chime.start(ct); + chime.stop(ct + 0.65); + } catch { + // Ignore + } + } + + /** + * Plays the iconic "Sad Trombone" losing sound (Wah-Wah-Wah-Waaah): + * 4 descending brass notes with resonant filter sweeps and a final + * long sliding note with wide comedic wobble vibrato. + */ + function playNoPrize() { + if (!soundEnabled.value) return; + try { + const ctx = getAudioContext(); + const now = ctx.currentTime; + + // Sad Trombone notes: Eb4 (311Hz), D4 (294Hz), Db4 (277Hz), C4 (261Hz -> sliding down to ~210Hz) + const noteDefs = [ + { freq: 311.13, duration: 0.28, delay: 0.00, slide: false }, + { freq: 293.66, duration: 0.28, delay: 0.32, slide: false }, + { freq: 277.18, duration: 0.28, delay: 0.64, slide: false }, + { freq: 261.63, duration: 1.45, delay: 0.98, slide: true }, + ]; + + noteDefs.forEach(({ freq, duration, delay, slide }) => { + const tStart = now + delay; + const tEnd = tStart + duration; + + // 1. Dual oscillator for rich brass timbre (Sawtooth + detuned Sawtooth) + const osc1 = ctx.createOscillator(); + const osc2 = ctx.createOscillator(); + osc1.type = 'sawtooth'; + osc2.type = 'sawtooth'; + + osc1.frequency.setValueAtTime(freq, tStart); + osc2.frequency.setValueAtTime(freq * 1.008, tStart); // detuned for fat horn chorus + + // 2. Comedic pitch bend and wobble on the final "Waaaaah" + if (slide) { + // Slow comedic slide down to ~210 Hz + osc1.frequency.setValueAtTime(freq, tStart); + osc2.frequency.setValueAtTime(freq * 1.008, tStart); + osc1.frequency.exponentialRampToValueAtTime(208, tEnd); + osc2.frequency.exponentialRampToValueAtTime(209, tEnd); + + // LFO vibrato (wobble) + const lfo = ctx.createOscillator(); + const lfoGain = ctx.createGain(); + lfo.frequency.setValueAtTime(5.8, tStart); // 5.8 Hz wobble + lfoGain.gain.setValueAtTime(0, tStart); + // Vibrato intensifies towards the end of the note + lfoGain.gain.linearRampToValueAtTime(8.5, tStart + 0.4); + + lfo.connect(lfoGain); + lfoGain.connect(osc1.frequency); + lfoGain.connect(osc2.frequency); + + lfo.start(tStart); + lfo.stop(tEnd + 0.1); + } + + // 3. Resonant "WAH" Filter (The trombone plunger mute effect) + // Sweeps cutoff from ~280Hz up to ~1500Hz then back down to create the vocal "wah" formants + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.Q.value = 4.8; // High resonance for characteristic "waaa" vowel shape + + const wahPeakTime = tStart + Math.min(0.08, duration * 0.28); + filter.frequency.setValueAtTime(280, tStart); + filter.frequency.exponentialRampToValueAtTime(slide ? 1650 : 1450, wahPeakTime); + filter.frequency.exponentialRampToValueAtTime(slide ? 300 : 360, tEnd); + + // 4. Amplitude envelope + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0.001, tStart); + gain.gain.linearRampToValueAtTime(0.22, tStart + 0.03); + if (slide) { + gain.gain.setValueAtTime(0.22, tStart + 0.65); + gain.gain.exponentialRampToValueAtTime(0.001, tEnd); + } else { + gain.gain.exponentialRampToValueAtTime(0.001, tEnd); + } + + // Audio node routing + osc1.connect(filter); + osc2.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + + osc1.start(tStart); + osc2.start(tStart); + osc1.stop(tEnd + 0.05); + osc2.stop(tEnd + 0.05); + }); + } catch { + // Ignore + } + } + + return { + soundEnabled, + toggleSound, + playTick, + playWin, + playNoPrize, + }; +} diff --git a/apps/client/src/composables/useWheelRotation.ts b/apps/client/src/composables/useWheelRotation.ts new file mode 100644 index 0000000..2f71c99 --- /dev/null +++ b/apps/client/src/composables/useWheelRotation.ts @@ -0,0 +1,218 @@ +import { computed, onUnmounted, ref } from 'vue'; +import { useSoundEffects } from './useSoundEffects'; + +export interface UseWheelRotationOptions { + segmentCount: () => number; + pegCount?: () => number; + onFinished?: () => void; +} + +/** + * Physics-based wheel rotation & realistic flapper/pointer bounce engine. + * + * Simulates real casino/carnival flapper stopper interaction: + * 1. As each peg/bulb on the wheel rim reaches 12 o'clock, it physically + * deflects the pointer tip clockwise (micro-tilt up to ~14°). + * 2. At the instant the peg slips off the tip, the pointer snaps back with a + * crisp spring recoil, and playTick() fires in frame-perfect sync. + * 3. At high speed, the pointer buzzes/chatters continuously; as the wheel + * decelerates, you clearly see each individual peg deflection and snap. + */ +export function useWheelRotation(options: UseWheelRotationOptions) { + const { playTick } = useSoundEffects(); + + const currentRotation = ref(0); + const pointerAngle = ref(0); + const isSpinning = ref(false); + + // Check prefers-reduced-motion + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + let animFrameId: number | null = null; + let settleFrameId: number | null = null; + + function spinTo(targetIndex: number) { + if (isSpinning.value) return; + + const n = options.segmentCount(); + if (n <= 0) return; + + if (settleFrameId !== null) { + cancelAnimationFrame(settleFrameId); + settleFrameId = null; + } + + isSpinning.value = true; + const sliceAngle = 360 / n; + + // Small jitter within slice for natural feel + const jitter = (Math.random() - 0.5) * (sliceAngle * 0.4); + + // Angle of target slice center from top (0 deg) + const targetSliceCenter = targetIndex * sliceAngle + sliceAngle / 2 + jitter; + + // Target stop angle in [0, 360) + const stopAngle = (360 - (targetSliceCenter % 360)) % 360; + + // Multi-turn rotation: 7-9 full spins + remainder + const fullSpins = prefersReducedMotion ? 1 : 7 + Math.floor(Math.random() * 3); + const spinDuration = prefersReducedMotion ? 0.8 : 5.5; // seconds + + // Compute total angle to add + const currentModulo = currentRotation.value % 360; + const distanceToStop = (stopAngle - currentModulo + 360) % 360; + const totalAngle = fullSpins * 360 + distanceToStop; + + // Decay constant — tuned so at t=spinDuration, the exponential is ~99.5% done + const k = 5.5 / spinDuration; + + const startRot = currentRotation.value; + const startTime = performance.now(); + let lastTime = startTime; + let lastRot = startRot; + + // Number of physical rim pegs (defaults to 24 or max(24, n)) + const totalPegs = options.pegCount ? options.pegCount() : Math.max(24, n); + const pegSpan = 360 / totalPegs; // e.g. 15 degrees for 24 pegs + const contactZone = pegSpan * 0.38; // last 38% of peg sector is physical push zone + + let pointerAngleVal = 0; + let pointerVelocity = 0; + let lastPegIndex = Math.floor(((startRot % 360) + 360) % 360 / pegSpan); + + function easeOutExponential(t: number): number { + return 1 - Math.exp(-k * t * spinDuration); + } + + const normFactor = 1 / easeOutExponential(1); + + function animate(now: number) { + const elapsed = (now - startTime) / 1000; + const progress = Math.min(1, elapsed / spinDuration); + const dt = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000)); + lastTime = now; + + let easedProgress: number; + if (progress >= 1) { + easedProgress = 1; + } else { + easedProgress = easeOutExponential(progress) * normFactor; + } + + // Current angle + const newRotation = startRot + totalAngle * easedProgress; + const deltaRotation = newRotation - lastRot; + lastRot = newRotation; + currentRotation.value = newRotation; + + // --- Flapper Pointer Physical Interaction --- + if (!prefersReducedMotion) { + const instantaneousSpeed = deltaRotation / dt; // deg/sec + const speedRatio = Math.min(1, Math.max(0, instantaneousSpeed / 800)); + + const mod = ((newRotation % pegSpan) + pegSpan) % pegSpan; + const currentPeg = Math.floor(((newRotation % 360) + 360) % 360 / pegSpan); + + // Peg release event (when crossing peg boundary) + if (currentPeg !== lastPegIndex) { + const intensity = Math.max(0.12, Math.min(1, speedRatio * 1.8 + 0.15)); + playTick(intensity); + + // Spring rebound kick + pointerVelocity = -180 * (0.8 + speedRatio * 0.5); + lastPegIndex = currentPeg; + } + + // Active physical contact with peg + if (mod >= pegSpan - contactZone) { + const u = (mod - (pegSpan - contactZone)) / contactZone; // 0 to 1 + const pushAngle = (11 + speedRatio * 3.5) * Math.sin(u * (Math.PI / 2)); + pointerAngleVal = Math.max(pointerAngleVal, pushAngle); + pointerVelocity = 0; + } else { + // Free flight damped harmonic oscillator + const springK = 1400; + const damping = 42; + const accel = -springK * pointerAngleVal - damping * pointerVelocity; + pointerVelocity += accel * dt; + pointerAngleVal += pointerVelocity * dt; + + // Physical stopper bounce: flapper cannot bend backwards through resting stop + if (pointerAngleVal < -3) { + pointerAngleVal = -3; + pointerVelocity = -pointerVelocity * 0.3; + } + } + + pointerAngle.value = pointerAngleVal; + } + + if (progress < 1) { + animFrameId = requestAnimationFrame(animate); + } else { + // Snap to exact final rotation + currentRotation.value = startRot + totalAngle; + isSpinning.value = false; + animFrameId = null; + + // Smoothly settle flapper + settlePointer(); + + if (options.onFinished) { + options.onFinished(); + } + } + } + + function settlePointer() { + let settleTime = performance.now(); + function settle(now: number) { + const dt = Math.min(0.03, (now - settleTime) / 1000); + settleTime = now; + const springK = 1000; + const damping = 40; + const accel = -springK * pointerAngleVal - damping * pointerVelocity; + pointerVelocity += accel * dt; + pointerAngleVal += pointerVelocity * dt; + + if (Math.abs(pointerAngleVal) < 0.05 && Math.abs(pointerVelocity) < 1) { + pointerAngle.value = 0; + settleFrameId = null; + return; + } + + pointerAngle.value = pointerAngleVal; + settleFrameId = requestAnimationFrame(settle); + } + settleFrameId = requestAnimationFrame(settle); + } + + animFrameId = requestAnimationFrame(animate); + } + + onUnmounted(() => { + if (animFrameId !== null) cancelAnimationFrame(animFrameId); + if (settleFrameId !== null) cancelAnimationFrame(settleFrameId); + }); + + const wheelStyle = computed(() => ({ + transform: `rotate(${currentRotation.value}deg)`, + transformOrigin: '50% 50%', + willChange: 'transform', + })); + + const pointerStyle = computed(() => ({ + transform: `rotate(${pointerAngle.value}deg)`, + transformOrigin: '50% 12px', + willChange: 'transform', + })); + + return { + currentRotation, + pointerAngle, + pointerStyle, + isSpinning, + wheelStyle, + spinTo, + }; +} diff --git a/apps/client/src/index.css b/apps/client/src/index.css new file mode 100644 index 0000000..4a4d720 --- /dev/null +++ b/apps/client/src/index.css @@ -0,0 +1,43 @@ +@import './assets/fonts/fonts.css'; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + * { + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + } + + html, body { + margin: 0; + padding: 0; + width: 100%; + min-height: 100%; + direction: rtl; + font-family: 'IRANSans', 'Vazirmatn', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + } +} + +/* Custom Utilities for Wheels */ +.wheel-slice-text { + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); + user-select: none; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: rgba(15, 23, 42, 0.6); +} +::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.4); + border-radius: 9999px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(148, 163, 184, 0.7); +} diff --git a/apps/client/src/main.ts b/apps/client/src/main.ts new file mode 100644 index 0000000..ce557ba --- /dev/null +++ b/apps/client/src/main.ts @@ -0,0 +1,13 @@ +import { createPinia } from 'pinia'; +import { createApp } from 'vue'; +import App from './App.vue'; +import './index.css'; +import { router } from './router'; + +const app = createApp(App); +const pinia = createPinia(); + +app.use(pinia); +app.use(router); + +app.mount('#app'); diff --git a/apps/client/src/router/index.ts b/apps/client/src/router/index.ts new file mode 100644 index 0000000..e1cadea --- /dev/null +++ b/apps/client/src/router/index.ts @@ -0,0 +1,33 @@ +import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'; + +const routes: RouteRecordRaw[] = [ + { + path: '/my-prizes', + name: 'my-prizes', + component: () => import('../views/MyPrizesView.vue'), + }, + { + path: '/c/:slug', + name: 'campaign-wheel-slug', + component: () => import('../views/CampaignWheelView.vue'), + }, + { + path: '/:slug', + name: 'campaign-wheel', + component: () => import('../views/CampaignWheelView.vue'), + }, + { + path: '/', + name: 'home', + redirect: '/c/default-campaign', + }, + { + path: '/:pathMatch(.*)*', + redirect: '/', + }, +]; + +export const router = createRouter({ + history: createWebHistory(), + routes, +}); diff --git a/apps/client/src/services/api/client.ts b/apps/client/src/services/api/client.ts new file mode 100644 index 0000000..71d752c --- /dev/null +++ b/apps/client/src/services/api/client.ts @@ -0,0 +1,37 @@ +import axios from 'axios'; + +declare module 'axios' { + export interface AxiosResponse { + meta?: any; + } +} + +export const apiClient = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, +}); + +apiClient.interceptors.request.use( + (config) => { + const token = localStorage.getItem('user_access_token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error), +); + +apiClient.interceptors.response.use( + (response) => { + if (response.data && response.data.success !== undefined && response.data.data !== undefined) { + return { ...response, data: response.data.data, meta: response.data.meta }; + } + return response; + }, + (error) => { + return Promise.reject(error); + }, +); diff --git a/apps/client/src/stores/auth.store.ts b/apps/client/src/stores/auth.store.ts new file mode 100644 index 0000000..b69bff9 --- /dev/null +++ b/apps/client/src/stores/auth.store.ts @@ -0,0 +1,108 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { apiClient } from '../services/api/client'; + +export interface UserProfile { + id: string; + mobile: string; + name?: string | null; +} + +export interface ChancesState { + totalChances: number; + usedChances: number; + remainingChances: number; +} + +export const useAuthStore = defineStore('userAuth', () => { + const token = ref(localStorage.getItem('user_access_token')); + const user = ref( + localStorage.getItem('user_info') ? JSON.parse(localStorage.getItem('user_info')!) : null, + ); + const chances = ref({ + totalChances: 0, + usedChances: 0, + remainingChances: 0, + }); + + const isAuthenticated = computed(() => !!token.value); + + async function requestOtp(mobile: string) { + const res = await apiClient.post('/auth/send-otp', { mobile }); + return res.data; + } + + async function verifyOtp(mobile: string, code: string, campaignId?: string) { + const res = await apiClient.post('/auth/verify-otp', { mobile, code }); + const authData = res.data; + + token.value = authData.accessToken; + user.value = authData.user; + + localStorage.setItem('user_access_token', authData.accessToken); + localStorage.setItem('user_info', JSON.stringify(authData.user)); + + if (campaignId) { + await loadChances(campaignId); + } + return authData; + } + + async function loadProfile() { + if (!token.value) return null; + try { + const res = await apiClient.get('/me'); + user.value = res.data; + localStorage.setItem('user_info', JSON.stringify(res.data)); + return res.data; + } catch { + logout(); + return null; + } + } + + async function loadChances(campaignId: string) { + if (!token.value || !campaignId) return; + try { + const res = await apiClient.get('/me/chances', { + params: { campaignId }, + }); + chances.value = res.data; + } catch { + // Ignore + } + } + + function decrementChance() { + if (chances.value.remainingChances > 0) { + chances.value.remainingChances--; + chances.value.usedChances++; + } + } + + function setRemainingChances(count: number) { + chances.value.remainingChances = count; + } + + function logout() { + token.value = null; + user.value = null; + chances.value = { totalChances: 0, usedChances: 0, remainingChances: 0 }; + localStorage.removeItem('user_access_token'); + localStorage.removeItem('user_info'); + } + + return { + token, + user, + chances, + isAuthenticated, + requestOtp, + verifyOtp, + loadProfile, + loadChances, + decrementChance, + setRemainingChances, + logout, + }; +}); diff --git a/apps/client/src/stores/spin.store.ts b/apps/client/src/stores/spin.store.ts new file mode 100644 index 0000000..483907d --- /dev/null +++ b/apps/client/src/stores/spin.store.ts @@ -0,0 +1,161 @@ +import confetti from 'canvas-confetti'; +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { useSoundEffects } from '../composables/useSoundEffects'; +import { apiClient } from '../services/api/client'; +import { useAuthStore } from './auth.store'; + +export interface PrizeResult { + id: string; + title: string; + segment: number; + code?: string | null; + type: string; + image?: string | null; +} + +export const useSpinStore = defineStore('spin', () => { + const authStore = useAuthStore(); + const { playWin, playNoPrize } = useSoundEffects(); + + const isRequesting = ref(false); + const isWheelSpinning = ref(false); + const targetSegmentIndex = ref(null); + const spinResult = ref(null); + const showResultModal = ref(false); + const errorMessage = ref(null); + const pendingIdempotencyKey = ref(null); + + function generateIdempotencyKey(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } + + async function executeSpin(campaignId: string, segments: any[]) { + if (isRequesting.value || isWheelSpinning.value) return; + + if (!authStore.isAuthenticated) { + errorMessage.value = 'لطفاً ابتدا وارد حساب کاربری خود شوید.'; + return; + } + + if (authStore.chances.remainingChances <= 0) { + errorMessage.value = 'شما شانس کافی برای چرخاندن گردونه ندارید!'; + return; + } + + isRequesting.value = true; + errorMessage.value = null; + + if (!pendingIdempotencyKey.value) { + pendingIdempotencyKey.value = generateIdempotencyKey(); + } + + try { + const res = await apiClient.post(`/campaigns/${campaignId}/spin`, { + idempotencyKey: pendingIdempotencyKey.value, + }); + + const data = res.data; + spinResult.value = data; + + // Update remaining chances from server + if (typeof data.remainingChances === 'number') { + authStore.setRemainingChances(data.remainingChances); + } else { + authStore.decrementChance(); + } + + // Find the segment index in the client segments list + let segIdx = 0; + if (data.prize) { + // Priority 1: Match strictly by prize id first + let found = segments.findIndex((s) => s.id === data.prize.id); + + // Priority 2: Fallback match by exact title + if (found === -1 && data.prize.title) { + found = segments.findIndex((s) => s.title === data.prize.title); + } + + // Priority 3: Fallback match by sortOrder / segment + if (found === -1 && data.prize.segment !== undefined) { + found = segments.findIndex((s) => s.sortOrder === data.prize.segment); + } + + segIdx = found !== -1 ? found : 0; + } + + targetSegmentIndex.value = segIdx; + isWheelSpinning.value = true; + + // Clear the idempotency key once successfully acknowledged by server + pendingIdempotencyKey.value = null; + } catch (err: any) { + let msg = + err.response?.data?.message || + (Array.isArray(err.response?.data?.message) + ? err.response?.data?.message.join(' - ') + : 'خطا در برقراری ارتباط با سرور قرعه‌کشی'); + if (typeof msg === 'string') { + if (msg === 'CAMPAIGN_DRAFT') { + msg = 'این کمپین در وضعیت پیش‌نویس (DRAFT) قرار دارد و هنوز فعال نشده است.'; + } else if (msg === 'CAMPAIGN_PAUSED') { + msg = 'این کمپین موقتاً متوقف شده است.'; + } else if (msg === 'CAMPAIGN_NOT_STARTED') { + msg = 'زمان شروع این کمپین هنوز فرا نرسیده است.'; + } else if (msg === 'CAMPAIGN_FINISHED') { + msg = 'مهلت شرکت در این کمپین به پایان رسیده است.'; + } + } + errorMessage.value = msg; + } finally { + isRequesting.value = false; + } + } + + function onWheelAnimationFinished() { + isWheelSpinning.value = false; + targetSegmentIndex.value = null; + + const prize = spinResult.value?.prize; + const isNoPrize = !prize || prize.type === 'NO_PRIZE' || prize.title?.trim() === 'پوچ' || prize.title?.includes('پوچ'); + + if (!isNoPrize) { + playWin(); + // Confetti burst + confetti({ + particleCount: 80, + spread: 70, + origin: { y: 0.6 }, + }); + } else { + // Play famous iconic Sad Trombone losing sound + playNoPrize(); + } + + showResultModal.value = true; + } + + function closeResultModal() { + showResultModal.value = false; + spinResult.value = null; + } + + return { + isRequesting, + isWheelSpinning, + targetSegmentIndex, + spinResult, + showResultModal, + errorMessage, + executeSpin, + onWheelAnimationFinished, + closeResultModal, + }; +}); diff --git a/apps/client/src/views/CampaignWheelView.vue b/apps/client/src/views/CampaignWheelView.vue new file mode 100644 index 0000000..8824e91 --- /dev/null +++ b/apps/client/src/views/CampaignWheelView.vue @@ -0,0 +1,324 @@ + + + diff --git a/apps/client/src/views/MyPrizesView.vue b/apps/client/src/views/MyPrizesView.vue new file mode 100644 index 0000000..003504a --- /dev/null +++ b/apps/client/src/views/MyPrizesView.vue @@ -0,0 +1,192 @@ + + + diff --git a/apps/client/tailwind.config.js b/apps/client/tailwind.config.js new file mode 100644 index 0000000..5d257b1 --- /dev/null +++ b/apps/client/tailwind.config.js @@ -0,0 +1,43 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + brand: { + 50: '#eff6ff', + 100: '#dbeafe', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + }, + gold: { + 400: '#fbbf24', + 500: '#f59e0b', + 600: '#d97706', + }, + }, + fontFamily: { + sans: ['IRANSans', 'Vazirmatn', 'Tahoma', 'sans-serif'], + }, + keyframes: { + 'pulse-glow': { + '0%, 100%': { filter: 'drop-shadow(0 0 15px rgba(245, 158, 11, 0.6))' }, + '50%': { filter: 'drop-shadow(0 0 25px rgba(245, 158, 11, 0.9))' }, + }, + 'neon-pulse': { + '0%, 100%': { filter: 'drop-shadow(0 0 10px #00f0ff) drop-shadow(0 0 20px #ff007f)' }, + '50%': { filter: 'drop-shadow(0 0 20px #00f0ff) drop-shadow(0 0 35px #ff007f)' }, + }, + }, + animation: { + 'pulse-glow': 'pulse-glow 2s ease-in-out infinite', + 'neon-pulse': 'neon-pulse 1.5s ease-in-out infinite', + }, + }, + }, + plugins: [], +} diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json new file mode 100644 index 0000000..4e5ea1b --- /dev/null +++ b/apps/client/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/client/tsconfig.node.json b/apps/client/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/apps/client/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts new file mode 100644 index 0000000..c806be5 --- /dev/null +++ b/apps/client/vite.config.ts @@ -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: 5174, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + '/uploads': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd54189 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + postgres: + image: postgres:15-alpine + container_name: lucky_wheel_postgres + restart: unless-stopped + environment: + POSTGRES_DB: lucky_wheel + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgrespassword + ports: + - "5432:5432" + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d lucky_wheel"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: lucky_wheel_redis + restart: unless-stopped + ports: + - "6380:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pg_data: + redis_data: diff --git a/docs/Lucky_Wheel_Implementation_Blueprint.md b/docs/Lucky_Wheel_Implementation_Blueprint.md new file mode 100644 index 0000000..25cf8a2 --- /dev/null +++ b/docs/Lucky_Wheel_Implementation_Blueprint.md @@ -0,0 +1,1578 @@ +# داکیومنت فنی پیاده‌سازی سامانه گردونه شانس و قرعه‌کشی + +**نسخه سند:** 2.0 (Implementation Blueprint) +**بر اساس:** Lucky Wheel Lottery Spec v1.0 +**نوع سند:** Technical Architecture & Implementation Guide +**Stack:** NestJS + Prisma + PostgreSQL (Backend) | Vue 3 + Vite + Pinia + Ant Design Vue (Admin) | Vue 3 + Vite + Pinia (Client) | BullMQ + Redis (Queue) | raygansms.com (SMS Provider) + +--- + +## فهرست مطالب + +0. مقدمه +1. Tech Stack نهایی +2. معماری کلی و ساختار Monorepo +3. مدل داده — Prisma Schema کامل +4. ساختار پروژه Backend (NestJS) +5. ماژول Authentication (OTP) +6. ماژول Campaign +7. ماژول Prize +8. ماژول Chance و Chance Ledger +9. Spin Engine — الگوریتم اجرایی و کنترل Concurrency +10. Idempotency +11. ماژول SMS (raygansms.com) + صف BullMQ +12. ماژول Winner / User Prizes / کد جایزه +13. ماژول File Storage (Local، قابل تعویض به S3) +14. مستندات کامل API — کاربر +15. مستندات کامل API — ادمین +16. ساختار پروژه Admin Panel (Vue 3 + Ant Design Vue + Pinia) +17. ساختار پروژه User Client (Vue 3 + Wheel Component) +18. پنج طرح گردونه (Wheel Designs) قابل انتخاب توسط ادمین +19. Security و Anti-Fraud +20. Rate Limiting +21. Environment Variables +22. Docker / Deployment +23. استراتژی تست و Mapping با Acceptance Criteria +24. جمع‌بندی و مسیر توسعه آینده + +--- + +## 0. مقدمه + +این سند، پیاده‌سازی فنی مشخصات کسب‌وکاری موجود در `Lucky_Wheel_Lottery_Spec_v1.0` را با Stack مشخص‌شده زیر تعریف می‌کند: + +| لایه | تکنولوژی | +|---|---| +| Backend Framework | NestJS (Node.js, TypeScript) | +| ORM | Prisma | +| Database | PostgreSQL | +| Admin Panel Frontend | Vue 3 + Vite + Pinia + Ant Design Vue | +| User Client Frontend | Vue 3 + Vite + Pinia | +| SMS Provider | raygansms.com | +| Queue / Job System | BullMQ + Redis | +| File Storage | Local Disk در فاز فعلی (طراحی‌شده برای مهاجرت بدون تغییر Business Logic به S3-Compatible مثل لیارا/آروان در آینده) | +| Auth کاربر | OTP + JWT | +| Auth ادمین | Email/Password + JWT (Access + Refresh) | + +اصول معماری بدون تغییر نسبت به سند اصلی حفظ می‌شوند: + +- نتیجه Spin همیشه در Backend تعیین می‌شود. +- عملیات Spin به‌صورت Atomic و داخل Database Transaction اجرا می‌شود. +- تمام Endpointهای حساس نیازمند Authentication هستند. +- تمام تغییرات Chance و اقدامات ادمین Audit می‌شوند. + +--- + +## 1. Tech Stack نهایی + +### Backend +- **Runtime:** Node.js 20 LTS +- **Framework:** NestJS 10.x (Modular Architecture, Dependency Injection) +- **ORM:** Prisma 5.x +- **Database:** PostgreSQL 15+ +- **Queue:** BullMQ روی Redis 7+ (برای SMS، و قابل توسعه برای Job‌های آینده مثل گزارش‌های سنگین) +- **Validation:** `class-validator` + `class-transformer` (DTO-based) +- **Auth:** `@nestjs/jwt` + `@nestjs/passport` (Strategy جدا برای User و Admin) +- **Rate Limiting:** `@nestjs/throttler` +- **API Docs:** `@nestjs/swagger` (Swagger UI برای مستندسازی زنده API) +- **Logger:** `pino` (nestjs-pino) برای Structured Logging +- **Scheduler:** `@nestjs/schedule` (برای Cron Jobهایی مانند انقضای جوایز) + +### Admin Panel +- Vue 3 (Composition API + ` + + +``` + +### ۱۷.۲ رفتار Spin در Frontend (طبق بخش ۱۴ سند اصلی، بدون تغییر) + +1. لمس دکمه «بچرخون» → Disable فوری دکمه. +2. نمایش Loading کوتاه (اسپینر کوچک روی دکمه، نه تمام‌صفحه). +3. تولید/استفاده از `idempotencyKey` موجود در Store و ارسال `POST /spin`. +4. دریافت پاسخ شامل `prize.segment`. +5. اجرای انیمیشن: چند دور کامل (مثلاً ۵ دور) + توقف دقیق روی زاویه مرکز Segment هدف (محاسبه با `360/segmentsCount * targetIndex + offset تصادفی کوچک داخل همان Segment برای طبیعی‌تر شدن`). +6. تا پایان انیمیشن (`transitionend` یا تایمر معادل)، دکمه غیرفعال می‌ماند. +7. نمایش Modal نتیجه (`ResultModal.vue`) بعد از پایان انیمیشن، نه هم‌زمان با دریافت پاسخ API. +8. بعد از بستن Modal: به‌روزرسانی `remainingChances` در Store؛ اگر باقی مانده، CTA «یک شانس دیگه دارم»، در غیر این صورت «مشاهده جوایز من». + +### ۱۷.۳ مدیریت خطای شبکه و Idempotency در Frontend + +```ts +// spin.store.ts +async function spin() { + if (isSpinning.value) return; // جلوگیری از دابل‌کلیک + isSpinning.value = true; + const key = pendingIdempotencyKey.value ?? crypto.randomUUID(); + pendingIdempotencyKey.value = key; // Persist در Store (و اختیاری sessionStorage) تا رفرش صفحه هم آن را نبازد + + try { + const { data } = await spinApi.spin(campaignId.value, { idempotencyKey: key }); + lastResult.value = data; + pendingIdempotencyKey.value = null; // فقط بعد از موفقیت قطعی پاک شود + } catch (err) { + if (isNetworkError(err)) { + // کلید را نگه می‌داریم؛ کاربر با دکمه "بررسی نتیجه" یا Retry خودکار همان Request را دوباره می‌فرستد + showNetworkErrorState(); + } else { + pendingIdempotencyKey.value = null; // خطای قطعی (مثلا NO_CHANCE_LEFT) → کلید جدید برای تلاش بعدی + handleBusinessError(err); + } + } finally { + isSpinning.value = false; + } +} +``` + +این الگو دقیقاً پیاده‌سازی قانون بخش ۲۹ سند اصلی است: «در خطای شبکه، کاربر نباید بدون مشخص شدن وضعیت Request قبلی، Spin جدید ایجاد کند.» + +--- + +## 18. پنج طرح گردونه (Wheel Designs) قابل انتخاب توسط ادمین + +هنگام ساخت یا ویرایش کمپین، ادمین از یک `WheelDesignPicker` (Thumbnail هر طرح + پیش‌نمایش زنده) یکی از پنج طرح زیر را انتخاب می‌کند؛ مقدار در `campaigns.wheel_design` ذخیره و همان طرح دقیقاً برای همه کاربران آن کمپین نمایش داده می‌شود. رنگ Segment از `prizes.segment_color` می‌آید؛ هر طرح فقط چارچوب بصری (Frame، Pointer، Hub، تایپوگرافی، افکت‌ها، پس‌زمینه) را تعیین می‌کند. + +### طرح ۱ — Classic Casino (کلاسیک کازینویی) +- **حس‌وحال:** لوکس سنتی، اعتمادساز، مناسب کمپین‌های عمومی و فروشگاهی. +- **پالت:** پس‌زمینه قرمز تیره/طلایی، حاشیه گردونه با حباب‌های نور طلایی چشمک‌زن (LED Bulb Ring). +- **Pointer:** فلش سه‌بعدی طلایی رو به پایین. +- **Hub مرکزی:** دکمه دایره‌ای طلایی با متن «SPIN» یا `cta_text` کمپین. +- **تایپوگرافی:** فونت ضخیم Serif برای اعداد/جوایز. +- **افکت برد:** Confetti طلایی + صدای سکه (اختیاری). +- **بهترین کاربرد:** کمپین‌های فروشگاهی، جشنواره‌های خرید. + +### طرح ۲ — Neon Cyber (نئون سایبری) +- **حس‌وحال:** مدرن، جوان‌پسند، تکنولوژیک؛ مناسب برندهای دیجیتال، اپلیکیشن، بازی. +- **پالت:** پس‌زمینه مشکی/بنفش تیره، خطوط Neon Glow (فیروزه‌ای/صورتی) دور گردونه و مرزهای Segment. +- **Pointer:** مثلث نئونی با افکت Glow پالسی. +- **Hub مرکزی:** دایره شیشه‌ای (Glassmorphism) با افکت Pulse هنگام Spin. +- **تایپوگرافی:** فونت Geometric Sans با افکت Glow روی متن برنده. +- **افکت برد:** انفجار ذرات نئونی + لرزش صفحه سبک (Screen Shake). +- **بهترین کاربرد:** اپلیکیشن‌های موبایل، گیمینگ، کمپین‌های Tech. + +### طرح ۳ — Minimal Flat (مینیمال تخت) +- **حس‌وحال:** تمیز، حرفه‌ای، سریع‌بارگذاری؛ مناسب برندهایی که هویت بصری ساده و مدرن دارند (فین‌تک، B2B، خدماتی). +- **پالت:** پس‌زمینه سفید/خاکستری روشن، Segmentها با رنگ‌های Flat تعریف‌شده در تنظیمات جایزه، بدون Gradient/سایه سنگین. +- **Pointer:** مثلث ساده تک‌رنگ (رنگ اصلی برند از `settings.primaryColor`). +- **Hub مرکزی:** دایره تخت با لوگوی کمپین. +- **تایپوگرافی:** فونت Sans-Serif ظریف، Line-height باز. +- **افکت برد:** Fade + Scale ساده بدون افکت گرافیکی سنگین (Performance بالا در موبایل‌های ضعیف). +- **بهترین کاربرد:** کمپین‌های Corporate، اپلیکیشن‌های بانکی/فین‌تک، برندهایی با اولویت سرعت بارگذاری. + +### طرح ۴ — Luxury Gold (لوکس طلایی/پرمیوم) +- **حس‌وحال:** خاص، گران‌قیمت، VIP؛ مناسب کمپین‌های جواهرات، خودرو، جوایز بزرگ. +- **پالت:** پس‌زمینه مشکی مات با بافت مرمر/طلایی ظریف، Segmentهای مشکی/طلایی متناوب با خطوط باریک طلایی جداکننده. +- **Pointer:** الماس یا تاج کوچک طلایی. +- **Hub مرکزی:** دایره طلایی با افکت درخشش آهسته (Shimmer Animation مداوم، نه فقط هنگام Spin). +- **تایپوگرافی:** فونت Serif ظریف (شبیه برندهای لوکس)، Letter-spacing باز. +- **افکت برد:** درخشش نور از مرکز به بیرون + صدای ملایم (اختیاری). +- **بهترین کاربرد:** کمپین‌های VIP، جوایز باارزش بالا، برندهای پرمیوم. + +### طرح ۵ — Playful Cartoon (کارتونی و بازیگوش) +- **حس‌وحال:** شاد، رنگارنگ، خانواده‌محور؛ مناسب کمپین‌های کودک، فست‌فود، سرگرمی، تخفیف‌های عمومی. +- **پالت:** رنگ‌های اشباع‌شده و شاد (نارنجی، فیروزه‌ای، زرد، صورتی)، Segmentها با الگوی نقطه‌ای/موجی کارتونی. +- **Pointer:** شخصیت کوچک انیمیشنی (مثلاً دست اشاره‌گر یا موشک) با بونس ساده (Bounce) هنگام توقف. +- **Hub مرکزی:** دکمه بزرگ گرد با افکت Squish (فشرده‌شدن) هنگام لمس. +- **تایپوگرافی:** فونت گرد و ضخیم (Rounded/Bubbly). +- **افکت برد:** بادکنک/کانفتی رنگی + انیمیشن پرش شخصیت. +- **بهترین کاربرد:** کمپین‌های فست‌فود، سرگرمی، مناسبتی (تولد، اعیاد)، مخاطب خانواده و کودک. + +### پیاده‌سازی فنی مشترک همه طرح‌ها + +- همه پنج کامپوننت (`ClassicCasinoWheel.vue`, `NeonCyberWheel.vue`, ...) یک **Contract یکسان Props/Emits** دارند تا `WheelView.vue` بدون شرط‌گذاری اضافه، هرکدام را Render کند: + +```ts +interface WheelDesignProps { + segments: { id: string; title: string; image?: string; color: string }[]; + targetSegmentIndex: number | null; // از پاسخ Backend + spinning: boolean; +} +// emits: ['spin-finished'] +``` + +- محاسبه زاویه دقیق توقف (منطق ریاضی چرخش) در `WheelBase.vue` مشترک نگه داشته می‌شود و به‌صورت Composable (`useWheelRotation()`) در تمام ۵ طرح Import می‌شود؛ فقط SVG/CSS/افکت هر طرح متفاوت است. این کار از تکرار منطق حساس (که مستقیماً روی صحت نمایش نتیجه Backend اثر دارد) در ۵ فایل جلوگیری می‌کند. +- هر طرح دارای نسخه *Reduced Motion* (برای کاربرانی که `prefers-reduced-motion` را در مرورگر فعال کرده‌اند) است: افکت‌های پرهزینه (Glow پیوسته، Shimmer، ذرات) غیرفعال و فقط چرخش اصلی نمایش داده می‌شود. +- تمام ۵ طرح باید در viewport موبایل (حداقل عرض ۳۲۰px) بدون Horizontal Scroll و بدون افت کیفیت رندر شوند (الزام بخش ۳۱ سند اصلی)؛ توصیه می‌شود گردونه به‌صورت SVG با `viewBox` نسبی (نه اندازه ثابت پیکسلی) پیاده‌سازی شود. + +--- + +## 19. Security و Anti-Fraud + +| کنترل | پیاده‌سازی فنی | +|---|---| +| OTP Verification | هش‌شده در دیتابیس، TTL ۲ دقیقه، محدودیت تلاش (بخش ۵) | +| Authentication Token | JWT جدا برای User و Admin با `type` claim متفاوت؛ Guard اختصاصی هرکدام | +| Server-Side Spin | تمام منطق در `SpinEngineService`؛ هیچ مقدار Prize/Winner از Body ورودی خوانده نمی‌شود | +| Idempotency | `idempotency_key` یکتا در دیتابیس + بررسی Application-level (بخش ۹، ۱۰) | +| IP / User-Agent Logging | ثبت روی هر `Spin` از `req.ip` (پشت Nginx با `X-Forwarded-For` صحیح تنظیم‌شده) و `req.headers['user-agent']` | +| محدودیت درخواست (Rate Limiting) | `@nestjs/throttler` سراسری + Throttle اختصاصی روی `/spin` و `/send-otp` (بخش ۲۰) | +| جلوگیری از Concurrent Spin | `SELECT ... FOR UPDATE` روی `user_campaigns` (بخش ۹.۳) — عملاً هر کاربر در هر لحظه فقط یک Spin در حال پردازش دارد | +| Transaction برای Prize Inventory | `SELECT ... FOR UPDATE` روی `prizes` + Prisma Interactive Transaction (بخش ۹) | +| Admin Audit Log | Interceptor سراسری (`AuditInterceptor`) روی تمام Route‌های `PATCH/POST/DELETE` پنل ادمین که مقدار قبل/بعد را در `admin_logs` ثبت می‌کند | +| عدم افشای Weight به کلاینت | Endpoint عمومی گردونه (`/campaigns/:slug/wheel`) فیلد `weight` را Strip می‌کند (DTO جدا از Entity، بخش ۱۴) | +| Helmet / CORS | `helmet()` فعال، CORS محدود به Origin دقیق دو Frontend (Admin/Client) در Production | +| SQL Injection | استفاده انحصاری از Prisma Client یا `$queryRaw` با Parameterized Query (Tagged Template)؛ هرگز String Concatenation | +| XSS | Escape خودکار Vue در Template؛ عدم استفاده از `v-html` برای محتوای وارد‌شده توسط کاربر/ادمین (مثلاً `description` کمپین با Sanitize مثل `DOMPurify` در صورت نیاز به HTML محدود) | + +--- + +## 20. Rate Limiting + +پیکربندی `@nestjs/throttler` به‌صورت چندلایه: + +```ts +ThrottlerModule.forRoot([ + { name: 'default', ttl: 60000, limit: 60 }, // سراسری: ۶۰ درخواست در دقیقه هر IP +]); +``` + +Override اختصاصی روی Endpointهای حساس: + +| Endpoint | محدودیت | +|---|---| +| `POST /auth/send-otp` | ۱ درخواست در ۶۰ ثانیه هر شماره موبایل + ۵ درخواست در ساعت هر IP | +| `POST /auth/verify-otp` | ۵ تلاش در ۵ دقیقه هر شماره موبایل | +| `POST /campaigns/:id/spin` | ۱۰ درخواست در دقیقه هر کاربر (فراتر از نیاز واقعی UX ولی سد در برابر Script/Bot) | +| `POST /admin/auth/login` | ۵ تلاش در ۱۵ دقیقه هر IP (جلوگیری از Brute-force پنل مدیریت) | + +--- + +## 21. Environment Variables + +```env +# App +NODE_ENV=production +PORT=3000 + +# Database +DATABASE_URL=postgresql://user:pass@localhost:5432/lucky_wheel + +# JWT +JWT_USER_SECRET=change-me +JWT_USER_EXPIRES_IN=7d +JWT_ADMIN_ACCESS_SECRET=change-me +JWT_ADMIN_ACCESS_EXPIRES_IN=1h +JWT_ADMIN_REFRESH_SECRET=change-me +JWT_ADMIN_REFRESH_EXPIRES_IN=7d + +# Redis / BullMQ +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# SMS - raygansms.com +RAYGAN_SMS_USERNAME=xxxx +RAYGAN_SMS_PASSWORD=xxxx +RAYGAN_SMS_LINE_NUMBER=xxxx +RAYGAN_SMS_API_BASE_URL=https://api.raygansms.com + +# File Storage (Local - فاز فعلی) +STORAGE_DRIVER=local +STORAGE_LOCAL_ROOT=./uploads +STORAGE_PUBLIC_URL=https://api.example.com/uploads + +# CORS +CORS_ADMIN_ORIGIN=https://admin.example.com +CORS_CLIENT_ORIGIN=https://wheel.example.com +``` + +> کلیدهای واقعی سرویس راه‌گان پیامک (نام کاربری/رمز/شماره خط) باید از پنل `raygansms.com` دریافت و هرگز در Git Commit نشوند (`.env` در `.gitignore`). + +--- + +## 22. Docker / Deployment + +```yaml +# docker-compose.yml (Dev) +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: lucky_wheel + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: ["5432:5432"] + volumes: ["pg_data:/var/lib/postgresql/data"] + + redis: + image: redis:7-alpine + ports: ["6379:6379"] + + api: + build: ./apps/api + depends_on: [postgres, redis] + env_file: ./apps/api/.env + ports: ["3000:3000"] + volumes: ["./apps/api/uploads:/app/uploads"] + + admin: + build: ./apps/admin + ports: ["5173:80"] + + client: + build: ./apps/client + ports: ["5174:80"] + +volumes: + pg_data: +``` + +### مراحل Deploy پیشنهادی + +1. `pnpm install` در Root (Workspace). +2. `pnpm --filter api prisma migrate deploy` (اجرای Migrationها روی دیتابیس Production). +3. Build سه اپلیکیشن (`pnpm --filter api build`, `--filter admin build`, `--filter client build`). +4. Serve `admin`/`client` به‌صورت استاتیک از پشت Nginx (یا هر CDN). +5. اجرای `api` با PM2/Docker با حداقل ۲ Instance پشت Load Balancer برای Zero-downtime Deploy (با توجه به این‌که Session در JWT است، نیازی به Sticky Session نیست). +6. اجرای Worker BullMQ به‌صورت Process/Instance جدا از API اصلی (مقیاس‌پذیری مستقل صف SMS از ترافیک HTTP). + +--- + +## 23. استراتژی تست و Mapping با Acceptance Criteria + +### سطوح تست + +- **Unit Test:** `WeightedRandomSelector`, `ChanceService`, `PrizeCodeGenerator`, `CampaignAvailabilityService` (Jest، بدون دیتابیس واقعی — Prisma Mock). +- **Integration Test:** `SpinEngineService` روی دیتابیس Test واقعی (Postgres در Docker) شامل سناریوهای Concurrency (اجرای موازی چند Promise برای شبیه‌سازی Race Condition آیفون بخش ۹ سند اصلی). +- **E2E Test:** با `supertest` روی کل مسیر OTP → Login → Spin → Result برای هر دو نقش. +- **Load Test:** k6 یا Artillery برای اطمینان از پایداری Lock Contention روی `prizes`/`user_campaigns` تحت بار بالا (مثلاً ۵۰۰ Spin هم‌زمان روی یک کمپین با یک جایزه کم‌موجودی). + +### Mapping مستقیم AC-01 تا AC-14 (سند اصلی) به تست + +| AC | تست متناظر | +|---|---| +| AC-01 | E2E: تلاش Spin بدون JWT معتبر → 401 | +| AC-02 | Integration: کاربر با `remaining=0` → 400 `NO_CHANCE_LEFT` | +| AC-03 | Integration: بررسی `usedChances` دقیقاً ۱ واحد افزایش بعد از هر Spin موفق | +| AC-04 | E2E: چند بار `GET /me/chances` پشت‌سرهم بدون تغییر مقدار | +| AC-05 / AC-06 | Unit + Code Review: هیچ Controller مقدار Prize را از Body نمی‌خواند؛ فقط `idempotencyKey` ورودی است | +| AC-07 | Integration: Prize با `remainingStock=0` هرگز در نتیجه ۱۰۰ اجرای متوالی Selector ظاهر نشود | +| AC-08 | Load Test: ۱۰۰ Request موازی روی Prize با موجودی ۱ → دقیقاً ۱ برنده، `remainingStock` هرگز منفی | +| AC-09 | Integration: ارسال دو Request با یک `idempotencyKey` → پاسخ یکسان، `usedChances` فقط یک بار افزایش | +| AC-10 | بررسی وجود رکورد `Spin` برای هر سناریوی بالا با `ip`/`userAgent` پر | +| AC-11 | Integration: `POST chances` ادمین → بررسی وجود `AdminLog` متناظر | +| AC-12 | Integration: Mock کردن Provider SMS برای پرتاب Exception → بررسی این‌که `Spin`/`UserPrize` همچنان ثبت‌شده باقی می‌ماند | +| AC-13 | E2E: Login مجدد کاربر → `GET /me/prizes` شامل جوایز قبلی | +| AC-14 | Integration: تلاش Spin روی کمپین با `endAt` گذشته → 400 `CAMPAIGN_FINISHED` | + +--- + +## 24. جمع‌بندی و مسیر توسعه آینده + +این سند، تمام محدوده MVP بخش ۳۴ سند اصلی را با معماری مشخص، Concurrency-Safe و قابل تست پوشش می‌دهد. طراحی به‌گونه‌ای انجام شده که موارد بخش ۳۳ سند اصلی (خارج از MVP) بدون تغییر ساختاری بزرگ قابل افزودن باشند: + +- **Referral / دریافت شانس با خرید:** فقط نیازمند یک `ChanceTxnType` جدید و یک Endpoint/Webhook جدید است؛ موتور Chance Ledger از قبل عمومی طراحی شده. +- **اتصال API فروشگاه / Webhook:** با افزودن ماژول `WebhookModule` که رویدادهای `spin.completed`, `prize.won` را Publish می‌کند (با BullMQ یا یک Event Emitter داخلی NestJS، `@nestjs/event-emitter`). +- **QR Code / Multi-Brand / White Label:** با افزودن سطح `Brand` بالای `Campaign` در مدل داده (یک `brand_id` روی جدول کمپین) بدون نیاز به تغییر Spin Engine. +- **طرح‌های گردونه بیشتر از ۵ مورد:** به لطف الگوی `WheelDesignProps` مشترک (بخش ۱۸)، افزودن طرح ششم صرفاً یک کامپوننت Vue جدید + یک مقدار Enum جدید است، بدون تغییر در Backend یا در چهار طرح دیگر. + +**مرجع اصلی این سند:** Lucky Wheel Lottery Spec v1.0 (فایل مرجع کارفرما) + تصمیمات فنی این جلسه (NestJS/Prisma/PostgreSQL، Vue3/Pinia/Ant Design Vue، BullMQ+Redis، raygansms.com، Local Storage قابل مهاجرت، ۵ طرح گردونه انتخابی). diff --git a/docs/Lucky_Wheel_Lottery_Spec_v1_0.md b/docs/Lucky_Wheel_Lottery_Spec_v1_0.md new file mode 100644 index 0000000..9953033 --- /dev/null +++ b/docs/Lucky_Wheel_Lottery_Spec_v1_0.md @@ -0,0 +1,1106 @@ +# داکیومنت فنی و اجرایی سامانه گردونه شانس و قرعه‌کشی + +**نسخه:** 1.0 +**نوع سیستم:** Web Application / Mobile First +**وضعیت:** MVP Specification +**هدف:** ایجاد و مدیریت کمپین‌های گردونه شانس با قابلیت تعریف جایزه، تخصیص شانس به کاربران، ثبت قرعه‌کشی، مدیریت برندگان، ارسال پیامک و گزارش‌گیری. + +--- + +## 1. خلاصه محصول + +سامانه از دو بخش اصلی تشکیل می‌شود: + +1. پنل کاربر و تجربه گردونه شانس +2. پنل مدیریت + +مدیر سیستم باید بتواند کمپین ایجاد کند، جوایز و موجودی آن‌ها را تعریف کند، تعداد شانس کاربران را مدیریت کند و نتایج تمام Spinها و برندگان را مشاهده و مدیریت کند. + +کاربر با شماره موبایل وارد سامانه می‌شود و بر اساس تعداد شانس موجود، گردونه را می‌چرخاند و نتیجه دریافت می‌کند. + +> **اصل معماری:** نتیجه Spin همیشه در Backend تعیین و ثبت می‌شود و انیمیشن گردونه صرفاً نتیجه‌ای را که Backend تعیین کرده نمایش می‌دهد. Frontend نباید هیچ نقش تصمیم‌گیرنده‌ای در انتخاب جایزه داشته باشد. + +--- + +## 2. نقش‌ها و دسترسی‌ها + +### 2.1 مدیر (Admin) + +- مدیریت کمپین‌ها +- مدیریت جوایز +- مدیریت کاربران +- مدیریت شانس‌ها +- مشاهده Spinها +- مشاهده و مدیریت برندگان +- مدیریت پیامک‌ها +- گزارش‌گیری +- مشاهده Audit Log +- مدیریت تنظیمات سیستم + +### 2.2 کاربر (User) + +- ورود با شماره موبایل و OTP +- مشاهده تعداد شانس +- چرخاندن گردونه +- مشاهده نتیجه +- مشاهده شانس‌های باقی‌مانده +- مشاهده تاریخچه جوایز + +--- + +## 3. جریان اصلی کاربر + +```text +شماره موبایل + ↓ +ارسال OTP + ↓ +تأیید شماره + ↓ +ایجاد یا شناسایی کاربر + ↓ +بررسی کمپین فعال + ↓ +محاسبه تعداد شانس + ↓ +نمایش گردونه + ↓ +درخواست Spin + ↓ +انتخاب نتیجه توسط Backend + ↓ +کسر یک شانس + ↓ +ثبت Transaction + ↓ +نمایش انیمیشن گردونه + ↓ +نمایش جایزه + ↓ +ارسال SMS + ↓ +Spin مجدد در صورت وجود شانس +``` + +--- + +## 4. احراز هویت + +ورود کاربران با شماره موبایل انجام می‌شود. + +### APIهای پایه + +- `POST /api/auth/send-otp` +- `POST /api/auth/verify-otp` + +### قوانین + +- شماره موبایل فیلد اصلی شناسایی کاربر است. +- پس از ارسال شماره، OTP ارسال شود. +- پس از تأیید OTP، Token یا Session معتبر ایجاد شود. +- محدودیت ارسال مجدد OTP اعمال شود. +- تعداد تلاش ناموفق OTP محدود شود. +- کاربر بدون شماره موبایل تأییدشده نباید امکان Spin داشته باشد. + +نمونه پیام: + +```text +کد ورود شما به گردونه شانس: 48215 +``` + +--- + +## 5. مدیریت کمپین + +سیستم باید Multi-Campaign باشد. + +### فیلدهای کمپین + +| فیلد | توضیح | +|---|---| +| id | شناسه یکتا | +| title | عنوان کمپین | +| slug | شناسه URL | +| description | توضیحات | +| image / banner | تصویر یا بنر | +| logo | لوگو | +| start_at | زمان شروع | +| end_at | زمان پایان | +| status | وضعیت کمپین | +| default_chances | تعداد شانس پیش‌فرض | +| rules | قوانین کمپین | +| cta_text | متن CTA | +| settings | تنظیمات ظاهری | +| created_at | زمان ایجاد | +| updated_at | زمان ویرایش | + +### وضعیت‌ها + +- `Draft` +- `Active` +- `Paused` +- `Finished` + +### شرط فعال بودن + +فقط کمپین‌هایی که وضعیت `Active` دارند و زمان فعلی داخل بازه `start_at` تا `end_at` است، قابل استفاده و Spin هستند. + +--- + +## 6. مدیریت جوایز + +برای هر کمپین تعداد نامحدودی Prize قابل تعریف است. + +### فیلدهای جایزه + +| فیلد | توضیح | +|---|---| +| title | عنوان جایزه | +| description | توضیح | +| image | تصویر | +| stock | موجودی | +| stock_type | محدود یا نامحدود | +| weight | وزن برد | +| sort_order | ترتیب Segment روی گردونه | +| segment_color | رنگ Segment | +| status | وضعیت | +| sms_template | متن پیامک | +| expires_at | تاریخ اعتبار | +| type | نوع جایزه | + +### نوع جایزه + +- `Physical` +- `Discount Code` +- `Credit` +- `Cash` +- `No Prize` +- `Other` + +### نوع موجودی + +- `Limited` +- `Unlimited` + +نمونه: + +- آیفون — 1 عدد +- کارت هدیه — 10 عدد +- تخفیف 30٪ — 100 عدد +- تخفیف 10٪ — نامحدود +- بدون جایزه — نامحدود + +--- + +## 7. موتور Spin + +### اصل کلیدی + +Spin باید کاملاً **Backend-Based** باشد. + +Frontend فقط درخواست را ارسال می‌کند و پس از دریافت نتیجه، Animation را اجرا می‌کند. + +### ترتیب اجرای Spin + +1. بررسی Authentication Token +2. بررسی فعال بودن کمپین +3. بررسی تعداد شانس کاربر +4. بررسی Prizeهای فعال +5. بررسی موجودی Prizeهای محدود +6. اعمال قوانین کمپین +7. انتخاب نتیجه توسط Backend +8. مصرف یک Chance +9. کاهش موجودی Prize در صورت محدود بودن +10. ثبت Spin +11. بازگرداندن نتیجه به Frontend +12. اجرای انیمیشن گردونه روی Segment تعیین‌شده +13. ارسال SMS + +تمام عملیات حساس Spin باید داخل Database Transaction و به‌صورت Atomic انجام شود. + +در صورت شکست عملیات، نباید Chance یا Prize Inventory به‌صورت ناقص تغییر کند. + +--- + +## 8. سیستم Weight و احتمال برد + +هر Prize یک `Weight` دارد و احتمال برد به‌صورت نسبی از مجموع Weightهای قابل انتخاب محاسبه می‌شود. + +مثال: + +| Prize | Weight | +|---|---:| +| آیفون | 1 | +| کارت هدیه | 10 | +| تخفیف 30٪ | 50 | +| تخفیف 10٪ | 200 | +| بدون جایزه | 739 | + +مجموع Weight در این مثال `1000` است. + +الگوریتم نیازی ندارد مجموع Weight برابر 100 یا 1000 باشد. ترکیب زیر نیز معتبر است: + +- A = 10 +- B = 20 +- C = 70 + +--- + +## 9. کنترل موجودی Prize + +اگر موجودی یک Prize به صفر رسید: + +- Prize نباید دوباره انتخاب شود. +- Backend باید آن را از Pool قرعه‌کشی خارج کند. +- نمایش Segment غیرفعال می‌تواند مطابق تنظیمات کمپین ادامه پیدا کند. +- کاهش موجودی باید داخل Transaction انجام شود. +- در درخواست‌های هم‌زمان، تعداد جوایز توزیع‌شده نباید از موجودی واقعی بیشتر شود. + +### Race Condition + +مثال: فقط یک آیفون باقی مانده و دو کاربر هم‌زمان Spin می‌کنند. + +نتیجه صحیح این است که فقط یک کاربر آیفون دریافت کند. + +بنابراین انتخاب Prize، کنترل موجودی، کاهش موجودی، مصرف Chance و ثبت Spin باید در یک Transaction با Lock مناسب انجام شود. + +--- + +## 10. سیستم Chance + +برای هر کاربر در هر کمپین تعداد شانس مستقل نگهداری شود. + +### Snapshot / Summary + +- `total_chances` +- `used_chances` +- `remaining_chances` + +### Chance Ledger + +علاوه بر Summary، تمام تغییرات شانس باید در یک Ledger ثبت شوند. + +نمونه: + +```text ++3 ثبت‌نام در کمپین ++2 اضافه‌شده توسط مدیر +-1 Spin +-1 Spin ++5 جایزه کمپین جانبی +``` + +Ledger برای Audit، گزارش‌گیری و بررسی اختلافات ضروری است. + +--- + +## 11. شانس پیش‌فرض + +هنگام ساخت کمپین، مدیر تعیین می‌کند هر کاربر جدید چند شانس دریافت کند. + +مثال: + +```text +Default Chances = 3 +``` + +کاربری که برای اولین بار وارد کمپین شود، 3 شانس دریافت می‌کند. + +مدیر باید بتواند برای کاربران مشخص، شانس را به‌صورت دستی اضافه یا کم کند. + +--- + +## 12. مدیریت کاربران + +### لیست کاربران + +- شماره موبایل +- نام در صورت وجود +- تاریخ عضویت +- آخرین ورود +- تعداد کمپین‌های شرکت‌کرده +- وضعیت + +### جزئیات کاربر + +- کمپین‌های کاربر +- شانس کل +- شانس مصرف‌شده +- شانس باقی‌مانده +- تاریخچه Spin +- جوایز +- تاریخچه تغییر شانس + +### عملیات مدیریتی + +- اضافه کردن شانس +- کم کردن شانس +- مسدود کردن کاربر +- مشاهده جزئیات فعالیت + +--- + +## 13. UX صفحه اصلی گردونه + +صفحه کاربر باید Mobile First باشد. + +### اجزای اصلی + +- لوگوی برند +- نام کمپین +- توضیح کوتاه +- گردونه +- Pointer +- تعداد شانس باقی‌مانده +- دکمه «بچرخون» +- لینک قوانین +- دکمه «جوایز من» + +نمونه نمایش: + +```text +3 شانس داری 🎟️ + +[ گردونه ] + +بچرخون +``` + +--- + +## 14. رفتار UI هنگام Spin + +1. کاربر دکمه Spin را لمس می‌کند. +2. دکمه فوراً Disable می‌شود. +3. Loading کوتاه نمایش داده می‌شود. +4. درخواست به Backend ارسال می‌شود. +5. Backend نتیجه را مشخص می‌کند. +6. گردونه چند دور کامل می‌چرخد. +7. گردونه دقیقاً روی Segment برگردانده‌شده متوقف می‌شود. +8. تا پایان Animation امکان Spin مجدد وجود ندارد. +9. Modal نتیجه نمایش داده می‌شود. + +--- + +## 15. صفحه نتیجه + +### در صورت برد + +```text +🎉 تبریک! + +شما برنده شدید + +[ تصویر جایزه ] + +نام جایزه + +کد جایزه: MNT-X7D92 +``` + +CTA: + +- در صورت وجود شانس: «یک شانس دیگه دارم» +- در صورت تمام شدن شانس: «مشاهده جوایز من» + +--- + +## 16. جوایز من + +کاربر باید تاریخچه جوایز خود را مشاهده کند. + +هر Card شامل: + +- تصویر +- عنوان +- تاریخ برد +- کد جایزه +- وضعیت +- تاریخ انقضا + +### وضعیت‌ها + +- `New` +- `Used` +- `Delivered` +- `Expired` + +--- + +## 17. ساختار پنل مدیریت + +منوی اصلی پیشنهادی: + +```text +Dashboard +Campaigns +Prizes +Users +Spins +Winners +Chance Management +SMS +Reports +Settings +``` + +--- + +## 18. Dashboard + +### KPIها + +- تعداد کاربران +- کاربران امروز +- تعداد کل Spin +- Spin امروز +- تعداد برندگان +- تعداد جوایز توزیع‌شده +- شانس‌های مصرف‌شده +- موجودی جوایز + +### نمودارها + +- Spin بر اساس روز +- ثبت‌نام بر اساس روز +- توزیع جوایز + +### جدول + +آخرین برندگان نیز نمایش داده شوند. + +--- + +## 19. صفحه Spinها + +تمام چرخش‌ها در این بخش ثبت و قابل مشاهده باشند. + +### ستون‌ها + +| ستون | توضیح | +|---|---| +| ID | شناسه Spin | +| Mobile | شماره موبایل | +| Campaign | کمپین | +| Prize | جایزه | +| Date | تاریخ | +| Time | ساعت | +| Status | وضعیت | +| IP | IP درخواست | +| User Agent | Device / Browser اطلاعاتی | + +### قابلیت‌ها + +- Search +- Filter +- Pagination +- Export Excel + +--- + +## 20. صفحه Winners + +### اطلاعات + +- شماره موبایل +- جایزه +- کمپین +- زمان برد +- کد جایزه +- وضعیت تحویل + +### وضعیت جایزه فیزیکی + +```text +جدید → تماس گرفته شد → تحویل شد +``` + +### وضعیت کد تخفیف + +```text +فعال → استفاده شد → منقضی شد +``` + +--- + +## 21. سیستم کد جایزه + +برای Prizeهایی که نیاز به Code دارند، سیستم باید Unique Code تولید کند. + +نمونه: + +```text +MNT-8F4KD2 +``` + +قوانین: + +- هر Code فقط متعلق به یک Winner باشد. +- امکان Import مجموعه Codeها در نسخه توسعه‌یافته پیش‌بینی شود. +- نمونه: فروشگاه 500 کد تخفیف آماده دارد و آن‌ها را وارد سامانه می‌کند. + +--- + +## 22. SMS Service + +SMS باید به‌صورت Service مستقل پیاده‌سازی شود تا Provider در آینده قابل تعویض باشد. + +### Templateهای پایه + +- OTP +- Winning +- No Prize +- Prize Reminder + +نمونه پیام: + +```text +تبریک! شما برنده کارت هدیه ۱ میلیون تومانی شدید. کد پیگیری: MNT-72F5 +``` + +### SMS Log + +تمام SMSها باید Log شوند. + +| فیلد | توضیح | +|---|---| +| mobile | شماره گیرنده | +| template | Template | +| message | متن ارسال‌شده | +| provider | سرویس‌دهنده | +| created_at | زمان | +| status | وضعیت | +| provider_response | پاسخ Provider | + +**قانون مهم:** Fail شدن SMS نباید باعث Rollback شدن Spin موفق شود. + +--- + +## 23. جلوگیری از Spin تکراری + +برای درخواست Spin از `Idempotency Key` استفاده شود. + +هر درخواست Spin باید یک شناسه یکتا داشته باشد. + +اگر همان Request دوباره ارسال شد: + +- نتیجه قبلی برگردانده شود. +- Chance مجدد مصرف نشود. +- Prize مجدد توزیع نشود. +- Spin جدید ثبت نشود. + +نمونه: + +```json +{ + "idempotency_key": "uuid" +} +``` + +--- + +## 24. امنیت و Anti-Fraud + +حداقل کنترل‌های لازم: + +- OTP Verification +- Rate Limiting +- Authentication Token / Session +- Server-Side Spin +- Idempotency +- IP Logging +- User Agent Logging +- محدودیت درخواست +- جلوگیری از Concurrent Spin +- Transaction برای Prize Inventory +- Admin Audit Log + +هیچ مقدار حساسی مانند موارد زیر نباید توسط Frontend تعیین شود: + +- Chance +- Prize ID +- Weight +- Winner + +--- + +## 25. Admin Audit Log + +تمام تغییرات مهم مدیر باید ثبت شوند. + +نمونه: + +```text +Admin X +در تاریخ X +برای کاربر 0912... +5 شانس اضافه کرد. +``` + +یا: + +```text +احتمال جایزه «کارت هدیه» +از 10 به 20 تغییر کرد. +``` + +Audit Log برای بررسی اختلافات و Trace تغییرات ضروری است. + +--- + +## 26. مدل پیشنهادی دیتابیس + +### admins + +- `id` +- `name` +- `mobile` +- `email` +- `password` +- `status` +- `created_at` + +### users + +- `id` +- `mobile` +- `name` +- `status` +- `last_login_at` +- `created_at` + +### campaigns + +- `id` +- `title` +- `slug` +- `description` +- `image` +- `banner` +- `logo` +- `start_at` +- `end_at` +- `status` +- `default_chances` +- `rules` +- `cta_text` +- `settings` +- `created_at` +- `updated_at` + +### prizes + +- `id` +- `campaign_id` +- `title` +- `description` +- `image` +- `type` +- `stock_type` +- `initial_stock` +- `remaining_stock` +- `weight` +- `status` +- `sort_order` +- `segment_color` +- `expires_at` +- `sms_template` +- `created_at` + +### user_campaigns + +- `id` +- `user_id` +- `campaign_id` +- `total_chances` +- `used_chances` +- `created_at` + +### chance_transactions + +- `id` +- `user_id` +- `campaign_id` +- `amount` +- `type` +- `reference_type` +- `reference_id` +- `description` +- `created_by` +- `created_at` + +### spins + +- `id` +- `user_id` +- `campaign_id` +- `prize_id` +- `idempotency_key` +- `ip` +- `user_agent` +- `created_at` + +### user_prizes + +- `id` +- `user_id` +- `campaign_id` +- `prize_id` +- `spin_id` +- `code` +- `status` +- `won_at` +- `redeemed_at` + +### sms_logs + +- `id` +- `user_id` +- `mobile` +- `type` +- `message` +- `provider` +- `status` +- `provider_response` +- `created_at` + +### admin_logs + +- `id` +- `admin_id` +- `action` +- `entity_type` +- `entity_id` +- `old_value` +- `new_value` +- `ip` +- `created_at` + +--- + +## 27. APIهای اصلی کاربر + +### Authentication + +```http +POST /api/auth/send-otp +POST /api/auth/verify-otp +``` + +### Campaign + +```http +GET /api/campaigns/{slug} +GET /api/campaigns/{slug}/wheel +``` + +### User + +```http +GET /api/me +GET /api/me/chances +GET /api/me/prizes +``` + +### Spin + +```http +POST /api/campaigns/{id}/spin +``` + +### نمونه Request + +```json +{ + "idempotency_key": "uuid" +} +``` + +### نمونه Response + +```json +{ + "success": true, + "spin_id": 1251, + "prize": { + "id": 7, + "title": "تخفیف 30 درصد", + "segment": 4, + "code": "MNT-7F92K" + }, + "remaining_chances": 2 +} +``` + +--- + +## 28. APIهای پنل مدیریت + +- Campaign CRUD +- Prize CRUD +- User List / Detail +- Add / Remove Chance +- Spin List +- Winner List +- Prize Status Update +- Reports +- SMS Logs +- Admin Logs + +تمام APIهای مدیریت باید Authentication و Authorization مستقل Admin داشته باشند. + +--- + +## 29. خطاها و Edge Caseها + +Frontend باید برای موارد زیر UI مناسب داشته باشد: + +- شانس کافی نیست. +- کمپین هنوز شروع نشده. +- کمپین تمام شده. +- کمپین موقتاً متوقف است. +- درخواست نامعتبر است. +- اینترنت قطع شده است. +- درخواست Spin تکراری است. +- خطای سرور رخ داده است. + +در خطای شبکه، کاربر نباید بدون مشخص شدن وضعیت Request قبلی، Spin جدید ایجاد کند. + +--- + +## 30. تنظیمات ظاهری کمپین + +ساختار `settings` باید برای توسعه آینده آماده باشد و حداقل موارد زیر را پشتیبانی کند: + +- Logo +- Background +- Primary Color +- Secondary Color +- متن بالای گردونه +- متن دکمه +- تصویر Header +- قوانین +- Celebration Effect + +بنابراین هر کمپین می‌تواند ظاهر برند مستقل خود را داشته باشد. + +--- + +## 31. Responsive Design + +### اولویت تجربه کاربر + +1. Mobile +2. Tablet +3. Desktop + +گردونه در موبایل باید بدون Horizontal Scroll و به‌صورت کامل قابل مشاهده باشد. + +پنل مدیریت Desktop First ولی Responsive باشد. + +--- + +## 32. گزارش‌ها + +### گزارش‌های MVP + +- تعداد کاربران +- تعداد Spin +- تعداد Spin موفق +- تعداد جوایز توزیع‌شده +- تعداد هر Prize +- شانس مصرف‌شده +- کاربران بدون Spin +- کاربران با شانس باقی‌مانده +- روند روزانه مشارکت + +### فیلترها + +- از تاریخ +- تا تاریخ +- کمپین +- جایزه + +### خروجی + +- Excel + +--- + +## 33. قابلیت‌های خارج از MVP اما معماری‌پذیر + +معماری باید امکان افزودن موارد زیر را حفظ کند: + +- دریافت شانس با خرید +- اتصال API فروشگاه +- QR Code +- Referral +- معرفی دوست +- Scratch Card +- Mystery Box +- مسابقه +- Quiz +- Daily Spin +- Birthday Spin +- Coupon System +- اتصال CRM +- Webhook +- Multi-Brand +- White Label + +--- + +## 34. محدوده MVP + +نسخه اول شامل موارد زیر است: + +1. ورود با موبایل و OTP +2. ایجاد کمپین +3. مدیریت کمپین +4. تعریف جوایز +5. موجودی جایزه +6. Weight جایزه +7. تعریف تعداد شانس +8. مدیریت کاربران +9. اضافه / کم کردن دستی شانس +10. گردونه کاربر +11. Spin سمت Backend +12. کسر شانس +13. ثبت نتیجه +14. مدیریت موجودی +15. نمایش نتیجه +16. ارسال SMS +17. صفحه جوایز من +18. لیست Spinها +19. لیست برندگان +20. داشبورد آماری +21. گزارش اولیه +22. Audit Log +23. Anti Duplicate Spin + +--- + +## 35. Acceptance Criteria + +| ID | معیار پذیرش | +|---|---| +| AC-01 | کاربر بدون شماره موبایل تأییدشده امکان Spin ندارد. | +| AC-02 | کاربری که صفر شانس دارد امکان Spin ندارد. | +| AC-03 | هر Spin دقیقاً یک Chance مصرف می‌کند. | +| AC-04 | Refresh صفحه باعث دریافت Chance جدید نمی‌شود. | +| AC-05 | نتیجه Spin فقط توسط Backend مشخص می‌شود. | +| AC-06 | کاربر نمی‌تواند Prize ID موردنظر خودش را برای Backend ارسال کند و با آن نتیجه را تعیین کند. | +| AC-07 | Prize بدون موجودی قابل برنده‌شدن نیست. | +| AC-08 | در درخواست هم‌زمان، موجودی Prize منفی نمی‌شود و بیش از موجودی واقعی توزیع نمی‌شود. | +| AC-09 | ارسال مجدد یک Spin Request باعث مصرف Chance دوم نمی‌شود. | +| AC-10 | تمام Spinها قابل Audit هستند. | +| AC-11 | تغییر Chance توسط Admin در Log ثبت می‌شود. | +| AC-12 | نتیجه برد حتی در صورت Fail شدن SMS در سیستم ذخیره می‌ماند. | +| AC-13 | کاربر پس از ورود مجدد می‌تواند جوایز قبلی خود را مشاهده کند. | +| AC-14 | کمپین خارج از بازه زمانی قابل Spin نیست. | + +--- + +## 36. معماری پیشنهادی + +```text +┌─────────────────────┐ +│ User Frontend │ +│ Mobile First UI │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ Backend API │ +├─────────────────────┤ +│ Auth / Campaign │ +│ User / Chance │ +│ Spin Engine │ +│ Winner / Reports │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ Business Logic │ +│ / Spin Engine │ +└──────────┬──────────┘ + │ + ▼ +┌─────────────────────┐ +│ Database │ +└─────────────────────┘ + + ┌──────────────┐ + │ SMS Service │ + └──────┬───────┘ + │ + Queue / Job System + + ┌──────────────┐ + │ Admin Panel │ + └──────────────┘ +``` + +منطق قرعه‌کشی باید به‌صورت Service مستقل داخل Backend پیاده‌سازی شود تا الگوریتم در آینده بدون تغییر Frontend قابل توسعه یا تعویض باشد. + +--- + +## 37. قاعده حیاتی اجرای Spin + +### ترتیب صحیح + +```text +Request Spin + ↓ +Backend Decision + ↓ +Database Commit + ↓ +Response + ↓ +Wheel Animation +``` + +### ترتیب ممنوع + +```text +Wheel Animation + ↓ +Frontend Random + ↓ +Save Result +``` + +تمام قوانین Chance، Inventory و Winner باید در Backend کنترل شوند. + +--- + +## 38. Definition of Done برای MVP + +نسخه MVP زمانی آماده تحویل است که: + +- تمام قابلیت‌های Scope MVP پیاده‌سازی شده باشند. +- Spin فقط از Backend نتیجه بگیرد. +- Idempotency برای Spin فعال باشد. +- Race Condition مربوط به Inventory کنترل شده باشد. +- Chance Ledger و Audit Log ثبت شوند. +- SMS Failure باعث Rollback Spin نشود. +- Acceptance Criteriaهای AC-01 تا AC-14 پاس شوند. +- تجربه Mobile First گردونه بدون Horizontal Scroll باشد. +- پنل مدیریت برای Campaign، Prize، User، Spin، Winner و Report قابل استفاده باشد. + +--- + +## 39. نکات پیاده‌سازی برای تیم توسعه + +1. منطق Spin را داخل یک Service مستقل نگه دارید. +2. تغییر Chance و Inventory باید قابل Audit باشد. +3. هیچ نتیجه‌ای از Frontend قابل اعتماد نیست. +4. `idempotency_key` باید برای هر Spin یکتا و قابل ردیابی باشد. +5. عملیات Spin شامل مصرف Chance، انتخاب Prize، کنترل Inventory و ثبت نتیجه باید Atomic باشد. +6. ارسال SMS از فرآیند اصلی Spin جدا و ترجیحاً Queue-based باشد. +7. معماری برای Campaignهای متعدد و قابلیت‌های Gamification آینده قابل توسعه نگه داشته شود. + +--- + +**مرجع اصلی این سند:** مشخصات اولیه سامانه گردونه شانس و قرعه‌کشی، نسخه 1.0. diff --git a/iranSans/eot/IRANSansWeb(FaNum).eot b/iranSans/eot/IRANSansWeb(FaNum).eot new file mode 100644 index 0000000..389ec4b Binary files /dev/null and b/iranSans/eot/IRANSansWeb(FaNum).eot differ diff --git a/iranSans/eot/IRANSansWeb(FaNum)_Black.eot b/iranSans/eot/IRANSansWeb(FaNum)_Black.eot new file mode 100644 index 0000000..a9c2bef Binary files /dev/null and b/iranSans/eot/IRANSansWeb(FaNum)_Black.eot differ diff --git a/iranSans/eot/IRANSansWeb(FaNum)_Bold.eot b/iranSans/eot/IRANSansWeb(FaNum)_Bold.eot new file mode 100644 index 0000000..7ce9b1b Binary files /dev/null and b/iranSans/eot/IRANSansWeb(FaNum)_Bold.eot differ diff --git a/iranSans/eot/IRANSansWeb(FaNum)_Light.eot b/iranSans/eot/IRANSansWeb(FaNum)_Light.eot new file mode 100644 index 0000000..ee72989 Binary files /dev/null and b/iranSans/eot/IRANSansWeb(FaNum)_Light.eot differ diff --git a/iranSans/eot/IRANSansWeb(FaNum)_Medium.eot b/iranSans/eot/IRANSansWeb(FaNum)_Medium.eot new file mode 100644 index 0000000..3c20c14 Binary files /dev/null and b/iranSans/eot/IRANSansWeb(FaNum)_Medium.eot differ diff --git a/iranSans/eot/IRANSansWeb(FaNum)_UltraLight.eot b/iranSans/eot/IRANSansWeb(FaNum)_UltraLight.eot new file mode 100644 index 0000000..c789bb3 Binary files /dev/null and b/iranSans/eot/IRANSansWeb(FaNum)_UltraLight.eot differ diff --git a/iranSans/ttf/IRANSansWeb(FaNum).ttf b/iranSans/ttf/IRANSansWeb(FaNum).ttf new file mode 100644 index 0000000..3835c1d Binary files /dev/null and b/iranSans/ttf/IRANSansWeb(FaNum).ttf differ diff --git a/iranSans/ttf/IRANSansWeb(FaNum)_Black.ttf b/iranSans/ttf/IRANSansWeb(FaNum)_Black.ttf new file mode 100644 index 0000000..42af658 Binary files /dev/null and b/iranSans/ttf/IRANSansWeb(FaNum)_Black.ttf differ diff --git a/iranSans/ttf/IRANSansWeb(FaNum)_Bold.ttf b/iranSans/ttf/IRANSansWeb(FaNum)_Bold.ttf new file mode 100644 index 0000000..adbf9e4 Binary files /dev/null and b/iranSans/ttf/IRANSansWeb(FaNum)_Bold.ttf differ diff --git a/iranSans/ttf/IRANSansWeb(FaNum)_Light.ttf b/iranSans/ttf/IRANSansWeb(FaNum)_Light.ttf new file mode 100644 index 0000000..fae92bd Binary files /dev/null and b/iranSans/ttf/IRANSansWeb(FaNum)_Light.ttf differ diff --git a/iranSans/ttf/IRANSansWeb(FaNum)_Medium.ttf b/iranSans/ttf/IRANSansWeb(FaNum)_Medium.ttf new file mode 100644 index 0000000..9622928 Binary files /dev/null and b/iranSans/ttf/IRANSansWeb(FaNum)_Medium.ttf differ diff --git a/iranSans/ttf/IRANSansWeb(FaNum)_UltraLight.ttf b/iranSans/ttf/IRANSansWeb(FaNum)_UltraLight.ttf new file mode 100644 index 0000000..59dfb58 Binary files /dev/null and b/iranSans/ttf/IRANSansWeb(FaNum)_UltraLight.ttf differ diff --git a/iranSans/woff/IRANSansWeb(FaNum).woff b/iranSans/woff/IRANSansWeb(FaNum).woff new file mode 100644 index 0000000..1e6eb36 Binary files /dev/null and b/iranSans/woff/IRANSansWeb(FaNum).woff differ diff --git a/iranSans/woff/IRANSansWeb(FaNum)_Black.woff b/iranSans/woff/IRANSansWeb(FaNum)_Black.woff new file mode 100644 index 0000000..019c556 Binary files /dev/null and b/iranSans/woff/IRANSansWeb(FaNum)_Black.woff differ diff --git a/iranSans/woff/IRANSansWeb(FaNum)_Bold.woff b/iranSans/woff/IRANSansWeb(FaNum)_Bold.woff new file mode 100644 index 0000000..7008b60 Binary files /dev/null and b/iranSans/woff/IRANSansWeb(FaNum)_Bold.woff differ diff --git a/iranSans/woff/IRANSansWeb(FaNum)_Light.woff b/iranSans/woff/IRANSansWeb(FaNum)_Light.woff new file mode 100644 index 0000000..ecea926 Binary files /dev/null and b/iranSans/woff/IRANSansWeb(FaNum)_Light.woff differ diff --git a/iranSans/woff/IRANSansWeb(FaNum)_Medium.woff b/iranSans/woff/IRANSansWeb(FaNum)_Medium.woff new file mode 100644 index 0000000..587883a Binary files /dev/null and b/iranSans/woff/IRANSansWeb(FaNum)_Medium.woff differ diff --git a/iranSans/woff/IRANSansWeb(FaNum)_UltraLight.woff b/iranSans/woff/IRANSansWeb(FaNum)_UltraLight.woff new file mode 100644 index 0000000..c815b45 Binary files /dev/null and b/iranSans/woff/IRANSansWeb(FaNum)_UltraLight.woff differ diff --git a/iranSans/woff2/IRANSansWeb(FaNum).woff2 b/iranSans/woff2/IRANSansWeb(FaNum).woff2 new file mode 100644 index 0000000..c3dcbab Binary files /dev/null and b/iranSans/woff2/IRANSansWeb(FaNum).woff2 differ diff --git a/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2 b/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2 new file mode 100644 index 0000000..fc008ef Binary files /dev/null and b/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2 differ diff --git a/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2 b/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2 new file mode 100644 index 0000000..69f7c66 Binary files /dev/null and b/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2 differ diff --git a/iranSans/woff2/IRANSansWeb(FaNum)_Light.woff2 b/iranSans/woff2/IRANSansWeb(FaNum)_Light.woff2 new file mode 100644 index 0000000..40f5b09 Binary files /dev/null and b/iranSans/woff2/IRANSansWeb(FaNum)_Light.woff2 differ diff --git a/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2 b/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2 new file mode 100644 index 0000000..8273b0a Binary files /dev/null and b/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2 differ diff --git a/iranSans/woff2/IRANSansWeb(FaNum)_UltraLight.woff2 b/iranSans/woff2/IRANSansWeb(FaNum)_UltraLight.woff2 new file mode 100644 index 0000000..8b77d17 Binary files /dev/null and b/iranSans/woff2/IRANSansWeb(FaNum)_UltraLight.woff2 differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab666c8 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "lucky-wheel-monorepo", + "version": "1.0.0", + "private": true, + "description": "Lucky Wheel & Lottery Monorepo Platform", + "scripts": { + "dev:api": "pnpm --filter @lucky-wheel/api start:dev", + "dev:admin": "pnpm --filter @lucky-wheel/admin dev", + "dev:client": "pnpm --filter @lucky-wheel/client dev", + "build:types": "pnpm --filter @lucky-wheel/shared-types build", + "build:api": "pnpm --filter @lucky-wheel/api build", + "build:admin": "pnpm --filter @lucky-wheel/admin build", + "build:client": "pnpm --filter @lucky-wheel/client build", + "build": "pnpm run build:types && pnpm --filter @lucky-wheel/api build && pnpm --filter @lucky-wheel/admin build && pnpm --filter @lucky-wheel/client build", + "test:api": "pnpm --filter @lucky-wheel/api test", + "docker:up": "docker-compose up -d", + "docker:down": "docker-compose down" + }, + "devDependencies": { + "typescript": "^5.4.5" + } +} diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 0000000..113cedf --- /dev/null +++ b/packages/shared-types/package.json @@ -0,0 +1,14 @@ +{ + "name": "@lucky-wheel/shared-types", + "version": "1.0.0", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "watch": "tsc -w" + }, + "devDependencies": { + "typescript": "^5.4.5" + } +} diff --git a/packages/shared-types/src/farsi-number.ts b/packages/shared-types/src/farsi-number.ts new file mode 100644 index 0000000..3ab4cb4 --- /dev/null +++ b/packages/shared-types/src/farsi-number.ts @@ -0,0 +1,44 @@ +/** + * تبدیل تمامی ارقام انگلیسی (0-9) و عربی (٠-٩) به ارقام استاندارد فارسی (۰-۹) + */ +const FARSI_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']; + +export function toFarsiDigits(val: number | string | null | undefined): string { + if (val === null || val === undefined) return ''; + return String(val) + .replace(/[0-9]/g, (d) => FARSI_DIGITS[+d]) + .replace(/[٠-٩]/g, (d) => FARSI_DIGITS[d.charCodeAt(0) - 1632]); +} + +/** + * قالب‌بندی اعداد با جداکننده هزارگان و ارقام فارسی + */ +export function formatFarsiNumber(val: number | string | null | undefined): string { + if (val === null || val === undefined || val === '') return ''; + const num = typeof val === 'string' ? parseFloat(toEnglishDigits(val)) : val; + if (isNaN(num)) return toFarsiDigits(val); + const formatted = new Intl.NumberFormat('fa-IR').format(num); + return toFarsiDigits(formatted); +} + +/** + * تبدیل ارقام فارسی (۰-۹) و عربی (٠-٩) به ارقام استاندارد انگلیسی (0-9) + */ +export function toEnglishDigits(val: number | string | null | undefined): string { + if (val === null || val === undefined) return ''; + return String(val) + .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776)) + .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632)); +} + +/** + * پارس مطمئن مقادیر عددی از ورودی‌ها (پشتیبانی از هر دو فرمت فارسی و انگلیسی) + */ +export function parseFarsiNumber(val: number | string | null | undefined, fallback: number = 0): number { + if (val === null || val === undefined || val === '') return fallback; + if (typeof val === 'number') return isNaN(val) ? fallback : val; + const enStr = toEnglishDigits(val).replace(/[^0-9.-]/g, ''); + const parsed = Number(enStr); + return isNaN(parsed) ? fallback : parsed; +} + diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts new file mode 100644 index 0000000..eeb51cc --- /dev/null +++ b/packages/shared-types/src/index.ts @@ -0,0 +1,202 @@ +// ========================================== +// Shared Enums (Synchronized with Prisma Schema) +// ========================================== + +export enum AdminStatus { + ACTIVE = 'ACTIVE', + DISABLED = 'DISABLED', +} + +export enum AdminRole { + SUPER_ADMIN = 'SUPER_ADMIN', + OPERATOR = 'OPERATOR', + SUPPORT = 'SUPPORT', +} + +export enum UserStatus { + ACTIVE = 'ACTIVE', + BLOCKED = 'BLOCKED', +} + +export enum CampaignStatus { + DRAFT = 'DRAFT', + ACTIVE = 'ACTIVE', + PAUSED = 'PAUSED', + FINISHED = 'FINISHED', +} + +export enum PrizeType { + PHYSICAL = 'PHYSICAL', + DISCOUNT_CODE = 'DISCOUNT_CODE', + CREDIT = 'CREDIT', + CASH = 'CASH', + NO_PRIZE = 'NO_PRIZE', + OTHER = 'OTHER', +} + +export enum StockType { + LIMITED = 'LIMITED', + UNLIMITED = 'UNLIMITED', +} + +export enum PrizeStatus { + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE', +} + +export enum SpinStatus { + SUCCESS = 'SUCCESS', + FAILED = 'FAILED', + DUPLICATE = 'DUPLICATE', +} + +export enum ChanceTxnType { + SIGNUP_BONUS = 'SIGNUP_BONUS', + ADMIN_ADJUSTMENT = 'ADMIN_ADJUSTMENT', + SPIN_CONSUME = 'SPIN_CONSUME', + CAMPAIGN_BONUS = 'CAMPAIGN_BONUS', + REFUND = 'REFUND', +} + +export enum UserPrizeStatus { + NEW = 'NEW', + CONTACTED = 'CONTACTED', + DELIVERED = 'DELIVERED', + ACTIVE_CODE = 'ACTIVE_CODE', + USED = 'USED', + EXPIRED = 'EXPIRED', +} + +export enum SmsType { + OTP = 'OTP', + WINNING = 'WINNING', + NO_PRIZE = 'NO_PRIZE', + PRIZE_REMINDER = 'PRIZE_REMINDER', +} + +export enum SmsStatus { + PENDING = 'PENDING', + SENT = 'SENT', + FAILED = 'FAILED', +} + +export enum WheelDesign { + CLASSIC_CASINO = 'CLASSIC_CASINO', + NEON_CYBER = 'NEON_CYBER', + MINIMAL_FLAT = 'MINIMAL_FLAT', + LUXURY_GOLD = 'LUXURY_GOLD', + PLAYFUL_CARTOON = 'PLAYFUL_CARTOON', +} + +// ========================================== +// Client & Wheel Interfaces +// ========================================== + +export interface WheelSegment { + id: string; + title: string; + image?: string | null; + color: string; + sortOrder: number; +} + +export interface WheelDesignProps { + segments: WheelSegment[]; + targetSegmentIndex: number | null; + spinning: boolean; +} + +export interface PublicCampaignWheelDto { + id: string; + title: string; + slug: string; + description: string | null; + logo: string | null; + banner: string | null; + wheelDesign: WheelDesign; + settings: Record | null; + rules: string | null; + ctaText: string | null; + startAt: string; + endAt: string; + segments: WheelSegment[]; +} + +export interface SpinRequestDto { + idempotencyKey: string; +} + +export interface PrizeResultDto { + id: string; + title: string; + segment: number; + code: string | null; + type: PrizeType; + image: string | null; +} + +export interface SpinResponseDto { + success: boolean; + spinId: string; + prize: PrizeResultDto | null; + remainingChances: number; +} + +export interface UserChancesDto { + totalChances: number; + usedChances: number; + remainingChances: number; +} + +export interface UserPrizeItemDto { + id: string; + prizeTitle: string; + prizeImage: string | null; + prizeType: PrizeType; + code: string | null; + status: UserPrizeStatus; + wonAt: string; + expiresAt: string | null; + campaignTitle: string; +} + +// ========================================== +// Admin & Reporting Interfaces +// ========================================== + +export interface PaginationMeta { + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +export interface PaginatedResponse { + data: T[]; + meta: PaginationMeta; +} + +export interface DashboardStatsDto { + totalUsers: number; + todayUsers: number; + totalSpins: number; + todaySpins: number; + totalWinners: number; + totalDistributedPrizes: number; + consumedChances: number; + activePrizesCount: number; +} + +export interface SpinsByDayDataPoint { + date: string; + spins: number; + winners: number; +} + +export interface PrizeDistributionDataPoint { + prizeTitle: string; + count: number; + percentage: number; +} + +export * from './farsi-number'; diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/shared-types/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..5e49fab --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,8358 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.4.5 + version: 5.9.3 + + apps/admin: + dependencies: + '@ant-design/icons-vue': + specifier: ^7.0.1 + version: 7.0.1(vue@3.5.42(typescript@5.9.3)) + '@lucky-wheel/shared-types': + specifier: workspace:* + version: link:../../packages/shared-types + ant-design-vue: + specifier: ^4.2.1 + version: 4.2.6(vue@3.5.42(typescript@5.9.3)) + axios: + specifier: ^1.7.2 + version: 1.20.0 + dayjs: + specifier: ^1.11.11 + version: 1.11.23 + echarts: + specifier: ^5.5.0 + version: 5.6.0 + pinia: + specifier: ^2.1.7 + version: 2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) + vue: + specifier: ^3.4.27 + version: 3.5.42(typescript@5.9.3) + vue-router: + specifier: ^4.3.2 + version: 4.6.4(vue@3.5.42(typescript@5.9.3)) + devDependencies: + '@types/node': + specifier: ^20.12.12 + version: 20.19.43 + '@vitejs/plugin-vue': + specifier: ^5.0.4 + version: 5.2.4(vite@5.4.21(@types/node@20.19.43)(terser@5.51.2))(vue@3.5.42(typescript@5.9.3)) + typescript: + specifier: ^5.4.5 + version: 5.9.3 + vite: + specifier: ^5.2.11 + version: 5.4.21(@types/node@20.19.43)(terser@5.51.2) + vue-tsc: + specifier: ^2.0.19 + version: 2.2.12(typescript@5.9.3) + + apps/api: + dependencies: + '@lucky-wheel/shared-types': + specifier: workspace:* + version: link:../../packages/shared-types + '@nestjs/bullmq': + specifier: ^10.1.1 + version: 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(bullmq@5.81.4) + '@nestjs/common': + specifier: ^10.3.8 + version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^3.2.2 + version: 3.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.3.8 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^10.2.0 + version: 10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^10.0.3 + version: 10.0.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/platform-express': + specifier: ^10.3.8 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + '@nestjs/schedule': + specifier: ^4.0.2 + version: 4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + '@nestjs/swagger': + specifier: ^7.3.1 + version: 7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': + specifier: ^5.1.2 + version: 5.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2) + '@prisma/client': + specifier: ^5.14.0 + version: 5.22.0(prisma@5.22.0) + bcrypt: + specifier: ^5.1.1 + version: 5.1.1 + bullmq: + specifier: ^5.7.8 + version: 5.81.4 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.4 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 + helmet: + specifier: ^7.1.0 + version: 7.2.0 + ioredis: + specifier: ^5.4.1 + version: 5.11.1 + multer: + specifier: 1.4.5-lts.1 + version: 1.4.5-lts.1 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + passport-local: + specifier: ^1.0.0 + version: 1.0.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@nestjs/cli': + specifier: ^10.3.2 + version: 10.4.9 + '@nestjs/schematics': + specifier: ^10.1.1 + version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^10.3.8 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22) + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/multer': + specifier: ^1.4.11 + version: 1.4.13 + '@types/node': + specifier: ^20.12.12 + version: 20.19.43 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + '@types/passport-local': + specifier: ^1.0.38 + version: 1.0.38 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + prisma: + specifier: ^5.14.0 + version: 5.22.0 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + supertest: + specifier: ^7.0.0 + version: 7.2.2 + ts-jest: + specifier: ^29.1.2 + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3) + ts-loader: + specifier: ^9.5.1 + version: 9.6.2(typescript@5.9.3)(webpack@5.97.1) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.43)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.4.5 + version: 5.9.3 + + apps/client: + dependencies: + '@lucky-wheel/shared-types': + specifier: workspace:* + version: link:../../packages/shared-types + axios: + specifier: ^1.7.2 + version: 1.20.0 + canvas-confetti: + specifier: ^1.9.3 + version: 1.9.4 + lucide-vue-next: + specifier: ^0.378.0 + version: 0.378.0(vue@3.5.42(typescript@5.9.3)) + pinia: + specifier: ^2.1.7 + version: 2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) + vue: + specifier: ^3.4.27 + version: 3.5.42(typescript@5.9.3) + vue-router: + specifier: ^4.3.2 + version: 4.6.4(vue@3.5.42(typescript@5.9.3)) + devDependencies: + '@types/canvas-confetti': + specifier: ^1.9.0 + version: 1.9.0 + '@types/node': + specifier: ^20.12.12 + version: 20.19.43 + '@vitejs/plugin-vue': + specifier: ^5.0.4 + version: 5.2.4(vite@5.4.21(@types/node@20.19.43)(terser@5.51.2))(vue@3.5.42(typescript@5.9.3)) + autoprefixer: + specifier: ^10.4.19 + version: 10.5.5(postcss@8.5.28) + postcss: + specifier: ^8.4.38 + version: 8.5.28 + tailwindcss: + specifier: ^3.4.3 + version: 3.4.19 + typescript: + specifier: ^5.4.5 + version: 5.9.3 + vite: + specifier: ^5.2.11 + version: 5.4.21(@types/node@20.19.43)(terser@5.51.2) + vue-tsc: + specifier: ^2.0.19 + version: 2.2.12(typescript@5.9.3) + + packages/shared-types: + devDependencies: + typescript: + specifier: ^5.4.5 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.3.0': + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} + engines: {node: '>=10'} + + '@angular-devkit/core@17.3.11': + resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^3.5.2 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@17.3.11': + resolution: {integrity: sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@17.3.11': + resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@ant-design/colors@6.0.0': + resolution: {integrity: sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==} + + '@ant-design/icons-svg@4.6.0': + resolution: {integrity: sha512-PRomU725ABMf/lnQp5HiB7my1kjEbFY0D10N4lXYxK6TIB1gKjIVD5MRThpDaezLgw1D774J8eOeeDB0M6wHrQ==} + + '@ant-design/icons-vue@7.0.1': + resolution: {integrity: sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==} + peerDependencies: + vue: '>=3.0.3' + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@ctrl/tinycolor@3.6.1': + resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} + engines: {node: '>=10'} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/unitless@0.8.1': + resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@ljharb/through@2.3.14': + resolution: {integrity: sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==} + engines: {node: '>= 0.4'} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + + '@microsoft/tsdoc@0.15.1': + resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@nestjs/bull-shared@10.2.3': + resolution: {integrity: sha512-XcgAjNOgq6b5DVCytxhR5BKiwWo7hsusVeyE7sfFnlXRHeEtIuC2hYWBr/ZAtvL/RH0/O0tqtq0rVl972nbhJw==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@nestjs/bullmq@10.2.3': + resolution: {integrity: sha512-Lo4W5kWD61/246Y6H70RNgV73ybfRbZyKKS4CBRDaMELpxgt89O+EgYZUB4pdoNrWH16rKcaT0AoVsB/iDztKg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 + + '@nestjs/cli@10.4.9': + resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==} + engines: {node: '>= 16.14'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@10.4.22': + resolution: {integrity: sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==} + peerDependencies: + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@3.3.0': + resolution: {integrity: sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@10.4.22': + resolution: {integrity: sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/microservices': ^10.0.0 + '@nestjs/platform-express': ^10.0.0 + '@nestjs/websockets': ^10.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/jwt@10.2.0': + resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@nestjs/mapped-types@2.0.5': + resolution: {integrity: sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/passport@10.0.3': + resolution: {integrity: sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + passport: ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + + '@nestjs/platform-express@10.4.22': + resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + + '@nestjs/schedule@4.1.2': + resolution: {integrity: sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@nestjs/schematics@10.2.3': + resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} + peerDependencies: + typescript: '>=4.8.2' + + '@nestjs/swagger@7.4.2': + resolution: {integrity: sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==} + peerDependencies: + '@fastify/static': ^6.0.0 || ^7.0.0 + '@nestjs/common': ^9.0.0 || ^10.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/testing@10.4.22': + resolution: {integrity: sha512-HO9aPus3bAedAC+jKVAA8jTdaj4fs5M9fing4giHrcYV2txe9CvC1l1WAjwQ9RDhEHdugjY4y+FZA/U/YqPZrA==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + '@nestjs/microservices': ^10.0.0 + '@nestjs/platform-express': ^10.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nestjs/throttler@5.2.0': + resolution: {integrity: sha512-G/G/MV3xf6sy1DwmnJsgeL+d2tQ/xGRNa9ZhZjm9Kyxp+3+ylGzwJtcnhWlN82PMEp3TiDQpTt+9waOIg/bpPg==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nuxtjs/opencollective@0.3.2': + resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} + + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} + + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + + '@rollup/rollup-android-arm-eabi@4.63.1': + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.1': + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.1': + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.1': + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.1': + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.1': + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.1': + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.1': + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.1': + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.1': + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.1': + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.1': + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.1': + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.1': + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + + '@simonwep/pickr@1.8.2': + resolution: {integrity: sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==} + + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@tokenizer/inflate@0.2.7': + resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@tsconfig/node10@1.0.13': + resolution: {integrity: sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bcrypt@5.0.2': + resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/express-serve-static-core@4.19.9': + resolution: {integrity: sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/jsonwebtoken@9.0.5': + resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} + + '@types/luxon@3.4.2': + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/multer@1.4.13': + resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} + + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-local@1.0.38': + resolution: {integrity: sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.42': + resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} + + '@vue/compiler-dom@3.5.42': + resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} + + '@vue/compiler-sfc@3.5.42': + resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} + + '@vue/compiler-ssr@3.5.42': + resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.42': + resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} + + '@vue/runtime-core@3.5.42': + resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} + + '@vue/runtime-dom@3.5.42': + resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} + + '@vue/server-renderer@3.5.42': + resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} + + '@vue/shared@3.5.42': + resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ant-design-vue@4.2.6: + resolution: {integrity: sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==} + engines: {node: '>=12.22.0'} + peerDependencies: + vue: '>=3.2.0' + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + array-tree-filter@2.1.0: + resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.5.5: + resolution: {integrity: sha512-uiRYvQYe/nNSzBJ7OUnd2/TZVsAdob3blml44teEpee9Cc1f4rGZFewO+JT3Wo8mgFOSzNqes4FHZn/Qz8WOuw==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bcrypt@5.1.1: + resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} + engines: {node: '>= 10.0.0'} + + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + + bullmq@5.81.4: + resolution: {integrity: sha512-n+WHSzz20KooBGoJyASre6oJNz/p5f1IJRRN2ibD+NWPQaNpNQmDrwYuPO+bsXrQeM1MQzUxXGbdjmyOFKP2xQ==} + engines: {node: '>=12.22.0'} + peerDependencies: + redis: '>=5.0.0' + peerDependenciesMeta: + redis: + optional: true + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + canvas-confetti@1.9.4: + resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} + + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.4: + resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + comment-json@4.2.5: + resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} + engines: {node: '>= 6'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + + compute-scroll-into-view@1.0.20: + resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + consola@2.15.3: + resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + core-js@3.50.0: + resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + deprecated: v4 is no longer maintained, upgrade to v5 + + cron@3.2.1: + resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-align@1.12.4: + resolution: {integrity: sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==} + + dom-scroll-into-view@2.0.1: + resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==} + + dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.424: + resolution: {integrity: sha512-rTOe76LHREmnLOWc0NlakB7AlnWjhTNflk4YE3gnIBPbSXLiY+VDEgwYl4wAYdBPD7eq9NQq6TR8lvd9Fls2CA==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.7: + resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-type@20.4.1: + resolution: {integrity: sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==} + engines: {node: '>=18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.0.2: + resolution: {integrity: sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg==} + engines: {node: '>=12.13.0', yarn: '>=1.0.0'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-own-prop@2.0.0: + resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + helmet@7.2.0: + resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} + engines: {node: '>=16.0.0'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inquirer@8.2.6: + resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} + engines: {node: '>=12.0.0'} + + inquirer@9.2.15: + resolution: {integrity: sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==} + engines: {node: '>=18'} + + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-object@3.0.1: + resolution: {integrity: sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==} + engines: {node: '>=0.10.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.2.1: + resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@3.2.3: + resolution: {integrity: sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + libphonenumber-js@1.13.12: + resolution: {integrity: sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-vue-next@0.378.0: + resolution: {integrity: sha512-tz2IUhdOf1q0x1mPOTZEJZYfXVLreQorO2ax4M+CxGOTgCNgXH3cljIWWfJ4jUvxn5rbkFlGPbl9EIfIelZBRA==} + deprecated: Package deprecated. Please use @lucide/vue instead. + peerDependencies: + vue: '>=3.0.1' + + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@0.30.8: + resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} + engines: {node: '>=12'} + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + multer@1.4.5-lts.1: + resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==} + engines: {node: '>= 6.0.0'} + deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version. + + multer@2.0.2: + resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} + engines: {node: '>= 10.16.0'} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + mute-stream@1.0.0: + resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanopop@2.4.2: + resolution: {integrity: sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-local@1.0.0: + resolution: {integrity: sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==} + engines: {node: '>= 0.4.0'} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.1: + resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==} + engines: {node: '>=12'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} + engines: {node: '>=16.13'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + read-cache@1.0.2: + resolution: {integrity: sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + run-async@3.0.0: + resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + scroll-into-view-if-needed@2.2.31: + resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + swagger-ui-dist@5.17.14: + resolution: {integrity: sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==} + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + ts-jest@29.4.12: + resolution: {integrity: sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <7' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + ts-loader@9.6.2: + resolution: {integrity: sha512-R4iuczmtgxvtuI556s+hTZ6/7Ee03VCAk/l/M8LY1OAsUgB7YydsCxkgq9D9pKRaD7GJqUi2u8fp9zZP/ufjKA==} + engines: {node: '>=12.0.0'} + peerDependencies: + loader-utils: '*' + typescript: '*' + webpack: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + loader-utils: + optional: true + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.7.2: + resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@11.0.3: + resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue-types@3.0.2: + resolution: {integrity: sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==} + engines: {node: '>=10.15.0'} + peerDependencies: + vue: ^3.0.0 + + vue@3.5.42: + resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + warning@4.0.3: + resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} + + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.5.1: + resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} + engines: {node: '>=10.13.0'} + + webpack@5.97.1: + resolution: {integrity: sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + +snapshots: + + '@alloc/quick-lru@5.3.0': {} + + '@angular-devkit/core@17.3.11(chokidar@3.6.0)': + dependencies: + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + jsonc-parser: 3.2.1 + picomatch: 4.0.1 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 3.6.0 + + '@angular-devkit/schematics-cli@17.3.11(chokidar@3.6.0)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + ansi-colors: 4.1.3 + inquirer: 9.2.15 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@17.3.11(chokidar@3.6.0)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + jsonc-parser: 3.2.1 + magic-string: 0.30.8 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@ant-design/colors@6.0.0': + dependencies: + '@ctrl/tinycolor': 3.6.1 + + '@ant-design/icons-svg@4.6.0': {} + + '@ant-design/icons-vue@7.0.1(vue@3.5.42(typescript@5.9.3))': + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons-svg': 4.6.0 + vue: 3.5.42(typescript@5.9.3) + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.9 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@borewit/text-codec@0.2.2': {} + + '@colors/colors@1.5.0': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@ctrl/tinycolor@3.6.1': {} + + '@emotion/hash@0.9.2': {} + + '@emotion/unitless@0.8.1': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + + '@ioredis/commands@1.10.0': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.15.0 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.19.43 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 20.19.43 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.43 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@ljharb/through@2.3.14': + dependencies: + call-bind: 1.0.9 + + '@lukeed/csprng@1.1.0': {} + + '@mapbox/node-pre-gyp@1.0.11': + dependencies: + detect-libc: 2.1.2 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.8.5 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@microsoft/tsdoc@0.15.1': {} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + + '@nestjs/bullmq@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(bullmq@5.81.4)': + dependencies: + '@nestjs/bull-shared': 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + bullmq: 5.81.4 + tslib: 2.8.1 + + '@nestjs/cli@10.4.9': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics-cli': 17.3.11(chokidar@3.6.0) + '@nestjs/schematics': 10.2.3(chokidar@3.6.0)(typescript@5.7.2) + chalk: 4.1.2 + chokidar: 3.6.0 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1) + glob: 10.4.5 + inquirer: 8.2.6 + node-emoji: 1.11.0 + ora: 5.4.1 + tree-kill: 1.2.2 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.7.2 + webpack: 5.97.1 + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + - webpack-cli + + '@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 20.4.1 + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@3.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 16.4.5 + dotenv-expand: 10.0.0 + lodash: 4.17.21 + rxjs: 7.8.2 + + '@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxtjs/opencollective': 0.3.2 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + transitivePeerDependencies: + - encoding + + '@nestjs/jwt@10.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.5 + jsonwebtoken: 9.0.2 + + '@nestjs/mapped-types@2.0.5(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + + '@nestjs/passport@10.0.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + body-parser: 1.20.4 + cors: 2.8.5 + express: 4.22.1 + multer: 2.0.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/schedule@4.1.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 3.2.1 + uuid: 11.0.3 + + '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + comment-json: 4.2.5 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.7.2 + transitivePeerDependencies: + - chokidar + + '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + comment-json: 4.2.5 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/swagger@7.4.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.15.1 + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.0.5(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + js-yaml: 4.1.0 + lodash: 4.17.21 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.17.14 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + + '@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + + '@nestjs/throttler@5.2.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + + '@nuxtjs/opencollective@0.3.2': + dependencies: + chalk: 4.1.2 + consola: 2.15.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@prisma/client@5.22.0(prisma@5.22.0)': + optionalDependencies: + prisma: 5.22.0 + + '@prisma/debug@5.22.0': {} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} + + '@prisma/engines@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 + + '@prisma/fetch-engine@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 + + '@prisma/get-platform@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + + '@rollup/rollup-android-arm-eabi@4.63.1': + optional: true + + '@rollup/rollup-android-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.1': + optional: true + + '@rollup/rollup-darwin-x64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.1': + optional: true + + '@simonwep/pickr@1.8.2': + dependencies: + core-js: 3.50.0 + nanopop: 2.4.2 + + '@sinclair/typebox@0.27.12': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@tokenizer/inflate@0.2.7': + dependencies: + debug: 4.4.3 + fflate: 0.8.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@tsconfig/node10@1.0.13': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/bcrypt@5.0.2': + dependencies: + '@types/node': 20.19.43 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.19.43 + + '@types/canvas-confetti@1.9.0': {} + + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.19.43 + + '@types/cookiejar@2.1.5': {} + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.9 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + + '@types/estree@1.0.9': {} + + '@types/express-serve-static-core@4.19.9': + dependencies: + '@types/node': 20.19.43 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.9 + '@types/qs': 6.15.1 + '@types/serve-static': 1.15.10 + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 20.19.43 + + '@types/http-errors@2.0.5': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.19.43 + + '@types/jsonwebtoken@9.0.5': + dependencies: + '@types/node': 20.19.43 + + '@types/luxon@3.4.2': {} + + '@types/methods@1.1.4': {} + + '@types/mime@1.3.5': {} + + '@types/ms@2.1.0': {} + + '@types/multer@1.4.13': + dependencies: + '@types/express': 4.17.25 + + '@types/node@14.18.63': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-local@1.0.38': + dependencies: + '@types/express': 4.17.25 + '@types/passport': 1.0.17 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 4.17.25 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 4.17.25 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.19.43 + + '@types/send@1.2.1': + dependencies: + '@types/node': 20.19.43 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 20.19.43 + '@types/send': 0.17.6 + + '@types/stack-utils@2.0.3': {} + + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 20.19.43 + form-data: 4.0.6 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@types/validator@13.15.10': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.43)(terser@5.51.2))(vue@3.5.42(typescript@5.9.3))': + dependencies: + vite: 5.4.21(@types/node@20.19.43)(terser@5.51.2) + vue: 3.5.42(typescript@5.9.3) + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + + '@vue/compiler-core@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.42 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.42': + dependencies: + '@vue/compiler-core': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/compiler-sfc@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.42 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-ssr': 3.5.42 + '@vue/shared': 3.5.42 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.42': + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.42 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.42': + dependencies: + '@vue/shared': 3.5.42 + + '@vue/runtime-core@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/runtime-dom@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/runtime-core': 3.5.42 + '@vue/shared': 3.5.42 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.42': + dependencies: + '@vue/compiler-ssr': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/shared@3.5.42': {} + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + abbrev@1.1.1: {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv-formats@2.1.1(ajv@8.12.0): + optionalDependencies: + ajv: 8.12.0 + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-keywords@3.5.2(ajv@6.15.0): + dependencies: + ajv: 6.15.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.7 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@1.0.13: {} + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + ant-design-vue@4.2.6(vue@3.5.42(typescript@5.9.3)): + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons-vue': 7.0.1(vue@3.5.42(typescript@5.9.3)) + '@babel/runtime': 7.29.7 + '@ctrl/tinycolor': 3.6.1 + '@emotion/hash': 0.9.2 + '@emotion/unitless': 0.8.1 + '@simonwep/pickr': 1.8.2 + array-tree-filter: 2.1.0 + async-validator: 4.2.5 + csstype: 3.2.3 + dayjs: 1.11.23 + dom-align: 1.12.4 + dom-scroll-into-view: 2.0.1 + lodash: 4.18.1 + lodash-es: 4.18.1 + resize-observer-polyfill: 1.5.1 + scroll-into-view-if-needed: 2.2.31 + shallow-equal: 1.2.1 + stylis: 4.4.0 + throttle-debounce: 5.0.2 + vue: 3.5.42(typescript@5.9.3) + vue-types: 3.0.2(vue@3.5.42(typescript@5.9.3)) + warning: 4.0.3 + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-field@1.0.0: {} + + aproba@2.1.0: {} + + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + + arg@4.1.3: {} + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-flatten@1.1.1: {} + + array-timsort@1.0.3: {} + + array-tree-filter@2.1.0: {} + + asap@2.0.6: {} + + async-validator@4.2.5: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + autoprefixer@10.5.5(postcss@8.5.28): + dependencies: + browserslist: 4.28.9 + caniuse-lite: 1.0.30001810 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.28 + postcss-value-parser: 4.2.0 + + axios@1.20.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + babel-jest@29.7.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + + babel-preset-jest@29.6.3(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.21: {} + + bcrypt@5.1.1: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + node-addon-api: 5.1.0 + transitivePeerDependencies: + - encoding + - supports-color + + big-integer@1.6.52: {} + + binary-extensions@2.3.0: {} + + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird@3.4.7: {} + + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.9: + dependencies: + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.424 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-crc32@0.2.13: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer-indexof-polyfill@1.0.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffers@0.1.1: {} + + bullmq@5.81.4: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.11.1 + msgpackr: 2.0.5 + node-abort-controller: 3.1.1 + semver: 7.8.5 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001810: {} + + canvas-confetti@1.9.4: {} + + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + + chardet@0.7.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chownr@2.0.0: {} + + chrome-trace-event@1.0.4: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + class-transformer@0.5.1: {} + + class-validator@0.14.4: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.12 + validator: 13.15.35 + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-width@3.0.0: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + cluster-key-slot@1.1.1: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-support@1.1.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@2.20.3: {} + + commander@4.1.1: {} + + comment-json@4.2.5: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + has-own-prop: 2.0.0 + repeat-string: 1.6.1 + + component-emitter@1.3.1: {} + + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + compute-scroll-into-view@1.0.20: {} + + concat-map@0.0.1: {} + + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + consola@2.15.3: {} + + console-control-strings@1.1.0: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.7: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + core-js@3.50.0: {} + + core-util-is@1.0.3: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@8.3.6(typescript@5.7.2): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.7.2 + + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + + create-jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + create-require@1.1.1: {} + + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + + cron@3.2.1: + dependencies: + '@types/luxon': 3.4.2 + luxon: 3.5.0 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + dayjs@1.11.23: {} + + de-indent@1.0.2: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.2: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + delayed-stream@1.0.0: {} + + delegates@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-newline@3.1.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + didyoumean@1.2.2: {} + + diff-sequences@29.6.3: {} + + diff@4.0.4: {} + + dlv@1.1.3: {} + + dom-align@1.12.4: {} + + dom-scroll-into-view@2.0.1: {} + + dotenv-expand@10.0.0: {} + + dotenv@16.4.5: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.424: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@7.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + esprima@4.0.1: {} + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + etag@1.8.1: {} + + events@3.3.0: {} + + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.23 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.7 + unzipper: 0.10.14 + uuid: 8.3.2 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.7: {} + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fflate@0.8.3: {} + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-type@20.4.1: + dependencies: + '@tokenizer/inflate': 0.2.7 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + follow-redirects@1.16.0: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1): + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + chokidar: 3.6.0 + cosmiconfig: 8.3.6(typescript@5.7.2) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.5 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.8.5 + tapable: 2.3.3 + typescript: 5.7.2 + webpack: 5.97.1 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fraction.js@5.3.4: {} + + fresh@0.5.2: {} + + fs-constants@1.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs-monkey@1.1.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + function-bind@1.1.2: {} + + gauge@3.0.2: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@4.0.0: {} + + has-own-prop@2.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + has-unicode@2.0.1: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + helmet@7.2.0: {} + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + immediate@3.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inquirer@8.2.6: + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.18.1 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + + inquirer@9.2.15: + dependencies: + '@ljharb/through': 2.3.14 + ansi-escapes: 4.3.2 + chalk: 5.6.2 + cli-cursor: 3.1.0 + cli-width: 4.1.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.18.1 + mute-stream: 1.0.0 + ora: 5.4.1 + run-async: 3.0.0 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-number@7.0.0: {} + + is-plain-object@3.0.1: {} + + is-stream@2.0.1: {} + + is-unicode-supported@0.1.0: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.8 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.8 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.43 + ts-node: 10.9.2(@types/node@20.19.43)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 20.19.43 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.12 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.43 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@27.5.1: + dependencies: + '@types/node': 20.19.43 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@29.7.0: + dependencies: + '@types/node': 20.19.43 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + jsonc-parser@3.2.1: {} + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.3 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.3: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + kleur@3.0.3: {} + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + leven@3.1.0: {} + + libphonenumber-js@1.13.12: {} + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + listenercount@1.0.1: {} + + loader-runner@4.3.2: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash-es@4.18.1: {} + + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.groupby@4.6.0: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnil@4.0.0: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.isundefined@3.0.1: {} + + lodash.memoize@4.1.2: {} + + lodash.once@4.1.1: {} + + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + + lodash@4.17.21: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-vue-next@0.378.0(vue@3.5.42(typescript@5.9.3)): + dependencies: + vue: 3.5.42(typescript@5.9.3) + + luxon@3.5.0: {} + + luxon@3.7.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@0.30.8: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + memfs@3.5.3: + dependencies: + fs-monkey: 1.1.0 + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mime@2.6.0: {} + + mimic-fn@2.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minimist@1.2.8: {} + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.3: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mkdirp@1.0.4: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + + muggle-string@0.4.1: {} + + multer@1.4.5-lts.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + multer@2.0.2: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + mute-stream@0.0.8: {} + + mute-stream@1.0.0: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.18: {} + + nanopop@2.4.2: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + neo-async@2.6.2: {} + + node-abort-controller@3.1.1: {} + + node-addon-api@5.1.0: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.18.1 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + node-int64@0.4.0: {} + + node-releases@2.0.54: {} + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + os-tmpdir@1.0.2: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + pako@1.0.11: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parseurl@1.3.3: {} + + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-local@1.0.0: + dependencies: + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-to-regexp@0.1.13: {} + + path-to-regexp@3.3.0: {} + + path-type@4.0.0: {} + + pause@0.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.1: {} + + picomatch@4.0.7: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.42(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.42(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pluralize@8.0.0: {} + + postcss-import@15.1.0(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + postcss-value-parser: 4.2.0 + read-cache: 1.0.2 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.28): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.28 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.28): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.28 + + postcss-nested@6.2.0(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + prisma@5.22.0: + dependencies: + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + + process-nextick-args@2.0.1: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qs@6.14.2: + dependencies: + side-channel: 1.1.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + queue-microtask@1.2.3: {} + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-is@18.3.1: {} + + read-cache@1.0.2: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + reflect-metadata@0.2.2: {} + + repeat-string@1.6.1: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resize-observer-polyfill@1.5.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + reusify@1.1.0: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.63.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + + run-async@2.4.1: {} + + run-async@3.0.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + scroll-into-view-if-needed@2.2.31: + dependencies: + compute-scroll-into-view: 1.0.20 + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + shallow-equal@1.2.1: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + source-map@0.7.6: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + standard-as-callback@2.1.0: {} + + statuses@2.0.2: {} + + streamsearch@1.1.0: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + stylis@4.4.0: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.16.0 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + swagger-ui-dist@5.17.14: {} + + symbol-observable@4.0.0: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.3.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.28 + postcss-import: 15.1.0(postcss@8.5.28) + postcss-js: 4.1.0(postcss@8.5.28) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.28) + postcss-nested: 6.2.0(postcss@8.5.28) + postcss-selector-parser: 6.1.4 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + tapable@2.3.3: {} + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + terser-webpack-plugin@5.6.1(webpack@5.97.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.51.2 + webpack: 5.97.1 + + terser@5.51.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + throttle-debounce@5.0.2: {} + + through@2.3.8: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + tmp@0.2.7: {} + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tr46@0.0.3: {} + + traverse@0.3.9: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 29.7.0(@types/node@20.19.43)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.5 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-util: 29.7.0 + + ts-loader@9.6.2(typescript@5.9.3)(webpack@5.97.1): + dependencies: + chalk: 4.1.2 + picomatch: 4.0.7 + source-map: 0.7.6 + typescript: 5.9.3 + webpack: 5.97.1 + + ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.13 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.19.43 + acorn: 8.18.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.24.5 + tapable: 2.3.3 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.3.0: {} + + tslib@2.8.1: {} + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray@0.0.6: {} + + typescript@5.7.2: {} + + typescript@5.9.3: {} + + uglify-js@3.19.3: + optional: true + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + undici-types@6.21.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + update-browserslist-db@1.3.2(browserslist@4.28.9): + dependencies: + browserslist: 4.28.9 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@11.0.3: {} + + uuid@8.3.2: {} + + v8-compile-cache-lib@3.0.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validator@13.15.35: {} + + vary@1.1.2: {} + + vite@5.4.21(@types/node@20.19.43)(terser@5.51.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.28 + rollup: 4.63.1 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + terser: 5.51.2 + + vscode-uri@3.2.0: {} + + vue-demi@0.14.10(vue@3.5.42(typescript@5.9.3)): + dependencies: + vue: 3.5.42(typescript@5.9.3) + + vue-router@4.6.4(vue@3.5.42(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.42(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue-types@3.0.2(vue@3.5.42(typescript@5.9.3)): + dependencies: + is-plain-object: 3.0.1 + vue: 3.5.42(typescript@5.9.3) + + vue@3.5.42(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-sfc': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/server-renderer': 3.5.42 + '@vue/shared': 3.5.42 + optionalDependencies: + typescript: 5.9.3 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 + + watchpack@2.5.2: + dependencies: + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webidl-conversions@3.0.1: {} + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.5.1: {} + + webpack@5.97.1: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.18.0 + browserslist: 4.28.9 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.5 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.2 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(webpack@5.97.1) + watchpack: 2.5.2 + webpack-sources: 3.5.1 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a4a3c8f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,19 @@ +packages: + - 'apps/*' + - 'packages/*' + +allowBuilds: + core-js: set this to true or false + esbuild: set this to true or false + vue-demi: set this to true or false + +onlyBuiltDependencies: + - '@nestjs/core' + - '@prisma/client' + - '@prisma/engines' + - 'bcrypt' + - 'msgpackr-extract' + - 'prisma' + - 'core-js' + - 'esbuild' + - 'vue-demi' diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..2fdb8fd --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictPropertyInitialization": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "sourceMap": true + } +}