Compare commits
24 Commits
d55d61ba81
...
b01da2e04e
| Author | SHA1 | Date | |
|---|---|---|---|
| b01da2e04e | |||
| cba4bc30da | |||
| 75a0ba593d | |||
| 35f5084005 | |||
| dc11190527 | |||
| 19ced3bb6d | |||
| 8f624e7416 | |||
| 9f1eccd2f7 | |||
| acae976622 | |||
| 0118ecc63a | |||
| 1e5ef02bdf | |||
| 4375bb12a4 | |||
| 2c6d4bfbd2 | |||
| c8e6f80c51 | |||
| 35830189ea | |||
| 1dc96e86fc | |||
| 3480eac2b0 | |||
| 769e76313f | |||
| 7cf6c78c77 | |||
| 15583009e0 | |||
| 665838bbe7 | |||
| fda1b130ff | |||
| 36faf4706a | |||
| 2823fb45f5 |
@@ -0,0 +1,13 @@
|
|||||||
|
# Postgres
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_NAME=dating_app
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# پورتهایی که روی هاست منتشر میشن؛ نگینکس خودت رو به این پورتها proxy_pass کن
|
||||||
|
APP_PORT=8080 # -> api.brainito.ir
|
||||||
|
WEB_PORT=3000 # -> brainito.ir
|
||||||
|
ADMIN_PORT=3001 # -> admin.brainito.ir
|
||||||
|
|
||||||
|
# آدرس API که موقع build توی باندل کلاینت web/admin جاسازی میشه (باید از مرورگر کاربر قابلدسترس باشه)
|
||||||
|
PUBLIC_API_URL=https://api.brainito.ir/api/v1
|
||||||
+32
-35
@@ -1,45 +1,42 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# --- محیط/سکرتها ---
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
|
||||||
# dependencies
|
# --- Go (backend) ---
|
||||||
/node_modules
|
/backend/server
|
||||||
/.pnp
|
/backend/*.exe
|
||||||
.pnp.*
|
/backend/*.test
|
||||||
.yarn/*
|
/backend/*.out
|
||||||
!.yarn/patches
|
/backend/uploads/
|
||||||
!.yarn/plugins
|
/backend/backups/
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
# --- Node (web/admin) ---
|
||||||
/coverage
|
node_modules/
|
||||||
|
.next/
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
/out/
|
||||||
|
*.tsbuildinfo
|
||||||
# production
|
next-env.d.ts
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
|
||||||
.env*
|
|
||||||
!.env*.example
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# فایلهای آپلودشدهی بکاند (اگه به اشتباه اینجا کنار بکاند اجرا بشه)
|
# --- آرشیوهای اضافی که قرار نبود توی گیت باشن ---
|
||||||
/uploads/
|
*.zip
|
||||||
|
|
||||||
# typescript
|
# --- Android/TWA: build و کلید امضا (نباید توی گیت باشه) ---
|
||||||
*.tsbuildinfo
|
/twa/app/build/
|
||||||
next-env.d.ts
|
/twa/build/
|
||||||
|
/twa/.gradle/
|
||||||
|
/twa/local.properties
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
/twa/android.keystore
|
||||||
|
|
||||||
|
# --- IDE / OS ---
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -1,103 +1,31 @@
|
|||||||
# دلدل — فرانت PWA اپ دیتینگ (Next.js + Basis Design System)
|
# دلدل (dating-app)
|
||||||
|
|
||||||
فرانت موبایلمحور و PWA برای بکاند Go/Gin/GORM که قبلاً ساختیم، با همون
|
پوشهی مادر پروژه؛ همهی بخشها بهصورت ریپوی گیت جدا زیر همین پوشهان:
|
||||||
Basis Design System (نسخهی شاد و گرد Atlas): قرمز پرانرژی، گوشههای گرد،
|
|
||||||
سایههای نرم، فونت Vazirmatn + Baloo 2.
|
|
||||||
|
|
||||||
## ویژگیها
|
```
|
||||||
|
dating-app/
|
||||||
|
├── backend/ بکاند Go (ریپوی گیت جدا)
|
||||||
|
├── admin/ پنل ادمین Next.js (ریپوی گیت جدا)
|
||||||
|
├── web/ وباپ Next.js (ریپوی گیت جدا)
|
||||||
|
├── twa/ پروژهی اندروید TWA (بدون گیت)
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
- ورود با شماره موبایل + کد OTP (بدون پسورد)
|
## اجرا روی سرور
|
||||||
- ۸ مرحلهی آنبوردینگ دقیقاً مطابق API بکاند (با state machine که جلوی پرش
|
|
||||||
از مرحله رو میگیره، ولی اجازهی برگشت و ویرایش میده)
|
|
||||||
- صفحهی پروفایل کامل با دکمهی ویرایش روی هر بخش (برمیگرده به همون مرحلهی
|
|
||||||
آنبوردینگ، چون بکاند بعد از تکمیل کامل اجازهی ویرایش هر مرحله رو میده)
|
|
||||||
- شبیهسازی یک اپ سهتبه (کشف/پروفایل/تنظیمات) — تب «کشف» چون بکاند فعلاً
|
|
||||||
matching نداره، حالت «بهزودی» نشون میده
|
|
||||||
- PWA کامل: قابل نصب روی گوشی (`manifest.webmanifest` + service worker +
|
|
||||||
آیکون)، تمام صفحات موبایلفرست و محدود به عرض یک گوشی
|
|
||||||
|
|
||||||
## ✅ تست شده
|
|
||||||
|
|
||||||
قبل از تحویل، کل مسیر کاربر (درخواست OTP → تایید → ۸ مرحلهی آنبوردینگ →
|
|
||||||
پروفایل نهایی → رفرش توکن) با یک اسکریپت Node که دقیقاً همون درخواستهای
|
|
||||||
`src/lib/api.ts` رو میفرسته، روی یک نمونهی واقعی از بکاند گو اجرا و با
|
|
||||||
موفقیت پاس شد — شامل تستهای منفی (OTP اشتباه، انتخاب ناقص علاقهمندی).
|
|
||||||
یعنی قرارداد بین این فرانت و بکاندی که قبلاً تحویل گرفتی، تضمینشده همخونه.
|
|
||||||
|
|
||||||
## راهاندازی
|
|
||||||
|
|
||||||
### ۱) تنظیم آدرس بکاند
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.local.example .env.local
|
cp .env.example .env # مقادیر رو با دامنه/رمزهای واقعی پر کن
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
و `NEXT_PUBLIC_API_URL` رو به آدرس واقعی بکاندت تغییر بده (پیشفرض
|
این کار ۴ کانتینر بالا میآره: `postgres`, `backend` (پورت `APP_PORT`), `web` (پورت `WEB_PORT`), `admin` (پورت `ADMIN_PORT`).
|
||||||
`http://localhost:8080/api/v1`).
|
کامپوز هیچ وبسروری بالا نمیآره؛ ریورسپروکسی و سابدامینها با nginx خودت روی هاست ست میشن، مثلاً:
|
||||||
|
|
||||||
### ۲) نصب و اجرا
|
| سابدامین | proxy_pass به |
|
||||||
|
|---|---|
|
||||||
|
| `brainito.ir` | `127.0.0.1:${WEB_PORT}` |
|
||||||
|
| `admin.brainito.ir` | `127.0.0.1:${ADMIN_PORT}` |
|
||||||
|
| `api.brainito.ir` | `127.0.0.1:${APP_PORT}` |
|
||||||
|
|
||||||
```bash
|
سکرتهای اختصاصی بکاند (JWT، DB و...) توی `backend/.env` میمونن و از اونجا با `env_file` به سرویس `backend` پاس داده میشن؛ `.env` ریشه فقط برای متغیرهای مشترک کامپوز (پورتها، اطلاعات پستگرس، آدرس عمومی API) استفاده میشه.
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
`http://localhost:3000` رو باز کن. برای تست موبایل واقعی، از ابزار
|
|
||||||
device-toolbar مرورگر استفاده کن یا با آیپی لوکال شبکه از گوشی وصل شو.
|
|
||||||
|
|
||||||
### ۳) نصب بهعنوان PWA
|
|
||||||
|
|
||||||
- **اندروید/کروم:** منوی مرورگر → «Add to Home screen»
|
|
||||||
- **iOS/Safari:** دکمهی Share → «Add to Home Screen»
|
|
||||||
|
|
||||||
بعد از نصب، اپ بدون نوار آدرس مرورگر و با آیکون اختصاصی روی صفحهی اصلی گوشی
|
|
||||||
باز میشه.
|
|
||||||
|
|
||||||
## ساختار پروژه
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── app/
|
|
||||||
│ ├── layout.tsx ← فونت، متادیتای PWA، AuthProvider
|
|
||||||
│ ├── page.tsx ← ریدایرکت اولیه بر اساس وضعیت کاربر
|
|
||||||
│ ├── login/page.tsx ← ورود با شماره موبایل
|
|
||||||
│ ├── verify/page.tsx ← تایید کد OTP
|
|
||||||
│ ├── onboarding/
|
|
||||||
│ │ ├── [slug]/page.tsx ← صفحهی داینامیک هر ۸ مرحله
|
|
||||||
│ │ └── done/page.tsx ← صفحهی تبریک پایان آنبوردینگ
|
|
||||||
│ └── (main)/ ← اپ اصلی بعد از آنبوردینگ (با BottomNav)
|
|
||||||
│ ├── layout.tsx
|
|
||||||
│ ├── home/page.tsx ← کشف (placeholder "بهزودی")
|
|
||||||
│ ├── profile/page.tsx ← پروفایل کامل + دکمهی ویرایش هر بخش
|
|
||||||
│ └── settings/page.tsx ← خروج از حساب + آیتمهای "بهزودی"
|
|
||||||
├── components/
|
|
||||||
│ ├── ui/ ← کامپوننتهای پایهی دیزاینسیستم
|
|
||||||
│ ├── onboarding/ ← wrapper و ۸ کامپوننت هر مرحله
|
|
||||||
│ └── BottomNav.tsx
|
|
||||||
└── lib/
|
|
||||||
├── api.ts ← کلاینت تایپشدهی کامل API (منطبق با بکاند گو)
|
|
||||||
├── auth-context.tsx ← مدیریت وضعیت ورود/کاربر/آنبوردینگ
|
|
||||||
├── token-store.ts ← ذخیرهی توکنها در localStorage
|
|
||||||
├── onboarding-steps.ts ← ترتیب و متادیتای ۸ مرحله
|
|
||||||
└── hooks.ts ← useRequireAuth، فرمت خطا
|
|
||||||
```
|
|
||||||
|
|
||||||
## نکات فنی مهم
|
|
||||||
|
|
||||||
- **رفرش خودکار توکن:** اگه access token منقضی بشه، `api.ts` خودکار با
|
|
||||||
refresh token یه توکن جدید میگیره و درخواست رو یکبار دوباره میفرسته؛
|
|
||||||
کاربر چیزی حس نمیکنه.
|
|
||||||
- **گارد مراحل آنبوردینگ:** هر صفحهی مرحله چک میکنه که کاربر اجازهی
|
|
||||||
دیدن اون مرحله رو داره یا نه (بر اساس `current_step` از بکاند)؛ اگه
|
|
||||||
بخواد از روی مرحله بپره، خودکار به مرحلهی درست برمیگرده.
|
|
||||||
- **فونت self-hosted:** مثل دیزاینسیستم قبلی، فونتها از `@fontsource-variable`
|
|
||||||
میان (نه `next/font/google`) تا هیچوقت به گوگل وصل نشه.
|
|
||||||
- **CORS:** بکاند گو از قبل CORS باز داره، پس این فرانت روی هر پورت/دامنهای
|
|
||||||
بدون تنظیم اضافه بهش وصل میشه.
|
|
||||||
|
|
||||||
## محدودیت شناختهشده
|
|
||||||
|
|
||||||
تب «کشف» (matching/کشف افراد) و بخش چت هنوز در بکاند پیاده نشدن، پس فقط
|
|
||||||
یک EmptyState با برچسب «بهزودی» نشون داده میشه. هر وقت اون endpoint ها
|
|
||||||
رو ساختی، فقط کافیه `src/app/(main)/home/page.tsx` رو با فراخوانی API واقعی
|
|
||||||
جایگزین کنی.
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dating-admin",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 3001
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env*.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# فایلهای آپلودشدهی بکاند (اگه به اشتباه اینجا کنار بکاند اجرا بشه)
|
||||||
|
/uploads/
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
*.zip
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# پنل ادمین دلدل
|
||||||
|
|
||||||
|
پنل مدیریت دسکتاپمحور (Next.js + Basis Design System) برای همون بکاند
|
||||||
|
Go/Gin/GORM اپ دیتینگ. با این پنل میتونی بدون لمس کد، تمام لیستهای
|
||||||
|
انتخابی و سوالات پروفایل اپ رو مدیریت کنی.
|
||||||
|
|
||||||
|
## ویژگیها
|
||||||
|
|
||||||
|
- **ورود امن:** همون فلوی OTP اپ اصلی، ولی فقط کاربرهایی که `is_admin=true`
|
||||||
|
دارن اجازهی ورود میگیرن (پیام «دسترسی نداری» برای بقیه).
|
||||||
|
- **مدیریت ۶ دستهی انتخابی:** سطح تحصیلات، رشته تحصیلی، فعالیتها، اهمیت در
|
||||||
|
رابطه، علاقهمندیها، دنبال چی میگردی — برای هر کدوم:
|
||||||
|
- ویرایش عنوان و حداقل/حداکثر تعداد انتخاب مجاز
|
||||||
|
- افزودن/ویرایش/حذف گزینه
|
||||||
|
- فعال/غیرفعال کردن آنی با یک کلیک (گزینهی غیرفعال فوراً از دید کاربر مخفی میشه)
|
||||||
|
- **مدیریت سوالات پروفایل:** افزودن/ویرایش/حذف/فعالسازی سوالاتی که کاربر
|
||||||
|
توی مرحلهی سوم آنبوردینگ ۳ تاشو انتخاب میکنه.
|
||||||
|
- فیدبک فوری با Toast بعد از هر عملیات، تایید قبل از حذف.
|
||||||
|
|
||||||
|
## ✅ تست شده
|
||||||
|
|
||||||
|
قبل از تحویل، یک اسکریپت Node که دقیقاً همون درخواستهای `src/lib/api.ts`
|
||||||
|
رو میفرسته، روی یک نمونهی واقعی از بکاند اجرا شد: ورود ادمین، GET/PATCH
|
||||||
|
دسته، CRUD کامل گزینه، toggle فعال/غیرفعال (با تایید اینکه گزینهی
|
||||||
|
غیرفعال از API عمومی کاربر واقعاً حذف میشه)، CRUD کامل سوالات، و مهمتر
|
||||||
|
از همه — تست کردیم که یک کاربر عادی (غیرادمین) با کد ۴۰۳ از تمام
|
||||||
|
`/admin/*` endpoint ها بلاک میشه. همهی ۱۴ چک پاس شد.
|
||||||
|
|
||||||
|
## راهاندازی
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.local.example .env.local
|
||||||
|
# NEXT_PUBLIC_API_URL رو به آدرس بکاندت تنظیم کن
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`http://localhost:3000` رو باز کن، با شمارهی موبایلِ یک کاربر ادمین
|
||||||
|
(همونی که با `ADMIN_BOOTSTRAP_PHONE` در بکاند ساخته شده، یا هر کاربری که
|
||||||
|
دستی `is_admin=true` شده) وارد شو.
|
||||||
|
|
||||||
|
## ساختار پروژه
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── login/, verify/ ← ورود OTP (مثل اپ اصلی)
|
||||||
|
│ └── (dashboard)/
|
||||||
|
│ ├── layout.tsx ← گارد ادمین + سایدبار
|
||||||
|
│ ├── page.tsx ← داشبورد با آمار سریع
|
||||||
|
│ ├── categories/page.tsx ← لیست ۶ دسته
|
||||||
|
│ ├── categories/[key]/ ← ویرایش تنظیمات دسته + CRUD گزینهها
|
||||||
|
│ └── questions/page.tsx ← CRUD سوالات پروفایل
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ ← دیزاینسیستم مشترک + Modal/Toast/ConfirmDialog جدید
|
||||||
|
│ └── Sidebar.tsx
|
||||||
|
└── lib/
|
||||||
|
├── api.ts ← کلاینت تایپشدهی endpoint های /admin/*
|
||||||
|
├── auth-context.tsx ← ورود + چک is_admin
|
||||||
|
└── hooks.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکتهی امنیتی
|
||||||
|
|
||||||
|
این پنل فقط روی احراز هویت سمت بکاند (`middleware.AdminRequired`) تکیه
|
||||||
|
میکنه — یعنی حتی اگه کسی مستقیم به route های این پنل دسترسی پیدا کنه،
|
||||||
|
بدون توکن معتبرِ یک کاربر `is_admin=true`، هیچ عملیاتی از سمت بکاند رد
|
||||||
|
نمیشه (این رو با تست ۴۰۳ بالا تایید کردیم). با این حال توصیه میشه این
|
||||||
|
پنل رو پشت یک دامنهی جدا یا حداقل VPN/IP-allowlist در پروداکشن قرار بدی.
|
||||||
Generated
+6822
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "dating-app-web",
|
"name": "dating-app-admin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
"@fontsource-variable/baloo-2": "^5.3.0",
|
"@fontsource-variable/baloo-2": "^5.3.0",
|
||||||
"@fontsource-variable/vazirmatn": "^5.3.0",
|
"@fontsource-variable/vazirmatn": "^5.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^13.3.0",
|
|
||||||
"lucide-react": "^1.45.0",
|
"lucide-react": "^1.45.0",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Database, Download, HardDrive, RefreshCw } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { BackupInfo, listBackups, createBackup, downloadBackup } from "@/lib/api";
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackupsPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [backups, setBackups] = useState<BackupInfo[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setBackups(await listBackups());
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await createBackup();
|
||||||
|
toast.showSuccess("بکآپ جدید با موفقیت ساخته شد");
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload(filename: string) {
|
||||||
|
setDownloadingFile(filename);
|
||||||
|
try {
|
||||||
|
await downloadBackup(filename);
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setDownloadingFile(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backups && !error) return <FullScreenLoader label="لیست بکآپها رو میاریم..." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-3xl">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
|
||||||
|
بکآپ دیتابیس 💾
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
یک نسخهی کامل و فشرده از دیتابیس بگیر یا نسخههای قبلی رو دانلود کن.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="tertiary" size="sm" icon={<RefreshCw size={14} />} onClick={load}>
|
||||||
|
تازهسازی
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" icon={<Database size={15} />} isLoading={creating} onClick={handleCreate}>
|
||||||
|
ساخت بکآپ جدید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
{!backups || backups.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-14 text-center">
|
||||||
|
<HardDrive size={28} className="text-(--color-content-tertiary)" />
|
||||||
|
<p className="text-[14px] font-bold text-(--color-content-primary)">
|
||||||
|
هنوز هیچ بکآپی گرفته نشده
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-(--color-content-tertiary)">
|
||||||
|
با دکمهی «ساخت بکآپ جدید» اولین نسخه رو بگیر.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-(--color-border-subtle)">
|
||||||
|
{backups.map((b) => (
|
||||||
|
<div key={b.filename} className="flex items-center justify-between px-5 py-4 gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p dir="ltr" className="font-bold text-[13px] text-(--color-content-primary) truncate">
|
||||||
|
{b.filename}
|
||||||
|
</p>
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
|
||||||
|
{new Date(b.created_at).toLocaleString("fa-IR")} · {formatSize(b.size_bytes)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
icon={<Download size={14} />}
|
||||||
|
isLoading={downloadingFile === b.filename}
|
||||||
|
onClick={() => handleDownload(b.filename)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
دانلود
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { ChevronRight, Plus, Pencil, Trash2, Settings2 } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { TextField } from "@/components/ui/TextField";
|
||||||
|
import { Switch } from "@/components/ui/Controls";
|
||||||
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import {
|
||||||
|
ALL_CATEGORIES,
|
||||||
|
CategoryKey,
|
||||||
|
Category,
|
||||||
|
Option,
|
||||||
|
getCategory,
|
||||||
|
updateCategory,
|
||||||
|
listOptions,
|
||||||
|
createOption,
|
||||||
|
updateOption,
|
||||||
|
deleteOption,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
export default function CategoryDetailPage() {
|
||||||
|
const params = useParams<{ key: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const key = params.key as CategoryKey;
|
||||||
|
const meta = ALL_CATEGORIES.find((c) => c.key === key);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [category, setCategory] = useState<Category | null>(null);
|
||||||
|
const [options, setOptions] = useState<Option[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [optionModal, setOptionModal] = useState<{ mode: "create" | "edit"; option?: Option } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Option | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [cat, opts] = await Promise.all([getCategory(key), listOptions(key)]);
|
||||||
|
setCategory(cat);
|
||||||
|
setOptions(opts);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
return <ErrorBanner message="دستهی نامعتبر" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !category) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!category || !options) return <FullScreenLoader />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-3xl">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/categories")}
|
||||||
|
className="flex items-center gap-1 text-[13px] font-semibold text-(--color-content-secondary) hover:text-(--color-brand-600) w-fit"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
بازگشت به دستهبندیها
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[24px] flex items-center gap-2">
|
||||||
|
<span>{meta.emoji}</span> {category.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge tone="brand">
|
||||||
|
حداقل انتخاب: {category.min_select}
|
||||||
|
</Badge>
|
||||||
|
<Badge tone="neutral">
|
||||||
|
حداکثر انتخاب: {category.max_select === 0 ? "نامحدود" : category.max_select}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="tertiary" size="sm" icon={<Settings2 size={15} />} onClick={() => setSettingsOpen(true)}>
|
||||||
|
تنظیمات دسته
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-[14px] font-bold text-(--color-content-secondary)">
|
||||||
|
گزینهها ({options.length})
|
||||||
|
</h2>
|
||||||
|
<Button size="sm" icon={<Plus size={16} />} onClick={() => setOptionModal({ mode: "create" })}>
|
||||||
|
افزودن گزینه
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
{options.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-(--color-content-tertiary) text-center py-10">
|
||||||
|
هنوز گزینهای اضافه نشده.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
|
||||||
|
<th className="text-right font-semibold px-4 py-3">عنوان</th>
|
||||||
|
<th className="text-right font-semibold px-4 py-3 w-20">ترتیب</th>
|
||||||
|
<th className="text-right font-semibold px-4 py-3 w-24">فعال</th>
|
||||||
|
<th className="text-right font-semibold px-4 py-3 w-24">عملیات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...options]
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
.map((opt) => (
|
||||||
|
<OptionRow
|
||||||
|
key={opt.id}
|
||||||
|
option={opt}
|
||||||
|
onToggleActive={async (next) => {
|
||||||
|
try {
|
||||||
|
await updateOption(opt.id, { is_active: next });
|
||||||
|
setOptions((prev) =>
|
||||||
|
prev!.map((o) => (o.id === opt.id ? { ...o, is_active: next } : o))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onEdit={() => setOptionModal({ mode: "edit", option: opt })}
|
||||||
|
onDelete={() => setDeleteTarget(opt)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<CategorySettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
category={category}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onSaved={(updated) => {
|
||||||
|
setCategory(updated);
|
||||||
|
setSettingsOpen(false);
|
||||||
|
toast.showSuccess("تنظیمات دسته بهروزرسانی شد");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionFormModal
|
||||||
|
open={!!optionModal}
|
||||||
|
mode={optionModal?.mode ?? "create"}
|
||||||
|
option={optionModal?.option}
|
||||||
|
categoryKey={key}
|
||||||
|
onClose={() => setOptionModal(null)}
|
||||||
|
onSaved={(opt, mode) => {
|
||||||
|
if (mode === "create") {
|
||||||
|
setOptions((prev) => [...(prev ?? []), opt]);
|
||||||
|
toast.showSuccess("گزینه اضافه شد");
|
||||||
|
} else {
|
||||||
|
setOptions((prev) => prev!.map((o) => (o.id === opt.id ? opt : o)));
|
||||||
|
toast.showSuccess("گزینه ویرایش شد");
|
||||||
|
}
|
||||||
|
setOptionModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title="حذف گزینه؟"
|
||||||
|
description={`گزینهی «${deleteTarget?.title}» برای همیشه حذف میشه. کاربرهایی که قبلاً این رو انتخاب کرده بودن، همچنان انتخابشون توی سابقه میمونه ولی این گزینه دیگه قابل انتخاب نیست.`}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteOption(deleteTarget.id);
|
||||||
|
setOptions((prev) => prev!.filter((o) => o.id !== deleteTarget.id));
|
||||||
|
toast.showSuccess("گزینه حذف شد");
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionRow({
|
||||||
|
option,
|
||||||
|
onToggleActive,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
option: Option;
|
||||||
|
onToggleActive: (next: boolean) => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-(--color-border-subtle) last:border-0">
|
||||||
|
<td className="px-4 py-3 font-medium text-(--color-content-primary)">{option.title}</td>
|
||||||
|
<td className="px-4 py-3 text-(--color-content-tertiary)">{option.sort_order}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Switch defaultChecked={option.is_active} onChange={onToggleActive} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-red-50 text-(--color-status-error)"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategorySettingsModal({
|
||||||
|
open,
|
||||||
|
category,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
category: Category;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (c: Category) => void;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState(category.title);
|
||||||
|
const [minSelect, setMinSelect] = useState(String(category.min_select));
|
||||||
|
const [maxSelect, setMaxSelect] = useState(String(category.max_select));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setTitle(category.title);
|
||||||
|
setMinSelect(String(category.min_select));
|
||||||
|
setMaxSelect(String(category.max_select));
|
||||||
|
}, [category]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await updateCategory(category.key, {
|
||||||
|
title,
|
||||||
|
min_select: parseInt(minSelect, 10),
|
||||||
|
max_select: parseInt(maxSelect, 10),
|
||||||
|
});
|
||||||
|
onSaved(updated);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="تنظیمات دسته">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<TextField label="عنوان دسته" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TextField
|
||||||
|
label="حداقل انتخاب"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={minSelect}
|
||||||
|
onChange={(e) => setMinSelect(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="حداکثر انتخاب"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
helperText="۰ یعنی نامحدود"
|
||||||
|
value={maxSelect}
|
||||||
|
onChange={(e) => setMaxSelect(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" isLoading={loading} onClick={handleSave}>
|
||||||
|
ذخیره
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OptionFormModal({
|
||||||
|
open,
|
||||||
|
mode,
|
||||||
|
option,
|
||||||
|
categoryKey,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
mode: "create" | "edit";
|
||||||
|
option?: Option;
|
||||||
|
categoryKey: CategoryKey;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (opt: Option, mode: "create" | "edit") => void;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState(option?.title ?? "");
|
||||||
|
const [sortOrder, setSortOrder] = useState(String(option?.sort_order ?? 0));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setTitle(option?.title ?? "");
|
||||||
|
setSortOrder(String(option?.sort_order ?? 0));
|
||||||
|
setError(null);
|
||||||
|
}, [option, open]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (mode === "create") {
|
||||||
|
const created = await createOption(categoryKey, {
|
||||||
|
title: title.trim(),
|
||||||
|
sort_order: parseInt(sortOrder, 10) || 0,
|
||||||
|
});
|
||||||
|
onSaved(created, "create");
|
||||||
|
} else if (option) {
|
||||||
|
const updated = await updateOption(option.id, {
|
||||||
|
title: title.trim(),
|
||||||
|
sort_order: parseInt(sortOrder, 10) || 0,
|
||||||
|
});
|
||||||
|
onSaved(updated, "edit");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={mode === "create" ? "افزودن گزینه" : "ویرایش گزینه"}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<TextField
|
||||||
|
label="عنوان"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="ترتیب نمایش"
|
||||||
|
type="number"
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(e.target.value)}
|
||||||
|
/>
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" isLoading={loading} disabled={!title.trim()} onClick={handleSave}>
|
||||||
|
ذخیره
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { ALL_CATEGORIES } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function CategoriesIndexPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-5xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||||
|
دستهبندیها و گزینهها 🗂️
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
هر دسته یه لیست انتخابیه که توی مراحل آنبوردینگ به کاربر نشون داده میشه.
|
||||||
|
روی هرکدوم بزن تا گزینههاش رو مدیریت کنی.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{ALL_CATEGORIES.map((c) => (
|
||||||
|
<Link key={c.key} href={`/categories/${c.key}`}>
|
||||||
|
<Card hoverable className="flex items-center justify-between h-full">
|
||||||
|
<div>
|
||||||
|
<p className="text-[24px] mb-1">{c.emoji}</p>
|
||||||
|
<p className="font-bold text-[15px] text-(--color-content-primary)">
|
||||||
|
{c.fallbackTitle}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] font-mono text-(--color-content-tertiary) mt-0.5">
|
||||||
|
{c.key}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ShieldAlert } from "lucide-react";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { status, logout } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "guest") router.replace("/login");
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
|
if (status === "loading" || status === "guest") return <FullScreenLoader />;
|
||||||
|
|
||||||
|
if (status === "not-admin") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh flex items-center justify-center px-4">
|
||||||
|
<Card className="w-full max-w-sm flex flex-col items-center gap-4 !p-8 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-(--radius-lg) bg-red-50 flex items-center justify-center">
|
||||||
|
<ShieldAlert size={26} className="text-(--color-status-error)" />
|
||||||
|
</div>
|
||||||
|
<h2 className="font-[var(--font-display)] font-extrabold text-[18px]">
|
||||||
|
دسترسی نداری
|
||||||
|
</h2>
|
||||||
|
<p className="text-[13px] text-(--color-content-secondary) leading-6">
|
||||||
|
حساب واردشده دسترسی ادمین نداره.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
router.push("/login");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
خروج و ورود با حساب دیگه
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 min-w-0 p-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight, HeartHandshake } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { AdminMatchView, listAllMatches } from "@/lib/api";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export default function MatchesPage() {
|
||||||
|
const [items, setItems] = useState<AdminMatchView[] | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async (p: number) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await listAllMatches(p, PAGE_SIZE);
|
||||||
|
setItems(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load(page);
|
||||||
|
}, [page, load]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-3xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||||
|
رفقا (مچها) 💞
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
همهی جفتهایی که با هم مچ شدن، جدیدترین اول.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
{!items ? (
|
||||||
|
<FullScreenLoader />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
icon={<HeartHandshake size={32} />}
|
||||||
|
title="هنوز هیچ مچی اتفاق نیفتاده"
|
||||||
|
description="وقتی دو نفر همدیگه رو لایک کنن، اینجا نشون داده میشن."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
{items.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={`flex items-center gap-4 px-4 py-3.5 ${
|
||||||
|
i !== items.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center -space-x-3 space-x-reverse shrink-0">
|
||||||
|
<Avatar name={m.user_one_name || m.user_one_phone} />
|
||||||
|
<Avatar name={m.user_two_name || m.user_two_phone} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 grid grid-cols-2 gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-bold text-[13.5px] text-(--color-content-primary) truncate">
|
||||||
|
{m.user_one_name || "بدون نام"}
|
||||||
|
</p>
|
||||||
|
<p dir="ltr" className="text-[11.5px] text-(--color-content-tertiary) text-right">
|
||||||
|
{m.user_one_phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-bold text-[13.5px] text-(--color-content-primary) truncate">
|
||||||
|
{m.user_two_name || "بدون نام"}
|
||||||
|
</p>
|
||||||
|
<p dir="ltr" className="text-[11.5px] text-(--color-content-tertiary) text-right">
|
||||||
|
{m.user_two_phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11.5px] text-(--color-content-tertiary) shrink-0">
|
||||||
|
{new Date(m.created_at).toLocaleDateString("fa-IR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||||
|
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} مچ
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
icon={<ChevronRight size={14} />}
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
قبلی
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
icon={<ChevronLeft size={14} />}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Activity, AlertTriangle, Clock, Gauge, RefreshCw, Zap } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import {
|
||||||
|
MonitoringSummary,
|
||||||
|
RequestLogEntry,
|
||||||
|
getMonitoringSummary,
|
||||||
|
getRecentRequests,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
const POLL_MS = 8_000;
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone = "neutral",
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "neutral" | "warning" | "error";
|
||||||
|
}) {
|
||||||
|
const toneClasses =
|
||||||
|
tone === "error"
|
||||||
|
? "text-(--color-status-error)"
|
||||||
|
: tone === "warning"
|
||||||
|
? "text-amber-600"
|
||||||
|
: "text-(--color-brand-600)";
|
||||||
|
return (
|
||||||
|
<Card className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-(--radius-md) bg-(--color-bg-secondary) flex items-center justify-center shrink-0 ${toneClasses}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary)">{label}</p>
|
||||||
|
<p className="text-[19px] font-extrabold text-(--color-content-primary)">{value}</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(code: number): "success" | "warning" | "error" | "neutral" {
|
||||||
|
if (code >= 500) return "error";
|
||||||
|
if (code >= 400) return "warning";
|
||||||
|
if (code >= 200 && code < 300) return "success";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (h > 0) return `${h} ساعت و ${m} دقیقه`;
|
||||||
|
return `${m} دقیقه`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitoringPage() {
|
||||||
|
const [summary, setSummary] = useState<MonitoringSummary | null>(null);
|
||||||
|
const [requests, setRequests] = useState<RequestLogEntry[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [s, r] = await Promise.all([getMonitoringSummary(), getRecentRequests(50)]);
|
||||||
|
setSummary(s);
|
||||||
|
setRequests(r);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load();
|
||||||
|
const interval = setInterval(load, POLL_MS);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!summary || !requests) {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <FullScreenLoader label="آمار سرور رو میاریم..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-5xl">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
|
||||||
|
مانیتورینگ سرور 📡
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
وضعیت زندهی بکاند — هر {POLL_MS / 1000} ثانیه خودکار بهروز میشه.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="tertiary" size="sm" icon={<RefreshCw size={14} />} onClick={load}>
|
||||||
|
تازهسازی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={<Activity size={18} />}
|
||||||
|
label="کل درخواستها (از زمان اجرا)"
|
||||||
|
value={summary.total_requests.toLocaleString("fa-IR")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Zap size={18} />}
|
||||||
|
label="درخواست در دقیقهی اخیر"
|
||||||
|
value={summary.requests_last_min.toLocaleString("fa-IR")}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Clock size={18} />}
|
||||||
|
label="زمان پاسخ میانگین"
|
||||||
|
value={`${summary.avg_latency_ms.toFixed(1)} ms`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Gauge size={18} />}
|
||||||
|
label="زمان پاسخ p95"
|
||||||
|
value={`${summary.p95_latency_ms.toFixed(1)} ms`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<AlertTriangle size={18} />}
|
||||||
|
label="نرخ خطا"
|
||||||
|
value={`${summary.error_rate_pct.toFixed(1)}%`}
|
||||||
|
tone={summary.error_rate_pct > 5 ? "error" : summary.error_rate_pct > 0 ? "warning" : "neutral"}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Activity size={18} />}
|
||||||
|
label="مدت زمان روشن بودن سرور"
|
||||||
|
value={formatUptime(summary.uptime_seconds)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-(--color-border-subtle)">
|
||||||
|
<h2 className="font-bold text-[15px]">آخرین درخواستها</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">متد</th>
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">مسیر</th>
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">وضعیت</th>
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">مدت</th>
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">IP</th>
|
||||||
|
<th className="text-right font-semibold px-5 py-2.5">زمان</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center text-(--color-content-tertiary) py-8">
|
||||||
|
هنوز درخواستی ثبت نشده
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
requests.map((r, i) => (
|
||||||
|
<tr key={i} className="border-b border-(--color-border-subtle) last:border-0">
|
||||||
|
<td className="px-5 py-2.5 font-bold" dir="ltr">
|
||||||
|
{r.method}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 font-mono text-[12px] text-(--color-content-secondary)" dir="ltr">
|
||||||
|
{r.path || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5">
|
||||||
|
<Badge tone={statusTone(r.status_code)}>{r.status_code}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5" dir="ltr">
|
||||||
|
{r.duration_ms.toFixed(1)} ms
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 text-(--color-content-tertiary)" dir="ltr">
|
||||||
|
{r.client_ip}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 text-(--color-content-tertiary)">
|
||||||
|
{new Date(r.time).toLocaleTimeString("fa-IR")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, UserCheck } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { ALL_CATEGORIES, listOptions, listQuestions, listPendingUsers } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Stat {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
emoji: string;
|
||||||
|
count: number;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardHomePage() {
|
||||||
|
const [stats, setStats] = useState<Stat[] | null>(null);
|
||||||
|
const [questionsCount, setQuestionsCount] = useState<number | null>(null);
|
||||||
|
const [pendingCount, setPendingCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listPendingUsers()
|
||||||
|
.then((users) => setPendingCount(users.length))
|
||||||
|
.catch(() => setPendingCount(0));
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
ALL_CATEGORIES.map(async (c) => {
|
||||||
|
try {
|
||||||
|
const options = await listOptions(c.key);
|
||||||
|
return {
|
||||||
|
key: c.key,
|
||||||
|
label: c.fallbackTitle,
|
||||||
|
emoji: c.emoji,
|
||||||
|
count: options.length,
|
||||||
|
href: `/categories/${c.key}`,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { key: c.key, label: c.fallbackTitle, emoji: c.emoji, count: 0, href: `/categories/${c.key}` };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).then(setStats);
|
||||||
|
|
||||||
|
listQuestions()
|
||||||
|
.then((qs) => setQuestionsCount(qs.length))
|
||||||
|
.catch(() => setQuestionsCount(0));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!stats) return <FullScreenLoader label="آمار رو میاریم..." />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 max-w-5xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
|
||||||
|
سلام ادمین جان 👋
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
از اینجا میتونی لیستهای انتخابی و سوالات پروفایل اپ دلدل رو مدیریت کنی.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href="/users">
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
className="flex items-center justify-between max-w-sm !border-(--color-brand-200) bg-(--color-brand-50)"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-11 h-11 rounded-(--radius-md) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-sm) shrink-0">
|
||||||
|
<UserCheck size={19} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-[15px]">کاربران در انتظار تایید</p>
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
|
||||||
|
{pendingCount === null ? "…" : `${pendingCount} نفر منتظرن`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!!pendingCount && (
|
||||||
|
<Badge tone="warning" className="shrink-0">
|
||||||
|
{pendingCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[13px] font-bold text-(--color-content-secondary) mb-3">
|
||||||
|
دستهبندیها
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{stats.map((s) => (
|
||||||
|
<Link key={s.key} href={s.href}>
|
||||||
|
<Card hoverable className="flex items-center justify-between h-full">
|
||||||
|
<div>
|
||||||
|
<p className="text-[24px] mb-1">{s.emoji}</p>
|
||||||
|
<p className="font-bold text-[15px] text-(--color-content-primary)">
|
||||||
|
{s.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
|
||||||
|
{s.count} گزینه
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[13px] font-bold text-(--color-content-secondary) mb-3">
|
||||||
|
سوالات پروفایل
|
||||||
|
</h2>
|
||||||
|
<Link href="/questions">
|
||||||
|
<Card hoverable className="flex items-center justify-between max-w-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-[24px] mb-1">💬</p>
|
||||||
|
<p className="font-bold text-[15px]">سوالات پروفایل</p>
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
|
||||||
|
{questionsCount ?? "…"} سوال
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight, ImageOff, Trash2, Flag } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { AdminPhotoView, listAllPhotos, deletePhotoAdmin, reportPhoto, photoUrl } from "@/lib/api";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
export default function PhotosModerationPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [items, setItems] = useState<AdminPhotoView[] | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<AdminPhotoView | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [pendingReport, setPendingReport] = useState<AdminPhotoView | null>(null);
|
||||||
|
const [reportReason, setReportReason] = useState("");
|
||||||
|
const [reporting, setReporting] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async (p: number) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await listAllPhotos(p, PAGE_SIZE);
|
||||||
|
setItems(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load(page);
|
||||||
|
}, [page, load]);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!pendingDelete) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deletePhotoAdmin(pendingDelete.id);
|
||||||
|
toast.showSuccess("عکس حذف شد");
|
||||||
|
setPendingDelete(null);
|
||||||
|
load(page);
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReport() {
|
||||||
|
if (!pendingReport || reportReason.trim().length < 3) return;
|
||||||
|
setReporting(true);
|
||||||
|
try {
|
||||||
|
await reportPhoto(pendingReport.id, reportReason.trim());
|
||||||
|
toast.showSuccess("گزارش ثبت شد");
|
||||||
|
setPendingReport(null);
|
||||||
|
setReportReason("");
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setReporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||||
|
نظارت بر عکسها 🖼️
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
آخرین عکسهای آپلودشدهی همهی کاربران، جدیدترین اول.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
{!items ? (
|
||||||
|
<FullScreenLoader />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
icon={<ImageOff size={32} />}
|
||||||
|
title="هنوز عکسی آپلود نشده"
|
||||||
|
description="عکسهای آپلودشدهی کاربران اینجا برای نظارت نشون داده میشن."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
{items.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="group relative aspect-square rounded-(--radius-md) overflow-hidden bg-(--color-bg-tertiary) border border-(--color-border-subtle)"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={photoUrl(p.url)}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 pt-6">
|
||||||
|
<p className="text-[11px] font-bold text-white truncate">
|
||||||
|
{p.owner_name || "بدون نام"}
|
||||||
|
</p>
|
||||||
|
<p dir="ltr" className="text-[10px] text-white/80 truncate">
|
||||||
|
{p.owner_phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-1.5 left-1.5 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingReport(p)}
|
||||||
|
className="w-7 h-7 rounded-full bg-black/50 hover:bg-amber-600 flex items-center justify-center text-white"
|
||||||
|
aria-label="گزارش عکس"
|
||||||
|
>
|
||||||
|
<Flag size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingDelete(p)}
|
||||||
|
className="w-7 h-7 rounded-full bg-black/50 hover:bg-(--color-status-error) flex items-center justify-center text-white"
|
||||||
|
aria-label="حذف عکس"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||||
|
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} عکس
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
icon={<ChevronRight size={14} />}
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
قبلی
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
icon={<ChevronLeft size={14} />}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!pendingDelete}
|
||||||
|
title="حذف عکس"
|
||||||
|
description="این عکس برای همیشه حذف میشه و از پروفایل کاربر هم پاک میشه. مطمئنی؟"
|
||||||
|
loading={deleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setPendingDelete(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={!!pendingReport}
|
||||||
|
onClose={() => setPendingReport(null)}
|
||||||
|
title="گزارش عکس"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-[13px] text-(--color-content-secondary)">
|
||||||
|
یک یادداشت نظارتی روی صاحب این عکس ثبت میشه (مستقل از حذف کردن).
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={reportReason}
|
||||||
|
onChange={(e) => setReportReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="دلیل گزارش..."
|
||||||
|
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setPendingReport(null)}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isLoading={reporting}
|
||||||
|
disabled={reportReason.trim().length < 3}
|
||||||
|
onClick={handleReport}
|
||||||
|
>
|
||||||
|
ثبت گزارش
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { TextField } from "@/components/ui/TextField";
|
||||||
|
import { Switch } from "@/components/ui/Controls";
|
||||||
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import {
|
||||||
|
ProfileQuestion,
|
||||||
|
listQuestions,
|
||||||
|
createQuestion,
|
||||||
|
updateQuestion,
|
||||||
|
deleteQuestion,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
export default function QuestionsPage() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [questions, setQuestions] = useState<ProfileQuestion[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [formModal, setFormModal] = useState<{ mode: "create" | "edit"; q?: ProfileQuestion } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ProfileQuestion | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setQuestions(await listQuestions());
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (error && !questions) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!questions) return <FullScreenLoader />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-3xl">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||||
|
سوالات پروفایل 💬
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
کاربر توی مرحلهی سوم آنبوردینگ دقیقاً ۳ تا از این سوالات رو انتخاب و
|
||||||
|
جواب میده.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" icon={<Plus size={16} />} onClick={() => setFormModal({ mode: "create" })}>
|
||||||
|
افزودن سوال
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
{questions.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-(--color-content-tertiary) text-center py-10">
|
||||||
|
هنوز سوالی اضافه نشده.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
|
||||||
|
<th className="text-right font-semibold px-4 py-3">متن سوال</th>
|
||||||
|
<th className="text-right font-semibold px-4 py-3 w-20">ترتیب</th>
|
||||||
|
<th className="text-right font-semibold px-4 py-3 w-24">فعال</th>
|
||||||
|
<th className="text-right font-semibold px-4 py-3 w-24">عملیات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...questions]
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
.map((q) => (
|
||||||
|
<tr key={q.id} className="border-b border-(--color-border-subtle) last:border-0">
|
||||||
|
<td className="px-4 py-3 font-medium text-(--color-content-primary)">
|
||||||
|
{q.text}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-(--color-content-tertiary)">{q.sort_order}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Switch
|
||||||
|
defaultChecked={q.is_active}
|
||||||
|
onChange={async (next) => {
|
||||||
|
try {
|
||||||
|
await updateQuestion(q.id, { is_active: next });
|
||||||
|
setQuestions((prev) =>
|
||||||
|
prev!.map((x) => (x.id === q.id ? { ...x, is_active: next } : x))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setFormModal({ mode: "edit", q })}
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(q)}
|
||||||
|
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-red-50 text-(--color-status-error)"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<QuestionFormModal
|
||||||
|
open={!!formModal}
|
||||||
|
mode={formModal?.mode ?? "create"}
|
||||||
|
question={formModal?.q}
|
||||||
|
onClose={() => setFormModal(null)}
|
||||||
|
onSaved={(q, mode) => {
|
||||||
|
if (mode === "create") {
|
||||||
|
setQuestions((prev) => [...(prev ?? []), q]);
|
||||||
|
toast.showSuccess("سوال اضافه شد");
|
||||||
|
} else {
|
||||||
|
setQuestions((prev) => prev!.map((x) => (x.id === q.id ? q : x)));
|
||||||
|
toast.showSuccess("سوال ویرایش شد");
|
||||||
|
}
|
||||||
|
setFormModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title="حذف سوال؟"
|
||||||
|
description="این سوال از لیست سوالات قابلانتخاب حذف میشه. پاسخهایی که کاربرها قبلاً بهش دادن دستنخورده میمونه."
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteQuestion(deleteTarget.id);
|
||||||
|
setQuestions((prev) => prev!.filter((x) => x.id !== deleteTarget.id));
|
||||||
|
toast.showSuccess("سوال حذف شد");
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionFormModal({
|
||||||
|
open,
|
||||||
|
mode,
|
||||||
|
question,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
mode: "create" | "edit";
|
||||||
|
question?: ProfileQuestion;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (q: ProfileQuestion, mode: "create" | "edit") => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState(question?.text ?? "");
|
||||||
|
const [sortOrder, setSortOrder] = useState(String(question?.sort_order ?? 0));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setText(question?.text ?? "");
|
||||||
|
setSortOrder(String(question?.sort_order ?? 0));
|
||||||
|
setError(null);
|
||||||
|
}, [question, open]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (text.trim().length < 3) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (mode === "create") {
|
||||||
|
const created = await createQuestion({ text: text.trim(), sort_order: parseInt(sortOrder, 10) || 0 });
|
||||||
|
onSaved(created, "create");
|
||||||
|
} else if (question) {
|
||||||
|
const updated = await updateQuestion(question.id, {
|
||||||
|
text: text.trim(),
|
||||||
|
sort_order: parseInt(sortOrder, 10) || 0,
|
||||||
|
});
|
||||||
|
onSaved(updated, "edit");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={mode === "create" ? "افزودن سوال" : "ویرایش سوال"}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||||
|
متن سوال
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TextField
|
||||||
|
label="ترتیب نمایش"
|
||||||
|
type="number"
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={(e) => setSortOrder(e.target.value)}
|
||||||
|
/>
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" isLoading={loading} disabled={text.trim().length < 3} onClick={handleSave}>
|
||||||
|
ذخیره
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Ruler,
|
||||||
|
Weight,
|
||||||
|
GraduationCap,
|
||||||
|
Briefcase,
|
||||||
|
Heart,
|
||||||
|
Sparkles,
|
||||||
|
Search,
|
||||||
|
Check,
|
||||||
|
Flag,
|
||||||
|
Activity,
|
||||||
|
Images,
|
||||||
|
User as UserIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Tag } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { UserModerationActions } from "@/components/UserModerationActions";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { calcAge } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
FullProfile,
|
||||||
|
ApprovalStatus,
|
||||||
|
Report,
|
||||||
|
RequestLogEntry,
|
||||||
|
getUserDetail,
|
||||||
|
approveUser,
|
||||||
|
rejectUser,
|
||||||
|
listUserReports,
|
||||||
|
getRecentRequests,
|
||||||
|
photoUrl,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
const statusMeta: Record<ApprovalStatus, { label: string; tone: "warning" | "success" | "error" }> = {
|
||||||
|
pending: { label: "در انتظار بررسی", tone: "warning" },
|
||||||
|
approved: { label: "تایید شده", tone: "success" },
|
||||||
|
rejected: { label: "رد شده", tone: "error" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function selectionTitles(profile: FullProfile, key: string): string[] {
|
||||||
|
return profile.selections.find((s) => s.category_key === key)?.titles ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionCard({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-(--color-content-secondary)">
|
||||||
|
{icon}
|
||||||
|
<span className="text-[13px] font-bold">{title}</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserReviewPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const toast = useToast();
|
||||||
|
const id = Number(params.id);
|
||||||
|
|
||||||
|
const [profile, setProfile] = useState<FullProfile | null>(null);
|
||||||
|
const [reports, setReports] = useState<Report[]>([]);
|
||||||
|
const [activity, setActivity] = useState<RequestLogEntry[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [acting, setActing] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setProfile(await getUserDetail(id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
}
|
||||||
|
listUserReports(id).then(setReports).catch(() => setReports([]));
|
||||||
|
getRecentRequests(15, id).then(setActivity).catch(() => setActivity([]));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function handleApprove() {
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
await approveUser(id);
|
||||||
|
toast.showSuccess("کاربر تایید شد");
|
||||||
|
router.push("/users");
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !profile) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!profile) return <FullScreenLoader />;
|
||||||
|
|
||||||
|
const { user } = profile;
|
||||||
|
const age = calcAge(user.birth_date);
|
||||||
|
const education = selectionTitles(profile, "education_level")[0];
|
||||||
|
const fieldOfStudy = selectionTitles(profile, "field_of_study")[0];
|
||||||
|
const occupations = selectionTitles(profile, "occupation");
|
||||||
|
const importance = selectionTitles(profile, "important_in_relationship");
|
||||||
|
const interests = selectionTitles(profile, "interest");
|
||||||
|
const lookingFor = selectionTitles(profile, "looking_for")[0];
|
||||||
|
const status = statusMeta[user.approval_status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-3xl pb-20">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/users")}
|
||||||
|
className="flex items-center gap-1 text-[13px] font-semibold text-(--color-content-secondary) hover:text-(--color-brand-600) w-fit"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
بازگشت به لیست
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar name={user.first_name || user.phone} size="lg" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[22px]">
|
||||||
|
{user.first_name || "بدون نام"}
|
||||||
|
{age ? `، ${age}` : ""}
|
||||||
|
</h1>
|
||||||
|
<p dir="ltr" className="text-[13px] text-(--color-content-tertiary)">
|
||||||
|
{user.phone}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge tone={status.tone}>{status.label}</Badge>
|
||||||
|
{user.gender && (
|
||||||
|
<Badge tone="neutral">{user.gender === "male" ? "مرد" : "زن"}</Badge>
|
||||||
|
)}
|
||||||
|
{user.state === "blocked" && <Badge tone="error">بلاک</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<UserModerationActions userId={id} state={user.state} onChanged={load} />
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
icon={<XCircle size={16} />}
|
||||||
|
className="!text-(--color-status-error) !border-red-200"
|
||||||
|
onClick={() => setRejectOpen(true)}
|
||||||
|
disabled={acting}
|
||||||
|
>
|
||||||
|
رد کردن
|
||||||
|
</Button>
|
||||||
|
<Button icon={<CheckCircle2 size={16} />} isLoading={acting} onClick={handleApprove}>
|
||||||
|
تایید کاربر
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.approval_status === "rejected" && user.rejection_reason && (
|
||||||
|
<ErrorBanner message={`دلیل رد قبلی: ${user.rejection_reason}`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.state === "blocked" && user.blocked_reason && (
|
||||||
|
<ErrorBanner message={`دلیل بلاک: ${user.blocked_reason}`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionCard icon={<Images size={15} />} title={`عکسها (${profile.photos.length})`}>
|
||||||
|
{profile.photos.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2 text-(--color-content-tertiary) text-[13px]">
|
||||||
|
<UserIcon size={15} />
|
||||||
|
<span>این کاربر هنوز عکسی آپلود نکرده.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2.5">
|
||||||
|
{profile.photos.map((p) => (
|
||||||
|
<a
|
||||||
|
key={p.id}
|
||||||
|
href={photoUrl(p.url)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="relative aspect-square rounded-(--radius-md) overflow-hidden bg-(--color-bg-tertiary) block"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={photoUrl(p.url)} alt="" className="w-full h-full object-cover" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
|
||||||
|
<div className="flex gap-4 text-[14px]">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Ruler size={14} className="text-(--color-brand-500)" />
|
||||||
|
{user.height_cm ?? "—"} سانتیمتر
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Weight size={14} className="text-(--color-brand-500)" />
|
||||||
|
{user.weight_kg ?? "—"} کیلوگرم
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={<GraduationCap size={15} />} title="تحصیلات">
|
||||||
|
<p className="text-[14px] text-(--color-content-primary)">
|
||||||
|
{education ?? "—"} {fieldOfStudy ? `· ${fieldOfStudy}` : ""}
|
||||||
|
</p>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={<Briefcase size={15} />} title="فعالیتها">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{occupations.length ? (
|
||||||
|
occupations.map((t) => <Tag key={t} label={t} color="sky" />)
|
||||||
|
) : (
|
||||||
|
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={<Heart size={15} />} title="توی رابطه براش مهمه">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{importance.length ? (
|
||||||
|
importance.map((t) => <Tag key={t} label={t} color="pink" />)
|
||||||
|
) : (
|
||||||
|
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={<Sparkles size={15} />} title="علاقهمندیها">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{interests.length ? (
|
||||||
|
interests.map((t) => <Tag key={t} label={t} color="yellow" />)
|
||||||
|
) : (
|
||||||
|
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<SectionCard icon={<Search size={15} />} title="دنبال چی میگرده">
|
||||||
|
<p className="text-[14px] text-(--color-content-primary)">{lookingFor ?? "—"}</p>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{profile.profile_answers.length > 0 && (
|
||||||
|
<SectionCard icon={<Sparkles size={15} />} title="یکم از خودش">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{profile.profile_answers.map((a) => (
|
||||||
|
<div key={a.id}>
|
||||||
|
<p className="text-[12px] font-bold text-(--color-content-tertiary) mb-0.5">
|
||||||
|
{a.question?.text}
|
||||||
|
</p>
|
||||||
|
<p className="text-[14px] text-(--color-content-primary)">{a.answer}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reports.length > 0 && (
|
||||||
|
<SectionCard icon={<Flag size={15} />} title={`سابقهی گزارشها (${reports.length})`}>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{reports.map((r) => (
|
||||||
|
<div key={r.id} className="flex flex-col gap-0.5">
|
||||||
|
<p className="text-[14px] text-(--color-content-primary)">{r.reason}</p>
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||||
|
{new Date(r.created_at).toLocaleString("fa-IR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionCard icon={<Activity size={15} />} title="فعالیت اخیر">
|
||||||
|
{activity.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-(--color-content-tertiary)">
|
||||||
|
درخواستی از این کاربر در حافظهی اخیر سرور ثبت نشده.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{activity.map((a, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-[12.5px]">
|
||||||
|
<span dir="ltr" className="font-mono text-(--color-content-secondary) truncate">
|
||||||
|
{a.method} {a.path}
|
||||||
|
</span>
|
||||||
|
<span className="text-(--color-content-tertiary) shrink-0 pr-2">
|
||||||
|
{new Date(a.time).toLocaleTimeString("fa-IR")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<RejectModal
|
||||||
|
open={rejectOpen}
|
||||||
|
onClose={() => setRejectOpen(false)}
|
||||||
|
onConfirm={async (reason) => {
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
await rejectUser(id, reason);
|
||||||
|
toast.showSuccess("کاربر رد شد");
|
||||||
|
setRejectOpen(false);
|
||||||
|
router.push("/users");
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={acting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// دلایل رایج رد پروفایل — ادمین تیک میزنه، متن نهایی خودکار برای کاربر ساخته میشه
|
||||||
|
const COMMON_REASONS = [
|
||||||
|
"عکس پروفایل نداره یا واضح نیست",
|
||||||
|
"اطلاعات پروفایل ناقصه",
|
||||||
|
"پروفایل به نظر جعلی یا ربات میاد",
|
||||||
|
"محتوای عکسها نامناسبه",
|
||||||
|
"توی پروفایل شماره تماس یا لینک گذاشته",
|
||||||
|
];
|
||||||
|
|
||||||
|
function RejectModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (reason: string) => void;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [extra, setExtra] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setSelected([]);
|
||||||
|
setExtra("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function toggle(text: string) {
|
||||||
|
setSelected((prev) =>
|
||||||
|
prev.includes(text) ? prev.filter((t) => t !== text) : [...prev, text]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalReason = [...selected, extra.trim()].filter(Boolean).join("؛ ");
|
||||||
|
const valid = finalReason.length >= 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="رد کردن کاربر">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||||
|
دلیل رد (برای کاربر نمایش داده میشه)
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{COMMON_REASONS.map((text) => {
|
||||||
|
const checked = selected.includes(text);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={text}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(text)}
|
||||||
|
className={`flex items-center gap-2.5 rounded-(--radius-sm) border-2 px-3.5 py-2.5 text-right text-[13px] font-medium transition-colors ${
|
||||||
|
checked
|
||||||
|
? "border-(--color-brand-500) bg-(--color-surface-interactive) text-(--color-brand-700)"
|
||||||
|
: "border-(--color-border-default) text-(--color-content-secondary)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-5 w-5 rounded-(--radius-xs) flex items-center justify-center shrink-0 border-2 ${
|
||||||
|
checked
|
||||||
|
? "bg-(--color-brand-500) border-(--color-brand-500)"
|
||||||
|
: "border-(--color-border-default)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{checked && <Check size={13} className="text-white" strokeWidth={3} />}
|
||||||
|
</span>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||||
|
توضیح تکمیلی (اختیاری)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={extra}
|
||||||
|
onChange={(e) => setExtra(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="اگه دلیل دیگهای هست بنویس..."
|
||||||
|
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isLoading={loading}
|
||||||
|
disabled={!valid}
|
||||||
|
className="!bg-(--color-status-error) !shadow-none hover:!bg-red-700"
|
||||||
|
onClick={() => onConfirm(finalReason)}
|
||||||
|
>
|
||||||
|
رد کردن کاربر
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft, ChevronRight, Search, Users as UsersIcon } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { UserModerationActions } from "@/components/UserModerationActions";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { calcAge } from "@/lib/utils";
|
||||||
|
import { AdminUserListItem, listAllUsers } from "@/lib/api";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
const approvalToneMap = {
|
||||||
|
pending: "warning",
|
||||||
|
approved: "success",
|
||||||
|
rejected: "error",
|
||||||
|
} as const;
|
||||||
|
const approvalLabelMap = {
|
||||||
|
pending: "در انتظار",
|
||||||
|
approved: "تاییدشده",
|
||||||
|
rejected: "ردشده",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AllUsersPage() {
|
||||||
|
const [items, setItems] = useState<AdminUserListItem[] | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async (p: number, s: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await listAllUsers(s, p, PAGE_SIZE);
|
||||||
|
setItems(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load(page, search);
|
||||||
|
// search دونهشده جدا (effect پایین) هندل میشه؛ اینجا فقط تغییر page مهمه
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPage(1);
|
||||||
|
load(1, search);
|
||||||
|
}, 350);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-4xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||||
|
همهی کاربران 👥
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
جستوجو بر اساس شماره یا اسم؛ بلاک/آنبلاک و گزارش کاربران از همینجا.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search size={16} className="absolute right-3.5 top-1/2 -translate-y-1/2 text-(--color-content-tertiary)" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="جستوجو با شماره یا اسم..."
|
||||||
|
className="w-full h-11 rounded-(--radius-md) border-2 border-(--color-border-default) bg-(--color-surface-default) pr-10 pl-4 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <ErrorBanner message={error} />}
|
||||||
|
|
||||||
|
{!items ? (
|
||||||
|
<FullScreenLoader />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState icon={<UsersIcon size={32} />} title="کاربری پیدا نشد" description="جستوجوت رو تغییر بده." />
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
{items.map((u, i) => (
|
||||||
|
<div
|
||||||
|
key={u.id}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3.5 ${
|
||||||
|
i !== items.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link href={`/users/${u.id}`} className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Avatar name={u.first_name || u.phone} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold text-[14px] text-(--color-content-primary) truncate">
|
||||||
|
{u.first_name || "بدون نام"}
|
||||||
|
{calcAge(u.birth_date) ? `، ${calcAge(u.birth_date)} سال` : ""}
|
||||||
|
</p>
|
||||||
|
<p dir="ltr" className="text-[12px] text-(--color-content-tertiary) text-right">
|
||||||
|
{u.phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{u.is_onboarded && (
|
||||||
|
<Badge tone={approvalToneMap[u.approval_status]}>
|
||||||
|
{approvalLabelMap[u.approval_status]}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{u.state === "blocked" && <Badge tone="error">بلاک</Badge>}
|
||||||
|
{u.report_count > 0 && (
|
||||||
|
<Badge tone="warning">{u.report_count} گزارش</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserModerationActions
|
||||||
|
userId={u.id}
|
||||||
|
state={u.state}
|
||||||
|
onChanged={() => load(page, search)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||||
|
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} کاربر
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
icon={<ChevronRight size={14} />}
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
قبلی
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
icon={<ChevronLeft size={14} />}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronLeft, UserCheck } from "lucide-react";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { calcAge } from "@/lib/utils";
|
||||||
|
import { PendingUserView, listPendingUsers, photoUrl } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function PendingUsersPage() {
|
||||||
|
const [users, setUsers] = useState<PendingUserView[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setUsers(await listPendingUsers());
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (error && !users) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!users) return <FullScreenLoader />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 max-w-3xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||||
|
کاربران در انتظار تایید ✅
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||||
|
این کاربرها آنبوردینگ رو کامل کردن و منتظرن پروفایلشون بررسی بشه. قدیمیترین
|
||||||
|
درخواست بالاتره.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
icon={<UserCheck size={32} />}
|
||||||
|
title="کسی منتظر بررسی نیست"
|
||||||
|
description="هر وقت کاربر جدیدی آنبوردینگ رو تموم کنه، اینجا برای بررسی نشون داده میشه."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card padded={false} className="overflow-hidden">
|
||||||
|
{users.map((u, i) => (
|
||||||
|
<Link
|
||||||
|
key={u.id}
|
||||||
|
href={`/users/${u.id}`}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3.5 hover:bg-(--color-bg-secondary) transition-colors ${
|
||||||
|
i !== users.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.photos.length > 0 ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={photoUrl(u.photos[0])}
|
||||||
|
alt=""
|
||||||
|
className="w-11 h-11 rounded-(--radius-md) object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Avatar name={u.first_name || u.phone} />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-bold text-[14px] text-(--color-content-primary) truncate">
|
||||||
|
{u.first_name || "بدون نام"}
|
||||||
|
{calcAge(u.birth_date) ? `، ${calcAge(u.birth_date)} سال` : ""}
|
||||||
|
</p>
|
||||||
|
<p dir="ltr" className="text-[12px] text-(--color-content-tertiary) text-right">
|
||||||
|
{u.phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{u.photos.length === 0 && (
|
||||||
|
<Badge tone="warning" className="shrink-0">
|
||||||
|
بدون عکس
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{u.gender && (
|
||||||
|
<Badge tone="neutral" className="shrink-0">
|
||||||
|
{u.gender === "male" ? "مرد" : "زن"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<ChevronLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,188 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
BASIS DESIGN SYSTEM — نسخهی شاد، گرد و گوگولیمگولیِ Atlas
|
||||||
|
همون اصول ساختاری (نقشمحور بودن توکنها، مقیاس فاصلهگذاری،
|
||||||
|
سلسلهمراتب تایپوگرافی) حفظ شده؛ چیزی که عوض شده «شخصیت»
|
||||||
|
بصریه: گوشههای گرد بهجای تیز، قرمز پرانرژیتر، سایههای
|
||||||
|
نرم رنگی بهجای بردر مسطح.
|
||||||
|
============================================================ */
|
||||||
|
:root {
|
||||||
|
/* -------- brand / accent -------- */
|
||||||
|
--color-brand-50: #fff1ee;
|
||||||
|
--color-brand-100: #ffe1da;
|
||||||
|
--color-brand-200: #ffc2b3;
|
||||||
|
--color-brand-300: #ff9c85;
|
||||||
|
--color-brand-400: #ff6f56;
|
||||||
|
--color-brand-500: #ff4433; /* قرمز شاد اصلی */
|
||||||
|
--color-brand-600: #f22c1f;
|
||||||
|
--color-brand-700: #d6210f;
|
||||||
|
--color-brand-800: #a91a0d;
|
||||||
|
--color-brand-900: #7c1409;
|
||||||
|
--color-brand-onPrimary: #fffaf8;
|
||||||
|
|
||||||
|
/* -------- accent دوم برای شادی بیشتر (نقطهها/برچسبها) -------- */
|
||||||
|
--color-pop-yellow: #ffc93c;
|
||||||
|
--color-pop-pink: #ff8fb1;
|
||||||
|
--color-pop-mint: #3ddc97;
|
||||||
|
--color-pop-sky: #4fb4ff;
|
||||||
|
|
||||||
|
/* -------- neutral warm (پسزمینهی گرم، نه خاکستری سرد) -------- */
|
||||||
|
--color-bg-primary: #fff8f5;
|
||||||
|
--color-bg-secondary: #fff1ec;
|
||||||
|
--color-bg-tertiary: #ffe4da;
|
||||||
|
|
||||||
|
--color-surface-default: #ffffff;
|
||||||
|
--color-surface-elevated: #ffffff;
|
||||||
|
--color-surface-interactive: #fff1ec;
|
||||||
|
|
||||||
|
--color-content-primary: #2b1a15;
|
||||||
|
--color-content-secondary: #7a5c52;
|
||||||
|
--color-content-tertiary: #a68c82;
|
||||||
|
--color-content-disabled: #d8c6bf;
|
||||||
|
--color-content-onColor: #fffaf8;
|
||||||
|
|
||||||
|
--color-border-subtle: #ffe4da;
|
||||||
|
--color-border-default: #ffd0c0;
|
||||||
|
--color-border-interactive: var(--color-brand-500);
|
||||||
|
|
||||||
|
--color-status-success: #21a179;
|
||||||
|
--color-status-warning: #d98a12;
|
||||||
|
--color-status-error: #e0301e;
|
||||||
|
--color-status-info: #3d8fd6;
|
||||||
|
|
||||||
|
/* -------- radius: همهچیز گرد و نرم -------- */
|
||||||
|
--radius-xs: 10px;
|
||||||
|
--radius-sm: 14px;
|
||||||
|
--radius-md: 20px;
|
||||||
|
--radius-lg: 28px;
|
||||||
|
--radius-xl: 36px;
|
||||||
|
--radius-full: 999px;
|
||||||
|
|
||||||
|
/* -------- سایههای نرم و رنگی (بهجای بردر مسطح Atlas) -------- */
|
||||||
|
--shadow-cute-sm: 0 2px 8px -2px rgba(255, 68, 51, 0.16), 0 1px 2px rgba(43, 26, 21, 0.06);
|
||||||
|
--shadow-cute-md: 0 10px 24px -8px rgba(255, 68, 51, 0.22), 0 2px 6px rgba(43, 26, 21, 0.06);
|
||||||
|
--shadow-cute-lg: 0 20px 45px -12px rgba(255, 68, 51, 0.28), 0 4px 10px rgba(43, 26, 21, 0.08);
|
||||||
|
--shadow-pop-yellow: 0 8px 20px -6px rgba(255, 201, 60, 0.45);
|
||||||
|
--shadow-pop-mint: 0 8px 20px -6px rgba(61, 220, 151, 0.4);
|
||||||
|
|
||||||
|
--background: var(--color-bg-primary);
|
||||||
|
--foreground: var(--color-content-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
|
||||||
|
--color-brand-50: var(--color-brand-50);
|
||||||
|
--color-brand-100: var(--color-brand-100);
|
||||||
|
--color-brand-200: var(--color-brand-200);
|
||||||
|
--color-brand-300: var(--color-brand-300);
|
||||||
|
--color-brand-400: var(--color-brand-400);
|
||||||
|
--color-brand-500: var(--color-brand-500);
|
||||||
|
--color-brand-600: var(--color-brand-600);
|
||||||
|
--color-brand-700: var(--color-brand-700);
|
||||||
|
--color-brand-800: var(--color-brand-800);
|
||||||
|
--color-brand-900: var(--color-brand-900);
|
||||||
|
|
||||||
|
--color-pop-yellow: var(--color-pop-yellow);
|
||||||
|
--color-pop-pink: var(--color-pop-pink);
|
||||||
|
--color-pop-mint: var(--color-pop-mint);
|
||||||
|
--color-pop-sky: var(--color-pop-sky);
|
||||||
|
|
||||||
|
--color-bg-primary: var(--color-bg-primary);
|
||||||
|
--color-bg-secondary: var(--color-bg-secondary);
|
||||||
|
--color-bg-tertiary: var(--color-bg-tertiary);
|
||||||
|
|
||||||
|
--color-surface-default: var(--color-surface-default);
|
||||||
|
--color-surface-elevated: var(--color-surface-elevated);
|
||||||
|
--color-surface-interactive: var(--color-surface-interactive);
|
||||||
|
|
||||||
|
--color-content-primary: var(--color-content-primary);
|
||||||
|
--color-content-secondary: var(--color-content-secondary);
|
||||||
|
--color-content-tertiary: var(--color-content-tertiary);
|
||||||
|
--color-content-disabled: var(--color-content-disabled);
|
||||||
|
--color-content-onColor: var(--color-content-onColor);
|
||||||
|
|
||||||
|
--color-border-subtle: var(--color-border-subtle);
|
||||||
|
--color-border-default: var(--color-border-default);
|
||||||
|
--color-border-interactive: var(--color-border-interactive);
|
||||||
|
|
||||||
|
--color-status-success: var(--color-status-success);
|
||||||
|
--color-status-warning: var(--color-status-warning);
|
||||||
|
--color-status-error: var(--color-status-error);
|
||||||
|
--color-status-info: var(--color-status-info);
|
||||||
|
|
||||||
|
--radius-xs: var(--radius-xs);
|
||||||
|
--radius-sm: var(--radius-sm);
|
||||||
|
--radius-md: var(--radius-md);
|
||||||
|
--radius-lg: var(--radius-lg);
|
||||||
|
--radius-xl: var(--radius-xl);
|
||||||
|
--radius-full: var(--radius-full);
|
||||||
|
|
||||||
|
--shadow-cute-sm: var(--shadow-cute-sm);
|
||||||
|
--shadow-cute-md: var(--shadow-cute-md);
|
||||||
|
--shadow-cute-lg: var(--shadow-cute-lg);
|
||||||
|
--shadow-pop-yellow: var(--shadow-pop-yellow);
|
||||||
|
--shadow-pop-mint: var(--shadow-pop-mint);
|
||||||
|
|
||||||
|
--font-sans: "Vazirmatn Variable", "Baloo 2 Variable", system-ui, sans-serif;
|
||||||
|
--font-display: "Baloo 2 Variable", "Vazirmatn Variable", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--color-brand-200);
|
||||||
|
color: var(--color-brand-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-brand-200);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce-in {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.9) translateY(8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: scale(1.02) translateY(-2px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wiggle {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(2deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "@fontsource-variable/vazirmatn";
|
||||||
|
import "@fontsource-variable/baloo-2";
|
||||||
|
import "./globals.css";
|
||||||
|
import { AuthProvider } from "@/lib/auth-context";
|
||||||
|
import { ToastProvider } from "@/components/ui/Toast";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "پنل ادمین دلدل",
|
||||||
|
description: "مدیریت گزینهها، دستهبندیها و سوالات پروفایل اپ دلدل",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
return (
|
||||||
|
<html lang="fa" dir="rtl" className="h-full antialiased">
|
||||||
|
<body className="min-h-dvh bg-(--color-bg-secondary) text-(--color-content-primary)">
|
||||||
|
<AuthProvider>
|
||||||
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { TextField } from "@/components/ui/TextField";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { requestOtp } from "@/lib/api";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
|
||||||
|
const PHONE_REGEX = /^09\d{9}$/;
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { status } = useAuth();
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "authenticated") router.replace("/");
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
|
const isValid = PHONE_REGEX.test(phone);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!isValid) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await requestOtp(phone);
|
||||||
|
router.push(`/verify?phone=${phone}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh flex items-center justify-center px-4">
|
||||||
|
<Card className="w-full max-w-sm flex flex-col items-center gap-6 !p-8">
|
||||||
|
<div className="w-16 h-16 rounded-(--radius-lg) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-md)">
|
||||||
|
<ShieldCheck size={28} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[22px]">
|
||||||
|
پنل ادمین دلدل
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px] text-(--color-content-secondary) mt-1">
|
||||||
|
فقط برای مدیران سیستم
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col gap-4">
|
||||||
|
<TextField
|
||||||
|
label="شماره موبایل ادمین"
|
||||||
|
type="tel"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="09xxxxxxxxx"
|
||||||
|
dir="ltr"
|
||||||
|
className="text-center tracking-widest"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value.replace(/\D/g, "").slice(0, 11))}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||||
|
/>
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||||
|
دریافت کد تایید
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { ChevronRight, ShieldAlert } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { OtpInput } from "@/components/ui/OtpInput";
|
||||||
|
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||||
|
import { Card } from "@/components/ui/Card";
|
||||||
|
import { requestOtp } from "@/lib/api";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
|
||||||
|
const RESEND_SECONDS = 60;
|
||||||
|
|
||||||
|
function VerifyForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const phone = searchParams.get("phone") ?? "";
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
const [otp, setOtp] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [countdown, setCountdown] = useState(RESEND_SECONDS);
|
||||||
|
const [resending, setResending] = useState(false);
|
||||||
|
const [notAdmin, setNotAdmin] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown <= 0) return;
|
||||||
|
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!phone) router.replace("/login");
|
||||||
|
}, [phone, router]);
|
||||||
|
|
||||||
|
async function handleVerify(code: string) {
|
||||||
|
if (code.length !== 4) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setNotAdmin(false);
|
||||||
|
try {
|
||||||
|
await login(phone, code);
|
||||||
|
router.push("/");
|
||||||
|
} catch (err) {
|
||||||
|
const msg = formatApiError(err, "کد وارد شده اشتباهه");
|
||||||
|
if (msg.includes("ادمین")) {
|
||||||
|
setNotAdmin(true);
|
||||||
|
} else {
|
||||||
|
setError(msg);
|
||||||
|
setOtp("");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
setResending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await requestOtp(phone);
|
||||||
|
setCountdown(RESEND_SECONDS);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setResending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh flex items-center justify-center px-4">
|
||||||
|
<Card className="w-full max-w-sm flex flex-col items-center gap-4 !p-8 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-(--radius-lg) bg-red-50 flex items-center justify-center">
|
||||||
|
<ShieldAlert size={26} className="text-(--color-status-error)" />
|
||||||
|
</div>
|
||||||
|
<h2 className="font-[var(--font-display)] font-extrabold text-[18px]">
|
||||||
|
دسترسی نداری
|
||||||
|
</h2>
|
||||||
|
<p className="text-[13px] text-(--color-content-secondary) leading-6">
|
||||||
|
این شماره حساب ادمین نیست. اگه فکر میکنی اشتباهه، با یکی از ادمینهای
|
||||||
|
فعلی سیستم تماس بگیر.
|
||||||
|
</p>
|
||||||
|
<Button variant="tertiary" onClick={() => router.push("/login")}>
|
||||||
|
برگشت به صفحهی ورود
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh flex items-center justify-center px-4">
|
||||||
|
<Card className="w-full max-w-sm flex flex-col gap-6 !p-8">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/login")}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) self-start -mb-2"
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} className="text-(--color-content-secondary)" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">
|
||||||
|
کد تایید رو وارد کن
|
||||||
|
</h1>
|
||||||
|
<p className="text-[13px] text-(--color-content-secondary) mt-1">
|
||||||
|
ارسالشده به <span dir="ltr" className="font-bold">{phone}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OtpInput value={otp} onChange={setOtp} onComplete={handleVerify} disabled={loading} />
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
isBlock
|
||||||
|
isLoading={loading}
|
||||||
|
disabled={otp.length !== 4}
|
||||||
|
onClick={() => handleVerify(otp)}
|
||||||
|
>
|
||||||
|
تایید و ورود
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-1.5 text-[13px]">
|
||||||
|
<span className="text-(--color-content-tertiary)">کد نیومد؟</span>
|
||||||
|
{countdown > 0 ? (
|
||||||
|
<span className="font-bold text-(--color-content-secondary)" dir="ltr">
|
||||||
|
{countdown}s
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={resending}
|
||||||
|
className="font-bold text-(--color-brand-600) hover:text-(--color-brand-700) disabled:opacity-50"
|
||||||
|
>
|
||||||
|
دوباره بفرست
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerifyPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<VerifyForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
LayoutGrid,
|
||||||
|
ListTree,
|
||||||
|
MessageCircleQuestion,
|
||||||
|
LogOut,
|
||||||
|
Heart,
|
||||||
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
HeartHandshake,
|
||||||
|
Images,
|
||||||
|
Activity,
|
||||||
|
Database,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/", label: "داشبورد", icon: LayoutGrid },
|
||||||
|
{ href: "/users", label: "کاربران در انتظار تایید", icon: UserCheck },
|
||||||
|
{ href: "/users/all", label: "همه کاربران", icon: Users },
|
||||||
|
{ href: "/matches", label: "رفقا (مچها)", icon: HeartHandshake },
|
||||||
|
{ href: "/photos", label: "نظارت بر عکسها", icon: Images },
|
||||||
|
{ href: "/categories", label: "دستهبندیها و گزینهها", icon: ListTree },
|
||||||
|
{ href: "/questions", label: "سوالات پروفایل", icon: MessageCircleQuestion },
|
||||||
|
{ href: "/monitoring", label: "مانیتورینگ سرور", icon: Activity },
|
||||||
|
{ href: "/backups", label: "بکآپ دیتابیس", icon: Database },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 shrink-0 h-dvh sticky top-0 flex flex-col bg-(--color-surface-default) border-l border-(--color-border-subtle)">
|
||||||
|
<div className="flex items-center gap-2.5 px-5 h-16 border-b border-(--color-border-subtle)">
|
||||||
|
<div className="w-8 h-8 rounded-(--radius-sm) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-sm)">
|
||||||
|
<Heart size={15} className="text-white" fill="white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-[var(--font-display)] font-extrabold text-[14px] leading-none">
|
||||||
|
دلدل
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-(--color-content-tertiary) leading-none mt-0.5">
|
||||||
|
پنل ادمین
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 flex flex-col gap-1 p-3">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const active = pathname === item.href;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-(--radius-sm) px-3.5 h-11 text-[14px] font-semibold transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-(--color-brand-500) text-white shadow-(--shadow-cute-sm)"
|
||||||
|
: "text-(--color-content-secondary) hover:bg-(--color-bg-secondary)"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={17} />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-3 border-t border-(--color-border-subtle) flex flex-col gap-2">
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
<p className="text-[11px] text-(--color-content-tertiary)">وارد شده بهعنوان</p>
|
||||||
|
<p dir="ltr" className="text-[13px] font-bold text-(--color-content-primary) text-right">
|
||||||
|
{user?.phone}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-3 rounded-(--radius-sm) px-3.5 h-10 text-[13px] font-semibold text-(--color-status-error) hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
خروج از حساب
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ShieldAlert, ShieldCheck, Flag } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { formatApiError } from "@/lib/hooks";
|
||||||
|
import { blockUser, unblockUser, reportUser, UserState } from "@/lib/api";
|
||||||
|
|
||||||
|
// دکمههای بلاک/آنبلاک/گزارش یک کاربر؛ هم توی لیست همهی کاربران و هم توی
|
||||||
|
// صفحهی جزئیات یک کاربر استفاده میشه.
|
||||||
|
export function UserModerationActions({
|
||||||
|
userId,
|
||||||
|
state,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
state: UserState;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [blockOpen, setBlockOpen] = useState(false);
|
||||||
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
|
const [blockReason, setBlockReason] = useState("");
|
||||||
|
const [reportReason, setReportReason] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleBlock() {
|
||||||
|
if (blockReason.trim().length < 3) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await blockUser(userId, blockReason.trim());
|
||||||
|
toast.showSuccess("کاربر بلاک شد");
|
||||||
|
setBlockOpen(false);
|
||||||
|
setBlockReason("");
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnblock() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await unblockUser(userId);
|
||||||
|
toast.showSuccess("کاربر آنبلاک شد");
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReport() {
|
||||||
|
if (reportReason.trim().length < 3) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await reportUser(userId, reportReason.trim());
|
||||||
|
toast.showSuccess("گزارش ثبت شد");
|
||||||
|
setReportOpen(false);
|
||||||
|
setReportReason("");
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
toast.showError(formatApiError(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{state === "blocked" ? (
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
icon={<ShieldCheck size={14} />}
|
||||||
|
isLoading={loading}
|
||||||
|
onClick={handleUnblock}
|
||||||
|
>
|
||||||
|
آنبلاک
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
icon={<ShieldAlert size={14} />}
|
||||||
|
className="!text-(--color-status-error) !border-red-200"
|
||||||
|
onClick={() => setBlockOpen(true)}
|
||||||
|
>
|
||||||
|
بلاک
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" icon={<Flag size={14} />} onClick={() => setReportOpen(true)}>
|
||||||
|
گزارش
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal open={blockOpen} onClose={() => setBlockOpen(false)} title="بلاک کردن کاربر">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-[13px] text-(--color-content-secondary)">
|
||||||
|
کاربر بلاکشده دیگه نمیتونه وارد اپ بشه (حتی با توکن معتبر فعلیش).
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={blockReason}
|
||||||
|
onChange={(e) => setBlockReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="دلیل بلاک کردن..."
|
||||||
|
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setBlockOpen(false)}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isLoading={loading}
|
||||||
|
disabled={blockReason.trim().length < 3}
|
||||||
|
className="!bg-(--color-status-error) !shadow-none hover:!bg-red-700"
|
||||||
|
onClick={handleBlock}
|
||||||
|
>
|
||||||
|
بلاک کن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={reportOpen} onClose={() => setReportOpen(false)} title="ثبت گزارش روی کاربر">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-[13px] text-(--color-content-secondary)">
|
||||||
|
یک یادداشت نظارتی برای این کاربر ثبت میشه (مستقل از بلاک کردن)؛ برای پیگیری
|
||||||
|
سابقهی شکایتها.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={reportReason}
|
||||||
|
onChange={(e) => setReportReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="دلیل گزارش..."
|
||||||
|
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setReportOpen(false)}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isLoading={loading}
|
||||||
|
disabled={reportReason.trim().length < 3}
|
||||||
|
onClick={handleReport}
|
||||||
|
>
|
||||||
|
ثبت گزارش
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export type ButtonVariant = "primary" | "secondary" | "tertiary" | "ghost";
|
||||||
|
export type ButtonSize = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isBlock?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses: Record<ButtonSize, string> = {
|
||||||
|
sm: "h-9 px-4 text-[13px] gap-1.5",
|
||||||
|
md: "h-11 px-5 text-[15px] gap-2",
|
||||||
|
lg: "h-13 px-6 text-[16px] gap-2.5",
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClasses: Record<ButtonVariant, string> = {
|
||||||
|
primary:
|
||||||
|
"bg-(--color-brand-500) text-(--color-brand-onPrimary) shadow-(--shadow-cute-md) hover:bg-(--color-brand-600) hover:-translate-y-0.5 hover:shadow-(--shadow-cute-lg) active:translate-y-0 active:bg-(--color-brand-700)",
|
||||||
|
secondary:
|
||||||
|
"bg-(--color-brand-100) text-(--color-brand-700) hover:bg-(--color-brand-200) active:bg-(--color-brand-300)",
|
||||||
|
tertiary:
|
||||||
|
"bg-(--color-surface-default) text-(--color-content-primary) border-2 border-(--color-border-default) hover:border-(--color-brand-300) hover:text-(--color-brand-600) active:bg-(--color-bg-secondary)",
|
||||||
|
ghost:
|
||||||
|
"bg-transparent text-(--color-content-secondary) hover:bg-(--color-bg-secondary) hover:text-(--color-brand-600)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
isLoading = false,
|
||||||
|
isBlock = false,
|
||||||
|
icon,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded-(--radius-md) font-[var(--font-sans)] font-semibold",
|
||||||
|
"transition-all duration-150 ease-out select-none cursor-pointer",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-(--color-brand-200)",
|
||||||
|
"disabled:opacity-45 disabled:pointer-events-none disabled:translate-y-0 disabled:shadow-none",
|
||||||
|
sizeClasses[size],
|
||||||
|
variantClasses[variant],
|
||||||
|
isBlock && "w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="animate-spin" size={18} />
|
||||||
|
) : (
|
||||||
|
icon
|
||||||
|
)}
|
||||||
|
<span>{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = "حذف کن",
|
||||||
|
loading,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onCancel} title={title}>
|
||||||
|
<p className="text-[14px] text-(--color-content-secondary) leading-6 mb-6">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isLoading={loading}
|
||||||
|
className="!bg-(--color-status-error) !shadow-none hover:!bg-red-700"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-(--radius-xl) bg-(--color-surface-default) shadow-(--shadow-cute-lg) [animation:bounce-in_0.25s_ease-out] max-h-[85vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 pt-5 pb-3 sticky top-0 bg-(--color-surface-default)">
|
||||||
|
<h3 className="font-[var(--font-display)] font-extrabold text-[18px]">{title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pb-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
tone: "success" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
showSuccess: (message: string) => void;
|
||||||
|
showError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
let idCounter = 0;
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const push = useCallback((message: string, tone: Toast["tone"]) => {
|
||||||
|
const id = ++idCounter;
|
||||||
|
setToasts((prev) => [...prev, { id, message, tone }]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, 3500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSuccess = useCallback((m: string) => push(m, "success"), [push]);
|
||||||
|
const showError = useCallback((m: string) => push(m, "error"), [push]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ showSuccess, showError }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 items-center">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`flex items-center gap-2 rounded-(--radius-md) px-4 py-3 shadow-(--shadow-cute-lg) text-[13px] font-semibold text-white [animation:bounce-in_0.3s_ease-out] ${
|
||||||
|
t.tone === "success" ? "bg-(--color-status-success)" : "bg-(--color-status-error)"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.tone === "success" ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
||||||
|
{t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error("useToast باید داخل ToastProvider استفاده بشه");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import { tokenStore } from "./token-store";
|
||||||
|
|
||||||
|
export const API_BASE =
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
|
||||||
|
|
||||||
|
// عکسها از مسیر نسبی (/uploads/...) برمیگردن، نه URL کامل؛ باید به origin
|
||||||
|
// سرور (بدون /api/v1) وصل بشن.
|
||||||
|
export const API_ORIGIN = API_BASE.replace(/\/api\/v1\/?$/, "");
|
||||||
|
export function photoUrl(path: string) {
|
||||||
|
if (!path) return "";
|
||||||
|
if (path.startsWith("http")) return path;
|
||||||
|
return `${API_ORIGIN}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiEnvelope<T> {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: T;
|
||||||
|
errors?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
errors?: unknown;
|
||||||
|
status: number;
|
||||||
|
constructor(message: string, status: number, errors?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- انواع ----------------
|
||||||
|
|
||||||
|
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
export type UserState = "inactive" | "active" | "blocked";
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
first_name?: string;
|
||||||
|
gender?: "male" | "female";
|
||||||
|
birth_date?: string;
|
||||||
|
height_cm?: number;
|
||||||
|
weight_kg?: number;
|
||||||
|
current_step: number;
|
||||||
|
is_onboarded: boolean;
|
||||||
|
approval_status: ApprovalStatus;
|
||||||
|
rejection_reason?: string;
|
||||||
|
is_admin: boolean;
|
||||||
|
state: UserState;
|
||||||
|
blocked_reason?: string;
|
||||||
|
blocked_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepView {
|
||||||
|
step: number;
|
||||||
|
key: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingStatus {
|
||||||
|
current_step: number;
|
||||||
|
current_step_key: string;
|
||||||
|
is_onboarded: boolean;
|
||||||
|
approval_status: ApprovalStatus;
|
||||||
|
rejection_reason?: string;
|
||||||
|
steps: StepView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionSummary {
|
||||||
|
category_key: string;
|
||||||
|
option_ids: number[];
|
||||||
|
titles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileAnswer {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
question_id: number;
|
||||||
|
answer: string;
|
||||||
|
question?: ProfileQuestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserPhoto {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
url: string;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullProfile {
|
||||||
|
user: UserProfile;
|
||||||
|
onboarding_status: OnboardingStatus;
|
||||||
|
selections: SelectionSummary[];
|
||||||
|
profile_answers: ProfileAnswer[];
|
||||||
|
photos: UserPhoto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenPair {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponseData {
|
||||||
|
tokens: TokenPair;
|
||||||
|
user: UserProfile;
|
||||||
|
is_new_user: boolean;
|
||||||
|
onboarding_done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CategoryKey =
|
||||||
|
| "education_level"
|
||||||
|
| "field_of_study"
|
||||||
|
| "occupation"
|
||||||
|
| "important_in_relationship"
|
||||||
|
| "interest"
|
||||||
|
| "looking_for";
|
||||||
|
|
||||||
|
export const ALL_CATEGORIES: { key: CategoryKey; fallbackTitle: string; emoji: string }[] = [
|
||||||
|
{ key: "education_level", fallbackTitle: "سطح تحصیلات", emoji: "🎓" },
|
||||||
|
{ key: "field_of_study", fallbackTitle: "رشته تحصیلی", emoji: "📚" },
|
||||||
|
{ key: "occupation", fallbackTitle: "فعالیتها", emoji: "💼" },
|
||||||
|
{ key: "important_in_relationship", fallbackTitle: "اهمیت در رابطه", emoji: "❤️" },
|
||||||
|
{ key: "interest", fallbackTitle: "علاقهمندیها", emoji: "✨" },
|
||||||
|
{ key: "looking_for", fallbackTitle: "دنبال چی میگردی", emoji: "🔍" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
key: CategoryKey;
|
||||||
|
title: string;
|
||||||
|
min_select: number;
|
||||||
|
max_select: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
|
id: number;
|
||||||
|
category_id: number;
|
||||||
|
title: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileQuestion {
|
||||||
|
id: number;
|
||||||
|
text: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- هستهی fetch ----------------
|
||||||
|
|
||||||
|
let refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
async function rawRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
auth = false
|
||||||
|
): Promise<T> {
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
if (auth) {
|
||||||
|
const token = tokenStore.getAccess();
|
||||||
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
let body: ApiEnvelope<T> | null = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
// بدنهی خالی
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(body?.message ?? "خطای غیرمنتظره از سرور", res.status, body?.errors);
|
||||||
|
}
|
||||||
|
return (body?.data as T) ?? (undefined as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await rawRequest<T>(path, options, true);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
|
const refreshed = await ensureRefreshed();
|
||||||
|
if (refreshed) return await rawRequest<T>(path, options, true);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureRefreshed(): Promise<boolean> {
|
||||||
|
const refresh = tokenStore.getRefresh();
|
||||||
|
if (!refresh) return false;
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = rawRequest<TokenPair>("/auth/token/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ refresh_token: refresh }),
|
||||||
|
})
|
||||||
|
.then((tokens) => {
|
||||||
|
tokenStore.set(tokens.access_token, tokens.refresh_token);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
tokenStore.clear();
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- احراز هویت ----------------
|
||||||
|
|
||||||
|
export function requestOtp(phone: string) {
|
||||||
|
return rawRequest<null>("/auth/request-otp", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ phone }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyOtp(phone: string, otp: string) {
|
||||||
|
return rawRequest<AuthResponseData>("/auth/verify-otp", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ phone, otp }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfileMe() {
|
||||||
|
return authRequest<{ user: UserProfile }>("/profile/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ادمین: دستهها و گزینهها ----------------
|
||||||
|
|
||||||
|
export function getCategory(key: CategoryKey) {
|
||||||
|
return authRequest<Category>(`/admin/categories/${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCategory(
|
||||||
|
key: CategoryKey,
|
||||||
|
payload: { title?: string; min_select?: number; max_select?: number }
|
||||||
|
) {
|
||||||
|
return authRequest<Category>(`/admin/categories/${key}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listOptions(key: CategoryKey) {
|
||||||
|
return authRequest<Option[]>(`/admin/categories/${key}/options`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOption(key: CategoryKey, payload: { title: string; sort_order?: number }) {
|
||||||
|
return authRequest<Option>(`/admin/categories/${key}/options`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOption(
|
||||||
|
id: number,
|
||||||
|
payload: { title?: string; sort_order?: number; is_active?: boolean }
|
||||||
|
) {
|
||||||
|
return authRequest<Option>(`/admin/options/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOption(id: number) {
|
||||||
|
return authRequest<null>(`/admin/options/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ادمین: سوالات پروفایل ----------------
|
||||||
|
|
||||||
|
export function listQuestions() {
|
||||||
|
return authRequest<ProfileQuestion[]>("/admin/profile-questions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createQuestion(payload: { text: string; sort_order?: number }) {
|
||||||
|
return authRequest<ProfileQuestion>("/admin/profile-questions", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateQuestion(
|
||||||
|
id: number,
|
||||||
|
payload: { text?: string; sort_order?: number; is_active?: boolean }
|
||||||
|
) {
|
||||||
|
return authRequest<ProfileQuestion>(`/admin/profile-questions/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteQuestion(id: number) {
|
||||||
|
return authRequest<null>(`/admin/profile-questions/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ادمین: بررسی و تایید کاربران ----------------
|
||||||
|
|
||||||
|
export interface PendingUserView extends UserProfile {
|
||||||
|
photos: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPendingUsers() {
|
||||||
|
return authRequest<PendingUserView[]>("/admin/users/pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserDetail(id: number) {
|
||||||
|
return authRequest<FullProfile>(`/admin/users/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveUser(id: number) {
|
||||||
|
return authRequest<UserProfile>(`/admin/users/${id}/approve`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectUser(id: number, reason: string) {
|
||||||
|
return authRequest<UserProfile>(`/admin/users/${id}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ادمین: مانیتورینگ ----------------
|
||||||
|
|
||||||
|
export interface MonitoringSummary {
|
||||||
|
total_requests: number;
|
||||||
|
total_errors: number;
|
||||||
|
error_rate_pct: number;
|
||||||
|
avg_latency_ms: number;
|
||||||
|
p95_latency_ms: number;
|
||||||
|
requests_last_min: number;
|
||||||
|
started_at: string;
|
||||||
|
uptime_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestLogEntry {
|
||||||
|
time: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
status_code: number;
|
||||||
|
duration_ms: number;
|
||||||
|
client_ip: string;
|
||||||
|
user_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonitoringSummary() {
|
||||||
|
return authRequest<MonitoringSummary>("/admin/monitoring/summary");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentRequests(limit = 100, userId?: number) {
|
||||||
|
const userParam = userId ? `&user_id=${userId}` : "";
|
||||||
|
return authRequest<RequestLogEntry[]>(`/admin/monitoring/requests?limit=${limit}${userParam}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ادمین: همهی کاربران، بلاک/آنبلاک، گزارش ----------------
|
||||||
|
|
||||||
|
export interface AdminUserListItem extends UserProfile {
|
||||||
|
report_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginatedResult<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAllUsers(search: string, page = 1, pageSize = 25) {
|
||||||
|
const searchParam = search ? `&search=${encodeURIComponent(search)}` : "";
|
||||||
|
return authRequest<PaginatedResult<AdminUserListItem>>(
|
||||||
|
`/admin/users?page=${page}&page_size=${pageSize}${searchParam}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockUser(id: number, reason: string) {
|
||||||
|
return authRequest<UserProfile>(`/admin/users/${id}/block`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unblockUser(id: number) {
|
||||||
|
return authRequest<UserProfile>(`/admin/users/${id}/unblock`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Report {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
reason: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportUser(id: number, reason: string) {
|
||||||
|
return authRequest<Report>(`/admin/users/${id}/report`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listUserReports(id: number) {
|
||||||
|
return authRequest<Report[]>(`/admin/users/${id}/reports`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ادمین: مچها (رفقا) ----------------
|
||||||
|
|
||||||
|
export interface AdminMatchView {
|
||||||
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
user_one_id: number;
|
||||||
|
user_one_name: string;
|
||||||
|
user_one_phone: string;
|
||||||
|
user_two_id: number;
|
||||||
|
user_two_name: string;
|
||||||
|
user_two_phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAllMatches(page = 1, pageSize = 25) {
|
||||||
|
return authRequest<PaginatedResult<AdminMatchView>>(
|
||||||
|
`/admin/matches?page=${page}&page_size=${pageSize}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ادمین: نظارت بر عکسها ----------------
|
||||||
|
|
||||||
|
export interface AdminPhotoView {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
url: string;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
owner_name: string;
|
||||||
|
owner_phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAllPhotos(page = 1, pageSize = 50) {
|
||||||
|
return authRequest<PaginatedResult<AdminPhotoView>>(
|
||||||
|
`/admin/photos?page=${page}&page_size=${pageSize}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePhotoAdmin(id: number) {
|
||||||
|
return authRequest<null>(`/admin/photos/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportPhoto(id: number, reason: string) {
|
||||||
|
return authRequest<Report>(`/admin/photos/${id}/report`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ادمین: بکآپ دیتابیس ----------------
|
||||||
|
|
||||||
|
export interface BackupInfo {
|
||||||
|
filename: string;
|
||||||
|
size_bytes: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBackups() {
|
||||||
|
return authRequest<BackupInfo[]>("/admin/backups");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackup() {
|
||||||
|
return authRequest<BackupInfo>("/admin/backups", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// دانلود بکآپ نیاز به هدر Authorization داره (اندپوینت فقط-ادمینه)، پس نمیشه
|
||||||
|
// مستقیم از یک <a href> استفاده کرد؛ فایل رو با fetch احرازهویتشده میگیریم و
|
||||||
|
// خودمون دانلودش رو با یک لینک موقت شبیهسازی میکنیم.
|
||||||
|
export async function downloadBackup(filename: string) {
|
||||||
|
const token = tokenStore.getAccess();
|
||||||
|
const res = await fetch(`${API_BASE}/admin/backups/${encodeURIComponent(filename)}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError("دانلود بکآپ ناموفق بود", res.status);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { tokenStore } from "./token-store";
|
||||||
|
import { UserProfile, verifyOtp as apiVerifyOtp, getProfileMe, ApiError } from "./api";
|
||||||
|
|
||||||
|
type AuthStatus = "loading" | "guest" | "not-admin" | "authenticated";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
status: AuthStatus;
|
||||||
|
user: UserProfile | null;
|
||||||
|
login: (phone: string, otp: string) => Promise<UserProfile>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null);
|
||||||
|
|
||||||
|
const bootstrap = useCallback(async () => {
|
||||||
|
const access = tokenStore.getAccess();
|
||||||
|
if (!access) {
|
||||||
|
setStatus("guest");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const profile = await getProfileMe();
|
||||||
|
if (!profile.user.is_admin) {
|
||||||
|
setUser(profile.user);
|
||||||
|
setStatus("not-admin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUser(profile.user);
|
||||||
|
setStatus("authenticated");
|
||||||
|
} catch {
|
||||||
|
tokenStore.clear();
|
||||||
|
setStatus("guest");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
bootstrap();
|
||||||
|
}, [bootstrap]);
|
||||||
|
|
||||||
|
const login = useCallback(async (phone: string, otp: string) => {
|
||||||
|
const data = await apiVerifyOtp(phone, otp);
|
||||||
|
tokenStore.set(data.tokens.access_token, data.tokens.refresh_token);
|
||||||
|
if (!data.user.is_admin) {
|
||||||
|
setUser(data.user);
|
||||||
|
setStatus("not-admin");
|
||||||
|
// توکن رو نگه میداریم نه، پاک میکنیم چون این پنل فقط برای ادمینهاست
|
||||||
|
tokenStore.clear();
|
||||||
|
throw new ApiError("این حساب دسترسی ادمین نداره", 403);
|
||||||
|
}
|
||||||
|
setUser(data.user);
|
||||||
|
setStatus("authenticated");
|
||||||
|
return data.user;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
tokenStore.clear();
|
||||||
|
setUser(null);
|
||||||
|
setStatus("guest");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ status, user, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth باید داخل AuthProvider استفاده بشه");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "./auth-context";
|
||||||
|
import { ApiError } from "./api";
|
||||||
|
|
||||||
|
export function useRequireAdmin() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { status, user } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "guest") {
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
|
return { status, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatApiError(err: unknown, fallback = "یه مشکلی پیش اومد؛ دوباره امتحان کن"): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
if (typeof err.errors === "string" && err.errors && err.errors !== err.message) {
|
||||||
|
return `${err.message} — ${err.errors}`;
|
||||||
|
}
|
||||||
|
return err.message || fallback;
|
||||||
|
}
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return clsx(inputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcAge(birthDate?: string): number | null {
|
||||||
|
if (!birthDate) return null;
|
||||||
|
const b = new Date(birthDate);
|
||||||
|
if (Number.isNaN(b.getTime())) return null;
|
||||||
|
const now = new Date();
|
||||||
|
let age = now.getFullYear() - b.getFullYear();
|
||||||
|
const m = now.getMonth() - b.getMonth();
|
||||||
|
if (m < 0 || (m === 0 && now.getDate() < b.getDate())) age--;
|
||||||
|
return age;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dating-web",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["--prefix", "/run/media/mobin/project/projects/dating-app/web", "run", "dev", "--", "--port", "3000"],
|
||||||
|
"port": 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dating-admin",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["--prefix", "/run/media/mobin/project/projects/dating-app/admin", "run", "dev", "--", "--port", "3001"],
|
||||||
|
"port": 3001
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.claude
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
uploads
|
||||||
|
*.md
|
||||||
|
/dating-app
|
||||||
|
/dating-app-server
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
APP_ENV=development
|
||||||
|
APP_PORT=8080
|
||||||
|
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_NAME=dating_app
|
||||||
|
DB_SSLMODE=disable
|
||||||
|
# connection pool؛ زیر بار بالا این مقادیر رو با ظرفیت واقعی سرور Postgres تنظیم کن
|
||||||
|
DB_MAX_OPEN_CONNS=25
|
||||||
|
DB_MAX_IDLE_CONNS=10
|
||||||
|
DB_CONN_MAX_LIFETIME_MINUTES=30
|
||||||
|
DB_CONN_MAX_IDLE_TIME_MINUTES=5
|
||||||
|
|
||||||
|
JWT_ACCESS_SECRET=test-access-secret
|
||||||
|
JWT_REFRESH_SECRET=test-refresh-secret
|
||||||
|
JWT_ACCESS_TTL_MINUTES=1440
|
||||||
|
JWT_REFRESH_TTL_DAYS=30
|
||||||
|
|
||||||
|
OTP_EXPIRY_SECONDS=180
|
||||||
|
OTP_LENGTH=4
|
||||||
|
OTP_HASH_SECRET=test-otp-secret
|
||||||
|
|
||||||
|
ADMIN_BOOTSTRAP_PHONE=09120000000
|
||||||
|
|
||||||
|
# -------- فقط برای docker-compose (نیازی نیست موقع اجرای مستقیم go run باشه) --------
|
||||||
|
# DB_HOST رو docker-compose خودش موقع اجرای backend روی "postgres" ست میکنه؛
|
||||||
|
# مقدار بالا (localhost) فقط برای اجرای مستقیم روی سیستم خودت استفاده میشه.
|
||||||
|
WEB_PORT=3000
|
||||||
|
ADMIN_PORT=3001
|
||||||
|
# آدرسی که از مرورگر کاربر قابلدسترسیه (نه آدرس داخلی شبکهی داکر)
|
||||||
|
PUBLIC_API_URL=http://localhost:8080/api/v1
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# binaries
|
||||||
|
/dating-app
|
||||||
|
/dating-app-server
|
||||||
|
/server
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env
|
||||||
|
|
||||||
|
# فایلهای آپلودشدهی کاربران (نباید توی گیت باشن)
|
||||||
|
/uploads/
|
||||||
|
|
||||||
|
# بکآپهای دیتابیس (نباید توی گیت باشن؛ حجیم و شامل دادهی کاربرانه)
|
||||||
|
/backups/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# ---- مرحلهی build ----
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN sed -i 's|dl-cdn.alpinelinux.org|mirror.mobinhost.com|g' /etc/apk/repositories
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/api
|
||||||
|
|
||||||
|
# ---- مرحلهی اجرا (ایمیج نهایی سبک) ----
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
RUN sed -i 's|dl-cdn.alpinelinux.org|mirror.mobinhost.com|g' /etc/apk/repositories \
|
||||||
|
&& apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /out/server ./server
|
||||||
|
RUN chmod +x /app/server
|
||||||
|
|
||||||
|
RUN mkdir -p /app/uploads/photos
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["./server"]
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# اپلیکیشن دوستیابی — بکاند Go (Gin + GORM + PostgreSQL)
|
||||||
|
|
||||||
|
یک بکاند تمیز و لایهبندیشده برای یک اپ دوستیابی با:
|
||||||
|
- احراز هویت فقط با شماره موبایل + کد OTP (بدون پسورد)
|
||||||
|
- فرآیند آنبوردینگ مرحلهبهمرحله (۸ مرحله) برای تکمیل پروفایل
|
||||||
|
- سیستم عمومی «دسته + گزینه» که کاملاً از طریق API ادمین قابل مدیریت است
|
||||||
|
(بدون نیاز به تغییر کد برای اضافه/حذف گزینههای هر لیست)
|
||||||
|
- سوالات پروفایل (Q&A) که ادمین تعریف میکند و کاربر ۳ تای آن را انتخاب میکند
|
||||||
|
|
||||||
|
## ساختار پروژه (لایهبندی تمیز)
|
||||||
|
|
||||||
|
```
|
||||||
|
dating-app/
|
||||||
|
├── cmd/api/main.go # نقطه ورود، wiring همهچیز
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # خواندن تنظیمات از .env
|
||||||
|
│ ├── database/ # اتصال دیتابیس، migrate، seed
|
||||||
|
│ ├── models/ # مدلهای GORM
|
||||||
|
│ ├── dto/ # ساختار request/response ها
|
||||||
|
│ ├── repository/ # لایه دسترسی به داده (فقط query)
|
||||||
|
│ ├── service/ # منطق تجاری (validation، state machine و...)
|
||||||
|
│ ├── handler/ # هندلرهای HTTP (gin.Context)
|
||||||
|
│ ├── middleware/ # auth، admin، cors
|
||||||
|
│ └── router/ # تعریف روتها
|
||||||
|
├── go.mod / go.sum
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
لایهبندی مشابه الگوی selector/service که در پروژهی جنگویی قبلیتون بود:
|
||||||
|
`repository` = selector (فقط query خام)، `service` = منطق و اعتبارسنجی،
|
||||||
|
`handler` = فقط ترجمهی HTTP request/response.
|
||||||
|
|
||||||
|
## اجرا
|
||||||
|
|
||||||
|
### ۱) پیشنیاز: PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
createdb dating_app
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲) تنظیمات
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# مقادیر رو مطابق محیط خودت ویرایش کن (خصوصاً JWT_*_SECRET و OTP_HASH_SECRET در پروداکشن)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ADMIN_BOOTSTRAP_PHONE` رو به شمارهی موبایل خودت تنظیم کن؛ در اولین اجرای برنامه
|
||||||
|
یک کاربر ادمین با اون شماره ساخته میشه (یا اگه از قبل وجود داشت، ادمین میشه).
|
||||||
|
بعدش با همون شماره از مسیر عادی OTP وارد شو تا توکن ادمین بگیری.
|
||||||
|
|
||||||
|
### ۳) اجرا
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
سرور روی `http://localhost:8080` بالا میاد. مسیر `/health` برای healthcheck هست.
|
||||||
|
|
||||||
|
## اتصال گیتوی پیامک واقعی
|
||||||
|
|
||||||
|
فعلاً `service.MockOTPSender` استفاده میشه که کد OTP رو بهجای ارسال واقعی توی
|
||||||
|
لاگ چاپ میکنه (`internal/service/otp_sender.go`). برای وصل کردن یک گیتوی واقعی:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type KavenegarOTPSender struct{ apiKey string }
|
||||||
|
|
||||||
|
func (s *KavenegarOTPSender) Send(phone, otp string) bool {
|
||||||
|
// درخواست HTTP به گیتوی، صبر برای پاسخ، و true/false واقعی برگردون.
|
||||||
|
// این تابع باید SYNC باشه (نتیجهی واقعی رو برگردونه)، وگرنه کاربر حتی
|
||||||
|
// در صورت شکست واقعی ارسال، پیام موفقیت میگیره.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
و در `cmd/api/main.go` خط `otpSender := service.NewMockOTPSender(...)` رو با
|
||||||
|
`otpSender := &KavenegarOTPSender{apiKey: cfg.KavenegarAPIKey}` عوض کن. بقیهی
|
||||||
|
کد (`AuthService` و...) نیازی به تغییر نداره چون فقط با اینترفیس `OTPSender` کار میکنه.
|
||||||
|
|
||||||
|
## فرآیند آنبوردینگ
|
||||||
|
|
||||||
|
۹ مرحلهی ثابت که باید بهترتیب طی بشن (نمیشه از روی مرحله پرید، ولی مراحل
|
||||||
|
قبلی هر وقت بخوای قابل ویرایشاند؛ بعد از تکمیل کامل هم همهی مراحل آزادانه
|
||||||
|
قابل ویرایش میشن — حتی توی همون بازهای که کاربر منتظر تایید ادمینه):
|
||||||
|
|
||||||
|
| # | مرحله | Endpoint |
|
||||||
|
|---|-------|----------|
|
||||||
|
| ۱ | جنسیت، اسم، تاریخ تولد | `POST /onboarding/basic-info` |
|
||||||
|
| ۲ | عکسها (حداقل ۱ عکس؛ خودِ آپلود از `/profile/photos`) | `POST /onboarding/photos` |
|
||||||
|
| ۳ | قد و وزن | `POST /onboarding/body-info` |
|
||||||
|
| ۴ | ۳ سوال از پیشتعریفشده + پاسخ | `POST /onboarding/profile-questions` |
|
||||||
|
| ۵ | سطح تحصیلات + رشته | `POST /onboarding/education` |
|
||||||
|
| ۶ | کارهایی که انجام میدهد (چندتایی) | `POST /onboarding/occupations` |
|
||||||
|
| ۷ | ۳ چیز مهم در رابطه | `POST /onboarding/relationship-importance` |
|
||||||
|
| ۸ | ۵ علاقهمندی | `POST /onboarding/interests` |
|
||||||
|
| ۹ | دنبال چی میگردی | `POST /onboarding/looking-for` |
|
||||||
|
|
||||||
|
وضعیت فعلی: `GET /onboarding/status`
|
||||||
|
|
||||||
|
اگه کاربر قبلاً رد شده باشه و بعد از onboarded شدن هرکدوم از مراحل رو دوباره
|
||||||
|
بزنه (حتی مرحلهی عکسها)، `approval_status` خودکار به `pending` برمیگرده تا
|
||||||
|
ادمین دوباره بررسی کنه.
|
||||||
|
|
||||||
|
### بعد از تکمیل آنبوردینگ: تایید ادمین
|
||||||
|
|
||||||
|
به محض تکمیل مرحلهی ۸، `approval_status` کاربر روی `pending` قرار میگیرد.
|
||||||
|
کاربر تا وقتی ادمین تاییدش نکنه (`approved`) اجازهی ورود به صفحهی دوستیابی
|
||||||
|
رو نداره (این بخش در فاز بعدی پیاده میشه). اپ باید بعد از `is_onboarded=true`،
|
||||||
|
مقدار `approval_status` رو از `GET /onboarding/status` یا `GET /profile/me`
|
||||||
|
بخونه و بر اساسش یکی از این صفحات رو نشون بده:
|
||||||
|
|
||||||
|
| `approval_status` | صفحهی مناسب در اپ |
|
||||||
|
|---|---|
|
||||||
|
| `pending` | «در انتظار تایید ادمین» |
|
||||||
|
| `approved` | ورود به صفحهی دوستیابی |
|
||||||
|
| `rejected` | نمایش `rejection_reason` + امکان ویرایش پروفایل |
|
||||||
|
|
||||||
|
اگر کاربر رد شده باشه و بعداً هر کدوم از مراحل آنبوردینگ رو ویرایش کنه،
|
||||||
|
`approval_status` خودکار به `pending` برمیگرده تا ادمین دوباره بررسی کنه.
|
||||||
|
|
||||||
|
گزینههای هر مرحله (که از پنل ادمین مدیریت میشن):
|
||||||
|
`GET /onboarding/options/:category` با یکی از این category ها:
|
||||||
|
`education_level`, `field_of_study`, `occupation`,
|
||||||
|
`important_in_relationship`, `interest`, `looking_for`
|
||||||
|
|
||||||
|
سوالات قابلانتخاب برای مرحلهی ۳: `GET /onboarding/profile-questions`
|
||||||
|
|
||||||
|
### چرا این معماری؟
|
||||||
|
|
||||||
|
بهجای اینکه برای هر لیست (تحصیلات، علاقهمندی، اهداف رابطه و...) یک جدول
|
||||||
|
جدا بسازیم، یک سیستم عمومی «دسته (Category) + گزینه (Option)» ساختیم.
|
||||||
|
هر Category یک `min_select`/`max_select` داره (مثلاً علاقهمندیها دقیقاً ۵
|
||||||
|
تا، تحصیلات دقیقاً ۱ تا، فعالیتها بدون سقف). این یعنی:
|
||||||
|
- ادمین میتونه از طریق API هر وقت خواست گزینه اضافه/ویرایش/غیرفعال کنه،
|
||||||
|
بدون نیاز به دیپلوی کد جدید.
|
||||||
|
- اضافه کردن یک دستهی جدید در آینده فقط یک `INSERT` توی جدول `categories`
|
||||||
|
میخواد، نه migration جدید.
|
||||||
|
|
||||||
|
## دوستیابی (Discovery)، مچ و پیامرسانی
|
||||||
|
|
||||||
|
بعد از اینکه کاربر تایید شد (`approval_status=approved`) وارد این بخش میشه.
|
||||||
|
تشخیص جنسیت مقابل فعلاً **ساده و هترو-پیشفرض** هست (زن↔مرد)؛ سیستم هنوز
|
||||||
|
فیلد جداگانهای برای «ترجیح جنسیتی/گرایش» نداره.
|
||||||
|
|
||||||
|
فاصله از روی `latitude`/`longitude` کاربر (که هیچوقت خام برنمیگرده، فقط
|
||||||
|
فاصلهی محاسبهشده) با فرمول haversine حساب و به نزدیکترین کیلومتر گرد
|
||||||
|
میشه — یعنی از همون ابتدا تقریبیه، نه GPS دقیق.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /profile/photos (داخل /profile/me برمیگرده، endpoint جدا نداره)
|
||||||
|
POST /profile/photos آپلود عکس multipart/form-data، فیلد "photo" (jpg/png/webp، حداکثر ۵ مگابایت، حداکثر ۶ عکس)
|
||||||
|
DELETE /profile/photos/:id حذف عکس
|
||||||
|
PATCH /profile/location body: {latitude, longitude}
|
||||||
|
|
||||||
|
GET /discover?max_distance_km=&min_age=&max_age= لیست پیشنهادها (نزدیکترین اول)
|
||||||
|
GET /discover/:id پروفایل کامل یک کاندید
|
||||||
|
POST /discover/:id/swipe body: {action: "like"|"pass"|"superlike"}
|
||||||
|
اگه طرف مقابل هم قبلاً لایک کرده باشه، مچ ساخته میشه
|
||||||
|
|
||||||
|
GET /matches لیست مچها (با آخرین پیام هرکدوم)
|
||||||
|
GET /matches/:id/messages پیامهای یک گفتگو
|
||||||
|
POST /matches/:id/messages body: {text}
|
||||||
|
```
|
||||||
|
|
||||||
|
فایلهای آپلودشده روی دیسک محلی سرور (`./uploads/photos`) ذخیره و از مسیر
|
||||||
|
`/uploads/...` سرو میشن (پیادهسازی `service.PhotoStorage`؛ برای پروداکشن
|
||||||
|
با یک object storage واقعی جایگزینش کن، دقیقاً مثل الگوی `OTPSender`).
|
||||||
|
پیامرسانی فعلاً polling-based هست، نه realtime/websocket.
|
||||||
|
|
||||||
|
## API پنل ادمین
|
||||||
|
|
||||||
|
احراز هویت ادمین از همون مسیر OTP هست؛ فقط باید `is_admin=true` روی
|
||||||
|
کاربر ست شده باشه (یا با `ADMIN_BOOTSTRAP_PHONE`، یا مستقیم توی دیتابیس).
|
||||||
|
همهی مسیرهای زیر نیاز به هدر `Authorization: Bearer <access_token>` یک
|
||||||
|
کاربر ادمین دارن.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /admin/users/pending لیست کاربران آنبوردشده و منتظر بررسی (قدیمیترین اول)
|
||||||
|
GET /admin/users/:id پروفایل کامل یک کاربر برای بررسی
|
||||||
|
POST /admin/users/:id/approve تایید کاربر
|
||||||
|
POST /admin/users/:id/reject رد کاربر body: {reason}
|
||||||
|
|
||||||
|
GET /admin/categories/:key دریافت تنظیمات یک دسته
|
||||||
|
PATCH /admin/categories/:key ویرایش min_select/max_select/title
|
||||||
|
|
||||||
|
GET /admin/categories/:key/options لیست همه گزینههای یک دسته (فعال/غیرفعال)
|
||||||
|
POST /admin/categories/:key/options ساخت گزینه جدید body: {title, sort_order}
|
||||||
|
PATCH /admin/options/:id ویرایش گزینه body: {title?, sort_order?, is_active?}
|
||||||
|
DELETE /admin/options/:id حذف گزینه
|
||||||
|
|
||||||
|
GET /admin/profile-questions لیست همه سوالات (فعال/غیرفعال)
|
||||||
|
POST /admin/profile-questions ساخت سوال جدید body: {text, sort_order}
|
||||||
|
PATCH /admin/profile-questions/:id ویرایش سوال body: {text?, sort_order?, is_active?}
|
||||||
|
DELETE /admin/profile-questions/:id حذف سوال
|
||||||
|
```
|
||||||
|
|
||||||
|
## خلاصهی کامل API
|
||||||
|
|
||||||
|
### احراز هویت (عمومی)
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/request-otp body: {phone}
|
||||||
|
POST /api/v1/auth/verify-otp body: {phone, otp} -> access/refresh token
|
||||||
|
POST /api/v1/auth/token/refresh body: {refresh_token} -> access/refresh جدید
|
||||||
|
```
|
||||||
|
|
||||||
|
### پروفایل
|
||||||
|
```
|
||||||
|
GET /api/v1/profile/me (نیاز به توکن) — پروفایل کامل + همهی انتخابها/پاسخها
|
||||||
|
```
|
||||||
|
|
||||||
|
همهی پاسخها با این ساختار برمیگردن:
|
||||||
|
```json
|
||||||
|
{ "success": true, "message": "...", "data": { ... } }
|
||||||
|
{ "success": false, "message": "...", "errors": { ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکاتی که از پروژهی جنگویی قبلیتون رعایت شده
|
||||||
|
|
||||||
|
- ارسال OTP **sync** انجام میشه (نه توی یک goroutine بیخیال از نتیجه)،
|
||||||
|
چون قبلاً دیدیم نسخهی async باعث میشد کاربر با اینکه ارسال واقعاً شکست
|
||||||
|
خورده، پیام موفقیت بگیره.
|
||||||
|
- اگه ارسال پیامک واقعاً شکست بخوره، OTP ذخیرهشده پاک میشه تا کاربر
|
||||||
|
فوراً بتونه دوباره تلاش کنه (وگرنه تا پایان OTP_EXPIRY_SECONDS با خطای
|
||||||
|
«کد قبلی هنوز معتبره» قفل میمونه).
|
||||||
|
- OTP هیچوقت خام ذخیره نمیشه؛ با HMAC-SHA256 هش میشه.
|
||||||
|
- کد OTP فقط وقتی `APP_ENV != production` باشه توی لاگ چاپ میشه.
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/config"
|
||||||
|
"dating-app/internal/database"
|
||||||
|
"dating-app/internal/handler"
|
||||||
|
"dating-app/internal/middleware"
|
||||||
|
"dating-app/internal/monitoring"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
"dating-app/internal/router"
|
||||||
|
"dating-app/internal/service"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
db, err := database.Connect(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("اتصال به دیتابیس ناموفق بود: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.AutoMigrate(db); err != nil {
|
||||||
|
log.Fatalf("migrate ناموفق بود: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.SeedCategories(db); err != nil {
|
||||||
|
log.Fatalf("seed کردن دستهها ناموفق بود: %v", err)
|
||||||
|
}
|
||||||
|
if err := database.BootstrapAdmin(db, cfg.AdminBootstrapPhone); err != nil {
|
||||||
|
log.Fatalf("ساخت ادمین اولیه ناموفق بود: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Repositories --------
|
||||||
|
userRepo := repository.NewUserRepository(db)
|
||||||
|
optionRepo := repository.NewOptionRepository(db)
|
||||||
|
questionRepo := repository.NewQuestionRepository(db)
|
||||||
|
photoRepo := repository.NewPhotoRepository(db)
|
||||||
|
swipeRepo := repository.NewSwipeRepository(db)
|
||||||
|
matchRepo := repository.NewMatchRepository(db)
|
||||||
|
messageRepo := repository.NewMessageRepository(db)
|
||||||
|
reportRepo := repository.NewReportRepository(db)
|
||||||
|
|
||||||
|
// -------- Utils / Infra --------
|
||||||
|
jwtManager := utils.NewJWTManager(
|
||||||
|
cfg.JWTAccessSecret,
|
||||||
|
cfg.JWTRefreshSecret,
|
||||||
|
cfg.JWTAccessTTL,
|
||||||
|
cfg.JWTRefreshTTL,
|
||||||
|
)
|
||||||
|
// برای اتصال به یک گیتوی پیامک واقعی، این خط رو با پیادهسازی خودت
|
||||||
|
// (که service.OTPSender رو implement میکنه) عوض کن.
|
||||||
|
otpSender := service.NewMockOTPSender(cfg.AppEnv != "production")
|
||||||
|
// برای اتصال به یک object storage واقعی، این خط رو با پیادهسازی خودت
|
||||||
|
// (که service.PhotoStorage رو implement میکنه) عوض کن.
|
||||||
|
photoStorage := service.NewLocalPhotoStorage("./uploads/photos", "/uploads/photos")
|
||||||
|
|
||||||
|
// -------- Services --------
|
||||||
|
authService := service.NewAuthService(
|
||||||
|
userRepo, otpSender, jwtManager,
|
||||||
|
cfg.OTPExpirySeconds, cfg.OTPLength, cfg.OTPHashSecret,
|
||||||
|
)
|
||||||
|
onboardingService := service.NewOnboardingService(userRepo, optionRepo, questionRepo, photoRepo)
|
||||||
|
profileService := service.NewProfileService(photoRepo, userRepo, photoStorage)
|
||||||
|
adminService := service.NewAdminService(optionRepo, questionRepo, userRepo, reportRepo, matchRepo, photoRepo, profileService)
|
||||||
|
discoveryService := service.NewDiscoveryService(userRepo, photoRepo, swipeRepo, matchRepo, optionRepo, questionRepo)
|
||||||
|
chatService := service.NewChatService(matchRepo, messageRepo, userRepo, photoRepo)
|
||||||
|
backupService := service.NewBackupService(cfg, "./backups")
|
||||||
|
|
||||||
|
// -------- مانیتورینگ --------
|
||||||
|
metricsStore := monitoring.NewStore()
|
||||||
|
|
||||||
|
// -------- Handlers --------
|
||||||
|
handlers := router.Handlers{
|
||||||
|
Auth: handler.NewAuthHandler(authService),
|
||||||
|
Onboarding: handler.NewOnboardingHandler(onboardingService),
|
||||||
|
Profile: handler.NewProfileHandler(optionRepo, questionRepo, photoRepo, onboardingService, profileService),
|
||||||
|
Admin: handler.NewAdminHandler(adminService, optionRepo, questionRepo, photoRepo, onboardingService),
|
||||||
|
Discovery: handler.NewDiscoveryHandler(discoveryService),
|
||||||
|
Chat: handler.NewChatHandler(chatService),
|
||||||
|
Monitoring: handler.NewMonitoringHandler(metricsStore),
|
||||||
|
Backup: handler.NewBackupHandler(backupService),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AppEnv == "production" {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
// بهجای gin.Default() (که فقط Logger+Recovery پیشفرض رو میده)، دستی
|
||||||
|
// میسازیم تا هم Metrics رو اضافه کنیم و هم Recovery خودمون که envelope
|
||||||
|
// استاندارد JSON برمیگردونه. ترتیب مهمه: Metrics باید بیرونیترین لایه
|
||||||
|
// باشه تا حتی درخواستهایی که پنیک میکنن (و توسط Recovery مهار میشن) هم
|
||||||
|
// توی آمار لحاظ بشن.
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(middleware.Metrics(metricsStore))
|
||||||
|
engine.Use(middleware.Recovery())
|
||||||
|
engine.Use(gin.Logger())
|
||||||
|
router.Setup(engine, handlers, jwtManager, userRepo)
|
||||||
|
|
||||||
|
// یک http.Server دستی بهجای engine.Run() ساده، به دو دلیل:
|
||||||
|
// ۱. تایماوتهای صریح روی خوندن/نوشتن، تا یک کلاینت کند یا مخرب (slowloris)
|
||||||
|
// نتونه goroutine/فایلدسکریپتورهای سرور رو زیر بار بالا قفل نگه داره.
|
||||||
|
// ۲. Shutdown مهلتدار، تا موقع دیپلوی/ریاستارت، درخواستهای در حال پردازش
|
||||||
|
// ناتمام قطع نشن (بهجای کشتن فوری پروسه).
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + cfg.AppPort,
|
||||||
|
Handler: engine,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 90 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("سرور روی پورت %s در حال اجراست (env=%s)", cfg.AppPort, cfg.AppEnv)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("اجرای سرور ناموفق بود: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
log.Println("سیگنال خاموشی دریافت شد؛ منتظر تمامشدن درخواستهای در حال پردازش...")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Fatalf("خاموش کردن سرور با مشکل مواجه شد: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("سرور با موفقیت خاموش شد")
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
module dating-app
|
||||||
|
|
||||||
|
go 1.25
|
||||||
|
|
||||||
|
replace golang.org/x/net => github.com/golang/net v0.25.0
|
||||||
|
|
||||||
|
replace golang.org/x/sys => github.com/golang/sys v0.20.0
|
||||||
|
|
||||||
|
replace golang.org/x/text => github.com/golang/text v0.15.0
|
||||||
|
|
||||||
|
replace golang.org/x/crypto => github.com/golang/crypto v0.23.0
|
||||||
|
|
||||||
|
replace golang.org/x/arch => github.com/golang/arch v0.8.0
|
||||||
|
|
||||||
|
replace golang.org/x/tools => github.com/golang/tools v0.21.0
|
||||||
|
|
||||||
|
replace gopkg.in/yaml.v2 => github.com/go-yaml/yaml v2.4.0+incompatible
|
||||||
|
|
||||||
|
replace gopkg.in/yaml.v3 => github.com/go-yaml/yaml v3.0.1+incompatible
|
||||||
|
|
||||||
|
replace rsc.io/pdf => github.com/rsc/pdf v0.1.1
|
||||||
|
|
||||||
|
replace gopkg.in/check.v1 => github.com/go-check/check v0.0.0-20180628173108-788fd7840127
|
||||||
|
|
||||||
|
replace google.golang.org/protobuf => github.com/protocolbuffers/protobuf-go v1.30.0
|
||||||
|
|
||||||
|
replace golang.org/x/xerrors => github.com/golang/xerrors v0.0.0-20191204190536-9bdfabe68543
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
gorm.io/driver/postgres v1.5.9
|
||||||
|
gorm.io/gorm v1.25.10
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
|
golang.org/x/crypto v0.23.0 // indirect
|
||||||
|
golang.org/x/net v0.21.0 // indirect
|
||||||
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
|
golang.org/x/sys v0.26.0 // indirect
|
||||||
|
golang.org/x/text v0.15.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace gorm.io/gorm => github.com/go-gorm/gorm v1.25.10
|
||||||
|
|
||||||
|
replace gorm.io/driver/postgres => github.com/go-gorm/postgres v1.5.9
|
||||||
|
|
||||||
|
replace golang.org/x/sync => github.com/golang/sync v0.1.0
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
|
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||||
|
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||||
|
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||||
|
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
|
||||||
|
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||||
|
github.com/go-gorm/gorm v1.25.10 h1:s8FP+QiJt4yTWhaqqaTEXmJezVd4pLuYihWFa5ka6oc=
|
||||||
|
github.com/go-gorm/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
github.com/go-gorm/postgres v1.5.9 h1:R8GFj+hNnViUX6OKlL8jp7b5fVUVlDE3ULqQ+ae7aPk=
|
||||||
|
github.com/go-gorm/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||||
|
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||||
|
github.com/go-yaml/yaml v3.0.1+incompatible h1:8i5pkxs8UxP4TeKSFhliVRFob5ie15DYTVMlEgls7Zk=
|
||||||
|
github.com/go-yaml/yaml v3.0.1+incompatible/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/arch v0.8.0 h1:QPY/mLodoR5zIr7llOQZAYuv+rljFZogBqb5GbTvjhI=
|
||||||
|
github.com/golang/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
github.com/golang/crypto v0.23.0 h1:RShXj2uPTe4hHfGGjtFi5oIlDx7rF677+p5uAYM5izU=
|
||||||
|
github.com/golang/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
github.com/golang/net v0.25.0 h1:3L1cfr31lBuJQfJJwCEIWm/rRLKM0X65IeT+yand+L4=
|
||||||
|
github.com/golang/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/sync v0.1.0 h1:IhdYeJ8w8G+NG1Ztq4N1bHNcrHFZsZQ+xB5bDksHLJA=
|
||||||
|
github.com/golang/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
github.com/golang/sys v0.20.0 h1:s6f6MpCY0CE+A62kAS9FiDNmD3rpVbd+OISylPY45NQ=
|
||||||
|
github.com/golang/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
github.com/golang/text v0.15.0 h1:Hr6Y5cbI9MiTMnLQhMfqQzC3smwk0O9n6gYp78JNYJ4=
|
||||||
|
github.com/golang/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
github.com/golang/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:WKKabR3QofYgj3cabwq+Blt8MSvmXR4GKLoohSdeY1A=
|
||||||
|
github.com/golang/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/protocolbuffers/protobuf-go v1.30.0 h1:8l0IAhOWx+qT8fxNzYIj+TS9SEU6xamBNS0ZmRIy3eY=
|
||||||
|
github.com/protocolbuffers/protobuf-go v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||||
|
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||||
|
github.com/rsc/pdf v0.1.1/go.mod h1:+1cgsOaCtY/6pdg9OEno8rLCRIQj5LeL9ckNDtNZsFs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||||
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
Vendored
+55
@@ -0,0 +1,55 @@
|
|||||||
|
// Package cache یک کش درونحافظهای ساده با انقضای زمانی (TTL) ارائه میده.
|
||||||
|
// برای دادههایی مناسبه که زیاد خونده میشن ولی بهندرت تغییر میکنن (مثل
|
||||||
|
// گزینههای دستهبندیها و سوالات پروفایل که فقط از پنل ادمین تغییر میکنن).
|
||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type entry[V any] struct {
|
||||||
|
value V
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type TTLCache[K comparable, V any] struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
ttl time.Duration
|
||||||
|
m map[K]entry[V]
|
||||||
|
}
|
||||||
|
|
||||||
|
func New[K comparable, V any](ttl time.Duration) *TTLCache[K, V] {
|
||||||
|
return &TTLCache[K, V]{ttl: ttl, m: make(map[K]entry[V])}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TTLCache[K, V]) Get(key K) (V, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
e, ok := c.m[key]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
var zero V
|
||||||
|
if !ok || time.Now().After(e.expiresAt) {
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
return e.value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TTLCache[K, V]) Set(key K, value V) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.m[key] = entry[V]{value: value, expiresAt: time.Now().Add(c.ttl)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TTLCache[K, V]) Delete(key K) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
delete(c.m, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear کل کش رو خالی میکنه؛ برای موقعی که نمیشه/نمیخوایم کلید دقیق رو حساب کنیم.
|
||||||
|
func (c *TTLCache[K, V]) Clear() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.m = make(map[K]entry[V])
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTTLCache_SetGet(t *testing.T) {
|
||||||
|
c := New[string, int](time.Minute)
|
||||||
|
c.Set("a", 42)
|
||||||
|
|
||||||
|
v, ok := c.Get("a")
|
||||||
|
if !ok || v != 42 {
|
||||||
|
t.Fatalf("expected (42, true), got (%d, %v)", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTTLCache_MissingKey(t *testing.T) {
|
||||||
|
c := New[string, int](time.Minute)
|
||||||
|
if _, ok := c.Get("missing"); ok {
|
||||||
|
t.Fatal("expected miss for key that was never set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTTLCache_Expiry(t *testing.T) {
|
||||||
|
c := New[string, int](10 * time.Millisecond)
|
||||||
|
c.Set("a", 1)
|
||||||
|
|
||||||
|
if _, ok := c.Get("a"); !ok {
|
||||||
|
t.Fatal("expected hit immediately after Set")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(30 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, ok := c.Get("a"); ok {
|
||||||
|
t.Fatal("expected miss after TTL expired")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTTLCache_Delete(t *testing.T) {
|
||||||
|
c := New[string, int](time.Minute)
|
||||||
|
c.Set("a", 1)
|
||||||
|
c.Delete("a")
|
||||||
|
|
||||||
|
if _, ok := c.Get("a"); ok {
|
||||||
|
t.Fatal("expected miss after Delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTTLCache_Clear(t *testing.T) {
|
||||||
|
c := New[string, int](time.Minute)
|
||||||
|
c.Set("a", 1)
|
||||||
|
c.Set("b", 2)
|
||||||
|
c.Clear()
|
||||||
|
|
||||||
|
if _, ok := c.Get("a"); ok {
|
||||||
|
t.Fatal("expected miss for 'a' after Clear")
|
||||||
|
}
|
||||||
|
if _, ok := c.Get("b"); ok {
|
||||||
|
t.Fatal("expected miss for 'b' after Clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config تمام تنظیمات قابلپیکربندی برنامه از طریق متغیرهای محیطی
|
||||||
|
type Config struct {
|
||||||
|
AppEnv string
|
||||||
|
AppPort string
|
||||||
|
|
||||||
|
DBHost string
|
||||||
|
DBPort string
|
||||||
|
DBUser string
|
||||||
|
DBPassword string
|
||||||
|
DBName string
|
||||||
|
DBSSLMode string
|
||||||
|
|
||||||
|
// تنظیمات connection pool؛ زیر بار بالا اینها از باز شدن بیرویهی اتصال
|
||||||
|
// (که خود Postgres رو زمین میزنه) و از نشتی اتصالات راکد جلوگیری میکنن.
|
||||||
|
DBMaxOpenConns int
|
||||||
|
DBMaxIdleConns int
|
||||||
|
DBConnMaxLifetimeMins int
|
||||||
|
DBConnMaxIdleTimeMins int
|
||||||
|
|
||||||
|
JWTAccessSecret string
|
||||||
|
JWTRefreshSecret string
|
||||||
|
JWTAccessTTL time.Duration
|
||||||
|
JWTRefreshTTL time.Duration
|
||||||
|
|
||||||
|
OTPExpirySeconds int
|
||||||
|
OTPLength int
|
||||||
|
OTPHashSecret string
|
||||||
|
|
||||||
|
// اگر پر باشد، در اولین اجرا یک کاربر ادمین با این شماره ساخته میشود
|
||||||
|
AdminBootstrapPhone string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load فایل .env را (در صورت وجود) میخواند و Config را برمیگرداند.
|
||||||
|
// نبودن فایل .env خطا محسوب نمیشود (مثلاً در پروداکشن env مستقیم ست میشود).
|
||||||
|
func Load() *Config {
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
AppEnv: getEnv("APP_ENV", "development"),
|
||||||
|
AppPort: getEnv("APP_PORT", "8080"),
|
||||||
|
|
||||||
|
DBHost: getEnv("DB_HOST", "localhost"),
|
||||||
|
DBPort: getEnv("DB_PORT", "5432"),
|
||||||
|
DBUser: getEnv("DB_USER", "postgres"),
|
||||||
|
DBPassword: getEnv("DB_PASSWORD", "postgres"),
|
||||||
|
DBName: getEnv("DB_NAME", "dating_app"),
|
||||||
|
DBSSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||||
|
|
||||||
|
DBMaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25),
|
||||||
|
DBMaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 10),
|
||||||
|
DBConnMaxLifetimeMins: getEnvInt("DB_CONN_MAX_LIFETIME_MINUTES", 30),
|
||||||
|
DBConnMaxIdleTimeMins: getEnvInt("DB_CONN_MAX_IDLE_TIME_MINUTES", 5),
|
||||||
|
|
||||||
|
JWTAccessSecret: getEnv("JWT_ACCESS_SECRET", "change-me-access-secret"),
|
||||||
|
JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "change-me-refresh-secret"),
|
||||||
|
JWTAccessTTL: time.Duration(getEnvInt("JWT_ACCESS_TTL_MINUTES", 60*24)) * time.Minute,
|
||||||
|
JWTRefreshTTL: time.Duration(getEnvInt("JWT_REFRESH_TTL_DAYS", 30)) * 24 * time.Hour,
|
||||||
|
|
||||||
|
OTPExpirySeconds: getEnvInt("OTP_EXPIRY_SECONDS", 180),
|
||||||
|
OTPLength: getEnvInt("OTP_LENGTH", 4),
|
||||||
|
OTPHashSecret: getEnv("OTP_HASH_SECRET", "change-me-otp-secret"),
|
||||||
|
|
||||||
|
AdminBootstrapPhone: getEnv("ADMIN_BOOTSTRAP_PHONE", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) DSN() string {
|
||||||
|
return "host=" + c.DBHost +
|
||||||
|
" user=" + c.DBUser +
|
||||||
|
" password=" + c.DBPassword +
|
||||||
|
" dbname=" + c.DBName +
|
||||||
|
" port=" + c.DBPort +
|
||||||
|
" sslmode=" + c.DBSSLMode +
|
||||||
|
" TimeZone=Asia/Tehran"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, fallback int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"dating-app/internal/config"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connect به دیتابیس پستگرس وصل میشود
|
||||||
|
func Connect(cfg *config.Config) (*gorm.DB, error) {
|
||||||
|
logLevel := logger.Warn
|
||||||
|
if cfg.AppEnv == "development" {
|
||||||
|
logLevel = logger.Info
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logLevel),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// تنظیم connection pool؛ بدون این، پیشفرض Go بینهایت اتصال باز میکنه که
|
||||||
|
// زیر بار بالا میتونه بهراحتی خود Postgres رو با تعداد زیاد اتصال همزمان
|
||||||
|
// از پا در بیاره. MaxIdleConns هم اتصالات راکد رو محدود میکنه تا حافظهی
|
||||||
|
// دیتابیس هدر نره.
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns)
|
||||||
|
sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns)
|
||||||
|
sqlDB.SetConnMaxLifetime(time.Duration(cfg.DBConnMaxLifetimeMins) * time.Minute)
|
||||||
|
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.DBConnMaxIdleTimeMins) * time.Minute)
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoMigrate جدولهای مورد نیاز را میسازد/بهروزرسانی میکند
|
||||||
|
func AutoMigrate(db *gorm.DB) error {
|
||||||
|
return db.AutoMigrate(
|
||||||
|
&models.User{},
|
||||||
|
&models.Category{},
|
||||||
|
&models.Option{},
|
||||||
|
&models.UserOptionSelection{},
|
||||||
|
&models.ProfileQuestion{},
|
||||||
|
&models.UserProfileAnswer{},
|
||||||
|
&models.Photo{},
|
||||||
|
&models.Swipe{},
|
||||||
|
&models.Match{},
|
||||||
|
&models.Message{},
|
||||||
|
&models.Report{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedCategories دستههای ثابت لیستهای انتخابی را در صورت نبود میسازد.
|
||||||
|
// خودِ گزینههای داخل هر دسته (بهجز looking_for که صراحتاً در نیازمندی
|
||||||
|
// پروژه مشخص شده) باید از پنل ادمین اضافه شوند.
|
||||||
|
func SeedCategories(db *gorm.DB) error {
|
||||||
|
defaults := []models.Category{
|
||||||
|
{Key: models.CategoryEducationLevel, Title: "سطح تحصیلات", MinSelect: 1, MaxSelect: 1},
|
||||||
|
{Key: models.CategoryFieldOfStudy, Title: "رشته تحصیلی", MinSelect: 1, MaxSelect: 1},
|
||||||
|
{Key: models.CategoryOccupation, Title: "کارهایی که انجام میدهید", MinSelect: 1, MaxSelect: 0},
|
||||||
|
{Key: models.CategoryImportantInRelationship, Title: "برات توی یک رابطه مهمه", MinSelect: 3, MaxSelect: 3},
|
||||||
|
{Key: models.CategoryInterest, Title: "علاقهمندیها", MinSelect: 5, MaxSelect: 5},
|
||||||
|
{Key: models.CategoryLookingFor, Title: "دنبال چی میگردی", MinSelect: 1, MaxSelect: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range defaults {
|
||||||
|
var existing models.Category
|
||||||
|
err := db.Where("key = ?", c.Key).First(&existing).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
if err := db.Create(&c).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("دستهی «%s» ساخته شد", c.Title)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// گزینههای "دنبال چی میگردی" مطابق چیزی که در نیازمندی مشخص شده seed میشن
|
||||||
|
var lookingFor models.Category
|
||||||
|
if err := db.Where("key = ?", models.CategoryLookingFor).First(&lookingFor).Error; err == nil {
|
||||||
|
var count int64
|
||||||
|
db.Model(&models.Option{}).Where("category_id = ?", lookingFor.ID).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
titles := []string{
|
||||||
|
"رابطه بلندمدت",
|
||||||
|
"همسر زندگی",
|
||||||
|
"قرارهای تفریحی",
|
||||||
|
"آشنایی با افراد جدید",
|
||||||
|
"ازدواج",
|
||||||
|
"دوست ساده",
|
||||||
|
}
|
||||||
|
for i, t := range titles {
|
||||||
|
db.Create(&models.Option{
|
||||||
|
CategoryID: lookingFor.ID,
|
||||||
|
Title: t,
|
||||||
|
SortOrder: i,
|
||||||
|
IsActive: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
log.Println("گزینههای «دنبال چی میگردی» seed شدند")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BootstrapAdmin اگر شمارهای برای ادمین اولیه مشخص شده باشد،
|
||||||
|
// یک کاربر ادمین با آن شماره میسازد (در صورت نبود).
|
||||||
|
func BootstrapAdmin(db *gorm.DB, phone string) error {
|
||||||
|
if phone == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var user models.User
|
||||||
|
err := db.Where("phone = ?", phone).First(&user).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
user = models.User{
|
||||||
|
Phone: phone,
|
||||||
|
State: models.UserStateActive,
|
||||||
|
IsAdmin: true,
|
||||||
|
IsActive: true,
|
||||||
|
ApprovalStatus: models.ApprovalApproved,
|
||||||
|
}
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("کاربر ادمین اولیه با شماره %s ساخته شد", phone)
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsAdmin {
|
||||||
|
user.IsAdmin = true
|
||||||
|
db.Save(&user)
|
||||||
|
log.Printf("کاربر %s به ادمین ارتقا یافت", phone)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// CreateOptionRequest ساخت یک گزینهی جدید در یک دسته
|
||||||
|
type CreateOptionRequest struct {
|
||||||
|
Title string `json:"title" binding:"required,min=1,max=150"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOptionRequest ویرایش یک گزینه
|
||||||
|
type UpdateOptionRequest struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCategoryRequest ویرایش تنظیمات یک دسته (مثلاً تغییر حداقل/حداکثر انتخاب)
|
||||||
|
type UpdateCategoryRequest struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
MinSelect *int `json:"min_select"`
|
||||||
|
MaxSelect *int `json:"max_select"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProfileQuestionRequest ساخت سوال جدید پروفایل
|
||||||
|
type CreateProfileQuestionRequest struct {
|
||||||
|
Text string `json:"text" binding:"required,min=3,max=255"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfileQuestionRequest ویرایش سوال پروفایل
|
||||||
|
type UpdateProfileQuestionRequest struct {
|
||||||
|
Text *string `json:"text"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectUserRequest رد کردن درخواست عضویت یک کاربر توسط ادمین (دلیل الزامی است)
|
||||||
|
type RejectUserRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"required,min=3,max=500"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockUserRequest بلاک کردن یک کاربر توسط ادمین (دلیل الزامی است)
|
||||||
|
type BlockUserRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"required,min=3,max=500"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportUserRequest ثبت یک یادداشت نظارتی روی یک کاربر (دلیل الزامی است)
|
||||||
|
type ReportUserRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"required,min=3,max=500"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// RequestOTPRequest بدنهی درخواست ارسال کد تایید
|
||||||
|
type RequestOTPRequest struct {
|
||||||
|
Phone string `json:"phone" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyOTPRequest بدنهی درخواست تایید کد
|
||||||
|
type VerifyOTPRequest struct {
|
||||||
|
Phone string `json:"phone" binding:"required"`
|
||||||
|
OTP string `json:"otp" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokenRequest بدنهی درخواست رفرش توکن
|
||||||
|
type RefreshTokenRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenPair جفت توکن access/refresh
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthResponse پاسخ استاندارد بعد از ورود موفق
|
||||||
|
type AuthResponse struct {
|
||||||
|
Tokens TokenPair `json:"tokens"`
|
||||||
|
User UserProfile `json:"user"`
|
||||||
|
IsNewUser bool `json:"is_new_user"`
|
||||||
|
OnboardingDone bool `json:"onboarding_done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserProfile نمایش خلاصهی پروفایل کاربر (بدون دادههای حساس)
|
||||||
|
type UserProfile struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
FirstName string `json:"first_name,omitempty"`
|
||||||
|
Gender *string `json:"gender,omitempty"`
|
||||||
|
BirthDate *string `json:"birth_date,omitempty"`
|
||||||
|
HeightCM *int `json:"height_cm,omitempty"`
|
||||||
|
WeightKG *int `json:"weight_kg,omitempty"`
|
||||||
|
CurrentStep int `json:"current_step"`
|
||||||
|
IsOnboarded bool `json:"is_onboarded"`
|
||||||
|
ApprovalStatus string `json:"approval_status"`
|
||||||
|
RejectionReason *string `json:"rejection_reason,omitempty"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
State string `json:"state"`
|
||||||
|
BlockedReason *string `json:"blocked_reason,omitempty"`
|
||||||
|
BlockedAt *time.Time `json:"blocked_at,omitempty"`
|
||||||
|
Bio *string `json:"bio,omitempty"`
|
||||||
|
StreakCount int `json:"streak_count"`
|
||||||
|
NotifyMatches bool `json:"notify_matches"`
|
||||||
|
NotifyMessages bool `json:"notify_messages"`
|
||||||
|
NotifyLikes bool `json:"notify_likes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserProfileWithPhotos برای لیستهای ادمینی که علاوه بر خلاصهی پروفایل، عکس
|
||||||
|
// کاربر رو هم نشون میدن (مثلاً کاربران در انتظار تایید).
|
||||||
|
type UserProfileWithPhotos struct {
|
||||||
|
UserProfile
|
||||||
|
Photos []string `json:"photos"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// DiscoverCandidate یک پروفایل پیشنهادی توی صفحهی دوستیابی
|
||||||
|
type DiscoverCandidate struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
Age int `json:"age"`
|
||||||
|
Bio string `json:"bio,omitempty"`
|
||||||
|
Photos []string `json:"photos"`
|
||||||
|
DistanceKM *float64 `json:"distance_km,omitempty"`
|
||||||
|
Interests []string `json:"interests,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwipeRequest بدنهی درخواست ثبت لایک/ردّ
|
||||||
|
type SwipeRequest struct {
|
||||||
|
Action string `json:"action" binding:"required,oneof=like pass superlike"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwipeResponse نتیجهی یک سواپ؛ اگه Matched=true باشه یعنی مچ تازه (یا از قبل) ساخته شده
|
||||||
|
type SwipeResponse struct {
|
||||||
|
Matched bool `json:"matched"`
|
||||||
|
Match *MatchView `json:"match,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchView خلاصهی یک مچ برای نمایش در لیست مچها/گفتگوها. برای پیشنمایش
|
||||||
|
// آخرین پیام، سرور رمزگشایی نمیکنه (نمیتونه) — CipherText/Nonce خودِ آخرین
|
||||||
|
// پیام رو برمیگردونه تا کلاینت خودش رمزگشایی کنه؛ LastMessage فقط برای
|
||||||
|
// پیامهای قدیمیِ قبل از E2EE پر میشه.
|
||||||
|
type MatchView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
Photos []string `json:"photos"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastMessage *string `json:"last_message,omitempty"`
|
||||||
|
LastMessageCipherText *string `json:"last_message_cipher_text,omitempty"`
|
||||||
|
LastMessageNonce *string `json:"last_message_nonce,omitempty"`
|
||||||
|
LastAt *string `json:"last_at,omitempty"`
|
||||||
|
// PartnerPublicKey کلید عمومی X25519 طرف مقابل (base64) برای رمزنگاری
|
||||||
|
// سرتاسری؛ اگه طرف مقابل هنوز کلیدی ثبت نکرده باشه خالیه.
|
||||||
|
PartnerPublicKey *string `json:"partner_public_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageView یک پیام در یک گفتگو. یا Text (پیام قدیمیِ قبل از E2EE) پر
|
||||||
|
// میشه، یا CipherText+Nonce (پیام رمزنگاریشده) — سرور خودش نمیدونه محتوای
|
||||||
|
// رمزنگاریشده چیه، فقط بین دو طرف مچ ردوبدلش میکنه.
|
||||||
|
type MessageView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
MatchID uint `json:"match_id"`
|
||||||
|
SenderID uint `json:"sender_id"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
CipherText *string `json:"cipher_text,omitempty"`
|
||||||
|
Nonce *string `json:"nonce,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
IsMine bool `json:"is_mine"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageRequest بدنهی درخواست ارسال پیام رمزنگاریشده. متن خام پیام
|
||||||
|
// هیچوقت به سرور نمیرسه؛ کلاینت قبل از ارسال با کلید عمومی طرف مقابل
|
||||||
|
// رمزنگاریش میکنه.
|
||||||
|
type SendMessageRequest struct {
|
||||||
|
CipherText string `json:"cipher_text" binding:"required,min=1,max=12000"`
|
||||||
|
Nonce string `json:"nonce" binding:"required,len=32"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CandidateAnswer پاسخ یک سوال پروفایل، برای نمایش عمومی
|
||||||
|
type CandidateAnswer struct {
|
||||||
|
Question string `json:"question"`
|
||||||
|
Answer string `json:"answer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CandidateDetail پروفایل کامل یک کاربر دیگه (برای نمایش صفحهی جزئیات پروفایل)
|
||||||
|
type CandidateDetail struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
Age int `json:"age"`
|
||||||
|
Bio string `json:"bio,omitempty"`
|
||||||
|
Photos []string `json:"photos"`
|
||||||
|
DistanceKM *float64 `json:"distance_km,omitempty"`
|
||||||
|
HeightCM *int `json:"height_cm,omitempty"`
|
||||||
|
WeightKG *int `json:"weight_kg,omitempty"`
|
||||||
|
Education string `json:"education,omitempty"`
|
||||||
|
FieldOfStudy string `json:"field_of_study,omitempty"`
|
||||||
|
Occupations []string `json:"occupations,omitempty"`
|
||||||
|
Interests []string `json:"interests,omitempty"`
|
||||||
|
ImportantInRelationship []string `json:"important_in_relationship,omitempty"`
|
||||||
|
LookingFor string `json:"looking_for,omitempty"`
|
||||||
|
Answers []CandidateAnswer `json:"profile_answers,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// BasicInfoRequest مرحله ۱: جنسیت، اسم کوچک، تاریخ تولد
|
||||||
|
type BasicInfoRequest struct {
|
||||||
|
Gender string `json:"gender" binding:"required,oneof=male female"`
|
||||||
|
FirstName string `json:"first_name" binding:"required,min=2,max=50"`
|
||||||
|
// فرمت: YYYY-MM-DD
|
||||||
|
BirthDate string `json:"birth_date" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyInfoRequest مرحله ۲: قد و وزن
|
||||||
|
type BodyInfoRequest struct {
|
||||||
|
HeightCM int `json:"height_cm" binding:"required,min=100,max=250"`
|
||||||
|
WeightKG int `json:"weight_kg" binding:"required,min=30,max=250"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileQuestionAnswerInput یک جفت (سوال انتخابشده، پاسخ) از سوالات پروفایل
|
||||||
|
type ProfileQuestionAnswerInput struct {
|
||||||
|
QuestionID uint `json:"question_id" binding:"required"`
|
||||||
|
Answer string `json:"answer" binding:"required,min=1,max=1000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileQuestionsRequest مرحله ۳: دقیقاً ۳ سوال انتخابی همراه با پاسخ
|
||||||
|
type ProfileQuestionsRequest struct {
|
||||||
|
Answers []ProfileQuestionAnswerInput `json:"answers" binding:"required,len=3,dive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EducationRequest مرحله ۴: سطح تحصیلات + رشته تحصیلی (هر دو از لیستهای ادمین)
|
||||||
|
type EducationRequest struct {
|
||||||
|
EducationLevelOptionID uint `json:"education_level_option_id" binding:"required"`
|
||||||
|
FieldOfStudyOptionID uint `json:"field_of_study_option_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionSelectionRequest برای مراحلی که فقط یک لیست از option_id ارسال میشود
|
||||||
|
// (کارها، اهمیتهای رابطه، علاقهمندیها، دنبال چی میگردی)
|
||||||
|
type OptionSelectionRequest struct {
|
||||||
|
OptionIDs []uint `json:"option_ids" binding:"required,min=1,dive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnboardingStatusResponse وضعیت فعلی آنبوردینگ کاربر
|
||||||
|
type OnboardingStatusResponse struct {
|
||||||
|
CurrentStep int `json:"current_step"`
|
||||||
|
CurrentStepKey string `json:"current_step_key"`
|
||||||
|
IsOnboarded bool `json:"is_onboarded"`
|
||||||
|
// ApprovalStatus بعد از تکمیل آنبوردینگ معنا پیدا میکنه: pending/approved/rejected.
|
||||||
|
// وقتی pending هست، اپ باید صفحهی «در انتظار تایید ادمین» رو نشون بده.
|
||||||
|
ApprovalStatus string `json:"approval_status"`
|
||||||
|
RejectionReason *string `json:"rejection_reason,omitempty"`
|
||||||
|
Steps []StepView `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StepView struct {
|
||||||
|
Step int `json:"step"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// UpdateLocationRequest بهروزرسانی موقعیت مکانی کاربر (برای محاسبهی فاصلهی تقریبی)
|
||||||
|
type UpdateLocationRequest struct {
|
||||||
|
Latitude float64 `json:"latitude" binding:"required"`
|
||||||
|
Longitude float64 `json:"longitude" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBioRequest ویرایش بیوگرافی کوتاه پروفایل
|
||||||
|
type UpdateBioRequest struct {
|
||||||
|
Bio string `json:"bio" binding:"max=300"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckInResult نتیجهی ثبت ورود روزانه (برای استریک گیمیفیکیشن)
|
||||||
|
type CheckInResult struct {
|
||||||
|
StreakCount int `json:"streak_count"`
|
||||||
|
IsNewToday bool `json:"is_new_today"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNotificationSettingsRequest ترجیحات اعلان کاربر
|
||||||
|
type UpdateNotificationSettingsRequest struct {
|
||||||
|
NotifyMatches bool `json:"notify_matches"`
|
||||||
|
NotifyMessages bool `json:"notify_messages"`
|
||||||
|
NotifyLikes bool `json:"notify_likes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePublicKeyRequest ثبت/بهروزرسانی کلید عمومی X25519 کاربر برای
|
||||||
|
// رمزنگاری سرتاسری پیامها. کلید باید دقیقاً ۳۲ بایت خام باشه که بهصورت
|
||||||
|
// base64 استاندارد (۴۴ کاراکتر، شامل padding) کدگذاری شده.
|
||||||
|
type UpdatePublicKeyRequest struct {
|
||||||
|
PublicKey string `json:"public_key" binding:"required,len=44"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
"dating-app/internal/service"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminHandler struct {
|
||||||
|
adminService *service.AdminService
|
||||||
|
optionRepo *repository.OptionRepository
|
||||||
|
questionRepo *repository.QuestionRepository
|
||||||
|
photoRepo *repository.PhotoRepository
|
||||||
|
onboardingService *service.OnboardingService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminHandler(
|
||||||
|
adminService *service.AdminService,
|
||||||
|
optionRepo *repository.OptionRepository,
|
||||||
|
questionRepo *repository.QuestionRepository,
|
||||||
|
photoRepo *repository.PhotoRepository,
|
||||||
|
onboardingService *service.OnboardingService,
|
||||||
|
) *AdminHandler {
|
||||||
|
return &AdminHandler{
|
||||||
|
adminService: adminService,
|
||||||
|
optionRepo: optionRepo,
|
||||||
|
questionRepo: questionRepo,
|
||||||
|
photoRepo: photoRepo,
|
||||||
|
onboardingService: onboardingService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCategoryKey(key string) (models.CategoryKey, bool) {
|
||||||
|
k := models.CategoryKey(key)
|
||||||
|
for _, valid := range models.AllCategoryKeys {
|
||||||
|
if valid == k {
|
||||||
|
return k, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- دستهها ----------------
|
||||||
|
|
||||||
|
// GetCategory GET /admin/categories/:key
|
||||||
|
func (h *AdminHandler) GetCategory(c *gin.Context) {
|
||||||
|
key, ok := validCategoryKey(c.Param("key"))
|
||||||
|
if !ok {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cat, err := h.adminService.GetCategory(key)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "دسته یافت نشد", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", cat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCategory PATCH /admin/categories/:key
|
||||||
|
func (h *AdminHandler) UpdateCategory(c *gin.Context) {
|
||||||
|
key, ok := validCategoryKey(c.Param("key"))
|
||||||
|
if !ok {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.UpdateCategoryRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cat, err := h.adminService.UpdateCategory(key, req)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "دسته بهروزرسانی شد", cat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- گزینهها ----------------
|
||||||
|
|
||||||
|
// ListOptions GET /admin/categories/:key/options
|
||||||
|
func (h *AdminHandler) ListOptions(c *gin.Context) {
|
||||||
|
key, ok := validCategoryKey(c.Param("key"))
|
||||||
|
if !ok {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options, err := h.adminService.ListOptions(key)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزینهها", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOption POST /admin/categories/:key/options
|
||||||
|
func (h *AdminHandler) CreateOption(c *gin.Context) {
|
||||||
|
key, ok := validCategoryKey(c.Param("key"))
|
||||||
|
if !ok {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.CreateOptionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opt, err := h.adminService.CreateOption(key, req)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusCreated, "گزینه ساخته شد", opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOption PATCH /admin/options/:id
|
||||||
|
func (h *AdminHandler) UpdateOption(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.UpdateOptionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opt, err := h.adminService.UpdateOption(uint(id), req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "گزینه یافت نشد", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "گزینه بهروزرسانی شد", opt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOption DELETE /admin/options/:id
|
||||||
|
func (h *AdminHandler) DeleteOption(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.adminService.DeleteOption(uint(id)); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "حذف گزینه با خطا مواجه شد", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "گزینه حذف شد", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- بررسی و تایید کاربران ----------------
|
||||||
|
|
||||||
|
// ListPendingUsers GET /admin/users/pending
|
||||||
|
func (h *AdminHandler) ListPendingUsers(c *gin.Context) {
|
||||||
|
users, photosByUser, err := h.adminService.ListPendingUsers()
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := make([]dto.UserProfileWithPhotos, 0, len(users))
|
||||||
|
for i := range users {
|
||||||
|
photos := photosByUser[users[i].ID]
|
||||||
|
if photos == nil {
|
||||||
|
photos = []string{}
|
||||||
|
}
|
||||||
|
result = append(result, dto.UserProfileWithPhotos{
|
||||||
|
UserProfile: toUserProfile(&users[i]),
|
||||||
|
Photos: photos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserDetail GET /admin/users/:id — پروفایل کامل یک کاربر برای بررسی ادمین
|
||||||
|
func (h *AdminHandler) GetUserDetail(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.adminService.GetUser(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "کاربر یافت نشد", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", fullProfilePayload(user, h.optionRepo, h.questionRepo, h.onboardingService, h.photoRepo))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveUser POST /admin/users/:id/approve
|
||||||
|
func (h *AdminHandler) ApproveUser(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.adminService.ApproveUser(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
h.handleUserReviewError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "کاربر تایید شد", toUserProfile(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectUser POST /admin/users/:id/reject body: {reason}
|
||||||
|
func (h *AdminHandler) RejectUser(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.RejectUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل رد الزامی است)", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.adminService.RejectUser(uint(id), req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
h.handleUserReviewError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "کاربر رد شد", toUserProfile(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) handleUserReviewError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
utils.Fail(c, http.StatusNotFound, "کاربر یافت نشد", nil)
|
||||||
|
case errors.Is(err, service.ErrUserNotOnboarded):
|
||||||
|
utils.Fail(c, http.StatusConflict, err.Error(), nil)
|
||||||
|
default:
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// paginationParams صفحه/تعداد رو از کوئری استرینگ میخونه (پیشفرضهای معقول اگه نبود/نامعتبر بود)
|
||||||
|
func paginationParams(c *gin.Context, defaultSize int) (page, pageSize int) {
|
||||||
|
page = 1
|
||||||
|
if p, err := strconv.Atoi(c.Query("page")); err == nil && p > 0 {
|
||||||
|
page = p
|
||||||
|
}
|
||||||
|
pageSize = defaultSize
|
||||||
|
if ps, err := strconv.Atoi(c.Query("page_size")); err == nil && ps > 0 {
|
||||||
|
pageSize = ps
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- همهی کاربران، بلاک/آنبلاک، گزارش ----------------
|
||||||
|
|
||||||
|
// ListAllUsers GET /admin/users?search=&page=&page_size=
|
||||||
|
func (h *AdminHandler) ListAllUsers(c *gin.Context) {
|
||||||
|
page, pageSize := paginationParams(c, 25)
|
||||||
|
search := c.Query("search")
|
||||||
|
|
||||||
|
items, total, err := h.adminService.ListAllUsers(search, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", gin.H{
|
||||||
|
"items": items, "total": total, "page": page, "page_size": pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockUser POST /admin/users/:id/block body: {reason}
|
||||||
|
func (h *AdminHandler) BlockUser(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.BlockUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل بلاک الزامی است)", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.adminService.BlockUser(uint(id), req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
h.handleUserReviewError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "کاربر بلاک شد", toUserProfile(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnblockUser POST /admin/users/:id/unblock
|
||||||
|
func (h *AdminHandler) UnblockUser(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.adminService.UnblockUser(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
h.handleUserReviewError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "کاربر آنبلاک شد", toUserProfile(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportUser POST /admin/users/:id/report body: {reason}
|
||||||
|
func (h *AdminHandler) ReportUser(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.ReportUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل گزارش الزامی است)", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report, err := h.adminService.ReportUser(uint(id), req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
h.handleUserReviewError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusCreated, "گزارش ثبت شد", report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserReports GET /admin/users/:id/reports
|
||||||
|
func (h *AdminHandler) ListUserReports(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reports, err := h.adminService.ListReports(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزارشها", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", reports)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- مچها (رفقا) ----------------
|
||||||
|
|
||||||
|
// ListAllMatches GET /admin/matches?page=&page_size=
|
||||||
|
func (h *AdminHandler) ListAllMatches(c *gin.Context) {
|
||||||
|
page, pageSize := paginationParams(c, 25)
|
||||||
|
items, total, err := h.adminService.ListAllMatches(page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست مچها", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", gin.H{
|
||||||
|
"items": items, "total": total, "page": page, "page_size": pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- نظارت بر عکسها ----------------
|
||||||
|
|
||||||
|
// ListAllPhotos GET /admin/photos?page=&page_size=
|
||||||
|
func (h *AdminHandler) ListAllPhotos(c *gin.Context) {
|
||||||
|
page, pageSize := paginationParams(c, 50)
|
||||||
|
items, total, err := h.adminService.ListAllPhotos(page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت عکسها", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", gin.H{
|
||||||
|
"items": items, "total": total, "page": page, "page_size": pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePhoto DELETE /admin/photos/:id
|
||||||
|
func (h *AdminHandler) DeletePhoto(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.adminService.DeletePhotoAdmin(uint(id)); err != nil {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "عکس حذف شد", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportPhoto POST /admin/photos/:id/report body: {reason}
|
||||||
|
func (h *AdminHandler) ReportPhoto(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.ReportUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل گزارش الزامی است)", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report, err := h.adminService.ReportPhoto(uint(id), req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusCreated, "گزارش ثبت شد", report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- سوالات پروفایل ----------------
|
||||||
|
|
||||||
|
// ListQuestions GET /admin/profile-questions
|
||||||
|
func (h *AdminHandler) ListQuestions(c *gin.Context) {
|
||||||
|
qs, err := h.adminService.ListQuestions()
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت سوالات", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", qs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateQuestion POST /admin/profile-questions
|
||||||
|
func (h *AdminHandler) CreateQuestion(c *gin.Context) {
|
||||||
|
var req dto.CreateProfileQuestionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q, err := h.adminService.CreateQuestion(req)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusCreated, "سوال ساخته شد", q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateQuestion PATCH /admin/profile-questions/:id
|
||||||
|
func (h *AdminHandler) UpdateQuestion(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.UpdateProfileQuestionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q, err := h.adminService.UpdateQuestion(uint(id), req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "سوال یافت نشد", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "سوال بهروزرسانی شد", q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteQuestion DELETE /admin/profile-questions/:id
|
||||||
|
func (h *AdminHandler) DeleteQuestion(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.adminService.DeleteQuestion(uint(id)); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "حذف سوال با خطا مواجه شد", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "سوال حذف شد", nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/service"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
authService *service.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
||||||
|
return &AuthHandler{authService: authService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestOTP POST /auth/request-otp
|
||||||
|
func (h *AuthHandler) RequestOTP(c *gin.Context) {
|
||||||
|
var req dto.RequestOTPRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.authService.RequestOTP(req.Phone)
|
||||||
|
if err != nil {
|
||||||
|
var stillValid *service.OTPStillValidError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &stillValid):
|
||||||
|
utils.Fail(c, http.StatusTooManyRequests,
|
||||||
|
"کد تایید قبلی هنوز معتبر است", gin.H{"remaining_seconds": stillValid.RemainingSeconds})
|
||||||
|
case errors.Is(err, service.ErrPhoneBlocked):
|
||||||
|
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||||
|
case errors.Is(err, service.ErrSMSSendFailed):
|
||||||
|
utils.Fail(c, http.StatusServiceUnavailable, err.Error(), nil)
|
||||||
|
default:
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, http.StatusOK, "کد تایید با موفقیت ارسال شد", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyOTP POST /auth/verify-otp
|
||||||
|
func (h *AuthHandler) VerifyOTP(c *gin.Context) {
|
||||||
|
var req dto.VerifyOTPRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, access, refresh, isNew, err := h.authService.VerifyOTP(req.Phone, req.OTP)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrUserNotFound):
|
||||||
|
utils.Fail(c, http.StatusNotFound, err.Error(), nil)
|
||||||
|
case errors.Is(err, service.ErrOTPExpired), errors.Is(err, service.ErrOTPInvalid), errors.Is(err, service.ErrOTPNotRequested):
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
default:
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, http.StatusOK, "ورود با موفقیت انجام شد", dto.AuthResponse{
|
||||||
|
Tokens: dto.TokenPair{
|
||||||
|
AccessToken: access,
|
||||||
|
RefreshToken: refresh,
|
||||||
|
},
|
||||||
|
User: toUserProfile(user),
|
||||||
|
IsNewUser: isNew,
|
||||||
|
OnboardingDone: user.IsOnboarded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken POST /auth/token/refresh
|
||||||
|
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||||
|
var req dto.RefreshTokenRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
access, refresh, err := h.authService.RefreshTokens(req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusUnauthorized, "رفرش توکن نامعتبر یا منقضی شده است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, http.StatusOK, "توکن جدید صادر شد", dto.TokenPair{
|
||||||
|
AccessToken: access,
|
||||||
|
RefreshToken: refresh,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/service"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BackupHandler struct {
|
||||||
|
backupService *service.BackupService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBackupHandler(backupService *service.BackupService) *BackupHandler {
|
||||||
|
return &BackupHandler{backupService: backupService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List GET /admin/backups
|
||||||
|
func (h *BackupHandler) List(c *gin.Context) {
|
||||||
|
backups, err := h.backupService.List()
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست بکآپها", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", backups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create POST /admin/backups
|
||||||
|
func (h *BackupHandler) Create(c *gin.Context) {
|
||||||
|
info, err := h.backupService.Create()
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "گرفتن بکآپ ناموفق بود: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusCreated, "بکآپ با موفقیت ساخته شد", info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download GET /admin/backups/:filename
|
||||||
|
func (h *BackupHandler) Download(c *gin.Context) {
|
||||||
|
filename := c.Param("filename")
|
||||||
|
path, err := h.backupService.ResolvePath(filename)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "فایل بکآپ یافت نشد", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.FileAttachment(path, filename)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/service"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChatHandler struct {
|
||||||
|
chatService *service.ChatService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChatHandler(chatService *service.ChatService) *ChatHandler {
|
||||||
|
return &ChatHandler{chatService: chatService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMatches GET /matches
|
||||||
|
func (h *ChatHandler) ListMatches(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matches, err := h.chatService.ListMatches(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت مچها", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMessages GET /matches/:id/messages
|
||||||
|
func (h *ChatHandler) ListMessages(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messages, err := h.chatService.ListMessages(user.ID, uint(matchID))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage POST /matches/:id/messages body: {cipher_text, nonce}
|
||||||
|
// متن خام پیام هیچوقت اینجا نمیرسه؛ کلاینت قبل از ارسال با کلید عمومی
|
||||||
|
// طرف مقابل (NaCl box / X25519) رمزنگاریش کرده.
|
||||||
|
func (h *ChatHandler) SendMessage(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.SendMessageRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "پیام رمزنگاریشده نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.CipherText, req.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusCreated, "", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ChatHandler) handleError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrNotMatchMember):
|
||||||
|
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||||
|
case errors.Is(err, service.ErrInvalidMessageEncoding):
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
default:
|
||||||
|
utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/middleware"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
"dating-app/internal/service"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// currentUser کاربر جاری رو از context (که توسط AuthRequired ست شده) استخراج میکنه
|
||||||
|
func currentUser(c *gin.Context) (*models.User, bool) {
|
||||||
|
val, exists := c.Get(middleware.ContextUserKey)
|
||||||
|
if !exists {
|
||||||
|
utils.Fail(c, http.StatusUnauthorized, "احراز هویت لازم است", nil)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
user, ok := val.(*models.User)
|
||||||
|
if !ok {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUserProfile(u *models.User) dto.UserProfile {
|
||||||
|
profile := dto.UserProfile{
|
||||||
|
ID: u.ID,
|
||||||
|
Phone: u.Phone,
|
||||||
|
FirstName: u.FirstName,
|
||||||
|
CurrentStep: int(u.CurrentStep),
|
||||||
|
IsOnboarded: u.IsOnboarded,
|
||||||
|
ApprovalStatus: string(u.ApprovalStatus),
|
||||||
|
RejectionReason: u.RejectionReason,
|
||||||
|
IsAdmin: u.IsAdmin,
|
||||||
|
HeightCM: u.HeightCM,
|
||||||
|
WeightKG: u.WeightKG,
|
||||||
|
State: string(u.State),
|
||||||
|
BlockedReason: u.BlockedReason,
|
||||||
|
BlockedAt: u.BlockedAt,
|
||||||
|
Bio: u.Bio,
|
||||||
|
StreakCount: u.StreakCount,
|
||||||
|
NotifyMatches: u.NotifyMatches,
|
||||||
|
NotifyMessages: u.NotifyMessages,
|
||||||
|
NotifyLikes: u.NotifyLikes,
|
||||||
|
}
|
||||||
|
if u.Gender != nil {
|
||||||
|
g := string(*u.Gender)
|
||||||
|
profile.Gender = &g
|
||||||
|
}
|
||||||
|
if u.BirthDate != nil {
|
||||||
|
bd := u.BirthDate.Format("2006-01-02")
|
||||||
|
profile.BirthDate = &bd
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectionSummary یک دستهی انتخابی همراه با عنوان گزینههای انتخابشده
|
||||||
|
type selectionSummary struct {
|
||||||
|
CategoryKey string `json:"category_key"`
|
||||||
|
OptionIDs []uint `json:"option_ids"`
|
||||||
|
Titles []string `json:"titles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fullProfilePayload پروفایل کامل یک کاربر (اطلاعات پایه + همهی انتخابها/پاسخها)
|
||||||
|
// رو میسازه. هم توسط خود کاربر (GET /profile/me) و هم توسط ادمین موقع بررسی
|
||||||
|
// درخواست تایید (GET /admin/users/:id) استفاده میشه.
|
||||||
|
func fullProfilePayload(
|
||||||
|
user *models.User,
|
||||||
|
optionRepo *repository.OptionRepository,
|
||||||
|
questionRepo *repository.QuestionRepository,
|
||||||
|
onboardingService *service.OnboardingService,
|
||||||
|
photoRepo *repository.PhotoRepository,
|
||||||
|
) gin.H {
|
||||||
|
// دستهها از کش خونده میشن (تغییر بهندرت اتفاق میافته)، ولی خودِ
|
||||||
|
// انتخابهای کاربر توی همهی دستهها با یک کوئری واحد گرفته میشه؛ قبلاً
|
||||||
|
// اینجا بهازای هر ۶ دسته یک کوئری جدا زده میشد.
|
||||||
|
keyToCategoryID := make(map[models.CategoryKey]uint, len(models.AllCategoryKeys))
|
||||||
|
categoryIDs := make([]uint, 0, len(models.AllCategoryKeys))
|
||||||
|
for _, key := range models.AllCategoryKeys {
|
||||||
|
cat, err := optionRepo.GetCategoryByKey(key)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keyToCategoryID[key] = cat.ID
|
||||||
|
categoryIDs = append(categoryIDs, cat.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectionsByCategoryID, _ := optionRepo.GetUserSelectionsForCategories(user.ID, categoryIDs)
|
||||||
|
|
||||||
|
selections := make([]selectionSummary, 0, len(models.AllCategoryKeys))
|
||||||
|
for _, key := range models.AllCategoryKeys {
|
||||||
|
summary := selectionSummary{CategoryKey: string(key)}
|
||||||
|
for _, s := range selectionsByCategoryID[keyToCategoryID[key]] {
|
||||||
|
summary.OptionIDs = append(summary.OptionIDs, s.OptionID)
|
||||||
|
if s.Option != nil {
|
||||||
|
summary.Titles = append(summary.Titles, s.Option.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selections = append(selections, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
answers, _ := questionRepo.GetUserAnswers(user.ID)
|
||||||
|
photos, _ := photoRepo.ListByUser(user.ID)
|
||||||
|
|
||||||
|
return gin.H{
|
||||||
|
"user": toUserProfile(user),
|
||||||
|
"onboarding_status": onboardingService.GetStatus(user),
|
||||||
|
"selections": selections,
|
||||||
|
"profile_answers": answers,
|
||||||
|
"photos": photos,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/service"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiscoveryHandler struct {
|
||||||
|
discoveryService *service.DiscoveryService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiscoveryHandler(discoveryService *service.DiscoveryService) *DiscoveryHandler {
|
||||||
|
return &DiscoveryHandler{discoveryService: discoveryService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed GET /discover?max_distance_km=&min_age=&max_age=
|
||||||
|
func (h *DiscoveryHandler) Feed(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxDistance *float64
|
||||||
|
if raw := c.Query("max_distance_km"); raw != "" {
|
||||||
|
if v, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||||
|
maxDistance = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
minAge, _ := strconv.Atoi(c.Query("min_age"))
|
||||||
|
maxAge, _ := strconv.Atoi(c.Query("max_age"))
|
||||||
|
|
||||||
|
candidates, err := h.discoveryService.GetFeed(user, maxDistance, minAge, maxAge)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CandidateDetail GET /discover/:id
|
||||||
|
func (h *DiscoveryHandler) CandidateDetail(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail, err := h.discoveryService.GetCandidateDetail(user, uint(targetID))
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "پروفایل یافت نشد", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe POST /discover/:id/swipe body: {action: "like"|"pass"|"superlike"}
|
||||||
|
func (h *DiscoveryHandler) Swipe(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.SwipeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.discoveryService.Swipe(user, uint(targetID), models.SwipeAction(req.Action))
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DiscoveryHandler) handleError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrGenderNotSet):
|
||||||
|
utils.Fail(c, http.StatusConflict, err.Error(), nil)
|
||||||
|
case errors.Is(err, service.ErrCannotSwipeSelf), errors.Is(err, service.ErrAlreadySwiped):
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
default:
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/monitoring"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MonitoringHandler struct {
|
||||||
|
store *monitoring.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMonitoringHandler(store *monitoring.Store) *MonitoringHandler {
|
||||||
|
return &MonitoringHandler{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary GET /admin/monitoring/summary
|
||||||
|
func (h *MonitoringHandler) Summary(c *gin.Context) {
|
||||||
|
utils.Success(c, http.StatusOK, "", h.store.Summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentRequests GET /admin/monitoring/requests?limit=100&user_id=42
|
||||||
|
// اگه user_id داده بشه، فقط فعالیت اخیر همون کاربر برمیگرده (برای صفحهی
|
||||||
|
// جزئیات کاربر توی پنل ادمین)؛ وگرنه آخرین درخواستهای کل سرور.
|
||||||
|
func (h *MonitoringHandler) RecentRequests(c *gin.Context) {
|
||||||
|
limit := 100
|
||||||
|
if raw := c.Query("limit"); raw != "" {
|
||||||
|
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 1000 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw := c.Query("user_id"); raw != "" {
|
||||||
|
if uid, err := strconv.ParseUint(raw, 10, 64); err == nil {
|
||||||
|
utils.Success(c, http.StatusOK, "", h.store.RecentByUser(uint(uid), limit))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Success(c, http.StatusOK, "", h.store.Recent(limit))
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
"dating-app/internal/service"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OnboardingHandler struct {
|
||||||
|
onboardingService *service.OnboardingService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOnboardingHandler(onboardingService *service.OnboardingService) *OnboardingHandler {
|
||||||
|
return &OnboardingHandler{onboardingService: onboardingService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OnboardingHandler) handleStepError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrStepOutOfOrder):
|
||||||
|
utils.Fail(c, http.StatusConflict, err.Error(), nil)
|
||||||
|
case errors.Is(err, service.ErrInvalidBirthDate),
|
||||||
|
errors.Is(err, service.ErrOptionsNotFound),
|
||||||
|
errors.Is(err, service.ErrQuestionsInvalid),
|
||||||
|
errors.Is(err, service.ErrDuplicateQuestion):
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
case errors.Is(err, repository.ErrCategoryNotFound):
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "دستهی موردنیاز در سیستم تعریف نشده است", nil)
|
||||||
|
case errors.Is(err, service.ErrSelectionCount):
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
default:
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status GET /onboarding/status
|
||||||
|
func (h *OnboardingHandler) Status(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", h.onboardingService.GetStatus(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicInfo POST /onboarding/basic-info
|
||||||
|
func (h *OnboardingHandler) BasicInfo(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.BasicInfoRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.onboardingService.SetBasicInfo(user, req); err != nil {
|
||||||
|
h.handleStepError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "اطلاعات پایه ثبت شد", h.onboardingService.GetStatus(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos POST /onboarding/photos — تایید مرحلهی عکسها (خودِ آپلود از /profile/photos انجام میشه)
|
||||||
|
func (h *OnboardingHandler) Photos(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.onboardingService.ConfirmPhotos(user); err != nil {
|
||||||
|
h.handleStepError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "عکسها ثبت شد", h.onboardingService.GetStatus(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyInfo POST /onboarding/body-info
|
||||||
|
func (h *OnboardingHandler) BodyInfo(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.BodyInfoRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.onboardingService.SetBodyInfo(user, req); err != nil {
|
||||||
|
h.handleStepError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "قد و وزن ثبت شد", h.onboardingService.GetStatus(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailableQuestions GET /onboarding/profile-questions
|
||||||
|
func (h *OnboardingHandler) AvailableQuestions(c *gin.Context) {
|
||||||
|
questions, err := h.onboardingService.GetAvailableQuestions()
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت سوالات", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", questions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProfileQuestions POST /onboarding/profile-questions
|
||||||
|
func (h *OnboardingHandler) SetProfileQuestions(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.ProfileQuestionsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (باید دقیقاً ۳ سوال با پاسخ ارسال شود)", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.onboardingService.SetProfileQuestions(user, req); err != nil {
|
||||||
|
h.handleStepError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "پاسخها ثبت شد", h.onboardingService.GetStatus(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Education POST /onboarding/education
|
||||||
|
func (h *OnboardingHandler) Education(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.EducationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.onboardingService.SetEducation(user, req); err != nil {
|
||||||
|
h.handleStepError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "اطلاعات تحصیلی ثبت شد", h.onboardingService.GetStatus(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occupations POST /onboarding/occupations
|
||||||
|
func (h *OnboardingHandler) Occupations(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.OptionSelectionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.onboardingService.SetOccupations(user, req.OptionIDs); err != nil {
|
||||||
|
h.handleStepError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "فعالیتها ثبت شد", h.onboardingService.GetStatus(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationshipImportance POST /onboarding/relationship-importance
|
||||||
|
func (h *OnboardingHandler) RelationshipImportance(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.OptionSelectionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.onboardingService.SetRelationshipImportance(user, req.OptionIDs); err != nil {
|
||||||
|
h.handleStepError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "موارد مهم در رابطه ثبت شد", h.onboardingService.GetStatus(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interests POST /onboarding/interests
|
||||||
|
func (h *OnboardingHandler) Interests(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.OptionSelectionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.onboardingService.SetInterests(user, req.OptionIDs); err != nil {
|
||||||
|
h.handleStepError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "علاقهمندیها ثبت شد", h.onboardingService.GetStatus(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookingFor POST /onboarding/looking-for
|
||||||
|
func (h *OnboardingHandler) LookingFor(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.OptionSelectionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.onboardingService.SetLookingFor(user, req.OptionIDs); err != nil {
|
||||||
|
h.handleStepError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "هدف شما ثبت شد", h.onboardingService.GetStatus(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options GET /onboarding/options/:category
|
||||||
|
// category یکی از: education_level, field_of_study, occupation,
|
||||||
|
// important_in_relationship, interest, looking_for
|
||||||
|
func (h *OnboardingHandler) Options(c *gin.Context) {
|
||||||
|
key := models.CategoryKey(c.Param("category"))
|
||||||
|
valid := false
|
||||||
|
for _, k := range models.AllCategoryKeys {
|
||||||
|
if k == key {
|
||||||
|
valid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options, err := h.onboardingService.GetActiveOptions(key)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزینهها", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", options)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
"dating-app/internal/service"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProfileHandler struct {
|
||||||
|
optionRepo *repository.OptionRepository
|
||||||
|
questionRepo *repository.QuestionRepository
|
||||||
|
photoRepo *repository.PhotoRepository
|
||||||
|
onboardingService *service.OnboardingService
|
||||||
|
profileService *service.ProfileService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProfileHandler(
|
||||||
|
optionRepo *repository.OptionRepository,
|
||||||
|
questionRepo *repository.QuestionRepository,
|
||||||
|
photoRepo *repository.PhotoRepository,
|
||||||
|
onboardingService *service.OnboardingService,
|
||||||
|
profileService *service.ProfileService,
|
||||||
|
) *ProfileHandler {
|
||||||
|
return &ProfileHandler{
|
||||||
|
optionRepo: optionRepo,
|
||||||
|
questionRepo: questionRepo,
|
||||||
|
photoRepo: photoRepo,
|
||||||
|
onboardingService: onboardingService,
|
||||||
|
profileService: profileService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me GET /profile/me — پروفایل کامل کاربر شامل تمام انتخابها و پاسخها
|
||||||
|
func (h *ProfileHandler) Me(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := fullProfilePayload(user, h.optionRepo, h.questionRepo, h.onboardingService, h.photoRepo)
|
||||||
|
utils.Success(c, http.StatusOK, "اطلاعات پروفایل با موفقیت دریافت شد", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadPhoto POST /profile/photos (multipart/form-data, فیلد "photo")
|
||||||
|
func (h *ProfileHandler) UploadPhoto(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileHeader, err := c.FormFile("photo")
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "فایل عکس ارسال نشده", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
|
||||||
|
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "خطا در خواندن فایل", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "خطا در خواندن فایل", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
photo, err := h.profileService.UploadPhoto(user.ID, ext, data)
|
||||||
|
if err != nil {
|
||||||
|
h.handlePhotoError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusCreated, "عکس اضافه شد", photo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePhoto DELETE /profile/photos/:id
|
||||||
|
func (h *ProfileHandler) DeletePhoto(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.profileService.DeletePhoto(user.ID, uint(id)); err != nil {
|
||||||
|
h.handlePhotoError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "عکس حذف شد", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProfileHandler) handlePhotoError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil)
|
||||||
|
case errors.Is(err, service.ErrPhotoNotOwned):
|
||||||
|
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||||
|
case errors.Is(err, service.ErrTooManyPhotos),
|
||||||
|
errors.Is(err, service.ErrInvalidImage),
|
||||||
|
errors.Is(err, service.ErrImageTooLarge):
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
default:
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی عکس", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLocation PATCH /profile/location
|
||||||
|
func (h *ProfileHandler) UpdateLocation(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.UpdateLocationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.profileService.UpdateLocation(user, req.Latitude, req.Longitude); err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی موقعیت مکانی", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "موقعیت مکانی ثبت شد", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBio PATCH /profile/bio body: {bio}
|
||||||
|
func (h *ProfileHandler) UpdateBio(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.UpdateBioRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "بیوگرافی نباید بیشتر از ۳۰۰ کاراکتر باشه", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.profileService.UpdateBio(user, req.Bio); err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی بیوگرافی", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "بیوگرافی ذخیره شد", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckIn POST /profile/checkin — ثبت ورود روزانه برای استریک گیمیفیکیشن
|
||||||
|
func (h *ProfileHandler) CheckIn(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.profileService.CheckIn(user)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در ثبت ورود روزانه", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateNotificationSettings PATCH /profile/notifications
|
||||||
|
func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.UpdateNotificationSettingsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.profileService.UpdateNotificationSettings(user, req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی تنظیمات اعلان", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "تنظیمات اعلان ذخیره شد", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAccount DELETE /profile/me — غیرفعال کردن (soft delete) حساب کاربر
|
||||||
|
func (h *ProfileHandler) DeleteAccount(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.profileService.DeleteAccount(user); err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در حذف حساب", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePublicKey PUT /profile/public-key — ثبت/جایگزینی کلید عمومی X25519
|
||||||
|
// برای رمزنگاری سرتاسری پیامها. کلاینت این کلید رو خودش تولید میکنه؛ کلید
|
||||||
|
// خصوصی متناظرش هیچوقت به این endpoint (یا هیچ endpoint دیگهای) فرستاده نمیشه.
|
||||||
|
func (h *ProfileHandler) UpdatePublicKey(c *gin.Context) {
|
||||||
|
user, ok := currentUser(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req dto.UpdatePublicKeyRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, "کلید عمومی نامعتبر است", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.profileService.UpdatePublicKey(user, req.PublicKey); err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidPublicKey) {
|
||||||
|
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی کلید عمومی", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Success(c, http.StatusOK, "کلید عمومی ذخیره شد", nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminRequired باید بعد از AuthRequired استفاده بشه. فقط کاربرانی که IsAdmin=true
|
||||||
|
// دارن اجازهی عبور دارن.
|
||||||
|
func AdminRequired() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userVal, exists := c.Get(ContextUserKey)
|
||||||
|
if !exists {
|
||||||
|
utils.Fail(c, http.StatusUnauthorized, "احراز هویت لازم است", nil)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, ok := userVal.(*models.User)
|
||||||
|
if !ok || !user.IsAdmin {
|
||||||
|
utils.Fail(c, http.StatusForbidden, "دسترسی فقط برای ادمین است", nil)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ContextUserKey = "current_user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthRequired توکن access رو از هدر Authorization میخونه، اعتبارسنجی میکنه
|
||||||
|
// و کاربر مربوطه رو توی context قرار میده.
|
||||||
|
func AuthRequired(jwtManager *utils.JWTManager, userRepo *repository.UserRepository) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
header := c.GetHeader("Authorization")
|
||||||
|
if header == "" || !strings.HasPrefix(header, "Bearer ") {
|
||||||
|
utils.Fail(c, http.StatusUnauthorized, "توکن احراز هویت ارسال نشده است", nil)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
||||||
|
claims, err := jwtManager.ParseAccessToken(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusUnauthorized, "توکن نامعتبر یا منقضی شده است", nil)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := userRepo.FindByID(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusUnauthorized, "کاربر یافت نشد", nil)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !user.IsActive {
|
||||||
|
utils.Fail(c, http.StatusForbidden, "حساب کاربری غیرفعال است", nil)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// اگه کاربر بعد از صادر شدن access token فعلی بلاک شده باشه، نباید تا
|
||||||
|
// انقضای طبیعی همون توکن بتونه درخواست بزنه؛ این چک همون لحظه قطعش میکنه.
|
||||||
|
if user.State == models.UserStateBlocked {
|
||||||
|
utils.Fail(c, http.StatusForbidden, "حساب کاربری مسدود شده است", nil)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(ContextUserKey, user)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CORS یک میانافزار ساده برای اجازه دادن به درخواستهای Cross-Origin
|
||||||
|
// (مناسب برای توسعه؛ در پروداکشن origin ها رو محدود کن)
|
||||||
|
func CORS() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
|
||||||
|
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gzipWriter struct {
|
||||||
|
gin.ResponseWriter
|
||||||
|
writer io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gzipWriter) Write(data []byte) (int, error) {
|
||||||
|
return g.writer.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *gzipWriter) WriteString(s string) (int, error) {
|
||||||
|
return g.writer.Write([]byte(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gzip پاسخهای JSON رو (وقتی کلاینت gzip رو توی Accept-Encoding پشتیبانی کنه)
|
||||||
|
// فشرده میکنه؛ بدون هیچ وابستگی بیرونی، فقط با compress/gzip استاندارد گو.
|
||||||
|
// برای مسیرهایی مثل فید دیسکاوری که پاسخ JSON نسبتاً بزرگی برمیگردونن، حجم
|
||||||
|
// انتقال رو محسوس کم میکنه.
|
||||||
|
func Gzip() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// فایلهای عکسِ آپلودشده از قبل فشردهان (jpeg/png/webp)؛ دوباره gzip
|
||||||
|
// کردنشون فقط CPU هدر میده بدون فایدهی محسوس.
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/uploads") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gz := gzip.NewWriter(c.Writer)
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
c.Header("Content-Encoding", "gzip")
|
||||||
|
c.Header("Vary", "Accept-Encoding")
|
||||||
|
c.Writer = &gzipWriter{ResponseWriter: c.Writer, writer: gz}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/monitoring"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metrics هر درخواست رو (متد، مسیر، کد پاسخ، مدت زمان) توی Store ثبت میکنه.
|
||||||
|
// باید قبل از Recovery ثبت بشه (بیرونیتر) تا حتی درخواستهایی که پنیک میکنن
|
||||||
|
// و توسط Recovery مهار میشن هم توی آمار لحاظ بشن.
|
||||||
|
func Metrics(store *monitoring.Store) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
path := c.FullPath()
|
||||||
|
if path == "" {
|
||||||
|
path = c.Request.URL.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := monitoring.RequestRecord{
|
||||||
|
Time: start,
|
||||||
|
Method: c.Request.Method,
|
||||||
|
Path: path,
|
||||||
|
StatusCode: c.Writer.Status(),
|
||||||
|
DurationMs: float64(time.Since(start).Microseconds()) / 1000.0,
|
||||||
|
ClientIP: c.ClientIP(),
|
||||||
|
}
|
||||||
|
if userVal, exists := c.Get(ContextUserKey); exists {
|
||||||
|
if user, ok := userVal.(*models.User); ok {
|
||||||
|
id := user.ID
|
||||||
|
rec.UserID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.Record(rec)
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bucket struct {
|
||||||
|
tokens float64
|
||||||
|
lastSeenAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiter یک rate limiter سادهی token-bucket بهازای هر IP؛ برای جلوگیری
|
||||||
|
// از سوءاستفاده از اندپوینتهای حساس مثل درخواست OTP (که هزینهی پیامک داره
|
||||||
|
// و هدف مناسبی برای اسپم/brute-force هست). maxTokens یعنی سقف درخواست
|
||||||
|
// پشتسرهم مجاز، refillPerSecond یعنی سرعت شارژ دوبارهی سطل.
|
||||||
|
func RateLimiter(maxTokens, refillPerSecond float64) gin.HandlerFunc {
|
||||||
|
var mu sync.Mutex
|
||||||
|
buckets := make(map[string]*bucket)
|
||||||
|
|
||||||
|
// سطلهای راکد (IP هایی که مدتیه درخواست نزدن) رو دورهای پاک میکنه تا
|
||||||
|
// حافظه با تعداد IP های دیدهشده بینهایت رشد نکنه.
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(10 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
cutoff := time.Now().Add(-10 * time.Minute)
|
||||||
|
mu.Lock()
|
||||||
|
for k, b := range buckets {
|
||||||
|
if b.lastSeenAt.Before(cutoff) {
|
||||||
|
delete(buckets, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
key := c.ClientIP()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
b, ok := buckets[key]
|
||||||
|
if !ok {
|
||||||
|
b = &bucket{tokens: maxTokens, lastSeenAt: now}
|
||||||
|
buckets[key] = b
|
||||||
|
}
|
||||||
|
elapsed := now.Sub(b.lastSeenAt).Seconds()
|
||||||
|
b.tokens = min(maxTokens, b.tokens+elapsed*refillPerSecond)
|
||||||
|
b.lastSeenAt = now
|
||||||
|
|
||||||
|
allowed := b.tokens >= 1
|
||||||
|
if allowed {
|
||||||
|
b.tokens--
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if !allowed {
|
||||||
|
utils.Fail(c, http.StatusTooManyRequests, "درخواستهای زیادی فرستادی؛ کمی صبر کن و دوباره امتحان کن", nil)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestEngine(mw gin.HandlerFunc) *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
engine := gin.New()
|
||||||
|
engine.GET("/x", mw, func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
})
|
||||||
|
return engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiter_AllowsWithinLimit(t *testing.T) {
|
||||||
|
engine := newTestEngine(RateLimiter(3, 1))
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
req.RemoteAddr = "1.2.3.4:1111"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiter_BlocksOverLimit(t *testing.T) {
|
||||||
|
engine := newTestEngine(RateLimiter(2, 0)) // بدون شارژ دوباره، فقط ۲ تا مجازه
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
req.RemoteAddr = "5.6.7.8:2222"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
req.RemoteAddr = "5.6.7.8:2222"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("expected 429 on 3rd request, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiter_SeparateBucketsPerIP(t *testing.T) {
|
||||||
|
engine := newTestEngine(RateLimiter(1, 0))
|
||||||
|
|
||||||
|
req1 := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
req1.RemoteAddr = "9.9.9.9:1"
|
||||||
|
w1 := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(w1, req1)
|
||||||
|
if w1.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for first IP, got %d", w1.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||||
|
req2.RemoteAddr = "8.8.8.8:1"
|
||||||
|
w2 := httptest.NewRecorder()
|
||||||
|
engine.ServeHTTP(w2, req2)
|
||||||
|
if w2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 for a different IP (separate bucket), got %d", w2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Recovery از کرش کردن کل پروسه بهخاطر panic غیرمنتظرهی یک هندلر
|
||||||
|
// (nil pointer، index out of range و...) جلوگیری میکنه: استکتریس رو لاگ
|
||||||
|
// میکنه و بهجای متن سادهی پیشفرض Gin، همون envelope استاندارد JSON
|
||||||
|
// پروژه (utils.Fail) رو برمیگردونه تا کلاینتها فرمت یکسانی ببینن.
|
||||||
|
func Recovery() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[PANIC RECOVERED] %v\n%s", r, debug.Stack())
|
||||||
|
if !c.Writer.Written() {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطای غیرمنتظرهای رخ داد؛ لطفاً دوباره امتحان کن", nil)
|
||||||
|
}
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Photo یکی از عکسهای پروفایل کاربر
|
||||||
|
type Photo struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||||
|
URL string `gorm:"size:500;not null" json:"url"`
|
||||||
|
SortOrder int `gorm:"default:0;not null" json:"sort_order"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Photo) TableName() string {
|
||||||
|
return "photos"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwipeAction نوع تصمیمی که کاربر روی یک پروفایل دیگه گرفته
|
||||||
|
type SwipeAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SwipeLike SwipeAction = "like"
|
||||||
|
SwipeSuperlike SwipeAction = "superlike"
|
||||||
|
SwipePass SwipeAction = "pass"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Swipe ثبت تصمیم یک کاربر (لایک/ردّ) روی یک کاربر دیگه.
|
||||||
|
// هر جفت (SwiperID, SwipedID) فقط یک بار میتونه ثبت بشه؛ توی discovery feed
|
||||||
|
// کسایی که قبلاً روشون تصمیم گرفته شده دیگه نشون داده نمیشن.
|
||||||
|
type Swipe struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
SwiperID uint `gorm:"not null;uniqueIndex:idx_swiper_swiped" json:"swiper_id"`
|
||||||
|
SwipedID uint `gorm:"not null;uniqueIndex:idx_swiper_swiped;index" json:"swiped_id"`
|
||||||
|
Action SwipeAction `gorm:"size:10;not null" json:"action"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Swipe) TableName() string {
|
||||||
|
return "swipes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match وقتی ساخته میشه که دو کاربر متقابلاً همدیگه رو لایک کرده باشن.
|
||||||
|
// UserOneID همیشه شناسهی کوچیکتره (برای جلوگیری از رکورد تکراری در دو جهت).
|
||||||
|
type Match struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserOneID uint `gorm:"not null;uniqueIndex:idx_match_pair" json:"user_one_id"`
|
||||||
|
UserTwoID uint `gorm:"not null;uniqueIndex:idx_match_pair" json:"user_two_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
UserOne *User `gorm:"foreignKey:UserOneID" json:"-"`
|
||||||
|
UserTwo *User `gorm:"foreignKey:UserTwoID" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Match) TableName() string {
|
||||||
|
return "matches"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message یک پیام داخل یک مچ. از رمزنگاری سرتاسری (E2EE) استفاده میشه:
|
||||||
|
// سرور فقط CipherText+Nonce (خروجی رمزنگاریشدهی سمت کلاینت با NaCl box،
|
||||||
|
// X25519-XSalsa20-Poly1305) رو ذخیره میکنه و هیچوقت متن خام پیام رو نمیبینه.
|
||||||
|
// Text قدیمی (قبل از اضافه شدن E2EE) برای نمایش پیامهای تاریخی نگه داشته میشه؛
|
||||||
|
// پیامهای جدید Text خالی دارن و بهجاش CipherText/Nonce پر میشه.
|
||||||
|
type Message struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
MatchID uint `gorm:"not null;index" json:"match_id"`
|
||||||
|
SenderID uint `gorm:"not null" json:"sender_id"`
|
||||||
|
Text string `gorm:"size:2000;not null" json:"text"`
|
||||||
|
CipherText *string `gorm:"size:12000" json:"cipher_text,omitempty"`
|
||||||
|
Nonce *string `gorm:"size:64" json:"nonce,omitempty"`
|
||||||
|
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Message) TableName() string {
|
||||||
|
return "messages"
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CategoryKey شناسهی ثابت هر دستهی لیست انتخابی.
|
||||||
|
// این کلیدها توی کد استفاده میشن (برای هر مرحله از آنبوردینگ)
|
||||||
|
// ولی گزینههای داخل هر دسته کاملاً از پنل ادمین قابل مدیریت هستن.
|
||||||
|
type CategoryKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CategoryEducationLevel CategoryKey = "education_level"
|
||||||
|
CategoryFieldOfStudy CategoryKey = "field_of_study"
|
||||||
|
CategoryOccupation CategoryKey = "occupation"
|
||||||
|
CategoryImportantInRelationship CategoryKey = "important_in_relationship"
|
||||||
|
CategoryInterest CategoryKey = "interest"
|
||||||
|
CategoryLookingFor CategoryKey = "looking_for"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllCategoryKeys برای seed کردن و اعتبارسنجی
|
||||||
|
var AllCategoryKeys = []CategoryKey{
|
||||||
|
CategoryEducationLevel,
|
||||||
|
CategoryFieldOfStudy,
|
||||||
|
CategoryOccupation,
|
||||||
|
CategoryImportantInRelationship,
|
||||||
|
CategoryInterest,
|
||||||
|
CategoryLookingFor,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category یک دستهی لیست انتخابی (مثلاً «علاقهمندیها»).
|
||||||
|
// MinSelect/MaxSelect تعداد مجاز انتخاب رو مشخص میکنه (MaxSelect=0 یعنی نامحدود).
|
||||||
|
type Category struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Key CategoryKey `gorm:"size:50;uniqueIndex;not null" json:"key"`
|
||||||
|
Title string `gorm:"size:150;not null" json:"title"`
|
||||||
|
// توجه: عمداً تگ gorm:"default:..." روی این دو فیلد گذاشته نشده.
|
||||||
|
// GORM مقادیر صفر (zero value) رو برای ستونهایی که تگ default دارن از
|
||||||
|
// INSERT حذف میکنه (چون نمیتونه "صفر عمدی" رو از "ست نشده" تشخیص بده)
|
||||||
|
// و این یعنی MaxSelect=0 (بهمعنی نامحدود) بهجای ذخیرهشدن، با دیفالت
|
||||||
|
// دیتابیس جایگزین میشد. چون همیشه این دو مقدار رو صریح ست میکنیم
|
||||||
|
// (در seed و در آپدیت ادمین)، نیازی به دیفالت دیتابیسی نیست.
|
||||||
|
MinSelect int `gorm:"not null" json:"min_select"`
|
||||||
|
MaxSelect int `gorm:"not null" json:"max_select"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
Options []Option `gorm:"foreignKey:CategoryID" json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Category) TableName() string {
|
||||||
|
return "categories"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option یک آیتم قابلانتخاب داخل یک دسته (مدیریتشده توسط ادمین)
|
||||||
|
type Option struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
CategoryID uint `gorm:"not null;index" json:"category_id"`
|
||||||
|
Title string `gorm:"size:150;not null" json:"title"`
|
||||||
|
SortOrder int `gorm:"default:0;not null" json:"sort_order"`
|
||||||
|
IsActive bool `gorm:"default:true;not null" json:"is_active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Option) TableName() string {
|
||||||
|
return "options"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserOptionSelection انتخابهای کاربر از یک دستهی مشخص.
|
||||||
|
// برای هر مرحله (مثل علاقهمندیها) چند ردیف از این جدول ساخته میشه.
|
||||||
|
type UserOptionSelection struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;uniqueIndex:idx_user_category_option" json:"user_id"`
|
||||||
|
CategoryID uint `gorm:"not null;uniqueIndex:idx_user_category_option;index" json:"category_id"`
|
||||||
|
OptionID uint `gorm:"not null;uniqueIndex:idx_user_category_option" json:"option_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
Option *Option `gorm:"foreignKey:OptionID" json:"option,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserOptionSelection) TableName() string {
|
||||||
|
return "user_option_selections"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ProfileQuestion سوالاتی که ادمین از پیش در پنل وارد میکند
|
||||||
|
// (مثلاً «بزرگترین درسی که از زندگی گرفتی چیه؟») و کاربر باید
|
||||||
|
// دقیقاً ۳ تا از آنها را انتخاب و برایش پاسخ بنویسد.
|
||||||
|
type ProfileQuestion struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Text string `gorm:"size:255;not null" json:"text"`
|
||||||
|
SortOrder int `gorm:"default:0;not null" json:"sort_order"`
|
||||||
|
IsActive bool `gorm:"default:true;not null" json:"is_active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ProfileQuestion) TableName() string {
|
||||||
|
return "profile_questions"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserProfileAnswer پاسخ کاربر به یکی از سوالات انتخابی
|
||||||
|
type UserProfileAnswer struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;uniqueIndex:idx_user_question" json:"user_id"`
|
||||||
|
QuestionID uint `gorm:"not null;uniqueIndex:idx_user_question" json:"question_id"`
|
||||||
|
Answer string `gorm:"type:text;not null" json:"answer"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
Question *ProfileQuestion `gorm:"foreignKey:QuestionID" json:"question,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserProfileAnswer) TableName() string {
|
||||||
|
return "user_profile_answers"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Report یک یادداشت/پرچم نظارتی که ادمین روی یک کاربر (یا یک عکس مشخص از
|
||||||
|
// اون کاربر) ثبت میکنه؛ برای پیگیری سابقهی نظارتی، مستقل از بلاک/حذف کردن.
|
||||||
|
type Report struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||||
|
PhotoID *uint `json:"photo_id,omitempty"`
|
||||||
|
Reason string `gorm:"type:text;not null" json:"reason"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Report) TableName() string {
|
||||||
|
return "reports"
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Gender جنسیت کاربر
|
||||||
|
type Gender string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GenderMale Gender = "male"
|
||||||
|
GenderFemale Gender = "female"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserState وضعیت کلی کاربر
|
||||||
|
type UserState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserStateInactive UserState = "inactive"
|
||||||
|
UserStateActive UserState = "active"
|
||||||
|
UserStateBlocked UserState = "blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OnboardingStep مرحلهی فعلی کاربر در فرآیند تکمیل پروفایل.
|
||||||
|
// هر مقدار یعنی «این مرحله هنوز باید تکمیل بشه». وقتی به StepCompleted
|
||||||
|
// میرسه یعنی کاربر تمام مراحل رو طی کرده.
|
||||||
|
type OnboardingStep int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StepBasicInfo OnboardingStep = iota + 1
|
||||||
|
StepPhotos
|
||||||
|
StepBodyInfo
|
||||||
|
StepProfileQuestions
|
||||||
|
StepEducation
|
||||||
|
StepOccupations
|
||||||
|
StepRelationshipImportance
|
||||||
|
StepInterests
|
||||||
|
StepLookingFor
|
||||||
|
StepCompleted
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApprovalStatus وضعیت بررسی پروفایل توسط ادمین (بعد از تکمیل آنبوردینگ)
|
||||||
|
type ApprovalStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ApprovalPending ApprovalStatus = "pending"
|
||||||
|
ApprovalApproved ApprovalStatus = "approved"
|
||||||
|
ApprovalRejected ApprovalStatus = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StepInfo برای پاسخهای API که وضعیت هر مرحله رو توضیح میده
|
||||||
|
type StepInfo struct {
|
||||||
|
Step OnboardingStep `json:"step"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ordered لیست ترتیب مراحل آنبوردینگ (برای ساخت وضعیت کامل)
|
||||||
|
var OnboardingStepOrder = []struct {
|
||||||
|
Step OnboardingStep
|
||||||
|
Key string
|
||||||
|
}{
|
||||||
|
{StepBasicInfo, "basic_info"},
|
||||||
|
{StepPhotos, "photos"},
|
||||||
|
{StepBodyInfo, "body_info"},
|
||||||
|
{StepProfileQuestions, "profile_questions"},
|
||||||
|
{StepEducation, "education"},
|
||||||
|
{StepOccupations, "occupations"},
|
||||||
|
{StepRelationshipImportance, "relationship_importance"},
|
||||||
|
{StepInterests, "interests"},
|
||||||
|
{StepLookingFor, "looking_for"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// User کاربر اصلی سیستم. احراز هویت فقط با شماره موبایل + OTP انجام میشود.
|
||||||
|
type User struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Phone string `gorm:"size:11;uniqueIndex;not null" json:"phone"`
|
||||||
|
State UserState `gorm:"size:10;default:inactive;not null" json:"state"`
|
||||||
|
IsAdmin bool `gorm:"default:false;not null" json:"is_admin"`
|
||||||
|
IsActive bool `gorm:"default:true;not null" json:"is_active"`
|
||||||
|
|
||||||
|
// -------- بلاک شدن توسط ادمین --------
|
||||||
|
BlockedReason *string `json:"blocked_reason,omitempty"`
|
||||||
|
BlockedAt *time.Time `json:"blocked_at,omitempty"`
|
||||||
|
|
||||||
|
// فیلدهای OTP - هش میشوند و هرگز در پاسخ API برنمیگردند
|
||||||
|
OTPCode *string `gorm:"size:128" json:"-"`
|
||||||
|
OTPExpiry *time.Time `json:"-"`
|
||||||
|
|
||||||
|
// -------- اطلاعات پروفایل (پر میشوند طی فرآیند آنبوردینگ) --------
|
||||||
|
Gender *Gender `gorm:"size:10" json:"gender,omitempty"`
|
||||||
|
FirstName string `gorm:"size:100" json:"first_name,omitempty"`
|
||||||
|
BirthDate *time.Time `json:"birth_date,omitempty"`
|
||||||
|
HeightCM *int `json:"height_cm,omitempty"`
|
||||||
|
WeightKG *int `json:"weight_kg,omitempty"`
|
||||||
|
Bio *string `gorm:"size:300" json:"bio,omitempty"`
|
||||||
|
|
||||||
|
// -------- گیمیفیکیشن: استریک روزانه (چند روز پشتسرهم اپ باز شده) --------
|
||||||
|
StreakCount int `gorm:"default:0;not null" json:"streak_count"`
|
||||||
|
LastActiveDate *time.Time `json:"last_active_date,omitempty"`
|
||||||
|
|
||||||
|
// -------- ترجیحات اعلان --------
|
||||||
|
// فعلاً فقط ذخیره میشن (زیرساخت ارسال واقعی اعلان هنوز پیاده نشده)؛ وقتی
|
||||||
|
// اون سیستم اضافه شد، همین مقادیر رو برای تصمیم به ارسال یا نه میخونه.
|
||||||
|
NotifyMatches bool `gorm:"default:true;not null" json:"notify_matches"`
|
||||||
|
NotifyMessages bool `gorm:"default:true;not null" json:"notify_messages"`
|
||||||
|
NotifyLikes bool `gorm:"default:true;not null" json:"notify_likes"`
|
||||||
|
|
||||||
|
// -------- موقعیت مکانی (برای محاسبهی فاصلهی تقریبی در دوستیابی) --------
|
||||||
|
Latitude *float64 `json:"-"`
|
||||||
|
Longitude *float64 `json:"-"`
|
||||||
|
|
||||||
|
// -------- کلید عمومی رمزنگاری سرتاسری پیامها (E2EE) --------
|
||||||
|
// کلید خصوصی هیچوقت به سرور نمیرسه و فقط روی دستگاه کاربر (localStorage)
|
||||||
|
// ذخیره میشه؛ سرور فقط این کلید عمومی رو نگه میداره تا طرف مقابل بتونه
|
||||||
|
// باهاش پیام رمزنگاری کنه. کلید X25519 هست، base64-encoded (۳۲ بایت خام).
|
||||||
|
PublicKey *string `gorm:"size:64" json:"public_key,omitempty"`
|
||||||
|
|
||||||
|
// -------- وضعیت آنبوردینگ --------
|
||||||
|
CurrentStep OnboardingStep `gorm:"default:1;not null" json:"current_step"`
|
||||||
|
IsOnboarded bool `gorm:"default:false;not null" json:"is_onboarded"`
|
||||||
|
|
||||||
|
// -------- بررسی ادمین (بعد از تکمیل آنبوردینگ) --------
|
||||||
|
// تا وقتی approved نشه، کاربر اجازهی ورود به صفحهی دوستیابی رو نداره.
|
||||||
|
ApprovalStatus ApprovalStatus `gorm:"size:10;default:pending;not null" json:"approval_status"`
|
||||||
|
RejectionReason *string `json:"rejection_reason,omitempty"`
|
||||||
|
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||||
|
|
||||||
|
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (User) TableName() string {
|
||||||
|
return "users"
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Package monitoring یک ذخیرهساز سبک و درونحافظهای برای دیدبانی درخواستهاست:
|
||||||
|
// آخرین درخواستها (برای دیباگ لحظهای) + آمار تجمیعی (بار، زمان پاسخ، نرخ خطا).
|
||||||
|
// عمداً بدون وابستگی بیرونی (بدون Prometheus/Redis) چون در این مقیاس یک حلقهی
|
||||||
|
// درونحافظهای thread-safe کافیه؛ اگه بعداً مقیاس بزرگتر شد، همین Store رو میشه
|
||||||
|
// پشت یک exporter واقعی گذاشت.
|
||||||
|
package monitoring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bufferSize = 1000
|
||||||
|
|
||||||
|
type RequestRecord struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
DurationMs float64 `json:"duration_ms"`
|
||||||
|
ClientIP string `json:"client_ip"`
|
||||||
|
UserID *uint `json:"user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Summary struct {
|
||||||
|
TotalRequests int64 `json:"total_requests"`
|
||||||
|
TotalErrors int64 `json:"total_errors"`
|
||||||
|
ErrorRatePct float64 `json:"error_rate_pct"`
|
||||||
|
AvgLatencyMs float64 `json:"avg_latency_ms"`
|
||||||
|
P95LatencyMs float64 `json:"p95_latency_ms"`
|
||||||
|
RequestsLastMin int `json:"requests_last_min"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
UptimeSeconds float64 `json:"uptime_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store یک حلقهی ثابتاندازه (ring buffer) از آخرین درخواستها بههمراه
|
||||||
|
// شمارندههای تجمیعیه. نوشتن/خواندن با RWMutex محافظت میشه؛ هزینهی هر
|
||||||
|
// درخواست فقط یک قفل کوتاه و بدون I/O روی دیسکه (سریع، مناسب مسیر داغ).
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
buf []RequestRecord
|
||||||
|
next int
|
||||||
|
count int
|
||||||
|
total int64
|
||||||
|
totalErr int64
|
||||||
|
sumLatency float64
|
||||||
|
startedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore() *Store {
|
||||||
|
return &Store{
|
||||||
|
buf: make([]RequestRecord, bufferSize),
|
||||||
|
startedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Record(rec RequestRecord) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.buf[s.next] = rec
|
||||||
|
s.next = (s.next + 1) % bufferSize
|
||||||
|
if s.count < bufferSize {
|
||||||
|
s.count++
|
||||||
|
}
|
||||||
|
s.total++
|
||||||
|
s.sumLatency += rec.DurationMs
|
||||||
|
if rec.StatusCode >= 400 {
|
||||||
|
s.totalErr++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent آخرین n درخواست رو برمیگردونه (جدیدترین اول).
|
||||||
|
func (s *Store) Recent(n int) []RequestRecord {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if n > s.count {
|
||||||
|
n = s.count
|
||||||
|
}
|
||||||
|
out := make([]RequestRecord, 0, n)
|
||||||
|
idx := s.next - 1
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if idx < 0 {
|
||||||
|
idx = bufferSize - 1
|
||||||
|
}
|
||||||
|
out = append(out, s.buf[idx])
|
||||||
|
idx--
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentByUser مثل Recent ولی فقط درخواستهای یک کاربر مشخص رو برمیگردونه
|
||||||
|
// (برای دیدن فعالیت اخیر یک کاربر خاص از صفحهی جزئیاتش توی پنل ادمین).
|
||||||
|
// کل بافر رو میگرده (نه فقط آخرین n)، چون فیلتر شده و ممکنه درخواستهای آن
|
||||||
|
// کاربر پراکنده باشن.
|
||||||
|
func (s *Store) RecentByUser(userID uint, n int) []RequestRecord {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
out := make([]RequestRecord, 0, n)
|
||||||
|
idx := s.next - 1
|
||||||
|
for i := 0; i < s.count && len(out) < n; i++ {
|
||||||
|
if idx < 0 {
|
||||||
|
idx = bufferSize - 1
|
||||||
|
}
|
||||||
|
rec := s.buf[idx]
|
||||||
|
if rec.UserID != nil && *rec.UserID == userID {
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
idx--
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Summary() Summary {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var errRate, avgLatency float64
|
||||||
|
if s.total > 0 {
|
||||||
|
errRate = float64(s.totalErr) / float64(s.total) * 100
|
||||||
|
avgLatency = s.sumLatency / float64(s.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-1 * time.Minute)
|
||||||
|
lastMin := 0
|
||||||
|
latencies := make([]float64, 0, s.count)
|
||||||
|
for i := 0; i < s.count; i++ {
|
||||||
|
r := s.buf[i]
|
||||||
|
if r.Time.After(cutoff) {
|
||||||
|
lastMin++
|
||||||
|
}
|
||||||
|
latencies = append(latencies, r.DurationMs)
|
||||||
|
}
|
||||||
|
sort.Float64s(latencies)
|
||||||
|
p95 := 0.0
|
||||||
|
if len(latencies) > 0 {
|
||||||
|
idx := int(float64(len(latencies)) * 0.95)
|
||||||
|
if idx >= len(latencies) {
|
||||||
|
idx = len(latencies) - 1
|
||||||
|
}
|
||||||
|
p95 = latencies[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
return Summary{
|
||||||
|
TotalRequests: s.total,
|
||||||
|
TotalErrors: s.totalErr,
|
||||||
|
ErrorRatePct: errRate,
|
||||||
|
AvgLatencyMs: avgLatency,
|
||||||
|
P95LatencyMs: p95,
|
||||||
|
RequestsLastMin: lastMin,
|
||||||
|
StartedAt: s.startedAt,
|
||||||
|
UptimeSeconds: time.Since(s.startedAt).Seconds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package monitoring
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStore_RecordAndSummary(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 200, DurationMs: 10})
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 500, DurationMs: 20})
|
||||||
|
|
||||||
|
sum := s.Summary()
|
||||||
|
if sum.TotalRequests != 2 {
|
||||||
|
t.Fatalf("expected 2 total requests, got %d", sum.TotalRequests)
|
||||||
|
}
|
||||||
|
if sum.TotalErrors != 1 {
|
||||||
|
t.Fatalf("expected 1 error (status>=400), got %d", sum.TotalErrors)
|
||||||
|
}
|
||||||
|
if sum.ErrorRatePct != 50 {
|
||||||
|
t.Fatalf("expected 50%% error rate, got %v", sum.ErrorRatePct)
|
||||||
|
}
|
||||||
|
if sum.AvgLatencyMs != 15 {
|
||||||
|
t.Fatalf("expected avg latency 15ms, got %v", sum.AvgLatencyMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_RecentOrder(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Path: "/1", StatusCode: 200})
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Path: "/2", StatusCode: 200})
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Path: "/3", StatusCode: 200})
|
||||||
|
|
||||||
|
recent := s.Recent(10)
|
||||||
|
if len(recent) != 3 {
|
||||||
|
t.Fatalf("expected 3 records, got %d", len(recent))
|
||||||
|
}
|
||||||
|
// جدیدترین باید اول باشه
|
||||||
|
if recent[0].Path != "/3" || recent[1].Path != "/2" || recent[2].Path != "/1" {
|
||||||
|
t.Fatalf("expected newest-first order, got %+v", recent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_RecentLimit(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200})
|
||||||
|
}
|
||||||
|
if got := len(s.Recent(2)); got != 2 {
|
||||||
|
t.Fatalf("expected limit of 2, got %d", got)
|
||||||
|
}
|
||||||
|
if got := len(s.Recent(100)); got != 5 {
|
||||||
|
t.Fatalf("expected all 5 when limit exceeds count, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_RingBufferWraparound(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
// بیشتر از ظرفیت بافر ثبت میکنیم تا رفتار wraparound رو تست کنیم
|
||||||
|
for i := 0; i < bufferSize+50; i++ {
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200, DurationMs: 1})
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := s.Summary()
|
||||||
|
if sum.TotalRequests != int64(bufferSize+50) {
|
||||||
|
t.Fatalf("expected total requests to keep counting past buffer size, got %d", sum.TotalRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
recent := s.Recent(bufferSize + 100)
|
||||||
|
if len(recent) != bufferSize {
|
||||||
|
t.Fatalf("expected Recent to be capped at bufferSize (%d), got %d", bufferSize, len(recent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_RecentByUser(t *testing.T) {
|
||||||
|
s := NewStore()
|
||||||
|
uidA := uint(1)
|
||||||
|
uidB := uint(2)
|
||||||
|
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Path: "/a1", UserID: &uidA})
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Path: "/b1", UserID: &uidB})
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Path: "/a2", UserID: &uidA})
|
||||||
|
s.Record(RequestRecord{Time: time.Now(), Path: "/none"}) // بدون کاربر (مثل OTP)
|
||||||
|
|
||||||
|
got := s.RecentByUser(uidA, 10)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 records for user A, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Path != "/a2" || got[1].Path != "/a1" {
|
||||||
|
t.Fatalf("expected newest-first order for user A, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MatchRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMatchRepository(db *gorm.DB) *MatchRepository {
|
||||||
|
return &MatchRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizePair همیشه شناسهی کوچیکتر رو اول میذاره تا هر جفت فقط یک رکورد داشته باشه
|
||||||
|
func normalizePair(a, b uint) (uint, uint) {
|
||||||
|
if a < b {
|
||||||
|
return a, b
|
||||||
|
}
|
||||||
|
return b, a
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindOrCreate اگه مچ بین این دو کاربر از قبل وجود داشته باشه همونو برمیگردونه،
|
||||||
|
// وگرنه میسازتش. خروجی دوم true یعنی همین الان تازه ساخته شده.
|
||||||
|
func (r *MatchRepository) FindOrCreate(userA, userB uint) (*models.Match, bool, error) {
|
||||||
|
one, two := normalizePair(userA, userB)
|
||||||
|
|
||||||
|
var match models.Match
|
||||||
|
err := r.db.Where("user_one_id = ? AND user_two_id = ?", one, two).First(&match).Error
|
||||||
|
if err == nil {
|
||||||
|
return &match, false, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
match = models.Match{UserOneID: one, UserTwoID: two}
|
||||||
|
if err := r.db.Create(&match).Error; err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return &match, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MatchRepository) FindByID(id uint) (*models.Match, error) {
|
||||||
|
var match models.Match
|
||||||
|
if err := r.db.First(&match, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &match, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByUser مچهای کاربر رو از جدیدترین به قدیمیترین برمیگردونه
|
||||||
|
func (r *MatchRepository) ListByUser(userID uint) ([]models.Match, error) {
|
||||||
|
var matches []models.Match
|
||||||
|
err := r.db.
|
||||||
|
Where("user_one_id = ? OR user_two_id = ?", userID, userID).
|
||||||
|
Order("created_at desc").
|
||||||
|
Find(&matches).Error
|
||||||
|
return matches, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllPaginated همهی مچهای اپ رو صفحهبندیشده برمیگردونه (برای پنل ادمین)
|
||||||
|
func (r *MatchRepository) ListAllPaginated(limit, offset int) ([]models.Match, int64, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&models.Match{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var matches []models.Match
|
||||||
|
err := r.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&matches).Error
|
||||||
|
return matches, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// OtherUserID شناسهی طرف مقابل کاربر جاری رو توی یک مچ برمیگردونه
|
||||||
|
func OtherUserID(match *models.Match, currentUserID uint) uint {
|
||||||
|
if match.UserOneID == currentUserID {
|
||||||
|
return match.UserTwoID
|
||||||
|
}
|
||||||
|
return match.UserOneID
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMember یعنی این کاربر عضو این مچه
|
||||||
|
func IsMember(match *models.Match, userID uint) bool {
|
||||||
|
return match.UserOneID == userID || match.UserTwoID == userID
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MessageRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMessageRepository(db *gorm.DB) *MessageRepository {
|
||||||
|
return &MessageRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MessageRepository) Create(msg *models.Message) error {
|
||||||
|
return r.db.Create(msg).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MessageRepository) ListByMatch(matchID uint) ([]models.Message, error) {
|
||||||
|
var messages []models.Message
|
||||||
|
err := r.db.Where("match_id = ?", matchID).Order("created_at asc").Find(&messages).Error
|
||||||
|
return messages, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastMessage آخرین پیام یک مچ رو برمیگردونه (برای پیشنمایش لیست گفتگوها)
|
||||||
|
func (r *MessageRepository) LastMessage(matchID uint) (*models.Message, error) {
|
||||||
|
var msg models.Message
|
||||||
|
err := r.db.Where("match_id = ?", matchID).Order("created_at desc").First(&msg).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastMessagesByMatchIDs آخرین پیام هر مچ رو، برای چند مچ با هم در یک کوئری
|
||||||
|
// برمیگردونه (بهجای یک کوئری جدا بهازای هر مچ توی لیست گفتگوها). از
|
||||||
|
// DISTINCT ON پستگرس استفاده میکنه که دقیقاً برای همین کار («آخرین ردیف هر
|
||||||
|
// گروه») ساخته شده.
|
||||||
|
func (r *MessageRepository) LastMessagesByMatchIDs(matchIDs []uint) (map[uint]models.Message, error) {
|
||||||
|
result := make(map[uint]models.Message, len(matchIDs))
|
||||||
|
if len(matchIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []models.Message
|
||||||
|
err := r.db.
|
||||||
|
Distinct("ON (match_id) *").
|
||||||
|
Where("match_id IN ?", matchIDs).
|
||||||
|
Order("match_id, created_at desc").
|
||||||
|
Find(&messages).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range messages {
|
||||||
|
result[m.MatchID] = m
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/cache"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrCategoryNotFound = errors.New("دستهی موردنظر یافت نشد")
|
||||||
|
|
||||||
|
// activeOptionsCacheTTL: گزینههای فعال هر دسته و خودِ رکورد دسته فقط از پنل
|
||||||
|
// ادمین تغییر میکنن (که خیلی بهندرت اتفاق میافته) ولی توی هر مرحلهی
|
||||||
|
// آنبوردینگ/دیسکاوری هر کاربر خونده میشن؛ کش کردنشون فشار روی دیتابیس رو
|
||||||
|
// زیاد کم میکنه.
|
||||||
|
const activeOptionsCacheTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
type OptionRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
activeOptionsByKey *cache.TTLCache[models.CategoryKey, []models.Option]
|
||||||
|
categoryByKey *cache.TTLCache[models.CategoryKey, models.Category]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOptionRepository(db *gorm.DB) *OptionRepository {
|
||||||
|
return &OptionRepository{
|
||||||
|
db: db,
|
||||||
|
activeOptionsByKey: cache.New[models.CategoryKey, []models.Option](activeOptionsCacheTTL),
|
||||||
|
categoryByKey: cache.New[models.CategoryKey, models.Category](activeOptionsCacheTTL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Category, error) {
|
||||||
|
if cached, ok := r.categoryByKey.Get(key); ok {
|
||||||
|
return &cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cat models.Category
|
||||||
|
err := r.db.Where("key = ?", key).First(&cat).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, ErrCategoryNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.categoryByKey.Set(key, cat)
|
||||||
|
return &cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) UpdateCategory(cat *models.Category) error {
|
||||||
|
if err := r.db.Save(cat).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.activeOptionsByKey.Delete(cat.Key)
|
||||||
|
r.categoryByKey.Delete(cat.Key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) ListActiveOptionsByCategoryKey(key models.CategoryKey) ([]models.Option, error) {
|
||||||
|
if cached, ok := r.activeOptionsByKey.Get(key); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cat, err := r.GetCategoryByKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var options []models.Option
|
||||||
|
err = r.db.Where("category_id = ? AND is_active = ?", cat.ID, true).
|
||||||
|
Order("sort_order asc, id asc").
|
||||||
|
Find(&options).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.activeOptionsByKey.Set(key, options)
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOptionsByCategoryKeyAdmin تمام گزینهها (فعال و غیرفعال) رو برمیگردونه - برای پنل ادمین
|
||||||
|
func (r *OptionRepository) ListOptionsByCategoryKeyAdmin(key models.CategoryKey) ([]models.Option, error) {
|
||||||
|
cat, err := r.GetCategoryByKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var options []models.Option
|
||||||
|
err = r.db.Where("category_id = ?", cat.ID).
|
||||||
|
Order("sort_order asc, id asc").
|
||||||
|
Find(&options).Error
|
||||||
|
return options, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) CreateOption(opt *models.Option) error {
|
||||||
|
if err := r.db.Create(opt).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.activeOptionsByKey.Clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) FindOptionByID(id uint) (*models.Option, error) {
|
||||||
|
var opt models.Option
|
||||||
|
err := r.db.First(&opt, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &opt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) UpdateOption(opt *models.Option) error {
|
||||||
|
if err := r.db.Save(opt).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.activeOptionsByKey.Clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) DeleteOption(id uint) error {
|
||||||
|
if err := r.db.Delete(&models.Option{}, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.activeOptionsByKey.Clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindActiveOptionsByIDsInCategory فقط گزینههای فعالی که در این دسته هستند و id شون
|
||||||
|
// داخل لیست دادهشده است رو برمیگردونه (برای اعتبارسنجی انتخاب کاربر)
|
||||||
|
func (r *OptionRepository) FindActiveOptionsByIDsInCategory(categoryID uint, ids []uint) ([]models.Option, error) {
|
||||||
|
var options []models.Option
|
||||||
|
err := r.db.Where("category_id = ? AND is_active = ? AND id IN ?", categoryID, true, ids).
|
||||||
|
Find(&options).Error
|
||||||
|
return options, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceUserSelections تمام انتخابهای قبلی کاربر در یک دسته را با لیست جدید جایگزین میکند (تراکنشی)
|
||||||
|
func (r *OptionRepository) ReplaceUserSelections(userID, categoryID uint, optionIDs []uint) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("user_id = ? AND category_id = ?", userID, categoryID).
|
||||||
|
Delete(&models.UserOptionSelection{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, optID := range optionIDs {
|
||||||
|
sel := models.UserOptionSelection{
|
||||||
|
UserID: userID,
|
||||||
|
CategoryID: categoryID,
|
||||||
|
OptionID: optID,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&sel).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserSelections انتخابهای کاربر در یک دسته را همراه با اطلاعات گزینه برمیگرداند
|
||||||
|
func (r *OptionRepository) GetUserSelections(userID, categoryID uint) ([]models.UserOptionSelection, error) {
|
||||||
|
var selections []models.UserOptionSelection
|
||||||
|
err := r.db.Preload("Option").
|
||||||
|
Where("user_id = ? AND category_id = ?", userID, categoryID).
|
||||||
|
Find(&selections).Error
|
||||||
|
return selections, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountUserSelections تعداد انتخابهای ثبتشدهی کاربر در یک دسته را برمیگرداند
|
||||||
|
// (برای بررسی اینکه مرحله تکمیل شده یا نه)
|
||||||
|
func (r *OptionRepository) CountUserSelections(userID, categoryID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.UserOptionSelection{}).
|
||||||
|
Where("user_id = ? AND category_id = ?", userID, categoryID).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSelectionsForUsersInCategory انتخابهای چند کاربر رو در یک دستهی مشخص،
|
||||||
|
// همه با هم در یک کوئری برمیگردونه (بهجای یک کوئری جدا برای هر کاربر).
|
||||||
|
// برای فید دیسکاوری استفاده میشه که تا ۶۰ کاندیدا با هم پردازش میشن.
|
||||||
|
func (r *OptionRepository) GetSelectionsForUsersInCategory(userIDs []uint, categoryID uint) (map[uint][]string, error) {
|
||||||
|
result := make(map[uint][]string, len(userIDs))
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var selections []models.UserOptionSelection
|
||||||
|
err := r.db.Preload("Option").
|
||||||
|
Where("user_id IN ? AND category_id = ?", userIDs, categoryID).
|
||||||
|
Find(&selections).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sel := range selections {
|
||||||
|
if sel.Option != nil {
|
||||||
|
result[sel.UserID] = append(result[sel.UserID], sel.Option.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserSelectionsForCategories انتخابهای یک کاربر رو در چند دسته با هم، در
|
||||||
|
// یک کوئری برمیگردونه (بهجای یک کوئری جدا بهازای هر دسته). برای صفحهی
|
||||||
|
// جزئیات پروفایل استفاده میشه که همزمان چند دسته (تحصیلات، شغل، علاقهمندی و...)
|
||||||
|
// رو نشون میده.
|
||||||
|
func (r *OptionRepository) GetUserSelectionsForCategories(userID uint, categoryIDs []uint) (map[uint][]models.UserOptionSelection, error) {
|
||||||
|
result := make(map[uint][]models.UserOptionSelection)
|
||||||
|
if len(categoryIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var selections []models.UserOptionSelection
|
||||||
|
err := r.db.Preload("Option").
|
||||||
|
Where("user_id = ? AND category_id IN ?", userID, categoryIDs).
|
||||||
|
Find(&selections).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sel := range selections {
|
||||||
|
result[sel.CategoryID] = append(result[sel.CategoryID], sel)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PhotoRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPhotoRepository(db *gorm.DB) *PhotoRepository {
|
||||||
|
return &PhotoRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PhotoRepository) ListByUser(userID uint) ([]models.Photo, error) {
|
||||||
|
var photos []models.Photo
|
||||||
|
err := r.db.Where("user_id = ?", userID).Order("sort_order asc, id asc").Find(&photos).Error
|
||||||
|
return photos, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PhotoRepository) ListByUserIDs(userIDs []uint) ([]models.Photo, error) {
|
||||||
|
var photos []models.Photo
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return photos, nil
|
||||||
|
}
|
||||||
|
err := r.db.Where("user_id IN ?", userIDs).Order("sort_order asc, id asc").Find(&photos).Error
|
||||||
|
return photos, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllPaginated همهی عکسهای آپلودشدهی اپ رو صفحهبندیشده، جدیدترین اول
|
||||||
|
// برمیگردونه (برای نظارت محتوایی پنل ادمین).
|
||||||
|
func (r *PhotoRepository) ListAllPaginated(limit, offset int) ([]models.Photo, int64, error) {
|
||||||
|
var total int64
|
||||||
|
if err := r.db.Model(&models.Photo{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var photos []models.Photo
|
||||||
|
err := r.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&photos).Error
|
||||||
|
return photos, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PhotoRepository) CountByUser(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Photo{}).Where("user_id = ?", userID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PhotoRepository) Create(photo *models.Photo) error {
|
||||||
|
return r.db.Create(photo).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PhotoRepository) FindByID(id uint) (*models.Photo, error) {
|
||||||
|
var photo models.Photo
|
||||||
|
if err := r.db.First(&photo, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &photo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PhotoRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.Photo{}, id).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/cache"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeQuestionsCacheTTL = 5 * time.Minute
|
||||||
|
const activeQuestionsCacheKey = "active"
|
||||||
|
|
||||||
|
type QuestionRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
activeCache *cache.TTLCache[string, []models.ProfileQuestion]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuestionRepository(db *gorm.DB) *QuestionRepository {
|
||||||
|
return &QuestionRepository{
|
||||||
|
db: db,
|
||||||
|
activeCache: cache.New[string, []models.ProfileQuestion](activeQuestionsCacheTTL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) ListActive() ([]models.ProfileQuestion, error) {
|
||||||
|
if cached, ok := r.activeCache.Get(activeQuestionsCacheKey); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
var qs []models.ProfileQuestion
|
||||||
|
err := r.db.Where("is_active = ?", true).Order("sort_order asc, id asc").Find(&qs).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.activeCache.Set(activeQuestionsCacheKey, qs)
|
||||||
|
return qs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) ListAllAdmin() ([]models.ProfileQuestion, error) {
|
||||||
|
var qs []models.ProfileQuestion
|
||||||
|
err := r.db.Order("sort_order asc, id asc").Find(&qs).Error
|
||||||
|
return qs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) FindByID(id uint) (*models.ProfileQuestion, error) {
|
||||||
|
var q models.ProfileQuestion
|
||||||
|
err := r.db.First(&q, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) FindActiveByIDs(ids []uint) ([]models.ProfileQuestion, error) {
|
||||||
|
var qs []models.ProfileQuestion
|
||||||
|
err := r.db.Where("is_active = ? AND id IN ?", true, ids).Find(&qs).Error
|
||||||
|
return qs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) Create(q *models.ProfileQuestion) error {
|
||||||
|
if err := r.db.Create(q).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.activeCache.Clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) Update(q *models.ProfileQuestion) error {
|
||||||
|
if err := r.db.Save(q).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.activeCache.Clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) Delete(id uint) error {
|
||||||
|
if err := r.db.Delete(&models.ProfileQuestion{}, id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.activeCache.Clear()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceUserAnswers پاسخهای قبلی کاربر رو با پاسخهای جدید جایگزین میکنه (تراکنشی)
|
||||||
|
func (r *QuestionRepository) ReplaceUserAnswers(userID uint, answers []models.UserProfileAnswer) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("user_id = ?", userID).Delete(&models.UserProfileAnswer{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range answers {
|
||||||
|
answers[i].UserID = userID
|
||||||
|
if err := tx.Create(&answers[i]).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) GetUserAnswers(userID uint) ([]models.UserProfileAnswer, error) {
|
||||||
|
var answers []models.UserProfileAnswer
|
||||||
|
err := r.db.Preload("Question").Where("user_id = ?", userID).Find(&answers).Error
|
||||||
|
return answers, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) CountUserAnswers(userID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.UserProfileAnswer{}).Where("user_id = ?", userID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReportRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReportRepository(db *gorm.DB) *ReportRepository {
|
||||||
|
return &ReportRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ReportRepository) Create(report *models.Report) error {
|
||||||
|
return r.db.Create(report).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ReportRepository) ListByUser(userID uint) ([]models.Report, error) {
|
||||||
|
var reports []models.Report
|
||||||
|
err := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&reports).Error
|
||||||
|
return reports, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountsByUserIDs تعداد گزارشهای ثبتشده روی چند کاربر رو با هم، در یک
|
||||||
|
// کوئری برمیگردونه (برای نشون دادن نشان تعداد گزارش روی لیست کاربران، بدون
|
||||||
|
// یک کوئری جدا بهازای هر کاربر).
|
||||||
|
func (r *ReportRepository) CountsByUserIDs(userIDs []uint) (map[uint]int64, error) {
|
||||||
|
result := make(map[uint]int64, len(userIDs))
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
UserID uint
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
err := r.db.Model(&models.Report{}).
|
||||||
|
Select("user_id, count(*) as count").
|
||||||
|
Where("user_id IN ?", userIDs).
|
||||||
|
Group("user_id").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, rr := range rows {
|
||||||
|
result[rr.UserID] = rr.Count
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user