Compare commits

...

24 Commits

Author SHA1 Message Date
mobiniqow b01da2e04e chore: add TWA (Android) wrapper project 2026-09-27 10:32:14 +03:30
mobiniqow cba4bc30da chore: merge web repo history into monorepo at web/
git-subtree-dir: web
git-subtree-mainline: 75a0ba593d
git-subtree-split: 8f624e7416
2026-09-27 10:31:24 +03:30
mobiniqow 75a0ba593d chore: merge admin repo history into monorepo at admin/
git-subtree-dir: admin
git-subtree-mainline: 35f5084005
git-subtree-split: 9f1eccd2f7
2026-09-27 10:30:47 +03:30
mobiniqow 35f5084005 chore: merge backend repo history into monorepo at backend/
git-subtree-dir: backend
git-subtree-mainline: dc11190527
git-subtree-split: acae976622
2026-09-27 10:30:16 +03:30
mobiniqow dc11190527 chore: rewrite root .gitignore for monorepo (drop blanket subfolder ignores) 2026-09-27 10:30:06 +03:30
mobiniqow 19ced3bb6d chore: add root docker-compose orchestration for backend/web/admin 2026-09-27 10:28:43 +03:30
mobiniqow 8f624e7416 chore: ignore stray zip archive, track Next.js agent-rules files 2026-09-27 10:28:28 +03:30
mobiniqow 9f1eccd2f7 chore: ignore stray zip archive 2026-09-27 10:28:28 +03:30
mobiniqow acae976622 chore: docker-compose split into root monorepo, fix launch.json paths, restore multi-stage Dockerfile build 2026-09-27 10:28:27 +03:30
mobiniqow 0118ecc63a Add end-to-end encryption for chat messages (X25519 / NaCl box)
Each device generates its own X25519 key pair on login (tweetnacl); the
private key never leaves localStorage or reaches our server. Messages are
encrypted client-side with the recipient's public key before being sent,
so the backend and anyone intercepting traffic only ever see ciphertext.

- src/lib/e2ee.ts: key generation/registration + encrypt/decrypt (NaCl box)
- Chat thread + matches list: encrypt on send, decrypt on receive/preview,
  with a graceful fallback when a message can't be decrypted on this device
  (e.g. after switching browsers) and full backward compat for legacy
  plaintext messages sent before this change
- Privacy settings page explains the guarantee and its one deliberate
  trade-off: there is no key backup/escrow, so clearing browser data or
  switching devices makes old messages permanently unreadable

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 18:58:21 +03:30
mobiniqow 1e5ef02bdf Add end-to-end encryption for chat messages (X25519 / NaCl box)
Messages between matched users are now encrypted client-side with each
user's own key pair before they ever reach the server; the backend only
ever stores/relays opaque ciphertext + nonce and cannot read message
content, including for the last-message preview on the matches list.

- User gains a PublicKey field, registered/rotated via PUT /profile/public-key
- Message gains CipherText/Nonce; legacy plaintext Text is kept as-is for
  messages sent before this change
- MatchView exposes the partner's public key and the last message's
  ciphertext/nonce so the client can decrypt previews itself
- Removed the now-dead plaintext SanitizeMessageText utility — the server
  can no longer sanitize message content since it's opaque ciphertext

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 18:57:46 +03:30
mobiniqow 4375bb12a4 Show user photos in admin panel, add per-photo reporting
- Add the missing `photos` field to FullProfile so the user-detail page
  can render a photo gallery (the backend already returned this data,
  the type just never declared it)
- Show applicants' first photo (or a "no photo" badge) in the pending-
  users list instead of a generic avatar
- Add a report action alongside delete on the photo moderation page, so
  a photo can be flagged without being removed

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 18:10:31 +03:30
mobiniqow 2c6d4bfbd2 Add notification settings, account deletion, photo reporting, and pending-user photos
Addresses user bug reports: settings pages needed real notification
preferences and account deletion, admin needed a way to flag a specific
photo independent of deleting it, and the pending-approval users list
needed to surface applicant photos for review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 18:08:57 +03:30
mobiniqow c8e6f80c51 Add bio field and daily streak check-in for gamification
- User.Bio: a short (300 char) freeform profile bio, editable via
  PATCH /profile/bio, shown on discover cards and candidate detail (richer
  profiles beyond the fixed onboarding fields).
- User.StreakCount/LastActiveDate: POST /profile/checkin increments the
  streak on consecutive days, resets after a gap, no-ops if already
  checked in today. Backs a daily-return hook on the frontend.
2026-09-20 12:26:06 +03:30
mobiniqow 35830189ea Add user moderation, matches, and photo review pages
- New "همه کاربران" page: searchable/paginated full user list with
  block/unblock/report actions and report-count badges.
- User detail page gained block/unblock/report buttons, a report history
  section, and a recent-activity panel (via the backend's per-user
  monitoring filter).
- New "رفقا" page listing every match across the app.
- New "نظارت بر عکس‌ها" page: grid of every uploaded photo with uploader
  info and a delete action, for content review without going profile-by-profile.
2026-09-20 05:30:33 +03:30
mobiniqow 1dc96e86fc Add admin user moderation: block/unblock, reports, matches list, photo review
- User blocking (with reason) now closes the auth gap immediately — a
  blocked user's still-valid access token stops working on the next
  request, not just on their next OTP/refresh.
- Admin-created reports (moderation notes) per user, viewable in a history
  list independent of block status.
- GET /admin/users: full paginated, searchable user list with report counts.
- GET /admin/matches: all matches across the app ("friends" who mutually
  liked each other), for admin visibility.
- GET/DELETE /admin/photos: paginated feed of every uploaded photo with
  uploader info, so admins can review and remove content without going
  user-by-user.
- Per-user activity: monitoring requests can now be filtered by user_id, so
  a user's detail page can show their recent API activity.
- Batched the profile-selections query in fullProfilePayload (was 6 queries
  per view, now 1) for consistency with the earlier N+1 fixes.
2026-09-20 05:30:14 +03:30
mobiniqow 3480eac2b0 Add server monitoring and database backup pages
New sidebar sections wired to the backend's new admin APIs:
- Monitoring: live request/error-rate/latency stats plus a recent-requests
  table, polling every 8s.
- Backups: create/list/download database backups (auth'd blob download
  since the endpoint requires a Bearer token, not a plain link).
2026-09-19 22:17:44 +03:30
mobiniqow 769e76313f Add production hardening: monitoring, backups, caching, and high-traffic resilience
- Recovery middleware (no more crash-on-panic), request metrics/monitoring
  store + admin API, in-memory TTL caching for read-heavy option/question/
  category lookups, gzip + rate-limiting middleware, sanitized chat message
  encoding, and pg_dump-based database backups with an admin API.
- Fix N+1 queries in the discover feed, candidate detail, and match list
  endpoints by batching selection/photo/user/last-message lookups into
  single queries instead of one-per-item loops.
- Tune DB connection pooling and add HTTP server timeouts + graceful
  shutdown so the API degrades gracefully instead of falling over under load.
- Fix a null (vs empty array) JSON response for candidates/matches without
  photos or interests, which crashed the web app's swipe card.
2026-09-19 22:17:12 +03:30
mobiniqow 7cf6c78c77 Add structured rejection reasons (checkable presets + free text)
The reject modal now offers 5 common rejection reasons as tickable
options (no photo, incomplete info, looks fake/bot, inappropriate
photos, contact info in profile), combined with an optional free-text
note. The combined string is sent as the existing reason field — no
backend change needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 09:23:11 +03:30
mobiniqow 15583009e0 Add photo upload as a real onboarding step
Photos are now step 2 of onboarding (right after basic info) instead
of only being manageable from the profile page later. Uploading still
goes through the existing /profile/photos endpoint; the new
POST /onboarding/photos just confirms at least one photo exists and
advances the step counter. Renumbered the OnboardingStep enum
accordingly (9 steps total now).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 09:21:50 +03:30
mobiniqow 665838bbe7 Add Docker support (standalone Next.js output)
Multi-stage Dockerfile producing a minimal runtime image via
next.config.ts's output:"standalone". NEXT_PUBLIC_API_URL is taken as
a build arg since it gets baked into the client bundle at build time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:01:41 +03:30
mobiniqow fda1b130ff Add Docker support and docker-compose orchestration
Multi-stage Dockerfile for the Go API, plus a docker-compose.yml that
runs postgres + backend + both frontends together for local
full-stack dev (frontends built from the sibling repo directories).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:01:10 +03:30
mobiniqow 36faf4706a Initial commit: dating app admin panel (Next.js)
Admin login, category/option and profile-question management, and
the pending-user review workflow (list, full profile detail,
approve/reject with a reason shown back to the user).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:26:24 +03:30
mobiniqow 2823fb45f5 Initial commit: dating app backend (Go/Gin/GORM/Postgres)
Phone+OTP auth, staged onboarding, admin approval workflow, and a
full discovery/swipe/match/chat system with photo upload and
approximate distance (haversine) based on user location.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:23:47 +03:30
239 changed files with 20054 additions and 145 deletions
+13
View File
@@ -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
View File
@@ -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
+22 -94
View File
@@ -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 واقعی
جایگزین کنی.
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dating-admin",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3001
}
]
}
+46
View File
@@ -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
View File
+70
View File
@@ -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 در پروداکشن قرار بدی.
+6822
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -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",
+129
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
+131
View File
@@ -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>
);
}
+131
View File
@@ -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>
);
}
+214
View File
@@ -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>
);
}
+109
View File
@@ -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

+188
View File
@@ -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);
}
}
+23
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+153
View File
@@ -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>
);
}
+98
View File
@@ -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>
</>
);
}
+76
View File
@@ -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";
+45
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+65
View File
@@ -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;
}
+501
View File
@@ -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);
}
+87
View File
@@ -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;
}
+30
View File
@@ -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;
}
+16
View File
@@ -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;
}
+17
View File
@@ -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
}
]
}
+10
View File
@@ -0,0 +1,10 @@
.git
.idea
.claude
.env
.env.*
!.env.example
uploads
*.md
/dating-app
/dating-app-server
+33
View File
@@ -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
+23
View File
@@ -0,0 +1,23 @@
# binaries
/dating-app
/dating-app-server
/server
*.exe
*.test
*.out
# env
.env
# فایل‌های آپلودشده‌ی کاربران (نباید توی گیت باشن)
/uploads/
# بک‌آپ‌های دیتابیس (نباید توی گیت باشن؛ حجیم و شامل داده‌ی کاربرانه)
/backups/
# IDE
.idea/
.vscode/
# OS
.DS_Store
+27
View File
@@ -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"]
+225
View File
@@ -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` باشه توی لاگ چاپ می‌شه.
+141
View File
@@ -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("سرور با موفقیت خاموش شد")
}
+78
View File
@@ -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
View File
@@ -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=
+55
View File
@@ -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
View File
@@ -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")
}
}
+101
View File
@@ -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
}
+148
View File
@@ -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
}
+49
View File
@@ -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"`
}
+64
View File
@@ -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"`
}
+89
View File
@@ -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"`
}
+56
View File
@@ -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"`
}
+32
View File
@@ -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"`
}
+479
View File
@@ -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)
}
+100
View File
@@ -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)
}
+91
View File
@@ -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)
}
}
+118
View File
@@ -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)
}
+227
View File
@@ -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()
}
}
+23
View File
@@ -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()
}
}
+76
View File
@@ -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"
}
+82
View File
@@ -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"
}
+17
View File
@@ -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"
}
+134
View File
@@ -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"
}
+157
View File
@@ -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(),
}
}
+93
View File
@@ -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