Compare commits
28 Commits
d55d61ba81
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b6b90f110 | |||
| 8c4e50e98c | |||
| 217f5a1a6d | |||
| 112b196809 | |||
| b01da2e04e | |||
| cba4bc30da | |||
| 75a0ba593d | |||
| 35f5084005 | |||
| dc11190527 | |||
| 19ced3bb6d | |||
| 8f624e7416 | |||
| 9f1eccd2f7 | |||
| acae976622 | |||
| 0118ecc63a | |||
| 1e5ef02bdf | |||
| 4375bb12a4 | |||
| 2c6d4bfbd2 | |||
| c8e6f80c51 | |||
| 35830189ea | |||
| 1dc96e86fc | |||
| 3480eac2b0 | |||
| 769e76313f | |||
| 7cf6c78c77 | |||
| 15583009e0 | |||
| 665838bbe7 | |||
| fda1b130ff | |||
| 36faf4706a | |||
| 2823fb45f5 |
@@ -0,0 +1,7 @@
|
||||
# Postgres
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=dating_app
|
||||
|
||||
# آدرس API که موقع build توی باندل کلاینت web/admin جاسازی میشه (باید از مرورگر کاربر قابلدسترس باشه)
|
||||
PUBLIC_API_URL=https://brainito.ir/api/v1
|
||||
+32
-35
@@ -1,45 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
# --- محیط/سکرتها ---
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
# --- Go (backend) ---
|
||||
/backend/server
|
||||
/backend/*.exe
|
||||
/backend/*.test
|
||||
/backend/*.out
|
||||
/backend/uploads/
|
||||
/backend/backups/
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
# --- Node (web/admin) ---
|
||||
node_modules/
|
||||
.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
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/
|
||||
# --- آرشیوهای اضافی که قرار نبود توی گیت باشن ---
|
||||
*.zip
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
# --- Android/TWA: build و کلید امضا (نباید توی گیت باشه) ---
|
||||
/twa/app/build/
|
||||
/twa/build/
|
||||
/twa/.gradle/
|
||||
/twa/local.properties
|
||||
*.apk
|
||||
*.aab
|
||||
/twa/android.keystore
|
||||
|
||||
# --- IDE / OS ---
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
|
||||
@@ -1,103 +1,33 @@
|
||||
# دلدل — فرانت 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
|
||||
cp .env.local.example .env.local
|
||||
cp .env.example .env # مقادیر رو با دامنه/رمزهای واقعی پر کن
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
و `NEXT_PUBLIC_API_URL` رو به آدرس واقعی بکاندت تغییر بده (پیشفرض
|
||||
`http://localhost:8080/api/v1`).
|
||||
این کار ۴ کانتینر بالا میآره: `postgres`, `backend`, `web`, `admin`.
|
||||
کامپوز هیچ وبسروری بالا نمیآره؛ ریورسپروکسی و سابدامینها با nginx خودت روی هاست ست میشن. چون کانتینر nginx روی همون شبکهی داکری این کامپوز نشسته، میتونه مستقیم با نام کانتینر بهشون proxy_pass بده (بهجای پورت هاست):
|
||||
|
||||
### ۲) نصب و اجرا
|
||||
| دامنه | proxy_pass به |
|
||||
|---|---|
|
||||
| `brainito.ir` | `http://dating-app-backend-1:8080` |
|
||||
| `app.brainito.ir` | `http://dating-app-web-1:3000` |
|
||||
| `panel.brainito.ir` | `http://dating-app-admin-1:3000` |
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
(نام کانتینرها بر اساس `name: dating-app` توی کامپوز و پیشوند سرویس ساخته میشن؛ اگه پروژه رو با نام دیگهای بالا بیاری این نامها عوض میشن.)
|
||||
|
||||
`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 واقعی
|
||||
جایگزین کنی.
|
||||
هیچ سرویسی پورتی روی هاست منتشر نمیکنه؛ فقط روی شبکهی داکلی کامپوز با نام کانتینر در دسترسن (nginx باید به همون شبکه متصل باشه). سکرتهای اختصاصی بکاند (JWT، DB و...) توی `backend/.env` میمونن و از اونجا با `env_file` به سرویس `backend` پاس داده میشن؛ `.env` ریشه فقط برای اطلاعات پستگرس و آدرس عمومی API استفاده میشه.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dating-admin",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3001
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env*.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# فایلهای آپلودشدهی بکاند (اگه به اشتباه اینجا کنار بکاند اجرا بشه)
|
||||
/uploads/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
*.zip
|
||||
@@ -0,0 +1,70 @@
|
||||
# پنل ادمین دلدل
|
||||
|
||||
پنل مدیریت دسکتاپمحور (Next.js + Basis Design System) برای همون بکاند
|
||||
Go/Gin/GORM اپ دیتینگ. با این پنل میتونی بدون لمس کد، تمام لیستهای
|
||||
انتخابی و سوالات پروفایل اپ رو مدیریت کنی.
|
||||
|
||||
## ویژگیها
|
||||
|
||||
- **ورود امن:** همون فلوی OTP اپ اصلی، ولی فقط کاربرهایی که `is_admin=true`
|
||||
دارن اجازهی ورود میگیرن (پیام «دسترسی نداری» برای بقیه).
|
||||
- **مدیریت ۶ دستهی انتخابی:** سطح تحصیلات، رشته تحصیلی، فعالیتها، اهمیت در
|
||||
رابطه، علاقهمندیها، دنبال چی میگردی — برای هر کدوم:
|
||||
- ویرایش عنوان و حداقل/حداکثر تعداد انتخاب مجاز
|
||||
- افزودن/ویرایش/حذف گزینه
|
||||
- فعال/غیرفعال کردن آنی با یک کلیک (گزینهی غیرفعال فوراً از دید کاربر مخفی میشه)
|
||||
- **مدیریت سوالات پروفایل:** افزودن/ویرایش/حذف/فعالسازی سوالاتی که کاربر
|
||||
توی مرحلهی سوم آنبوردینگ ۳ تاشو انتخاب میکنه.
|
||||
- فیدبک فوری با Toast بعد از هر عملیات، تایید قبل از حذف.
|
||||
|
||||
## ✅ تست شده
|
||||
|
||||
قبل از تحویل، یک اسکریپت Node که دقیقاً همون درخواستهای `src/lib/api.ts`
|
||||
رو میفرسته، روی یک نمونهی واقعی از بکاند اجرا شد: ورود ادمین، GET/PATCH
|
||||
دسته، CRUD کامل گزینه، toggle فعال/غیرفعال (با تایید اینکه گزینهی
|
||||
غیرفعال از API عمومی کاربر واقعاً حذف میشه)، CRUD کامل سوالات، و مهمتر
|
||||
از همه — تست کردیم که یک کاربر عادی (غیرادمین) با کد ۴۰۳ از تمام
|
||||
`/admin/*` endpoint ها بلاک میشه. همهی ۱۴ چک پاس شد.
|
||||
|
||||
## راهاندازی
|
||||
|
||||
```bash
|
||||
cp .env.local.example .env.local
|
||||
# NEXT_PUBLIC_API_URL رو به آدرس بکاندت تنظیم کن
|
||||
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`http://localhost:3000` رو باز کن، با شمارهی موبایلِ یک کاربر ادمین
|
||||
(همونی که با `ADMIN_BOOTSTRAP_PHONE` در بکاند ساخته شده، یا هر کاربری که
|
||||
دستی `is_admin=true` شده) وارد شو.
|
||||
|
||||
## ساختار پروژه
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── login/, verify/ ← ورود OTP (مثل اپ اصلی)
|
||||
│ └── (dashboard)/
|
||||
│ ├── layout.tsx ← گارد ادمین + سایدبار
|
||||
│ ├── page.tsx ← داشبورد با آمار سریع
|
||||
│ ├── categories/page.tsx ← لیست ۶ دسته
|
||||
│ ├── categories/[key]/ ← ویرایش تنظیمات دسته + CRUD گزینهها
|
||||
│ └── questions/page.tsx ← CRUD سوالات پروفایل
|
||||
├── components/
|
||||
│ ├── ui/ ← دیزاینسیستم مشترک + Modal/Toast/ConfirmDialog جدید
|
||||
│ └── Sidebar.tsx
|
||||
└── lib/
|
||||
├── api.ts ← کلاینت تایپشدهی endpoint های /admin/*
|
||||
├── auth-context.tsx ← ورود + چک is_admin
|
||||
└── hooks.ts
|
||||
```
|
||||
|
||||
## نکتهی امنیتی
|
||||
|
||||
این پنل فقط روی احراز هویت سمت بکاند (`middleware.AdminRequired`) تکیه
|
||||
میکنه — یعنی حتی اگه کسی مستقیم به route های این پنل دسترسی پیدا کنه،
|
||||
بدون توکن معتبرِ یک کاربر `is_admin=true`، هیچ عملیاتی از سمت بکاند رد
|
||||
نمیشه (این رو با تست ۴۰۳ بالا تایید کردیم). با این حال توصیه میشه این
|
||||
پنل رو پشت یک دامنهی جدا یا حداقل VPN/IP-allowlist در پروداکشن قرار بدی.
|
||||
Generated
+6822
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "dating-app-web",
|
||||
"name": "dating-app-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -12,7 +12,6 @@
|
||||
"@fontsource-variable/baloo-2": "^5.3.0",
|
||||
"@fontsource-variable/vazirmatn": "^5.3.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^13.3.0",
|
||||
"lucide-react": "^1.45.0",
|
||||
"next": "16.3.5",
|
||||
"react": "19.2.8",
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Database, Download, HardDrive, RefreshCw } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { BackupInfo, listBackups, createBackup, downloadBackup } from "@/lib/api";
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function BackupsPage() {
|
||||
const toast = useToast();
|
||||
const [backups, setBackups] = useState<BackupInfo[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setBackups(await listBackups());
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleCreate() {
|
||||
setCreating(true);
|
||||
try {
|
||||
await createBackup();
|
||||
toast.showSuccess("بکآپ جدید با موفقیت ساخته شد");
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(filename: string) {
|
||||
setDownloadingFile(filename);
|
||||
try {
|
||||
await downloadBackup(filename);
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setDownloadingFile(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!backups && !error) return <FullScreenLoader label="لیست بکآپها رو میاریم..." />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-3xl">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
|
||||
بکآپ دیتابیس 💾
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
یک نسخهی کامل و فشرده از دیتابیس بگیر یا نسخههای قبلی رو دانلود کن.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="tertiary" size="sm" icon={<RefreshCw size={14} />} onClick={load}>
|
||||
تازهسازی
|
||||
</Button>
|
||||
<Button size="sm" icon={<Database size={15} />} isLoading={creating} onClick={handleCreate}>
|
||||
ساخت بکآپ جدید
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
{!backups || backups.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-14 text-center">
|
||||
<HardDrive size={28} className="text-(--color-content-tertiary)" />
|
||||
<p className="text-[14px] font-bold text-(--color-content-primary)">
|
||||
هنوز هیچ بکآپی گرفته نشده
|
||||
</p>
|
||||
<p className="text-[13px] text-(--color-content-tertiary)">
|
||||
با دکمهی «ساخت بکآپ جدید» اولین نسخه رو بگیر.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-(--color-border-subtle)">
|
||||
{backups.map((b) => (
|
||||
<div key={b.filename} className="flex items-center justify-between px-5 py-4 gap-3">
|
||||
<div className="min-w-0">
|
||||
<p dir="ltr" className="font-bold text-[13px] text-(--color-content-primary) truncate">
|
||||
{b.filename}
|
||||
</p>
|
||||
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
|
||||
{new Date(b.created_at).toLocaleString("fa-IR")} · {formatSize(b.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<Download size={14} />}
|
||||
isLoading={downloadingFile === b.filename}
|
||||
onClick={() => handleDownload(b.filename)}
|
||||
className="shrink-0"
|
||||
>
|
||||
دانلود
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { ChevronRight, Plus, Pencil, Trash2, Settings2 } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { TextField } from "@/components/ui/TextField";
|
||||
import { Switch } from "@/components/ui/Controls";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import {
|
||||
ALL_CATEGORIES,
|
||||
CategoryKey,
|
||||
Category,
|
||||
Option,
|
||||
getCategory,
|
||||
updateCategory,
|
||||
listOptions,
|
||||
createOption,
|
||||
updateOption,
|
||||
deleteOption,
|
||||
} from "@/lib/api";
|
||||
|
||||
export default function CategoryDetailPage() {
|
||||
const params = useParams<{ key: string }>();
|
||||
const router = useRouter();
|
||||
const key = params.key as CategoryKey;
|
||||
const meta = ALL_CATEGORIES.find((c) => c.key === key);
|
||||
const toast = useToast();
|
||||
|
||||
const [category, setCategory] = useState<Category | null>(null);
|
||||
const [options, setOptions] = useState<Option[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [optionModal, setOptionModal] = useState<{ mode: "create" | "edit"; option?: Option } | null>(
|
||||
null
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Option | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [cat, opts] = await Promise.all([getCategory(key), listOptions(key)]);
|
||||
setCategory(cat);
|
||||
setOptions(opts);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (!meta) {
|
||||
return <ErrorBanner message="دستهی نامعتبر" />;
|
||||
}
|
||||
|
||||
if (error && !category) {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!category || !options) return <FullScreenLoader />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-3xl">
|
||||
<button
|
||||
onClick={() => router.push("/categories")}
|
||||
className="flex items-center gap-1 text-[13px] font-semibold text-(--color-content-secondary) hover:text-(--color-brand-600) w-fit"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
بازگشت به دستهبندیها
|
||||
</button>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px] flex items-center gap-2">
|
||||
<span>{meta.emoji}</span> {category.title}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge tone="brand">
|
||||
حداقل انتخاب: {category.min_select}
|
||||
</Badge>
|
||||
<Badge tone="neutral">
|
||||
حداکثر انتخاب: {category.max_select === 0 ? "نامحدود" : category.max_select}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="tertiary" size="sm" icon={<Settings2 size={15} />} onClick={() => setSettingsOpen(true)}>
|
||||
تنظیمات دسته
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[14px] font-bold text-(--color-content-secondary)">
|
||||
گزینهها ({options.length})
|
||||
</h2>
|
||||
<Button size="sm" icon={<Plus size={16} />} onClick={() => setOptionModal({ mode: "create" })}>
|
||||
افزودن گزینه
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
{options.length === 0 ? (
|
||||
<p className="text-[13px] text-(--color-content-tertiary) text-center py-10">
|
||||
هنوز گزینهای اضافه نشده.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
|
||||
<th className="text-right font-semibold px-4 py-3">عنوان</th>
|
||||
<th className="text-right font-semibold px-4 py-3 w-20">ترتیب</th>
|
||||
<th className="text-right font-semibold px-4 py-3 w-24">فعال</th>
|
||||
<th className="text-right font-semibold px-4 py-3 w-24">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...options]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((opt) => (
|
||||
<OptionRow
|
||||
key={opt.id}
|
||||
option={opt}
|
||||
onToggleActive={async (next) => {
|
||||
try {
|
||||
await updateOption(opt.id, { is_active: next });
|
||||
setOptions((prev) =>
|
||||
prev!.map((o) => (o.id === opt.id ? { ...o, is_active: next } : o))
|
||||
);
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
}
|
||||
}}
|
||||
onEdit={() => setOptionModal({ mode: "edit", option: opt })}
|
||||
onDelete={() => setDeleteTarget(opt)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<CategorySettingsModal
|
||||
open={settingsOpen}
|
||||
category={category}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onSaved={(updated) => {
|
||||
setCategory(updated);
|
||||
setSettingsOpen(false);
|
||||
toast.showSuccess("تنظیمات دسته بهروزرسانی شد");
|
||||
}}
|
||||
/>
|
||||
|
||||
<OptionFormModal
|
||||
open={!!optionModal}
|
||||
mode={optionModal?.mode ?? "create"}
|
||||
option={optionModal?.option}
|
||||
categoryKey={key}
|
||||
onClose={() => setOptionModal(null)}
|
||||
onSaved={(opt, mode) => {
|
||||
if (mode === "create") {
|
||||
setOptions((prev) => [...(prev ?? []), opt]);
|
||||
toast.showSuccess("گزینه اضافه شد");
|
||||
} else {
|
||||
setOptions((prev) => prev!.map((o) => (o.id === opt.id ? opt : o)));
|
||||
toast.showSuccess("گزینه ویرایش شد");
|
||||
}
|
||||
setOptionModal(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف گزینه؟"
|
||||
description={`گزینهی «${deleteTarget?.title}» برای همیشه حذف میشه. کاربرهایی که قبلاً این رو انتخاب کرده بودن، همچنان انتخابشون توی سابقه میمونه ولی این گزینه دیگه قابل انتخاب نیست.`}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteOption(deleteTarget.id);
|
||||
setOptions((prev) => prev!.filter((o) => o.id !== deleteTarget.id));
|
||||
toast.showSuccess("گزینه حذف شد");
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionRow({
|
||||
option,
|
||||
onToggleActive,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
option: Option;
|
||||
onToggleActive: (next: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<tr className="border-b border-(--color-border-subtle) last:border-0">
|
||||
<td className="px-4 py-3 font-medium text-(--color-content-primary)">{option.title}</td>
|
||||
<td className="px-4 py-3 text-(--color-content-tertiary)">{option.sort_order}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Switch defaultChecked={option.is_active} onChange={onToggleActive} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-red-50 text-(--color-status-error)"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySettingsModal({
|
||||
open,
|
||||
category,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
category: Category;
|
||||
onClose: () => void;
|
||||
onSaved: (c: Category) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(category.title);
|
||||
const [minSelect, setMinSelect] = useState(String(category.min_select));
|
||||
const [maxSelect, setMaxSelect] = useState(String(category.max_select));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTitle(category.title);
|
||||
setMinSelect(String(category.min_select));
|
||||
setMaxSelect(String(category.max_select));
|
||||
}, [category]);
|
||||
|
||||
async function handleSave() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateCategory(category.key, {
|
||||
title,
|
||||
min_select: parseInt(minSelect, 10),
|
||||
max_select: parseInt(maxSelect, 10),
|
||||
});
|
||||
onSaved(updated);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="تنظیمات دسته">
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField label="عنوان دسته" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TextField
|
||||
label="حداقل انتخاب"
|
||||
type="number"
|
||||
min={0}
|
||||
value={minSelect}
|
||||
onChange={(e) => setMinSelect(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="حداکثر انتخاب"
|
||||
type="number"
|
||||
min={0}
|
||||
helperText="۰ یعنی نامحدود"
|
||||
value={maxSelect}
|
||||
onChange={(e) => setMaxSelect(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ErrorBanner message={error} />
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button size="sm" isLoading={loading} onClick={handleSave}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionFormModal({
|
||||
open,
|
||||
mode,
|
||||
option,
|
||||
categoryKey,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: "create" | "edit";
|
||||
option?: Option;
|
||||
categoryKey: CategoryKey;
|
||||
onClose: () => void;
|
||||
onSaved: (opt: Option, mode: "create" | "edit") => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(option?.title ?? "");
|
||||
const [sortOrder, setSortOrder] = useState(String(option?.sort_order ?? 0));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTitle(option?.title ?? "");
|
||||
setSortOrder(String(option?.sort_order ?? 0));
|
||||
setError(null);
|
||||
}, [option, open]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim()) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const created = await createOption(categoryKey, {
|
||||
title: title.trim(),
|
||||
sort_order: parseInt(sortOrder, 10) || 0,
|
||||
});
|
||||
onSaved(created, "create");
|
||||
} else if (option) {
|
||||
const updated = await updateOption(option.id, {
|
||||
title: title.trim(),
|
||||
sort_order: parseInt(sortOrder, 10) || 0,
|
||||
});
|
||||
onSaved(updated, "edit");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={mode === "create" ? "افزودن گزینه" : "ویرایش گزینه"}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField
|
||||
label="عنوان"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
label="ترتیب نمایش"
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
/>
|
||||
<ErrorBanner message={error} />
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button size="sm" isLoading={loading} disabled={!title.trim()} onClick={handleSave}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { ALL_CATEGORIES } from "@/lib/api";
|
||||
|
||||
export default function CategoriesIndexPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-5xl">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||
دستهبندیها و گزینهها 🗂️
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
هر دسته یه لیست انتخابیه که توی مراحل آنبوردینگ به کاربر نشون داده میشه.
|
||||
روی هرکدوم بزن تا گزینههاش رو مدیریت کنی.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{ALL_CATEGORIES.map((c) => (
|
||||
<Link key={c.key} href={`/categories/${c.key}`}>
|
||||
<Card hoverable className="flex items-center justify-between h-full">
|
||||
<div>
|
||||
<p className="text-[24px] mb-1">{c.emoji}</p>
|
||||
<p className="font-bold text-[15px] text-(--color-content-primary)">
|
||||
{c.fallbackTitle}
|
||||
</p>
|
||||
<p className="text-[11px] font-mono text-(--color-content-tertiary) mt-0.5">
|
||||
{c.key}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { status, logout } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "guest") router.replace("/login");
|
||||
}, [status, router]);
|
||||
|
||||
if (status === "loading" || status === "guest") return <FullScreenLoader />;
|
||||
|
||||
if (status === "not-admin") {
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center px-4">
|
||||
<Card className="w-full max-w-sm flex flex-col items-center gap-4 !p-8 text-center">
|
||||
<div className="w-14 h-14 rounded-(--radius-lg) bg-red-50 flex items-center justify-center">
|
||||
<ShieldAlert size={26} className="text-(--color-status-error)" />
|
||||
</div>
|
||||
<h2 className="font-[var(--font-display)] font-extrabold text-[18px]">
|
||||
دسترسی نداری
|
||||
</h2>
|
||||
<p className="text-[13px] text-(--color-content-secondary) leading-6">
|
||||
حساب واردشده دسترسی ادمین نداره.
|
||||
</p>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
logout();
|
||||
router.push("/login");
|
||||
}}
|
||||
>
|
||||
خروج و ورود با حساب دیگه
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-w-0 p-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight, HeartHandshake } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { AdminMatchView, listAllMatches } from "@/lib/api";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function MatchesPage() {
|
||||
const [items, setItems] = useState<AdminMatchView[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (p: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await listAllMatches(p, PAGE_SIZE);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load(page);
|
||||
}, [page, load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||
رفقا (مچها) 💞
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
همهی جفتهایی که با هم مچ شدن، جدیدترین اول.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{!items ? (
|
||||
<FullScreenLoader />
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<HeartHandshake size={32} />}
|
||||
title="هنوز هیچ مچی اتفاق نیفتاده"
|
||||
description="وقتی دو نفر همدیگه رو لایک کنن، اینجا نشون داده میشن."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
{items.map((m, i) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex items-center gap-4 px-4 py-3.5 ${
|
||||
i !== items.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center -space-x-3 space-x-reverse shrink-0">
|
||||
<Avatar name={m.user_one_name || m.user_one_phone} />
|
||||
<Avatar name={m.user_two_name || m.user_two_phone} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 grid grid-cols-2 gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-[13.5px] text-(--color-content-primary) truncate">
|
||||
{m.user_one_name || "بدون نام"}
|
||||
</p>
|
||||
<p dir="ltr" className="text-[11.5px] text-(--color-content-tertiary) text-right">
|
||||
{m.user_one_phone}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-[13.5px] text-(--color-content-primary) truncate">
|
||||
{m.user_two_name || "بدون نام"}
|
||||
</p>
|
||||
<p dir="ltr" className="text-[11.5px] text-(--color-content-tertiary) text-right">
|
||||
{m.user_two_phone}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-(--color-content-tertiary) shrink-0">
|
||||
{new Date(m.created_at).toLocaleDateString("fa-IR")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} مچ
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronRight size={14} />}
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronLeft size={14} />}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Activity, AlertTriangle, Clock, Gauge, RefreshCw, Zap } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import {
|
||||
MonitoringSummary,
|
||||
RequestLogEntry,
|
||||
getMonitoringSummary,
|
||||
getRecentRequests,
|
||||
} from "@/lib/api";
|
||||
|
||||
const POLL_MS = 8_000;
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "warning" | "error";
|
||||
}) {
|
||||
const toneClasses =
|
||||
tone === "error"
|
||||
? "text-(--color-status-error)"
|
||||
: tone === "warning"
|
||||
? "text-amber-600"
|
||||
: "text-(--color-brand-600)";
|
||||
return (
|
||||
<Card className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-(--radius-md) bg-(--color-bg-secondary) flex items-center justify-center shrink-0 ${toneClasses}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[12px] text-(--color-content-tertiary)">{label}</p>
|
||||
<p className="text-[19px] font-extrabold text-(--color-content-primary)">{value}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(code: number): "success" | "warning" | "error" | "neutral" {
|
||||
if (code >= 500) return "error";
|
||||
if (code >= 400) return "warning";
|
||||
if (code >= 200 && code < 300) return "success";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h} ساعت و ${m} دقیقه`;
|
||||
return `${m} دقیقه`;
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [summary, setSummary] = useState<MonitoringSummary | null>(null);
|
||||
const [requests, setRequests] = useState<RequestLogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [s, r] = await Promise.all([getMonitoringSummary(), getRecentRequests(50)]);
|
||||
setSummary(s);
|
||||
setRequests(r);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
const interval = setInterval(load, POLL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [load]);
|
||||
|
||||
if (!summary || !requests) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <FullScreenLoader label="آمار سرور رو میاریم..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-5xl">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
|
||||
مانیتورینگ سرور 📡
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
وضعیت زندهی بکاند — هر {POLL_MS / 1000} ثانیه خودکار بهروز میشه.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="tertiary" size="sm" icon={<RefreshCw size={14} />} onClick={load}>
|
||||
تازهسازی
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
icon={<Activity size={18} />}
|
||||
label="کل درخواستها (از زمان اجرا)"
|
||||
value={summary.total_requests.toLocaleString("fa-IR")}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Zap size={18} />}
|
||||
label="درخواست در دقیقهی اخیر"
|
||||
value={summary.requests_last_min.toLocaleString("fa-IR")}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock size={18} />}
|
||||
label="زمان پاسخ میانگین"
|
||||
value={`${summary.avg_latency_ms.toFixed(1)} ms`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Gauge size={18} />}
|
||||
label="زمان پاسخ p95"
|
||||
value={`${summary.p95_latency_ms.toFixed(1)} ms`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertTriangle size={18} />}
|
||||
label="نرخ خطا"
|
||||
value={`${summary.error_rate_pct.toFixed(1)}%`}
|
||||
tone={summary.error_rate_pct > 5 ? "error" : summary.error_rate_pct > 0 ? "warning" : "neutral"}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity size={18} />}
|
||||
label="مدت زمان روشن بودن سرور"
|
||||
value={formatUptime(summary.uptime_seconds)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-(--color-border-subtle)">
|
||||
<h2 className="font-bold text-[15px]">آخرین درخواستها</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
|
||||
<th className="text-right font-semibold px-5 py-2.5">متد</th>
|
||||
<th className="text-right font-semibold px-5 py-2.5">مسیر</th>
|
||||
<th className="text-right font-semibold px-5 py-2.5">وضعیت</th>
|
||||
<th className="text-right font-semibold px-5 py-2.5">مدت</th>
|
||||
<th className="text-right font-semibold px-5 py-2.5">IP</th>
|
||||
<th className="text-right font-semibold px-5 py-2.5">زمان</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center text-(--color-content-tertiary) py-8">
|
||||
هنوز درخواستی ثبت نشده
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
requests.map((r, i) => (
|
||||
<tr key={i} className="border-b border-(--color-border-subtle) last:border-0">
|
||||
<td className="px-5 py-2.5 font-bold" dir="ltr">
|
||||
{r.method}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 font-mono text-[12px] text-(--color-content-secondary)" dir="ltr">
|
||||
{r.path || "—"}
|
||||
</td>
|
||||
<td className="px-5 py-2.5">
|
||||
<Badge tone={statusTone(r.status_code)}>{r.status_code}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-2.5" dir="ltr">
|
||||
{r.duration_ms.toFixed(1)} ms
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-(--color-content-tertiary)" dir="ltr">
|
||||
{r.client_ip}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-(--color-content-tertiary)">
|
||||
{new Date(r.time).toLocaleTimeString("fa-IR")}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, UserCheck } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ALL_CATEGORIES, listOptions, listQuestions, listPendingUsers } from "@/lib/api";
|
||||
|
||||
interface Stat {
|
||||
key: string;
|
||||
label: string;
|
||||
emoji: string;
|
||||
count: number;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export default function DashboardHomePage() {
|
||||
const [stats, setStats] = useState<Stat[] | null>(null);
|
||||
const [questionsCount, setQuestionsCount] = useState<number | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listPendingUsers()
|
||||
.then((users) => setPendingCount(users.length))
|
||||
.catch(() => setPendingCount(0));
|
||||
|
||||
Promise.all(
|
||||
ALL_CATEGORIES.map(async (c) => {
|
||||
try {
|
||||
const options = await listOptions(c.key);
|
||||
return {
|
||||
key: c.key,
|
||||
label: c.fallbackTitle,
|
||||
emoji: c.emoji,
|
||||
count: options.length,
|
||||
href: `/categories/${c.key}`,
|
||||
};
|
||||
} catch {
|
||||
return { key: c.key, label: c.fallbackTitle, emoji: c.emoji, count: 0, href: `/categories/${c.key}` };
|
||||
}
|
||||
})
|
||||
).then(setStats);
|
||||
|
||||
listQuestions()
|
||||
.then((qs) => setQuestionsCount(qs.length))
|
||||
.catch(() => setQuestionsCount(0));
|
||||
}, []);
|
||||
|
||||
if (!stats) return <FullScreenLoader label="آمار رو میاریم..." />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-5xl">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[26px]">
|
||||
سلام ادمین جان 👋
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
از اینجا میتونی لیستهای انتخابی و سوالات پروفایل اپ دلدل رو مدیریت کنی.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link href="/users">
|
||||
<Card
|
||||
hoverable
|
||||
className="flex items-center justify-between max-w-sm !border-(--color-brand-200) bg-(--color-brand-50)"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-(--radius-md) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-sm) shrink-0">
|
||||
<UserCheck size={19} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-[15px]">کاربران در انتظار تایید</p>
|
||||
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
|
||||
{pendingCount === null ? "…" : `${pendingCount} نفر منتظرن`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!!pendingCount && (
|
||||
<Badge tone="warning" className="shrink-0">
|
||||
{pendingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<h2 className="text-[13px] font-bold text-(--color-content-secondary) mb-3">
|
||||
دستهبندیها
|
||||
</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{stats.map((s) => (
|
||||
<Link key={s.key} href={s.href}>
|
||||
<Card hoverable className="flex items-center justify-between h-full">
|
||||
<div>
|
||||
<p className="text-[24px] mb-1">{s.emoji}</p>
|
||||
<p className="font-bold text-[15px] text-(--color-content-primary)">
|
||||
{s.label}
|
||||
</p>
|
||||
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
|
||||
{s.count} گزینه
|
||||
</p>
|
||||
</div>
|
||||
<ArrowLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-[13px] font-bold text-(--color-content-secondary) mb-3">
|
||||
سوالات پروفایل
|
||||
</h2>
|
||||
<Link href="/questions">
|
||||
<Card hoverable className="flex items-center justify-between max-w-sm">
|
||||
<div>
|
||||
<p className="text-[24px] mb-1">💬</p>
|
||||
<p className="font-bold text-[15px]">سوالات پروفایل</p>
|
||||
<p className="text-[12px] text-(--color-content-tertiary) mt-0.5">
|
||||
{questionsCount ?? "…"} سوال
|
||||
</p>
|
||||
</div>
|
||||
<ArrowLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ChevronLeft, ChevronRight, ImageOff, Trash2, Flag } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { AdminPhotoView, listAllPhotos, deletePhotoAdmin, reportPhoto, photoUrl } from "@/lib/api";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
export default function PhotosModerationPage() {
|
||||
const toast = useToast();
|
||||
const [items, setItems] = useState<AdminPhotoView[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<AdminPhotoView | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [pendingReport, setPendingReport] = useState<AdminPhotoView | null>(null);
|
||||
const [reportReason, setReportReason] = useState("");
|
||||
const [reporting, setReporting] = useState(false);
|
||||
|
||||
const load = useCallback(async (p: number) => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await listAllPhotos(p, PAGE_SIZE);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load(page);
|
||||
}, [page, load]);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!pendingDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deletePhotoAdmin(pendingDelete.id);
|
||||
toast.showSuccess("عکس حذف شد");
|
||||
setPendingDelete(null);
|
||||
load(page);
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReport() {
|
||||
if (!pendingReport || reportReason.trim().length < 3) return;
|
||||
setReporting(true);
|
||||
try {
|
||||
await reportPhoto(pendingReport.id, reportReason.trim());
|
||||
toast.showSuccess("گزارش ثبت شد");
|
||||
setPendingReport(null);
|
||||
setReportReason("");
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setReporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||
نظارت بر عکسها 🖼️
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
آخرین عکسهای آپلودشدهی همهی کاربران، جدیدترین اول.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{!items ? (
|
||||
<FullScreenLoader />
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<ImageOff size={32} />}
|
||||
title="هنوز عکسی آپلود نشده"
|
||||
description="عکسهای آپلودشدهی کاربران اینجا برای نظارت نشون داده میشن."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{items.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="group relative aspect-square rounded-(--radius-md) overflow-hidden bg-(--color-bg-tertiary) border border-(--color-border-subtle)"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photoUrl(p.url)}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 pt-6">
|
||||
<p className="text-[11px] font-bold text-white truncate">
|
||||
{p.owner_name || "بدون نام"}
|
||||
</p>
|
||||
<p dir="ltr" className="text-[10px] text-white/80 truncate">
|
||||
{p.owner_phone}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-1.5 left-1.5 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => setPendingReport(p)}
|
||||
className="w-7 h-7 rounded-full bg-black/50 hover:bg-amber-600 flex items-center justify-center text-white"
|
||||
aria-label="گزارش عکس"
|
||||
>
|
||||
<Flag size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPendingDelete(p)}
|
||||
className="w-7 h-7 rounded-full bg-black/50 hover:bg-(--color-status-error) flex items-center justify-center text-white"
|
||||
aria-label="حذف عکس"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} عکس
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronRight size={14} />}
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronLeft size={14} />}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pendingDelete}
|
||||
title="حذف عکس"
|
||||
description="این عکس برای همیشه حذف میشه و از پروفایل کاربر هم پاک میشه. مطمئنی؟"
|
||||
loading={deleting}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={!!pendingReport}
|
||||
onClose={() => setPendingReport(null)}
|
||||
title="گزارش عکس"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-[13px] text-(--color-content-secondary)">
|
||||
یک یادداشت نظارتی روی صاحب این عکس ثبت میشه (مستقل از حذف کردن).
|
||||
</p>
|
||||
<textarea
|
||||
value={reportReason}
|
||||
onChange={(e) => setReportReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="دلیل گزارش..."
|
||||
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setPendingReport(null)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={reporting}
|
||||
disabled={reportReason.trim().length < 3}
|
||||
onClick={handleReport}
|
||||
>
|
||||
ثبت گزارش
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/TextField";
|
||||
import { Switch } from "@/components/ui/Controls";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import {
|
||||
ProfileQuestion,
|
||||
listQuestions,
|
||||
createQuestion,
|
||||
updateQuestion,
|
||||
deleteQuestion,
|
||||
} from "@/lib/api";
|
||||
|
||||
export default function QuestionsPage() {
|
||||
const toast = useToast();
|
||||
const [questions, setQuestions] = useState<ProfileQuestion[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [formModal, setFormModal] = useState<{ mode: "create" | "edit"; q?: ProfileQuestion } | null>(
|
||||
null
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProfileQuestion | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setQuestions(await listQuestions());
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (error && !questions) {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!questions) return <FullScreenLoader />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-3xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||
سوالات پروفایل 💬
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
کاربر توی مرحلهی سوم آنبوردینگ دقیقاً ۳ تا از این سوالات رو انتخاب و
|
||||
جواب میده.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" icon={<Plus size={16} />} onClick={() => setFormModal({ mode: "create" })}>
|
||||
افزودن سوال
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
{questions.length === 0 ? (
|
||||
<p className="text-[13px] text-(--color-content-tertiary) text-center py-10">
|
||||
هنوز سوالی اضافه نشده.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-(--color-content-tertiary) text-[11px] border-b border-(--color-border-subtle)">
|
||||
<th className="text-right font-semibold px-4 py-3">متن سوال</th>
|
||||
<th className="text-right font-semibold px-4 py-3 w-20">ترتیب</th>
|
||||
<th className="text-right font-semibold px-4 py-3 w-24">فعال</th>
|
||||
<th className="text-right font-semibold px-4 py-3 w-24">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...questions]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((q) => (
|
||||
<tr key={q.id} className="border-b border-(--color-border-subtle) last:border-0">
|
||||
<td className="px-4 py-3 font-medium text-(--color-content-primary)">
|
||||
{q.text}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-(--color-content-tertiary)">{q.sort_order}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Switch
|
||||
defaultChecked={q.is_active}
|
||||
onChange={async (next) => {
|
||||
try {
|
||||
await updateQuestion(q.id, { is_active: next });
|
||||
setQuestions((prev) =>
|
||||
prev!.map((x) => (x.id === q.id ? { ...x, is_active: next } : x))
|
||||
);
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setFormModal({ mode: "edit", q })}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(q)}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center hover:bg-red-50 text-(--color-status-error)"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<QuestionFormModal
|
||||
open={!!formModal}
|
||||
mode={formModal?.mode ?? "create"}
|
||||
question={formModal?.q}
|
||||
onClose={() => setFormModal(null)}
|
||||
onSaved={(q, mode) => {
|
||||
if (mode === "create") {
|
||||
setQuestions((prev) => [...(prev ?? []), q]);
|
||||
toast.showSuccess("سوال اضافه شد");
|
||||
} else {
|
||||
setQuestions((prev) => prev!.map((x) => (x.id === q.id ? q : x)));
|
||||
toast.showSuccess("سوال ویرایش شد");
|
||||
}
|
||||
setFormModal(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف سوال؟"
|
||||
description="این سوال از لیست سوالات قابلانتخاب حذف میشه. پاسخهایی که کاربرها قبلاً بهش دادن دستنخورده میمونه."
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteQuestion(deleteTarget.id);
|
||||
setQuestions((prev) => prev!.filter((x) => x.id !== deleteTarget.id));
|
||||
toast.showSuccess("سوال حذف شد");
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionFormModal({
|
||||
open,
|
||||
mode,
|
||||
question,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: "create" | "edit";
|
||||
question?: ProfileQuestion;
|
||||
onClose: () => void;
|
||||
onSaved: (q: ProfileQuestion, mode: "create" | "edit") => void;
|
||||
}) {
|
||||
const [text, setText] = useState(question?.text ?? "");
|
||||
const [sortOrder, setSortOrder] = useState(String(question?.sort_order ?? 0));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setText(question?.text ?? "");
|
||||
setSortOrder(String(question?.sort_order ?? 0));
|
||||
setError(null);
|
||||
}, [question, open]);
|
||||
|
||||
async function handleSave() {
|
||||
if (text.trim().length < 3) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const created = await createQuestion({ text: text.trim(), sort_order: parseInt(sortOrder, 10) || 0 });
|
||||
onSaved(created, "create");
|
||||
} else if (question) {
|
||||
const updated = await updateQuestion(question.id, {
|
||||
text: text.trim(),
|
||||
sort_order: parseInt(sortOrder, 10) || 0,
|
||||
});
|
||||
onSaved(updated, "edit");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={mode === "create" ? "افزودن سوال" : "ویرایش سوال"}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||
متن سوال
|
||||
</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="ترتیب نمایش"
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
/>
|
||||
<ErrorBanner message={error} />
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button size="sm" isLoading={loading} disabled={text.trim().length < 3} onClick={handleSave}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Ruler,
|
||||
Weight,
|
||||
GraduationCap,
|
||||
Briefcase,
|
||||
Heart,
|
||||
Sparkles,
|
||||
Search,
|
||||
Check,
|
||||
Flag,
|
||||
Activity,
|
||||
Images,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Tag } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { UserModerationActions } from "@/components/UserModerationActions";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { calcAge } from "@/lib/utils";
|
||||
import {
|
||||
FullProfile,
|
||||
ApprovalStatus,
|
||||
Report,
|
||||
RequestLogEntry,
|
||||
getUserDetail,
|
||||
approveUser,
|
||||
rejectUser,
|
||||
listUserReports,
|
||||
getRecentRequests,
|
||||
photoUrl,
|
||||
} from "@/lib/api";
|
||||
|
||||
const statusMeta: Record<ApprovalStatus, { label: string; tone: "warning" | "success" | "error" }> = {
|
||||
pending: { label: "در انتظار بررسی", tone: "warning" },
|
||||
approved: { label: "تایید شده", tone: "success" },
|
||||
rejected: { label: "رد شده", tone: "error" },
|
||||
};
|
||||
|
||||
function selectionTitles(profile: FullProfile, key: string): string[] {
|
||||
return profile.selections.find((s) => s.category_key === key)?.titles ?? [];
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-(--color-content-secondary)">
|
||||
{icon}
|
||||
<span className="text-[13px] font-bold">{title}</span>
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserReviewPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const id = Number(params.id);
|
||||
|
||||
const [profile, setProfile] = useState<FullProfile | null>(null);
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [activity, setActivity] = useState<RequestLogEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [acting, setActing] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setProfile(await getUserDetail(id));
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
listUserReports(id).then(setReports).catch(() => setReports([]));
|
||||
getRecentRequests(15, id).then(setActivity).catch(() => setActivity([]));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleApprove() {
|
||||
setActing(true);
|
||||
try {
|
||||
await approveUser(id);
|
||||
toast.showSuccess("کاربر تایید شد");
|
||||
router.push("/users");
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !profile) {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!profile) return <FullScreenLoader />;
|
||||
|
||||
const { user } = profile;
|
||||
const age = calcAge(user.birth_date);
|
||||
const education = selectionTitles(profile, "education_level")[0];
|
||||
const fieldOfStudy = selectionTitles(profile, "field_of_study")[0];
|
||||
const occupations = selectionTitles(profile, "occupation");
|
||||
const importance = selectionTitles(profile, "important_in_relationship");
|
||||
const interests = selectionTitles(profile, "interest");
|
||||
const lookingFor = selectionTitles(profile, "looking_for")[0];
|
||||
const status = statusMeta[user.approval_status];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-3xl pb-20">
|
||||
<button
|
||||
onClick={() => router.push("/users")}
|
||||
className="flex items-center gap-1 text-[13px] font-semibold text-(--color-content-secondary) hover:text-(--color-brand-600) w-fit"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
بازگشت به لیست
|
||||
</button>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={user.first_name || user.phone} size="lg" />
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[22px]">
|
||||
{user.first_name || "بدون نام"}
|
||||
{age ? `، ${age}` : ""}
|
||||
</h1>
|
||||
<p dir="ltr" className="text-[13px] text-(--color-content-tertiary)">
|
||||
{user.phone}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge tone={status.tone}>{status.label}</Badge>
|
||||
{user.gender && (
|
||||
<Badge tone="neutral">{user.gender === "male" ? "مرد" : "زن"}</Badge>
|
||||
)}
|
||||
{user.state === "blocked" && <Badge tone="error">بلاک</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<UserModerationActions userId={id} state={user.state} onChanged={load} />
|
||||
<Button
|
||||
variant="tertiary"
|
||||
icon={<XCircle size={16} />}
|
||||
className="!text-(--color-status-error) !border-red-200"
|
||||
onClick={() => setRejectOpen(true)}
|
||||
disabled={acting}
|
||||
>
|
||||
رد کردن
|
||||
</Button>
|
||||
<Button icon={<CheckCircle2 size={16} />} isLoading={acting} onClick={handleApprove}>
|
||||
تایید کاربر
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.approval_status === "rejected" && user.rejection_reason && (
|
||||
<ErrorBanner message={`دلیل رد قبلی: ${user.rejection_reason}`} />
|
||||
)}
|
||||
|
||||
{user.state === "blocked" && user.blocked_reason && (
|
||||
<ErrorBanner message={`دلیل بلاک: ${user.blocked_reason}`} />
|
||||
)}
|
||||
|
||||
<SectionCard icon={<Images size={15} />} title={`عکسها (${profile.photos.length})`}>
|
||||
{profile.photos.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-(--color-content-tertiary) text-[13px]">
|
||||
<UserIcon size={15} />
|
||||
<span>این کاربر هنوز عکسی آپلود نکرده.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2.5">
|
||||
{profile.photos.map((p) => (
|
||||
<a
|
||||
key={p.id}
|
||||
href={photoUrl(p.url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative aspect-square rounded-(--radius-md) overflow-hidden bg-(--color-bg-tertiary) block"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photoUrl(p.url)} alt="" className="w-full h-full object-cover" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={<Ruler size={15} />} title="قد و وزن">
|
||||
<div className="flex gap-4 text-[14px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Ruler size={14} className="text-(--color-brand-500)" />
|
||||
{user.height_cm ?? "—"} سانتیمتر
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Weight size={14} className="text-(--color-brand-500)" />
|
||||
{user.weight_kg ?? "—"} کیلوگرم
|
||||
</span>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={<GraduationCap size={15} />} title="تحصیلات">
|
||||
<p className="text-[14px] text-(--color-content-primary)">
|
||||
{education ?? "—"} {fieldOfStudy ? `· ${fieldOfStudy}` : ""}
|
||||
</p>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={<Briefcase size={15} />} title="فعالیتها">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{occupations.length ? (
|
||||
occupations.map((t) => <Tag key={t} label={t} color="sky" />)
|
||||
) : (
|
||||
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={<Heart size={15} />} title="توی رابطه براش مهمه">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{importance.length ? (
|
||||
importance.map((t) => <Tag key={t} label={t} color="pink" />)
|
||||
) : (
|
||||
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={<Sparkles size={15} />} title="علاقهمندیها">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{interests.length ? (
|
||||
interests.map((t) => <Tag key={t} label={t} color="yellow" />)
|
||||
) : (
|
||||
<span className="text-[13px] text-(--color-content-tertiary)">—</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={<Search size={15} />} title="دنبال چی میگرده">
|
||||
<p className="text-[14px] text-(--color-content-primary)">{lookingFor ?? "—"}</p>
|
||||
</SectionCard>
|
||||
|
||||
{profile.profile_answers.length > 0 && (
|
||||
<SectionCard icon={<Sparkles size={15} />} title="یکم از خودش">
|
||||
<div className="flex flex-col gap-3">
|
||||
{profile.profile_answers.map((a) => (
|
||||
<div key={a.id}>
|
||||
<p className="text-[12px] font-bold text-(--color-content-tertiary) mb-0.5">
|
||||
{a.question?.text}
|
||||
</p>
|
||||
<p className="text-[14px] text-(--color-content-primary)">{a.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{reports.length > 0 && (
|
||||
<SectionCard icon={<Flag size={15} />} title={`سابقهی گزارشها (${reports.length})`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{reports.map((r) => (
|
||||
<div key={r.id} className="flex flex-col gap-0.5">
|
||||
<p className="text-[14px] text-(--color-content-primary)">{r.reason}</p>
|
||||
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||
{new Date(r.created_at).toLocaleString("fa-IR")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard icon={<Activity size={15} />} title="فعالیت اخیر">
|
||||
{activity.length === 0 ? (
|
||||
<p className="text-[13px] text-(--color-content-tertiary)">
|
||||
درخواستی از این کاربر در حافظهی اخیر سرور ثبت نشده.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{activity.map((a, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[12.5px]">
|
||||
<span dir="ltr" className="font-mono text-(--color-content-secondary) truncate">
|
||||
{a.method} {a.path}
|
||||
</span>
|
||||
<span className="text-(--color-content-tertiary) shrink-0 pr-2">
|
||||
{new Date(a.time).toLocaleTimeString("fa-IR")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<RejectModal
|
||||
open={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
onConfirm={async (reason) => {
|
||||
setActing(true);
|
||||
try {
|
||||
await rejectUser(id, reason);
|
||||
toast.showSuccess("کاربر رد شد");
|
||||
setRejectOpen(false);
|
||||
router.push("/users");
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
}}
|
||||
loading={acting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// دلایل رایج رد پروفایل — ادمین تیک میزنه، متن نهایی خودکار برای کاربر ساخته میشه
|
||||
const COMMON_REASONS = [
|
||||
"عکس پروفایل نداره یا واضح نیست",
|
||||
"اطلاعات پروفایل ناقصه",
|
||||
"پروفایل به نظر جعلی یا ربات میاد",
|
||||
"محتوای عکسها نامناسبه",
|
||||
"توی پروفایل شماره تماس یا لینک گذاشته",
|
||||
];
|
||||
|
||||
function RejectModal({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (reason: string) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [extra, setExtra] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSelected([]);
|
||||
setExtra("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function toggle(text: string) {
|
||||
setSelected((prev) =>
|
||||
prev.includes(text) ? prev.filter((t) => t !== text) : [...prev, text]
|
||||
);
|
||||
}
|
||||
|
||||
const finalReason = [...selected, extra.trim()].filter(Boolean).join("؛ ");
|
||||
const valid = finalReason.length >= 3;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="رد کردن کاربر">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||
دلیل رد (برای کاربر نمایش داده میشه)
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{COMMON_REASONS.map((text) => {
|
||||
const checked = selected.includes(text);
|
||||
return (
|
||||
<button
|
||||
key={text}
|
||||
type="button"
|
||||
onClick={() => toggle(text)}
|
||||
className={`flex items-center gap-2.5 rounded-(--radius-sm) border-2 px-3.5 py-2.5 text-right text-[13px] font-medium transition-colors ${
|
||||
checked
|
||||
? "border-(--color-brand-500) bg-(--color-surface-interactive) text-(--color-brand-700)"
|
||||
: "border-(--color-border-default) text-(--color-content-secondary)"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-5 w-5 rounded-(--radius-xs) flex items-center justify-center shrink-0 border-2 ${
|
||||
checked
|
||||
? "bg-(--color-brand-500) border-(--color-brand-500)"
|
||||
: "border-(--color-border-default)"
|
||||
}`}
|
||||
>
|
||||
{checked && <Check size={13} className="text-white" strokeWidth={3} />}
|
||||
</span>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[13px] font-semibold text-(--color-content-secondary) px-1">
|
||||
توضیح تکمیلی (اختیاری)
|
||||
</label>
|
||||
<textarea
|
||||
value={extra}
|
||||
onChange={(e) => setExtra(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="اگه دلیل دیگهای هست بنویس..."
|
||||
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
disabled={!valid}
|
||||
className="!bg-(--color-status-error) !shadow-none hover:!bg-red-700"
|
||||
onClick={() => onConfirm(finalReason)}
|
||||
>
|
||||
رد کردن کاربر
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight, Search, Users as UsersIcon } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { UserModerationActions } from "@/components/UserModerationActions";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { calcAge } from "@/lib/utils";
|
||||
import { AdminUserListItem, listAllUsers } from "@/lib/api";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const approvalToneMap = {
|
||||
pending: "warning",
|
||||
approved: "success",
|
||||
rejected: "error",
|
||||
} as const;
|
||||
const approvalLabelMap = {
|
||||
pending: "در انتظار",
|
||||
approved: "تاییدشده",
|
||||
rejected: "ردشده",
|
||||
};
|
||||
|
||||
export default function AllUsersPage() {
|
||||
const [items, setItems] = useState<AdminUserListItem[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (p: number, s: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await listAllUsers(s, p, PAGE_SIZE);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load(page, search);
|
||||
// search دونهشده جدا (effect پایین) هندل میشه؛ اینجا فقط تغییر page مهمه
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, load]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setPage(1);
|
||||
load(1, search);
|
||||
}, 350);
|
||||
return () => clearTimeout(t);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-4xl">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||
همهی کاربران 👥
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
جستوجو بر اساس شماره یا اسم؛ بلاک/آنبلاک و گزارش کاربران از همینجا.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search size={16} className="absolute right-3.5 top-1/2 -translate-y-1/2 text-(--color-content-tertiary)" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="جستوجو با شماره یا اسم..."
|
||||
className="w-full h-11 rounded-(--radius-md) border-2 border-(--color-border-default) bg-(--color-surface-default) pr-10 pl-4 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{!items ? (
|
||||
<FullScreenLoader />
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState icon={<UsersIcon size={32} />} title="کاربری پیدا نشد" description="جستوجوت رو تغییر بده." />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
{items.map((u, i) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className={`flex items-center gap-3 px-4 py-3.5 ${
|
||||
i !== items.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||
}`}
|
||||
>
|
||||
<Link href={`/users/${u.id}`} className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Avatar name={u.first_name || u.phone} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-[14px] text-(--color-content-primary) truncate">
|
||||
{u.first_name || "بدون نام"}
|
||||
{calcAge(u.birth_date) ? `، ${calcAge(u.birth_date)} سال` : ""}
|
||||
</p>
|
||||
<p dir="ltr" className="text-[12px] text-(--color-content-tertiary) text-right">
|
||||
{u.phone}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{u.is_onboarded && (
|
||||
<Badge tone={approvalToneMap[u.approval_status]}>
|
||||
{approvalLabelMap[u.approval_status]}
|
||||
</Badge>
|
||||
)}
|
||||
{u.state === "blocked" && <Badge tone="error">بلاک</Badge>}
|
||||
{u.report_count > 0 && (
|
||||
<Badge tone="warning">{u.report_count} گزارش</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserModerationActions
|
||||
userId={u.id}
|
||||
state={u.state}
|
||||
onChanged={() => load(page, search)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[12px] text-(--color-content-tertiary)">
|
||||
صفحه {page.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")} · {total.toLocaleString("fa-IR")} کاربر
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronRight size={14} />}
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
قبلی
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ChevronLeft size={14} />}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
بعدی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, UserCheck } from "lucide-react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { FullScreenLoader } from "@/components/ui/FullScreenLoader";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { calcAge } from "@/lib/utils";
|
||||
import { PendingUserView, listPendingUsers, photoUrl } from "@/lib/api";
|
||||
|
||||
export default function PendingUsersPage() {
|
||||
const [users, setUsers] = useState<PendingUserView[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setUsers(await listPendingUsers());
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (error && !users) {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<ErrorBanner message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!users) return <FullScreenLoader />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[24px]">
|
||||
کاربران در انتظار تایید ✅
|
||||
</h1>
|
||||
<p className="text-[14px] text-(--color-content-secondary) mt-1">
|
||||
این کاربرها آنبوردینگ رو کامل کردن و منتظرن پروفایلشون بررسی بشه. قدیمیترین
|
||||
درخواست بالاتره.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<UserCheck size={32} />}
|
||||
title="کسی منتظر بررسی نیست"
|
||||
description="هر وقت کاربر جدیدی آنبوردینگ رو تموم کنه، اینجا برای بررسی نشون داده میشه."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padded={false} className="overflow-hidden">
|
||||
{users.map((u, i) => (
|
||||
<Link
|
||||
key={u.id}
|
||||
href={`/users/${u.id}`}
|
||||
className={`flex items-center gap-3 px-4 py-3.5 hover:bg-(--color-bg-secondary) transition-colors ${
|
||||
i !== users.length - 1 ? "border-b border-(--color-border-subtle)" : ""
|
||||
}`}
|
||||
>
|
||||
{u.photos.length > 0 ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={photoUrl(u.photos[0])}
|
||||
alt=""
|
||||
className="w-11 h-11 rounded-(--radius-md) object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<Avatar name={u.first_name || u.phone} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-[14px] text-(--color-content-primary) truncate">
|
||||
{u.first_name || "بدون نام"}
|
||||
{calcAge(u.birth_date) ? `، ${calcAge(u.birth_date)} سال` : ""}
|
||||
</p>
|
||||
<p dir="ltr" className="text-[12px] text-(--color-content-tertiary) text-right">
|
||||
{u.phone}
|
||||
</p>
|
||||
</div>
|
||||
{u.photos.length === 0 && (
|
||||
<Badge tone="warning" className="shrink-0">
|
||||
بدون عکس
|
||||
</Badge>
|
||||
)}
|
||||
{u.gender && (
|
||||
<Badge tone="neutral" className="shrink-0">
|
||||
{u.gender === "male" ? "مرد" : "زن"}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronLeft size={16} className="text-(--color-content-tertiary) shrink-0" />
|
||||
</Link>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,188 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================
|
||||
BASIS DESIGN SYSTEM — نسخهی شاد، گرد و گوگولیمگولیِ Atlas
|
||||
همون اصول ساختاری (نقشمحور بودن توکنها، مقیاس فاصلهگذاری،
|
||||
سلسلهمراتب تایپوگرافی) حفظ شده؛ چیزی که عوض شده «شخصیت»
|
||||
بصریه: گوشههای گرد بهجای تیز، قرمز پرانرژیتر، سایههای
|
||||
نرم رنگی بهجای بردر مسطح.
|
||||
============================================================ */
|
||||
:root {
|
||||
/* -------- brand / accent -------- */
|
||||
--color-brand-50: #fff1ee;
|
||||
--color-brand-100: #ffe1da;
|
||||
--color-brand-200: #ffc2b3;
|
||||
--color-brand-300: #ff9c85;
|
||||
--color-brand-400: #ff6f56;
|
||||
--color-brand-500: #ff4433; /* قرمز شاد اصلی */
|
||||
--color-brand-600: #f22c1f;
|
||||
--color-brand-700: #d6210f;
|
||||
--color-brand-800: #a91a0d;
|
||||
--color-brand-900: #7c1409;
|
||||
--color-brand-onPrimary: #fffaf8;
|
||||
|
||||
/* -------- accent دوم برای شادی بیشتر (نقطهها/برچسبها) -------- */
|
||||
--color-pop-yellow: #ffc93c;
|
||||
--color-pop-pink: #ff8fb1;
|
||||
--color-pop-mint: #3ddc97;
|
||||
--color-pop-sky: #4fb4ff;
|
||||
|
||||
/* -------- neutral warm (پسزمینهی گرم، نه خاکستری سرد) -------- */
|
||||
--color-bg-primary: #fff8f5;
|
||||
--color-bg-secondary: #fff1ec;
|
||||
--color-bg-tertiary: #ffe4da;
|
||||
|
||||
--color-surface-default: #ffffff;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-surface-interactive: #fff1ec;
|
||||
|
||||
--color-content-primary: #2b1a15;
|
||||
--color-content-secondary: #7a5c52;
|
||||
--color-content-tertiary: #a68c82;
|
||||
--color-content-disabled: #d8c6bf;
|
||||
--color-content-onColor: #fffaf8;
|
||||
|
||||
--color-border-subtle: #ffe4da;
|
||||
--color-border-default: #ffd0c0;
|
||||
--color-border-interactive: var(--color-brand-500);
|
||||
|
||||
--color-status-success: #21a179;
|
||||
--color-status-warning: #d98a12;
|
||||
--color-status-error: #e0301e;
|
||||
--color-status-info: #3d8fd6;
|
||||
|
||||
/* -------- radius: همهچیز گرد و نرم -------- */
|
||||
--radius-xs: 10px;
|
||||
--radius-sm: 14px;
|
||||
--radius-md: 20px;
|
||||
--radius-lg: 28px;
|
||||
--radius-xl: 36px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* -------- سایههای نرم و رنگی (بهجای بردر مسطح Atlas) -------- */
|
||||
--shadow-cute-sm: 0 2px 8px -2px rgba(255, 68, 51, 0.16), 0 1px 2px rgba(43, 26, 21, 0.06);
|
||||
--shadow-cute-md: 0 10px 24px -8px rgba(255, 68, 51, 0.22), 0 2px 6px rgba(43, 26, 21, 0.06);
|
||||
--shadow-cute-lg: 0 20px 45px -12px rgba(255, 68, 51, 0.28), 0 4px 10px rgba(43, 26, 21, 0.08);
|
||||
--shadow-pop-yellow: 0 8px 20px -6px rgba(255, 201, 60, 0.45);
|
||||
--shadow-pop-mint: 0 8px 20px -6px rgba(61, 220, 151, 0.4);
|
||||
|
||||
--background: var(--color-bg-primary);
|
||||
--foreground: var(--color-content-primary);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-brand-50: var(--color-brand-50);
|
||||
--color-brand-100: var(--color-brand-100);
|
||||
--color-brand-200: var(--color-brand-200);
|
||||
--color-brand-300: var(--color-brand-300);
|
||||
--color-brand-400: var(--color-brand-400);
|
||||
--color-brand-500: var(--color-brand-500);
|
||||
--color-brand-600: var(--color-brand-600);
|
||||
--color-brand-700: var(--color-brand-700);
|
||||
--color-brand-800: var(--color-brand-800);
|
||||
--color-brand-900: var(--color-brand-900);
|
||||
|
||||
--color-pop-yellow: var(--color-pop-yellow);
|
||||
--color-pop-pink: var(--color-pop-pink);
|
||||
--color-pop-mint: var(--color-pop-mint);
|
||||
--color-pop-sky: var(--color-pop-sky);
|
||||
|
||||
--color-bg-primary: var(--color-bg-primary);
|
||||
--color-bg-secondary: var(--color-bg-secondary);
|
||||
--color-bg-tertiary: var(--color-bg-tertiary);
|
||||
|
||||
--color-surface-default: var(--color-surface-default);
|
||||
--color-surface-elevated: var(--color-surface-elevated);
|
||||
--color-surface-interactive: var(--color-surface-interactive);
|
||||
|
||||
--color-content-primary: var(--color-content-primary);
|
||||
--color-content-secondary: var(--color-content-secondary);
|
||||
--color-content-tertiary: var(--color-content-tertiary);
|
||||
--color-content-disabled: var(--color-content-disabled);
|
||||
--color-content-onColor: var(--color-content-onColor);
|
||||
|
||||
--color-border-subtle: var(--color-border-subtle);
|
||||
--color-border-default: var(--color-border-default);
|
||||
--color-border-interactive: var(--color-border-interactive);
|
||||
|
||||
--color-status-success: var(--color-status-success);
|
||||
--color-status-warning: var(--color-status-warning);
|
||||
--color-status-error: var(--color-status-error);
|
||||
--color-status-info: var(--color-status-info);
|
||||
|
||||
--radius-xs: var(--radius-xs);
|
||||
--radius-sm: var(--radius-sm);
|
||||
--radius-md: var(--radius-md);
|
||||
--radius-lg: var(--radius-lg);
|
||||
--radius-xl: var(--radius-xl);
|
||||
--radius-full: var(--radius-full);
|
||||
|
||||
--shadow-cute-sm: var(--shadow-cute-sm);
|
||||
--shadow-cute-md: var(--shadow-cute-md);
|
||||
--shadow-cute-lg: var(--shadow-cute-lg);
|
||||
--shadow-pop-yellow: var(--shadow-pop-yellow);
|
||||
--shadow-pop-mint: var(--shadow-pop-mint);
|
||||
|
||||
--font-sans: "Vazirmatn Variable", "Baloo 2 Variable", system-ui, sans-serif;
|
||||
--font-display: "Baloo 2 Variable", "Vazirmatn Variable", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-brand-200);
|
||||
color: var(--color-brand-900);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-brand-200);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0.9) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.02) translateY(-2px);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import "@fontsource-variable/vazirmatn";
|
||||
import "@fontsource-variable/baloo-2";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import { ToastProvider } from "@/components/ui/Toast";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "پنل ادمین دلدل",
|
||||
description: "مدیریت گزینهها، دستهبندیها و سوالات پروفایل اپ دلدل",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className="h-full antialiased">
|
||||
<body className="min-h-dvh bg-(--color-bg-secondary) text-(--color-content-primary)">
|
||||
<AuthProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { TextField } from "@/components/ui/TextField";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { requestOtp } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
const PHONE_REGEX = /^09\d{9}$/;
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { status } = useAuth();
|
||||
const [phone, setPhone] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") router.replace("/");
|
||||
}, [status, router]);
|
||||
|
||||
const isValid = PHONE_REGEX.test(phone);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValid) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await requestOtp(phone);
|
||||
router.push(`/verify?phone=${phone}`);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center px-4">
|
||||
<Card className="w-full max-w-sm flex flex-col items-center gap-6 !p-8">
|
||||
<div className="w-16 h-16 rounded-(--radius-lg) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-md)">
|
||||
<ShieldCheck size={28} className="text-white" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[22px]">
|
||||
پنل ادمین دلدل
|
||||
</h1>
|
||||
<p className="text-[13px] text-(--color-content-secondary) mt-1">
|
||||
فقط برای مدیران سیستم
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<TextField
|
||||
label="شماره موبایل ادمین"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
placeholder="09xxxxxxxxx"
|
||||
dir="ltr"
|
||||
className="text-center tracking-widest"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, "").slice(0, 11))}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||
/>
|
||||
<ErrorBanner message={error} />
|
||||
<Button size="lg" isBlock disabled={!isValid} isLoading={loading} onClick={handleSubmit}>
|
||||
دریافت کد تایید
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ChevronRight, ShieldAlert } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { OtpInput } from "@/components/ui/OtpInput";
|
||||
import { ErrorBanner } from "@/components/ui/ErrorBanner";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { requestOtp } from "@/lib/api";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
const RESEND_SECONDS = 60;
|
||||
|
||||
function VerifyForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const phone = searchParams.get("phone") ?? "";
|
||||
const { login } = useAuth();
|
||||
|
||||
const [otp, setOtp] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [countdown, setCountdown] = useState(RESEND_SECONDS);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [notAdmin, setNotAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [countdown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!phone) router.replace("/login");
|
||||
}, [phone, router]);
|
||||
|
||||
async function handleVerify(code: string) {
|
||||
if (code.length !== 4) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setNotAdmin(false);
|
||||
try {
|
||||
await login(phone, code);
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
const msg = formatApiError(err, "کد وارد شده اشتباهه");
|
||||
if (msg.includes("ادمین")) {
|
||||
setNotAdmin(true);
|
||||
} else {
|
||||
setError(msg);
|
||||
setOtp("");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResend() {
|
||||
setResending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await requestOtp(phone);
|
||||
setCountdown(RESEND_SECONDS);
|
||||
} catch (err) {
|
||||
setError(formatApiError(err));
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (notAdmin) {
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center px-4">
|
||||
<Card className="w-full max-w-sm flex flex-col items-center gap-4 !p-8 text-center">
|
||||
<div className="w-14 h-14 rounded-(--radius-lg) bg-red-50 flex items-center justify-center">
|
||||
<ShieldAlert size={26} className="text-(--color-status-error)" />
|
||||
</div>
|
||||
<h2 className="font-[var(--font-display)] font-extrabold text-[18px]">
|
||||
دسترسی نداری
|
||||
</h2>
|
||||
<p className="text-[13px] text-(--color-content-secondary) leading-6">
|
||||
این شماره حساب ادمین نیست. اگه فکر میکنی اشتباهه، با یکی از ادمینهای
|
||||
فعلی سیستم تماس بگیر.
|
||||
</p>
|
||||
<Button variant="tertiary" onClick={() => router.push("/login")}>
|
||||
برگشت به صفحهی ورود
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center px-4">
|
||||
<Card className="w-full max-w-sm flex flex-col gap-6 !p-8">
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) self-start -mb-2"
|
||||
>
|
||||
<ChevronRight size={18} className="text-(--color-content-secondary)" />
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<h1 className="font-[var(--font-display)] font-extrabold text-[20px]">
|
||||
کد تایید رو وارد کن
|
||||
</h1>
|
||||
<p className="text-[13px] text-(--color-content-secondary) mt-1">
|
||||
ارسالشده به <span dir="ltr" className="font-bold">{phone}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OtpInput value={otp} onChange={setOtp} onComplete={handleVerify} disabled={loading} />
|
||||
<ErrorBanner message={error} />
|
||||
<Button
|
||||
size="lg"
|
||||
isBlock
|
||||
isLoading={loading}
|
||||
disabled={otp.length !== 4}
|
||||
onClick={() => handleVerify(otp)}
|
||||
>
|
||||
تایید و ورود
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-1.5 text-[13px]">
|
||||
<span className="text-(--color-content-tertiary)">کد نیومد؟</span>
|
||||
{countdown > 0 ? (
|
||||
<span className="font-bold text-(--color-content-secondary)" dir="ltr">
|
||||
{countdown}s
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={resending}
|
||||
className="font-bold text-(--color-brand-600) hover:text-(--color-brand-700) disabled:opacity-50"
|
||||
>
|
||||
دوباره بفرست
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<VerifyForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
LayoutGrid,
|
||||
ListTree,
|
||||
MessageCircleQuestion,
|
||||
LogOut,
|
||||
Heart,
|
||||
UserCheck,
|
||||
Users,
|
||||
HeartHandshake,
|
||||
Images,
|
||||
Activity,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "داشبورد", icon: LayoutGrid },
|
||||
{ href: "/users", label: "کاربران در انتظار تایید", icon: UserCheck },
|
||||
{ href: "/users/all", label: "همه کاربران", icon: Users },
|
||||
{ href: "/matches", label: "رفقا (مچها)", icon: HeartHandshake },
|
||||
{ href: "/photos", label: "نظارت بر عکسها", icon: Images },
|
||||
{ href: "/categories", label: "دستهبندیها و گزینهها", icon: ListTree },
|
||||
{ href: "/questions", label: "سوالات پروفایل", icon: MessageCircleQuestion },
|
||||
{ href: "/monitoring", label: "مانیتورینگ سرور", icon: Activity },
|
||||
{ href: "/backups", label: "بکآپ دیتابیس", icon: Database },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="w-64 shrink-0 h-dvh sticky top-0 flex flex-col bg-(--color-surface-default) border-l border-(--color-border-subtle)">
|
||||
<div className="flex items-center gap-2.5 px-5 h-16 border-b border-(--color-border-subtle)">
|
||||
<div className="w-8 h-8 rounded-(--radius-sm) bg-(--color-brand-500) flex items-center justify-center shadow-(--shadow-cute-sm)">
|
||||
<Heart size={15} className="text-white" fill="white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-[var(--font-display)] font-extrabold text-[14px] leading-none">
|
||||
دلدل
|
||||
</p>
|
||||
<p className="text-[10px] text-(--color-content-tertiary) leading-none mt-0.5">
|
||||
پنل ادمین
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-1 p-3">
|
||||
{navItems.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-(--radius-sm) px-3.5 h-11 text-[14px] font-semibold transition-colors",
|
||||
active
|
||||
? "bg-(--color-brand-500) text-white shadow-(--shadow-cute-sm)"
|
||||
: "text-(--color-content-secondary) hover:bg-(--color-bg-secondary)"
|
||||
)}
|
||||
>
|
||||
<Icon size={17} />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-(--color-border-subtle) flex flex-col gap-2">
|
||||
<div className="px-2 py-2">
|
||||
<p className="text-[11px] text-(--color-content-tertiary)">وارد شده بهعنوان</p>
|
||||
<p dir="ltr" className="text-[13px] font-bold text-(--color-content-primary) text-right">
|
||||
{user?.phone}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 rounded-(--radius-sm) px-3.5 h-10 text-[13px] font-semibold text-(--color-status-error) hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
خروج از حساب
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ShieldAlert, ShieldCheck, Flag } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { formatApiError } from "@/lib/hooks";
|
||||
import { blockUser, unblockUser, reportUser, UserState } from "@/lib/api";
|
||||
|
||||
// دکمههای بلاک/آنبلاک/گزارش یک کاربر؛ هم توی لیست همهی کاربران و هم توی
|
||||
// صفحهی جزئیات یک کاربر استفاده میشه.
|
||||
export function UserModerationActions({
|
||||
userId,
|
||||
state,
|
||||
onChanged,
|
||||
}: {
|
||||
userId: number;
|
||||
state: UserState;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [blockOpen, setBlockOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [blockReason, setBlockReason] = useState("");
|
||||
const [reportReason, setReportReason] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleBlock() {
|
||||
if (blockReason.trim().length < 3) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await blockUser(userId, blockReason.trim());
|
||||
toast.showSuccess("کاربر بلاک شد");
|
||||
setBlockOpen(false);
|
||||
setBlockReason("");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnblock() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await unblockUser(userId);
|
||||
toast.showSuccess("کاربر آنبلاک شد");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReport() {
|
||||
if (reportReason.trim().length < 3) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await reportUser(userId, reportReason.trim());
|
||||
toast.showSuccess("گزارش ثبت شد");
|
||||
setReportOpen(false);
|
||||
setReportReason("");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.showError(formatApiError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{state === "blocked" ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ShieldCheck size={14} />}
|
||||
isLoading={loading}
|
||||
onClick={handleUnblock}
|
||||
>
|
||||
آنبلاک
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
icon={<ShieldAlert size={14} />}
|
||||
className="!text-(--color-status-error) !border-red-200"
|
||||
onClick={() => setBlockOpen(true)}
|
||||
>
|
||||
بلاک
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" icon={<Flag size={14} />} onClick={() => setReportOpen(true)}>
|
||||
گزارش
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal open={blockOpen} onClose={() => setBlockOpen(false)} title="بلاک کردن کاربر">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-[13px] text-(--color-content-secondary)">
|
||||
کاربر بلاکشده دیگه نمیتونه وارد اپ بشه (حتی با توکن معتبر فعلیش).
|
||||
</p>
|
||||
<textarea
|
||||
value={blockReason}
|
||||
onChange={(e) => setBlockReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="دلیل بلاک کردن..."
|
||||
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setBlockOpen(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
disabled={blockReason.trim().length < 3}
|
||||
className="!bg-(--color-status-error) !shadow-none hover:!bg-red-700"
|
||||
onClick={handleBlock}
|
||||
>
|
||||
بلاک کن
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal open={reportOpen} onClose={() => setReportOpen(false)} title="ثبت گزارش روی کاربر">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-[13px] text-(--color-content-secondary)">
|
||||
یک یادداشت نظارتی برای این کاربر ثبت میشه (مستقل از بلاک کردن)؛ برای پیگیری
|
||||
سابقهی شکایتها.
|
||||
</p>
|
||||
<textarea
|
||||
value={reportReason}
|
||||
onChange={(e) => setReportReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="دلیل گزارش..."
|
||||
className="rounded-(--radius-sm) border-2 border-(--color-border-default) bg-(--color-surface-default) px-4 py-3 text-[14px] focus:outline-none focus:border-(--color-border-interactive) focus:ring-4 focus:ring-(--color-brand-100) resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setReportOpen(false)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
disabled={reportReason.trim().length < 3}
|
||||
onClick={handleReport}
|
||||
>
|
||||
ثبت گزارش
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "tertiary" | "ghost";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
isLoading?: boolean;
|
||||
isBlock?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: "h-9 px-4 text-[13px] gap-1.5",
|
||||
md: "h-11 px-5 text-[15px] gap-2",
|
||||
lg: "h-13 px-6 text-[16px] gap-2.5",
|
||||
};
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-(--color-brand-500) text-(--color-brand-onPrimary) shadow-(--shadow-cute-md) hover:bg-(--color-brand-600) hover:-translate-y-0.5 hover:shadow-(--shadow-cute-lg) active:translate-y-0 active:bg-(--color-brand-700)",
|
||||
secondary:
|
||||
"bg-(--color-brand-100) text-(--color-brand-700) hover:bg-(--color-brand-200) active:bg-(--color-brand-300)",
|
||||
tertiary:
|
||||
"bg-(--color-surface-default) text-(--color-content-primary) border-2 border-(--color-border-default) hover:border-(--color-brand-300) hover:text-(--color-brand-600) active:bg-(--color-bg-secondary)",
|
||||
ghost:
|
||||
"bg-transparent text-(--color-content-secondary) hover:bg-(--color-bg-secondary) hover:text-(--color-brand-600)",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
isLoading = false,
|
||||
isBlock = false,
|
||||
icon,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-(--radius-md) font-[var(--font-sans)] font-semibold",
|
||||
"transition-all duration-150 ease-out select-none cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-(--color-brand-200)",
|
||||
"disabled:opacity-45 disabled:pointer-events-none disabled:translate-y-0 disabled:shadow-none",
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
isBlock && "w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "./Modal";
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "حذف کن",
|
||||
loading,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title={title}>
|
||||
<p className="text-[14px] text-(--color-content-secondary) leading-6 mb-6">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={loading}
|
||||
className="!bg-(--color-status-error) !shadow-none hover:!bg-red-700"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-(--radius-xl) bg-(--color-surface-default) shadow-(--shadow-cute-lg) [animation:bounce-in_0.25s_ease-out] max-h-[85vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 pt-5 pb-3 sticky top-0 bg-(--color-surface-default)">
|
||||
<h3 className="font-[var(--font-display)] font-extrabold text-[18px]">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center hover:bg-(--color-bg-secondary) text-(--color-content-tertiary)"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 pb-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
tone: "success" | "error";
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showSuccess: (message: string) => void;
|
||||
showError: (message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const push = useCallback((message: string, tone: Toast["tone"]) => {
|
||||
const id = ++idCounter;
|
||||
setToasts((prev) => [...prev, { id, message, tone }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 3500);
|
||||
}, []);
|
||||
|
||||
const showSuccess = useCallback((m: string) => push(m, "success"), [push]);
|
||||
const showError = useCallback((m: string) => push(m, "error"), [push]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showSuccess, showError }}>
|
||||
{children}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[100] flex flex-col gap-2 items-center">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`flex items-center gap-2 rounded-(--radius-md) px-4 py-3 shadow-(--shadow-cute-lg) text-[13px] font-semibold text-white [animation:bounce-in_0.3s_ease-out] ${
|
||||
t.tone === "success" ? "bg-(--color-status-success)" : "bg-(--color-status-error)"
|
||||
}`}
|
||||
>
|
||||
{t.tone === "success" ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast باید داخل ToastProvider استفاده بشه");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { tokenStore } from "./token-store";
|
||||
|
||||
export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080/api/v1";
|
||||
|
||||
// عکسها از مسیر نسبی (/uploads/...) برمیگردن، نه URL کامل؛ باید به origin
|
||||
// سرور (بدون /api/v1) وصل بشن.
|
||||
export const API_ORIGIN = API_BASE.replace(/\/api\/v1\/?$/, "");
|
||||
export function photoUrl(path: string) {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${API_ORIGIN}${path}`;
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
errors?: unknown;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
errors?: unknown;
|
||||
status: number;
|
||||
constructor(message: string, status: number, errors?: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- انواع ----------------
|
||||
|
||||
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
export type UserState = "inactive" | "active" | "blocked";
|
||||
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
phone: string;
|
||||
first_name?: string;
|
||||
gender?: "male" | "female";
|
||||
birth_date?: string;
|
||||
height_cm?: number;
|
||||
weight_kg?: number;
|
||||
current_step: number;
|
||||
is_onboarded: boolean;
|
||||
approval_status: ApprovalStatus;
|
||||
rejection_reason?: string;
|
||||
is_admin: boolean;
|
||||
state: UserState;
|
||||
blocked_reason?: string;
|
||||
blocked_at?: string;
|
||||
}
|
||||
|
||||
export interface StepView {
|
||||
step: number;
|
||||
key: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingStatus {
|
||||
current_step: number;
|
||||
current_step_key: string;
|
||||
is_onboarded: boolean;
|
||||
approval_status: ApprovalStatus;
|
||||
rejection_reason?: string;
|
||||
steps: StepView[];
|
||||
}
|
||||
|
||||
export interface SelectionSummary {
|
||||
category_key: string;
|
||||
option_ids: number[];
|
||||
titles: string[];
|
||||
}
|
||||
|
||||
export interface ProfileAnswer {
|
||||
id: number;
|
||||
user_id: number;
|
||||
question_id: number;
|
||||
answer: string;
|
||||
question?: ProfileQuestion;
|
||||
}
|
||||
|
||||
export interface UserPhoto {
|
||||
id: number;
|
||||
user_id: number;
|
||||
url: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface FullProfile {
|
||||
user: UserProfile;
|
||||
onboarding_status: OnboardingStatus;
|
||||
selections: SelectionSummary[];
|
||||
profile_answers: ProfileAnswer[];
|
||||
photos: UserPhoto[];
|
||||
}
|
||||
|
||||
export interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface AuthResponseData {
|
||||
tokens: TokenPair;
|
||||
user: UserProfile;
|
||||
is_new_user: boolean;
|
||||
onboarding_done: boolean;
|
||||
}
|
||||
|
||||
export type CategoryKey =
|
||||
| "education_level"
|
||||
| "field_of_study"
|
||||
| "occupation"
|
||||
| "important_in_relationship"
|
||||
| "interest"
|
||||
| "looking_for";
|
||||
|
||||
export const ALL_CATEGORIES: { key: CategoryKey; fallbackTitle: string; emoji: string }[] = [
|
||||
{ key: "education_level", fallbackTitle: "سطح تحصیلات", emoji: "🎓" },
|
||||
{ key: "field_of_study", fallbackTitle: "رشته تحصیلی", emoji: "📚" },
|
||||
{ key: "occupation", fallbackTitle: "فعالیتها", emoji: "💼" },
|
||||
{ key: "important_in_relationship", fallbackTitle: "اهمیت در رابطه", emoji: "❤️" },
|
||||
{ key: "interest", fallbackTitle: "علاقهمندیها", emoji: "✨" },
|
||||
{ key: "looking_for", fallbackTitle: "دنبال چی میگردی", emoji: "🔍" },
|
||||
];
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
key: CategoryKey;
|
||||
title: string;
|
||||
min_select: number;
|
||||
max_select: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Option {
|
||||
id: number;
|
||||
category_id: number;
|
||||
title: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProfileQuestion {
|
||||
id: number;
|
||||
text: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ---------------- هستهی fetch ----------------
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
async function rawRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
auth = false
|
||||
): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (auth) {
|
||||
const token = tokenStore.getAccess();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
let body: ApiEnvelope<T> | null = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
// بدنهی خالی
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(body?.message ?? "خطای غیرمنتظره از سرور", res.status, body?.errors);
|
||||
}
|
||||
return (body?.data as T) ?? (undefined as T);
|
||||
}
|
||||
|
||||
async function authRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
try {
|
||||
return await rawRequest<T>(path, options, true);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
const refreshed = await ensureRefreshed();
|
||||
if (refreshed) return await rawRequest<T>(path, options, true);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRefreshed(): Promise<boolean> {
|
||||
const refresh = tokenStore.getRefresh();
|
||||
if (!refresh) return false;
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = rawRequest<TokenPair>("/auth/token/refresh", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refresh_token: refresh }),
|
||||
})
|
||||
.then((tokens) => {
|
||||
tokenStore.set(tokens.access_token, tokens.refresh_token);
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
tokenStore.clear();
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
// ---------------- احراز هویت ----------------
|
||||
|
||||
export function requestOtp(phone: string) {
|
||||
return rawRequest<null>("/auth/request-otp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyOtp(phone: string, otp: string) {
|
||||
return rawRequest<AuthResponseData>("/auth/verify-otp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ phone, otp }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getProfileMe() {
|
||||
return authRequest<{ user: UserProfile }>("/profile/me");
|
||||
}
|
||||
|
||||
// ---------------- ادمین: دستهها و گزینهها ----------------
|
||||
|
||||
export function getCategory(key: CategoryKey) {
|
||||
return authRequest<Category>(`/admin/categories/${key}`);
|
||||
}
|
||||
|
||||
export function updateCategory(
|
||||
key: CategoryKey,
|
||||
payload: { title?: string; min_select?: number; max_select?: number }
|
||||
) {
|
||||
return authRequest<Category>(`/admin/categories/${key}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function listOptions(key: CategoryKey) {
|
||||
return authRequest<Option[]>(`/admin/categories/${key}/options`);
|
||||
}
|
||||
|
||||
export function createOption(key: CategoryKey, payload: { title: string; sort_order?: number }) {
|
||||
return authRequest<Option>(`/admin/categories/${key}/options`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOption(
|
||||
id: number,
|
||||
payload: { title?: string; sort_order?: number; is_active?: boolean }
|
||||
) {
|
||||
return authRequest<Option>(`/admin/options/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOption(id: number) {
|
||||
return authRequest<null>(`/admin/options/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ---------------- ادمین: سوالات پروفایل ----------------
|
||||
|
||||
export function listQuestions() {
|
||||
return authRequest<ProfileQuestion[]>("/admin/profile-questions");
|
||||
}
|
||||
|
||||
export function createQuestion(payload: { text: string; sort_order?: number }) {
|
||||
return authRequest<ProfileQuestion>("/admin/profile-questions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateQuestion(
|
||||
id: number,
|
||||
payload: { text?: string; sort_order?: number; is_active?: boolean }
|
||||
) {
|
||||
return authRequest<ProfileQuestion>(`/admin/profile-questions/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteQuestion(id: number) {
|
||||
return authRequest<null>(`/admin/profile-questions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ---------------- ادمین: بررسی و تایید کاربران ----------------
|
||||
|
||||
export interface PendingUserView extends UserProfile {
|
||||
photos: string[];
|
||||
}
|
||||
|
||||
export function listPendingUsers() {
|
||||
return authRequest<PendingUserView[]>("/admin/users/pending");
|
||||
}
|
||||
|
||||
export function getUserDetail(id: number) {
|
||||
return authRequest<FullProfile>(`/admin/users/${id}`);
|
||||
}
|
||||
|
||||
export function approveUser(id: number) {
|
||||
return authRequest<UserProfile>(`/admin/users/${id}/approve`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function rejectUser(id: number, reason: string) {
|
||||
return authRequest<UserProfile>(`/admin/users/${id}/reject`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- ادمین: مانیتورینگ ----------------
|
||||
|
||||
export interface MonitoringSummary {
|
||||
total_requests: number;
|
||||
total_errors: number;
|
||||
error_rate_pct: number;
|
||||
avg_latency_ms: number;
|
||||
p95_latency_ms: number;
|
||||
requests_last_min: number;
|
||||
started_at: string;
|
||||
uptime_seconds: number;
|
||||
}
|
||||
|
||||
export interface RequestLogEntry {
|
||||
time: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
duration_ms: number;
|
||||
client_ip: string;
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
export function getMonitoringSummary() {
|
||||
return authRequest<MonitoringSummary>("/admin/monitoring/summary");
|
||||
}
|
||||
|
||||
export function getRecentRequests(limit = 100, userId?: number) {
|
||||
const userParam = userId ? `&user_id=${userId}` : "";
|
||||
return authRequest<RequestLogEntry[]>(`/admin/monitoring/requests?limit=${limit}${userParam}`);
|
||||
}
|
||||
|
||||
// ---------------- ادمین: همهی کاربران، بلاک/آنبلاک، گزارش ----------------
|
||||
|
||||
export interface AdminUserListItem extends UserProfile {
|
||||
report_count: number;
|
||||
}
|
||||
|
||||
interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export function listAllUsers(search: string, page = 1, pageSize = 25) {
|
||||
const searchParam = search ? `&search=${encodeURIComponent(search)}` : "";
|
||||
return authRequest<PaginatedResult<AdminUserListItem>>(
|
||||
`/admin/users?page=${page}&page_size=${pageSize}${searchParam}`
|
||||
);
|
||||
}
|
||||
|
||||
export function blockUser(id: number, reason: string) {
|
||||
return authRequest<UserProfile>(`/admin/users/${id}/block`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export function unblockUser(id: number) {
|
||||
return authRequest<UserProfile>(`/admin/users/${id}/unblock`, { method: "POST" });
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: number;
|
||||
user_id: number;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function reportUser(id: number, reason: string) {
|
||||
return authRequest<Report>(`/admin/users/${id}/report`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listUserReports(id: number) {
|
||||
return authRequest<Report[]>(`/admin/users/${id}/reports`);
|
||||
}
|
||||
|
||||
// ---------------- ادمین: مچها (رفقا) ----------------
|
||||
|
||||
export interface AdminMatchView {
|
||||
id: number;
|
||||
created_at: string;
|
||||
user_one_id: number;
|
||||
user_one_name: string;
|
||||
user_one_phone: string;
|
||||
user_two_id: number;
|
||||
user_two_name: string;
|
||||
user_two_phone: string;
|
||||
}
|
||||
|
||||
export function listAllMatches(page = 1, pageSize = 25) {
|
||||
return authRequest<PaginatedResult<AdminMatchView>>(
|
||||
`/admin/matches?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- ادمین: نظارت بر عکسها ----------------
|
||||
|
||||
export interface AdminPhotoView {
|
||||
id: number;
|
||||
user_id: number;
|
||||
url: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
owner_name: string;
|
||||
owner_phone: string;
|
||||
}
|
||||
|
||||
export function listAllPhotos(page = 1, pageSize = 50) {
|
||||
return authRequest<PaginatedResult<AdminPhotoView>>(
|
||||
`/admin/photos?page=${page}&page_size=${pageSize}`
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePhotoAdmin(id: number) {
|
||||
return authRequest<null>(`/admin/photos/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function reportPhoto(id: number, reason: string) {
|
||||
return authRequest<Report>(`/admin/photos/${id}/report`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- ادمین: بکآپ دیتابیس ----------------
|
||||
|
||||
export interface BackupInfo {
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function listBackups() {
|
||||
return authRequest<BackupInfo[]>("/admin/backups");
|
||||
}
|
||||
|
||||
export function createBackup() {
|
||||
return authRequest<BackupInfo>("/admin/backups", { method: "POST" });
|
||||
}
|
||||
|
||||
// دانلود بکآپ نیاز به هدر Authorization داره (اندپوینت فقط-ادمینه)، پس نمیشه
|
||||
// مستقیم از یک <a href> استفاده کرد؛ فایل رو با fetch احرازهویتشده میگیریم و
|
||||
// خودمون دانلودش رو با یک لینک موقت شبیهسازی میکنیم.
|
||||
export async function downloadBackup(filename: string) {
|
||||
const token = tokenStore.getAccess();
|
||||
const res = await fetch(`${API_BASE}/admin/backups/${encodeURIComponent(filename)}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new ApiError("دانلود بکآپ ناموفق بود", res.status);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { tokenStore } from "./token-store";
|
||||
import { UserProfile, verifyOtp as apiVerifyOtp, getProfileMe, ApiError } from "./api";
|
||||
|
||||
type AuthStatus = "loading" | "guest" | "not-admin" | "authenticated";
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: UserProfile | null;
|
||||
login: (phone: string, otp: string) => Promise<UserProfile>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
const access = tokenStore.getAccess();
|
||||
if (!access) {
|
||||
setStatus("guest");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profile = await getProfileMe();
|
||||
if (!profile.user.is_admin) {
|
||||
setUser(profile.user);
|
||||
setStatus("not-admin");
|
||||
return;
|
||||
}
|
||||
setUser(profile.user);
|
||||
setStatus("authenticated");
|
||||
} catch {
|
||||
tokenStore.clear();
|
||||
setStatus("guest");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
bootstrap();
|
||||
}, [bootstrap]);
|
||||
|
||||
const login = useCallback(async (phone: string, otp: string) => {
|
||||
const data = await apiVerifyOtp(phone, otp);
|
||||
tokenStore.set(data.tokens.access_token, data.tokens.refresh_token);
|
||||
if (!data.user.is_admin) {
|
||||
setUser(data.user);
|
||||
setStatus("not-admin");
|
||||
// توکن رو نگه میداریم نه، پاک میکنیم چون این پنل فقط برای ادمینهاست
|
||||
tokenStore.clear();
|
||||
throw new ApiError("این حساب دسترسی ادمین نداره", 403);
|
||||
}
|
||||
setUser(data.user);
|
||||
setStatus("authenticated");
|
||||
return data.user;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
tokenStore.clear();
|
||||
setUser(null);
|
||||
setStatus("guest");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ status, user, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth باید داخل AuthProvider استفاده بشه");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "./auth-context";
|
||||
import { ApiError } from "./api";
|
||||
|
||||
export function useRequireAdmin() {
|
||||
const router = useRouter();
|
||||
const { status, user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "guest") {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
return { status, user };
|
||||
}
|
||||
|
||||
export function formatApiError(err: unknown, fallback = "یه مشکلی پیش اومد؛ دوباره امتحان کن"): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (typeof err.errors === "string" && err.errors && err.errors !== err.message) {
|
||||
return `${err.message} — ${err.errors}`;
|
||||
}
|
||||
return err.message || fallback;
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export function calcAge(birthDate?: string): number | null {
|
||||
if (!birthDate) return null;
|
||||
const b = new Date(birthDate);
|
||||
if (Number.isNaN(b.getTime())) return null;
|
||||
const now = new Date();
|
||||
let age = now.getFullYear() - b.getFullYear();
|
||||
const m = now.getMonth() - b.getMonth();
|
||||
if (m < 0 || (m === 0 && now.getDate() < b.getDate())) age--;
|
||||
return age;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dating-web",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "/run/media/mobin/project/projects/dating-app/web", "run", "dev", "--", "--port", "3000"],
|
||||
"port": 3000
|
||||
},
|
||||
{
|
||||
"name": "dating-admin",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "/run/media/mobin/project/projects/dating-app/admin", "run", "dev", "--", "--port", "3001"],
|
||||
"port": 3001
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.idea
|
||||
.claude
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
uploads
|
||||
*.md
|
||||
/dating-app
|
||||
/dating-app-server
|
||||
@@ -0,0 +1,33 @@
|
||||
APP_ENV=development
|
||||
APP_PORT=8080
|
||||
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=dating_app
|
||||
DB_SSLMODE=disable
|
||||
# connection pool؛ زیر بار بالا این مقادیر رو با ظرفیت واقعی سرور Postgres تنظیم کن
|
||||
DB_MAX_OPEN_CONNS=25
|
||||
DB_MAX_IDLE_CONNS=10
|
||||
DB_CONN_MAX_LIFETIME_MINUTES=30
|
||||
DB_CONN_MAX_IDLE_TIME_MINUTES=5
|
||||
|
||||
JWT_ACCESS_SECRET=test-access-secret
|
||||
JWT_REFRESH_SECRET=test-refresh-secret
|
||||
JWT_ACCESS_TTL_MINUTES=1440
|
||||
JWT_REFRESH_TTL_DAYS=30
|
||||
|
||||
OTP_EXPIRY_SECONDS=180
|
||||
OTP_LENGTH=4
|
||||
OTP_HASH_SECRET=test-otp-secret
|
||||
|
||||
ADMIN_BOOTSTRAP_PHONE=09120000000
|
||||
|
||||
# -------- فقط برای docker-compose (نیازی نیست موقع اجرای مستقیم go run باشه) --------
|
||||
# DB_HOST رو docker-compose خودش موقع اجرای backend روی "postgres" ست میکنه؛
|
||||
# مقدار بالا (localhost) فقط برای اجرای مستقیم روی سیستم خودت استفاده میشه.
|
||||
WEB_PORT=3000
|
||||
ADMIN_PORT=3001
|
||||
# آدرسی که از مرورگر کاربر قابلدسترسیه (نه آدرس داخلی شبکهی داکر)
|
||||
PUBLIC_API_URL=http://localhost:8080/api/v1
|
||||
@@ -0,0 +1,23 @@
|
||||
# binaries
|
||||
/dating-app
|
||||
/dating-app-server
|
||||
/server
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# env
|
||||
.env
|
||||
|
||||
# فایلهای آپلودشدهی کاربران (نباید توی گیت باشن)
|
||||
/uploads/
|
||||
|
||||
# بکآپهای دیتابیس (نباید توی گیت باشن؛ حجیم و شامل دادهی کاربرانه)
|
||||
/backups/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -0,0 +1,14 @@
|
||||
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 server ./server
|
||||
RUN chmod +x /app/server
|
||||
|
||||
RUN mkdir -p /app/uploads/photos
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["./server"]
|
||||
@@ -0,0 +1,225 @@
|
||||
# اپلیکیشن دوستیابی — بکاند Go (Gin + GORM + PostgreSQL)
|
||||
|
||||
یک بکاند تمیز و لایهبندیشده برای یک اپ دوستیابی با:
|
||||
- احراز هویت فقط با شماره موبایل + کد OTP (بدون پسورد)
|
||||
- فرآیند آنبوردینگ مرحلهبهمرحله (۸ مرحله) برای تکمیل پروفایل
|
||||
- سیستم عمومی «دسته + گزینه» که کاملاً از طریق API ادمین قابل مدیریت است
|
||||
(بدون نیاز به تغییر کد برای اضافه/حذف گزینههای هر لیست)
|
||||
- سوالات پروفایل (Q&A) که ادمین تعریف میکند و کاربر ۳ تای آن را انتخاب میکند
|
||||
|
||||
## ساختار پروژه (لایهبندی تمیز)
|
||||
|
||||
```
|
||||
dating-app/
|
||||
├── cmd/api/main.go # نقطه ورود، wiring همهچیز
|
||||
├── internal/
|
||||
│ ├── config/ # خواندن تنظیمات از .env
|
||||
│ ├── database/ # اتصال دیتابیس، migrate، seed
|
||||
│ ├── models/ # مدلهای GORM
|
||||
│ ├── dto/ # ساختار request/response ها
|
||||
│ ├── repository/ # لایه دسترسی به داده (فقط query)
|
||||
│ ├── service/ # منطق تجاری (validation، state machine و...)
|
||||
│ ├── handler/ # هندلرهای HTTP (gin.Context)
|
||||
│ ├── middleware/ # auth، admin، cors
|
||||
│ └── router/ # تعریف روتها
|
||||
├── go.mod / go.sum
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
لایهبندی مشابه الگوی selector/service که در پروژهی جنگویی قبلیتون بود:
|
||||
`repository` = selector (فقط query خام)، `service` = منطق و اعتبارسنجی،
|
||||
`handler` = فقط ترجمهی HTTP request/response.
|
||||
|
||||
## اجرا
|
||||
|
||||
### ۱) پیشنیاز: PostgreSQL
|
||||
|
||||
```bash
|
||||
createdb dating_app
|
||||
```
|
||||
|
||||
### ۲) تنظیمات
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# مقادیر رو مطابق محیط خودت ویرایش کن (خصوصاً JWT_*_SECRET و OTP_HASH_SECRET در پروداکشن)
|
||||
```
|
||||
|
||||
`ADMIN_BOOTSTRAP_PHONE` رو به شمارهی موبایل خودت تنظیم کن؛ در اولین اجرای برنامه
|
||||
یک کاربر ادمین با اون شماره ساخته میشه (یا اگه از قبل وجود داشت، ادمین میشه).
|
||||
بعدش با همون شماره از مسیر عادی OTP وارد شو تا توکن ادمین بگیری.
|
||||
|
||||
### ۳) اجرا
|
||||
|
||||
```bash
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
سرور روی `http://localhost:8080` بالا میاد. مسیر `/health` برای healthcheck هست.
|
||||
|
||||
## اتصال گیتوی پیامک واقعی
|
||||
|
||||
فعلاً `service.MockOTPSender` استفاده میشه که کد OTP رو بهجای ارسال واقعی توی
|
||||
لاگ چاپ میکنه (`internal/service/otp_sender.go`). برای وصل کردن یک گیتوی واقعی:
|
||||
|
||||
```go
|
||||
type KavenegarOTPSender struct{ apiKey string }
|
||||
|
||||
func (s *KavenegarOTPSender) Send(phone, otp string) bool {
|
||||
// درخواست HTTP به گیتوی، صبر برای پاسخ، و true/false واقعی برگردون.
|
||||
// این تابع باید SYNC باشه (نتیجهی واقعی رو برگردونه)، وگرنه کاربر حتی
|
||||
// در صورت شکست واقعی ارسال، پیام موفقیت میگیره.
|
||||
}
|
||||
```
|
||||
|
||||
و در `cmd/api/main.go` خط `otpSender := service.NewMockOTPSender(...)` رو با
|
||||
`otpSender := &KavenegarOTPSender{apiKey: cfg.KavenegarAPIKey}` عوض کن. بقیهی
|
||||
کد (`AuthService` و...) نیازی به تغییر نداره چون فقط با اینترفیس `OTPSender` کار میکنه.
|
||||
|
||||
## فرآیند آنبوردینگ
|
||||
|
||||
۹ مرحلهی ثابت که باید بهترتیب طی بشن (نمیشه از روی مرحله پرید، ولی مراحل
|
||||
قبلی هر وقت بخوای قابل ویرایشاند؛ بعد از تکمیل کامل هم همهی مراحل آزادانه
|
||||
قابل ویرایش میشن — حتی توی همون بازهای که کاربر منتظر تایید ادمینه):
|
||||
|
||||
| # | مرحله | Endpoint |
|
||||
|---|-------|----------|
|
||||
| ۱ | جنسیت، اسم، تاریخ تولد | `POST /onboarding/basic-info` |
|
||||
| ۲ | عکسها (حداقل ۱ عکس؛ خودِ آپلود از `/profile/photos`) | `POST /onboarding/photos` |
|
||||
| ۳ | قد و وزن | `POST /onboarding/body-info` |
|
||||
| ۴ | ۳ سوال از پیشتعریفشده + پاسخ | `POST /onboarding/profile-questions` |
|
||||
| ۵ | سطح تحصیلات + رشته | `POST /onboarding/education` |
|
||||
| ۶ | کارهایی که انجام میدهد (چندتایی) | `POST /onboarding/occupations` |
|
||||
| ۷ | ۳ چیز مهم در رابطه | `POST /onboarding/relationship-importance` |
|
||||
| ۸ | ۵ علاقهمندی | `POST /onboarding/interests` |
|
||||
| ۹ | دنبال چی میگردی | `POST /onboarding/looking-for` |
|
||||
|
||||
وضعیت فعلی: `GET /onboarding/status`
|
||||
|
||||
اگه کاربر قبلاً رد شده باشه و بعد از onboarded شدن هرکدوم از مراحل رو دوباره
|
||||
بزنه (حتی مرحلهی عکسها)، `approval_status` خودکار به `pending` برمیگرده تا
|
||||
ادمین دوباره بررسی کنه.
|
||||
|
||||
### بعد از تکمیل آنبوردینگ: تایید ادمین
|
||||
|
||||
به محض تکمیل مرحلهی ۸، `approval_status` کاربر روی `pending` قرار میگیرد.
|
||||
کاربر تا وقتی ادمین تاییدش نکنه (`approved`) اجازهی ورود به صفحهی دوستیابی
|
||||
رو نداره (این بخش در فاز بعدی پیاده میشه). اپ باید بعد از `is_onboarded=true`،
|
||||
مقدار `approval_status` رو از `GET /onboarding/status` یا `GET /profile/me`
|
||||
بخونه و بر اساسش یکی از این صفحات رو نشون بده:
|
||||
|
||||
| `approval_status` | صفحهی مناسب در اپ |
|
||||
|---|---|
|
||||
| `pending` | «در انتظار تایید ادمین» |
|
||||
| `approved` | ورود به صفحهی دوستیابی |
|
||||
| `rejected` | نمایش `rejection_reason` + امکان ویرایش پروفایل |
|
||||
|
||||
اگر کاربر رد شده باشه و بعداً هر کدوم از مراحل آنبوردینگ رو ویرایش کنه،
|
||||
`approval_status` خودکار به `pending` برمیگرده تا ادمین دوباره بررسی کنه.
|
||||
|
||||
گزینههای هر مرحله (که از پنل ادمین مدیریت میشن):
|
||||
`GET /onboarding/options/:category` با یکی از این category ها:
|
||||
`education_level`, `field_of_study`, `occupation`,
|
||||
`important_in_relationship`, `interest`, `looking_for`
|
||||
|
||||
سوالات قابلانتخاب برای مرحلهی ۳: `GET /onboarding/profile-questions`
|
||||
|
||||
### چرا این معماری؟
|
||||
|
||||
بهجای اینکه برای هر لیست (تحصیلات، علاقهمندی، اهداف رابطه و...) یک جدول
|
||||
جدا بسازیم، یک سیستم عمومی «دسته (Category) + گزینه (Option)» ساختیم.
|
||||
هر Category یک `min_select`/`max_select` داره (مثلاً علاقهمندیها دقیقاً ۵
|
||||
تا، تحصیلات دقیقاً ۱ تا، فعالیتها بدون سقف). این یعنی:
|
||||
- ادمین میتونه از طریق API هر وقت خواست گزینه اضافه/ویرایش/غیرفعال کنه،
|
||||
بدون نیاز به دیپلوی کد جدید.
|
||||
- اضافه کردن یک دستهی جدید در آینده فقط یک `INSERT` توی جدول `categories`
|
||||
میخواد، نه migration جدید.
|
||||
|
||||
## دوستیابی (Discovery)، مچ و پیامرسانی
|
||||
|
||||
بعد از اینکه کاربر تایید شد (`approval_status=approved`) وارد این بخش میشه.
|
||||
تشخیص جنسیت مقابل فعلاً **ساده و هترو-پیشفرض** هست (زن↔مرد)؛ سیستم هنوز
|
||||
فیلد جداگانهای برای «ترجیح جنسیتی/گرایش» نداره.
|
||||
|
||||
فاصله از روی `latitude`/`longitude` کاربر (که هیچوقت خام برنمیگرده، فقط
|
||||
فاصلهی محاسبهشده) با فرمول haversine حساب و به نزدیکترین کیلومتر گرد
|
||||
میشه — یعنی از همون ابتدا تقریبیه، نه GPS دقیق.
|
||||
|
||||
```
|
||||
GET /profile/photos (داخل /profile/me برمیگرده، endpoint جدا نداره)
|
||||
POST /profile/photos آپلود عکس multipart/form-data، فیلد "photo" (jpg/png/webp، حداکثر ۵ مگابایت، حداکثر ۶ عکس)
|
||||
DELETE /profile/photos/:id حذف عکس
|
||||
PATCH /profile/location body: {latitude, longitude}
|
||||
|
||||
GET /discover?max_distance_km=&min_age=&max_age= لیست پیشنهادها (نزدیکترین اول)
|
||||
GET /discover/:id پروفایل کامل یک کاندید
|
||||
POST /discover/:id/swipe body: {action: "like"|"pass"|"superlike"}
|
||||
اگه طرف مقابل هم قبلاً لایک کرده باشه، مچ ساخته میشه
|
||||
|
||||
GET /matches لیست مچها (با آخرین پیام هرکدوم)
|
||||
GET /matches/:id/messages پیامهای یک گفتگو
|
||||
POST /matches/:id/messages body: {text}
|
||||
```
|
||||
|
||||
فایلهای آپلودشده روی دیسک محلی سرور (`./uploads/photos`) ذخیره و از مسیر
|
||||
`/uploads/...` سرو میشن (پیادهسازی `service.PhotoStorage`؛ برای پروداکشن
|
||||
با یک object storage واقعی جایگزینش کن، دقیقاً مثل الگوی `OTPSender`).
|
||||
پیامرسانی فعلاً polling-based هست، نه realtime/websocket.
|
||||
|
||||
## API پنل ادمین
|
||||
|
||||
احراز هویت ادمین از همون مسیر OTP هست؛ فقط باید `is_admin=true` روی
|
||||
کاربر ست شده باشه (یا با `ADMIN_BOOTSTRAP_PHONE`، یا مستقیم توی دیتابیس).
|
||||
همهی مسیرهای زیر نیاز به هدر `Authorization: Bearer <access_token>` یک
|
||||
کاربر ادمین دارن.
|
||||
|
||||
```
|
||||
GET /admin/users/pending لیست کاربران آنبوردشده و منتظر بررسی (قدیمیترین اول)
|
||||
GET /admin/users/:id پروفایل کامل یک کاربر برای بررسی
|
||||
POST /admin/users/:id/approve تایید کاربر
|
||||
POST /admin/users/:id/reject رد کاربر body: {reason}
|
||||
|
||||
GET /admin/categories/:key دریافت تنظیمات یک دسته
|
||||
PATCH /admin/categories/:key ویرایش min_select/max_select/title
|
||||
|
||||
GET /admin/categories/:key/options لیست همه گزینههای یک دسته (فعال/غیرفعال)
|
||||
POST /admin/categories/:key/options ساخت گزینه جدید body: {title, sort_order}
|
||||
PATCH /admin/options/:id ویرایش گزینه body: {title?, sort_order?, is_active?}
|
||||
DELETE /admin/options/:id حذف گزینه
|
||||
|
||||
GET /admin/profile-questions لیست همه سوالات (فعال/غیرفعال)
|
||||
POST /admin/profile-questions ساخت سوال جدید body: {text, sort_order}
|
||||
PATCH /admin/profile-questions/:id ویرایش سوال body: {text?, sort_order?, is_active?}
|
||||
DELETE /admin/profile-questions/:id حذف سوال
|
||||
```
|
||||
|
||||
## خلاصهی کامل API
|
||||
|
||||
### احراز هویت (عمومی)
|
||||
```
|
||||
POST /api/v1/auth/request-otp body: {phone}
|
||||
POST /api/v1/auth/verify-otp body: {phone, otp} -> access/refresh token
|
||||
POST /api/v1/auth/token/refresh body: {refresh_token} -> access/refresh جدید
|
||||
```
|
||||
|
||||
### پروفایل
|
||||
```
|
||||
GET /api/v1/profile/me (نیاز به توکن) — پروفایل کامل + همهی انتخابها/پاسخها
|
||||
```
|
||||
|
||||
همهی پاسخها با این ساختار برمیگردن:
|
||||
```json
|
||||
{ "success": true, "message": "...", "data": { ... } }
|
||||
{ "success": false, "message": "...", "errors": { ... } }
|
||||
```
|
||||
|
||||
## نکاتی که از پروژهی جنگویی قبلیتون رعایت شده
|
||||
|
||||
- ارسال OTP **sync** انجام میشه (نه توی یک goroutine بیخیال از نتیجه)،
|
||||
چون قبلاً دیدیم نسخهی async باعث میشد کاربر با اینکه ارسال واقعاً شکست
|
||||
خورده، پیام موفقیت بگیره.
|
||||
- اگه ارسال پیامک واقعاً شکست بخوره، OTP ذخیرهشده پاک میشه تا کاربر
|
||||
فوراً بتونه دوباره تلاش کنه (وگرنه تا پایان OTP_EXPIRY_SECONDS با خطای
|
||||
«کد قبلی هنوز معتبره» قفل میمونه).
|
||||
- OTP هیچوقت خام ذخیره نمیشه؛ با HMAC-SHA256 هش میشه.
|
||||
- کد OTP فقط وقتی `APP_ENV != production` باشه توی لاگ چاپ میشه.
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/config"
|
||||
"dating-app/internal/database"
|
||||
"dating-app/internal/handler"
|
||||
"dating-app/internal/middleware"
|
||||
"dating-app/internal/monitoring"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/router"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
db, err := database.Connect(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("اتصال به دیتابیس ناموفق بود: %v", err)
|
||||
}
|
||||
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
log.Fatalf("migrate ناموفق بود: %v", err)
|
||||
}
|
||||
if err := database.SeedCategories(db); err != nil {
|
||||
log.Fatalf("seed کردن دستهها ناموفق بود: %v", err)
|
||||
}
|
||||
if err := database.BootstrapAdmin(db, cfg.AdminBootstrapPhone); err != nil {
|
||||
log.Fatalf("ساخت ادمین اولیه ناموفق بود: %v", err)
|
||||
}
|
||||
|
||||
// -------- Repositories --------
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
optionRepo := repository.NewOptionRepository(db)
|
||||
questionRepo := repository.NewQuestionRepository(db)
|
||||
photoRepo := repository.NewPhotoRepository(db)
|
||||
swipeRepo := repository.NewSwipeRepository(db)
|
||||
matchRepo := repository.NewMatchRepository(db)
|
||||
messageRepo := repository.NewMessageRepository(db)
|
||||
reportRepo := repository.NewReportRepository(db)
|
||||
|
||||
// -------- Utils / Infra --------
|
||||
jwtManager := utils.NewJWTManager(
|
||||
cfg.JWTAccessSecret,
|
||||
cfg.JWTRefreshSecret,
|
||||
cfg.JWTAccessTTL,
|
||||
cfg.JWTRefreshTTL,
|
||||
)
|
||||
// برای اتصال به یک گیتوی پیامک واقعی، این خط رو با پیادهسازی خودت
|
||||
// (که service.OTPSender رو implement میکنه) عوض کن.
|
||||
otpSender := service.NewMockOTPSender(cfg.AppEnv != "production")
|
||||
// برای اتصال به یک object storage واقعی، این خط رو با پیادهسازی خودت
|
||||
// (که service.PhotoStorage رو implement میکنه) عوض کن.
|
||||
photoStorage := service.NewLocalPhotoStorage("./uploads/photos", "/uploads/photos")
|
||||
|
||||
// -------- Services --------
|
||||
authService := service.NewAuthService(
|
||||
userRepo, otpSender, jwtManager,
|
||||
cfg.OTPExpirySeconds, cfg.OTPLength, cfg.OTPHashSecret,
|
||||
)
|
||||
onboardingService := service.NewOnboardingService(userRepo, optionRepo, questionRepo, photoRepo)
|
||||
profileService := service.NewProfileService(photoRepo, userRepo, photoStorage)
|
||||
adminService := service.NewAdminService(optionRepo, questionRepo, userRepo, reportRepo, matchRepo, photoRepo, profileService)
|
||||
discoveryService := service.NewDiscoveryService(userRepo, photoRepo, swipeRepo, matchRepo, optionRepo, questionRepo)
|
||||
chatService := service.NewChatService(matchRepo, messageRepo, userRepo, photoRepo)
|
||||
backupService := service.NewBackupService(cfg, "./backups")
|
||||
|
||||
// -------- مانیتورینگ --------
|
||||
metricsStore := monitoring.NewStore()
|
||||
|
||||
// -------- Handlers --------
|
||||
handlers := router.Handlers{
|
||||
Auth: handler.NewAuthHandler(authService),
|
||||
Onboarding: handler.NewOnboardingHandler(onboardingService),
|
||||
Profile: handler.NewProfileHandler(optionRepo, questionRepo, photoRepo, onboardingService, profileService),
|
||||
Admin: handler.NewAdminHandler(adminService, optionRepo, questionRepo, photoRepo, onboardingService),
|
||||
Discovery: handler.NewDiscoveryHandler(discoveryService),
|
||||
Chat: handler.NewChatHandler(chatService),
|
||||
Monitoring: handler.NewMonitoringHandler(metricsStore),
|
||||
Backup: handler.NewBackupHandler(backupService),
|
||||
}
|
||||
|
||||
if cfg.AppEnv == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
// بهجای gin.Default() (که فقط Logger+Recovery پیشفرض رو میده)، دستی
|
||||
// میسازیم تا هم Metrics رو اضافه کنیم و هم Recovery خودمون که envelope
|
||||
// استاندارد JSON برمیگردونه. ترتیب مهمه: Metrics باید بیرونیترین لایه
|
||||
// باشه تا حتی درخواستهایی که پنیک میکنن (و توسط Recovery مهار میشن) هم
|
||||
// توی آمار لحاظ بشن.
|
||||
engine := gin.New()
|
||||
engine.Use(middleware.Metrics(metricsStore))
|
||||
engine.Use(middleware.Recovery())
|
||||
engine.Use(gin.Logger())
|
||||
router.Setup(engine, handlers, jwtManager, userRepo)
|
||||
|
||||
// یک http.Server دستی بهجای engine.Run() ساده، به دو دلیل:
|
||||
// ۱. تایماوتهای صریح روی خوندن/نوشتن، تا یک کلاینت کند یا مخرب (slowloris)
|
||||
// نتونه goroutine/فایلدسکریپتورهای سرور رو زیر بار بالا قفل نگه داره.
|
||||
// ۲. Shutdown مهلتدار، تا موقع دیپلوی/ریاستارت، درخواستهای در حال پردازش
|
||||
// ناتمام قطع نشن (بهجای کشتن فوری پروسه).
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.AppPort,
|
||||
Handler: engine,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("سرور روی پورت %s در حال اجراست (env=%s)", cfg.AppPort, cfg.AppEnv)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("اجرای سرور ناموفق بود: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("سیگنال خاموشی دریافت شد؛ منتظر تمامشدن درخواستهای در حال پردازش...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("خاموش کردن سرور با مشکل مواجه شد: %v", err)
|
||||
}
|
||||
log.Println("سرور با موفقیت خاموش شد")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
module dating-app
|
||||
|
||||
go 1.25
|
||||
|
||||
replace golang.org/x/net => github.com/golang/net v0.25.0
|
||||
|
||||
replace golang.org/x/sys => github.com/golang/sys v0.20.0
|
||||
|
||||
replace golang.org/x/text => github.com/golang/text v0.15.0
|
||||
|
||||
replace golang.org/x/crypto => github.com/golang/crypto v0.23.0
|
||||
|
||||
replace golang.org/x/arch => github.com/golang/arch v0.8.0
|
||||
|
||||
replace golang.org/x/tools => github.com/golang/tools v0.21.0
|
||||
|
||||
replace gopkg.in/yaml.v2 => github.com/go-yaml/yaml v2.4.0+incompatible
|
||||
|
||||
replace gopkg.in/yaml.v3 => github.com/go-yaml/yaml v3.0.1+incompatible
|
||||
|
||||
replace rsc.io/pdf => github.com/rsc/pdf v0.1.1
|
||||
|
||||
replace gopkg.in/check.v1 => github.com/go-check/check v0.0.0-20180628173108-788fd7840127
|
||||
|
||||
replace google.golang.org/protobuf => github.com/protocolbuffers/protobuf-go v1.30.0
|
||||
|
||||
replace golang.org/x/xerrors => github.com/golang/xerrors v0.0.0-20191204190536-9bdfabe68543
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
gorm.io/driver/postgres v1.5.9
|
||||
gorm.io/gorm v1.25.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace gorm.io/gorm => github.com/go-gorm/gorm v1.25.10
|
||||
|
||||
replace gorm.io/driver/postgres => github.com/go-gorm/postgres v1.5.9
|
||||
|
||||
replace golang.org/x/sync => github.com/golang/sync v0.1.0
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-gorm/gorm v1.25.10 h1:s8FP+QiJt4yTWhaqqaTEXmJezVd4pLuYihWFa5ka6oc=
|
||||
github.com/go-gorm/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
github.com/go-gorm/postgres v1.5.9 h1:R8GFj+hNnViUX6OKlL8jp7b5fVUVlDE3ULqQ+ae7aPk=
|
||||
github.com/go-gorm/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-yaml/yaml v3.0.1+incompatible h1:8i5pkxs8UxP4TeKSFhliVRFob5ie15DYTVMlEgls7Zk=
|
||||
github.com/go-yaml/yaml v3.0.1+incompatible/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/arch v0.8.0 h1:QPY/mLodoR5zIr7llOQZAYuv+rljFZogBqb5GbTvjhI=
|
||||
github.com/golang/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
github.com/golang/crypto v0.23.0 h1:RShXj2uPTe4hHfGGjtFi5oIlDx7rF677+p5uAYM5izU=
|
||||
github.com/golang/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
github.com/golang/net v0.25.0 h1:3L1cfr31lBuJQfJJwCEIWm/rRLKM0X65IeT+yand+L4=
|
||||
github.com/golang/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/sync v0.1.0 h1:IhdYeJ8w8G+NG1Ztq4N1bHNcrHFZsZQ+xB5bDksHLJA=
|
||||
github.com/golang/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
github.com/golang/sys v0.20.0 h1:s6f6MpCY0CE+A62kAS9FiDNmD3rpVbd+OISylPY45NQ=
|
||||
github.com/golang/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
github.com/golang/text v0.15.0 h1:Hr6Y5cbI9MiTMnLQhMfqQzC3smwk0O9n6gYp78JNYJ4=
|
||||
github.com/golang/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
github.com/golang/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:WKKabR3QofYgj3cabwq+Blt8MSvmXR4GKLoohSdeY1A=
|
||||
github.com/golang/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/protocolbuffers/protobuf-go v1.30.0 h1:8l0IAhOWx+qT8fxNzYIj+TS9SEU6xamBNS0ZmRIy3eY=
|
||||
github.com/protocolbuffers/protobuf-go v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/rsc/pdf v0.1.1/go.mod h1:+1cgsOaCtY/6pdg9OEno8rLCRIQj5LeL9ckNDtNZsFs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
Vendored
+55
@@ -0,0 +1,55 @@
|
||||
// Package cache یک کش درونحافظهای ساده با انقضای زمانی (TTL) ارائه میده.
|
||||
// برای دادههایی مناسبه که زیاد خونده میشن ولی بهندرت تغییر میکنن (مثل
|
||||
// گزینههای دستهبندیها و سوالات پروفایل که فقط از پنل ادمین تغییر میکنن).
|
||||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type entry[V any] struct {
|
||||
value V
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type TTLCache[K comparable, V any] struct {
|
||||
mu sync.RWMutex
|
||||
ttl time.Duration
|
||||
m map[K]entry[V]
|
||||
}
|
||||
|
||||
func New[K comparable, V any](ttl time.Duration) *TTLCache[K, V] {
|
||||
return &TTLCache[K, V]{ttl: ttl, m: make(map[K]entry[V])}
|
||||
}
|
||||
|
||||
func (c *TTLCache[K, V]) Get(key K) (V, bool) {
|
||||
c.mu.RLock()
|
||||
e, ok := c.m[key]
|
||||
c.mu.RUnlock()
|
||||
|
||||
var zero V
|
||||
if !ok || time.Now().After(e.expiresAt) {
|
||||
return zero, false
|
||||
}
|
||||
return e.value, true
|
||||
}
|
||||
|
||||
func (c *TTLCache[K, V]) Set(key K, value V) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.m[key] = entry[V]{value: value, expiresAt: time.Now().Add(c.ttl)}
|
||||
}
|
||||
|
||||
func (c *TTLCache[K, V]) Delete(key K) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.m, key)
|
||||
}
|
||||
|
||||
// Clear کل کش رو خالی میکنه؛ برای موقعی که نمیشه/نمیخوایم کلید دقیق رو حساب کنیم.
|
||||
func (c *TTLCache[K, V]) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.m = make(map[K]entry[V])
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTTLCache_SetGet(t *testing.T) {
|
||||
c := New[string, int](time.Minute)
|
||||
c.Set("a", 42)
|
||||
|
||||
v, ok := c.Get("a")
|
||||
if !ok || v != 42 {
|
||||
t.Fatalf("expected (42, true), got (%d, %v)", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTLCache_MissingKey(t *testing.T) {
|
||||
c := New[string, int](time.Minute)
|
||||
if _, ok := c.Get("missing"); ok {
|
||||
t.Fatal("expected miss for key that was never set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTLCache_Expiry(t *testing.T) {
|
||||
c := New[string, int](10 * time.Millisecond)
|
||||
c.Set("a", 1)
|
||||
|
||||
if _, ok := c.Get("a"); !ok {
|
||||
t.Fatal("expected hit immediately after Set")
|
||||
}
|
||||
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
if _, ok := c.Get("a"); ok {
|
||||
t.Fatal("expected miss after TTL expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTLCache_Delete(t *testing.T) {
|
||||
c := New[string, int](time.Minute)
|
||||
c.Set("a", 1)
|
||||
c.Delete("a")
|
||||
|
||||
if _, ok := c.Get("a"); ok {
|
||||
t.Fatal("expected miss after Delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTLCache_Clear(t *testing.T) {
|
||||
c := New[string, int](time.Minute)
|
||||
c.Set("a", 1)
|
||||
c.Set("b", 2)
|
||||
c.Clear()
|
||||
|
||||
if _, ok := c.Get("a"); ok {
|
||||
t.Fatal("expected miss for 'a' after Clear")
|
||||
}
|
||||
if _, ok := c.Get("b"); ok {
|
||||
t.Fatal("expected miss for 'b' after Clear")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config تمام تنظیمات قابلپیکربندی برنامه از طریق متغیرهای محیطی
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
AppPort string
|
||||
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
DBSSLMode string
|
||||
|
||||
// تنظیمات connection pool؛ زیر بار بالا اینها از باز شدن بیرویهی اتصال
|
||||
// (که خود Postgres رو زمین میزنه) و از نشتی اتصالات راکد جلوگیری میکنن.
|
||||
DBMaxOpenConns int
|
||||
DBMaxIdleConns int
|
||||
DBConnMaxLifetimeMins int
|
||||
DBConnMaxIdleTimeMins int
|
||||
|
||||
JWTAccessSecret string
|
||||
JWTRefreshSecret string
|
||||
JWTAccessTTL time.Duration
|
||||
JWTRefreshTTL time.Duration
|
||||
|
||||
OTPExpirySeconds int
|
||||
OTPLength int
|
||||
OTPHashSecret string
|
||||
|
||||
// اگر پر باشد، در اولین اجرا یک کاربر ادمین با این شماره ساخته میشود
|
||||
AdminBootstrapPhone string
|
||||
}
|
||||
|
||||
// Load فایل .env را (در صورت وجود) میخواند و Config را برمیگرداند.
|
||||
// نبودن فایل .env خطا محسوب نمیشود (مثلاً در پروداکشن env مستقیم ست میشود).
|
||||
func Load() *Config {
|
||||
_ = godotenv.Load()
|
||||
|
||||
return &Config{
|
||||
AppEnv: getEnv("APP_ENV", "development"),
|
||||
AppPort: getEnv("APP_PORT", "8080"),
|
||||
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnv("DB_PORT", "5432"),
|
||||
DBUser: getEnv("DB_USER", "postgres"),
|
||||
DBPassword: getEnv("DB_PASSWORD", "postgres"),
|
||||
DBName: getEnv("DB_NAME", "dating_app"),
|
||||
DBSSLMode: getEnv("DB_SSLMODE", "disable"),
|
||||
|
||||
DBMaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25),
|
||||
DBMaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 10),
|
||||
DBConnMaxLifetimeMins: getEnvInt("DB_CONN_MAX_LIFETIME_MINUTES", 30),
|
||||
DBConnMaxIdleTimeMins: getEnvInt("DB_CONN_MAX_IDLE_TIME_MINUTES", 5),
|
||||
|
||||
JWTAccessSecret: getEnv("JWT_ACCESS_SECRET", "change-me-access-secret"),
|
||||
JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "change-me-refresh-secret"),
|
||||
JWTAccessTTL: time.Duration(getEnvInt("JWT_ACCESS_TTL_MINUTES", 60*24)) * time.Minute,
|
||||
JWTRefreshTTL: time.Duration(getEnvInt("JWT_REFRESH_TTL_DAYS", 30)) * 24 * time.Hour,
|
||||
|
||||
OTPExpirySeconds: getEnvInt("OTP_EXPIRY_SECONDS", 180),
|
||||
OTPLength: getEnvInt("OTP_LENGTH", 4),
|
||||
OTPHashSecret: getEnv("OTP_HASH_SECRET", "change-me-otp-secret"),
|
||||
|
||||
AdminBootstrapPhone: getEnv("ADMIN_BOOTSTRAP_PHONE", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) DSN() string {
|
||||
return "host=" + c.DBHost +
|
||||
" user=" + c.DBUser +
|
||||
" password=" + c.DBPassword +
|
||||
" dbname=" + c.DBName +
|
||||
" port=" + c.DBPort +
|
||||
" sslmode=" + c.DBSSLMode +
|
||||
" TimeZone=Asia/Tehran"
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"dating-app/internal/config"
|
||||
"dating-app/internal/models"
|
||||
)
|
||||
|
||||
// Connect به دیتابیس پستگرس وصل میشود
|
||||
func Connect(cfg *config.Config) (*gorm.DB, error) {
|
||||
logLevel := logger.Warn
|
||||
if cfg.AppEnv == "development" {
|
||||
logLevel = logger.Info
|
||||
}
|
||||
|
||||
db, err := gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logLevel),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// تنظیم connection pool؛ بدون این، پیشفرض Go بینهایت اتصال باز میکنه که
|
||||
// زیر بار بالا میتونه بهراحتی خود Postgres رو با تعداد زیاد اتصال همزمان
|
||||
// از پا در بیاره. MaxIdleConns هم اتصالات راکد رو محدود میکنه تا حافظهی
|
||||
// دیتابیس هدر نره.
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns)
|
||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.DBConnMaxLifetimeMins) * time.Minute)
|
||||
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.DBConnMaxIdleTimeMins) * time.Minute)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// AutoMigrate جدولهای مورد نیاز را میسازد/بهروزرسانی میکند
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Category{},
|
||||
&models.Option{},
|
||||
&models.UserOptionSelection{},
|
||||
&models.ProfileQuestion{},
|
||||
&models.UserProfileAnswer{},
|
||||
&models.Photo{},
|
||||
&models.Swipe{},
|
||||
&models.Match{},
|
||||
&models.Message{},
|
||||
&models.Report{},
|
||||
)
|
||||
}
|
||||
|
||||
// SeedCategories دستههای ثابت لیستهای انتخابی را در صورت نبود میسازد.
|
||||
// خودِ گزینههای داخل هر دسته (بهجز looking_for که صراحتاً در نیازمندی
|
||||
// پروژه مشخص شده) باید از پنل ادمین اضافه شوند.
|
||||
func SeedCategories(db *gorm.DB) error {
|
||||
defaults := []models.Category{
|
||||
{Key: models.CategoryEducationLevel, Title: "سطح تحصیلات", MinSelect: 1, MaxSelect: 1},
|
||||
{Key: models.CategoryFieldOfStudy, Title: "رشته تحصیلی", MinSelect: 1, MaxSelect: 1},
|
||||
{Key: models.CategoryOccupation, Title: "کارهایی که انجام میدهید", MinSelect: 1, MaxSelect: 0},
|
||||
{Key: models.CategoryImportantInRelationship, Title: "برات توی یک رابطه مهمه", MinSelect: 3, MaxSelect: 3},
|
||||
{Key: models.CategoryInterest, Title: "علاقهمندیها", MinSelect: 5, MaxSelect: 5},
|
||||
{Key: models.CategoryLookingFor, Title: "دنبال چی میگردی", MinSelect: 1, MaxSelect: 1},
|
||||
}
|
||||
|
||||
for _, c := range defaults {
|
||||
var existing models.Category
|
||||
err := db.Where("key = ?", c.Key).First(&existing).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
if err := db.Create(&c).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("دستهی «%s» ساخته شد", c.Title)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// گزینههای "دنبال چی میگردی" مطابق چیزی که در نیازمندی مشخص شده seed میشن
|
||||
var lookingFor models.Category
|
||||
if err := db.Where("key = ?", models.CategoryLookingFor).First(&lookingFor).Error; err == nil {
|
||||
var count int64
|
||||
db.Model(&models.Option{}).Where("category_id = ?", lookingFor.ID).Count(&count)
|
||||
if count == 0 {
|
||||
titles := []string{
|
||||
"رابطه بلندمدت",
|
||||
"همسر زندگی",
|
||||
"قرارهای تفریحی",
|
||||
"آشنایی با افراد جدید",
|
||||
"ازدواج",
|
||||
"دوست ساده",
|
||||
}
|
||||
for i, t := range titles {
|
||||
db.Create(&models.Option{
|
||||
CategoryID: lookingFor.ID,
|
||||
Title: t,
|
||||
SortOrder: i,
|
||||
IsActive: true,
|
||||
})
|
||||
}
|
||||
log.Println("گزینههای «دنبال چی میگردی» seed شدند")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BootstrapAdmin اگر شمارهای برای ادمین اولیه مشخص شده باشد،
|
||||
// یک کاربر ادمین با آن شماره میسازد (در صورت نبود).
|
||||
func BootstrapAdmin(db *gorm.DB, phone string) error {
|
||||
if phone == "" {
|
||||
return nil
|
||||
}
|
||||
var user models.User
|
||||
err := db.Where("phone = ?", phone).First(&user).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
user = models.User{
|
||||
Phone: phone,
|
||||
State: models.UserStateActive,
|
||||
IsAdmin: true,
|
||||
IsActive: true,
|
||||
ApprovalStatus: models.ApprovalApproved,
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("کاربر ادمین اولیه با شماره %s ساخته شد", phone)
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !user.IsAdmin {
|
||||
user.IsAdmin = true
|
||||
db.Save(&user)
|
||||
log.Printf("کاربر %s به ادمین ارتقا یافت", phone)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package dto
|
||||
|
||||
// CreateOptionRequest ساخت یک گزینهی جدید در یک دسته
|
||||
type CreateOptionRequest struct {
|
||||
Title string `json:"title" binding:"required,min=1,max=150"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// UpdateOptionRequest ویرایش یک گزینه
|
||||
type UpdateOptionRequest struct {
|
||||
Title *string `json:"title"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// UpdateCategoryRequest ویرایش تنظیمات یک دسته (مثلاً تغییر حداقل/حداکثر انتخاب)
|
||||
type UpdateCategoryRequest struct {
|
||||
Title *string `json:"title"`
|
||||
MinSelect *int `json:"min_select"`
|
||||
MaxSelect *int `json:"max_select"`
|
||||
}
|
||||
|
||||
// CreateProfileQuestionRequest ساخت سوال جدید پروفایل
|
||||
type CreateProfileQuestionRequest struct {
|
||||
Text string `json:"text" binding:"required,min=3,max=255"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// UpdateProfileQuestionRequest ویرایش سوال پروفایل
|
||||
type UpdateProfileQuestionRequest struct {
|
||||
Text *string `json:"text"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// RejectUserRequest رد کردن درخواست عضویت یک کاربر توسط ادمین (دلیل الزامی است)
|
||||
type RejectUserRequest struct {
|
||||
Reason string `json:"reason" binding:"required,min=3,max=500"`
|
||||
}
|
||||
|
||||
// BlockUserRequest بلاک کردن یک کاربر توسط ادمین (دلیل الزامی است)
|
||||
type BlockUserRequest struct {
|
||||
Reason string `json:"reason" binding:"required,min=3,max=500"`
|
||||
}
|
||||
|
||||
// ReportUserRequest ثبت یک یادداشت نظارتی روی یک کاربر (دلیل الزامی است)
|
||||
type ReportUserRequest struct {
|
||||
Reason string `json:"reason" binding:"required,min=3,max=500"`
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// RequestOTPRequest بدنهی درخواست ارسال کد تایید
|
||||
type RequestOTPRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
}
|
||||
|
||||
// VerifyOTPRequest بدنهی درخواست تایید کد
|
||||
type VerifyOTPRequest struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
OTP string `json:"otp" binding:"required"`
|
||||
}
|
||||
|
||||
// RefreshTokenRequest بدنهی درخواست رفرش توکن
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// TokenPair جفت توکن access/refresh
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// AuthResponse پاسخ استاندارد بعد از ورود موفق
|
||||
type AuthResponse struct {
|
||||
Tokens TokenPair `json:"tokens"`
|
||||
User UserProfile `json:"user"`
|
||||
IsNewUser bool `json:"is_new_user"`
|
||||
OnboardingDone bool `json:"onboarding_done"`
|
||||
}
|
||||
|
||||
// UserProfile نمایش خلاصهی پروفایل کاربر (بدون دادههای حساس)
|
||||
type UserProfile struct {
|
||||
ID uint `json:"id"`
|
||||
Phone string `json:"phone"`
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
Gender *string `json:"gender,omitempty"`
|
||||
BirthDate *string `json:"birth_date,omitempty"`
|
||||
HeightCM *int `json:"height_cm,omitempty"`
|
||||
WeightKG *int `json:"weight_kg,omitempty"`
|
||||
CurrentStep int `json:"current_step"`
|
||||
IsOnboarded bool `json:"is_onboarded"`
|
||||
ApprovalStatus string `json:"approval_status"`
|
||||
RejectionReason *string `json:"rejection_reason,omitempty"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
State string `json:"state"`
|
||||
BlockedReason *string `json:"blocked_reason,omitempty"`
|
||||
BlockedAt *time.Time `json:"blocked_at,omitempty"`
|
||||
Bio *string `json:"bio,omitempty"`
|
||||
StreakCount int `json:"streak_count"`
|
||||
NotifyMatches bool `json:"notify_matches"`
|
||||
NotifyMessages bool `json:"notify_messages"`
|
||||
NotifyLikes bool `json:"notify_likes"`
|
||||
}
|
||||
|
||||
// UserProfileWithPhotos برای لیستهای ادمینی که علاوه بر خلاصهی پروفایل، عکس
|
||||
// کاربر رو هم نشون میدن (مثلاً کاربران در انتظار تایید).
|
||||
type UserProfileWithPhotos struct {
|
||||
UserProfile
|
||||
Photos []string `json:"photos"`
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package dto
|
||||
|
||||
// DiscoverCandidate یک پروفایل پیشنهادی توی صفحهی دوستیابی
|
||||
type DiscoverCandidate struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
Age int `json:"age"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Photos []string `json:"photos"`
|
||||
DistanceKM *float64 `json:"distance_km,omitempty"`
|
||||
Interests []string `json:"interests,omitempty"`
|
||||
}
|
||||
|
||||
// SwipeRequest بدنهی درخواست ثبت لایک/ردّ
|
||||
type SwipeRequest struct {
|
||||
Action string `json:"action" binding:"required,oneof=like pass superlike"`
|
||||
}
|
||||
|
||||
// SwipeResponse نتیجهی یک سواپ؛ اگه Matched=true باشه یعنی مچ تازه (یا از قبل) ساخته شده
|
||||
type SwipeResponse struct {
|
||||
Matched bool `json:"matched"`
|
||||
Match *MatchView `json:"match,omitempty"`
|
||||
}
|
||||
|
||||
// MatchView خلاصهی یک مچ برای نمایش در لیست مچها/گفتگوها. برای پیشنمایش
|
||||
// آخرین پیام، سرور رمزگشایی نمیکنه (نمیتونه) — CipherText/Nonce خودِ آخرین
|
||||
// پیام رو برمیگردونه تا کلاینت خودش رمزگشایی کنه؛ LastMessage فقط برای
|
||||
// پیامهای قدیمیِ قبل از E2EE پر میشه.
|
||||
type MatchView struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
Photos []string `json:"photos"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastMessage *string `json:"last_message,omitempty"`
|
||||
LastMessageCipherText *string `json:"last_message_cipher_text,omitempty"`
|
||||
LastMessageNonce *string `json:"last_message_nonce,omitempty"`
|
||||
LastAt *string `json:"last_at,omitempty"`
|
||||
// PartnerPublicKey کلید عمومی X25519 طرف مقابل (base64) برای رمزنگاری
|
||||
// سرتاسری؛ اگه طرف مقابل هنوز کلیدی ثبت نکرده باشه خالیه.
|
||||
PartnerPublicKey *string `json:"partner_public_key,omitempty"`
|
||||
}
|
||||
|
||||
// MessageView یک پیام در یک گفتگو. یا Text (پیام قدیمیِ قبل از E2EE) پر
|
||||
// میشه، یا CipherText+Nonce (پیام رمزنگاریشده) — سرور خودش نمیدونه محتوای
|
||||
// رمزنگاریشده چیه، فقط بین دو طرف مچ ردوبدلش میکنه.
|
||||
type MessageView struct {
|
||||
ID uint `json:"id"`
|
||||
MatchID uint `json:"match_id"`
|
||||
SenderID uint `json:"sender_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
CipherText *string `json:"cipher_text,omitempty"`
|
||||
Nonce *string `json:"nonce,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IsMine bool `json:"is_mine"`
|
||||
}
|
||||
|
||||
// SendMessageRequest بدنهی درخواست ارسال پیام رمزنگاریشده. متن خام پیام
|
||||
// هیچوقت به سرور نمیرسه؛ کلاینت قبل از ارسال با کلید عمومی طرف مقابل
|
||||
// رمزنگاریش میکنه.
|
||||
type SendMessageRequest struct {
|
||||
CipherText string `json:"cipher_text" binding:"required,min=1,max=12000"`
|
||||
Nonce string `json:"nonce" binding:"required,len=32"`
|
||||
}
|
||||
|
||||
// CandidateAnswer پاسخ یک سوال پروفایل، برای نمایش عمومی
|
||||
type CandidateAnswer struct {
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
|
||||
// CandidateDetail پروفایل کامل یک کاربر دیگه (برای نمایش صفحهی جزئیات پروفایل)
|
||||
type CandidateDetail struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
Age int `json:"age"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Photos []string `json:"photos"`
|
||||
DistanceKM *float64 `json:"distance_km,omitempty"`
|
||||
HeightCM *int `json:"height_cm,omitempty"`
|
||||
WeightKG *int `json:"weight_kg,omitempty"`
|
||||
Education string `json:"education,omitempty"`
|
||||
FieldOfStudy string `json:"field_of_study,omitempty"`
|
||||
Occupations []string `json:"occupations,omitempty"`
|
||||
Interests []string `json:"interests,omitempty"`
|
||||
ImportantInRelationship []string `json:"important_in_relationship,omitempty"`
|
||||
LookingFor string `json:"looking_for,omitempty"`
|
||||
Answers []CandidateAnswer `json:"profile_answers,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package dto
|
||||
|
||||
// BasicInfoRequest مرحله ۱: جنسیت، اسم کوچک، تاریخ تولد
|
||||
type BasicInfoRequest struct {
|
||||
Gender string `json:"gender" binding:"required,oneof=male female"`
|
||||
FirstName string `json:"first_name" binding:"required,min=2,max=50"`
|
||||
// فرمت: YYYY-MM-DD
|
||||
BirthDate string `json:"birth_date" binding:"required"`
|
||||
}
|
||||
|
||||
// BodyInfoRequest مرحله ۲: قد و وزن
|
||||
type BodyInfoRequest struct {
|
||||
HeightCM int `json:"height_cm" binding:"required,min=100,max=250"`
|
||||
WeightKG int `json:"weight_kg" binding:"required,min=30,max=250"`
|
||||
}
|
||||
|
||||
// ProfileQuestionAnswerInput یک جفت (سوال انتخابشده، پاسخ) از سوالات پروفایل
|
||||
type ProfileQuestionAnswerInput struct {
|
||||
QuestionID uint `json:"question_id" binding:"required"`
|
||||
Answer string `json:"answer" binding:"required,min=1,max=1000"`
|
||||
}
|
||||
|
||||
// ProfileQuestionsRequest مرحله ۳: دقیقاً ۳ سوال انتخابی همراه با پاسخ
|
||||
type ProfileQuestionsRequest struct {
|
||||
Answers []ProfileQuestionAnswerInput `json:"answers" binding:"required,len=3,dive"`
|
||||
}
|
||||
|
||||
// EducationRequest مرحله ۴: سطح تحصیلات + رشته تحصیلی (هر دو از لیستهای ادمین)
|
||||
type EducationRequest struct {
|
||||
EducationLevelOptionID uint `json:"education_level_option_id" binding:"required"`
|
||||
FieldOfStudyOptionID uint `json:"field_of_study_option_id" binding:"required"`
|
||||
}
|
||||
|
||||
// OptionSelectionRequest برای مراحلی که فقط یک لیست از option_id ارسال میشود
|
||||
// (کارها، اهمیتهای رابطه، علاقهمندیها، دنبال چی میگردی)
|
||||
type OptionSelectionRequest struct {
|
||||
OptionIDs []uint `json:"option_ids" binding:"required,min=1,dive"`
|
||||
}
|
||||
|
||||
// OnboardingStatusResponse وضعیت فعلی آنبوردینگ کاربر
|
||||
type OnboardingStatusResponse struct {
|
||||
CurrentStep int `json:"current_step"`
|
||||
CurrentStepKey string `json:"current_step_key"`
|
||||
IsOnboarded bool `json:"is_onboarded"`
|
||||
// ApprovalStatus بعد از تکمیل آنبوردینگ معنا پیدا میکنه: pending/approved/rejected.
|
||||
// وقتی pending هست، اپ باید صفحهی «در انتظار تایید ادمین» رو نشون بده.
|
||||
ApprovalStatus string `json:"approval_status"`
|
||||
RejectionReason *string `json:"rejection_reason,omitempty"`
|
||||
Steps []StepView `json:"steps"`
|
||||
}
|
||||
|
||||
type StepView struct {
|
||||
Step int `json:"step"`
|
||||
Key string `json:"key"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dto
|
||||
|
||||
// UpdateLocationRequest بهروزرسانی موقعیت مکانی کاربر (برای محاسبهی فاصلهی تقریبی)
|
||||
type UpdateLocationRequest struct {
|
||||
Latitude float64 `json:"latitude" binding:"required"`
|
||||
Longitude float64 `json:"longitude" binding:"required"`
|
||||
}
|
||||
|
||||
// UpdateBioRequest ویرایش بیوگرافی کوتاه پروفایل
|
||||
type UpdateBioRequest struct {
|
||||
Bio string `json:"bio" binding:"max=300"`
|
||||
}
|
||||
|
||||
// CheckInResult نتیجهی ثبت ورود روزانه (برای استریک گیمیفیکیشن)
|
||||
type CheckInResult struct {
|
||||
StreakCount int `json:"streak_count"`
|
||||
IsNewToday bool `json:"is_new_today"`
|
||||
}
|
||||
|
||||
// UpdateNotificationSettingsRequest ترجیحات اعلان کاربر
|
||||
type UpdateNotificationSettingsRequest struct {
|
||||
NotifyMatches bool `json:"notify_matches"`
|
||||
NotifyMessages bool `json:"notify_messages"`
|
||||
NotifyLikes bool `json:"notify_likes"`
|
||||
}
|
||||
|
||||
// UpdatePublicKeyRequest ثبت/بهروزرسانی کلید عمومی X25519 کاربر برای
|
||||
// رمزنگاری سرتاسری پیامها. کلید باید دقیقاً ۳۲ بایت خام باشه که بهصورت
|
||||
// base64 استاندارد (۴۴ کاراکتر، شامل padding) کدگذاری شده.
|
||||
type UpdatePublicKeyRequest struct {
|
||||
PublicKey string `json:"public_key" binding:"required,len=44"`
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
adminService *service.AdminService
|
||||
optionRepo *repository.OptionRepository
|
||||
questionRepo *repository.QuestionRepository
|
||||
photoRepo *repository.PhotoRepository
|
||||
onboardingService *service.OnboardingService
|
||||
}
|
||||
|
||||
func NewAdminHandler(
|
||||
adminService *service.AdminService,
|
||||
optionRepo *repository.OptionRepository,
|
||||
questionRepo *repository.QuestionRepository,
|
||||
photoRepo *repository.PhotoRepository,
|
||||
onboardingService *service.OnboardingService,
|
||||
) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
adminService: adminService,
|
||||
optionRepo: optionRepo,
|
||||
questionRepo: questionRepo,
|
||||
photoRepo: photoRepo,
|
||||
onboardingService: onboardingService,
|
||||
}
|
||||
}
|
||||
|
||||
func validCategoryKey(key string) (models.CategoryKey, bool) {
|
||||
k := models.CategoryKey(key)
|
||||
for _, valid := range models.AllCategoryKeys {
|
||||
if valid == k {
|
||||
return k, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ---------------- دستهها ----------------
|
||||
|
||||
// GetCategory GET /admin/categories/:key
|
||||
func (h *AdminHandler) GetCategory(c *gin.Context) {
|
||||
key, ok := validCategoryKey(c.Param("key"))
|
||||
if !ok {
|
||||
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
cat, err := h.adminService.GetCategory(key)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusNotFound, "دسته یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", cat)
|
||||
}
|
||||
|
||||
// UpdateCategory PATCH /admin/categories/:key
|
||||
func (h *AdminHandler) UpdateCategory(c *gin.Context) {
|
||||
key, ok := validCategoryKey(c.Param("key"))
|
||||
if !ok {
|
||||
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.UpdateCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
cat, err := h.adminService.UpdateCategory(key, req)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "دسته بهروزرسانی شد", cat)
|
||||
}
|
||||
|
||||
// ---------------- گزینهها ----------------
|
||||
|
||||
// ListOptions GET /admin/categories/:key/options
|
||||
func (h *AdminHandler) ListOptions(c *gin.Context) {
|
||||
key, ok := validCategoryKey(c.Param("key"))
|
||||
if !ok {
|
||||
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
options, err := h.adminService.ListOptions(key)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزینهها", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", options)
|
||||
}
|
||||
|
||||
// CreateOption POST /admin/categories/:key/options
|
||||
func (h *AdminHandler) CreateOption(c *gin.Context) {
|
||||
key, ok := validCategoryKey(c.Param("key"))
|
||||
if !ok {
|
||||
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.CreateOptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
opt, err := h.adminService.CreateOption(key, req)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "گزینه ساخته شد", opt)
|
||||
}
|
||||
|
||||
// UpdateOption PATCH /admin/options/:id
|
||||
func (h *AdminHandler) UpdateOption(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.UpdateOptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
opt, err := h.adminService.UpdateOption(uint(id), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.Fail(c, http.StatusNotFound, "گزینه یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "گزینه بهروزرسانی شد", opt)
|
||||
}
|
||||
|
||||
// DeleteOption DELETE /admin/options/:id
|
||||
func (h *AdminHandler) DeleteOption(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
if err := h.adminService.DeleteOption(uint(id)); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "حذف گزینه با خطا مواجه شد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "گزینه حذف شد", nil)
|
||||
}
|
||||
|
||||
// ---------------- بررسی و تایید کاربران ----------------
|
||||
|
||||
// ListPendingUsers GET /admin/users/pending
|
||||
func (h *AdminHandler) ListPendingUsers(c *gin.Context) {
|
||||
users, photosByUser, err := h.adminService.ListPendingUsers()
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil)
|
||||
return
|
||||
}
|
||||
result := make([]dto.UserProfileWithPhotos, 0, len(users))
|
||||
for i := range users {
|
||||
photos := photosByUser[users[i].ID]
|
||||
if photos == nil {
|
||||
photos = []string{}
|
||||
}
|
||||
result = append(result, dto.UserProfileWithPhotos{
|
||||
UserProfile: toUserProfile(&users[i]),
|
||||
Photos: photos,
|
||||
})
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", result)
|
||||
}
|
||||
|
||||
// GetUserDetail GET /admin/users/:id — پروفایل کامل یک کاربر برای بررسی ادمین
|
||||
func (h *AdminHandler) GetUserDetail(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
user, err := h.adminService.GetUser(uint(id))
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusNotFound, "کاربر یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", fullProfilePayload(user, h.optionRepo, h.questionRepo, h.onboardingService, h.photoRepo))
|
||||
}
|
||||
|
||||
// ApproveUser POST /admin/users/:id/approve
|
||||
func (h *AdminHandler) ApproveUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
user, err := h.adminService.ApproveUser(uint(id))
|
||||
if err != nil {
|
||||
h.handleUserReviewError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "کاربر تایید شد", toUserProfile(user))
|
||||
}
|
||||
|
||||
// RejectUser POST /admin/users/:id/reject body: {reason}
|
||||
func (h *AdminHandler) RejectUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.RejectUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل رد الزامی است)", err.Error())
|
||||
return
|
||||
}
|
||||
user, err := h.adminService.RejectUser(uint(id), req.Reason)
|
||||
if err != nil {
|
||||
h.handleUserReviewError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "کاربر رد شد", toUserProfile(user))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) handleUserReviewError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
utils.Fail(c, http.StatusNotFound, "کاربر یافت نشد", nil)
|
||||
case errors.Is(err, service.ErrUserNotOnboarded):
|
||||
utils.Fail(c, http.StatusConflict, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
}
|
||||
}
|
||||
|
||||
// paginationParams صفحه/تعداد رو از کوئری استرینگ میخونه (پیشفرضهای معقول اگه نبود/نامعتبر بود)
|
||||
func paginationParams(c *gin.Context, defaultSize int) (page, pageSize int) {
|
||||
page = 1
|
||||
if p, err := strconv.Atoi(c.Query("page")); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
pageSize = defaultSize
|
||||
if ps, err := strconv.Atoi(c.Query("page_size")); err == nil && ps > 0 {
|
||||
pageSize = ps
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ---------------- همهی کاربران، بلاک/آنبلاک، گزارش ----------------
|
||||
|
||||
// ListAllUsers GET /admin/users?search=&page=&page_size=
|
||||
func (h *AdminHandler) ListAllUsers(c *gin.Context) {
|
||||
page, pageSize := paginationParams(c, 25)
|
||||
search := c.Query("search")
|
||||
|
||||
items, total, err := h.adminService.ListAllUsers(search, page, pageSize)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", gin.H{
|
||||
"items": items, "total": total, "page": page, "page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// BlockUser POST /admin/users/:id/block body: {reason}
|
||||
func (h *AdminHandler) BlockUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.BlockUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل بلاک الزامی است)", err.Error())
|
||||
return
|
||||
}
|
||||
user, err := h.adminService.BlockUser(uint(id), req.Reason)
|
||||
if err != nil {
|
||||
h.handleUserReviewError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "کاربر بلاک شد", toUserProfile(user))
|
||||
}
|
||||
|
||||
// UnblockUser POST /admin/users/:id/unblock
|
||||
func (h *AdminHandler) UnblockUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
user, err := h.adminService.UnblockUser(uint(id))
|
||||
if err != nil {
|
||||
h.handleUserReviewError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "کاربر آنبلاک شد", toUserProfile(user))
|
||||
}
|
||||
|
||||
// ReportUser POST /admin/users/:id/report body: {reason}
|
||||
func (h *AdminHandler) ReportUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.ReportUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل گزارش الزامی است)", err.Error())
|
||||
return
|
||||
}
|
||||
report, err := h.adminService.ReportUser(uint(id), req.Reason)
|
||||
if err != nil {
|
||||
h.handleUserReviewError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "گزارش ثبت شد", report)
|
||||
}
|
||||
|
||||
// ListUserReports GET /admin/users/:id/reports
|
||||
func (h *AdminHandler) ListUserReports(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
reports, err := h.adminService.ListReports(uint(id))
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزارشها", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", reports)
|
||||
}
|
||||
|
||||
// ---------------- مچها (رفقا) ----------------
|
||||
|
||||
// ListAllMatches GET /admin/matches?page=&page_size=
|
||||
func (h *AdminHandler) ListAllMatches(c *gin.Context) {
|
||||
page, pageSize := paginationParams(c, 25)
|
||||
items, total, err := h.adminService.ListAllMatches(page, pageSize)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست مچها", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", gin.H{
|
||||
"items": items, "total": total, "page": page, "page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------- نظارت بر عکسها ----------------
|
||||
|
||||
// ListAllPhotos GET /admin/photos?page=&page_size=
|
||||
func (h *AdminHandler) ListAllPhotos(c *gin.Context) {
|
||||
page, pageSize := paginationParams(c, 50)
|
||||
items, total, err := h.adminService.ListAllPhotos(page, pageSize)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت عکسها", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", gin.H{
|
||||
"items": items, "total": total, "page": page, "page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// DeletePhoto DELETE /admin/photos/:id
|
||||
func (h *AdminHandler) DeletePhoto(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
if err := h.adminService.DeletePhotoAdmin(uint(id)); err != nil {
|
||||
utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "عکس حذف شد", nil)
|
||||
}
|
||||
|
||||
// ReportPhoto POST /admin/photos/:id/report body: {reason}
|
||||
func (h *AdminHandler) ReportPhoto(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.ReportUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل گزارش الزامی است)", err.Error())
|
||||
return
|
||||
}
|
||||
report, err := h.adminService.ReportPhoto(uint(id), req.Reason)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "گزارش ثبت شد", report)
|
||||
}
|
||||
|
||||
// ---------------- سوالات پروفایل ----------------
|
||||
|
||||
// ListQuestions GET /admin/profile-questions
|
||||
func (h *AdminHandler) ListQuestions(c *gin.Context) {
|
||||
qs, err := h.adminService.ListQuestions()
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت سوالات", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", qs)
|
||||
}
|
||||
|
||||
// CreateQuestion POST /admin/profile-questions
|
||||
func (h *AdminHandler) CreateQuestion(c *gin.Context) {
|
||||
var req dto.CreateProfileQuestionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
q, err := h.adminService.CreateQuestion(req)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "سوال ساخته شد", q)
|
||||
}
|
||||
|
||||
// UpdateQuestion PATCH /admin/profile-questions/:id
|
||||
func (h *AdminHandler) UpdateQuestion(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.UpdateProfileQuestionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
q, err := h.adminService.UpdateQuestion(uint(id), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.Fail(c, http.StatusNotFound, "سوال یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "سوال بهروزرسانی شد", q)
|
||||
}
|
||||
|
||||
// DeleteQuestion DELETE /admin/profile-questions/:id
|
||||
func (h *AdminHandler) DeleteQuestion(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
if err := h.adminService.DeleteQuestion(uint(id)); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "حذف سوال با خطا مواجه شد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "سوال حذف شد", nil)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{authService: authService}
|
||||
}
|
||||
|
||||
// RequestOTP POST /auth/request-otp
|
||||
func (h *AuthHandler) RequestOTP(c *gin.Context) {
|
||||
var req dto.RequestOTPRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.RequestOTP(req.Phone)
|
||||
if err != nil {
|
||||
var stillValid *service.OTPStillValidError
|
||||
switch {
|
||||
case errors.As(err, &stillValid):
|
||||
utils.Fail(c, http.StatusTooManyRequests,
|
||||
"کد تایید قبلی هنوز معتبر است", gin.H{"remaining_seconds": stillValid.RemainingSeconds})
|
||||
case errors.Is(err, service.ErrPhoneBlocked):
|
||||
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrSMSSendFailed):
|
||||
utils.Fail(c, http.StatusServiceUnavailable, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "کد تایید با موفقیت ارسال شد", nil)
|
||||
}
|
||||
|
||||
// VerifyOTP POST /auth/verify-otp
|
||||
func (h *AuthHandler) VerifyOTP(c *gin.Context) {
|
||||
var req dto.VerifyOTPRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, access, refresh, isNew, err := h.authService.VerifyOTP(req.Phone, req.OTP)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUserNotFound):
|
||||
utils.Fail(c, http.StatusNotFound, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrOTPExpired), errors.Is(err, service.ErrOTPInvalid), errors.Is(err, service.ErrOTPNotRequested):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "ورود با موفقیت انجام شد", dto.AuthResponse{
|
||||
Tokens: dto.TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
},
|
||||
User: toUserProfile(user),
|
||||
IsNewUser: isNew,
|
||||
OnboardingDone: user.IsOnboarded,
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshToken POST /auth/token/refresh
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req dto.RefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
access, refresh, err := h.authService.RefreshTokens(req.RefreshToken)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusUnauthorized, "رفرش توکن نامعتبر یا منقضی شده است", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "توکن جدید صادر شد", dto.TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type BackupHandler struct {
|
||||
backupService *service.BackupService
|
||||
}
|
||||
|
||||
func NewBackupHandler(backupService *service.BackupService) *BackupHandler {
|
||||
return &BackupHandler{backupService: backupService}
|
||||
}
|
||||
|
||||
// List GET /admin/backups
|
||||
func (h *BackupHandler) List(c *gin.Context) {
|
||||
backups, err := h.backupService.List()
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست بکآپها", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", backups)
|
||||
}
|
||||
|
||||
// Create POST /admin/backups
|
||||
func (h *BackupHandler) Create(c *gin.Context) {
|
||||
info, err := h.backupService.Create()
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "گرفتن بکآپ ناموفق بود: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "بکآپ با موفقیت ساخته شد", info)
|
||||
}
|
||||
|
||||
// Download GET /admin/backups/:filename
|
||||
func (h *BackupHandler) Download(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
path, err := h.backupService.ResolvePath(filename)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusNotFound, "فایل بکآپ یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
c.FileAttachment(path, filename)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type ChatHandler struct {
|
||||
chatService *service.ChatService
|
||||
}
|
||||
|
||||
func NewChatHandler(chatService *service.ChatService) *ChatHandler {
|
||||
return &ChatHandler{chatService: chatService}
|
||||
}
|
||||
|
||||
// ListMatches GET /matches
|
||||
func (h *ChatHandler) ListMatches(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
matches, err := h.chatService.ListMatches(user.ID)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت مچها", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", matches)
|
||||
}
|
||||
|
||||
// ListMessages GET /matches/:id/messages
|
||||
func (h *ChatHandler) ListMessages(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
matchID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
messages, err := h.chatService.ListMessages(user.ID, uint(matchID))
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", messages)
|
||||
}
|
||||
|
||||
// SendMessage POST /matches/:id/messages body: {cipher_text, nonce}
|
||||
// متن خام پیام هیچوقت اینجا نمیرسه؛ کلاینت قبل از ارسال با کلید عمومی
|
||||
// طرف مقابل (NaCl box / X25519) رمزنگاریش کرده.
|
||||
func (h *ChatHandler) SendMessage(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
matchID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.SendMessageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "پیام رمزنگاریشده نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.CipherText, req.Nonce)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "", msg)
|
||||
}
|
||||
|
||||
func (h *ChatHandler) handleError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrNotMatchMember):
|
||||
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrInvalidMessageEncoding):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/middleware"
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
// currentUser کاربر جاری رو از context (که توسط AuthRequired ست شده) استخراج میکنه
|
||||
func currentUser(c *gin.Context) (*models.User, bool) {
|
||||
val, exists := c.Get(middleware.ContextUserKey)
|
||||
if !exists {
|
||||
utils.Fail(c, http.StatusUnauthorized, "احراز هویت لازم است", nil)
|
||||
return nil, false
|
||||
}
|
||||
user, ok := val.(*models.User)
|
||||
if !ok {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
|
||||
return nil, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
func toUserProfile(u *models.User) dto.UserProfile {
|
||||
profile := dto.UserProfile{
|
||||
ID: u.ID,
|
||||
Phone: u.Phone,
|
||||
FirstName: u.FirstName,
|
||||
CurrentStep: int(u.CurrentStep),
|
||||
IsOnboarded: u.IsOnboarded,
|
||||
ApprovalStatus: string(u.ApprovalStatus),
|
||||
RejectionReason: u.RejectionReason,
|
||||
IsAdmin: u.IsAdmin,
|
||||
HeightCM: u.HeightCM,
|
||||
WeightKG: u.WeightKG,
|
||||
State: string(u.State),
|
||||
BlockedReason: u.BlockedReason,
|
||||
BlockedAt: u.BlockedAt,
|
||||
Bio: u.Bio,
|
||||
StreakCount: u.StreakCount,
|
||||
NotifyMatches: u.NotifyMatches,
|
||||
NotifyMessages: u.NotifyMessages,
|
||||
NotifyLikes: u.NotifyLikes,
|
||||
}
|
||||
if u.Gender != nil {
|
||||
g := string(*u.Gender)
|
||||
profile.Gender = &g
|
||||
}
|
||||
if u.BirthDate != nil {
|
||||
bd := u.BirthDate.Format("2006-01-02")
|
||||
profile.BirthDate = &bd
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// selectionSummary یک دستهی انتخابی همراه با عنوان گزینههای انتخابشده
|
||||
type selectionSummary struct {
|
||||
CategoryKey string `json:"category_key"`
|
||||
OptionIDs []uint `json:"option_ids"`
|
||||
Titles []string `json:"titles"`
|
||||
}
|
||||
|
||||
// fullProfilePayload پروفایل کامل یک کاربر (اطلاعات پایه + همهی انتخابها/پاسخها)
|
||||
// رو میسازه. هم توسط خود کاربر (GET /profile/me) و هم توسط ادمین موقع بررسی
|
||||
// درخواست تایید (GET /admin/users/:id) استفاده میشه.
|
||||
func fullProfilePayload(
|
||||
user *models.User,
|
||||
optionRepo *repository.OptionRepository,
|
||||
questionRepo *repository.QuestionRepository,
|
||||
onboardingService *service.OnboardingService,
|
||||
photoRepo *repository.PhotoRepository,
|
||||
) gin.H {
|
||||
// دستهها از کش خونده میشن (تغییر بهندرت اتفاق میافته)، ولی خودِ
|
||||
// انتخابهای کاربر توی همهی دستهها با یک کوئری واحد گرفته میشه؛ قبلاً
|
||||
// اینجا بهازای هر ۶ دسته یک کوئری جدا زده میشد.
|
||||
keyToCategoryID := make(map[models.CategoryKey]uint, len(models.AllCategoryKeys))
|
||||
categoryIDs := make([]uint, 0, len(models.AllCategoryKeys))
|
||||
for _, key := range models.AllCategoryKeys {
|
||||
cat, err := optionRepo.GetCategoryByKey(key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
keyToCategoryID[key] = cat.ID
|
||||
categoryIDs = append(categoryIDs, cat.ID)
|
||||
}
|
||||
|
||||
selectionsByCategoryID, _ := optionRepo.GetUserSelectionsForCategories(user.ID, categoryIDs)
|
||||
|
||||
selections := make([]selectionSummary, 0, len(models.AllCategoryKeys))
|
||||
for _, key := range models.AllCategoryKeys {
|
||||
summary := selectionSummary{CategoryKey: string(key)}
|
||||
for _, s := range selectionsByCategoryID[keyToCategoryID[key]] {
|
||||
summary.OptionIDs = append(summary.OptionIDs, s.OptionID)
|
||||
if s.Option != nil {
|
||||
summary.Titles = append(summary.Titles, s.Option.Title)
|
||||
}
|
||||
}
|
||||
selections = append(selections, summary)
|
||||
}
|
||||
|
||||
answers, _ := questionRepo.GetUserAnswers(user.ID)
|
||||
photos, _ := photoRepo.ListByUser(user.ID)
|
||||
|
||||
return gin.H{
|
||||
"user": toUserProfile(user),
|
||||
"onboarding_status": onboardingService.GetStatus(user),
|
||||
"selections": selections,
|
||||
"profile_answers": answers,
|
||||
"photos": photos,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type DiscoveryHandler struct {
|
||||
discoveryService *service.DiscoveryService
|
||||
}
|
||||
|
||||
func NewDiscoveryHandler(discoveryService *service.DiscoveryService) *DiscoveryHandler {
|
||||
return &DiscoveryHandler{discoveryService: discoveryService}
|
||||
}
|
||||
|
||||
// Feed GET /discover?max_distance_km=&min_age=&max_age=
|
||||
func (h *DiscoveryHandler) Feed(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var maxDistance *float64
|
||||
if raw := c.Query("max_distance_km"); raw != "" {
|
||||
if v, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
maxDistance = &v
|
||||
}
|
||||
}
|
||||
minAge, _ := strconv.Atoi(c.Query("min_age"))
|
||||
maxAge, _ := strconv.Atoi(c.Query("max_age"))
|
||||
|
||||
candidates, err := h.discoveryService.GetFeed(user, maxDistance, minAge, maxAge)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", candidates)
|
||||
}
|
||||
|
||||
// CandidateDetail GET /discover/:id
|
||||
func (h *DiscoveryHandler) CandidateDetail(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
detail, err := h.discoveryService.GetCandidateDetail(user, uint(targetID))
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusNotFound, "پروفایل یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", detail)
|
||||
}
|
||||
|
||||
// Swipe POST /discover/:id/swipe body: {action: "like"|"pass"|"superlike"}
|
||||
func (h *DiscoveryHandler) Swipe(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.SwipeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.discoveryService.Swipe(user, uint(targetID), models.SwipeAction(req.Action))
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", result)
|
||||
}
|
||||
|
||||
func (h *DiscoveryHandler) handleError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrGenderNotSet):
|
||||
utils.Fail(c, http.StatusConflict, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrCannotSwipeSelf), errors.Is(err, service.ErrAlreadySwiped):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/monitoring"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type MonitoringHandler struct {
|
||||
store *monitoring.Store
|
||||
}
|
||||
|
||||
func NewMonitoringHandler(store *monitoring.Store) *MonitoringHandler {
|
||||
return &MonitoringHandler{store: store}
|
||||
}
|
||||
|
||||
// Summary GET /admin/monitoring/summary
|
||||
func (h *MonitoringHandler) Summary(c *gin.Context) {
|
||||
utils.Success(c, http.StatusOK, "", h.store.Summary())
|
||||
}
|
||||
|
||||
// RecentRequests GET /admin/monitoring/requests?limit=100&user_id=42
|
||||
// اگه user_id داده بشه، فقط فعالیت اخیر همون کاربر برمیگرده (برای صفحهی
|
||||
// جزئیات کاربر توی پنل ادمین)؛ وگرنه آخرین درخواستهای کل سرور.
|
||||
func (h *MonitoringHandler) RecentRequests(c *gin.Context) {
|
||||
limit := 100
|
||||
if raw := c.Query("limit"); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 1000 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
if raw := c.Query("user_id"); raw != "" {
|
||||
if uid, err := strconv.ParseUint(raw, 10, 64); err == nil {
|
||||
utils.Success(c, http.StatusOK, "", h.store.RecentByUser(uint(uid), limit))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "", h.store.Recent(limit))
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type OnboardingHandler struct {
|
||||
onboardingService *service.OnboardingService
|
||||
}
|
||||
|
||||
func NewOnboardingHandler(onboardingService *service.OnboardingService) *OnboardingHandler {
|
||||
return &OnboardingHandler{onboardingService: onboardingService}
|
||||
}
|
||||
|
||||
func (h *OnboardingHandler) handleStepError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrStepOutOfOrder):
|
||||
utils.Fail(c, http.StatusConflict, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrInvalidBirthDate),
|
||||
errors.Is(err, service.ErrOptionsNotFound),
|
||||
errors.Is(err, service.ErrQuestionsInvalid),
|
||||
errors.Is(err, service.ErrDuplicateQuestion):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
case errors.Is(err, repository.ErrCategoryNotFound):
|
||||
utils.Fail(c, http.StatusInternalServerError, "دستهی موردنیاز در سیستم تعریف نشده است", nil)
|
||||
case errors.Is(err, service.ErrSelectionCount):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Status GET /onboarding/status
|
||||
func (h *OnboardingHandler) Status(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// BasicInfo POST /onboarding/basic-info
|
||||
func (h *OnboardingHandler) BasicInfo(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.BasicInfoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetBasicInfo(user, req); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "اطلاعات پایه ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// Photos POST /onboarding/photos — تایید مرحلهی عکسها (خودِ آپلود از /profile/photos انجام میشه)
|
||||
func (h *OnboardingHandler) Photos(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.ConfirmPhotos(user); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "عکسها ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// BodyInfo POST /onboarding/body-info
|
||||
func (h *OnboardingHandler) BodyInfo(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.BodyInfoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetBodyInfo(user, req); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "قد و وزن ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// AvailableQuestions GET /onboarding/profile-questions
|
||||
func (h *OnboardingHandler) AvailableQuestions(c *gin.Context) {
|
||||
questions, err := h.onboardingService.GetAvailableQuestions()
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت سوالات", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", questions)
|
||||
}
|
||||
|
||||
// SetProfileQuestions POST /onboarding/profile-questions
|
||||
func (h *OnboardingHandler) SetProfileQuestions(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.ProfileQuestionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (باید دقیقاً ۳ سوال با پاسخ ارسال شود)", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetProfileQuestions(user, req); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "پاسخها ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// Education POST /onboarding/education
|
||||
func (h *OnboardingHandler) Education(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.EducationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetEducation(user, req); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "اطلاعات تحصیلی ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// Occupations POST /onboarding/occupations
|
||||
func (h *OnboardingHandler) Occupations(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.OptionSelectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetOccupations(user, req.OptionIDs); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "فعالیتها ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// RelationshipImportance POST /onboarding/relationship-importance
|
||||
func (h *OnboardingHandler) RelationshipImportance(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.OptionSelectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetRelationshipImportance(user, req.OptionIDs); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "موارد مهم در رابطه ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// Interests POST /onboarding/interests
|
||||
func (h *OnboardingHandler) Interests(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.OptionSelectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetInterests(user, req.OptionIDs); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "علاقهمندیها ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// LookingFor POST /onboarding/looking-for
|
||||
func (h *OnboardingHandler) LookingFor(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.OptionSelectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetLookingFor(user, req.OptionIDs); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "هدف شما ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// Options GET /onboarding/options/:category
|
||||
// category یکی از: education_level, field_of_study, occupation,
|
||||
// important_in_relationship, interest, looking_for
|
||||
func (h *OnboardingHandler) Options(c *gin.Context) {
|
||||
key := models.CategoryKey(c.Param("category"))
|
||||
valid := false
|
||||
for _, k := range models.AllCategoryKeys {
|
||||
if k == key {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
|
||||
options, err := h.onboardingService.GetActiveOptions(key)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزینهها", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", options)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type ProfileHandler struct {
|
||||
optionRepo *repository.OptionRepository
|
||||
questionRepo *repository.QuestionRepository
|
||||
photoRepo *repository.PhotoRepository
|
||||
onboardingService *service.OnboardingService
|
||||
profileService *service.ProfileService
|
||||
}
|
||||
|
||||
func NewProfileHandler(
|
||||
optionRepo *repository.OptionRepository,
|
||||
questionRepo *repository.QuestionRepository,
|
||||
photoRepo *repository.PhotoRepository,
|
||||
onboardingService *service.OnboardingService,
|
||||
profileService *service.ProfileService,
|
||||
) *ProfileHandler {
|
||||
return &ProfileHandler{
|
||||
optionRepo: optionRepo,
|
||||
questionRepo: questionRepo,
|
||||
photoRepo: photoRepo,
|
||||
onboardingService: onboardingService,
|
||||
profileService: profileService,
|
||||
}
|
||||
}
|
||||
|
||||
// Me GET /profile/me — پروفایل کامل کاربر شامل تمام انتخابها و پاسخها
|
||||
func (h *ProfileHandler) Me(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
payload := fullProfilePayload(user, h.optionRepo, h.questionRepo, h.onboardingService, h.photoRepo)
|
||||
utils.Success(c, http.StatusOK, "اطلاعات پروفایل با موفقیت دریافت شد", payload)
|
||||
}
|
||||
|
||||
// UploadPhoto POST /profile/photos (multipart/form-data, فیلد "photo")
|
||||
func (h *ProfileHandler) UploadPhoto(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("photo")
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "فایل عکس ارسال نشده", nil)
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "خطا در خواندن فایل", nil)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "خطا در خواندن فایل", nil)
|
||||
return
|
||||
}
|
||||
|
||||
photo, err := h.profileService.UploadPhoto(user.ID, ext, data)
|
||||
if err != nil {
|
||||
h.handlePhotoError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "عکس اضافه شد", photo)
|
||||
}
|
||||
|
||||
// DeletePhoto DELETE /profile/photos/:id
|
||||
func (h *ProfileHandler) DeletePhoto(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
if err := h.profileService.DeletePhoto(user.ID, uint(id)); err != nil {
|
||||
h.handlePhotoError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "عکس حذف شد", nil)
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) handlePhotoError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil)
|
||||
case errors.Is(err, service.ErrPhotoNotOwned):
|
||||
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrTooManyPhotos),
|
||||
errors.Is(err, service.ErrInvalidImage),
|
||||
errors.Is(err, service.ErrImageTooLarge):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی عکس", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateLocation PATCH /profile/location
|
||||
func (h *ProfileHandler) UpdateLocation(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.UpdateLocationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.profileService.UpdateLocation(user, req.Latitude, req.Longitude); err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی موقعیت مکانی", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "موقعیت مکانی ثبت شد", nil)
|
||||
}
|
||||
|
||||
// UpdateBio PATCH /profile/bio body: {bio}
|
||||
func (h *ProfileHandler) UpdateBio(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.UpdateBioRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "بیوگرافی نباید بیشتر از ۳۰۰ کاراکتر باشه", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.profileService.UpdateBio(user, req.Bio); err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی بیوگرافی", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "بیوگرافی ذخیره شد", nil)
|
||||
}
|
||||
|
||||
// CheckIn POST /profile/checkin — ثبت ورود روزانه برای استریک گیمیفیکیشن
|
||||
func (h *ProfileHandler) CheckIn(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.profileService.CheckIn(user)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در ثبت ورود روزانه", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", result)
|
||||
}
|
||||
|
||||
// UpdateNotificationSettings PATCH /profile/notifications
|
||||
func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.UpdateNotificationSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.profileService.UpdateNotificationSettings(user, req); err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی تنظیمات اعلان", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "تنظیمات اعلان ذخیره شد", nil)
|
||||
}
|
||||
|
||||
// DeleteAccount DELETE /profile/me — غیرفعال کردن (soft delete) حساب کاربر
|
||||
func (h *ProfileHandler) DeleteAccount(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.profileService.DeleteAccount(user); err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در حذف حساب", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil)
|
||||
}
|
||||
|
||||
// UpdatePublicKey PUT /profile/public-key — ثبت/جایگزینی کلید عمومی X25519
|
||||
// برای رمزنگاری سرتاسری پیامها. کلاینت این کلید رو خودش تولید میکنه؛ کلید
|
||||
// خصوصی متناظرش هیچوقت به این endpoint (یا هیچ endpoint دیگهای) فرستاده نمیشه.
|
||||
func (h *ProfileHandler) UpdatePublicKey(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.UpdatePublicKeyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "کلید عمومی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.profileService.UpdatePublicKey(user, req.PublicKey); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidPublicKey) {
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی کلید عمومی", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "کلید عمومی ذخیره شد", nil)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
// AdminRequired باید بعد از AuthRequired استفاده بشه. فقط کاربرانی که IsAdmin=true
|
||||
// دارن اجازهی عبور دارن.
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userVal, exists := c.Get(ContextUserKey)
|
||||
if !exists {
|
||||
utils.Fail(c, http.StatusUnauthorized, "احراز هویت لازم است", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
user, ok := userVal.(*models.User)
|
||||
if !ok || !user.IsAdmin {
|
||||
utils.Fail(c, http.StatusForbidden, "دسترسی فقط برای ادمین است", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
ContextUserKey = "current_user"
|
||||
)
|
||||
|
||||
// AuthRequired توکن access رو از هدر Authorization میخونه، اعتبارسنجی میکنه
|
||||
// و کاربر مربوطه رو توی context قرار میده.
|
||||
func AuthRequired(jwtManager *utils.JWTManager, userRepo *repository.UserRepository) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if header == "" || !strings.HasPrefix(header, "Bearer ") {
|
||||
utils.Fail(c, http.StatusUnauthorized, "توکن احراز هویت ارسال نشده است", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
||||
claims, err := jwtManager.ParseAccessToken(tokenStr)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusUnauthorized, "توکن نامعتبر یا منقضی شده است", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, err := userRepo.FindByID(claims.UserID)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusUnauthorized, "کاربر یافت نشد", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if !user.IsActive {
|
||||
utils.Fail(c, http.StatusForbidden, "حساب کاربری غیرفعال است", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// اگه کاربر بعد از صادر شدن access token فعلی بلاک شده باشه، نباید تا
|
||||
// انقضای طبیعی همون توکن بتونه درخواست بزنه؛ این چک همون لحظه قطعش میکنه.
|
||||
if user.State == models.UserStateBlocked {
|
||||
utils.Fail(c, http.StatusForbidden, "حساب کاربری مسدود شده است", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ContextUserKey, user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS یک میانافزار ساده برای اجازه دادن به درخواستهای Cross-Origin
|
||||
// (مناسب برای توسعه؛ در پروداکشن origin ها رو محدود کن)
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type gzipWriter struct {
|
||||
gin.ResponseWriter
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
func (g *gzipWriter) Write(data []byte) (int, error) {
|
||||
return g.writer.Write(data)
|
||||
}
|
||||
|
||||
func (g *gzipWriter) WriteString(s string) (int, error) {
|
||||
return g.writer.Write([]byte(s))
|
||||
}
|
||||
|
||||
// Gzip پاسخهای JSON رو (وقتی کلاینت gzip رو توی Accept-Encoding پشتیبانی کنه)
|
||||
// فشرده میکنه؛ بدون هیچ وابستگی بیرونی، فقط با compress/gzip استاندارد گو.
|
||||
// برای مسیرهایی مثل فید دیسکاوری که پاسخ JSON نسبتاً بزرگی برمیگردونن، حجم
|
||||
// انتقال رو محسوس کم میکنه.
|
||||
func Gzip() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// فایلهای عکسِ آپلودشده از قبل فشردهان (jpeg/png/webp)؛ دوباره gzip
|
||||
// کردنشون فقط CPU هدر میده بدون فایدهی محسوس.
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/uploads") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
gz := gzip.NewWriter(c.Writer)
|
||||
defer gz.Close()
|
||||
|
||||
c.Header("Content-Encoding", "gzip")
|
||||
c.Header("Vary", "Accept-Encoding")
|
||||
c.Writer = &gzipWriter{ResponseWriter: c.Writer, writer: gz}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/monitoring"
|
||||
)
|
||||
|
||||
// Metrics هر درخواست رو (متد، مسیر، کد پاسخ، مدت زمان) توی Store ثبت میکنه.
|
||||
// باید قبل از Recovery ثبت بشه (بیرونیتر) تا حتی درخواستهایی که پنیک میکنن
|
||||
// و توسط Recovery مهار میشن هم توی آمار لحاظ بشن.
|
||||
func Metrics(store *monitoring.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
defer func() {
|
||||
path := c.FullPath()
|
||||
if path == "" {
|
||||
path = c.Request.URL.Path
|
||||
}
|
||||
|
||||
rec := monitoring.RequestRecord{
|
||||
Time: start,
|
||||
Method: c.Request.Method,
|
||||
Path: path,
|
||||
StatusCode: c.Writer.Status(),
|
||||
DurationMs: float64(time.Since(start).Microseconds()) / 1000.0,
|
||||
ClientIP: c.ClientIP(),
|
||||
}
|
||||
if userVal, exists := c.Get(ContextUserKey); exists {
|
||||
if user, ok := userVal.(*models.User); ok {
|
||||
id := user.ID
|
||||
rec.UserID = &id
|
||||
}
|
||||
}
|
||||
store.Record(rec)
|
||||
}()
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
lastSeenAt time.Time
|
||||
}
|
||||
|
||||
// RateLimiter یک rate limiter سادهی token-bucket بهازای هر IP؛ برای جلوگیری
|
||||
// از سوءاستفاده از اندپوینتهای حساس مثل درخواست OTP (که هزینهی پیامک داره
|
||||
// و هدف مناسبی برای اسپم/brute-force هست). maxTokens یعنی سقف درخواست
|
||||
// پشتسرهم مجاز، refillPerSecond یعنی سرعت شارژ دوبارهی سطل.
|
||||
func RateLimiter(maxTokens, refillPerSecond float64) gin.HandlerFunc {
|
||||
var mu sync.Mutex
|
||||
buckets := make(map[string]*bucket)
|
||||
|
||||
// سطلهای راکد (IP هایی که مدتیه درخواست نزدن) رو دورهای پاک میکنه تا
|
||||
// حافظه با تعداد IP های دیدهشده بینهایت رشد نکنه.
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
mu.Lock()
|
||||
for k, b := range buckets {
|
||||
if b.lastSeenAt.Before(cutoff) {
|
||||
delete(buckets, k)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
key := c.ClientIP()
|
||||
now := time.Now()
|
||||
|
||||
mu.Lock()
|
||||
b, ok := buckets[key]
|
||||
if !ok {
|
||||
b = &bucket{tokens: maxTokens, lastSeenAt: now}
|
||||
buckets[key] = b
|
||||
}
|
||||
elapsed := now.Sub(b.lastSeenAt).Seconds()
|
||||
b.tokens = min(maxTokens, b.tokens+elapsed*refillPerSecond)
|
||||
b.lastSeenAt = now
|
||||
|
||||
allowed := b.tokens >= 1
|
||||
if allowed {
|
||||
b.tokens--
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
if !allowed {
|
||||
utils.Fail(c, http.StatusTooManyRequests, "درخواستهای زیادی فرستادی؛ کمی صبر کن و دوباره امتحان کن", nil)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newTestEngine(mw gin.HandlerFunc) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/x", mw, func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestRateLimiter_AllowsWithinLimit(t *testing.T) {
|
||||
engine := newTestEngine(RateLimiter(3, 1))
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.RemoteAddr = "1.2.3.4:1111"
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_BlocksOverLimit(t *testing.T) {
|
||||
engine := newTestEngine(RateLimiter(2, 0)) // بدون شارژ دوباره، فقط ۲ تا مجازه
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.RemoteAddr = "5.6.7.8:2222"
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.RemoteAddr = "5.6.7.8:2222"
|
||||
w := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected 429 on 3rd request, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_SeparateBucketsPerIP(t *testing.T) {
|
||||
engine := newTestEngine(RateLimiter(1, 0))
|
||||
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req1.RemoteAddr = "9.9.9.9:1"
|
||||
w1 := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w1, req1)
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for first IP, got %d", w1.Code)
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req2.RemoteAddr = "8.8.8.8:1"
|
||||
w2 := httptest.NewRecorder()
|
||||
engine.ServeHTTP(w2, req2)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for a different IP (separate bucket), got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
// Recovery از کرش کردن کل پروسه بهخاطر panic غیرمنتظرهی یک هندلر
|
||||
// (nil pointer، index out of range و...) جلوگیری میکنه: استکتریس رو لاگ
|
||||
// میکنه و بهجای متن سادهی پیشفرض Gin، همون envelope استاندارد JSON
|
||||
// پروژه (utils.Fail) رو برمیگردونه تا کلاینتها فرمت یکسانی ببینن.
|
||||
func Recovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[PANIC RECOVERED] %v\n%s", r, debug.Stack())
|
||||
if !c.Writer.Written() {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطای غیرمنتظرهای رخ داد؛ لطفاً دوباره امتحان کن", nil)
|
||||
}
|
||||
c.Abort()
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Photo یکی از عکسهای پروفایل کاربر
|
||||
type Photo struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
URL string `gorm:"size:500;not null" json:"url"`
|
||||
SortOrder int `gorm:"default:0;not null" json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Photo) TableName() string {
|
||||
return "photos"
|
||||
}
|
||||
|
||||
// SwipeAction نوع تصمیمی که کاربر روی یک پروفایل دیگه گرفته
|
||||
type SwipeAction string
|
||||
|
||||
const (
|
||||
SwipeLike SwipeAction = "like"
|
||||
SwipeSuperlike SwipeAction = "superlike"
|
||||
SwipePass SwipeAction = "pass"
|
||||
)
|
||||
|
||||
// Swipe ثبت تصمیم یک کاربر (لایک/ردّ) روی یک کاربر دیگه.
|
||||
// هر جفت (SwiperID, SwipedID) فقط یک بار میتونه ثبت بشه؛ توی discovery feed
|
||||
// کسایی که قبلاً روشون تصمیم گرفته شده دیگه نشون داده نمیشن.
|
||||
type Swipe struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SwiperID uint `gorm:"not null;uniqueIndex:idx_swiper_swiped" json:"swiper_id"`
|
||||
SwipedID uint `gorm:"not null;uniqueIndex:idx_swiper_swiped;index" json:"swiped_id"`
|
||||
Action SwipeAction `gorm:"size:10;not null" json:"action"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Swipe) TableName() string {
|
||||
return "swipes"
|
||||
}
|
||||
|
||||
// Match وقتی ساخته میشه که دو کاربر متقابلاً همدیگه رو لایک کرده باشن.
|
||||
// UserOneID همیشه شناسهی کوچیکتره (برای جلوگیری از رکورد تکراری در دو جهت).
|
||||
type Match struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserOneID uint `gorm:"not null;uniqueIndex:idx_match_pair" json:"user_one_id"`
|
||||
UserTwoID uint `gorm:"not null;uniqueIndex:idx_match_pair" json:"user_two_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
UserOne *User `gorm:"foreignKey:UserOneID" json:"-"`
|
||||
UserTwo *User `gorm:"foreignKey:UserTwoID" json:"-"`
|
||||
}
|
||||
|
||||
func (Match) TableName() string {
|
||||
return "matches"
|
||||
}
|
||||
|
||||
// Message یک پیام داخل یک مچ. از رمزنگاری سرتاسری (E2EE) استفاده میشه:
|
||||
// سرور فقط CipherText+Nonce (خروجی رمزنگاریشدهی سمت کلاینت با NaCl box،
|
||||
// X25519-XSalsa20-Poly1305) رو ذخیره میکنه و هیچوقت متن خام پیام رو نمیبینه.
|
||||
// Text قدیمی (قبل از اضافه شدن E2EE) برای نمایش پیامهای تاریخی نگه داشته میشه؛
|
||||
// پیامهای جدید Text خالی دارن و بهجاش CipherText/Nonce پر میشه.
|
||||
type Message struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
MatchID uint `gorm:"not null;index" json:"match_id"`
|
||||
SenderID uint `gorm:"not null" json:"sender_id"`
|
||||
Text string `gorm:"size:2000;not null" json:"text"`
|
||||
CipherText *string `gorm:"size:12000" json:"cipher_text,omitempty"`
|
||||
Nonce *string `gorm:"size:64" json:"nonce,omitempty"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Message) TableName() string {
|
||||
return "messages"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CategoryKey شناسهی ثابت هر دستهی لیست انتخابی.
|
||||
// این کلیدها توی کد استفاده میشن (برای هر مرحله از آنبوردینگ)
|
||||
// ولی گزینههای داخل هر دسته کاملاً از پنل ادمین قابل مدیریت هستن.
|
||||
type CategoryKey string
|
||||
|
||||
const (
|
||||
CategoryEducationLevel CategoryKey = "education_level"
|
||||
CategoryFieldOfStudy CategoryKey = "field_of_study"
|
||||
CategoryOccupation CategoryKey = "occupation"
|
||||
CategoryImportantInRelationship CategoryKey = "important_in_relationship"
|
||||
CategoryInterest CategoryKey = "interest"
|
||||
CategoryLookingFor CategoryKey = "looking_for"
|
||||
)
|
||||
|
||||
// AllCategoryKeys برای seed کردن و اعتبارسنجی
|
||||
var AllCategoryKeys = []CategoryKey{
|
||||
CategoryEducationLevel,
|
||||
CategoryFieldOfStudy,
|
||||
CategoryOccupation,
|
||||
CategoryImportantInRelationship,
|
||||
CategoryInterest,
|
||||
CategoryLookingFor,
|
||||
}
|
||||
|
||||
// Category یک دستهی لیست انتخابی (مثلاً «علاقهمندیها»).
|
||||
// MinSelect/MaxSelect تعداد مجاز انتخاب رو مشخص میکنه (MaxSelect=0 یعنی نامحدود).
|
||||
type Category struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Key CategoryKey `gorm:"size:50;uniqueIndex;not null" json:"key"`
|
||||
Title string `gorm:"size:150;not null" json:"title"`
|
||||
// توجه: عمداً تگ gorm:"default:..." روی این دو فیلد گذاشته نشده.
|
||||
// GORM مقادیر صفر (zero value) رو برای ستونهایی که تگ default دارن از
|
||||
// INSERT حذف میکنه (چون نمیتونه "صفر عمدی" رو از "ست نشده" تشخیص بده)
|
||||
// و این یعنی MaxSelect=0 (بهمعنی نامحدود) بهجای ذخیرهشدن، با دیفالت
|
||||
// دیتابیس جایگزین میشد. چون همیشه این دو مقدار رو صریح ست میکنیم
|
||||
// (در seed و در آپدیت ادمین)، نیازی به دیفالت دیتابیسی نیست.
|
||||
MinSelect int `gorm:"not null" json:"min_select"`
|
||||
MaxSelect int `gorm:"not null" json:"max_select"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Options []Option `gorm:"foreignKey:CategoryID" json:"options,omitempty"`
|
||||
}
|
||||
|
||||
func (Category) TableName() string {
|
||||
return "categories"
|
||||
}
|
||||
|
||||
// Option یک آیتم قابلانتخاب داخل یک دسته (مدیریتشده توسط ادمین)
|
||||
type Option struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CategoryID uint `gorm:"not null;index" json:"category_id"`
|
||||
Title string `gorm:"size:150;not null" json:"title"`
|
||||
SortOrder int `gorm:"default:0;not null" json:"sort_order"`
|
||||
IsActive bool `gorm:"default:true;not null" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Option) TableName() string {
|
||||
return "options"
|
||||
}
|
||||
|
||||
// UserOptionSelection انتخابهای کاربر از یک دستهی مشخص.
|
||||
// برای هر مرحله (مثل علاقهمندیها) چند ردیف از این جدول ساخته میشه.
|
||||
type UserOptionSelection struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;uniqueIndex:idx_user_category_option" json:"user_id"`
|
||||
CategoryID uint `gorm:"not null;uniqueIndex:idx_user_category_option;index" json:"category_id"`
|
||||
OptionID uint `gorm:"not null;uniqueIndex:idx_user_category_option" json:"option_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
Option *Option `gorm:"foreignKey:OptionID" json:"option,omitempty"`
|
||||
}
|
||||
|
||||
func (UserOptionSelection) TableName() string {
|
||||
return "user_option_selections"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ProfileQuestion سوالاتی که ادمین از پیش در پنل وارد میکند
|
||||
// (مثلاً «بزرگترین درسی که از زندگی گرفتی چیه؟») و کاربر باید
|
||||
// دقیقاً ۳ تا از آنها را انتخاب و برایش پاسخ بنویسد.
|
||||
type ProfileQuestion struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Text string `gorm:"size:255;not null" json:"text"`
|
||||
SortOrder int `gorm:"default:0;not null" json:"sort_order"`
|
||||
IsActive bool `gorm:"default:true;not null" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ProfileQuestion) TableName() string {
|
||||
return "profile_questions"
|
||||
}
|
||||
|
||||
// UserProfileAnswer پاسخ کاربر به یکی از سوالات انتخابی
|
||||
type UserProfileAnswer struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;uniqueIndex:idx_user_question" json:"user_id"`
|
||||
QuestionID uint `gorm:"not null;uniqueIndex:idx_user_question" json:"question_id"`
|
||||
Answer string `gorm:"type:text;not null" json:"answer"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Question *ProfileQuestion `gorm:"foreignKey:QuestionID" json:"question,omitempty"`
|
||||
}
|
||||
|
||||
func (UserProfileAnswer) TableName() string {
|
||||
return "user_profile_answers"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Report یک یادداشت/پرچم نظارتی که ادمین روی یک کاربر (یا یک عکس مشخص از
|
||||
// اون کاربر) ثبت میکنه؛ برای پیگیری سابقهی نظارتی، مستقل از بلاک/حذف کردن.
|
||||
type Report struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
PhotoID *uint `json:"photo_id,omitempty"`
|
||||
Reason string `gorm:"type:text;not null" json:"reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Report) TableName() string {
|
||||
return "reports"
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Gender جنسیت کاربر
|
||||
type Gender string
|
||||
|
||||
const (
|
||||
GenderMale Gender = "male"
|
||||
GenderFemale Gender = "female"
|
||||
)
|
||||
|
||||
// UserState وضعیت کلی کاربر
|
||||
type UserState string
|
||||
|
||||
const (
|
||||
UserStateInactive UserState = "inactive"
|
||||
UserStateActive UserState = "active"
|
||||
UserStateBlocked UserState = "blocked"
|
||||
)
|
||||
|
||||
// OnboardingStep مرحلهی فعلی کاربر در فرآیند تکمیل پروفایل.
|
||||
// هر مقدار یعنی «این مرحله هنوز باید تکمیل بشه». وقتی به StepCompleted
|
||||
// میرسه یعنی کاربر تمام مراحل رو طی کرده.
|
||||
type OnboardingStep int
|
||||
|
||||
const (
|
||||
StepBasicInfo OnboardingStep = iota + 1
|
||||
StepPhotos
|
||||
StepBodyInfo
|
||||
StepProfileQuestions
|
||||
StepEducation
|
||||
StepOccupations
|
||||
StepRelationshipImportance
|
||||
StepInterests
|
||||
StepLookingFor
|
||||
StepCompleted
|
||||
)
|
||||
|
||||
// ApprovalStatus وضعیت بررسی پروفایل توسط ادمین (بعد از تکمیل آنبوردینگ)
|
||||
type ApprovalStatus string
|
||||
|
||||
const (
|
||||
ApprovalPending ApprovalStatus = "pending"
|
||||
ApprovalApproved ApprovalStatus = "approved"
|
||||
ApprovalRejected ApprovalStatus = "rejected"
|
||||
)
|
||||
|
||||
// StepInfo برای پاسخهای API که وضعیت هر مرحله رو توضیح میده
|
||||
type StepInfo struct {
|
||||
Step OnboardingStep `json:"step"`
|
||||
Key string `json:"key"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
|
||||
// ordered لیست ترتیب مراحل آنبوردینگ (برای ساخت وضعیت کامل)
|
||||
var OnboardingStepOrder = []struct {
|
||||
Step OnboardingStep
|
||||
Key string
|
||||
}{
|
||||
{StepBasicInfo, "basic_info"},
|
||||
{StepPhotos, "photos"},
|
||||
{StepBodyInfo, "body_info"},
|
||||
{StepProfileQuestions, "profile_questions"},
|
||||
{StepEducation, "education"},
|
||||
{StepOccupations, "occupations"},
|
||||
{StepRelationshipImportance, "relationship_importance"},
|
||||
{StepInterests, "interests"},
|
||||
{StepLookingFor, "looking_for"},
|
||||
}
|
||||
|
||||
// User کاربر اصلی سیستم. احراز هویت فقط با شماره موبایل + OTP انجام میشود.
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Phone string `gorm:"size:11;uniqueIndex;not null" json:"phone"`
|
||||
State UserState `gorm:"size:10;default:inactive;not null" json:"state"`
|
||||
IsAdmin bool `gorm:"default:false;not null" json:"is_admin"`
|
||||
IsActive bool `gorm:"default:true;not null" json:"is_active"`
|
||||
|
||||
// -------- بلاک شدن توسط ادمین --------
|
||||
BlockedReason *string `json:"blocked_reason,omitempty"`
|
||||
BlockedAt *time.Time `json:"blocked_at,omitempty"`
|
||||
|
||||
// فیلدهای OTP - هش میشوند و هرگز در پاسخ API برنمیگردند
|
||||
OTPCode *string `gorm:"size:128" json:"-"`
|
||||
OTPExpiry *time.Time `json:"-"`
|
||||
|
||||
// -------- اطلاعات پروفایل (پر میشوند طی فرآیند آنبوردینگ) --------
|
||||
Gender *Gender `gorm:"size:10" json:"gender,omitempty"`
|
||||
FirstName string `gorm:"size:100" json:"first_name,omitempty"`
|
||||
BirthDate *time.Time `json:"birth_date,omitempty"`
|
||||
HeightCM *int `json:"height_cm,omitempty"`
|
||||
WeightKG *int `json:"weight_kg,omitempty"`
|
||||
Bio *string `gorm:"size:300" json:"bio,omitempty"`
|
||||
|
||||
// -------- گیمیفیکیشن: استریک روزانه (چند روز پشتسرهم اپ باز شده) --------
|
||||
StreakCount int `gorm:"default:0;not null" json:"streak_count"`
|
||||
LastActiveDate *time.Time `json:"last_active_date,omitempty"`
|
||||
|
||||
// -------- ترجیحات اعلان --------
|
||||
// فعلاً فقط ذخیره میشن (زیرساخت ارسال واقعی اعلان هنوز پیاده نشده)؛ وقتی
|
||||
// اون سیستم اضافه شد، همین مقادیر رو برای تصمیم به ارسال یا نه میخونه.
|
||||
NotifyMatches bool `gorm:"default:true;not null" json:"notify_matches"`
|
||||
NotifyMessages bool `gorm:"default:true;not null" json:"notify_messages"`
|
||||
NotifyLikes bool `gorm:"default:true;not null" json:"notify_likes"`
|
||||
|
||||
// -------- موقعیت مکانی (برای محاسبهی فاصلهی تقریبی در دوستیابی) --------
|
||||
Latitude *float64 `json:"-"`
|
||||
Longitude *float64 `json:"-"`
|
||||
|
||||
// -------- کلید عمومی رمزنگاری سرتاسری پیامها (E2EE) --------
|
||||
// کلید خصوصی هیچوقت به سرور نمیرسه و فقط روی دستگاه کاربر (localStorage)
|
||||
// ذخیره میشه؛ سرور فقط این کلید عمومی رو نگه میداره تا طرف مقابل بتونه
|
||||
// باهاش پیام رمزنگاری کنه. کلید X25519 هست، base64-encoded (۳۲ بایت خام).
|
||||
PublicKey *string `gorm:"size:64" json:"public_key,omitempty"`
|
||||
|
||||
// -------- وضعیت آنبوردینگ --------
|
||||
CurrentStep OnboardingStep `gorm:"default:1;not null" json:"current_step"`
|
||||
IsOnboarded bool `gorm:"default:false;not null" json:"is_onboarded"`
|
||||
|
||||
// -------- بررسی ادمین (بعد از تکمیل آنبوردینگ) --------
|
||||
// تا وقتی approved نشه، کاربر اجازهی ورود به صفحهی دوستیابی رو نداره.
|
||||
ApprovalStatus ApprovalStatus `gorm:"size:10;default:pending;not null" json:"approval_status"`
|
||||
RejectionReason *string `json:"rejection_reason,omitempty"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||
|
||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Package monitoring یک ذخیرهساز سبک و درونحافظهای برای دیدبانی درخواستهاست:
|
||||
// آخرین درخواستها (برای دیباگ لحظهای) + آمار تجمیعی (بار، زمان پاسخ، نرخ خطا).
|
||||
// عمداً بدون وابستگی بیرونی (بدون Prometheus/Redis) چون در این مقیاس یک حلقهی
|
||||
// درونحافظهای thread-safe کافیه؛ اگه بعداً مقیاس بزرگتر شد، همین Store رو میشه
|
||||
// پشت یک exporter واقعی گذاشت.
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const bufferSize = 1000
|
||||
|
||||
type RequestRecord struct {
|
||||
Time time.Time `json:"time"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
StatusCode int `json:"status_code"`
|
||||
DurationMs float64 `json:"duration_ms"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
UserID *uint `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
TotalErrors int64 `json:"total_errors"`
|
||||
ErrorRatePct float64 `json:"error_rate_pct"`
|
||||
AvgLatencyMs float64 `json:"avg_latency_ms"`
|
||||
P95LatencyMs float64 `json:"p95_latency_ms"`
|
||||
RequestsLastMin int `json:"requests_last_min"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
UptimeSeconds float64 `json:"uptime_seconds"`
|
||||
}
|
||||
|
||||
// Store یک حلقهی ثابتاندازه (ring buffer) از آخرین درخواستها بههمراه
|
||||
// شمارندههای تجمیعیه. نوشتن/خواندن با RWMutex محافظت میشه؛ هزینهی هر
|
||||
// درخواست فقط یک قفل کوتاه و بدون I/O روی دیسکه (سریع، مناسب مسیر داغ).
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
buf []RequestRecord
|
||||
next int
|
||||
count int
|
||||
total int64
|
||||
totalErr int64
|
||||
sumLatency float64
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{
|
||||
buf: make([]RequestRecord, bufferSize),
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Record(rec RequestRecord) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.buf[s.next] = rec
|
||||
s.next = (s.next + 1) % bufferSize
|
||||
if s.count < bufferSize {
|
||||
s.count++
|
||||
}
|
||||
s.total++
|
||||
s.sumLatency += rec.DurationMs
|
||||
if rec.StatusCode >= 400 {
|
||||
s.totalErr++
|
||||
}
|
||||
}
|
||||
|
||||
// Recent آخرین n درخواست رو برمیگردونه (جدیدترین اول).
|
||||
func (s *Store) Recent(n int) []RequestRecord {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if n > s.count {
|
||||
n = s.count
|
||||
}
|
||||
out := make([]RequestRecord, 0, n)
|
||||
idx := s.next - 1
|
||||
for i := 0; i < n; i++ {
|
||||
if idx < 0 {
|
||||
idx = bufferSize - 1
|
||||
}
|
||||
out = append(out, s.buf[idx])
|
||||
idx--
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RecentByUser مثل Recent ولی فقط درخواستهای یک کاربر مشخص رو برمیگردونه
|
||||
// (برای دیدن فعالیت اخیر یک کاربر خاص از صفحهی جزئیاتش توی پنل ادمین).
|
||||
// کل بافر رو میگرده (نه فقط آخرین n)، چون فیلتر شده و ممکنه درخواستهای آن
|
||||
// کاربر پراکنده باشن.
|
||||
func (s *Store) RecentByUser(userID uint, n int) []RequestRecord {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
out := make([]RequestRecord, 0, n)
|
||||
idx := s.next - 1
|
||||
for i := 0; i < s.count && len(out) < n; i++ {
|
||||
if idx < 0 {
|
||||
idx = bufferSize - 1
|
||||
}
|
||||
rec := s.buf[idx]
|
||||
if rec.UserID != nil && *rec.UserID == userID {
|
||||
out = append(out, rec)
|
||||
}
|
||||
idx--
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Summary() Summary {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var errRate, avgLatency float64
|
||||
if s.total > 0 {
|
||||
errRate = float64(s.totalErr) / float64(s.total) * 100
|
||||
avgLatency = s.sumLatency / float64(s.total)
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-1 * time.Minute)
|
||||
lastMin := 0
|
||||
latencies := make([]float64, 0, s.count)
|
||||
for i := 0; i < s.count; i++ {
|
||||
r := s.buf[i]
|
||||
if r.Time.After(cutoff) {
|
||||
lastMin++
|
||||
}
|
||||
latencies = append(latencies, r.DurationMs)
|
||||
}
|
||||
sort.Float64s(latencies)
|
||||
p95 := 0.0
|
||||
if len(latencies) > 0 {
|
||||
idx := int(float64(len(latencies)) * 0.95)
|
||||
if idx >= len(latencies) {
|
||||
idx = len(latencies) - 1
|
||||
}
|
||||
p95 = latencies[idx]
|
||||
}
|
||||
|
||||
return Summary{
|
||||
TotalRequests: s.total,
|
||||
TotalErrors: s.totalErr,
|
||||
ErrorRatePct: errRate,
|
||||
AvgLatencyMs: avgLatency,
|
||||
P95LatencyMs: p95,
|
||||
RequestsLastMin: lastMin,
|
||||
StartedAt: s.startedAt,
|
||||
UptimeSeconds: time.Since(s.startedAt).Seconds(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package monitoring
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStore_RecordAndSummary(t *testing.T) {
|
||||
s := NewStore()
|
||||
|
||||
s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 200, DurationMs: 10})
|
||||
s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 500, DurationMs: 20})
|
||||
|
||||
sum := s.Summary()
|
||||
if sum.TotalRequests != 2 {
|
||||
t.Fatalf("expected 2 total requests, got %d", sum.TotalRequests)
|
||||
}
|
||||
if sum.TotalErrors != 1 {
|
||||
t.Fatalf("expected 1 error (status>=400), got %d", sum.TotalErrors)
|
||||
}
|
||||
if sum.ErrorRatePct != 50 {
|
||||
t.Fatalf("expected 50%% error rate, got %v", sum.ErrorRatePct)
|
||||
}
|
||||
if sum.AvgLatencyMs != 15 {
|
||||
t.Fatalf("expected avg latency 15ms, got %v", sum.AvgLatencyMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_RecentOrder(t *testing.T) {
|
||||
s := NewStore()
|
||||
s.Record(RequestRecord{Time: time.Now(), Path: "/1", StatusCode: 200})
|
||||
s.Record(RequestRecord{Time: time.Now(), Path: "/2", StatusCode: 200})
|
||||
s.Record(RequestRecord{Time: time.Now(), Path: "/3", StatusCode: 200})
|
||||
|
||||
recent := s.Recent(10)
|
||||
if len(recent) != 3 {
|
||||
t.Fatalf("expected 3 records, got %d", len(recent))
|
||||
}
|
||||
// جدیدترین باید اول باشه
|
||||
if recent[0].Path != "/3" || recent[1].Path != "/2" || recent[2].Path != "/1" {
|
||||
t.Fatalf("expected newest-first order, got %+v", recent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_RecentLimit(t *testing.T) {
|
||||
s := NewStore()
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200})
|
||||
}
|
||||
if got := len(s.Recent(2)); got != 2 {
|
||||
t.Fatalf("expected limit of 2, got %d", got)
|
||||
}
|
||||
if got := len(s.Recent(100)); got != 5 {
|
||||
t.Fatalf("expected all 5 when limit exceeds count, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_RingBufferWraparound(t *testing.T) {
|
||||
s := NewStore()
|
||||
// بیشتر از ظرفیت بافر ثبت میکنیم تا رفتار wraparound رو تست کنیم
|
||||
for i := 0; i < bufferSize+50; i++ {
|
||||
s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200, DurationMs: 1})
|
||||
}
|
||||
|
||||
sum := s.Summary()
|
||||
if sum.TotalRequests != int64(bufferSize+50) {
|
||||
t.Fatalf("expected total requests to keep counting past buffer size, got %d", sum.TotalRequests)
|
||||
}
|
||||
|
||||
recent := s.Recent(bufferSize + 100)
|
||||
if len(recent) != bufferSize {
|
||||
t.Fatalf("expected Recent to be capped at bufferSize (%d), got %d", bufferSize, len(recent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_RecentByUser(t *testing.T) {
|
||||
s := NewStore()
|
||||
uidA := uint(1)
|
||||
uidB := uint(2)
|
||||
|
||||
s.Record(RequestRecord{Time: time.Now(), Path: "/a1", UserID: &uidA})
|
||||
s.Record(RequestRecord{Time: time.Now(), Path: "/b1", UserID: &uidB})
|
||||
s.Record(RequestRecord{Time: time.Now(), Path: "/a2", UserID: &uidA})
|
||||
s.Record(RequestRecord{Time: time.Now(), Path: "/none"}) // بدون کاربر (مثل OTP)
|
||||
|
||||
got := s.RecentByUser(uidA, 10)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 records for user A, got %d", len(got))
|
||||
}
|
||||
if got[0].Path != "/a2" || got[1].Path != "/a1" {
|
||||
t.Fatalf("expected newest-first order for user A, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/models"
|
||||
)
|
||||
|
||||
type MatchRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMatchRepository(db *gorm.DB) *MatchRepository {
|
||||
return &MatchRepository{db: db}
|
||||
}
|
||||
|
||||
// normalizePair همیشه شناسهی کوچیکتر رو اول میذاره تا هر جفت فقط یک رکورد داشته باشه
|
||||
func normalizePair(a, b uint) (uint, uint) {
|
||||
if a < b {
|
||||
return a, b
|
||||
}
|
||||
return b, a
|
||||
}
|
||||
|
||||
// FindOrCreate اگه مچ بین این دو کاربر از قبل وجود داشته باشه همونو برمیگردونه،
|
||||
// وگرنه میسازتش. خروجی دوم true یعنی همین الان تازه ساخته شده.
|
||||
func (r *MatchRepository) FindOrCreate(userA, userB uint) (*models.Match, bool, error) {
|
||||
one, two := normalizePair(userA, userB)
|
||||
|
||||
var match models.Match
|
||||
err := r.db.Where("user_one_id = ? AND user_two_id = ?", one, two).First(&match).Error
|
||||
if err == nil {
|
||||
return &match, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
match = models.Match{UserOneID: one, UserTwoID: two}
|
||||
if err := r.db.Create(&match).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &match, true, nil
|
||||
}
|
||||
|
||||
func (r *MatchRepository) FindByID(id uint) (*models.Match, error) {
|
||||
var match models.Match
|
||||
if err := r.db.First(&match, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &match, nil
|
||||
}
|
||||
|
||||
// ListByUser مچهای کاربر رو از جدیدترین به قدیمیترین برمیگردونه
|
||||
func (r *MatchRepository) ListByUser(userID uint) ([]models.Match, error) {
|
||||
var matches []models.Match
|
||||
err := r.db.
|
||||
Where("user_one_id = ? OR user_two_id = ?", userID, userID).
|
||||
Order("created_at desc").
|
||||
Find(&matches).Error
|
||||
return matches, err
|
||||
}
|
||||
|
||||
// ListAllPaginated همهی مچهای اپ رو صفحهبندیشده برمیگردونه (برای پنل ادمین)
|
||||
func (r *MatchRepository) ListAllPaginated(limit, offset int) ([]models.Match, int64, error) {
|
||||
var total int64
|
||||
if err := r.db.Model(&models.Match{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var matches []models.Match
|
||||
err := r.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&matches).Error
|
||||
return matches, total, err
|
||||
}
|
||||
|
||||
// OtherUserID شناسهی طرف مقابل کاربر جاری رو توی یک مچ برمیگردونه
|
||||
func OtherUserID(match *models.Match, currentUserID uint) uint {
|
||||
if match.UserOneID == currentUserID {
|
||||
return match.UserTwoID
|
||||
}
|
||||
return match.UserOneID
|
||||
}
|
||||
|
||||
// IsMember یعنی این کاربر عضو این مچه
|
||||
func IsMember(match *models.Match, userID uint) bool {
|
||||
return match.UserOneID == userID || match.UserTwoID == userID
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/models"
|
||||
)
|
||||
|
||||
type MessageRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMessageRepository(db *gorm.DB) *MessageRepository {
|
||||
return &MessageRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *MessageRepository) Create(msg *models.Message) error {
|
||||
return r.db.Create(msg).Error
|
||||
}
|
||||
|
||||
func (r *MessageRepository) ListByMatch(matchID uint) ([]models.Message, error) {
|
||||
var messages []models.Message
|
||||
err := r.db.Where("match_id = ?", matchID).Order("created_at asc").Find(&messages).Error
|
||||
return messages, err
|
||||
}
|
||||
|
||||
// LastMessage آخرین پیام یک مچ رو برمیگردونه (برای پیشنمایش لیست گفتگوها)
|
||||
func (r *MessageRepository) LastMessage(matchID uint) (*models.Message, error) {
|
||||
var msg models.Message
|
||||
err := r.db.Where("match_id = ?", matchID).Order("created_at desc").First(&msg).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// LastMessagesByMatchIDs آخرین پیام هر مچ رو، برای چند مچ با هم در یک کوئری
|
||||
// برمیگردونه (بهجای یک کوئری جدا بهازای هر مچ توی لیست گفتگوها). از
|
||||
// DISTINCT ON پستگرس استفاده میکنه که دقیقاً برای همین کار («آخرین ردیف هر
|
||||
// گروه») ساخته شده.
|
||||
func (r *MessageRepository) LastMessagesByMatchIDs(matchIDs []uint) (map[uint]models.Message, error) {
|
||||
result := make(map[uint]models.Message, len(matchIDs))
|
||||
if len(matchIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var messages []models.Message
|
||||
err := r.db.
|
||||
Distinct("ON (match_id) *").
|
||||
Where("match_id IN ?", matchIDs).
|
||||
Order("match_id, created_at desc").
|
||||
Find(&messages).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range messages {
|
||||
result[m.MatchID] = m
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/cache"
|
||||
"dating-app/internal/models"
|
||||
)
|
||||
|
||||
var ErrCategoryNotFound = errors.New("دستهی موردنظر یافت نشد")
|
||||
|
||||
// activeOptionsCacheTTL: گزینههای فعال هر دسته و خودِ رکورد دسته فقط از پنل
|
||||
// ادمین تغییر میکنن (که خیلی بهندرت اتفاق میافته) ولی توی هر مرحلهی
|
||||
// آنبوردینگ/دیسکاوری هر کاربر خونده میشن؛ کش کردنشون فشار روی دیتابیس رو
|
||||
// زیاد کم میکنه.
|
||||
const activeOptionsCacheTTL = 5 * time.Minute
|
||||
|
||||
type OptionRepository struct {
|
||||
db *gorm.DB
|
||||
activeOptionsByKey *cache.TTLCache[models.CategoryKey, []models.Option]
|
||||
categoryByKey *cache.TTLCache[models.CategoryKey, models.Category]
|
||||
}
|
||||
|
||||
func NewOptionRepository(db *gorm.DB) *OptionRepository {
|
||||
return &OptionRepository{
|
||||
db: db,
|
||||
activeOptionsByKey: cache.New[models.CategoryKey, []models.Option](activeOptionsCacheTTL),
|
||||
categoryByKey: cache.New[models.CategoryKey, models.Category](activeOptionsCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Category, error) {
|
||||
if cached, ok := r.categoryByKey.Get(key); ok {
|
||||
return &cached, nil
|
||||
}
|
||||
|
||||
var cat models.Category
|
||||
err := r.db.Where("key = ?", key).First(&cat).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrCategoryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.categoryByKey.Set(key, cat)
|
||||
return &cat, nil
|
||||
}
|
||||
|
||||
func (r *OptionRepository) UpdateCategory(cat *models.Category) error {
|
||||
if err := r.db.Save(cat).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeOptionsByKey.Delete(cat.Key)
|
||||
r.categoryByKey.Delete(cat.Key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *OptionRepository) ListActiveOptionsByCategoryKey(key models.CategoryKey) ([]models.Option, error) {
|
||||
if cached, ok := r.activeOptionsByKey.Get(key); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
cat, err := r.GetCategoryByKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var options []models.Option
|
||||
err = r.db.Where("category_id = ? AND is_active = ?", cat.ID, true).
|
||||
Order("sort_order asc, id asc").
|
||||
Find(&options).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.activeOptionsByKey.Set(key, options)
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// ListOptionsByCategoryKeyAdmin تمام گزینهها (فعال و غیرفعال) رو برمیگردونه - برای پنل ادمین
|
||||
func (r *OptionRepository) ListOptionsByCategoryKeyAdmin(key models.CategoryKey) ([]models.Option, error) {
|
||||
cat, err := r.GetCategoryByKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var options []models.Option
|
||||
err = r.db.Where("category_id = ?", cat.ID).
|
||||
Order("sort_order asc, id asc").
|
||||
Find(&options).Error
|
||||
return options, err
|
||||
}
|
||||
|
||||
func (r *OptionRepository) CreateOption(opt *models.Option) error {
|
||||
if err := r.db.Create(opt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeOptionsByKey.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *OptionRepository) FindOptionByID(id uint) (*models.Option, error) {
|
||||
var opt models.Option
|
||||
err := r.db.First(&opt, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &opt, nil
|
||||
}
|
||||
|
||||
func (r *OptionRepository) UpdateOption(opt *models.Option) error {
|
||||
if err := r.db.Save(opt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeOptionsByKey.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *OptionRepository) DeleteOption(id uint) error {
|
||||
if err := r.db.Delete(&models.Option{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeOptionsByKey.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindActiveOptionsByIDsInCategory فقط گزینههای فعالی که در این دسته هستند و id شون
|
||||
// داخل لیست دادهشده است رو برمیگردونه (برای اعتبارسنجی انتخاب کاربر)
|
||||
func (r *OptionRepository) FindActiveOptionsByIDsInCategory(categoryID uint, ids []uint) ([]models.Option, error) {
|
||||
var options []models.Option
|
||||
err := r.db.Where("category_id = ? AND is_active = ? AND id IN ?", categoryID, true, ids).
|
||||
Find(&options).Error
|
||||
return options, err
|
||||
}
|
||||
|
||||
// ReplaceUserSelections تمام انتخابهای قبلی کاربر در یک دسته را با لیست جدید جایگزین میکند (تراکنشی)
|
||||
func (r *OptionRepository) ReplaceUserSelections(userID, categoryID uint, optionIDs []uint) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("user_id = ? AND category_id = ?", userID, categoryID).
|
||||
Delete(&models.UserOptionSelection{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, optID := range optionIDs {
|
||||
sel := models.UserOptionSelection{
|
||||
UserID: userID,
|
||||
CategoryID: categoryID,
|
||||
OptionID: optID,
|
||||
}
|
||||
if err := tx.Create(&sel).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserSelections انتخابهای کاربر در یک دسته را همراه با اطلاعات گزینه برمیگرداند
|
||||
func (r *OptionRepository) GetUserSelections(userID, categoryID uint) ([]models.UserOptionSelection, error) {
|
||||
var selections []models.UserOptionSelection
|
||||
err := r.db.Preload("Option").
|
||||
Where("user_id = ? AND category_id = ?", userID, categoryID).
|
||||
Find(&selections).Error
|
||||
return selections, err
|
||||
}
|
||||
|
||||
// CountUserSelections تعداد انتخابهای ثبتشدهی کاربر در یک دسته را برمیگرداند
|
||||
// (برای بررسی اینکه مرحله تکمیل شده یا نه)
|
||||
func (r *OptionRepository) CountUserSelections(userID, categoryID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.UserOptionSelection{}).
|
||||
Where("user_id = ? AND category_id = ?", userID, categoryID).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetSelectionsForUsersInCategory انتخابهای چند کاربر رو در یک دستهی مشخص،
|
||||
// همه با هم در یک کوئری برمیگردونه (بهجای یک کوئری جدا برای هر کاربر).
|
||||
// برای فید دیسکاوری استفاده میشه که تا ۶۰ کاندیدا با هم پردازش میشن.
|
||||
func (r *OptionRepository) GetSelectionsForUsersInCategory(userIDs []uint, categoryID uint) (map[uint][]string, error) {
|
||||
result := make(map[uint][]string, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var selections []models.UserOptionSelection
|
||||
err := r.db.Preload("Option").
|
||||
Where("user_id IN ? AND category_id = ?", userIDs, categoryID).
|
||||
Find(&selections).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, sel := range selections {
|
||||
if sel.Option != nil {
|
||||
result[sel.UserID] = append(result[sel.UserID], sel.Option.Title)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetUserSelectionsForCategories انتخابهای یک کاربر رو در چند دسته با هم، در
|
||||
// یک کوئری برمیگردونه (بهجای یک کوئری جدا بهازای هر دسته). برای صفحهی
|
||||
// جزئیات پروفایل استفاده میشه که همزمان چند دسته (تحصیلات، شغل، علاقهمندی و...)
|
||||
// رو نشون میده.
|
||||
func (r *OptionRepository) GetUserSelectionsForCategories(userID uint, categoryIDs []uint) (map[uint][]models.UserOptionSelection, error) {
|
||||
result := make(map[uint][]models.UserOptionSelection)
|
||||
if len(categoryIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var selections []models.UserOptionSelection
|
||||
err := r.db.Preload("Option").
|
||||
Where("user_id = ? AND category_id IN ?", userID, categoryIDs).
|
||||
Find(&selections).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, sel := range selections {
|
||||
result[sel.CategoryID] = append(result[sel.CategoryID], sel)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/models"
|
||||
)
|
||||
|
||||
type PhotoRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPhotoRepository(db *gorm.DB) *PhotoRepository {
|
||||
return &PhotoRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *PhotoRepository) ListByUser(userID uint) ([]models.Photo, error) {
|
||||
var photos []models.Photo
|
||||
err := r.db.Where("user_id = ?", userID).Order("sort_order asc, id asc").Find(&photos).Error
|
||||
return photos, err
|
||||
}
|
||||
|
||||
func (r *PhotoRepository) ListByUserIDs(userIDs []uint) ([]models.Photo, error) {
|
||||
var photos []models.Photo
|
||||
if len(userIDs) == 0 {
|
||||
return photos, nil
|
||||
}
|
||||
err := r.db.Where("user_id IN ?", userIDs).Order("sort_order asc, id asc").Find(&photos).Error
|
||||
return photos, err
|
||||
}
|
||||
|
||||
// ListAllPaginated همهی عکسهای آپلودشدهی اپ رو صفحهبندیشده، جدیدترین اول
|
||||
// برمیگردونه (برای نظارت محتوایی پنل ادمین).
|
||||
func (r *PhotoRepository) ListAllPaginated(limit, offset int) ([]models.Photo, int64, error) {
|
||||
var total int64
|
||||
if err := r.db.Model(&models.Photo{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var photos []models.Photo
|
||||
err := r.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&photos).Error
|
||||
return photos, total, err
|
||||
}
|
||||
|
||||
func (r *PhotoRepository) CountByUser(userID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.Photo{}).Where("user_id = ?", userID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *PhotoRepository) Create(photo *models.Photo) error {
|
||||
return r.db.Create(photo).Error
|
||||
}
|
||||
|
||||
func (r *PhotoRepository) FindByID(id uint) (*models.Photo, error) {
|
||||
var photo models.Photo
|
||||
if err := r.db.First(&photo, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &photo, nil
|
||||
}
|
||||
|
||||
func (r *PhotoRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Photo{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/cache"
|
||||
"dating-app/internal/models"
|
||||
)
|
||||
|
||||
const activeQuestionsCacheTTL = 5 * time.Minute
|
||||
const activeQuestionsCacheKey = "active"
|
||||
|
||||
type QuestionRepository struct {
|
||||
db *gorm.DB
|
||||
activeCache *cache.TTLCache[string, []models.ProfileQuestion]
|
||||
}
|
||||
|
||||
func NewQuestionRepository(db *gorm.DB) *QuestionRepository {
|
||||
return &QuestionRepository{
|
||||
db: db,
|
||||
activeCache: cache.New[string, []models.ProfileQuestion](activeQuestionsCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) ListActive() ([]models.ProfileQuestion, error) {
|
||||
if cached, ok := r.activeCache.Get(activeQuestionsCacheKey); ok {
|
||||
return cached, nil
|
||||
}
|
||||
var qs []models.ProfileQuestion
|
||||
err := r.db.Where("is_active = ?", true).Order("sort_order asc, id asc").Find(&qs).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.activeCache.Set(activeQuestionsCacheKey, qs)
|
||||
return qs, nil
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) ListAllAdmin() ([]models.ProfileQuestion, error) {
|
||||
var qs []models.ProfileQuestion
|
||||
err := r.db.Order("sort_order asc, id asc").Find(&qs).Error
|
||||
return qs, err
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) FindByID(id uint) (*models.ProfileQuestion, error) {
|
||||
var q models.ProfileQuestion
|
||||
err := r.db.First(&q, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) FindActiveByIDs(ids []uint) ([]models.ProfileQuestion, error) {
|
||||
var qs []models.ProfileQuestion
|
||||
err := r.db.Where("is_active = ? AND id IN ?", true, ids).Find(&qs).Error
|
||||
return qs, err
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) Create(q *models.ProfileQuestion) error {
|
||||
if err := r.db.Create(q).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeCache.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) Update(q *models.ProfileQuestion) error {
|
||||
if err := r.db.Save(q).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeCache.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) Delete(id uint) error {
|
||||
if err := r.db.Delete(&models.ProfileQuestion{}, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.activeCache.Clear()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceUserAnswers پاسخهای قبلی کاربر رو با پاسخهای جدید جایگزین میکنه (تراکنشی)
|
||||
func (r *QuestionRepository) ReplaceUserAnswers(userID uint, answers []models.UserProfileAnswer) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&models.UserProfileAnswer{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range answers {
|
||||
answers[i].UserID = userID
|
||||
if err := tx.Create(&answers[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) GetUserAnswers(userID uint) ([]models.UserProfileAnswer, error) {
|
||||
var answers []models.UserProfileAnswer
|
||||
err := r.db.Preload("Question").Where("user_id = ?", userID).Find(&answers).Error
|
||||
return answers, err
|
||||
}
|
||||
|
||||
func (r *QuestionRepository) CountUserAnswers(userID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.UserProfileAnswer{}).Where("user_id = ?", userID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/models"
|
||||
)
|
||||
|
||||
type ReportRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewReportRepository(db *gorm.DB) *ReportRepository {
|
||||
return &ReportRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ReportRepository) Create(report *models.Report) error {
|
||||
return r.db.Create(report).Error
|
||||
}
|
||||
|
||||
func (r *ReportRepository) ListByUser(userID uint) ([]models.Report, error) {
|
||||
var reports []models.Report
|
||||
err := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&reports).Error
|
||||
return reports, err
|
||||
}
|
||||
|
||||
// CountsByUserIDs تعداد گزارشهای ثبتشده روی چند کاربر رو با هم، در یک
|
||||
// کوئری برمیگردونه (برای نشون دادن نشان تعداد گزارش روی لیست کاربران، بدون
|
||||
// یک کوئری جدا بهازای هر کاربر).
|
||||
func (r *ReportRepository) CountsByUserIDs(userIDs []uint) (map[uint]int64, error) {
|
||||
result := make(map[uint]int64, len(userIDs))
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
UserID uint
|
||||
Count int64
|
||||
}
|
||||
var rows []row
|
||||
err := r.db.Model(&models.Report{}).
|
||||
Select("user_id, count(*) as count").
|
||||
Where("user_id IN ?", userIDs).
|
||||
Group("user_id").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rr := range rows {
|
||||
result[rr.UserID] = rr.Count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user