Initial commit: dating app backend (Go/Gin/GORM/Postgres)
Phone+OTP auth, staged onboarding, admin approval workflow, and a full discovery/swipe/match/chat system with photo upload and approximate distance (haversine) based on user location. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dating-web",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["--prefix", "/run/media/mobin/project/projects/frontend/dating-app-web", "run", "dev", "--", "--port", "3000"],
|
||||||
|
"port": 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dating-admin",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["--prefix", "/run/media/mobin/project/projects/frontend/dating-app-admin", "run", "dev", "--", "--port", "3001"],
|
||||||
|
"port": 3001
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# binaries
|
||||||
|
/dating-app
|
||||||
|
/dating-app-server
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env
|
||||||
|
|
||||||
|
# فایلهای آپلودشدهی کاربران (نباید توی گیت باشن)
|
||||||
|
/uploads/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# اپلیکیشن دوستیابی — بکاند 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` |
|
||||||
|
| ۲ | قد و وزن | `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`
|
||||||
|
|
||||||
|
### بعد از تکمیل آنبوردینگ: تایید ادمین
|
||||||
|
|
||||||
|
به محض تکمیل مرحلهی ۸، `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,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/config"
|
||||||
|
"dating-app/internal/database"
|
||||||
|
"dating-app/internal/handler"
|
||||||
|
"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)
|
||||||
|
|
||||||
|
// -------- 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)
|
||||||
|
adminService := service.NewAdminService(optionRepo, questionRepo, userRepo)
|
||||||
|
profileService := service.NewProfileService(photoRepo, userRepo, photoStorage)
|
||||||
|
discoveryService := service.NewDiscoveryService(userRepo, photoRepo, swipeRepo, matchRepo, optionRepo, questionRepo)
|
||||||
|
chatService := service.NewChatService(matchRepo, messageRepo, userRepo, photoRepo)
|
||||||
|
|
||||||
|
// -------- 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),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AppEnv == "production" {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
engine := gin.Default()
|
||||||
|
router.Setup(engine, handlers, jwtManager, userRepo)
|
||||||
|
|
||||||
|
log.Printf("سرور روی پورت %s در حال اجراست (env=%s)", cfg.AppPort, cfg.AppEnv)
|
||||||
|
if err := engine.Run(":" + cfg.AppPort); err != nil {
|
||||||
|
log.Fatalf("اجرای سرور ناموفق بود: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
module dating-app
|
||||||
|
|
||||||
|
go 1.22.2
|
||||||
|
|
||||||
|
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/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/gin-gonic/gin v1.9.1 // 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/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // 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/joho/godotenv v1.5.1 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 // 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/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.20.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
|
||||||
|
gorm.io/driver/postgres v1.5.9 // indirect
|
||||||
|
gorm.io/gorm v1.25.10 // 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
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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/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/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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
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/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/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/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/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=
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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"),
|
||||||
|
|
||||||
|
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,132 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
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{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,39 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// DiscoverCandidate یک پروفایل پیشنهادی توی صفحهی دوستیابی
|
||||||
|
type DiscoverCandidate struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
Age int `json:"age"`
|
||||||
|
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 خلاصهی یک مچ برای نمایش در لیست مچها/گفتگوها
|
||||||
|
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"`
|
||||||
|
LastAt *string `json:"last_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageView یک پیام در یک گفتگو
|
||||||
|
type MessageView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
MatchID uint `json:"match_id"`
|
||||||
|
SenderID uint `json:"sender_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
IsMine bool `json:"is_mine"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageRequest بدنهی درخواست ارسال پیام
|
||||||
|
type SendMessageRequest struct {
|
||||||
|
Text string `json:"text" binding:"required,min=1,max=2000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
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,7 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// UpdateLocationRequest بهروزرسانی موقعیت مکانی کاربر (برای محاسبهی فاصلهی تقریبی)
|
||||||
|
type UpdateLocationRequest struct {
|
||||||
|
Latitude float64 `json:"latitude" binding:"required"`
|
||||||
|
Longitude float64 `json:"longitude" binding:"required"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
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, err := h.adminService.ListPendingUsers()
|
||||||
|
if err != nil {
|
||||||
|
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := make([]dto.UserProfile, 0, len(users))
|
||||||
|
for i := range users {
|
||||||
|
result = append(result, toUserProfile(&users[i]))
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- سوالات پروفایل ----------------
|
||||||
|
|
||||||
|
// 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,87 @@
|
|||||||
|
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: {text}
|
||||||
|
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.Text)
|
||||||
|
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)
|
||||||
|
default:
|
||||||
|
utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
selections := make([]selectionSummary, 0, len(models.AllCategoryKeys))
|
||||||
|
for _, key := range models.AllCategoryKeys {
|
||||||
|
cat, err := optionRepo.GetCategoryByKey(key)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sels, err := optionRepo.GetUserSelections(user.ID, cat.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary := selectionSummary{CategoryKey: string(key)}
|
||||||
|
for _, s := range sels {
|
||||||
|
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,228 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,140 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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,51 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,70 @@
|
|||||||
|
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 یک پیام متنی داخل یک مچ
|
||||||
|
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"`
|
||||||
|
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,110 @@
|
|||||||
|
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
|
||||||
|
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"},
|
||||||
|
{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"`
|
||||||
|
|
||||||
|
// فیلدهای 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"`
|
||||||
|
|
||||||
|
// -------- موقعیت مکانی (برای محاسبهی فاصلهی تقریبی در دوستیابی) --------
|
||||||
|
Latitude *float64 `json:"-"`
|
||||||
|
Longitude *float64 `json:"-"`
|
||||||
|
|
||||||
|
// -------- وضعیت آنبوردینگ --------
|
||||||
|
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,77 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,35 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrCategoryNotFound = errors.New("دستهی موردنظر یافت نشد")
|
||||||
|
|
||||||
|
type OptionRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOptionRepository(db *gorm.DB) *OptionRepository {
|
||||||
|
return &OptionRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Category, error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return &cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) UpdateCategory(cat *models.Category) error {
|
||||||
|
return r.db.Save(cat).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) ListActiveOptionsByCategoryKey(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 = ? AND is_active = ?", cat.ID, true).
|
||||||
|
Order("sort_order asc, id asc").
|
||||||
|
Find(&options).Error
|
||||||
|
return options, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return r.db.Create(opt).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return r.db.Save(opt).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OptionRepository) DeleteOption(id uint) error {
|
||||||
|
return r.db.Delete(&models.Option{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,82 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QuestionRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQuestionRepository(db *gorm.DB) *QuestionRepository {
|
||||||
|
return &QuestionRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) ListActive() ([]models.ProfileQuestion, error) {
|
||||||
|
var qs []models.ProfileQuestion
|
||||||
|
err := r.db.Where("is_active = ?", true).Order("sort_order asc, id asc").Find(&qs).Error
|
||||||
|
return qs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return r.db.Create(q).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) Update(q *models.ProfileQuestion) error {
|
||||||
|
return r.db.Save(q).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *QuestionRepository) Delete(id uint) error {
|
||||||
|
return r.db.Delete(&models.ProfileQuestion{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,42 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SwipeRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSwipeRepository(db *gorm.DB) *SwipeRepository {
|
||||||
|
return &SwipeRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SwipeRepository) Create(swipe *models.Swipe) error {
|
||||||
|
return r.db.Create(swipe).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasLiked یعنی swiperID قبلاً swipedID رو لایک/سوپرلایک کرده
|
||||||
|
func (r *SwipeRepository) HasLiked(swiperID, swipedID uint) (bool, error) {
|
||||||
|
var swipe models.Swipe
|
||||||
|
err := r.db.
|
||||||
|
Where("swiper_id = ? AND swiped_id = ? AND action IN ?", swiperID, swipedID, []models.SwipeAction{models.SwipeLike, models.SwipeSuperlike}).
|
||||||
|
First(&swipe).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SwipeRepository) Exists(swiperID, swipedID uint) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.Swipe{}).Where("swiper_id = ? AND swiped_id = ?", swiperID, swipedID).Count(&count).Error
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||||
|
return &UserRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) FindByPhone(phone string) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
err := r.db.Where("phone = ?", phone).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) FindByID(id uint) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
err := r.db.First(&user, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateByPhone کاربر را با شمارهی دادهشده پیدا میکند یا در صورت نبود میسازد.
|
||||||
|
func (r *UserRepository) GetOrCreateByPhone(phone string) (*models.User, bool, error) {
|
||||||
|
var user models.User
|
||||||
|
err := r.db.Where("phone = ?", phone).First(&user).Error
|
||||||
|
if err == nil {
|
||||||
|
return &user, false, nil
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user = models.User{
|
||||||
|
Phone: phone,
|
||||||
|
State: models.UserStateInactive,
|
||||||
|
IsActive: true,
|
||||||
|
CurrentStep: models.StepBasicInfo,
|
||||||
|
}
|
||||||
|
if err := r.db.Create(&user).Error; err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return &user, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPendingApproval کاربرانی که آنبوردینگ رو تکمیل کردن و منتظر بررسی ادمیناند
|
||||||
|
// رو به ترتیب قدیمیترین اول برمیگردونه.
|
||||||
|
func (r *UserRepository) ListPendingApproval() ([]models.User, error) {
|
||||||
|
var users []models.User
|
||||||
|
err := r.db.
|
||||||
|
Where("is_onboarded = ? AND approval_status = ?", true, models.ApprovalPending).
|
||||||
|
Order("updated_at asc").
|
||||||
|
Find(&users).Error
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDiscoverable کاندیداهای بخش دوستیابی رو برمیگردونه: تاییدشده، فعال،
|
||||||
|
// جنسیت مخالف کاربر (بهعنوان سادهترین فرض پیشفرض؛ فیلد ترجیح جنسیتی/گرایش
|
||||||
|
// هنوز توی سیستم وجود نداره) و کسایی که قبلاً روشون سواپ نزده.
|
||||||
|
// چون فاصله باید توی Go محاسبه بشه (haversine)، یک batch بزرگتر از حد
|
||||||
|
// نهایی برمیگردونه تا بعد از فیلتر فاصله همچنان گزینهی کافی بمونه.
|
||||||
|
func (r *UserRepository) ListDiscoverable(currentUserID uint, oppositeGender models.Gender, minAge, maxAge int, batchSize int) ([]models.User, error) {
|
||||||
|
var users []models.User
|
||||||
|
now := time.Now()
|
||||||
|
minBirth := now.AddDate(-maxAge-1, 0, 1)
|
||||||
|
maxBirth := now.AddDate(-minAge, 0, 0)
|
||||||
|
|
||||||
|
err := r.db.
|
||||||
|
Where("id != ?", currentUserID).
|
||||||
|
Where("is_onboarded = ? AND approval_status = ? AND is_active = ?", true, models.ApprovalApproved, true).
|
||||||
|
Where("gender = ?", oppositeGender).
|
||||||
|
Where("birth_date BETWEEN ? AND ?", minBirth, maxBirth).
|
||||||
|
Where("NOT EXISTS (SELECT 1 FROM swipes s WHERE s.swiper_id = ? AND s.swiped_id = users.id)", currentUserID).
|
||||||
|
Order("id asc").
|
||||||
|
Limit(batchSize).
|
||||||
|
Find(&users).Error
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Save(user *models.User) error {
|
||||||
|
return r.db.Save(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) UpdateFields(user *models.User, fields map[string]interface{}) error {
|
||||||
|
return r.db.Model(user).Updates(fields).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) DB() *gorm.DB {
|
||||||
|
return r.db
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"dating-app/internal/handler"
|
||||||
|
"dating-app/internal/middleware"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handlers struct {
|
||||||
|
Auth *handler.AuthHandler
|
||||||
|
Onboarding *handler.OnboardingHandler
|
||||||
|
Profile *handler.ProfileHandler
|
||||||
|
Admin *handler.AdminHandler
|
||||||
|
Discovery *handler.DiscoveryHandler
|
||||||
|
Chat *handler.ChatHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func Setup(
|
||||||
|
engine *gin.Engine,
|
||||||
|
h Handlers,
|
||||||
|
jwtManager *utils.JWTManager,
|
||||||
|
userRepo *repository.UserRepository,
|
||||||
|
) {
|
||||||
|
engine.Use(middleware.CORS())
|
||||||
|
|
||||||
|
engine.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// فایلهای عکس آپلودشده (خارج از /api/v1، چون فایل استاتیکه نه API)
|
||||||
|
engine.Static("/uploads", "./uploads")
|
||||||
|
|
||||||
|
api := engine.Group("/api/v1")
|
||||||
|
|
||||||
|
// -------- احراز هویت (عمومی) --------
|
||||||
|
auth := api.Group("/auth")
|
||||||
|
{
|
||||||
|
auth.POST("/request-otp", h.Auth.RequestOTP)
|
||||||
|
auth.POST("/verify-otp", h.Auth.VerifyOTP)
|
||||||
|
auth.POST("/token/refresh", h.Auth.RefreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
authRequired := middleware.AuthRequired(jwtManager, userRepo)
|
||||||
|
|
||||||
|
// -------- پروفایل --------
|
||||||
|
profile := api.Group("/profile", authRequired)
|
||||||
|
{
|
||||||
|
profile.GET("/me", h.Profile.Me)
|
||||||
|
profile.POST("/photos", h.Profile.UploadPhoto)
|
||||||
|
profile.DELETE("/photos/:id", h.Profile.DeletePhoto)
|
||||||
|
profile.PATCH("/location", h.Profile.UpdateLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- آنبوردینگ (نیاز به ورود) --------
|
||||||
|
onboarding := api.Group("/onboarding", authRequired)
|
||||||
|
{
|
||||||
|
onboarding.GET("/status", h.Onboarding.Status)
|
||||||
|
onboarding.POST("/basic-info", h.Onboarding.BasicInfo)
|
||||||
|
onboarding.POST("/body-info", h.Onboarding.BodyInfo)
|
||||||
|
onboarding.GET("/profile-questions", h.Onboarding.AvailableQuestions)
|
||||||
|
onboarding.POST("/profile-questions", h.Onboarding.SetProfileQuestions)
|
||||||
|
onboarding.POST("/education", h.Onboarding.Education)
|
||||||
|
onboarding.POST("/occupations", h.Onboarding.Occupations)
|
||||||
|
onboarding.POST("/relationship-importance", h.Onboarding.RelationshipImportance)
|
||||||
|
onboarding.POST("/interests", h.Onboarding.Interests)
|
||||||
|
onboarding.POST("/looking-for", h.Onboarding.LookingFor)
|
||||||
|
// لیست گزینههای فعال هر دسته، برای نمایش در فرم هر مرحله
|
||||||
|
// category یکی از: education_level, field_of_study, occupation,
|
||||||
|
// important_in_relationship, interest, looking_for
|
||||||
|
onboarding.GET("/options/:category", h.Onboarding.Options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- دوستیابی (نیاز به ورود) --------
|
||||||
|
api.GET("/discover", authRequired, h.Discovery.Feed)
|
||||||
|
api.GET("/discover/:id", authRequired, h.Discovery.CandidateDetail)
|
||||||
|
api.POST("/discover/:id/swipe", authRequired, h.Discovery.Swipe)
|
||||||
|
|
||||||
|
// -------- مچها و گفتگو (نیاز به ورود) --------
|
||||||
|
matches := api.Group("/matches", authRequired)
|
||||||
|
{
|
||||||
|
matches.GET("", h.Chat.ListMatches)
|
||||||
|
matches.GET("/:id/messages", h.Chat.ListMessages)
|
||||||
|
matches.POST("/:id/messages", h.Chat.SendMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- پنل ادمین (نیاز به ورود + نقش ادمین) --------
|
||||||
|
admin := api.Group("/admin", authRequired, middleware.AdminRequired())
|
||||||
|
{
|
||||||
|
admin.GET("/users/pending", h.Admin.ListPendingUsers)
|
||||||
|
admin.GET("/users/:id", h.Admin.GetUserDetail)
|
||||||
|
admin.POST("/users/:id/approve", h.Admin.ApproveUser)
|
||||||
|
admin.POST("/users/:id/reject", h.Admin.RejectUser)
|
||||||
|
|
||||||
|
admin.GET("/categories/:key", h.Admin.GetCategory)
|
||||||
|
admin.PATCH("/categories/:key", h.Admin.UpdateCategory)
|
||||||
|
admin.GET("/categories/:key/options", h.Admin.ListOptions)
|
||||||
|
admin.POST("/categories/:key/options", h.Admin.CreateOption)
|
||||||
|
admin.PATCH("/options/:id", h.Admin.UpdateOption)
|
||||||
|
admin.DELETE("/options/:id", h.Admin.DeleteOption)
|
||||||
|
|
||||||
|
admin.GET("/profile-questions", h.Admin.ListQuestions)
|
||||||
|
admin.POST("/profile-questions", h.Admin.CreateQuestion)
|
||||||
|
admin.PATCH("/profile-questions/:id", h.Admin.UpdateQuestion)
|
||||||
|
admin.DELETE("/profile-questions/:id", h.Admin.DeleteQuestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUserNotOnboarded = errors.New("کاربر هنوز آنبوردینگ را تکمیل نکرده است")
|
||||||
|
|
||||||
|
type AdminService struct {
|
||||||
|
optionRepo *repository.OptionRepository
|
||||||
|
questionRepo *repository.QuestionRepository
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdminService(
|
||||||
|
optionRepo *repository.OptionRepository,
|
||||||
|
questionRepo *repository.QuestionRepository,
|
||||||
|
userRepo *repository.UserRepository,
|
||||||
|
) *AdminService {
|
||||||
|
return &AdminService{optionRepo: optionRepo, questionRepo: questionRepo, userRepo: userRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- بررسی و تایید کاربران ----------------
|
||||||
|
|
||||||
|
// GetUser یک کاربر رو برای نمایش به ادمین (بررسی درخواست) برمیگردونه
|
||||||
|
func (s *AdminService) GetUser(id uint) (*models.User, error) {
|
||||||
|
return s.userRepo.FindByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPendingUsers کاربرانی که آنبوردینگ رو تکمیل کردن و منتظر بررسیاند
|
||||||
|
func (s *AdminService) ListPendingUsers() ([]models.User, error) {
|
||||||
|
return s.userRepo.ListPendingApproval()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproveUser کاربر رو تایید میکنه تا بتونه وارد صفحهی دوستیابی بشه
|
||||||
|
func (s *AdminService) ApproveUser(id uint) (*models.User, error) {
|
||||||
|
user, err := s.userRepo.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !user.IsOnboarded {
|
||||||
|
return nil, ErrUserNotOnboarded
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"approval_status": models.ApprovalApproved,
|
||||||
|
"rejection_reason": nil,
|
||||||
|
"reviewed_at": now,
|
||||||
|
}
|
||||||
|
if err := s.userRepo.UpdateFields(user, updates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user.ApprovalStatus = models.ApprovalApproved
|
||||||
|
user.RejectionReason = nil
|
||||||
|
user.ReviewedAt = &now
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RejectUser درخواست عضویت کاربر رو با یک دلیل مشخص رد میکنه
|
||||||
|
func (s *AdminService) RejectUser(id uint, reason string) (*models.User, error) {
|
||||||
|
user, err := s.userRepo.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !user.IsOnboarded {
|
||||||
|
return nil, ErrUserNotOnboarded
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"approval_status": models.ApprovalRejected,
|
||||||
|
"rejection_reason": reason,
|
||||||
|
"reviewed_at": now,
|
||||||
|
}
|
||||||
|
if err := s.userRepo.UpdateFields(user, updates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user.ApprovalStatus = models.ApprovalRejected
|
||||||
|
user.RejectionReason = &reason
|
||||||
|
user.ReviewedAt = &now
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- دستهها و گزینهها ----------------
|
||||||
|
|
||||||
|
func (s *AdminService) GetCategory(key models.CategoryKey) (*models.Category, error) {
|
||||||
|
return s.optionRepo.GetCategoryByKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) UpdateCategory(key models.CategoryKey, req dto.UpdateCategoryRequest) (*models.Category, error) {
|
||||||
|
cat, err := s.optionRepo.GetCategoryByKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Title != nil {
|
||||||
|
cat.Title = *req.Title
|
||||||
|
}
|
||||||
|
if req.MinSelect != nil {
|
||||||
|
cat.MinSelect = *req.MinSelect
|
||||||
|
}
|
||||||
|
if req.MaxSelect != nil {
|
||||||
|
cat.MaxSelect = *req.MaxSelect
|
||||||
|
}
|
||||||
|
if err := s.optionRepo.UpdateCategory(cat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) ListOptions(key models.CategoryKey) ([]models.Option, error) {
|
||||||
|
return s.optionRepo.ListOptionsByCategoryKeyAdmin(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) CreateOption(key models.CategoryKey, req dto.CreateOptionRequest) (*models.Option, error) {
|
||||||
|
cat, err := s.optionRepo.GetCategoryByKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opt := &models.Option{
|
||||||
|
CategoryID: cat.ID,
|
||||||
|
Title: req.Title,
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := s.optionRepo.CreateOption(opt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return opt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) UpdateOption(id uint, req dto.UpdateOptionRequest) (*models.Option, error) {
|
||||||
|
opt, err := s.optionRepo.FindOptionByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Title != nil {
|
||||||
|
opt.Title = *req.Title
|
||||||
|
}
|
||||||
|
if req.SortOrder != nil {
|
||||||
|
opt.SortOrder = *req.SortOrder
|
||||||
|
}
|
||||||
|
if req.IsActive != nil {
|
||||||
|
opt.IsActive = *req.IsActive
|
||||||
|
}
|
||||||
|
if err := s.optionRepo.UpdateOption(opt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return opt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) DeleteOption(id uint) error {
|
||||||
|
return s.optionRepo.DeleteOption(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- سوالات پروفایل ----------------
|
||||||
|
|
||||||
|
func (s *AdminService) ListQuestions() ([]models.ProfileQuestion, error) {
|
||||||
|
return s.questionRepo.ListAllAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) CreateQuestion(req dto.CreateProfileQuestionRequest) (*models.ProfileQuestion, error) {
|
||||||
|
q := &models.ProfileQuestion{
|
||||||
|
Text: req.Text,
|
||||||
|
SortOrder: req.SortOrder,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := s.questionRepo.Create(q); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) UpdateQuestion(id uint, req dto.UpdateProfileQuestionRequest) (*models.ProfileQuestion, error) {
|
||||||
|
q, err := s.questionRepo.FindByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Text != nil {
|
||||||
|
q.Text = *req.Text
|
||||||
|
}
|
||||||
|
if req.SortOrder != nil {
|
||||||
|
q.SortOrder = *req.SortOrder
|
||||||
|
}
|
||||||
|
if req.IsActive != nil {
|
||||||
|
q.IsActive = *req.IsActive
|
||||||
|
}
|
||||||
|
if err := s.questionRepo.Update(q); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdminService) DeleteQuestion(id uint) error {
|
||||||
|
return s.questionRepo.Delete(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPhoneBlocked = errors.New("حساب کاربری مسدود شده است")
|
||||||
|
ErrOTPStillValid = errors.New("کد تایید قبلی هنوز معتبر است")
|
||||||
|
ErrOTPNotRequested = errors.New("ابتدا باید کد تایید را درخواست کنید")
|
||||||
|
ErrOTPExpired = errors.New("کد تایید منقضی شده است")
|
||||||
|
ErrOTPInvalid = errors.New("کد وارد شده نامعتبر است")
|
||||||
|
ErrSMSSendFailed = errors.New("ارسال پیامک با خطا مواجه شد؛ لطفاً چند لحظه دیگر دوباره تلاش کنید")
|
||||||
|
ErrUserNotFound = errors.New("کاربر یافت نشد")
|
||||||
|
)
|
||||||
|
|
||||||
|
// OTPStillValidError برای برگردوندن تعداد ثانیهی باقیمانده تا انقضای کد قبلی
|
||||||
|
type OTPStillValidError struct {
|
||||||
|
RemainingSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *OTPStillValidError) Error() string {
|
||||||
|
return "کد تایید قبلی هنوز معتبر است"
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
otpSender OTPSender
|
||||||
|
jwtManager *utils.JWTManager
|
||||||
|
|
||||||
|
otpExpiry time.Duration
|
||||||
|
otpLength int
|
||||||
|
otpHashSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthService(
|
||||||
|
userRepo *repository.UserRepository,
|
||||||
|
otpSender OTPSender,
|
||||||
|
jwtManager *utils.JWTManager,
|
||||||
|
otpExpirySeconds int,
|
||||||
|
otpLength int,
|
||||||
|
otpHashSecret string,
|
||||||
|
) *AuthService {
|
||||||
|
return &AuthService{
|
||||||
|
userRepo: userRepo,
|
||||||
|
otpSender: otpSender,
|
||||||
|
jwtManager: jwtManager,
|
||||||
|
otpExpiry: time.Duration(otpExpirySeconds) * time.Second,
|
||||||
|
otpLength: otpLength,
|
||||||
|
otpHashSecret: otpHashSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestOTP یک کد تایید جدید تولید، هش و ذخیره میکند و از طریق OTPSender ارسال میکند.
|
||||||
|
func (s *AuthService) RequestOTP(phone string) error {
|
||||||
|
if err := utils.ValidatePhone(phone); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, _, err := s.userRepo.GetOrCreateByPhone(phone)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.State == models.UserStateBlocked {
|
||||||
|
return ErrPhoneBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
// اگر کد قبلی هنوز معتبره، اجازهی ارسال دوباره نده
|
||||||
|
if user.OTPCode != nil && user.OTPExpiry != nil && time.Now().Before(*user.OTPExpiry) {
|
||||||
|
remaining := int(time.Until(*user.OTPExpiry).Seconds())
|
||||||
|
return &OTPStillValidError{RemainingSeconds: remaining}
|
||||||
|
}
|
||||||
|
|
||||||
|
otp := utils.GenerateOTP(s.otpLength)
|
||||||
|
hashed := utils.HashOTP(otp, s.otpHashSecret)
|
||||||
|
expiry := time.Now().Add(s.otpExpiry)
|
||||||
|
|
||||||
|
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
|
||||||
|
"otp_code": hashed,
|
||||||
|
"otp_expiry": expiry,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ارسال پیامک بهصورت sync؛ اگه واقعاً شکست خورد، صادقانه به کاربر خبر بده
|
||||||
|
// و کد ذخیرهشده رو پاک کن تا کاربر بلافاصله بتونه دوباره تلاش کنه
|
||||||
|
// (وگرنه تا پایان OTPExpiry با پیام «کد قبلی هنوز معتبره» قفل میمونه).
|
||||||
|
if ok := s.otpSender.Send(phone, otp); !ok {
|
||||||
|
_ = s.userRepo.UpdateFields(user, map[string]interface{}{
|
||||||
|
"otp_code": nil,
|
||||||
|
"otp_expiry": nil,
|
||||||
|
})
|
||||||
|
return ErrSMSSendFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyOTP کد را بررسی میکند و در صورت صحت، کاربر را فعال کرده و توکن صادر میکند.
|
||||||
|
func (s *AuthService) VerifyOTP(phone, otp string) (*models.User, string, string, bool, error) {
|
||||||
|
user, err := s.userRepo.FindByPhone(phone)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, "", "", false, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, "", "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.OTPCode == nil || user.OTPExpiry == nil {
|
||||||
|
return nil, "", "", false, ErrOTPNotRequested
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(*user.OTPExpiry) {
|
||||||
|
_ = s.userRepo.UpdateFields(user, map[string]interface{}{
|
||||||
|
"otp_code": nil,
|
||||||
|
"otp_expiry": nil,
|
||||||
|
})
|
||||||
|
return nil, "", "", false, ErrOTPExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
if *user.OTPCode != utils.HashOTP(otp, s.otpHashSecret) {
|
||||||
|
return nil, "", "", false, ErrOTPInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
isNewUser := user.State == models.UserStateInactive
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"otp_code": nil,
|
||||||
|
"otp_expiry": nil,
|
||||||
|
"state": models.UserStateActive,
|
||||||
|
"last_login": now,
|
||||||
|
}
|
||||||
|
if err := s.userRepo.UpdateFields(user, updates); err != nil {
|
||||||
|
return nil, "", "", false, err
|
||||||
|
}
|
||||||
|
// رفرش شیء user در حافظه برای برگردوندن مقادیر بهروز
|
||||||
|
user.State = models.UserStateActive
|
||||||
|
user.LastLogin = &now
|
||||||
|
user.OTPCode = nil
|
||||||
|
user.OTPExpiry = nil
|
||||||
|
|
||||||
|
access, err := s.jwtManager.GenerateAccessToken(user.ID, user.IsAdmin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", false, err
|
||||||
|
}
|
||||||
|
refresh, err := s.jwtManager.GenerateRefreshToken(user.ID, user.IsAdmin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, access, refresh, isNewUser, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokens با یک refresh token معتبر، یک جفت توکن جدید صادر میکند.
|
||||||
|
func (s *AuthService) RefreshTokens(refreshToken string) (string, string, error) {
|
||||||
|
claims, err := s.jwtManager.ParseRefreshToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.userRepo.FindByID(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", ErrUserNotFound
|
||||||
|
}
|
||||||
|
if user.State == models.UserStateBlocked || !user.IsActive {
|
||||||
|
return "", "", ErrPhoneBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
access, err := s.jwtManager.GenerateAccessToken(user.ID, user.IsAdmin)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
newRefresh, err := s.jwtManager.GenerateRefreshToken(user.ID, user.IsAdmin)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return access, newRefresh, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
|
||||||
|
|
||||||
|
type ChatService struct {
|
||||||
|
matchRepo *repository.MatchRepository
|
||||||
|
messageRepo *repository.MessageRepository
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
photoRepo *repository.PhotoRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChatService(
|
||||||
|
matchRepo *repository.MatchRepository,
|
||||||
|
messageRepo *repository.MessageRepository,
|
||||||
|
userRepo *repository.UserRepository,
|
||||||
|
photoRepo *repository.PhotoRepository,
|
||||||
|
) *ChatService {
|
||||||
|
return &ChatService{
|
||||||
|
matchRepo: matchRepo,
|
||||||
|
messageRepo: messageRepo,
|
||||||
|
userRepo: userRepo,
|
||||||
|
photoRepo: photoRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMatches همهی مچهای کاربر رو همراه با اطلاعات طرف مقابل و آخرین پیام برمیگردونه
|
||||||
|
func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) {
|
||||||
|
matches, err := s.matchRepo.ListByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]dto.MatchView, 0, len(matches))
|
||||||
|
for _, m := range matches {
|
||||||
|
otherID := repository.OtherUserID(&m, userID)
|
||||||
|
other, err := s.userRepo.FindByID(otherID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
photos, _ := s.photoRepo.ListByUser(otherID)
|
||||||
|
urls := make([]string, len(photos))
|
||||||
|
for i, p := range photos {
|
||||||
|
urls[i] = p.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
view := dto.MatchView{
|
||||||
|
ID: m.ID,
|
||||||
|
UserID: other.ID,
|
||||||
|
FirstName: other.FirstName,
|
||||||
|
Photos: urls,
|
||||||
|
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if last, err := s.messageRepo.LastMessage(m.ID); err == nil {
|
||||||
|
text := last.Text
|
||||||
|
at := last.CreatedAt.Format(time.RFC3339)
|
||||||
|
view.LastMessage = &text
|
||||||
|
view.LastAt = &at
|
||||||
|
}
|
||||||
|
views = append(views, view)
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChatService) getAuthorizedMatch(userID, matchID uint) (uint, error) {
|
||||||
|
match, err := s.matchRepo.FindByID(matchID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !repository.IsMember(match, userID) {
|
||||||
|
return 0, ErrNotMatchMember
|
||||||
|
}
|
||||||
|
return match.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMessages پیامهای یک مچ رو برمیگردونه؛ فقط اگه کاربر عضو اون مچ باشه.
|
||||||
|
func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, error) {
|
||||||
|
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
messages, err := s.messageRepo.ListByMatch(matchID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]dto.MessageView, 0, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
views = append(views, dto.MessageView{
|
||||||
|
ID: m.ID,
|
||||||
|
MatchID: m.MatchID,
|
||||||
|
SenderID: m.SenderID,
|
||||||
|
Text: m.Text,
|
||||||
|
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||||||
|
IsMine: m.SenderID == userID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage یک پیام جدید توی یک مچ ثبت میکنه؛ فقط اگه کاربر عضو اون مچ باشه.
|
||||||
|
func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.MessageView, error) {
|
||||||
|
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &models.Message{MatchID: matchID, SenderID: userID, Text: text}
|
||||||
|
if err := s.messageRepo.Create(msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.MessageView{
|
||||||
|
ID: msg.ID,
|
||||||
|
MatchID: msg.MatchID,
|
||||||
|
SenderID: msg.SenderID,
|
||||||
|
Text: msg.Text,
|
||||||
|
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
||||||
|
IsMine: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
"dating-app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultMinAge = 18
|
||||||
|
DefaultMaxAge = 60
|
||||||
|
DiscoverBatchSize = 60
|
||||||
|
DiscoverResultSize = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrGenderNotSet = errors.New("برای دیدن پیشنهادها اول باید جنسیتت مشخص شده باشه")
|
||||||
|
ErrCannotSwipeSelf = errors.New("نمیتونی روی خودت سواپ بزنی")
|
||||||
|
ErrAlreadySwiped = errors.New("قبلاً روی این پروفایل تصمیم گرفتی")
|
||||||
|
)
|
||||||
|
|
||||||
|
// جنسیت مخالف کاربر رو برمیگردونه. این سادهترین فرض ممکنه (هترو دیفالت)؛
|
||||||
|
// سیستم فعلاً فیلد جداگانهای برای «ترجیح جنسیتی/گرایش» نداره.
|
||||||
|
func oppositeGender(g models.Gender) models.Gender {
|
||||||
|
if g == models.GenderFemale {
|
||||||
|
return models.GenderMale
|
||||||
|
}
|
||||||
|
return models.GenderFemale
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoveryService struct {
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
photoRepo *repository.PhotoRepository
|
||||||
|
swipeRepo *repository.SwipeRepository
|
||||||
|
matchRepo *repository.MatchRepository
|
||||||
|
optionRepo *repository.OptionRepository
|
||||||
|
questionRepo *repository.QuestionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiscoveryService(
|
||||||
|
userRepo *repository.UserRepository,
|
||||||
|
photoRepo *repository.PhotoRepository,
|
||||||
|
swipeRepo *repository.SwipeRepository,
|
||||||
|
matchRepo *repository.MatchRepository,
|
||||||
|
optionRepo *repository.OptionRepository,
|
||||||
|
questionRepo *repository.QuestionRepository,
|
||||||
|
) *DiscoveryService {
|
||||||
|
return &DiscoveryService{
|
||||||
|
userRepo: userRepo,
|
||||||
|
photoRepo: photoRepo,
|
||||||
|
swipeRepo: swipeRepo,
|
||||||
|
matchRepo: matchRepo,
|
||||||
|
optionRepo: optionRepo,
|
||||||
|
questionRepo: questionRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type candidateWithDistance struct {
|
||||||
|
user models.User
|
||||||
|
distance *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFeed لیست پیشنهادهای صفحهی دوستیابی رو برمیگردونه؛ نزدیکترینها اول.
|
||||||
|
func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, minAge, maxAge int) ([]dto.DiscoverCandidate, error) {
|
||||||
|
if user.Gender == nil {
|
||||||
|
return nil, ErrGenderNotSet
|
||||||
|
}
|
||||||
|
if minAge <= 0 {
|
||||||
|
minAge = DefaultMinAge
|
||||||
|
}
|
||||||
|
if maxAge <= 0 || maxAge < minAge {
|
||||||
|
maxAge = DefaultMaxAge
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := s.userRepo.ListDiscoverable(user.ID, oppositeGender(*user.Gender), minAge, maxAge, DiscoverBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return []dto.DiscoverCandidate{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]uint, len(candidates))
|
||||||
|
for i, c := range candidates {
|
||||||
|
ids[i] = c.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
photos, err := s.photoRepo.ListByUserIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
photosByUser := map[uint][]string{}
|
||||||
|
for _, p := range photos {
|
||||||
|
photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
interestsByUser := s.batchInterests(ids)
|
||||||
|
|
||||||
|
withDist := make([]candidateWithDistance, 0, len(candidates))
|
||||||
|
for _, cand := range candidates {
|
||||||
|
var dist *float64
|
||||||
|
if user.Latitude != nil && user.Longitude != nil && cand.Latitude != nil && cand.Longitude != nil {
|
||||||
|
km := math.Round(utils.HaversineKM(*user.Latitude, *user.Longitude, *cand.Latitude, *cand.Longitude))
|
||||||
|
dist = &km
|
||||||
|
}
|
||||||
|
if maxDistanceKM != nil && dist != nil && *dist > *maxDistanceKM {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
withDist = append(withDist, candidateWithDistance{user: cand, distance: dist})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(withDist, func(i, j int) bool {
|
||||||
|
if withDist[i].distance == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if withDist[j].distance == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *withDist[i].distance < *withDist[j].distance
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(withDist) > DiscoverResultSize {
|
||||||
|
withDist = withDist[:DiscoverResultSize]
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]dto.DiscoverCandidate, 0, len(withDist))
|
||||||
|
for _, wd := range withDist {
|
||||||
|
result = append(result, dto.DiscoverCandidate{
|
||||||
|
ID: wd.user.ID,
|
||||||
|
FirstName: wd.user.FirstName,
|
||||||
|
Age: utils.CalcAge(wd.user.BirthDate),
|
||||||
|
Photos: photosByUser[wd.user.ID],
|
||||||
|
DistanceKM: wd.distance,
|
||||||
|
Interests: interestsByUser[wd.user.ID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiscoveryService) batchInterests(userIDs []uint) map[uint][]string {
|
||||||
|
result := map[uint][]string{}
|
||||||
|
cat, err := s.optionRepo.GetCategoryByKey(models.CategoryInterest)
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, id := range userIDs {
|
||||||
|
sels, err := s.optionRepo.GetUserSelections(id, cat.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, sel := range sels {
|
||||||
|
if sel.Option != nil {
|
||||||
|
result[id] = append(result[id], sel.Option.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe یک تصمیم (لایک/ردّ/سوپرلایک) ثبت میکنه و در صورت لایک متقابل، مچ میسازه.
|
||||||
|
func (s *DiscoveryService) Swipe(user *models.User, targetID uint, action models.SwipeAction) (*dto.SwipeResponse, error) {
|
||||||
|
if targetID == user.ID {
|
||||||
|
return nil, ErrCannotSwipeSelf
|
||||||
|
}
|
||||||
|
target, err := s.userRepo.FindByID(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
alreadySwiped, err := s.swipeRepo.Exists(user.ID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if alreadySwiped {
|
||||||
|
return nil, ErrAlreadySwiped
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.swipeRepo.Create(&models.Swipe{SwiperID: user.ID, SwipedID: targetID, Action: action}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == models.SwipePass {
|
||||||
|
return &dto.SwipeResponse{Matched: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
theyLikedMe, err := s.swipeRepo.HasLiked(targetID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !theyLikedMe {
|
||||||
|
return &dto.SwipeResponse{Matched: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
match, _, err := s.matchRepo.FindOrCreate(user.ID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
photos, _ := s.photoRepo.ListByUser(target.ID)
|
||||||
|
urls := make([]string, len(photos))
|
||||||
|
for i, p := range photos {
|
||||||
|
urls[i] = p.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.SwipeResponse{
|
||||||
|
Matched: true,
|
||||||
|
Match: &dto.MatchView{
|
||||||
|
ID: match.ID,
|
||||||
|
UserID: target.ID,
|
||||||
|
FirstName: target.FirstName,
|
||||||
|
Photos: urls,
|
||||||
|
CreatedAt: match.CreatedAt.Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCandidateDetail پروفایل کامل یک کاربر دیگه رو برای نمایش صفحهی جزئیات برمیگردونه
|
||||||
|
func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint) (*dto.CandidateDetail, error) {
|
||||||
|
target, err := s.userRepo.FindByID(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !target.IsOnboarded || target.ApprovalStatus != models.ApprovalApproved || !target.IsActive {
|
||||||
|
return nil, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
photos, _ := s.photoRepo.ListByUser(target.ID)
|
||||||
|
urls := make([]string, len(photos))
|
||||||
|
for i, p := range photos {
|
||||||
|
urls[i] = p.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
var distance *float64
|
||||||
|
if viewer.Latitude != nil && viewer.Longitude != nil && target.Latitude != nil && target.Longitude != nil {
|
||||||
|
km := math.Round(utils.HaversineKM(*viewer.Latitude, *viewer.Longitude, *target.Latitude, *target.Longitude))
|
||||||
|
distance = &km
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := &dto.CandidateDetail{
|
||||||
|
ID: target.ID,
|
||||||
|
FirstName: target.FirstName,
|
||||||
|
Age: utils.CalcAge(target.BirthDate),
|
||||||
|
Photos: urls,
|
||||||
|
DistanceKM: distance,
|
||||||
|
HeightCM: target.HeightCM,
|
||||||
|
WeightKG: target.WeightKG,
|
||||||
|
}
|
||||||
|
|
||||||
|
detail.Education = s.firstSelectionTitle(target.ID, models.CategoryEducationLevel)
|
||||||
|
detail.FieldOfStudy = s.firstSelectionTitle(target.ID, models.CategoryFieldOfStudy)
|
||||||
|
detail.LookingFor = s.firstSelectionTitle(target.ID, models.CategoryLookingFor)
|
||||||
|
detail.Occupations = s.allSelectionTitles(target.ID, models.CategoryOccupation)
|
||||||
|
detail.Interests = s.allSelectionTitles(target.ID, models.CategoryInterest)
|
||||||
|
detail.ImportantInRelationship = s.allSelectionTitles(target.ID, models.CategoryImportantInRelationship)
|
||||||
|
|
||||||
|
if answers, err := s.questionRepo.GetUserAnswers(target.ID); err == nil {
|
||||||
|
for _, a := range answers {
|
||||||
|
if a.Question == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
detail.Answers = append(detail.Answers, dto.CandidateAnswer{
|
||||||
|
Question: a.Question.Text,
|
||||||
|
Answer: a.Answer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiscoveryService) allSelectionTitles(userID uint, key models.CategoryKey) []string {
|
||||||
|
cat, err := s.optionRepo.GetCategoryByKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sels, err := s.optionRepo.GetUserSelections(userID, cat.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
titles := make([]string, 0, len(sels))
|
||||||
|
for _, sel := range sels {
|
||||||
|
if sel.Option != nil {
|
||||||
|
titles = append(titles, sel.Option.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return titles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiscoveryService) firstSelectionTitle(userID uint, key models.CategoryKey) string {
|
||||||
|
titles := s.allSelectionTitles(userID, key)
|
||||||
|
if len(titles) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return titles[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrStepOutOfOrder = errors.New("لطفاً ابتدا مراحل قبلی را تکمیل کنید")
|
||||||
|
ErrInvalidBirthDate = errors.New("فرمت تاریخ تولد نامعتبر است (YYYY-MM-DD)")
|
||||||
|
ErrOptionsNotFound = errors.New("برخی از گزینههای انتخابی معتبر نیستند")
|
||||||
|
ErrSelectionCount = errors.New("تعداد گزینههای انتخابی مجاز نیست")
|
||||||
|
ErrQuestionsInvalid = errors.New("سوالات انتخابی معتبر نیستند")
|
||||||
|
ErrDuplicateQuestion = errors.New("هر سوال فقط یکبار میتواند انتخاب شود")
|
||||||
|
)
|
||||||
|
|
||||||
|
type OnboardingService struct {
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
optionRepo *repository.OptionRepository
|
||||||
|
questionRepo *repository.QuestionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOnboardingService(
|
||||||
|
userRepo *repository.UserRepository,
|
||||||
|
optionRepo *repository.OptionRepository,
|
||||||
|
questionRepo *repository.QuestionRepository,
|
||||||
|
) *OnboardingService {
|
||||||
|
return &OnboardingService{
|
||||||
|
userRepo: userRepo,
|
||||||
|
optionRepo: optionRepo,
|
||||||
|
questionRepo: questionRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// canModifyStep بررسی میکند که آیا کاربر مجاز به تکمیل/ویرایش این مرحله هست یا نه.
|
||||||
|
// اگر کاربر قبلاً کل آنبوردینگ رو تموم کرده، اجازهی ویرایش هر مرحلهای رو داره.
|
||||||
|
// در غیر این صورت فقط میتونه مرحلهی فعلی یا مراحل قبلی (که قبلاً تکمیل شدن) رو بزنه؛
|
||||||
|
// نمیتونه از روی یک مرحله بپره جلو.
|
||||||
|
func (s *OnboardingService) canModifyStep(user *models.User, step models.OnboardingStep) error {
|
||||||
|
if user.IsOnboarded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if step > user.CurrentStep {
|
||||||
|
return ErrStepOutOfOrder
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceIfCurrent اگه مرحلهی تکمیلشده همون مرحلهی فعلی کاربر بود، به مرحلهی بعد میره
|
||||||
|
func (s *OnboardingService) advanceIfCurrent(user *models.User, completedStep models.OnboardingStep) error {
|
||||||
|
if user.CurrentStep != completedStep {
|
||||||
|
// یا مرحلهی قبلی داشت ویرایش میشد، یا از قبل onboarded بود.
|
||||||
|
// اگر قبلاً توسط ادمین رد شده بود، ویرایش پروفایل یعنی درخواست بررسی مجدد.
|
||||||
|
return s.resubmitIfRejected(user)
|
||||||
|
}
|
||||||
|
next := completedStep + 1
|
||||||
|
updates := map[string]interface{}{"current_step": next}
|
||||||
|
if next >= models.StepCompleted {
|
||||||
|
updates["current_step"] = models.StepCompleted
|
||||||
|
updates["is_onboarded"] = true
|
||||||
|
updates["approval_status"] = models.ApprovalPending
|
||||||
|
updates["rejection_reason"] = nil
|
||||||
|
}
|
||||||
|
return s.userRepo.UpdateFields(user, updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resubmitIfRejected اگه کاربر قبلاً رد شده و بعد از onboarded شدن پروفایلش رو
|
||||||
|
// ویرایش کنه، وضعیت رو به pending برمیگردونه تا ادمین دوباره بررسی کنه.
|
||||||
|
func (s *OnboardingService) resubmitIfRejected(user *models.User) error {
|
||||||
|
if !user.IsOnboarded || user.ApprovalStatus != models.ApprovalRejected {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"approval_status": models.ApprovalPending,
|
||||||
|
"rejection_reason": nil,
|
||||||
|
}
|
||||||
|
if err := s.userRepo.UpdateFields(user, updates); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.ApprovalStatus = models.ApprovalPending
|
||||||
|
user.RejectionReason = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- مرحله ۱: اطلاعات پایه ----------------
|
||||||
|
|
||||||
|
func (s *OnboardingService) SetBasicInfo(user *models.User, req dto.BasicInfoRequest) error {
|
||||||
|
if err := s.canModifyStep(user, models.StepBasicInfo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
birthDate, err := time.Parse("2006-01-02", req.BirthDate)
|
||||||
|
if err != nil {
|
||||||
|
return ErrInvalidBirthDate
|
||||||
|
}
|
||||||
|
gender := models.Gender(req.Gender)
|
||||||
|
|
||||||
|
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
|
||||||
|
"gender": gender,
|
||||||
|
"first_name": req.FirstName,
|
||||||
|
"birth_date": birthDate,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.advanceIfCurrent(user, models.StepBasicInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- مرحله ۲: قد و وزن ----------------
|
||||||
|
|
||||||
|
func (s *OnboardingService) SetBodyInfo(user *models.User, req dto.BodyInfoRequest) error {
|
||||||
|
if err := s.canModifyStep(user, models.StepBodyInfo); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
|
||||||
|
"height_cm": req.HeightCM,
|
||||||
|
"weight_kg": req.WeightKG,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.advanceIfCurrent(user, models.StepBodyInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- مرحله ۳: سوالات پروفایل (دقیقاً ۳ سوال) ----------------
|
||||||
|
|
||||||
|
func (s *OnboardingService) GetAvailableQuestions() ([]models.ProfileQuestion, error) {
|
||||||
|
return s.questionRepo.ListActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OnboardingService) SetProfileQuestions(user *models.User, req dto.ProfileQuestionsRequest) error {
|
||||||
|
if err := s.canModifyStep(user, models.StepProfileQuestions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]uint, 0, len(req.Answers))
|
||||||
|
seen := map[uint]bool{}
|
||||||
|
for _, a := range req.Answers {
|
||||||
|
if seen[a.QuestionID] {
|
||||||
|
return ErrDuplicateQuestion
|
||||||
|
}
|
||||||
|
seen[a.QuestionID] = true
|
||||||
|
ids = append(ids, a.QuestionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
questions, err := s.questionRepo.FindActiveByIDs(ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(questions) != len(ids) {
|
||||||
|
return ErrQuestionsInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
answers := make([]models.UserProfileAnswer, 0, len(req.Answers))
|
||||||
|
for _, a := range req.Answers {
|
||||||
|
answers = append(answers, models.UserProfileAnswer{
|
||||||
|
QuestionID: a.QuestionID,
|
||||||
|
Answer: a.Answer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.questionRepo.ReplaceUserAnswers(user.ID, answers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.advanceIfCurrent(user, models.StepProfileQuestions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- مرحله ۴: تحصیلات (سطح تحصیلات + رشته) ----------------
|
||||||
|
|
||||||
|
func (s *OnboardingService) SetEducation(user *models.User, req dto.EducationRequest) error {
|
||||||
|
if err := s.canModifyStep(user, models.StepEducation); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.selectSingleOption(user.ID, models.CategoryEducationLevel, req.EducationLevelOptionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.selectSingleOption(user.ID, models.CategoryFieldOfStudy, req.FieldOfStudyOptionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.advanceIfCurrent(user, models.StepEducation)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OnboardingService) selectSingleOption(userID uint, key models.CategoryKey, optionID uint) error {
|
||||||
|
return s.selectOptions(userID, key, []uint{optionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- مراحل مبتنی بر لیست چندانتخابی عمومی ----------------
|
||||||
|
|
||||||
|
// SetOccupations مرحله ۵: کارهایی که کاربر انجام میدهد (چندانتخابی، بدون تعداد ثابت)
|
||||||
|
func (s *OnboardingService) SetOccupations(user *models.User, optionIDs []uint) error {
|
||||||
|
if err := s.canModifyStep(user, models.StepOccupations); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.selectOptions(user.ID, models.CategoryOccupation, optionIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.advanceIfCurrent(user, models.StepOccupations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRelationshipImportance مرحله ۶: دقیقاً ۳ مورد از چیزهای مهم در رابطه
|
||||||
|
func (s *OnboardingService) SetRelationshipImportance(user *models.User, optionIDs []uint) error {
|
||||||
|
if err := s.canModifyStep(user, models.StepRelationshipImportance); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.selectOptions(user.ID, models.CategoryImportantInRelationship, optionIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.advanceIfCurrent(user, models.StepRelationshipImportance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetInterests مرحله ۷: دقیقاً ۵ علاقهمندی
|
||||||
|
func (s *OnboardingService) SetInterests(user *models.User, optionIDs []uint) error {
|
||||||
|
if err := s.canModifyStep(user, models.StepInterests); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.selectOptions(user.ID, models.CategoryInterest, optionIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.advanceIfCurrent(user, models.StepInterests)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLookingFor مرحله ۸: دنبال چی میگردی
|
||||||
|
func (s *OnboardingService) SetLookingFor(user *models.User, optionIDs []uint) error {
|
||||||
|
if err := s.canModifyStep(user, models.StepLookingFor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.selectOptions(user.ID, models.CategoryLookingFor, optionIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.advanceIfCurrent(user, models.StepLookingFor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectOptions منطق مشترک اعتبارسنجی + ذخیرهی انتخابهای یک دسته
|
||||||
|
func (s *OnboardingService) selectOptions(userID uint, key models.CategoryKey, optionIDs []uint) error {
|
||||||
|
cat, err := s.optionRepo.GetCategoryByKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
count := len(optionIDs)
|
||||||
|
if count < cat.MinSelect {
|
||||||
|
return fmt.Errorf("%w: حداقل باید %d مورد انتخاب کنید", ErrSelectionCount, cat.MinSelect)
|
||||||
|
}
|
||||||
|
if cat.MaxSelect > 0 && count > cat.MaxSelect {
|
||||||
|
return fmt.Errorf("%w: حداکثر میتوانید %d مورد انتخاب کنید", ErrSelectionCount, cat.MaxSelect)
|
||||||
|
}
|
||||||
|
|
||||||
|
// جلوگیری از تکراری بودن id ها
|
||||||
|
uniqueIDs := make([]uint, 0, len(optionIDs))
|
||||||
|
seen := map[uint]bool{}
|
||||||
|
for _, id := range optionIDs {
|
||||||
|
if !seen[id] {
|
||||||
|
seen[id] = true
|
||||||
|
uniqueIDs = append(uniqueIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validOptions, err := s.optionRepo.FindActiveOptionsByIDsInCategory(cat.ID, uniqueIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(validOptions) != len(uniqueIDs) {
|
||||||
|
return ErrOptionsNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.optionRepo.ReplaceUserSelections(userID, cat.ID, uniqueIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveOptions لیست گزینههای فعال یک دسته را برمیگرداند (برای نمایش به کاربر در هر مرحله)
|
||||||
|
func (s *OnboardingService) GetActiveOptions(key models.CategoryKey) ([]models.Option, error) {
|
||||||
|
return s.optionRepo.ListActiveOptionsByCategoryKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- وضعیت کلی آنبوردینگ ----------------
|
||||||
|
|
||||||
|
func (s *OnboardingService) GetStatus(user *models.User) dto.OnboardingStatusResponse {
|
||||||
|
steps := make([]dto.StepView, 0, len(models.OnboardingStepOrder))
|
||||||
|
for _, s2 := range models.OnboardingStepOrder {
|
||||||
|
done := user.IsOnboarded || user.CurrentStep > s2.Step
|
||||||
|
steps = append(steps, dto.StepView{
|
||||||
|
Step: int(s2.Step),
|
||||||
|
Key: s2.Key,
|
||||||
|
Done: done,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
currentKey := "completed"
|
||||||
|
for _, s2 := range models.OnboardingStepOrder {
|
||||||
|
if s2.Step == user.CurrentStep {
|
||||||
|
currentKey = s2.Key
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.OnboardingStatusResponse{
|
||||||
|
CurrentStep: int(user.CurrentStep),
|
||||||
|
CurrentStepKey: currentKey,
|
||||||
|
IsOnboarded: user.IsOnboarded,
|
||||||
|
ApprovalStatus: string(user.ApprovalStatus),
|
||||||
|
RejectionReason: user.RejectionReason,
|
||||||
|
Steps: steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
// OTPSender اینترفیس ارسال پیامک کد تایید.
|
||||||
|
// برای اتصال به یک گیتوی واقعی (کاوهنگار، ملیپیامک و ...) کافیه یک
|
||||||
|
// پیادهسازی جدید از این اینترفیس بسازی و در main.go جایگزین mock کنی؛
|
||||||
|
// بقیهی کد (UserService و...) نیازی به تغییر ندارد.
|
||||||
|
//
|
||||||
|
// نکتهی مهم: پیادهسازی واقعی باید SYNC باشه (منتظر پاسخ گیتوی بمونه)
|
||||||
|
// و نتیجهی واقعی ارسال رو برگردونه؛ اگه async/بیخیال از نتیجه باشه،
|
||||||
|
// کاربر حتی در صورت شکست واقعی ارسال، پیام «با موفقیت ارسال شد» میگیره
|
||||||
|
// و منتظر کدی میمونه که هیچوقت نمیرسه.
|
||||||
|
type OTPSender interface {
|
||||||
|
// Send کد OTP را برای شمارهی دادهشده ارسال میکند.
|
||||||
|
// خروجی true یعنی گیتوی ارسال را با موفقیت تایید کرده است.
|
||||||
|
Send(phone string, otp string) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockOTPSender پیادهسازی توسعه/تست: بهجای ارسال واقعی پیامک،
|
||||||
|
// کد را در لاگ سرور چاپ میکند. برای پروداکشن باید با یک گیتوی واقعی
|
||||||
|
// جایگزین شود.
|
||||||
|
type MockOTPSender struct {
|
||||||
|
// Debug اگر true باشد، کد OTP در لاگ چاپ میشود (فقط برای dev/staging).
|
||||||
|
Debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockOTPSender(debug bool) *MockOTPSender {
|
||||||
|
return &MockOTPSender{Debug: debug}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MockOTPSender) Send(phone string, otp string) bool {
|
||||||
|
if s.Debug {
|
||||||
|
log.Printf("[MOCK-SMS] کد OTP برای %s: %s", phone, otp)
|
||||||
|
} else {
|
||||||
|
log.Printf("[MOCK-SMS] یک کد OTP برای %s ارسال شد", phone)
|
||||||
|
}
|
||||||
|
// mock همیشه موفق فرض میشود؛ برای شبیهسازی خطای گیتوی میتونی
|
||||||
|
// این مقدار رو شرطی کنی (مثلاً بر اساس یک فلگ تستی).
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PhotoStorage اینترفیس ذخیرهسازی فایل عکس پروفایل.
|
||||||
|
// برای اتصال به یک storage واقعی (S3، Arvan Object Storage و...) کافیه یک
|
||||||
|
// پیادهسازی جدید از این اینترفیس بسازی و در main.go جایگزین نسخهی local کنی؛
|
||||||
|
// بقیهی کد (ProfileService و...) نیازی به تغییر ندارد.
|
||||||
|
type PhotoStorage interface {
|
||||||
|
// Save فایل رو ذخیره میکنه و URL قابلدسترسی (نسبی یا کامل) رو برمیگردونه.
|
||||||
|
Save(userID uint, ext string, data []byte) (url string, err error)
|
||||||
|
// Delete فایل رو با URL برگردوندهشده از Save حذف میکنه.
|
||||||
|
Delete(url string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalPhotoStorage فایلها رو روی دیسک محلی سرور ذخیره میکنه و از طریق
|
||||||
|
// مسیر استاتیک /uploads سرو میشن. برای پروداکشن با تعداد کاربر بالا،
|
||||||
|
// بهتره با یک object storage واقعی جایگزین بشه (فایلها روی دیسک سرور API
|
||||||
|
// میمونن و بین چند instance به اشتراک گذاشته نمیشن).
|
||||||
|
type LocalPhotoStorage struct {
|
||||||
|
rootDir string // مسیر روی دیسک، مثلاً "./uploads/photos"
|
||||||
|
urlPrefix string // پیشوند URL عمومی، مثلاً "/uploads/photos"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLocalPhotoStorage(rootDir, urlPrefix string) *LocalPhotoStorage {
|
||||||
|
return &LocalPhotoStorage{rootDir: rootDir, urlPrefix: urlPrefix}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalPhotoStorage) Save(userID uint, ext string, data []byte) (string, error) {
|
||||||
|
userDir := filepath.Join(s.rootDir, fmt.Sprintf("%d", userID))
|
||||||
|
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := uuid.NewString() + ext
|
||||||
|
fullPath := filepath.Join(userDir, filename)
|
||||||
|
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s/%d/%s", s.urlPrefix, userID, filename), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalPhotoStorage) Delete(url string) error {
|
||||||
|
rel, err := filepath.Rel(s.urlPrefix, url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(s.rootDir, rel)
|
||||||
|
err = os.Remove(fullPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"dating-app/internal/models"
|
||||||
|
"dating-app/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxPhotosPerUser = 6
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری")
|
||||||
|
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
|
||||||
|
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
|
||||||
|
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
|
||||||
|
)
|
||||||
|
|
||||||
|
const MaxPhotoSizeBytes = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
type ProfileService struct {
|
||||||
|
photoRepo *repository.PhotoRepository
|
||||||
|
userRepo *repository.UserRepository
|
||||||
|
storage PhotoStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProfileService(photoRepo *repository.PhotoRepository, userRepo *repository.UserRepository, storage PhotoStorage) *ProfileService {
|
||||||
|
return &ProfileService{photoRepo: photoRepo, userRepo: userRepo, storage: storage}
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedImageExts = map[string]bool{
|
||||||
|
".jpg": true, ".jpeg": true, ".png": true, ".webp": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProfileService) UploadPhoto(userID uint, ext string, data []byte) (*models.Photo, error) {
|
||||||
|
if !allowedImageExts[ext] {
|
||||||
|
return nil, ErrInvalidImage
|
||||||
|
}
|
||||||
|
if len(data) > MaxPhotoSizeBytes {
|
||||||
|
return nil, ErrImageTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := s.photoRepo.CountByUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count >= MaxPhotosPerUser {
|
||||||
|
return nil, ErrTooManyPhotos
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := s.storage.Save(userID, ext, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
photo := &models.Photo{UserID: userID, URL: url, SortOrder: int(count)}
|
||||||
|
if err := s.photoRepo.Create(photo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return photo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProfileService) DeletePhoto(userID, photoID uint) error {
|
||||||
|
photo, err := s.photoRepo.FindByID(photoID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if photo.UserID != userID {
|
||||||
|
return ErrPhotoNotOwned
|
||||||
|
}
|
||||||
|
if err := s.photoRepo.Delete(photoID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.storage.Delete(photo.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProfileService) ListPhotos(userID uint) ([]models.Photo, error) {
|
||||||
|
return s.photoRepo.ListByUser(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProfileService) UpdateLocation(user *models.User, lat, lng float64) error {
|
||||||
|
return s.userRepo.UpdateFields(user, map[string]interface{}{
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lng,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CalcAge سن رو از تاریخ تولد حساب میکنه (نسبت به الان)
|
||||||
|
func CalcAge(birthDate *time.Time) int {
|
||||||
|
if birthDate == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
age := now.Year() - birthDate.Year()
|
||||||
|
if now.Month() < birthDate.Month() || (now.Month() == birthDate.Month() && now.Day() < birthDate.Day()) {
|
||||||
|
age--
|
||||||
|
}
|
||||||
|
return age
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
const earthRadiusKM = 6371.0
|
||||||
|
|
||||||
|
// HaversineKM فاصلهی تقریبی بین دو مختصات جغرافیایی رو به کیلومتر برمیگردونه.
|
||||||
|
func HaversineKM(lat1, lng1, lat2, lng2 float64) float64 {
|
||||||
|
toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
|
||||||
|
|
||||||
|
dLat := toRad(lat2 - lat1)
|
||||||
|
dLng := toRad(lng2 - lng1)
|
||||||
|
|
||||||
|
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||||
|
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLng/2)*math.Sin(dLng/2)
|
||||||
|
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||||
|
|
||||||
|
return earthRadiusKM * c
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidToken = errors.New("توکن نامعتبر است")
|
||||||
|
ErrExpiredToken = errors.New("توکن منقضی شده است")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenType نوع توکن، برای جلوگیری از استفاده از refresh token بهجای access و برعکس
|
||||||
|
type TokenType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AccessToken TokenType = "access"
|
||||||
|
RefreshToken TokenType = "refresh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims دیتای داخل JWT
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
Type TokenType `json:"type"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTManager تولید و اعتبارسنجی توکنها
|
||||||
|
type JWTManager struct {
|
||||||
|
accessSecret []byte
|
||||||
|
refreshSecret []byte
|
||||||
|
accessTTL time.Duration
|
||||||
|
refreshTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJWTManager(accessSecret, refreshSecret string, accessTTL, refreshTTL time.Duration) *JWTManager {
|
||||||
|
return &JWTManager{
|
||||||
|
accessSecret: []byte(accessSecret),
|
||||||
|
refreshSecret: []byte(refreshSecret),
|
||||||
|
accessTTL: accessTTL,
|
||||||
|
refreshTTL: refreshTTL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) GenerateAccessToken(userID uint, isAdmin bool) (string, error) {
|
||||||
|
return m.generate(userID, isAdmin, AccessToken, m.accessSecret, m.accessTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) GenerateRefreshToken(userID uint, isAdmin bool) (string, error) {
|
||||||
|
return m.generate(userID, isAdmin, RefreshToken, m.refreshSecret, m.refreshTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) generate(userID uint, isAdmin bool, tokenType TokenType, secret []byte, ttl time.Duration) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
Type: tokenType,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) ParseAccessToken(tokenStr string) (*Claims, error) {
|
||||||
|
return m.parse(tokenStr, m.accessSecret, AccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) ParseRefreshToken(tokenStr string) (*Claims, error) {
|
||||||
|
return m.parse(tokenStr, m.refreshSecret, RefreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) parse(tokenStr string, secret []byte, expectedType TokenType) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
return nil, ErrExpiredToken
|
||||||
|
}
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
if claims.Type != expectedType {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"math/rand"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var phoneRegex = regexp.MustCompile(`^09\d{9}$`)
|
||||||
|
|
||||||
|
// ValidatePhone فقط شماره موبایل ایرانی با فرمت 09XXXXXXXXX را قبول میکند
|
||||||
|
func ValidatePhone(phone string) error {
|
||||||
|
if !phoneRegex.MatchString(phone) {
|
||||||
|
return errors.New("شماره موبایل باید ۱۱ رقم باشد و با 09 شروع شود")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateOTP یک کد عددی تصادفی با طول مشخص میسازد
|
||||||
|
func GenerateOTP(length int) string {
|
||||||
|
digits := "0123456789"
|
||||||
|
b := make([]byte, length)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = digits[rand.Intn(len(digits))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashOTP کد OTP را با HMAC-SHA256 هش میکند تا در دیتابیس بهصورت خام ذخیره نشود
|
||||||
|
func HashOTP(otp, secretKey string) string {
|
||||||
|
h := hmac.New(sha256.New, []byte(secretKey))
|
||||||
|
h.Write([]byte(otp))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
// Response قالب استاندارد تمام پاسخهای API
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
Errors interface{} `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Success(c *gin.Context, code int, message string, data interface{}) {
|
||||||
|
c.JSON(code, Response{
|
||||||
|
Success: true,
|
||||||
|
Message: message,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Fail(c *gin.Context, code int, message string, errs interface{}) {
|
||||||
|
c.JSON(code, Response{
|
||||||
|
Success: false,
|
||||||
|
Message: message,
|
||||||
|
Errors: errs,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user