commit 2823fb45f520207cfe4ee942431ed7af2d63590b Author: mobiniqow Date: Tue Sep 15 23:23:47 2026 +0330 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 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..412618b --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3a65cc6 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ed7034 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# binaries +/dating-app +/dating-app-server +*.exe +*.test +*.out + +# env +.env + +# فایل‌های آپلودشده‌ی کاربران (نباید توی گیت باشن) +/uploads/ + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..dedf434 --- /dev/null +++ b/README.md @@ -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 ` یک +کاربر ادمین دارن. + +``` +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` باشه توی لاگ چاپ می‌شه. diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..b73549e --- /dev/null +++ b/cmd/api/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a6324b7 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1fc1241 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8874991 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..79354c7 --- /dev/null +++ b/internal/database/database.go @@ -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 +} diff --git a/internal/dto/admin_dto.go b/internal/dto/admin_dto.go new file mode 100644 index 0000000..623e494 --- /dev/null +++ b/internal/dto/admin_dto.go @@ -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"` +} diff --git a/internal/dto/auth_dto.go b/internal/dto/auth_dto.go new file mode 100644 index 0000000..6eb7fae --- /dev/null +++ b/internal/dto/auth_dto.go @@ -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"` +} diff --git a/internal/dto/discovery_dto.go b/internal/dto/discovery_dto.go new file mode 100644 index 0000000..b85454c --- /dev/null +++ b/internal/dto/discovery_dto.go @@ -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"` +} diff --git a/internal/dto/onboarding_dto.go b/internal/dto/onboarding_dto.go new file mode 100644 index 0000000..74d275b --- /dev/null +++ b/internal/dto/onboarding_dto.go @@ -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"` +} diff --git a/internal/dto/profile_dto.go b/internal/dto/profile_dto.go new file mode 100644 index 0000000..d97f5aa --- /dev/null +++ b/internal/dto/profile_dto.go @@ -0,0 +1,7 @@ +package dto + +// UpdateLocationRequest به‌روزرسانی موقعیت مکانی کاربر (برای محاسبه‌ی فاصله‌ی تقریبی) +type UpdateLocationRequest struct { + Latitude float64 `json:"latitude" binding:"required"` + Longitude float64 `json:"longitude" binding:"required"` +} diff --git a/internal/handler/admin_handler.go b/internal/handler/admin_handler.go new file mode 100644 index 0000000..aa1d93c --- /dev/null +++ b/internal/handler/admin_handler.go @@ -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) +} diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go new file mode 100644 index 0000000..4069d2d --- /dev/null +++ b/internal/handler/auth_handler.go @@ -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, + }) +} diff --git a/internal/handler/chat_handler.go b/internal/handler/chat_handler.go new file mode 100644 index 0000000..d8c40f2 --- /dev/null +++ b/internal/handler/chat_handler.go @@ -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) + } +} diff --git a/internal/handler/common.go b/internal/handler/common.go new file mode 100644 index 0000000..432c4a4 --- /dev/null +++ b/internal/handler/common.go @@ -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, + } +} diff --git a/internal/handler/discovery_handler.go b/internal/handler/discovery_handler.go new file mode 100644 index 0000000..b725a02 --- /dev/null +++ b/internal/handler/discovery_handler.go @@ -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) + } +} diff --git a/internal/handler/onboarding_handler.go b/internal/handler/onboarding_handler.go new file mode 100644 index 0000000..bcd6180 --- /dev/null +++ b/internal/handler/onboarding_handler.go @@ -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) +} diff --git a/internal/handler/profile_handler.go b/internal/handler/profile_handler.go new file mode 100644 index 0000000..de679d3 --- /dev/null +++ b/internal/handler/profile_handler.go @@ -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) +} diff --git a/internal/middleware/admin_middleware.go b/internal/middleware/admin_middleware.go new file mode 100644 index 0000000..b924d44 --- /dev/null +++ b/internal/middleware/admin_middleware.go @@ -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() + } +} diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go new file mode 100644 index 0000000..4d614ef --- /dev/null +++ b/internal/middleware/auth_middleware.go @@ -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() + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..ca74197 --- /dev/null +++ b/internal/middleware/cors.go @@ -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() + } +} diff --git a/internal/models/discovery.go b/internal/models/discovery.go new file mode 100644 index 0000000..f5204ae --- /dev/null +++ b/internal/models/discovery.go @@ -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" +} diff --git a/internal/models/option.go b/internal/models/option.go new file mode 100644 index 0000000..b0b6831 --- /dev/null +++ b/internal/models/option.go @@ -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" +} diff --git a/internal/models/profile_question.go b/internal/models/profile_question.go new file mode 100644 index 0000000..e724b0a --- /dev/null +++ b/internal/models/profile_question.go @@ -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" +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..ade426d --- /dev/null +++ b/internal/models/user.go @@ -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" +} diff --git a/internal/repository/match_repository.go b/internal/repository/match_repository.go new file mode 100644 index 0000000..639d088 --- /dev/null +++ b/internal/repository/match_repository.go @@ -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 +} diff --git a/internal/repository/message_repository.go b/internal/repository/message_repository.go new file mode 100644 index 0000000..6e523a3 --- /dev/null +++ b/internal/repository/message_repository.go @@ -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 +} diff --git a/internal/repository/option_repository.go b/internal/repository/option_repository.go new file mode 100644 index 0000000..dcef60b --- /dev/null +++ b/internal/repository/option_repository.go @@ -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 +} diff --git a/internal/repository/photo_repository.go b/internal/repository/photo_repository.go new file mode 100644 index 0000000..be273dc --- /dev/null +++ b/internal/repository/photo_repository.go @@ -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 +} diff --git a/internal/repository/question_repository.go b/internal/repository/question_repository.go new file mode 100644 index 0000000..15761d7 --- /dev/null +++ b/internal/repository/question_repository.go @@ -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 +} diff --git a/internal/repository/swipe_repository.go b/internal/repository/swipe_repository.go new file mode 100644 index 0000000..d119c29 --- /dev/null +++ b/internal/repository/swipe_repository.go @@ -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 +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go new file mode 100644 index 0000000..e77aef5 --- /dev/null +++ b/internal/repository/user_repository.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..8400ac8 --- /dev/null +++ b/internal/router/router.go @@ -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) + } +} diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go new file mode 100644 index 0000000..20a3d9e --- /dev/null +++ b/internal/service/admin_service.go @@ -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) +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 0000000..43d4c48 --- /dev/null +++ b/internal/service/auth_service.go @@ -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 +} diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go new file mode 100644 index 0000000..51d5eb5 --- /dev/null +++ b/internal/service/chat_service.go @@ -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 +} diff --git a/internal/service/discovery_service.go b/internal/service/discovery_service.go new file mode 100644 index 0000000..657a57e --- /dev/null +++ b/internal/service/discovery_service.go @@ -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] +} diff --git a/internal/service/onboarding_service.go b/internal/service/onboarding_service.go new file mode 100644 index 0000000..97b238c --- /dev/null +++ b/internal/service/onboarding_service.go @@ -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, + } +} diff --git a/internal/service/otp_sender.go b/internal/service/otp_sender.go new file mode 100644 index 0000000..e78aa41 --- /dev/null +++ b/internal/service/otp_sender.go @@ -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 +} diff --git a/internal/service/photo_storage.go b/internal/service/photo_storage.go new file mode 100644 index 0000000..1a729a5 --- /dev/null +++ b/internal/service/photo_storage.go @@ -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 +} diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go new file mode 100644 index 0000000..6697f78 --- /dev/null +++ b/internal/service/profile_service.go @@ -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, + }) +} diff --git a/internal/utils/age.go b/internal/utils/age.go new file mode 100644 index 0000000..ac771fe --- /dev/null +++ b/internal/utils/age.go @@ -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 +} diff --git a/internal/utils/geo.go b/internal/utils/geo.go new file mode 100644 index 0000000..5ff52a5 --- /dev/null +++ b/internal/utils/geo.go @@ -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 +} diff --git a/internal/utils/jwt.go b/internal/utils/jwt.go new file mode 100644 index 0000000..d41e345 --- /dev/null +++ b/internal/utils/jwt.go @@ -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 +} diff --git a/internal/utils/otp.go b/internal/utils/otp.go new file mode 100644 index 0000000..0a43639 --- /dev/null +++ b/internal/utils/otp.go @@ -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)) +} diff --git a/internal/utils/response.go b/internal/utils/response.go new file mode 100644 index 0000000..cd03d4a --- /dev/null +++ b/internal/utils/response.go @@ -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, + }) +}