From 2823fb45f520207cfe4ee942431ed7af2d63590b Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Tue, 15 Sep 2026 23:23:47 +0330 Subject: [PATCH 1/9] 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 --- .claude/launch.json | 17 ++ .env.example | 20 ++ .gitignore | 19 ++ README.md | 220 +++++++++++++++ cmd/api/main.go | 89 ++++++ go.mod | 73 +++++ go.sum | 97 +++++++ internal/config/config.go | 89 ++++++ internal/database/database.go | 132 +++++++++ internal/dto/admin_dto.go | 39 +++ internal/dto/auth_dto.go | 47 ++++ internal/dto/discovery_dto.go | 72 +++++ internal/dto/onboarding_dto.go | 56 ++++ internal/dto/profile_dto.go | 7 + internal/handler/admin_handler.go | 304 ++++++++++++++++++++ internal/handler/auth_handler.go | 100 +++++++ internal/handler/chat_handler.go | 87 ++++++ internal/handler/common.go | 102 +++++++ internal/handler/discovery_handler.go | 101 +++++++ internal/handler/onboarding_handler.go | 228 +++++++++++++++ internal/handler/profile_handler.go | 140 ++++++++++ internal/middleware/admin_middleware.go | 30 ++ internal/middleware/auth_middleware.go | 51 ++++ internal/middleware/cors.go | 23 ++ internal/models/discovery.go | 70 +++++ internal/models/option.go | 82 ++++++ internal/models/profile_question.go | 35 +++ internal/models/user.go | 110 ++++++++ internal/repository/match_repository.go | 77 ++++++ internal/repository/message_repository.go | 35 +++ internal/repository/option_repository.go | 130 +++++++++ internal/repository/photo_repository.go | 52 ++++ internal/repository/question_repository.go | 82 ++++++ internal/repository/swipe_repository.go | 42 +++ internal/repository/user_repository.go | 104 +++++++ internal/router/router.go | 109 ++++++++ internal/service/admin_service.go | 201 ++++++++++++++ internal/service/auth_service.go | 187 +++++++++++++ internal/service/chat_service.go | 126 +++++++++ internal/service/discovery_service.go | 303 ++++++++++++++++++++ internal/service/onboarding_service.go | 306 +++++++++++++++++++++ internal/service/otp_sender.go | 41 +++ internal/service/photo_storage.go | 61 ++++ internal/service/profile_service.go | 86 ++++++ internal/utils/age.go | 16 ++ internal/utils/geo.go | 19 ++ internal/utils/jwt.go | 101 +++++++ internal/utils/otp.go | 37 +++ internal/utils/response.go | 27 ++ 49 files changed, 4582 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/api/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/database/database.go create mode 100644 internal/dto/admin_dto.go create mode 100644 internal/dto/auth_dto.go create mode 100644 internal/dto/discovery_dto.go create mode 100644 internal/dto/onboarding_dto.go create mode 100644 internal/dto/profile_dto.go create mode 100644 internal/handler/admin_handler.go create mode 100644 internal/handler/auth_handler.go create mode 100644 internal/handler/chat_handler.go create mode 100644 internal/handler/common.go create mode 100644 internal/handler/discovery_handler.go create mode 100644 internal/handler/onboarding_handler.go create mode 100644 internal/handler/profile_handler.go create mode 100644 internal/middleware/admin_middleware.go create mode 100644 internal/middleware/auth_middleware.go create mode 100644 internal/middleware/cors.go create mode 100644 internal/models/discovery.go create mode 100644 internal/models/option.go create mode 100644 internal/models/profile_question.go create mode 100644 internal/models/user.go create mode 100644 internal/repository/match_repository.go create mode 100644 internal/repository/message_repository.go create mode 100644 internal/repository/option_repository.go create mode 100644 internal/repository/photo_repository.go create mode 100644 internal/repository/question_repository.go create mode 100644 internal/repository/swipe_repository.go create mode 100644 internal/repository/user_repository.go create mode 100644 internal/router/router.go create mode 100644 internal/service/admin_service.go create mode 100644 internal/service/auth_service.go create mode 100644 internal/service/chat_service.go create mode 100644 internal/service/discovery_service.go create mode 100644 internal/service/onboarding_service.go create mode 100644 internal/service/otp_sender.go create mode 100644 internal/service/photo_storage.go create mode 100644 internal/service/profile_service.go create mode 100644 internal/utils/age.go create mode 100644 internal/utils/geo.go create mode 100644 internal/utils/jwt.go create mode 100644 internal/utils/otp.go create mode 100644 internal/utils/response.go 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, + }) +} From fda1b130ff09cb29c6319c5235348067af197ee7 Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Wed, 16 Sep 2026 00:01:10 +0330 Subject: [PATCH 2/9] Add Docker support and docker-compose orchestration Multi-stage Dockerfile for the Go API, plus a docker-compose.yml that runs postgres + backend + both frontends together for local full-stack dev (frontends built from the sibling repo directories). Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 10 ++++++++ .env.example | 8 ++++++ Dockerfile | 23 +++++++++++++++++ docker-compose.yml | 64 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eac3203 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.idea +.claude +.env +.env.* +!.env.example +uploads +*.md +/dating-app +/dating-app-server diff --git a/.env.example b/.env.example index 3a65cc6..088a927 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,11 @@ OTP_LENGTH=4 OTP_HASH_SECRET=test-otp-secret ADMIN_BOOTSTRAP_PHONE=09120000000 + +# -------- فقط برای docker-compose (نیازی نیست موقع اجرای مستقیم go run باشه) -------- +# DB_HOST رو docker-compose خودش موقع اجرای backend روی "postgres" ست می‌کنه؛ +# مقدار بالا (localhost) فقط برای اجرای مستقیم روی سیستم خودت استفاده می‌شه. +WEB_PORT=3000 +ADMIN_PORT=3001 +# آدرسی که از مرورگر کاربر قابل‌دسترسیه (نه آدرس داخلی شبکه‌ی داکر) +PUBLIC_API_URL=http://localhost:8080/api/v1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..111ffab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# ---- مرحله‌ی build ---- +FROM golang:1.22-alpine AS builder +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/api + +# ---- مرحله‌ی اجرا (ایمیج نهایی سبک) ---- +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app + +COPY --from=builder /out/server ./server + +# محل ذخیره‌ی عکس‌های آپلودشده؛ باید به یک volume متصل بشه تا با هر +# دیپلوی جدید پاک نشه (service.LocalPhotoStorage دقیقاً همین مسیر رو می‌سازه) +RUN mkdir -p /app/uploads/photos + +EXPOSE 8080 +ENTRYPOINT ["./server"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..07aee9e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,64 @@ +name: dating-app + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-dating_app} + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "${DB_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-dating_app}"] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: + context: . + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + env_file: + - .env + environment: + # داخل شبکه‌ی داکر، بک‌اند باید با نام سرویس به دیتابیس وصل بشه نه localhost + DB_HOST: postgres + ports: + - "${APP_PORT:-8080}:8080" + volumes: + - backend_uploads:/app/uploads + + web: + build: + context: ../../../frontend/dating-app-web + args: + # این آدرس توی مرورگر کاربر استفاده می‌شه، پس باید از بیرون قابل‌دسترس باشه + # (نه آدرس داخلی شبکه‌ی داکر مثل http://backend:8080) + NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:8080/api/v1} + restart: unless-stopped + depends_on: + - backend + ports: + - "${WEB_PORT:-3000}:3000" + + admin: + build: + context: ../../../frontend/dating-app-admin + args: + NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:8080/api/v1} + restart: unless-stopped + depends_on: + - backend + ports: + - "${ADMIN_PORT:-3001}:3000" + +volumes: + postgres_data: + backend_uploads: From 15583009e0aea8fc57365839364484cc120f24b2 Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Wed, 16 Sep 2026 09:21:50 +0330 Subject: [PATCH 3/9] Add photo upload as a real onboarding step Photos are now step 2 of onboarding (right after basic info) instead of only being manageable from the profile page later. Uploading still goes through the existing /profile/photos endpoint; the new POST /onboarding/photos just confirms at least one photo exists and advances the step counter. Renumbered the OnboardingStep enum accordingly (9 steps total now). Co-Authored-By: Claude Sonnet 5 --- README.md | 23 ++++++++++++++--------- cmd/api/main.go | 2 +- internal/handler/onboarding_handler.go | 13 +++++++++++++ internal/models/user.go | 2 ++ internal/router/router.go | 1 + internal/service/onboarding_service.go | 24 +++++++++++++++++++++++- 6 files changed, 54 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index dedf434..b499cb7 100644 --- a/README.md +++ b/README.md @@ -78,23 +78,28 @@ func (s *KavenegarOTPSender) Send(phone, otp string) bool { ## فرآیند آنبوردینگ -۸ مرحله‌ی ثابت که باید به‌ترتیب طی بشن (نمی‌شه از روی مرحله پرید، ولی مراحل +۹ مرحله‌ی ثابت که باید به‌ترتیب طی بشن (نمی‌شه از روی مرحله پرید، ولی مراحل قبلی هر وقت بخوای قابل ویرایش‌اند؛ بعد از تکمیل کامل هم همه‌ی مراحل آزادانه -قابل ویرایش می‌شن): +قابل ویرایش می‌شن — حتی توی همون بازه‌ای که کاربر منتظر تایید ادمینه): | # | مرحله | 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` | +| ۲ | عکس‌ها (حداقل ۱ عکس؛ خودِ آپلود از `/profile/photos`) | `POST /onboarding/photos` | +| ۳ | قد و وزن | `POST /onboarding/body-info` | +| ۴ | ۳ سوال از پیش‌تعریف‌شده + پاسخ | `POST /onboarding/profile-questions` | +| ۵ | سطح تحصیلات + رشته | `POST /onboarding/education` | +| ۶ | کارهایی که انجام می‌دهد (چندتایی) | `POST /onboarding/occupations` | +| ۷ | ۳ چیز مهم در رابطه | `POST /onboarding/relationship-importance` | +| ۸ | ۵ علاقه‌مندی | `POST /onboarding/interests` | +| ۹ | دنبال چی می‌گردی | `POST /onboarding/looking-for` | وضعیت فعلی: `GET /onboarding/status` +اگه کاربر قبلاً رد شده باشه و بعد از onboarded شدن هرکدوم از مراحل رو دوباره +بزنه (حتی مرحله‌ی عکس‌ها)، `approval_status` خودکار به `pending` برمی‌گرده تا +ادمین دوباره بررسی کنه. + ### بعد از تکمیل آنبوردینگ: تایید ادمین به محض تکمیل مرحله‌ی ۸، `approval_status` کاربر روی `pending` قرار می‌گیرد. diff --git a/cmd/api/main.go b/cmd/api/main.go index b73549e..5d11aad 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -60,7 +60,7 @@ func main() { userRepo, otpSender, jwtManager, cfg.OTPExpirySeconds, cfg.OTPLength, cfg.OTPHashSecret, ) - onboardingService := service.NewOnboardingService(userRepo, optionRepo, questionRepo) + onboardingService := service.NewOnboardingService(userRepo, optionRepo, questionRepo, photoRepo) adminService := service.NewAdminService(optionRepo, questionRepo, userRepo) profileService := service.NewProfileService(photoRepo, userRepo, photoStorage) discoveryService := service.NewDiscoveryService(userRepo, photoRepo, swipeRepo, matchRepo, optionRepo, questionRepo) diff --git a/internal/handler/onboarding_handler.go b/internal/handler/onboarding_handler.go index bcd6180..1c8c745 100644 --- a/internal/handler/onboarding_handler.go +++ b/internal/handler/onboarding_handler.go @@ -66,6 +66,19 @@ func (h *OnboardingHandler) BasicInfo(c *gin.Context) { utils.Success(c, http.StatusOK, "اطلاعات پایه ثبت شد", h.onboardingService.GetStatus(user)) } +// Photos POST /onboarding/photos — تایید مرحله‌ی عکس‌ها (خودِ آپلود از /profile/photos انجام می‌شه) +func (h *OnboardingHandler) Photos(c *gin.Context) { + user, ok := currentUser(c) + if !ok { + return + } + if err := h.onboardingService.ConfirmPhotos(user); err != nil { + h.handleStepError(c, err) + return + } + utils.Success(c, http.StatusOK, "عکس‌ها ثبت شد", h.onboardingService.GetStatus(user)) +} + // BodyInfo POST /onboarding/body-info func (h *OnboardingHandler) BodyInfo(c *gin.Context) { user, ok := currentUser(c) diff --git a/internal/models/user.go b/internal/models/user.go index ade426d..7488849 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -26,6 +26,7 @@ type OnboardingStep int const ( StepBasicInfo OnboardingStep = iota + 1 + StepPhotos StepBodyInfo StepProfileQuestions StepEducation @@ -58,6 +59,7 @@ var OnboardingStepOrder = []struct { Key string }{ {StepBasicInfo, "basic_info"}, + {StepPhotos, "photos"}, {StepBodyInfo, "body_info"}, {StepProfileQuestions, "profile_questions"}, {StepEducation, "education"}, diff --git a/internal/router/router.go b/internal/router/router.go index 8400ac8..02b93a8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -59,6 +59,7 @@ func Setup( { onboarding.GET("/status", h.Onboarding.Status) onboarding.POST("/basic-info", h.Onboarding.BasicInfo) + onboarding.POST("/photos", h.Onboarding.Photos) onboarding.POST("/body-info", h.Onboarding.BodyInfo) onboarding.GET("/profile-questions", h.Onboarding.AvailableQuestions) onboarding.POST("/profile-questions", h.Onboarding.SetProfileQuestions) diff --git a/internal/service/onboarding_service.go b/internal/service/onboarding_service.go index 97b238c..adb75e2 100644 --- a/internal/service/onboarding_service.go +++ b/internal/service/onboarding_service.go @@ -17,23 +17,27 @@ var ( ErrSelectionCount = errors.New("تعداد گزینه‌های انتخابی مجاز نیست") ErrQuestionsInvalid = errors.New("سوالات انتخابی معتبر نیستند") ErrDuplicateQuestion = errors.New("هر سوال فقط یک‌بار می‌تواند انتخاب شود") + ErrNoPhotos = errors.New("برای ادامه حداقل باید یک عکس اضافه کنی") ) type OnboardingService struct { userRepo *repository.UserRepository optionRepo *repository.OptionRepository questionRepo *repository.QuestionRepository + photoRepo *repository.PhotoRepository } func NewOnboardingService( userRepo *repository.UserRepository, optionRepo *repository.OptionRepository, questionRepo *repository.QuestionRepository, + photoRepo *repository.PhotoRepository, ) *OnboardingService { return &OnboardingService{ userRepo: userRepo, optionRepo: optionRepo, questionRepo: questionRepo, + photoRepo: photoRepo, } } @@ -109,7 +113,25 @@ func (s *OnboardingService) SetBasicInfo(user *models.User, req dto.BasicInfoReq return s.advanceIfCurrent(user, models.StepBasicInfo) } -// ---------------- مرحله ۲: قد و وزن ---------------- +// ---------------- مرحله ۲: عکس‌ها (حداقل ۱ عکس) ---------------- + +// ConfirmPhotos عکس واقعی رو از طریق endpoint عمومی /profile/photos آپلود می‌کنیم؛ +// این متد فقط تایید می‌کنه که حداقل یک عکس هست و مرحله رو جلو می‌بره. +func (s *OnboardingService) ConfirmPhotos(user *models.User) error { + if err := s.canModifyStep(user, models.StepPhotos); err != nil { + return err + } + count, err := s.photoRepo.CountByUser(user.ID) + if err != nil { + return err + } + if count < 1 { + return ErrNoPhotos + } + return s.advanceIfCurrent(user, models.StepPhotos) +} + +// ---------------- مرحله ۳: قد و وزن ---------------- func (s *OnboardingService) SetBodyInfo(user *models.User, req dto.BodyInfoRequest) error { if err := s.canModifyStep(user, models.StepBodyInfo); err != nil { From 769e76313f6e926b96e1765f6c90fd5e4c45b395 Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Sat, 19 Sep 2026 22:17:12 +0330 Subject: [PATCH 4/9] Add production hardening: monitoring, backups, caching, and high-traffic resilience - Recovery middleware (no more crash-on-panic), request metrics/monitoring store + admin API, in-memory TTL caching for read-heavy option/question/ category lookups, gzip + rate-limiting middleware, sanitized chat message encoding, and pg_dump-based database backups with an admin API. - Fix N+1 queries in the discover feed, candidate detail, and match list endpoints by batching selection/photo/user/last-message lookups into single queries instead of one-per-item loops. - Tune DB connection pooling and add HTTP server timeouts + graceful shutdown so the API degrades gracefully instead of falling over under load. - Fix a null (vs empty array) JSON response for candidates/matches without photos or interests, which crashed the web app's swipe card. --- .env.example | 5 + .gitignore | 3 + cmd/api/main.go | 59 +++++++- internal/cache/ttl_cache.go | 55 +++++++ internal/cache/ttl_cache_test.go | 62 ++++++++ internal/config/config.go | 12 ++ internal/database/database.go | 15 ++ internal/handler/backup_handler.go | 49 +++++++ internal/handler/chat_handler.go | 4 + internal/handler/monitoring_handler.go | 35 +++++ internal/middleware/gzip_middleware.go | 49 +++++++ internal/middleware/metrics_middleware.go | 44 ++++++ internal/middleware/rate_limit_middleware.go | 70 +++++++++ .../middleware/rate_limit_middleware_test.go | 74 ++++++++++ internal/middleware/recovery_middleware.go | 30 ++++ internal/monitoring/store.go | 134 +++++++++++++++++ internal/monitoring/store_test.go | 74 ++++++++++ internal/repository/message_repository.go | 26 ++++ internal/repository/option_repository.go | 109 +++++++++++++- internal/repository/question_repository.go | 41 +++++- internal/repository/user_repository.go | 12 ++ internal/router/router.go | 19 ++- internal/service/backup_service.go | 136 ++++++++++++++++++ internal/service/chat_service.go | 59 ++++++-- internal/service/discovery_service.go | 111 ++++++++------ internal/utils/age_test.go | 26 ++++ internal/utils/geo_test.go | 18 +++ internal/utils/sanitize.go | 47 ++++++ internal/utils/sanitize_test.go | 71 +++++++++ 29 files changed, 1377 insertions(+), 72 deletions(-) create mode 100644 internal/cache/ttl_cache.go create mode 100644 internal/cache/ttl_cache_test.go create mode 100644 internal/handler/backup_handler.go create mode 100644 internal/handler/monitoring_handler.go create mode 100644 internal/middleware/gzip_middleware.go create mode 100644 internal/middleware/metrics_middleware.go create mode 100644 internal/middleware/rate_limit_middleware.go create mode 100644 internal/middleware/rate_limit_middleware_test.go create mode 100644 internal/middleware/recovery_middleware.go create mode 100644 internal/monitoring/store.go create mode 100644 internal/monitoring/store_test.go create mode 100644 internal/service/backup_service.go create mode 100644 internal/utils/age_test.go create mode 100644 internal/utils/geo_test.go create mode 100644 internal/utils/sanitize.go create mode 100644 internal/utils/sanitize_test.go diff --git a/.env.example b/.env.example index 088a927..34699a1 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ DB_USER=postgres DB_PASSWORD=postgres DB_NAME=dating_app DB_SSLMODE=disable +# connection pool؛ زیر بار بالا این مقادیر رو با ظرفیت واقعی سرور Postgres تنظیم کن +DB_MAX_OPEN_CONNS=25 +DB_MAX_IDLE_CONNS=10 +DB_CONN_MAX_LIFETIME_MINUTES=30 +DB_CONN_MAX_IDLE_TIME_MINUTES=5 JWT_ACCESS_SECRET=test-access-secret JWT_REFRESH_SECRET=test-refresh-secret diff --git a/.gitignore b/.gitignore index 7ed7034..c69238d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ # فایل‌های آپلودشده‌ی کاربران (نباید توی گیت باشن) /uploads/ +# بک‌آپ‌های دیتابیس (نباید توی گیت باشن؛ حجیم و شامل داده‌ی کاربرانه) +/backups/ + # IDE .idea/ .vscode/ diff --git a/cmd/api/main.go b/cmd/api/main.go index 5d11aad..06abdd5 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -1,13 +1,22 @@ package main import ( + "context" + "errors" "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" "github.com/gin-gonic/gin" "dating-app/internal/config" "dating-app/internal/database" "dating-app/internal/handler" + "dating-app/internal/middleware" + "dating-app/internal/monitoring" "dating-app/internal/repository" "dating-app/internal/router" "dating-app/internal/service" @@ -65,6 +74,10 @@ func main() { profileService := service.NewProfileService(photoRepo, userRepo, photoStorage) discoveryService := service.NewDiscoveryService(userRepo, photoRepo, swipeRepo, matchRepo, optionRepo, questionRepo) chatService := service.NewChatService(matchRepo, messageRepo, userRepo, photoRepo) + backupService := service.NewBackupService(cfg, "./backups") + + // -------- مانیتورینگ -------- + metricsStore := monitoring.NewStore() // -------- Handlers -------- handlers := router.Handlers{ @@ -74,16 +87,54 @@ func main() { Admin: handler.NewAdminHandler(adminService, optionRepo, questionRepo, photoRepo, onboardingService), Discovery: handler.NewDiscoveryHandler(discoveryService), Chat: handler.NewChatHandler(chatService), + Monitoring: handler.NewMonitoringHandler(metricsStore), + Backup: handler.NewBackupHandler(backupService), } if cfg.AppEnv == "production" { gin.SetMode(gin.ReleaseMode) } - engine := gin.Default() + // به‌جای gin.Default() (که فقط Logger+Recovery پیش‌فرض رو می‌ده)، دستی + // می‌سازیم تا هم Metrics رو اضافه کنیم و هم Recovery خودمون که envelope + // استاندارد JSON برمی‌گردونه. ترتیب مهمه: Metrics باید بیرونی‌ترین لایه + // باشه تا حتی درخواست‌هایی که پنیک می‌کنن (و توسط Recovery مهار می‌شن) هم + // توی آمار لحاظ بشن. + engine := gin.New() + engine.Use(middleware.Metrics(metricsStore)) + engine.Use(middleware.Recovery()) + engine.Use(gin.Logger()) router.Setup(engine, handlers, jwtManager, userRepo) - log.Printf("سرور روی پورت %s در حال اجراست (env=%s)", cfg.AppPort, cfg.AppEnv) - if err := engine.Run(":" + cfg.AppPort); err != nil { - log.Fatalf("اجرای سرور ناموفق بود: %v", err) + // یک http.Server دستی به‌جای engine.Run() ساده، به دو دلیل: + // ۱. تایم‌اوت‌های صریح روی خوندن/نوشتن، تا یک کلاینت کند یا مخرب (slowloris) + // نتونه goroutine/فایل‌دسکریپتورهای سرور رو زیر بار بالا قفل نگه داره. + // ۲. Shutdown مهلت‌دار، تا موقع دیپلوی/ری‌استارت، درخواست‌های در حال پردازش + // ناتمام قطع نشن (به‌جای کشتن فوری پروسه). + srv := &http.Server{ + Addr: ":" + cfg.AppPort, + Handler: engine, + ReadTimeout: 30 * time.Second, + ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 90 * time.Second, } + + go func() { + log.Printf("سرور روی پورت %s در حال اجراست (env=%s)", cfg.AppPort, cfg.AppEnv) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("اجرای سرور ناموفق بود: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("سیگنال خاموشی دریافت شد؛ منتظر تمام‌شدن درخواست‌های در حال پردازش...") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("خاموش کردن سرور با مشکل مواجه شد: %v", err) + } + log.Println("سرور با موفقیت خاموش شد") } diff --git a/internal/cache/ttl_cache.go b/internal/cache/ttl_cache.go new file mode 100644 index 0000000..48f88f0 --- /dev/null +++ b/internal/cache/ttl_cache.go @@ -0,0 +1,55 @@ +// Package cache یک کش درون‌حافظه‌ای ساده با انقضای زمانی (TTL) ارائه می‌ده. +// برای داده‌هایی مناسبه که زیاد خونده می‌شن ولی به‌ندرت تغییر می‌کنن (مثل +// گزینه‌های دسته‌بندی‌ها و سوالات پروفایل که فقط از پنل ادمین تغییر می‌کنن). +package cache + +import ( + "sync" + "time" +) + +type entry[V any] struct { + value V + expiresAt time.Time +} + +type TTLCache[K comparable, V any] struct { + mu sync.RWMutex + ttl time.Duration + m map[K]entry[V] +} + +func New[K comparable, V any](ttl time.Duration) *TTLCache[K, V] { + return &TTLCache[K, V]{ttl: ttl, m: make(map[K]entry[V])} +} + +func (c *TTLCache[K, V]) Get(key K) (V, bool) { + c.mu.RLock() + e, ok := c.m[key] + c.mu.RUnlock() + + var zero V + if !ok || time.Now().After(e.expiresAt) { + return zero, false + } + return e.value, true +} + +func (c *TTLCache[K, V]) Set(key K, value V) { + c.mu.Lock() + defer c.mu.Unlock() + c.m[key] = entry[V]{value: value, expiresAt: time.Now().Add(c.ttl)} +} + +func (c *TTLCache[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.m, key) +} + +// Clear کل کش رو خالی می‌کنه؛ برای موقعی که نمی‌شه/نمی‌خوایم کلید دقیق رو حساب کنیم. +func (c *TTLCache[K, V]) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.m = make(map[K]entry[V]) +} diff --git a/internal/cache/ttl_cache_test.go b/internal/cache/ttl_cache_test.go new file mode 100644 index 0000000..958c45d --- /dev/null +++ b/internal/cache/ttl_cache_test.go @@ -0,0 +1,62 @@ +package cache + +import ( + "testing" + "time" +) + +func TestTTLCache_SetGet(t *testing.T) { + c := New[string, int](time.Minute) + c.Set("a", 42) + + v, ok := c.Get("a") + if !ok || v != 42 { + t.Fatalf("expected (42, true), got (%d, %v)", v, ok) + } +} + +func TestTTLCache_MissingKey(t *testing.T) { + c := New[string, int](time.Minute) + if _, ok := c.Get("missing"); ok { + t.Fatal("expected miss for key that was never set") + } +} + +func TestTTLCache_Expiry(t *testing.T) { + c := New[string, int](10 * time.Millisecond) + c.Set("a", 1) + + if _, ok := c.Get("a"); !ok { + t.Fatal("expected hit immediately after Set") + } + + time.Sleep(30 * time.Millisecond) + + if _, ok := c.Get("a"); ok { + t.Fatal("expected miss after TTL expired") + } +} + +func TestTTLCache_Delete(t *testing.T) { + c := New[string, int](time.Minute) + c.Set("a", 1) + c.Delete("a") + + if _, ok := c.Get("a"); ok { + t.Fatal("expected miss after Delete") + } +} + +func TestTTLCache_Clear(t *testing.T) { + c := New[string, int](time.Minute) + c.Set("a", 1) + c.Set("b", 2) + c.Clear() + + if _, ok := c.Get("a"); ok { + t.Fatal("expected miss for 'a' after Clear") + } + if _, ok := c.Get("b"); ok { + t.Fatal("expected miss for 'b' after Clear") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 8874991..2d3d5bb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,13 @@ type Config struct { DBName string DBSSLMode string + // تنظیمات connection pool؛ زیر بار بالا این‌ها از باز شدن بی‌رویه‌ی اتصال + // (که خود Postgres رو زمین می‌زنه) و از نشتی اتصالات راکد جلوگیری می‌کنن. + DBMaxOpenConns int + DBMaxIdleConns int + DBConnMaxLifetimeMins int + DBConnMaxIdleTimeMins int + JWTAccessSecret string JWTRefreshSecret string JWTAccessTTL time.Duration @@ -49,6 +56,11 @@ func Load() *Config { DBName: getEnv("DB_NAME", "dating_app"), DBSSLMode: getEnv("DB_SSLMODE", "disable"), + DBMaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25), + DBMaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 10), + DBConnMaxLifetimeMins: getEnvInt("DB_CONN_MAX_LIFETIME_MINUTES", 30), + DBConnMaxIdleTimeMins: getEnvInt("DB_CONN_MAX_IDLE_TIME_MINUTES", 5), + JWTAccessSecret: getEnv("JWT_ACCESS_SECRET", "change-me-access-secret"), JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", "change-me-refresh-secret"), JWTAccessTTL: time.Duration(getEnvInt("JWT_ACCESS_TTL_MINUTES", 60*24)) * time.Minute, diff --git a/internal/database/database.go b/internal/database/database.go index 79354c7..1866f54 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -2,6 +2,7 @@ package database import ( "log" + "time" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -24,6 +25,20 @@ func Connect(cfg *config.Config) (*gorm.DB, error) { if err != nil { return nil, err } + + // تنظیم connection pool؛ بدون این، پیش‌فرض Go بی‌نهایت اتصال باز می‌کنه که + // زیر بار بالا می‌تونه به‌راحتی خود Postgres رو با تعداد زیاد اتصال همزمان + // از پا در بیاره. MaxIdleConns هم اتصالات راکد رو محدود می‌کنه تا حافظه‌ی + // دیتابیس هدر نره. + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns) + sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.DBConnMaxLifetimeMins) * time.Minute) + sqlDB.SetConnMaxIdleTime(time.Duration(cfg.DBConnMaxIdleTimeMins) * time.Minute) + return db, nil } diff --git a/internal/handler/backup_handler.go b/internal/handler/backup_handler.go new file mode 100644 index 0000000..01a2366 --- /dev/null +++ b/internal/handler/backup_handler.go @@ -0,0 +1,49 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "dating-app/internal/service" + "dating-app/internal/utils" +) + +type BackupHandler struct { + backupService *service.BackupService +} + +func NewBackupHandler(backupService *service.BackupService) *BackupHandler { + return &BackupHandler{backupService: backupService} +} + +// List GET /admin/backups +func (h *BackupHandler) List(c *gin.Context) { + backups, err := h.backupService.List() + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست بک‌آپ‌ها", nil) + return + } + utils.Success(c, http.StatusOK, "", backups) +} + +// Create POST /admin/backups +func (h *BackupHandler) Create(c *gin.Context) { + info, err := h.backupService.Create() + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "گرفتن بک‌آپ ناموفق بود: "+err.Error(), nil) + return + } + utils.Success(c, http.StatusCreated, "بک‌آپ با موفقیت ساخته شد", info) +} + +// Download GET /admin/backups/:filename +func (h *BackupHandler) Download(c *gin.Context) { + filename := c.Param("filename") + path, err := h.backupService.ResolvePath(filename) + if err != nil { + utils.Fail(c, http.StatusNotFound, "فایل بک‌آپ یافت نشد", nil) + return + } + c.FileAttachment(path, filename) +} diff --git a/internal/handler/chat_handler.go b/internal/handler/chat_handler.go index d8c40f2..f6c9a16 100644 --- a/internal/handler/chat_handler.go +++ b/internal/handler/chat_handler.go @@ -81,6 +81,10 @@ func (h *ChatHandler) handleError(c *gin.Context, err error) { switch { case errors.Is(err, service.ErrNotMatchMember): utils.Fail(c, http.StatusForbidden, err.Error(), nil) + case errors.Is(err, utils.ErrInvalidMessageEncoding), + errors.Is(err, utils.ErrMessageEmpty), + errors.Is(err, utils.ErrMessageTooLong): + utils.Fail(c, http.StatusBadRequest, err.Error(), nil) default: utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil) } diff --git a/internal/handler/monitoring_handler.go b/internal/handler/monitoring_handler.go new file mode 100644 index 0000000..7643208 --- /dev/null +++ b/internal/handler/monitoring_handler.go @@ -0,0 +1,35 @@ +package handler + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "dating-app/internal/monitoring" + "dating-app/internal/utils" +) + +type MonitoringHandler struct { + store *monitoring.Store +} + +func NewMonitoringHandler(store *monitoring.Store) *MonitoringHandler { + return &MonitoringHandler{store: store} +} + +// Summary GET /admin/monitoring/summary +func (h *MonitoringHandler) Summary(c *gin.Context) { + utils.Success(c, http.StatusOK, "", h.store.Summary()) +} + +// RecentRequests GET /admin/monitoring/requests?limit=100 +func (h *MonitoringHandler) RecentRequests(c *gin.Context) { + limit := 100 + if raw := c.Query("limit"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 1000 { + limit = n + } + } + utils.Success(c, http.StatusOK, "", h.store.Recent(limit)) +} diff --git a/internal/middleware/gzip_middleware.go b/internal/middleware/gzip_middleware.go new file mode 100644 index 0000000..c04a199 --- /dev/null +++ b/internal/middleware/gzip_middleware.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "compress/gzip" + "io" + "strings" + + "github.com/gin-gonic/gin" +) + +type gzipWriter struct { + gin.ResponseWriter + writer io.Writer +} + +func (g *gzipWriter) Write(data []byte) (int, error) { + return g.writer.Write(data) +} + +func (g *gzipWriter) WriteString(s string) (int, error) { + return g.writer.Write([]byte(s)) +} + +// Gzip پاسخ‌های JSON رو (وقتی کلاینت gzip رو توی Accept-Encoding پشتیبانی کنه) +// فشرده می‌کنه؛ بدون هیچ وابستگی بیرونی، فقط با compress/gzip استاندارد گو. +// برای مسیرهایی مثل فید دیسکاوری که پاسخ JSON نسبتاً بزرگی برمی‌گردونن، حجم +// انتقال رو محسوس کم می‌کنه. +func Gzip() gin.HandlerFunc { + return func(c *gin.Context) { + if !strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") { + c.Next() + return + } + // فایل‌های عکسِ آپلودشده از قبل فشرده‌ان (jpeg/png/webp)؛ دوباره gzip + // کردنشون فقط CPU هدر می‌ده بدون فایده‌ی محسوس. + if strings.HasPrefix(c.Request.URL.Path, "/uploads") { + c.Next() + return + } + + gz := gzip.NewWriter(c.Writer) + defer gz.Close() + + c.Header("Content-Encoding", "gzip") + c.Header("Vary", "Accept-Encoding") + c.Writer = &gzipWriter{ResponseWriter: c.Writer, writer: gz} + c.Next() + } +} diff --git a/internal/middleware/metrics_middleware.go b/internal/middleware/metrics_middleware.go new file mode 100644 index 0000000..6ebc832 --- /dev/null +++ b/internal/middleware/metrics_middleware.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + + "dating-app/internal/models" + "dating-app/internal/monitoring" +) + +// Metrics هر درخواست رو (متد، مسیر، کد پاسخ، مدت زمان) توی Store ثبت می‌کنه. +// باید قبل از Recovery ثبت بشه (بیرونی‌تر) تا حتی درخواست‌هایی که پنیک می‌کنن +// و توسط Recovery مهار می‌شن هم توی آمار لحاظ بشن. +func Metrics(store *monitoring.Store) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + + defer func() { + path := c.FullPath() + if path == "" { + path = c.Request.URL.Path + } + + rec := monitoring.RequestRecord{ + Time: start, + Method: c.Request.Method, + Path: path, + StatusCode: c.Writer.Status(), + DurationMs: float64(time.Since(start).Microseconds()) / 1000.0, + ClientIP: c.ClientIP(), + } + if userVal, exists := c.Get(ContextUserKey); exists { + if user, ok := userVal.(*models.User); ok { + id := user.ID + rec.UserID = &id + } + } + store.Record(rec) + }() + + c.Next() + } +} diff --git a/internal/middleware/rate_limit_middleware.go b/internal/middleware/rate_limit_middleware.go new file mode 100644 index 0000000..315452f --- /dev/null +++ b/internal/middleware/rate_limit_middleware.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + + "dating-app/internal/utils" +) + +type bucket struct { + tokens float64 + lastSeenAt time.Time +} + +// RateLimiter یک rate limiter ساده‌ی token-bucket به‌ازای هر IP؛ برای جلوگیری +// از سوءاستفاده از اندپوینت‌های حساس مثل درخواست OTP (که هزینه‌ی پیامک داره +// و هدف مناسبی برای اسپم/brute-force هست). maxTokens یعنی سقف درخواست +// پشت‌سرهم مجاز، refillPerSecond یعنی سرعت شارژ دوباره‌ی سطل. +func RateLimiter(maxTokens, refillPerSecond float64) gin.HandlerFunc { + var mu sync.Mutex + buckets := make(map[string]*bucket) + + // سطل‌های راکد (IP هایی که مدتیه درخواست نزدن) رو دوره‌ای پاک می‌کنه تا + // حافظه با تعداد IP های دیده‌شده بی‌نهایت رشد نکنه. + go func() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for range ticker.C { + cutoff := time.Now().Add(-10 * time.Minute) + mu.Lock() + for k, b := range buckets { + if b.lastSeenAt.Before(cutoff) { + delete(buckets, k) + } + } + mu.Unlock() + } + }() + + return func(c *gin.Context) { + key := c.ClientIP() + now := time.Now() + + mu.Lock() + b, ok := buckets[key] + if !ok { + b = &bucket{tokens: maxTokens, lastSeenAt: now} + buckets[key] = b + } + elapsed := now.Sub(b.lastSeenAt).Seconds() + b.tokens = min(maxTokens, b.tokens+elapsed*refillPerSecond) + b.lastSeenAt = now + + allowed := b.tokens >= 1 + if allowed { + b.tokens-- + } + mu.Unlock() + + if !allowed { + utils.Fail(c, http.StatusTooManyRequests, "درخواست‌های زیادی فرستادی؛ کمی صبر کن و دوباره امتحان کن", nil) + c.Abort() + return + } + c.Next() + } +} diff --git a/internal/middleware/rate_limit_middleware_test.go b/internal/middleware/rate_limit_middleware_test.go new file mode 100644 index 0000000..741213c --- /dev/null +++ b/internal/middleware/rate_limit_middleware_test.go @@ -0,0 +1,74 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func newTestEngine(mw gin.HandlerFunc) *gin.Engine { + gin.SetMode(gin.TestMode) + engine := gin.New() + engine.GET("/x", mw, func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + return engine +} + +func TestRateLimiter_AllowsWithinLimit(t *testing.T) { + engine := newTestEngine(RateLimiter(3, 1)) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = "1.2.3.4:1111" + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("request %d: expected 200, got %d", i+1, w.Code) + } + } +} + +func TestRateLimiter_BlocksOverLimit(t *testing.T) { + engine := newTestEngine(RateLimiter(2, 0)) // بدون شارژ دوباره، فقط ۲ تا مجازه + + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = "5.6.7.8:2222" + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("request %d: expected 200, got %d", i+1, w.Code) + } + } + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = "5.6.7.8:2222" + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("expected 429 on 3rd request, got %d", w.Code) + } +} + +func TestRateLimiter_SeparateBucketsPerIP(t *testing.T) { + engine := newTestEngine(RateLimiter(1, 0)) + + req1 := httptest.NewRequest(http.MethodGet, "/x", nil) + req1.RemoteAddr = "9.9.9.9:1" + w1 := httptest.NewRecorder() + engine.ServeHTTP(w1, req1) + if w1.Code != http.StatusOK { + t.Fatalf("expected 200 for first IP, got %d", w1.Code) + } + + req2 := httptest.NewRequest(http.MethodGet, "/x", nil) + req2.RemoteAddr = "8.8.8.8:1" + w2 := httptest.NewRecorder() + engine.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Fatalf("expected 200 for a different IP (separate bucket), got %d", w2.Code) + } +} diff --git a/internal/middleware/recovery_middleware.go b/internal/middleware/recovery_middleware.go new file mode 100644 index 0000000..72de913 --- /dev/null +++ b/internal/middleware/recovery_middleware.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "log" + "net/http" + "runtime/debug" + + "github.com/gin-gonic/gin" + + "dating-app/internal/utils" +) + +// Recovery از کرش کردن کل پروسه به‌خاطر panic غیرمنتظره‌ی یک هندلر +// (nil pointer، index out of range و...) جلوگیری می‌کنه: استک‌تریس رو لاگ +// می‌کنه و به‌جای متن ساده‌ی پیش‌فرض Gin، همون envelope استاندارد JSON +// پروژه (utils.Fail) رو برمی‌گردونه تا کلاینت‌ها فرمت یکسانی ببینن. +func Recovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + log.Printf("[PANIC RECOVERED] %v\n%s", r, debug.Stack()) + if !c.Writer.Written() { + utils.Fail(c, http.StatusInternalServerError, "خطای غیرمنتظره‌ای رخ داد؛ لطفاً دوباره امتحان کن", nil) + } + c.Abort() + } + }() + c.Next() + } +} diff --git a/internal/monitoring/store.go b/internal/monitoring/store.go new file mode 100644 index 0000000..7ac5a22 --- /dev/null +++ b/internal/monitoring/store.go @@ -0,0 +1,134 @@ +// Package monitoring یک ذخیره‌ساز سبک و درون‌حافظه‌ای برای دیدبانی درخواست‌هاست: +// آخرین درخواست‌ها (برای دیباگ لحظه‌ای) + آمار تجمیعی (بار، زمان پاسخ، نرخ خطا). +// عمداً بدون وابستگی بیرونی (بدون Prometheus/Redis) چون در این مقیاس یک حلقه‌ی +// درون‌حافظه‌ای thread-safe کافیه؛ اگه بعداً مقیاس بزرگ‌تر شد، همین Store رو می‌شه +// پشت یک exporter واقعی گذاشت. +package monitoring + +import ( + "sort" + "sync" + "time" +) + +const bufferSize = 1000 + +type RequestRecord struct { + Time time.Time `json:"time"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int `json:"status_code"` + DurationMs float64 `json:"duration_ms"` + ClientIP string `json:"client_ip"` + UserID *uint `json:"user_id,omitempty"` +} + +type Summary struct { + TotalRequests int64 `json:"total_requests"` + TotalErrors int64 `json:"total_errors"` + ErrorRatePct float64 `json:"error_rate_pct"` + AvgLatencyMs float64 `json:"avg_latency_ms"` + P95LatencyMs float64 `json:"p95_latency_ms"` + RequestsLastMin int `json:"requests_last_min"` + StartedAt time.Time `json:"started_at"` + UptimeSeconds float64 `json:"uptime_seconds"` +} + +// Store یک حلقه‌ی ثابت‌اندازه (ring buffer) از آخرین درخواست‌ها به‌همراه +// شمارنده‌های تجمیعیه. نوشتن/خواندن با RWMutex محافظت می‌شه؛ هزینه‌ی هر +// درخواست فقط یک قفل کوتاه و بدون I/O روی دیسکه (سریع، مناسب مسیر داغ). +type Store struct { + mu sync.RWMutex + buf []RequestRecord + next int + count int + total int64 + totalErr int64 + sumLatency float64 + startedAt time.Time +} + +func NewStore() *Store { + return &Store{ + buf: make([]RequestRecord, bufferSize), + startedAt: time.Now(), + } +} + +func (s *Store) Record(rec RequestRecord) { + s.mu.Lock() + defer s.mu.Unlock() + + s.buf[s.next] = rec + s.next = (s.next + 1) % bufferSize + if s.count < bufferSize { + s.count++ + } + s.total++ + s.sumLatency += rec.DurationMs + if rec.StatusCode >= 400 { + s.totalErr++ + } +} + +// Recent آخرین n درخواست رو برمی‌گردونه (جدیدترین اول). +func (s *Store) Recent(n int) []RequestRecord { + s.mu.RLock() + defer s.mu.RUnlock() + + if n > s.count { + n = s.count + } + out := make([]RequestRecord, 0, n) + idx := s.next - 1 + for i := 0; i < n; i++ { + if idx < 0 { + idx = bufferSize - 1 + } + out = append(out, s.buf[idx]) + idx-- + } + return out +} + +func (s *Store) Summary() Summary { + s.mu.RLock() + defer s.mu.RUnlock() + + var errRate, avgLatency float64 + if s.total > 0 { + errRate = float64(s.totalErr) / float64(s.total) * 100 + avgLatency = s.sumLatency / float64(s.total) + } + + cutoff := time.Now().Add(-1 * time.Minute) + lastMin := 0 + latencies := make([]float64, 0, s.count) + for i := 0; i < s.count; i++ { + r := s.buf[i] + if r.Time.After(cutoff) { + lastMin++ + } + latencies = append(latencies, r.DurationMs) + } + sort.Float64s(latencies) + p95 := 0.0 + if len(latencies) > 0 { + idx := int(float64(len(latencies)) * 0.95) + if idx >= len(latencies) { + idx = len(latencies) - 1 + } + p95 = latencies[idx] + } + + return Summary{ + TotalRequests: s.total, + TotalErrors: s.totalErr, + ErrorRatePct: errRate, + AvgLatencyMs: avgLatency, + P95LatencyMs: p95, + RequestsLastMin: lastMin, + StartedAt: s.startedAt, + UptimeSeconds: time.Since(s.startedAt).Seconds(), + } +} diff --git a/internal/monitoring/store_test.go b/internal/monitoring/store_test.go new file mode 100644 index 0000000..0335f32 --- /dev/null +++ b/internal/monitoring/store_test.go @@ -0,0 +1,74 @@ +package monitoring + +import ( + "testing" + "time" +) + +func TestStore_RecordAndSummary(t *testing.T) { + s := NewStore() + + s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 200, DurationMs: 10}) + s.Record(RequestRecord{Time: time.Now(), Method: "GET", Path: "/x", StatusCode: 500, DurationMs: 20}) + + sum := s.Summary() + if sum.TotalRequests != 2 { + t.Fatalf("expected 2 total requests, got %d", sum.TotalRequests) + } + if sum.TotalErrors != 1 { + t.Fatalf("expected 1 error (status>=400), got %d", sum.TotalErrors) + } + if sum.ErrorRatePct != 50 { + t.Fatalf("expected 50%% error rate, got %v", sum.ErrorRatePct) + } + if sum.AvgLatencyMs != 15 { + t.Fatalf("expected avg latency 15ms, got %v", sum.AvgLatencyMs) + } +} + +func TestStore_RecentOrder(t *testing.T) { + s := NewStore() + s.Record(RequestRecord{Time: time.Now(), Path: "/1", StatusCode: 200}) + s.Record(RequestRecord{Time: time.Now(), Path: "/2", StatusCode: 200}) + s.Record(RequestRecord{Time: time.Now(), Path: "/3", StatusCode: 200}) + + recent := s.Recent(10) + if len(recent) != 3 { + t.Fatalf("expected 3 records, got %d", len(recent)) + } + // جدیدترین باید اول باشه + if recent[0].Path != "/3" || recent[1].Path != "/2" || recent[2].Path != "/1" { + t.Fatalf("expected newest-first order, got %+v", recent) + } +} + +func TestStore_RecentLimit(t *testing.T) { + s := NewStore() + for i := 0; i < 5; i++ { + s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200}) + } + if got := len(s.Recent(2)); got != 2 { + t.Fatalf("expected limit of 2, got %d", got) + } + if got := len(s.Recent(100)); got != 5 { + t.Fatalf("expected all 5 when limit exceeds count, got %d", got) + } +} + +func TestStore_RingBufferWraparound(t *testing.T) { + s := NewStore() + // بیشتر از ظرفیت بافر ثبت می‌کنیم تا رفتار wraparound رو تست کنیم + for i := 0; i < bufferSize+50; i++ { + s.Record(RequestRecord{Time: time.Now(), Path: "/x", StatusCode: 200, DurationMs: 1}) + } + + sum := s.Summary() + if sum.TotalRequests != int64(bufferSize+50) { + t.Fatalf("expected total requests to keep counting past buffer size, got %d", sum.TotalRequests) + } + + recent := s.Recent(bufferSize + 100) + if len(recent) != bufferSize { + t.Fatalf("expected Recent to be capped at bufferSize (%d), got %d", bufferSize, len(recent)) + } +} diff --git a/internal/repository/message_repository.go b/internal/repository/message_repository.go index 6e523a3..06b62c6 100644 --- a/internal/repository/message_repository.go +++ b/internal/repository/message_repository.go @@ -33,3 +33,29 @@ func (r *MessageRepository) LastMessage(matchID uint) (*models.Message, error) { } return &msg, nil } + +// LastMessagesByMatchIDs آخرین پیام هر مچ رو، برای چند مچ با هم در یک کوئری +// برمی‌گردونه (به‌جای یک کوئری جدا به‌ازای هر مچ توی لیست گفتگوها). از +// DISTINCT ON پستگرس استفاده می‌کنه که دقیقاً برای همین کار («آخرین ردیف هر +// گروه») ساخته شده. +func (r *MessageRepository) LastMessagesByMatchIDs(matchIDs []uint) (map[uint]models.Message, error) { + result := make(map[uint]models.Message, len(matchIDs)) + if len(matchIDs) == 0 { + return result, nil + } + + var messages []models.Message + err := r.db. + Distinct("ON (match_id) *"). + Where("match_id IN ?", matchIDs). + Order("match_id, created_at desc"). + Find(&messages).Error + if err != nil { + return nil, err + } + + for _, m := range messages { + result[m.MatchID] = m + } + return result, nil +} diff --git a/internal/repository/option_repository.go b/internal/repository/option_repository.go index dcef60b..f864dee 100644 --- a/internal/repository/option_repository.go +++ b/internal/repository/option_repository.go @@ -2,23 +2,41 @@ package repository import ( "errors" + "time" "gorm.io/gorm" + "dating-app/internal/cache" "dating-app/internal/models" ) var ErrCategoryNotFound = errors.New("دسته‌ی موردنظر یافت نشد") +// activeOptionsCacheTTL: گزینه‌های فعال هر دسته و خودِ رکورد دسته فقط از پنل +// ادمین تغییر می‌کنن (که خیلی به‌ندرت اتفاق می‌افته) ولی توی هر مرحله‌ی +// آنبوردینگ/دیسکاوری هر کاربر خونده می‌شن؛ کش کردنشون فشار روی دیتابیس رو +// زیاد کم می‌کنه. +const activeOptionsCacheTTL = 5 * time.Minute + type OptionRepository struct { - db *gorm.DB + db *gorm.DB + activeOptionsByKey *cache.TTLCache[models.CategoryKey, []models.Option] + categoryByKey *cache.TTLCache[models.CategoryKey, models.Category] } func NewOptionRepository(db *gorm.DB) *OptionRepository { - return &OptionRepository{db: db} + return &OptionRepository{ + db: db, + activeOptionsByKey: cache.New[models.CategoryKey, []models.Option](activeOptionsCacheTTL), + categoryByKey: cache.New[models.CategoryKey, models.Category](activeOptionsCacheTTL), + } } func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Category, error) { + if cached, ok := r.categoryByKey.Get(key); ok { + return &cached, nil + } + var cat models.Category err := r.db.Where("key = ?", key).First(&cat).Error if err == gorm.ErrRecordNotFound { @@ -27,14 +45,25 @@ func (r *OptionRepository) GetCategoryByKey(key models.CategoryKey) (*models.Cat if err != nil { return nil, err } + + r.categoryByKey.Set(key, cat) return &cat, nil } func (r *OptionRepository) UpdateCategory(cat *models.Category) error { - return r.db.Save(cat).Error + if err := r.db.Save(cat).Error; err != nil { + return err + } + r.activeOptionsByKey.Delete(cat.Key) + r.categoryByKey.Delete(cat.Key) + return nil } func (r *OptionRepository) ListActiveOptionsByCategoryKey(key models.CategoryKey) ([]models.Option, error) { + if cached, ok := r.activeOptionsByKey.Get(key); ok { + return cached, nil + } + cat, err := r.GetCategoryByKey(key) if err != nil { return nil, err @@ -43,7 +72,12 @@ func (r *OptionRepository) ListActiveOptionsByCategoryKey(key models.CategoryKey err = r.db.Where("category_id = ? AND is_active = ?", cat.ID, true). Order("sort_order asc, id asc"). Find(&options).Error - return options, err + if err != nil { + return nil, err + } + + r.activeOptionsByKey.Set(key, options) + return options, nil } // ListOptionsByCategoryKeyAdmin تمام گزینه‌ها (فعال و غیرفعال) رو برمی‌گردونه - برای پنل ادمین @@ -60,7 +94,11 @@ func (r *OptionRepository) ListOptionsByCategoryKeyAdmin(key models.CategoryKey) } func (r *OptionRepository) CreateOption(opt *models.Option) error { - return r.db.Create(opt).Error + if err := r.db.Create(opt).Error; err != nil { + return err + } + r.activeOptionsByKey.Clear() + return nil } func (r *OptionRepository) FindOptionByID(id uint) (*models.Option, error) { @@ -73,11 +111,19 @@ func (r *OptionRepository) FindOptionByID(id uint) (*models.Option, error) { } func (r *OptionRepository) UpdateOption(opt *models.Option) error { - return r.db.Save(opt).Error + if err := r.db.Save(opt).Error; err != nil { + return err + } + r.activeOptionsByKey.Clear() + return nil } func (r *OptionRepository) DeleteOption(id uint) error { - return r.db.Delete(&models.Option{}, id).Error + if err := r.db.Delete(&models.Option{}, id).Error; err != nil { + return err + } + r.activeOptionsByKey.Clear() + return nil } // FindActiveOptionsByIDsInCategory فقط گزینه‌های فعالی که در این دسته هستند و id شون @@ -128,3 +174,52 @@ func (r *OptionRepository) CountUserSelections(userID, categoryID uint) (int64, Count(&count).Error return count, err } + +// GetSelectionsForUsersInCategory انتخاب‌های چند کاربر رو در یک دسته‌ی مشخص، +// همه با هم در یک کوئری برمی‌گردونه (به‌جای یک کوئری جدا برای هر کاربر). +// برای فید دیسکاوری استفاده می‌شه که تا ۶۰ کاندیدا با هم پردازش می‌شن. +func (r *OptionRepository) GetSelectionsForUsersInCategory(userIDs []uint, categoryID uint) (map[uint][]string, error) { + result := make(map[uint][]string, len(userIDs)) + if len(userIDs) == 0 { + return result, nil + } + + var selections []models.UserOptionSelection + err := r.db.Preload("Option"). + Where("user_id IN ? AND category_id = ?", userIDs, categoryID). + Find(&selections).Error + if err != nil { + return nil, err + } + + for _, sel := range selections { + if sel.Option != nil { + result[sel.UserID] = append(result[sel.UserID], sel.Option.Title) + } + } + return result, nil +} + +// GetUserSelectionsForCategories انتخاب‌های یک کاربر رو در چند دسته با هم، در +// یک کوئری برمی‌گردونه (به‌جای یک کوئری جدا به‌ازای هر دسته). برای صفحه‌ی +// جزئیات پروفایل استفاده می‌شه که هم‌زمان چند دسته (تحصیلات، شغل، علاقه‌مندی و...) +// رو نشون می‌ده. +func (r *OptionRepository) GetUserSelectionsForCategories(userID uint, categoryIDs []uint) (map[uint][]models.UserOptionSelection, error) { + result := make(map[uint][]models.UserOptionSelection) + if len(categoryIDs) == 0 { + return result, nil + } + + var selections []models.UserOptionSelection + err := r.db.Preload("Option"). + Where("user_id = ? AND category_id IN ?", userID, categoryIDs). + Find(&selections).Error + if err != nil { + return nil, err + } + + for _, sel := range selections { + result[sel.CategoryID] = append(result[sel.CategoryID], sel) + } + return result, nil +} diff --git a/internal/repository/question_repository.go b/internal/repository/question_repository.go index 15761d7..c9a3524 100644 --- a/internal/repository/question_repository.go +++ b/internal/repository/question_repository.go @@ -1,23 +1,40 @@ package repository import ( + "time" + "gorm.io/gorm" + "dating-app/internal/cache" "dating-app/internal/models" ) +const activeQuestionsCacheTTL = 5 * time.Minute +const activeQuestionsCacheKey = "active" + type QuestionRepository struct { - db *gorm.DB + db *gorm.DB + activeCache *cache.TTLCache[string, []models.ProfileQuestion] } func NewQuestionRepository(db *gorm.DB) *QuestionRepository { - return &QuestionRepository{db: db} + return &QuestionRepository{ + db: db, + activeCache: cache.New[string, []models.ProfileQuestion](activeQuestionsCacheTTL), + } } func (r *QuestionRepository) ListActive() ([]models.ProfileQuestion, error) { + if cached, ok := r.activeCache.Get(activeQuestionsCacheKey); ok { + return cached, nil + } var qs []models.ProfileQuestion err := r.db.Where("is_active = ?", true).Order("sort_order asc, id asc").Find(&qs).Error - return qs, err + if err != nil { + return nil, err + } + r.activeCache.Set(activeQuestionsCacheKey, qs) + return qs, nil } func (r *QuestionRepository) ListAllAdmin() ([]models.ProfileQuestion, error) { @@ -42,15 +59,27 @@ func (r *QuestionRepository) FindActiveByIDs(ids []uint) ([]models.ProfileQuesti } func (r *QuestionRepository) Create(q *models.ProfileQuestion) error { - return r.db.Create(q).Error + if err := r.db.Create(q).Error; err != nil { + return err + } + r.activeCache.Clear() + return nil } func (r *QuestionRepository) Update(q *models.ProfileQuestion) error { - return r.db.Save(q).Error + if err := r.db.Save(q).Error; err != nil { + return err + } + r.activeCache.Clear() + return nil } func (r *QuestionRepository) Delete(id uint) error { - return r.db.Delete(&models.ProfileQuestion{}, id).Error + if err := r.db.Delete(&models.ProfileQuestion{}, id).Error; err != nil { + return err + } + r.activeCache.Clear() + return nil } // ReplaceUserAnswers پاسخ‌های قبلی کاربر رو با پاسخ‌های جدید جایگزین می‌کنه (تراکنشی) diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index e77aef5..1796ee2 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -34,6 +34,18 @@ func (r *UserRepository) FindByID(id uint) (*models.User, error) { return &user, nil } +// FindByIDs چند کاربر رو با هم در یک کوئری برمی‌گردونه (به‌جای یک FindByID +// جدا به‌ازای هر id)؛ برای لیست‌هایی مثل مچ‌ها که باید اطلاعات طرف مقابل +// چندین مچ رو یک‌جا نشون بدن. +func (r *UserRepository) FindByIDs(ids []uint) ([]models.User, error) { + var users []models.User + if len(ids) == 0 { + return users, nil + } + err := r.db.Where("id IN ?", ids).Find(&users).Error + return users, err +} + // GetOrCreateByPhone کاربر را با شماره‌ی داده‌شده پیدا می‌کند یا در صورت نبود می‌سازد. func (r *UserRepository) GetOrCreateByPhone(phone string) (*models.User, bool, error) { var user models.User diff --git a/internal/router/router.go b/internal/router/router.go index 02b93a8..c5abd00 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -16,6 +16,8 @@ type Handlers struct { Admin *handler.AdminHandler Discovery *handler.DiscoveryHandler Chat *handler.ChatHandler + Monitoring *handler.MonitoringHandler + Backup *handler.BackupHandler } func Setup( @@ -25,6 +27,7 @@ func Setup( userRepo *repository.UserRepository, ) { engine.Use(middleware.CORS()) + engine.Use(middleware.Gzip()) engine.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) @@ -36,10 +39,13 @@ func Setup( api := engine.Group("/api/v1") // -------- احراز هویت (عمومی) -------- + // درخواست OTP هزینه‌ی پیامک داره و هدف مناسبی برای اسپم/brute-force هست؛ + // حداکثر ۵ درخواست، بعد هر ثانیه یک توکن شارژ می‌شه (یعنی حدود ۵ تا در دقیقه‌ی اول و بعدش پیوسته). + otpRateLimit := middleware.RateLimiter(5, 1.0/12.0) auth := api.Group("/auth") { - auth.POST("/request-otp", h.Auth.RequestOTP) - auth.POST("/verify-otp", h.Auth.VerifyOTP) + auth.POST("/request-otp", otpRateLimit, h.Auth.RequestOTP) + auth.POST("/verify-otp", otpRateLimit, h.Auth.VerifyOTP) auth.POST("/token/refresh", h.Auth.RefreshToken) } @@ -106,5 +112,14 @@ func Setup( admin.POST("/profile-questions", h.Admin.CreateQuestion) admin.PATCH("/profile-questions/:id", h.Admin.UpdateQuestion) admin.DELETE("/profile-questions/:id", h.Admin.DeleteQuestion) + + // مانیتورینگ: بار/زمان/ریسپانس درخواست‌های اخیر سرور + admin.GET("/monitoring/summary", h.Monitoring.Summary) + admin.GET("/monitoring/requests", h.Monitoring.RecentRequests) + + // بک‌آپ دیتابیس + admin.GET("/backups", h.Backup.List) + admin.POST("/backups", h.Backup.Create) + admin.GET("/backups/:filename", h.Backup.Download) } } diff --git a/internal/service/backup_service.go b/internal/service/backup_service.go new file mode 100644 index 0000000..4d2b9aa --- /dev/null +++ b/internal/service/backup_service.go @@ -0,0 +1,136 @@ +package service + +import ( + "bytes" + "compress/gzip" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "time" + + "dating-app/internal/config" +) + +var ( + ErrBackupNotFound = errors.New("فایل بک‌آپ یافت نشد") + backupNamePattern = regexp.MustCompile(`^backup_\d{8}_\d{6}\.sql\.gz$`) +) + +type BackupInfo struct { + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt time.Time `json:"created_at"` +} + +// BackupService با اجرای pg_dump یک نسخه‌ی پشتیبان فشرده از کل دیتابیس +// می‌سازه. برای گرفتن بک‌آپ نیازی به هیچ ابزار بیرونی/سرویس ابری نیست؛ +// فایل‌ها روی همون دیسک سرور (پوشه‌ی dir) نگه داشته می‌شن. +type BackupService struct { + cfg *config.Config + dir string +} + +func NewBackupService(cfg *config.Config, dir string) *BackupService { + return &BackupService{cfg: cfg, dir: dir} +} + +// Create یک بک‌آپ جدید می‌گیره: خروجی pg_dump به‌صورت stream به gzip داده +// می‌شه تا حافظه و فضای دیسک مصرفی کمتر باشه. +func (s *BackupService) Create() (*BackupInfo, error) { + if err := os.MkdirAll(s.dir, 0o755); err != nil { + return nil, fmt.Errorf("ساخت پوشه‌ی بک‌آپ ناموفق بود: %w", err) + } + + filename := fmt.Sprintf("backup_%s.sql.gz", time.Now().Format("20060102_150405")) + fullPath := filepath.Join(s.dir, filename) + + out, err := os.Create(fullPath) + if err != nil { + return nil, fmt.Errorf("ساخت فایل بک‌آپ ناموفق بود: %w", err) + } + + gz := gzip.NewWriter(out) + + cmd := exec.Command("pg_dump", + "-h", s.cfg.DBHost, + "-p", s.cfg.DBPort, + "-U", s.cfg.DBUser, + "-d", s.cfg.DBName, + "--no-password", + "--format=plain", + ) + cmd.Env = append(os.Environ(), "PGPASSWORD="+s.cfg.DBPassword) + cmd.Stdout = gz + var stderr bytes.Buffer + cmd.Stderr = &stderr + + runErr := cmd.Run() + gzErr := gz.Close() + outErr := out.Close() + + if runErr != nil { + os.Remove(fullPath) + return nil, fmt.Errorf("pg_dump ناموفق بود: %v - %s", runErr, stderr.String()) + } + if gzErr != nil { + os.Remove(fullPath) + return nil, gzErr + } + if outErr != nil { + return nil, outErr + } + + info, err := os.Stat(fullPath) + if err != nil { + return nil, err + } + return &BackupInfo{Filename: filename, SizeBytes: info.Size(), CreatedAt: info.ModTime()}, nil +} + +// List بک‌آپ‌های موجود رو، جدیدترین اول، برمی‌گردونه. +func (s *BackupService) List() ([]BackupInfo, error) { + entries, err := os.ReadDir(s.dir) + if err != nil { + if os.IsNotExist(err) { + return []BackupInfo{}, nil + } + return nil, err + } + + backups := make([]BackupInfo, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !backupNamePattern.MatchString(e.Name()) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + backups = append(backups, BackupInfo{ + Filename: e.Name(), + SizeBytes: info.Size(), + CreatedAt: info.ModTime(), + }) + } + sort.Slice(backups, func(i, j int) bool { return backups[i].CreatedAt.After(backups[j].CreatedAt) }) + return backups, nil +} + +// ResolvePath یک نام فایل بک‌آپ (که مستقیماً از پارامتر URL میاد) رو به مسیر +// کامل تبدیل می‌کنه. چون ورودی از کاربره، صراحتاً باید در برابر path traversal +// (مثلاً "../../etc/passwd") محافظت بشه؛ برای همین فقط اسم‌هایی که دقیقاً با +// الگوی خودمون ساخته شدن (backup_YYYYMMDD_HHMMSS.sql.gz) قبول می‌شن. +func (s *BackupService) ResolvePath(filename string) (string, error) { + if !backupNamePattern.MatchString(filename) { + return "", ErrBackupNotFound + } + full := filepath.Join(s.dir, filename) + if _, err := os.Stat(full); err != nil { + return "", ErrBackupNotFound + } + return full, nil +} diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 51d5eb5..72eb4ed 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -7,6 +7,7 @@ import ( "dating-app/internal/dto" "dating-app/internal/models" "dating-app/internal/repository" + "dating-app/internal/utils" ) var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست") @@ -32,34 +33,65 @@ func NewChatService( } } -// ListMatches همه‌ی مچ‌های کاربر رو همراه با اطلاعات طرف مقابل و آخرین پیام برمی‌گردونه +// ListMatches همه‌ی مچ‌های کاربر رو همراه با اطلاعات طرف مقابل و آخرین پیام +// برمی‌گردونه. قبلاً به‌ازای هر مچ ۳ کوئری جدا (کاربر، عکس‌ها، آخرین پیام) +// می‌زد؛ الان هر سه رو batch می‌کنه تا صرف‌نظر از تعداد مچ‌ها، تعداد کوئری‌ها +// ثابت (۴ تا) بمونه. func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) { matches, err := s.matchRepo.ListByUser(userID) if err != nil { return nil, err } + if len(matches) == 0 { + return []dto.MatchView{}, nil + } + + otherIDs := make([]uint, len(matches)) + matchIDs := make([]uint, len(matches)) + for i, m := range matches { + otherIDs[i] = repository.OtherUserID(&m, userID) + matchIDs[i] = m.ID + } + + otherUsers, err := s.userRepo.FindByIDs(otherIDs) + if err != nil { + return nil, err + } + userByID := make(map[uint]models.User, len(otherUsers)) + for _, u := range otherUsers { + userByID[u.ID] = u + } + + photos, err := s.photoRepo.ListByUserIDs(otherIDs) + if err != nil { + return nil, err + } + photosByUser := make(map[uint][]string, len(otherIDs)) + for _, p := range photos { + photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL) + } + + lastMessages, err := s.messageRepo.LastMessagesByMatchIDs(matchIDs) + 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 { + other, ok := userByID[otherID] + if !ok { 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, + Photos: orEmptyStrings(photosByUser[otherID]), CreatedAt: m.CreatedAt.Format(time.RFC3339), } - if last, err := s.messageRepo.LastMessage(m.ID); err == nil { + if last, ok := lastMessages[m.ID]; ok { text := last.Text at := last.CreatedAt.Format(time.RFC3339) view.LastMessage = &text @@ -110,7 +142,12 @@ func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.Messa return nil, err } - msg := &models.Message{MatchID: matchID, SenderID: userID, Text: text} + clean, err := utils.SanitizeMessageText(text) + if err != nil { + return nil, err + } + + msg := &models.Message{MatchID: matchID, SenderID: userID, Text: clean} if err := s.messageRepo.Create(msg); err != nil { return nil, err } diff --git a/internal/service/discovery_service.go b/internal/service/discovery_service.go index 657a57e..f4a42f3 100644 --- a/internal/service/discovery_service.go +++ b/internal/service/discovery_service.go @@ -134,33 +134,37 @@ func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, mi 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], + ID: wd.user.ID, + FirstName: wd.user.FirstName, + Age: utils.CalcAge(wd.user.BirthDate), + // نبود عکس/علاقه‌مندی نباید توی JSON به‌صورت null دربیاد؛ کلاینت‌ها + // همیشه یک آرایه (حتی خالی) انتظار دارن، نه null. + Photos: orEmptyStrings(photosByUser[wd.user.ID]), DistanceKM: wd.distance, - Interests: interestsByUser[wd.user.ID], + Interests: orEmptyStrings(interestsByUser[wd.user.ID]), }) } return result, nil } +func orEmptyStrings(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +// batchInterests علاقه‌مندی‌های تمام کاندیداهای فید رو در یک کوئری واحد +// می‌گیره (به‌جای یک کوئری جدا به‌ازای هر کاندیدا)؛ روی فید دیسکاوری که تا ۶۰ +// کاربر با هم پردازش می‌شن، این تفاوت بین ۱ کوئری و تا ۶۰ کوئری رو ایجاد می‌کنه. 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 + return map[uint][]string{} } - 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) - } - } + result, err := s.optionRepo.GetSelectionsForUsersInCategory(userIDs, cat.ID) + if err != nil { + return map[uint][]string{} } return result } @@ -254,12 +258,26 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint 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) + titlesByCategory := s.selectionTitlesByCategory(target.ID, []models.CategoryKey{ + models.CategoryEducationLevel, + models.CategoryFieldOfStudy, + models.CategoryLookingFor, + models.CategoryOccupation, + models.CategoryInterest, + models.CategoryImportantInRelationship, + }) + first := func(titles []string) string { + if len(titles) == 0 { + return "" + } + return titles[0] + } + detail.Education = first(titlesByCategory[models.CategoryEducationLevel]) + detail.FieldOfStudy = first(titlesByCategory[models.CategoryFieldOfStudy]) + detail.LookingFor = first(titlesByCategory[models.CategoryLookingFor]) + detail.Occupations = titlesByCategory[models.CategoryOccupation] + detail.Interests = titlesByCategory[models.CategoryInterest] + detail.ImportantInRelationship = titlesByCategory[models.CategoryImportantInRelationship] if answers, err := s.questionRepo.GetUserAnswers(target.ID); err == nil { for _, a := range answers { @@ -276,28 +294,37 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint return detail, nil } -func (s *DiscoveryService) allSelectionTitles(userID uint, key models.CategoryKey) []string { - cat, err := s.optionRepo.GetCategoryByKey(key) - if err != nil { - return nil +// selectionTitlesByCategory انتخاب‌های یک کاربر رو در چند دسته با هم، طی یک +// کوئری واحد به دیتابیس می‌گیره (به‌جای یک کوئری جدا به‌ازای هر دسته که قبلاً +// برای صفحه‌ی جزئیات پروفایل تا ۶ رفت‌وبرگشت جدا به دیتابیس می‌زد). +// GetCategoryByKey از کش می‌خونه (چون دسته‌ها به‌ندرت تغییر می‌کنن) پس عملاً +// فقط همین یک کوئری واقعی به دیتابیس زده می‌شه. +func (s *DiscoveryService) selectionTitlesByCategory(userID uint, keys []models.CategoryKey) map[models.CategoryKey][]string { + result := make(map[models.CategoryKey][]string, len(keys)) + + categoryIDToKey := make(map[uint]models.CategoryKey, len(keys)) + categoryIDs := make([]uint, 0, len(keys)) + for _, key := range keys { + cat, err := s.optionRepo.GetCategoryByKey(key) + if err != nil { + continue + } + categoryIDToKey[cat.ID] = key + categoryIDs = append(categoryIDs, cat.ID) } - sels, err := s.optionRepo.GetUserSelections(userID, cat.ID) + + selectionsByCategoryID, err := s.optionRepo.GetUserSelectionsForCategories(userID, categoryIDs) if err != nil { - return nil + return result } - titles := make([]string, 0, len(sels)) - for _, sel := range sels { - if sel.Option != nil { - titles = append(titles, sel.Option.Title) + + for categoryID, sels := range selectionsByCategoryID { + key := categoryIDToKey[categoryID] + for _, sel := range sels { + if sel.Option != nil { + result[key] = append(result[key], 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] + return result } diff --git a/internal/utils/age_test.go b/internal/utils/age_test.go new file mode 100644 index 0000000..79165f4 --- /dev/null +++ b/internal/utils/age_test.go @@ -0,0 +1,26 @@ +package utils + +import ( + "testing" + "time" +) + +func TestCalcAge_NilBirthDate(t *testing.T) { + if age := CalcAge(nil); age != 0 { + t.Fatalf("expected 0 for nil birth date, got %d", age) + } +} + +func TestCalcAge_BirthdayAlreadyPassedThisYear(t *testing.T) { + birth := time.Now().AddDate(-25, -1, 0) // ۲۵ سال و ۱ ماه پیش -> تولد امسال رد شده + if age := CalcAge(&birth); age != 25 { + t.Fatalf("expected age 25, got %d", age) + } +} + +func TestCalcAge_BirthdayNotYetThisYear(t *testing.T) { + birth := time.Now().AddDate(-25, 1, 0) // تولد ۱ ماه دیگه‌ست -> هنوز ۲۴ سالشه + if age := CalcAge(&birth); age != 24 { + t.Fatalf("expected age 24, got %d", age) + } +} diff --git a/internal/utils/geo_test.go b/internal/utils/geo_test.go new file mode 100644 index 0000000..b3e547b --- /dev/null +++ b/internal/utils/geo_test.go @@ -0,0 +1,18 @@ +package utils + +import "testing" + +func TestHaversineKM_SamePoint(t *testing.T) { + d := HaversineKM(35.6892, 51.3890, 35.6892, 51.3890) + if d > 0.001 { + t.Fatalf("expected ~0km for identical points, got %v", d) + } +} + +func TestHaversineKM_TehranToIsfahan(t *testing.T) { + // فاصله‌ی تهران تا اصفهان تقریباً ۳۴۰ کیلومتره (خط مستقیم) + d := HaversineKM(35.6892, 51.3890, 32.6546, 51.6680) + if d < 300 || d > 380 { + t.Fatalf("expected ~340km between Tehran and Isfahan, got %v", d) + } +} diff --git a/internal/utils/sanitize.go b/internal/utils/sanitize.go new file mode 100644 index 0000000..ddd2bc3 --- /dev/null +++ b/internal/utils/sanitize.go @@ -0,0 +1,47 @@ +package utils + +import ( + "errors" + "strings" + "unicode/utf8" +) + +var ( + ErrInvalidMessageEncoding = errors.New("متن پیام معتبر نیست (کدگذاری اشتباه)") + ErrMessageEmpty = errors.New("متن پیام نمی‌تواند خالی باشد") + ErrMessageTooLong = errors.New("متن پیام بیش از حد مجاز طولانی است") +) + +const MaxMessageLength = 2000 + +// SanitizeMessageText متن یک پیام چت رو قبل از ذخیره‌سازی پاک‌سازی/اعتبارسنجی می‌کنه: +// - رد کردن رشته‌هایی که UTF-8 معتبر نیستن (بایت خراب/دستکاری‌شده) +// - حذف کاراکترهای کنترلی (به‌جز \n و \t) که می‌تونن نمایش یا لاگ‌ها رو خراب کنن +// - trim فاصله‌های اضافه و محدود کردن طول +func SanitizeMessageText(raw string) (string, error) { + if !utf8.ValidString(raw) { + return "", ErrInvalidMessageEncoding + } + + var b strings.Builder + b.Grow(len(raw)) + for _, r := range raw { + if r == '\n' || r == '\t' { + b.WriteRune(r) + continue + } + if r < 0x20 || r == 0x7F { + continue + } + b.WriteRune(r) + } + + text := strings.TrimSpace(b.String()) + if text == "" { + return "", ErrMessageEmpty + } + if utf8.RuneCountInString(text) > MaxMessageLength { + return "", ErrMessageTooLong + } + return text, nil +} diff --git a/internal/utils/sanitize_test.go b/internal/utils/sanitize_test.go new file mode 100644 index 0000000..bfe8a8d --- /dev/null +++ b/internal/utils/sanitize_test.go @@ -0,0 +1,71 @@ +package utils + +import ( + "errors" + "strings" + "testing" +) + +func TestSanitizeMessageText_TrimsWhitespace(t *testing.T) { + got, err := SanitizeMessageText(" سلام دوست من ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "سلام دوست من" { + t.Fatalf("expected trimmed text, got %q", got) + } +} + +func TestSanitizeMessageText_EmptyAfterTrim(t *testing.T) { + _, err := SanitizeMessageText(" ") + if !errors.Is(err, ErrMessageEmpty) { + t.Fatalf("expected ErrMessageEmpty, got %v", err) + } +} + +func TestSanitizeMessageText_StripsControlChars(t *testing.T) { + got, err := SanitizeMessageText("سلام\x00\x07دنیا") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "سلامدنیا" { + t.Fatalf("expected control chars stripped, got %q", got) + } +} + +func TestSanitizeMessageText_KeepsNewlinesAndTabs(t *testing.T) { + got, err := SanitizeMessageText("خط اول\nخط دوم\tبا تب") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "خط اول\nخط دوم\tبا تب" { + t.Fatalf("expected newline/tab preserved, got %q", got) + } +} + +func TestSanitizeMessageText_InvalidUTF8(t *testing.T) { + invalid := string([]byte{0xff, 0xfe, 0xfd}) + _, err := SanitizeMessageText(invalid) + if !errors.Is(err, ErrInvalidMessageEncoding) { + t.Fatalf("expected ErrInvalidMessageEncoding, got %v", err) + } +} + +func TestSanitizeMessageText_TooLong(t *testing.T) { + long := strings.Repeat("a", MaxMessageLength+1) + _, err := SanitizeMessageText(long) + if !errors.Is(err, ErrMessageTooLong) { + t.Fatalf("expected ErrMessageTooLong, got %v", err) + } +} + +func TestSanitizeMessageText_ExactlyMaxLength(t *testing.T) { + exact := strings.Repeat("a", MaxMessageLength) + got, err := SanitizeMessageText(exact) + if err != nil { + t.Fatalf("unexpected error at exact max length: %v", err) + } + if len(got) != MaxMessageLength { + t.Fatalf("expected length %d, got %d", MaxMessageLength, len(got)) + } +} From 1dc96e86fcff438cca2c5212c2de3d419b180e1a Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Sun, 20 Sep 2026 05:30:14 +0330 Subject: [PATCH 5/9] Add admin user moderation: block/unblock, reports, matches list, photo review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User blocking (with reason) now closes the auth gap immediately — a blocked user's still-valid access token stops working on the next request, not just on their next OTP/refresh. - Admin-created reports (moderation notes) per user, viewable in a history list independent of block status. - GET /admin/users: full paginated, searchable user list with report counts. - GET /admin/matches: all matches across the app ("friends" who mutually liked each other), for admin visibility. - GET/DELETE /admin/photos: paginated feed of every uploaded photo with uploader info, so admins can review and remove content without going user-by-user. - Per-user activity: monitoring requests can now be filtered by user_id, so a user's detail page can show their recent API activity. - Batched the profile-selections query in fullProfilePayload (was 6 queries per view, now 1) for consistency with the earlier N+1 fixes. --- cmd/api/main.go | 3 +- internal/database/database.go | 1 + internal/dto/admin_dto.go | 10 + internal/dto/auth_dto.go | 29 +-- internal/handler/admin_handler.go | 144 ++++++++++++++ internal/handler/common.go | 23 ++- internal/handler/monitoring_handler.go | 12 +- internal/middleware/auth_middleware.go | 8 + internal/models/report.go | 17 ++ internal/models/user.go | 4 + internal/monitoring/store.go | 23 +++ internal/monitoring/store_test.go | 19 ++ internal/repository/match_repository.go | 11 ++ internal/repository/photo_repository.go | 12 ++ internal/repository/report_repository.go | 53 +++++ internal/repository/user_repository.go | 19 ++ internal/router/router.go | 12 ++ internal/service/admin_service.go | 240 ++++++++++++++++++++++- internal/service/profile_service.go | 13 ++ 19 files changed, 628 insertions(+), 25 deletions(-) create mode 100644 internal/models/report.go create mode 100644 internal/repository/report_repository.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 06abdd5..007edd6 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -49,6 +49,7 @@ func main() { swipeRepo := repository.NewSwipeRepository(db) matchRepo := repository.NewMatchRepository(db) messageRepo := repository.NewMessageRepository(db) + reportRepo := repository.NewReportRepository(db) // -------- Utils / Infra -------- jwtManager := utils.NewJWTManager( @@ -70,8 +71,8 @@ func main() { cfg.OTPExpirySeconds, cfg.OTPLength, cfg.OTPHashSecret, ) onboardingService := service.NewOnboardingService(userRepo, optionRepo, questionRepo, photoRepo) - adminService := service.NewAdminService(optionRepo, questionRepo, userRepo) profileService := service.NewProfileService(photoRepo, userRepo, photoStorage) + adminService := service.NewAdminService(optionRepo, questionRepo, userRepo, reportRepo, matchRepo, photoRepo, profileService) discoveryService := service.NewDiscoveryService(userRepo, photoRepo, swipeRepo, matchRepo, optionRepo, questionRepo) chatService := service.NewChatService(matchRepo, messageRepo, userRepo, photoRepo) backupService := service.NewBackupService(cfg, "./backups") diff --git a/internal/database/database.go b/internal/database/database.go index 1866f54..b641a8b 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -55,6 +55,7 @@ func AutoMigrate(db *gorm.DB) error { &models.Swipe{}, &models.Match{}, &models.Message{}, + &models.Report{}, ) } diff --git a/internal/dto/admin_dto.go b/internal/dto/admin_dto.go index 623e494..5dd7e36 100644 --- a/internal/dto/admin_dto.go +++ b/internal/dto/admin_dto.go @@ -37,3 +37,13 @@ type UpdateProfileQuestionRequest struct { type RejectUserRequest struct { Reason string `json:"reason" binding:"required,min=3,max=500"` } + +// BlockUserRequest بلاک کردن یک کاربر توسط ادمین (دلیل الزامی است) +type BlockUserRequest struct { + Reason string `json:"reason" binding:"required,min=3,max=500"` +} + +// ReportUserRequest ثبت یک یادداشت نظارتی روی یک کاربر (دلیل الزامی است) +type ReportUserRequest struct { + Reason string `json:"reason" binding:"required,min=3,max=500"` +} diff --git a/internal/dto/auth_dto.go b/internal/dto/auth_dto.go index 6eb7fae..9fb4e01 100644 --- a/internal/dto/auth_dto.go +++ b/internal/dto/auth_dto.go @@ -1,5 +1,7 @@ package dto +import "time" + // RequestOTPRequest بدنه‌ی درخواست ارسال کد تایید type RequestOTPRequest struct { Phone string `json:"phone" binding:"required"` @@ -32,16 +34,19 @@ type AuthResponse struct { // 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"` + ID uint `json:"id"` + Phone string `json:"phone"` + FirstName string `json:"first_name,omitempty"` + Gender *string `json:"gender,omitempty"` + BirthDate *string `json:"birth_date,omitempty"` + HeightCM *int `json:"height_cm,omitempty"` + WeightKG *int `json:"weight_kg,omitempty"` + CurrentStep int `json:"current_step"` + IsOnboarded bool `json:"is_onboarded"` + ApprovalStatus string `json:"approval_status"` + RejectionReason *string `json:"rejection_reason,omitempty"` + IsAdmin bool `json:"is_admin"` + State string `json:"state"` + BlockedReason *string `json:"blocked_reason,omitempty"` + BlockedAt *time.Time `json:"blocked_at,omitempty"` } diff --git a/internal/handler/admin_handler.go b/internal/handler/admin_handler.go index aa1d93c..e2dc643 100644 --- a/internal/handler/admin_handler.go +++ b/internal/handler/admin_handler.go @@ -238,6 +238,150 @@ func (h *AdminHandler) handleUserReviewError(c *gin.Context, err error) { } } +// paginationParams صفحه/تعداد رو از کوئری استرینگ می‌خونه (پیش‌فرض‌های معقول اگه نبود/نامعتبر بود) +func paginationParams(c *gin.Context, defaultSize int) (page, pageSize int) { + page = 1 + if p, err := strconv.Atoi(c.Query("page")); err == nil && p > 0 { + page = p + } + pageSize = defaultSize + if ps, err := strconv.Atoi(c.Query("page_size")); err == nil && ps > 0 { + pageSize = ps + } + return +} + +// ---------------- همه‌ی کاربران، بلاک/آنبلاک، گزارش ---------------- + +// ListAllUsers GET /admin/users?search=&page=&page_size= +func (h *AdminHandler) ListAllUsers(c *gin.Context) { + page, pageSize := paginationParams(c, 25) + search := c.Query("search") + + items, total, err := h.adminService.ListAllUsers(search, page, pageSize) + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil) + return + } + utils.Success(c, http.StatusOK, "", gin.H{ + "items": items, "total": total, "page": page, "page_size": pageSize, + }) +} + +// BlockUser POST /admin/users/:id/block body: {reason} +func (h *AdminHandler) BlockUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil) + return + } + var req dto.BlockUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل بلاک الزامی است)", err.Error()) + return + } + user, err := h.adminService.BlockUser(uint(id), req.Reason) + if err != nil { + h.handleUserReviewError(c, err) + return + } + utils.Success(c, http.StatusOK, "کاربر بلاک شد", toUserProfile(user)) +} + +// UnblockUser POST /admin/users/:id/unblock +func (h *AdminHandler) UnblockUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil) + return + } + user, err := h.adminService.UnblockUser(uint(id)) + if err != nil { + h.handleUserReviewError(c, err) + return + } + utils.Success(c, http.StatusOK, "کاربر آنبلاک شد", toUserProfile(user)) +} + +// ReportUser POST /admin/users/:id/report body: {reason} +func (h *AdminHandler) ReportUser(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil) + return + } + var req dto.ReportUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل گزارش الزامی است)", err.Error()) + return + } + report, err := h.adminService.ReportUser(uint(id), req.Reason) + if err != nil { + h.handleUserReviewError(c, err) + return + } + utils.Success(c, http.StatusCreated, "گزارش ثبت شد", report) +} + +// ListUserReports GET /admin/users/:id/reports +func (h *AdminHandler) ListUserReports(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil) + return + } + reports, err := h.adminService.ListReports(uint(id)) + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزارش‌ها", nil) + return + } + utils.Success(c, http.StatusOK, "", reports) +} + +// ---------------- مچ‌ها (رفقا) ---------------- + +// ListAllMatches GET /admin/matches?page=&page_size= +func (h *AdminHandler) ListAllMatches(c *gin.Context) { + page, pageSize := paginationParams(c, 25) + items, total, err := h.adminService.ListAllMatches(page, pageSize) + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست مچ‌ها", nil) + return + } + utils.Success(c, http.StatusOK, "", gin.H{ + "items": items, "total": total, "page": page, "page_size": pageSize, + }) +} + +// ---------------- نظارت بر عکس‌ها ---------------- + +// ListAllPhotos GET /admin/photos?page=&page_size= +func (h *AdminHandler) ListAllPhotos(c *gin.Context) { + page, pageSize := paginationParams(c, 50) + items, total, err := h.adminService.ListAllPhotos(page, pageSize) + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت عکس‌ها", nil) + return + } + utils.Success(c, http.StatusOK, "", gin.H{ + "items": items, "total": total, "page": page, "page_size": pageSize, + }) +} + +// DeletePhoto DELETE /admin/photos/:id +func (h *AdminHandler) DeletePhoto(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil) + return + } + if err := h.adminService.DeletePhotoAdmin(uint(id)); err != nil { + utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil) + return + } + utils.Success(c, http.StatusOK, "عکس حذف شد", nil) +} + // ---------------- سوالات پروفایل ---------------- // ListQuestions GET /admin/profile-questions diff --git a/internal/handler/common.go b/internal/handler/common.go index 432c4a4..e821260 100644 --- a/internal/handler/common.go +++ b/internal/handler/common.go @@ -40,6 +40,9 @@ func toUserProfile(u *models.User) dto.UserProfile { IsAdmin: u.IsAdmin, HeightCM: u.HeightCM, WeightKG: u.WeightKG, + State: string(u.State), + BlockedReason: u.BlockedReason, + BlockedAt: u.BlockedAt, } if u.Gender != nil { g := string(*u.Gender) @@ -69,18 +72,26 @@ func fullProfilePayload( onboardingService *service.OnboardingService, photoRepo *repository.PhotoRepository, ) gin.H { - selections := make([]selectionSummary, 0, len(models.AllCategoryKeys)) + // دسته‌ها از کش خونده می‌شن (تغییر به‌ندرت اتفاق می‌افته)، ولی خودِ + // انتخاب‌های کاربر توی همه‌ی دسته‌ها با یک کوئری واحد گرفته می‌شه؛ قبلاً + // اینجا به‌ازای هر ۶ دسته یک کوئری جدا زده می‌شد. + keyToCategoryID := make(map[models.CategoryKey]uint, len(models.AllCategoryKeys)) + categoryIDs := make([]uint, 0, len(models.AllCategoryKeys)) for _, key := range models.AllCategoryKeys { cat, err := optionRepo.GetCategoryByKey(key) if err != nil { continue } - sels, err := optionRepo.GetUserSelections(user.ID, cat.ID) - if err != nil { - continue - } + keyToCategoryID[key] = cat.ID + categoryIDs = append(categoryIDs, cat.ID) + } + + selectionsByCategoryID, _ := optionRepo.GetUserSelectionsForCategories(user.ID, categoryIDs) + + selections := make([]selectionSummary, 0, len(models.AllCategoryKeys)) + for _, key := range models.AllCategoryKeys { summary := selectionSummary{CategoryKey: string(key)} - for _, s := range sels { + for _, s := range selectionsByCategoryID[keyToCategoryID[key]] { summary.OptionIDs = append(summary.OptionIDs, s.OptionID) if s.Option != nil { summary.Titles = append(summary.Titles, s.Option.Title) diff --git a/internal/handler/monitoring_handler.go b/internal/handler/monitoring_handler.go index 7643208..fee10f7 100644 --- a/internal/handler/monitoring_handler.go +++ b/internal/handler/monitoring_handler.go @@ -23,7 +23,9 @@ func (h *MonitoringHandler) Summary(c *gin.Context) { utils.Success(c, http.StatusOK, "", h.store.Summary()) } -// RecentRequests GET /admin/monitoring/requests?limit=100 +// RecentRequests GET /admin/monitoring/requests?limit=100&user_id=42 +// اگه user_id داده بشه، فقط فعالیت اخیر همون کاربر برمی‌گرده (برای صفحه‌ی +// جزئیات کاربر توی پنل ادمین)؛ وگرنه آخرین درخواست‌های کل سرور. func (h *MonitoringHandler) RecentRequests(c *gin.Context) { limit := 100 if raw := c.Query("limit"); raw != "" { @@ -31,5 +33,13 @@ func (h *MonitoringHandler) RecentRequests(c *gin.Context) { limit = n } } + + if raw := c.Query("user_id"); raw != "" { + if uid, err := strconv.ParseUint(raw, 10, 64); err == nil { + utils.Success(c, http.StatusOK, "", h.store.RecentByUser(uint(uid), limit)) + return + } + } + utils.Success(c, http.StatusOK, "", h.store.Recent(limit)) } diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go index 4d614ef..10cea2c 100644 --- a/internal/middleware/auth_middleware.go +++ b/internal/middleware/auth_middleware.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "dating-app/internal/models" "dating-app/internal/repository" "dating-app/internal/utils" ) @@ -44,6 +45,13 @@ func AuthRequired(jwtManager *utils.JWTManager, userRepo *repository.UserReposit c.Abort() return } + // اگه کاربر بعد از صادر شدن access token فعلی بلاک شده باشه، نباید تا + // انقضای طبیعی همون توکن بتونه درخواست بزنه؛ این چک همون لحظه قطعش می‌کنه. + if user.State == models.UserStateBlocked { + utils.Fail(c, http.StatusForbidden, "حساب کاربری مسدود شده است", nil) + c.Abort() + return + } c.Set(ContextUserKey, user) c.Next() diff --git a/internal/models/report.go b/internal/models/report.go new file mode 100644 index 0000000..47e97aa --- /dev/null +++ b/internal/models/report.go @@ -0,0 +1,17 @@ +package models + +import "time" + +// Report یک یادداشت/پرچم نظارتی که ادمین روی یک کاربر ثبت می‌کنه (مثلاً بر +// اساس شکایتی که از کانال دیگه‌ای دریافت شده)؛ برای پیگیری سابقه‌ی نظارتی +// یک کاربر، مستقل از بلاک‌شدن یا نشدنش. +type Report struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Reason string `gorm:"type:text;not null" json:"reason"` + CreatedAt time.Time `json:"created_at"` +} + +func (Report) TableName() string { + return "reports" +} diff --git a/internal/models/user.go b/internal/models/user.go index 7488849..e9e67c7 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -77,6 +77,10 @@ type User struct { IsAdmin bool `gorm:"default:false;not null" json:"is_admin"` IsActive bool `gorm:"default:true;not null" json:"is_active"` + // -------- بلاک شدن توسط ادمین -------- + BlockedReason *string `json:"blocked_reason,omitempty"` + BlockedAt *time.Time `json:"blocked_at,omitempty"` + // فیلدهای OTP - هش می‌شوند و هرگز در پاسخ API برنمی‌گردند OTPCode *string `gorm:"size:128" json:"-"` OTPExpiry *time.Time `json:"-"` diff --git a/internal/monitoring/store.go b/internal/monitoring/store.go index 7ac5a22..0d99790 100644 --- a/internal/monitoring/store.go +++ b/internal/monitoring/store.go @@ -91,6 +91,29 @@ func (s *Store) Recent(n int) []RequestRecord { return out } +// RecentByUser مثل Recent ولی فقط درخواست‌های یک کاربر مشخص رو برمی‌گردونه +// (برای دیدن فعالیت اخیر یک کاربر خاص از صفحه‌ی جزئیاتش توی پنل ادمین). +// کل بافر رو می‌گرده (نه فقط آخرین n)، چون فیلتر شده و ممکنه درخواست‌های آن +// کاربر پراکنده باشن. +func (s *Store) RecentByUser(userID uint, n int) []RequestRecord { + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]RequestRecord, 0, n) + idx := s.next - 1 + for i := 0; i < s.count && len(out) < n; i++ { + if idx < 0 { + idx = bufferSize - 1 + } + rec := s.buf[idx] + if rec.UserID != nil && *rec.UserID == userID { + out = append(out, rec) + } + idx-- + } + return out +} + func (s *Store) Summary() Summary { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/monitoring/store_test.go b/internal/monitoring/store_test.go index 0335f32..0f2f94d 100644 --- a/internal/monitoring/store_test.go +++ b/internal/monitoring/store_test.go @@ -72,3 +72,22 @@ func TestStore_RingBufferWraparound(t *testing.T) { t.Fatalf("expected Recent to be capped at bufferSize (%d), got %d", bufferSize, len(recent)) } } + +func TestStore_RecentByUser(t *testing.T) { + s := NewStore() + uidA := uint(1) + uidB := uint(2) + + s.Record(RequestRecord{Time: time.Now(), Path: "/a1", UserID: &uidA}) + s.Record(RequestRecord{Time: time.Now(), Path: "/b1", UserID: &uidB}) + s.Record(RequestRecord{Time: time.Now(), Path: "/a2", UserID: &uidA}) + s.Record(RequestRecord{Time: time.Now(), Path: "/none"}) // بدون کاربر (مثل OTP) + + got := s.RecentByUser(uidA, 10) + if len(got) != 2 { + t.Fatalf("expected 2 records for user A, got %d", len(got)) + } + if got[0].Path != "/a2" || got[1].Path != "/a1" { + t.Fatalf("expected newest-first order for user A, got %+v", got) + } +} diff --git a/internal/repository/match_repository.go b/internal/repository/match_repository.go index 639d088..1202d97 100644 --- a/internal/repository/match_repository.go +++ b/internal/repository/match_repository.go @@ -63,6 +63,17 @@ func (r *MatchRepository) ListByUser(userID uint) ([]models.Match, error) { return matches, err } +// ListAllPaginated همه‌ی مچ‌های اپ رو صفحه‌بندی‌شده برمی‌گردونه (برای پنل ادمین) +func (r *MatchRepository) ListAllPaginated(limit, offset int) ([]models.Match, int64, error) { + var total int64 + if err := r.db.Model(&models.Match{}).Count(&total).Error; err != nil { + return nil, 0, err + } + var matches []models.Match + err := r.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&matches).Error + return matches, total, err +} + // OtherUserID شناسه‌ی طرف مقابل کاربر جاری رو توی یک مچ برمی‌گردونه func OtherUserID(match *models.Match, currentUserID uint) uint { if match.UserOneID == currentUserID { diff --git a/internal/repository/photo_repository.go b/internal/repository/photo_repository.go index be273dc..c65ec03 100644 --- a/internal/repository/photo_repository.go +++ b/internal/repository/photo_repository.go @@ -29,6 +29,18 @@ func (r *PhotoRepository) ListByUserIDs(userIDs []uint) ([]models.Photo, error) return photos, err } +// ListAllPaginated همه‌ی عکس‌های آپلودشده‌ی اپ رو صفحه‌بندی‌شده، جدیدترین اول +// برمی‌گردونه (برای نظارت محتوایی پنل ادمین). +func (r *PhotoRepository) ListAllPaginated(limit, offset int) ([]models.Photo, int64, error) { + var total int64 + if err := r.db.Model(&models.Photo{}).Count(&total).Error; err != nil { + return nil, 0, err + } + var photos []models.Photo + err := r.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&photos).Error + return photos, total, err +} + func (r *PhotoRepository) CountByUser(userID uint) (int64, error) { var count int64 err := r.db.Model(&models.Photo{}).Where("user_id = ?", userID).Count(&count).Error diff --git a/internal/repository/report_repository.go b/internal/repository/report_repository.go new file mode 100644 index 0000000..6745c6b --- /dev/null +++ b/internal/repository/report_repository.go @@ -0,0 +1,53 @@ +package repository + +import ( + "gorm.io/gorm" + + "dating-app/internal/models" +) + +type ReportRepository struct { + db *gorm.DB +} + +func NewReportRepository(db *gorm.DB) *ReportRepository { + return &ReportRepository{db: db} +} + +func (r *ReportRepository) Create(report *models.Report) error { + return r.db.Create(report).Error +} + +func (r *ReportRepository) ListByUser(userID uint) ([]models.Report, error) { + var reports []models.Report + err := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&reports).Error + return reports, err +} + +// CountsByUserIDs تعداد گزارش‌های ثبت‌شده روی چند کاربر رو با هم، در یک +// کوئری برمی‌گردونه (برای نشون دادن نشان تعداد گزارش روی لیست کاربران، بدون +// یک کوئری جدا به‌ازای هر کاربر). +func (r *ReportRepository) CountsByUserIDs(userIDs []uint) (map[uint]int64, error) { + result := make(map[uint]int64, len(userIDs)) + if len(userIDs) == 0 { + return result, nil + } + + type row struct { + UserID uint + Count int64 + } + var rows []row + err := r.db.Model(&models.Report{}). + Select("user_id, count(*) as count"). + Where("user_id IN ?", userIDs). + Group("user_id"). + Find(&rows).Error + if err != nil { + return nil, err + } + for _, rr := range rows { + result[rr.UserID] = rr.Count + } + return result, nil +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index 1796ee2..89066cf 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -80,6 +80,25 @@ func (r *UserRepository) ListPendingApproval() ([]models.User, error) { return users, err } +// ListAll همه‌ی کاربران رو صفحه‌بندی‌شده برمی‌گردونه (برای صفحه‌ی «همه‌ی +// کاربران» پنل ادمین)؛ search روی شماره موبایل و اسم کوچیک می‌گرده. +func (r *UserRepository) ListAll(search string, limit, offset int) ([]models.User, int64, error) { + query := r.db.Model(&models.User{}) + if search != "" { + like := "%" + search + "%" + query = query.Where("phone ILIKE ? OR first_name ILIKE ?", like, like) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var users []models.User + err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error + return users, total, err +} + // ListDiscoverable کاندیداهای بخش دوستیابی رو برمی‌گردونه: تاییدشده، فعال، // جنسیت مخالف کاربر (به‌عنوان ساده‌ترین فرض پیش‌فرض؛ فیلد ترجیح جنسیتی/گرایش // هنوز توی سیستم وجود نداره) و کسایی که قبلاً روشون سواپ نزده. diff --git a/internal/router/router.go b/internal/router/router.go index c5abd00..da8c27a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -96,10 +96,22 @@ func Setup( // -------- پنل ادمین (نیاز به ورود + نقش ادمین) -------- admin := api.Group("/admin", authRequired, middleware.AdminRequired()) { + admin.GET("/users", h.Admin.ListAllUsers) 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.POST("/users/:id/block", h.Admin.BlockUser) + admin.POST("/users/:id/unblock", h.Admin.UnblockUser) + admin.POST("/users/:id/report", h.Admin.ReportUser) + admin.GET("/users/:id/reports", h.Admin.ListUserReports) + + // رفقا (مچ‌ها) + admin.GET("/matches", h.Admin.ListAllMatches) + + // نظارت بر عکس‌های آپلودشده + admin.GET("/photos", h.Admin.ListAllPhotos) + admin.DELETE("/photos/:id", h.Admin.DeletePhoto) admin.GET("/categories/:key", h.Admin.GetCategory) admin.PATCH("/categories/:key", h.Admin.UpdateCategory) diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index 20a3d9e..3626b25 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -9,20 +9,40 @@ import ( "dating-app/internal/repository" ) -var ErrUserNotOnboarded = errors.New("کاربر هنوز آنبوردینگ را تکمیل نکرده است") +var ( + ErrUserNotOnboarded = errors.New("کاربر هنوز آنبوردینگ را تکمیل نکرده است") + ErrBlockReasonEmpty = errors.New("دلیل بلاک کردن نمی‌تواند خالی باشد") + ErrReportReasonEmpty = errors.New("دلیل گزارش نمی‌تواند خالی باشد") +) type AdminService struct { - optionRepo *repository.OptionRepository - questionRepo *repository.QuestionRepository - userRepo *repository.UserRepository + optionRepo *repository.OptionRepository + questionRepo *repository.QuestionRepository + userRepo *repository.UserRepository + reportRepo *repository.ReportRepository + matchRepo *repository.MatchRepository + photoRepo *repository.PhotoRepository + profileService *ProfileService } func NewAdminService( optionRepo *repository.OptionRepository, questionRepo *repository.QuestionRepository, userRepo *repository.UserRepository, + reportRepo *repository.ReportRepository, + matchRepo *repository.MatchRepository, + photoRepo *repository.PhotoRepository, + profileService *ProfileService, ) *AdminService { - return &AdminService{optionRepo: optionRepo, questionRepo: questionRepo, userRepo: userRepo} + return &AdminService{ + optionRepo: optionRepo, + questionRepo: questionRepo, + userRepo: userRepo, + reportRepo: reportRepo, + matchRepo: matchRepo, + photoRepo: photoRepo, + profileService: profileService, + } } // ---------------- بررسی و تایید کاربران ---------------- @@ -87,6 +107,216 @@ func (s *AdminService) RejectUser(id uint, reason string) (*models.User, error) return user, nil } +// ---------------- همه‌ی کاربران، بلاک/آنبلاک، گزارش ---------------- + +// AdminUserListItem یک کاربر به‌همراه تعداد گزارش‌هاش، برای لیست کامل کاربران +type AdminUserListItem struct { + models.User + ReportCount int64 `json:"report_count"` +} + +// ListAllUsers همه‌ی کاربران رو صفحه‌بندی‌شده و قابل‌جستجو (شماره/اسم) برمی‌گردونه. +func (s *AdminService) ListAllUsers(search string, page, pageSize int) ([]AdminUserListItem, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 25 + } + users, total, err := s.userRepo.ListAll(search, pageSize, (page-1)*pageSize) + if err != nil { + return nil, 0, err + } + + ids := make([]uint, len(users)) + for i, u := range users { + ids[i] = u.ID + } + counts, err := s.reportRepo.CountsByUserIDs(ids) + if err != nil { + return nil, 0, err + } + + items := make([]AdminUserListItem, len(users)) + for i, u := range users { + items[i] = AdminUserListItem{User: u, ReportCount: counts[u.ID]} + } + return items, total, nil +} + +// BlockUser از ورود کاربر به اپ جلوگیری می‌کنه (چه توکن معتبر داشته باشه چه نه). +func (s *AdminService) BlockUser(id uint, reason string) (*models.User, error) { + if reason == "" { + return nil, ErrBlockReasonEmpty + } + user, err := s.userRepo.FindByID(id) + if err != nil { + return nil, err + } + + now := time.Now() + updates := map[string]interface{}{ + "state": models.UserStateBlocked, + "blocked_reason": reason, + "blocked_at": now, + } + if err := s.userRepo.UpdateFields(user, updates); err != nil { + return nil, err + } + user.State = models.UserStateBlocked + user.BlockedReason = &reason + user.BlockedAt = &now + return user, nil +} + +// UnblockUser دوباره اجازه‌ی ورود به کاربر می‌ده. +func (s *AdminService) UnblockUser(id uint) (*models.User, error) { + user, err := s.userRepo.FindByID(id) + if err != nil { + return nil, err + } + + updates := map[string]interface{}{ + "state": models.UserStateActive, + "blocked_reason": nil, + "blocked_at": nil, + } + if err := s.userRepo.UpdateFields(user, updates); err != nil { + return nil, err + } + user.State = models.UserStateActive + user.BlockedReason = nil + user.BlockedAt = nil + return user, nil +} + +// ReportUser یک یادداشت نظارتی روی کاربر ثبت می‌کنه (مستقل از بلاک کردن). +func (s *AdminService) ReportUser(id uint, reason string) (*models.Report, error) { + if reason == "" { + return nil, ErrReportReasonEmpty + } + if _, err := s.userRepo.FindByID(id); err != nil { + return nil, err + } + report := &models.Report{UserID: id, Reason: reason} + if err := s.reportRepo.Create(report); err != nil { + return nil, err + } + return report, nil +} + +// ListReports سابقه‌ی گزارش‌های ثبت‌شده روی یک کاربر رو برمی‌گردونه. +func (s *AdminService) ListReports(userID uint) ([]models.Report, error) { + return s.reportRepo.ListByUser(userID) +} + +// ---------------- مچ‌ها (رفقا) ---------------- + +// AdminMatchView یک مچ به‌همراه اطلاعات خلاصه‌ی هر دو طرف، برای نمایش به ادمین. +type AdminMatchView struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + UserOneID uint `json:"user_one_id"` + UserOneName string `json:"user_one_name"` + UserOnePhone string `json:"user_one_phone"` + UserTwoID uint `json:"user_two_id"` + UserTwoName string `json:"user_two_name"` + UserTwoPhone string `json:"user_two_phone"` +} + +// ListAllMatches همه‌ی مچ‌های اپ رو صفحه‌بندی‌شده برمی‌گردونه («رفقا»یی که با +// هم مچ شدن)، جدیدترین اول. +func (s *AdminService) ListAllMatches(page, pageSize int) ([]AdminMatchView, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 25 + } + matches, total, err := s.matchRepo.ListAllPaginated(pageSize, (page-1)*pageSize) + if err != nil { + return nil, 0, err + } + + userIDs := make([]uint, 0, len(matches)*2) + for _, m := range matches { + userIDs = append(userIDs, m.UserOneID, m.UserTwoID) + } + users, err := s.userRepo.FindByIDs(userIDs) + if err != nil { + return nil, 0, err + } + userByID := make(map[uint]models.User, len(users)) + for _, u := range users { + userByID[u.ID] = u + } + + views := make([]AdminMatchView, 0, len(matches)) + for _, m := range matches { + one := userByID[m.UserOneID] + two := userByID[m.UserTwoID] + views = append(views, AdminMatchView{ + ID: m.ID, + CreatedAt: m.CreatedAt, + UserOneID: one.ID, + UserOneName: one.FirstName, + UserOnePhone: one.Phone, + UserTwoID: two.ID, + UserTwoName: two.FirstName, + UserTwoPhone: two.Phone, + }) + } + return views, total, nil +} + +// ---------------- نظارت بر عکس‌ها ---------------- + +// AdminPhotoView یک عکس آپلودشده به‌همراه اطلاعات صاحبش، برای نظارت ادمین. +type AdminPhotoView struct { + models.Photo + OwnerName string `json:"owner_name"` + OwnerPhone string `json:"owner_phone"` +} + +// ListAllPhotos همه‌ی عکس‌های آپلودشده‌ی اپ رو صفحه‌بندی‌شده، جدیدترین اول برمی‌گردونه. +func (s *AdminService) ListAllPhotos(page, pageSize int) ([]AdminPhotoView, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 200 { + pageSize = 50 + } + photos, total, err := s.photoRepo.ListAllPaginated(pageSize, (page-1)*pageSize) + if err != nil { + return nil, 0, err + } + + ids := make([]uint, len(photos)) + for i, p := range photos { + ids[i] = p.UserID + } + users, err := s.userRepo.FindByIDs(ids) + if err != nil { + return nil, 0, err + } + userByID := make(map[uint]models.User, len(users)) + for _, u := range users { + userByID[u.ID] = u + } + + views := make([]AdminPhotoView, len(photos)) + for i, p := range photos { + owner := userByID[p.UserID] + views[i] = AdminPhotoView{Photo: p, OwnerName: owner.FirstName, OwnerPhone: owner.Phone} + } + return views, total, nil +} + +// DeletePhotoAdmin یک عکس رو صرف‌نظر از مالکش حذف می‌کنه (برای نظارت محتوایی). +func (s *AdminService) DeletePhotoAdmin(photoID uint) error { + return s.profileService.DeletePhotoAdmin(photoID) +} + // ---------------- دسته‌ها و گزینه‌ها ---------------- func (s *AdminService) GetCategory(key models.CategoryKey) (*models.Category, error) { diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index 6697f78..4447865 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -74,6 +74,19 @@ func (s *ProfileService) DeletePhoto(userID, photoID uint) error { return s.storage.Delete(photo.URL) } +// DeletePhotoAdmin مثل DeletePhoto ولی بدون چک مالکیت؛ برای نظارت محتوایی +// ادمین که باید بتونه هر عکسی رو صرف‌نظر از صاحبش حذف کنه. +func (s *ProfileService) DeletePhotoAdmin(photoID uint) error { + photo, err := s.photoRepo.FindByID(photoID) + if err != nil { + return err + } + 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) } From c8e6f80c51dc4505b8bf6ae54157352e65fb8808 Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Sun, 20 Sep 2026 12:26:06 +0330 Subject: [PATCH 6/9] Add bio field and daily streak check-in for gamification - User.Bio: a short (300 char) freeform profile bio, editable via PATCH /profile/bio, shown on discover cards and candidate detail (richer profiles beyond the fixed onboarding fields). - User.StreakCount/LastActiveDate: POST /profile/checkin increments the streak on consecutive days, resets after a gap, no-ops if already checked in today. Backs a daily-return hook on the frontend. --- internal/dto/auth_dto.go | 2 ++ internal/dto/discovery_dto.go | 2 ++ internal/dto/profile_dto.go | 11 +++++++ internal/handler/common.go | 2 ++ internal/handler/profile_handler.go | 32 +++++++++++++++++++ internal/models/user.go | 5 +++ internal/router/router.go | 3 ++ internal/service/discovery_service.go | 9 ++++++ internal/service/profile_service.go | 44 +++++++++++++++++++++++++++ 9 files changed, 110 insertions(+) diff --git a/internal/dto/auth_dto.go b/internal/dto/auth_dto.go index 9fb4e01..fe2f564 100644 --- a/internal/dto/auth_dto.go +++ b/internal/dto/auth_dto.go @@ -49,4 +49,6 @@ type UserProfile struct { State string `json:"state"` BlockedReason *string `json:"blocked_reason,omitempty"` BlockedAt *time.Time `json:"blocked_at,omitempty"` + Bio *string `json:"bio,omitempty"` + StreakCount int `json:"streak_count"` } diff --git a/internal/dto/discovery_dto.go b/internal/dto/discovery_dto.go index b85454c..5490fdb 100644 --- a/internal/dto/discovery_dto.go +++ b/internal/dto/discovery_dto.go @@ -5,6 +5,7 @@ type DiscoverCandidate struct { ID uint `json:"id"` FirstName string `json:"first_name"` Age int `json:"age"` + Bio string `json:"bio,omitempty"` Photos []string `json:"photos"` DistanceKM *float64 `json:"distance_km,omitempty"` Interests []string `json:"interests,omitempty"` @@ -58,6 +59,7 @@ type CandidateDetail struct { ID uint `json:"id"` FirstName string `json:"first_name"` Age int `json:"age"` + Bio string `json:"bio,omitempty"` Photos []string `json:"photos"` DistanceKM *float64 `json:"distance_km,omitempty"` HeightCM *int `json:"height_cm,omitempty"` diff --git a/internal/dto/profile_dto.go b/internal/dto/profile_dto.go index d97f5aa..a1e536b 100644 --- a/internal/dto/profile_dto.go +++ b/internal/dto/profile_dto.go @@ -5,3 +5,14 @@ type UpdateLocationRequest struct { Latitude float64 `json:"latitude" binding:"required"` Longitude float64 `json:"longitude" binding:"required"` } + +// UpdateBioRequest ویرایش بیوگرافی کوتاه پروفایل +type UpdateBioRequest struct { + Bio string `json:"bio" binding:"max=300"` +} + +// CheckInResult نتیجه‌ی ثبت ورود روزانه (برای استریک گیمیفیکیشن) +type CheckInResult struct { + StreakCount int `json:"streak_count"` + IsNewToday bool `json:"is_new_today"` +} diff --git a/internal/handler/common.go b/internal/handler/common.go index e821260..a77355a 100644 --- a/internal/handler/common.go +++ b/internal/handler/common.go @@ -43,6 +43,8 @@ func toUserProfile(u *models.User) dto.UserProfile { State: string(u.State), BlockedReason: u.BlockedReason, BlockedAt: u.BlockedAt, + Bio: u.Bio, + StreakCount: u.StreakCount, } if u.Gender != nil { g := string(*u.Gender) diff --git a/internal/handler/profile_handler.go b/internal/handler/profile_handler.go index de679d3..4dec35c 100644 --- a/internal/handler/profile_handler.go +++ b/internal/handler/profile_handler.go @@ -138,3 +138,35 @@ func (h *ProfileHandler) UpdateLocation(c *gin.Context) { } utils.Success(c, http.StatusOK, "موقعیت مکانی ثبت شد", nil) } + +// UpdateBio PATCH /profile/bio body: {bio} +func (h *ProfileHandler) UpdateBio(c *gin.Context) { + user, ok := currentUser(c) + if !ok { + return + } + var req dto.UpdateBioRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, http.StatusBadRequest, "بیوگرافی نباید بیشتر از ۳۰۰ کاراکتر باشه", err.Error()) + return + } + if err := h.profileService.UpdateBio(user, req.Bio); err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی بیوگرافی", nil) + return + } + utils.Success(c, http.StatusOK, "بیوگرافی ذخیره شد", nil) +} + +// CheckIn POST /profile/checkin — ثبت ورود روزانه برای استریک گیمیفیکیشن +func (h *ProfileHandler) CheckIn(c *gin.Context) { + user, ok := currentUser(c) + if !ok { + return + } + result, err := h.profileService.CheckIn(user) + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در ثبت ورود روزانه", nil) + return + } + utils.Success(c, http.StatusOK, "", result) +} diff --git a/internal/models/user.go b/internal/models/user.go index e9e67c7..b26358a 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -91,6 +91,11 @@ type User struct { BirthDate *time.Time `json:"birth_date,omitempty"` HeightCM *int `json:"height_cm,omitempty"` WeightKG *int `json:"weight_kg,omitempty"` + Bio *string `gorm:"size:300" json:"bio,omitempty"` + + // -------- گیمیفیکیشن: استریک روزانه (چند روز پشت‌سرهم اپ باز شده) -------- + StreakCount int `gorm:"default:0;not null" json:"streak_count"` + LastActiveDate *time.Time `json:"last_active_date,omitempty"` // -------- موقعیت مکانی (برای محاسبه‌ی فاصله‌ی تقریبی در دوستیابی) -------- Latitude *float64 `json:"-"` diff --git a/internal/router/router.go b/internal/router/router.go index da8c27a..6a9c8b5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -58,6 +58,9 @@ func Setup( profile.POST("/photos", h.Profile.UploadPhoto) profile.DELETE("/photos/:id", h.Profile.DeletePhoto) profile.PATCH("/location", h.Profile.UpdateLocation) + profile.PATCH("/bio", h.Profile.UpdateBio) + // استریک روزانه (گیمیفیکیشن): هر بار اپ باز می‌شه یه بار صدا زده می‌شه + profile.POST("/checkin", h.Profile.CheckIn) } // -------- آنبوردینگ (نیاز به ورود) -------- diff --git a/internal/service/discovery_service.go b/internal/service/discovery_service.go index f4a42f3..4c8bca3 100644 --- a/internal/service/discovery_service.go +++ b/internal/service/discovery_service.go @@ -137,6 +137,7 @@ func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, mi ID: wd.user.ID, FirstName: wd.user.FirstName, Age: utils.CalcAge(wd.user.BirthDate), + Bio: derefStr(wd.user.Bio), // نبود عکس/علاقه‌مندی نباید توی JSON به‌صورت null دربیاد؛ کلاینت‌ها // همیشه یک آرایه (حتی خالی) انتظار دارن، نه null. Photos: orEmptyStrings(photosByUser[wd.user.ID]), @@ -154,6 +155,13 @@ func orEmptyStrings(s []string) []string { return s } +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} + // batchInterests علاقه‌مندی‌های تمام کاندیداهای فید رو در یک کوئری واحد // می‌گیره (به‌جای یک کوئری جدا به‌ازای هر کاندیدا)؛ روی فید دیسکاوری که تا ۶۰ // کاربر با هم پردازش می‌شن، این تفاوت بین ۱ کوئری و تا ۶۰ کوئری رو ایجاد می‌کنه. @@ -252,6 +260,7 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint ID: target.ID, FirstName: target.FirstName, Age: utils.CalcAge(target.BirthDate), + Bio: derefStr(target.Bio), Photos: urls, DistanceKM: distance, HeightCM: target.HeightCM, diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index 4447865..9e4c09b 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -2,7 +2,10 @@ package service import ( "errors" + "strings" + "time" + "dating-app/internal/dto" "dating-app/internal/models" "dating-app/internal/repository" ) @@ -97,3 +100,44 @@ func (s *ProfileService) UpdateLocation(user *models.User, lat, lng float64) err "longitude": lng, }) } + +// UpdateBio بیوگرافی کوتاه پروفایل رو ذخیره می‌کنه (طول توسط binding در DTO چک شده) +func (s *ProfileService) UpdateBio(user *models.User, bio string) error { + bio = strings.TrimSpace(bio) + if err := s.userRepo.UpdateFields(user, map[string]interface{}{"bio": bio}); err != nil { + return err + } + user.Bio = &bio + return nil +} + +// CheckIn ورود روزانه‌ی کاربر رو ثبت می‌کنه و استریک (چند روز پشت‌سرهم) رو +// به‌روز می‌کنه: اگه دیروز هم اومده بوده، استریک +۱ می‌شه؛ اگه امروز قبلاً +// چک‌این کرده، چیزی تغییر نمی‌کنه؛ وگرنه (وقفه افتاده) استریک از ۱ شروع می‌شه. +func (s *ProfileService) CheckIn(user *models.User) (*dto.CheckInResult, error) { + now := time.Now() + today := now.Format("2006-01-02") + + if user.LastActiveDate != nil && user.LastActiveDate.Format("2006-01-02") == today { + return &dto.CheckInResult{StreakCount: user.StreakCount, IsNewToday: false}, nil + } + + newStreak := 1 + if user.LastActiveDate != nil { + yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") + if user.LastActiveDate.Format("2006-01-02") == yesterday { + newStreak = user.StreakCount + 1 + } + } + + if err := s.userRepo.UpdateFields(user, map[string]interface{}{ + "streak_count": newStreak, + "last_active_date": now, + }); err != nil { + return nil, err + } + user.StreakCount = newStreak + user.LastActiveDate = &now + + return &dto.CheckInResult{StreakCount: newStreak, IsNewToday: true}, nil +} From 2c6d4bfbd25d121bd2c7214afa06e9d158cf0cf5 Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Sat, 26 Sep 2026 18:08:57 +0330 Subject: [PATCH 7/9] Add notification settings, account deletion, photo reporting, and pending-user photos Addresses user bug reports: settings pages needed real notification preferences and account deletion, admin needed a way to flag a specific photo independent of deleting it, and the pending-approval users list needed to surface applicant photos for review. Co-Authored-By: Claude Sonnet 5 --- go.mod | 21 ++++++++------ go.sum | 15 ++++++++++ internal/dto/auth_dto.go | 10 +++++++ internal/dto/profile_dto.go | 7 +++++ internal/handler/admin_handler.go | 37 +++++++++++++++++++++++-- internal/handler/common.go | 3 ++ internal/handler/profile_handler.go | 31 +++++++++++++++++++++ internal/models/report.go | 6 ++-- internal/models/user.go | 7 +++++ internal/router/router.go | 3 ++ internal/service/admin_service.go | 43 +++++++++++++++++++++++++++-- internal/service/profile_service.go | 22 +++++++++++++++ 12 files changed, 188 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index a6324b7..bb47ac9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module dating-app -go 1.22.2 +go 1.25 replace golang.org/x/net => github.com/golang/net v0.25.0 @@ -26,44 +26,49 @@ replace google.golang.org/protobuf => github.com/protocolbuffers/protobuf-go v1. replace golang.org/x/xerrors => github.com/golang/xerrors v0.0.0-20191204190536-9bdfabe68543 +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + gorm.io/driver/postgres v1.5.9 + gorm.io/gorm v1.25.10 +) + require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect - github.com/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/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.21.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.20.0 // indirect + golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - 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 diff --git a/go.sum b/go.sum index 1fc1241..6f560ad 100644 --- a/go.sum +++ b/go.sum @@ -4,7 +4,9 @@ github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZX github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= @@ -12,11 +14,14 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-gorm/gorm v1.25.10 h1:s8FP+QiJt4yTWhaqqaTEXmJezVd4pLuYihWFa5ka6oc= github.com/go-gorm/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= github.com/go-gorm/postgres v1.5.9 h1:R8GFj+hNnViUX6OKlL8jp7b5fVUVlDE3ULqQ+ae7aPk= github.com/go-gorm/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -42,7 +47,9 @@ github.com/golang/sys v0.20.0 h1:s6f6MpCY0CE+A62kAS9FiDNmD3rpVbd+OISylPY45NQ= github.com/golang/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= github.com/golang/text v0.15.0 h1:Hr6Y5cbI9MiTMnLQhMfqQzC3smwk0O9n6gYp78JNYJ4= github.com/golang/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +github.com/golang/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:WKKabR3QofYgj3cabwq+Blt8MSvmXR4GKLoohSdeY1A= github.com/golang/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -66,6 +73,10 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= @@ -77,9 +88,12 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/protocolbuffers/protobuf-go v1.30.0 h1:8l0IAhOWx+qT8fxNzYIj+TS9SEU6xamBNS0ZmRIy3eY= github.com/protocolbuffers/protobuf-go v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rsc/pdf v0.1.1/go.mod h1:+1cgsOaCtY/6pdg9OEno8rLCRIQj5LeL9ckNDtNZsFs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -90,6 +104,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= diff --git a/internal/dto/auth_dto.go b/internal/dto/auth_dto.go index fe2f564..516faa2 100644 --- a/internal/dto/auth_dto.go +++ b/internal/dto/auth_dto.go @@ -51,4 +51,14 @@ type UserProfile struct { BlockedAt *time.Time `json:"blocked_at,omitempty"` Bio *string `json:"bio,omitempty"` StreakCount int `json:"streak_count"` + NotifyMatches bool `json:"notify_matches"` + NotifyMessages bool `json:"notify_messages"` + NotifyLikes bool `json:"notify_likes"` +} + +// UserProfileWithPhotos برای لیست‌های ادمینی که علاوه بر خلاصه‌ی پروفایل، عکس +// کاربر رو هم نشون می‌دن (مثلاً کاربران در انتظار تایید). +type UserProfileWithPhotos struct { + UserProfile + Photos []string `json:"photos"` } diff --git a/internal/dto/profile_dto.go b/internal/dto/profile_dto.go index a1e536b..29e6ba3 100644 --- a/internal/dto/profile_dto.go +++ b/internal/dto/profile_dto.go @@ -16,3 +16,10 @@ type CheckInResult struct { StreakCount int `json:"streak_count"` IsNewToday bool `json:"is_new_today"` } + +// UpdateNotificationSettingsRequest ترجیحات اعلان کاربر +type UpdateNotificationSettingsRequest struct { + NotifyMatches bool `json:"notify_matches"` + NotifyMessages bool `json:"notify_messages"` + NotifyLikes bool `json:"notify_likes"` +} diff --git a/internal/handler/admin_handler.go b/internal/handler/admin_handler.go index e2dc643..0c24135 100644 --- a/internal/handler/admin_handler.go +++ b/internal/handler/admin_handler.go @@ -165,14 +165,21 @@ func (h *AdminHandler) DeleteOption(c *gin.Context) { // ListPendingUsers GET /admin/users/pending func (h *AdminHandler) ListPendingUsers(c *gin.Context) { - users, err := h.adminService.ListPendingUsers() + users, photosByUser, err := h.adminService.ListPendingUsers() if err != nil { utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil) return } - result := make([]dto.UserProfile, 0, len(users)) + result := make([]dto.UserProfileWithPhotos, 0, len(users)) for i := range users { - result = append(result, toUserProfile(&users[i])) + photos := photosByUser[users[i].ID] + if photos == nil { + photos = []string{} + } + result = append(result, dto.UserProfileWithPhotos{ + UserProfile: toUserProfile(&users[i]), + Photos: photos, + }) } utils.Success(c, http.StatusOK, "", result) } @@ -382,6 +389,30 @@ func (h *AdminHandler) DeletePhoto(c *gin.Context) { utils.Success(c, http.StatusOK, "عکس حذف شد", nil) } +// ReportPhoto POST /admin/photos/:id/report body: {reason} +func (h *AdminHandler) ReportPhoto(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil) + return + } + var req dto.ReportUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل گزارش الزامی است)", err.Error()) + return + } + report, err := h.adminService.ReportPhoto(uint(id), req.Reason) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil) + return + } + utils.Fail(c, http.StatusBadRequest, err.Error(), nil) + return + } + utils.Success(c, http.StatusCreated, "گزارش ثبت شد", report) +} + // ---------------- سوالات پروفایل ---------------- // ListQuestions GET /admin/profile-questions diff --git a/internal/handler/common.go b/internal/handler/common.go index a77355a..3ca180f 100644 --- a/internal/handler/common.go +++ b/internal/handler/common.go @@ -45,6 +45,9 @@ func toUserProfile(u *models.User) dto.UserProfile { BlockedAt: u.BlockedAt, Bio: u.Bio, StreakCount: u.StreakCount, + NotifyMatches: u.NotifyMatches, + NotifyMessages: u.NotifyMessages, + NotifyLikes: u.NotifyLikes, } if u.Gender != nil { g := string(*u.Gender) diff --git a/internal/handler/profile_handler.go b/internal/handler/profile_handler.go index 4dec35c..e64d7a5 100644 --- a/internal/handler/profile_handler.go +++ b/internal/handler/profile_handler.go @@ -170,3 +170,34 @@ func (h *ProfileHandler) CheckIn(c *gin.Context) { } utils.Success(c, http.StatusOK, "", result) } + +// UpdateNotificationSettings PATCH /profile/notifications +func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) { + user, ok := currentUser(c) + if !ok { + return + } + var req dto.UpdateNotificationSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error()) + return + } + if err := h.profileService.UpdateNotificationSettings(user, req); err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی تنظیمات اعلان", nil) + return + } + utils.Success(c, http.StatusOK, "تنظیمات اعلان ذخیره شد", nil) +} + +// DeleteAccount DELETE /profile/me — غیرفعال کردن (soft delete) حساب کاربر +func (h *ProfileHandler) DeleteAccount(c *gin.Context) { + user, ok := currentUser(c) + if !ok { + return + } + if err := h.profileService.DeleteAccount(user); err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در حذف حساب", nil) + return + } + utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil) +} diff --git a/internal/models/report.go b/internal/models/report.go index 47e97aa..6096714 100644 --- a/internal/models/report.go +++ b/internal/models/report.go @@ -2,12 +2,12 @@ package models import "time" -// Report یک یادداشت/پرچم نظارتی که ادمین روی یک کاربر ثبت می‌کنه (مثلاً بر -// اساس شکایتی که از کانال دیگه‌ای دریافت شده)؛ برای پیگیری سابقه‌ی نظارتی -// یک کاربر، مستقل از بلاک‌شدن یا نشدنش. +// Report یک یادداشت/پرچم نظارتی که ادمین روی یک کاربر (یا یک عکس مشخص از +// اون کاربر) ثبت می‌کنه؛ برای پیگیری سابقه‌ی نظارتی، مستقل از بلاک/حذف کردن. type Report struct { ID uint `gorm:"primaryKey" json:"id"` UserID uint `gorm:"not null;index" json:"user_id"` + PhotoID *uint `json:"photo_id,omitempty"` Reason string `gorm:"type:text;not null" json:"reason"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/models/user.go b/internal/models/user.go index b26358a..38ff00b 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -97,6 +97,13 @@ type User struct { StreakCount int `gorm:"default:0;not null" json:"streak_count"` LastActiveDate *time.Time `json:"last_active_date,omitempty"` + // -------- ترجیحات اعلان -------- + // فعلاً فقط ذخیره می‌شن (زیرساخت ارسال واقعی اعلان هنوز پیاده نشده)؛ وقتی + // اون سیستم اضافه شد، همین مقادیر رو برای تصمیم به ارسال یا نه می‌خونه. + NotifyMatches bool `gorm:"default:true;not null" json:"notify_matches"` + NotifyMessages bool `gorm:"default:true;not null" json:"notify_messages"` + NotifyLikes bool `gorm:"default:true;not null" json:"notify_likes"` + // -------- موقعیت مکانی (برای محاسبه‌ی فاصله‌ی تقریبی در دوستیابی) -------- Latitude *float64 `json:"-"` Longitude *float64 `json:"-"` diff --git a/internal/router/router.go b/internal/router/router.go index 6a9c8b5..1daa6ee 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -61,6 +61,8 @@ func Setup( profile.PATCH("/bio", h.Profile.UpdateBio) // استریک روزانه (گیمیفیکیشن): هر بار اپ باز می‌شه یه بار صدا زده می‌شه profile.POST("/checkin", h.Profile.CheckIn) + profile.PATCH("/notifications", h.Profile.UpdateNotificationSettings) + profile.DELETE("/me", h.Profile.DeleteAccount) } // -------- آنبوردینگ (نیاز به ورود) -------- @@ -115,6 +117,7 @@ func Setup( // نظارت بر عکس‌های آپلودشده admin.GET("/photos", h.Admin.ListAllPhotos) admin.DELETE("/photos/:id", h.Admin.DeletePhoto) + admin.POST("/photos/:id/report", h.Admin.ReportPhoto) admin.GET("/categories/:key", h.Admin.GetCategory) admin.PATCH("/categories/:key", h.Admin.UpdateCategory) diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index 3626b25..c06f259 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -52,9 +52,29 @@ 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() +// ListPendingUsers کاربرانی که آنبوردینگ رو تکمیل کردن و منتظر بررسی‌اند، به +// همراه عکس‌هاشون (batch گرفته می‌شه، نه یک کوئری جدا به‌ازای هر کاربر) تا +// ادمین بدون کلیک روی هرکدوم بتونه یه نگاه سریع به عکس‌ها بندازه. +func (s *AdminService) ListPendingUsers() ([]models.User, map[uint][]string, error) { + users, err := s.userRepo.ListPendingApproval() + if err != nil { + return nil, nil, err + } + + ids := make([]uint, len(users)) + for i, u := range users { + ids[i] = u.ID + } + photos, err := s.photoRepo.ListByUserIDs(ids) + if err != nil { + return nil, nil, err + } + photosByUser := make(map[uint][]string, len(ids)) + for _, p := range photos { + photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL) + } + + return users, photosByUser, nil } // ApproveUser کاربر رو تایید می‌کنه تا بتونه وارد صفحه‌ی دوستیابی بشه @@ -205,6 +225,23 @@ func (s *AdminService) ReportUser(id uint, reason string) (*models.Report, error return report, nil } +// ReportPhoto یک عکس مشخص رو گزارش می‌کنه (روی صاحب عکس هم توی سابقه‌ی +// گزارش‌ها ثبت می‌شه، با ارجاع به همون عکس). +func (s *AdminService) ReportPhoto(photoID uint, reason string) (*models.Report, error) { + if reason == "" { + return nil, ErrReportReasonEmpty + } + photo, err := s.photoRepo.FindByID(photoID) + if err != nil { + return nil, err + } + report := &models.Report{UserID: photo.UserID, PhotoID: &photo.ID, Reason: reason} + if err := s.reportRepo.Create(report); err != nil { + return nil, err + } + return report, nil +} + // ListReports سابقه‌ی گزارش‌های ثبت‌شده روی یک کاربر رو برمی‌گردونه. func (s *AdminService) ListReports(userID uint) ([]models.Report, error) { return s.reportRepo.ListByUser(userID) diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index 9e4c09b..511ed26 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -141,3 +141,25 @@ func (s *ProfileService) CheckIn(user *models.User) (*dto.CheckInResult, error) return &dto.CheckInResult{StreakCount: newStreak, IsNewToday: true}, nil } + +// UpdateNotificationSettings ترجیحات اعلان کاربر رو ذخیره می‌کنه. +func (s *ProfileService) UpdateNotificationSettings(user *models.User, req dto.UpdateNotificationSettingsRequest) error { + if err := s.userRepo.UpdateFields(user, map[string]interface{}{ + "notify_matches": req.NotifyMatches, + "notify_messages": req.NotifyMessages, + "notify_likes": req.NotifyLikes, + }); err != nil { + return err + } + user.NotifyMatches = req.NotifyMatches + user.NotifyMessages = req.NotifyMessages + user.NotifyLikes = req.NotifyLikes + return nil +} + +// DeleteAccount حساب کاربر رو غیرفعال می‌کنه (soft) نه پاک‌سازی کامل رکورد؛ +// چون مچ‌ها/پیام‌های طرف مقابل نباید یهو پاره بشن. کاربر غیرفعال دیگه اصلاً +// نمی‌تونه لاگین کنه (چک IsActive توی AuthRequired و AuthService). +func (s *ProfileService) DeleteAccount(user *models.User) error { + return s.userRepo.UpdateFields(user, map[string]interface{}{"is_active": false}) +} From 1e5ef02bdf2365a1ffc8dcf24265b03c31b944d5 Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Sat, 26 Sep 2026 18:57:46 +0330 Subject: [PATCH 8/9] Add end-to-end encryption for chat messages (X25519 / NaCl box) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Messages between matched users are now encrypted client-side with each user's own key pair before they ever reach the server; the backend only ever stores/relays opaque ciphertext + nonce and cannot read message content, including for the last-message preview on the matches list. - User gains a PublicKey field, registered/rotated via PUT /profile/public-key - Message gains CipherText/Nonce; legacy plaintext Text is kept as-is for messages sent before this change - MatchView exposes the partner's public key and the last message's ciphertext/nonce so the client can decrypt previews itself - Removed the now-dead plaintext SanitizeMessageText utility — the server can no longer sanitize message content since it's opaque ciphertext Co-Authored-By: Claude Sonnet 5 --- internal/dto/discovery_dto.go | 49 +++++++++++++------- internal/dto/profile_dto.go | 7 +++ internal/handler/chat_handler.go | 12 ++--- internal/handler/profile_handler.go | 24 ++++++++++ internal/models/discovery.go | 20 +++++--- internal/models/user.go | 6 +++ internal/router/router.go | 1 + internal/service/chat_service.go | 72 ++++++++++++++++++----------- internal/service/profile_service.go | 25 ++++++++-- internal/utils/sanitize.go | 47 ------------------- internal/utils/sanitize_test.go | 71 ---------------------------- 11 files changed, 154 insertions(+), 180 deletions(-) delete mode 100644 internal/utils/sanitize.go delete mode 100644 internal/utils/sanitize_test.go diff --git a/internal/dto/discovery_dto.go b/internal/dto/discovery_dto.go index 5490fdb..861d4ec 100644 --- a/internal/dto/discovery_dto.go +++ b/internal/dto/discovery_dto.go @@ -22,30 +22,45 @@ type SwipeResponse struct { Match *MatchView `json:"match,omitempty"` } -// MatchView خلاصه‌ی یک مچ برای نمایش در لیست مچ‌ها/گفتگوها +// MatchView خلاصه‌ی یک مچ برای نمایش در لیست مچ‌ها/گفتگوها. برای پیش‌نمایش +// آخرین پیام، سرور رمزگشایی نمی‌کنه (نمی‌تونه) — CipherText/Nonce خودِ آخرین +// پیام رو برمی‌گردونه تا کلاینت خودش رمزگشایی کنه؛ LastMessage فقط برای +// پیام‌های قدیمیِ قبل از E2EE پر می‌شه. type MatchView struct { - ID uint `json:"id"` - UserID uint `json:"user_id"` - FirstName string `json:"first_name"` - Photos []string `json:"photos"` - CreatedAt string `json:"created_at"` - LastMessage *string `json:"last_message,omitempty"` - LastAt *string `json:"last_at,omitempty"` + ID uint `json:"id"` + UserID uint `json:"user_id"` + FirstName string `json:"first_name"` + Photos []string `json:"photos"` + CreatedAt string `json:"created_at"` + LastMessage *string `json:"last_message,omitempty"` + LastMessageCipherText *string `json:"last_message_cipher_text,omitempty"` + LastMessageNonce *string `json:"last_message_nonce,omitempty"` + LastAt *string `json:"last_at,omitempty"` + // PartnerPublicKey کلید عمومی X25519 طرف مقابل (base64) برای رمزنگاری + // سرتاسری؛ اگه طرف مقابل هنوز کلیدی ثبت نکرده باشه خالیه. + PartnerPublicKey *string `json:"partner_public_key,omitempty"` } -// MessageView یک پیام در یک گفتگو +// MessageView یک پیام در یک گفتگو. یا Text (پیام قدیمیِ قبل از E2EE) پر +// می‌شه، یا CipherText+Nonce (پیام رمزنگاری‌شده) — سرور خودش نمی‌دونه محتوای +// رمزنگاری‌شده چیه، فقط بین دو طرف مچ ردوبدلش می‌کنه. type MessageView struct { - ID uint `json:"id"` - MatchID uint `json:"match_id"` - SenderID uint `json:"sender_id"` - Text string `json:"text"` - CreatedAt string `json:"created_at"` - IsMine bool `json:"is_mine"` + ID uint `json:"id"` + MatchID uint `json:"match_id"` + SenderID uint `json:"sender_id"` + Text string `json:"text,omitempty"` + CipherText *string `json:"cipher_text,omitempty"` + Nonce *string `json:"nonce,omitempty"` + CreatedAt string `json:"created_at"` + IsMine bool `json:"is_mine"` } -// SendMessageRequest بدنه‌ی درخواست ارسال پیام +// SendMessageRequest بدنه‌ی درخواست ارسال پیام رمزنگاری‌شده. متن خام پیام +// هیچ‌وقت به سرور نمی‌رسه؛ کلاینت قبل از ارسال با کلید عمومی طرف مقابل +// رمزنگاریش می‌کنه. type SendMessageRequest struct { - Text string `json:"text" binding:"required,min=1,max=2000"` + CipherText string `json:"cipher_text" binding:"required,min=1,max=12000"` + Nonce string `json:"nonce" binding:"required,len=32"` } // CandidateAnswer پاسخ یک سوال پروفایل، برای نمایش عمومی diff --git a/internal/dto/profile_dto.go b/internal/dto/profile_dto.go index 29e6ba3..0a56e83 100644 --- a/internal/dto/profile_dto.go +++ b/internal/dto/profile_dto.go @@ -23,3 +23,10 @@ type UpdateNotificationSettingsRequest struct { NotifyMessages bool `json:"notify_messages"` NotifyLikes bool `json:"notify_likes"` } + +// UpdatePublicKeyRequest ثبت/به‌روزرسانی کلید عمومی X25519 کاربر برای +// رمزنگاری سرتاسری پیام‌ها. کلید باید دقیقاً ۳۲ بایت خام باشه که به‌صورت +// base64 استاندارد (۴۴ کاراکتر، شامل padding) کدگذاری شده. +type UpdatePublicKeyRequest struct { + PublicKey string `json:"public_key" binding:"required,len=44"` +} diff --git a/internal/handler/chat_handler.go b/internal/handler/chat_handler.go index f6c9a16..f061d7a 100644 --- a/internal/handler/chat_handler.go +++ b/internal/handler/chat_handler.go @@ -53,7 +53,9 @@ func (h *ChatHandler) ListMessages(c *gin.Context) { utils.Success(c, http.StatusOK, "", messages) } -// SendMessage POST /matches/:id/messages body: {text} +// SendMessage POST /matches/:id/messages body: {cipher_text, nonce} +// متن خام پیام هیچ‌وقت اینجا نمی‌رسه؛ کلاینت قبل از ارسال با کلید عمومی +// طرف مقابل (NaCl box / X25519) رمزنگاریش کرده. func (h *ChatHandler) SendMessage(c *gin.Context) { user, ok := currentUser(c) if !ok { @@ -66,10 +68,10 @@ func (h *ChatHandler) SendMessage(c *gin.Context) { } var req dto.SendMessageRequest if err := c.ShouldBindJSON(&req); err != nil { - utils.Fail(c, http.StatusBadRequest, "متن پیام نمی‌تونه خالی باشه", err.Error()) + utils.Fail(c, http.StatusBadRequest, "پیام رمزنگاری‌شده نامعتبر است", err.Error()) return } - msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.Text) + msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.CipherText, req.Nonce) if err != nil { h.handleError(c, err) return @@ -81,9 +83,7 @@ func (h *ChatHandler) handleError(c *gin.Context, err error) { switch { case errors.Is(err, service.ErrNotMatchMember): utils.Fail(c, http.StatusForbidden, err.Error(), nil) - case errors.Is(err, utils.ErrInvalidMessageEncoding), - errors.Is(err, utils.ErrMessageEmpty), - errors.Is(err, utils.ErrMessageTooLong): + case errors.Is(err, service.ErrInvalidMessageEncoding): utils.Fail(c, http.StatusBadRequest, err.Error(), nil) default: utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil) diff --git a/internal/handler/profile_handler.go b/internal/handler/profile_handler.go index e64d7a5..c23471a 100644 --- a/internal/handler/profile_handler.go +++ b/internal/handler/profile_handler.go @@ -201,3 +201,27 @@ func (h *ProfileHandler) DeleteAccount(c *gin.Context) { } utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil) } + +// UpdatePublicKey PUT /profile/public-key — ثبت/جایگزینی کلید عمومی X25519 +// برای رمزنگاری سرتاسری پیام‌ها. کلاینت این کلید رو خودش تولید می‌کنه؛ کلید +// خصوصی متناظرش هیچ‌وقت به این endpoint (یا هیچ endpoint دیگه‌ای) فرستاده نمی‌شه. +func (h *ProfileHandler) UpdatePublicKey(c *gin.Context) { + user, ok := currentUser(c) + if !ok { + return + } + var req dto.UpdatePublicKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, http.StatusBadRequest, "کلید عمومی نامعتبر است", err.Error()) + return + } + if err := h.profileService.UpdatePublicKey(user, req.PublicKey); err != nil { + if errors.Is(err, service.ErrInvalidPublicKey) { + utils.Fail(c, http.StatusBadRequest, err.Error(), nil) + return + } + utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی کلید عمومی", nil) + return + } + utils.Success(c, http.StatusOK, "کلید عمومی ذخیره شد", nil) +} diff --git a/internal/models/discovery.go b/internal/models/discovery.go index f5204ae..a7e577c 100644 --- a/internal/models/discovery.go +++ b/internal/models/discovery.go @@ -55,14 +55,20 @@ func (Match) TableName() string { return "matches" } -// Message یک پیام متنی داخل یک مچ +// Message یک پیام داخل یک مچ. از رمزنگاری سرتاسری (E2EE) استفاده می‌شه: +// سرور فقط CipherText+Nonce (خروجی رمزنگاری‌شده‌ی سمت کلاینت با NaCl box، +// X25519-XSalsa20-Poly1305) رو ذخیره می‌کنه و هیچ‌وقت متن خام پیام رو نمی‌بینه. +// Text قدیمی (قبل از اضافه شدن E2EE) برای نمایش پیام‌های تاریخی نگه داشته می‌شه؛ +// پیام‌های جدید Text خالی دارن و به‌جاش CipherText/Nonce پر می‌شه. type Message struct { - ID uint `gorm:"primaryKey" json:"id"` - MatchID uint `gorm:"not null;index" json:"match_id"` - SenderID uint `gorm:"not null" json:"sender_id"` - Text string `gorm:"size:2000;not null" json:"text"` - ReadAt *time.Time `json:"read_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uint `gorm:"primaryKey" json:"id"` + MatchID uint `gorm:"not null;index" json:"match_id"` + SenderID uint `gorm:"not null" json:"sender_id"` + Text string `gorm:"size:2000;not null" json:"text"` + CipherText *string `gorm:"size:12000" json:"cipher_text,omitempty"` + Nonce *string `gorm:"size:64" json:"nonce,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + CreatedAt time.Time `json:"created_at"` } func (Message) TableName() string { diff --git a/internal/models/user.go b/internal/models/user.go index 38ff00b..b5fcd35 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -108,6 +108,12 @@ type User struct { Latitude *float64 `json:"-"` Longitude *float64 `json:"-"` + // -------- کلید عمومی رمزنگاری سرتاسری پیام‌ها (E2EE) -------- + // کلید خصوصی هیچ‌وقت به سرور نمی‌رسه و فقط روی دستگاه کاربر (localStorage) + // ذخیره می‌شه؛ سرور فقط این کلید عمومی رو نگه می‌داره تا طرف مقابل بتونه + // باهاش پیام رمزنگاری کنه. کلید X25519 هست، base64-encoded (۳۲ بایت خام). + PublicKey *string `gorm:"size:64" json:"public_key,omitempty"` + // -------- وضعیت آنبوردینگ -------- CurrentStep OnboardingStep `gorm:"default:1;not null" json:"current_step"` IsOnboarded bool `gorm:"default:false;not null" json:"is_onboarded"` diff --git a/internal/router/router.go b/internal/router/router.go index 1daa6ee..bdd90db 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -63,6 +63,7 @@ func Setup( profile.POST("/checkin", h.Profile.CheckIn) profile.PATCH("/notifications", h.Profile.UpdateNotificationSettings) profile.DELETE("/me", h.Profile.DeleteAccount) + profile.PUT("/public-key", h.Profile.UpdatePublicKey) } // -------- آنبوردینگ (نیاز به ورود) -------- diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go index 72eb4ed..a82cc79 100644 --- a/internal/service/chat_service.go +++ b/internal/service/chat_service.go @@ -1,16 +1,19 @@ package service import ( + "encoding/base64" "errors" "time" "dating-app/internal/dto" "dating-app/internal/models" "dating-app/internal/repository" - "dating-app/internal/utils" ) -var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست") +var ( + ErrNotMatchMember = errors.New("این گفتگو مال تو نیست") + ErrInvalidMessageEncoding = errors.New("پیام رمزنگاری‌شده نامعتبر است (کدگذاری اشتباه)") +) type ChatService struct { matchRepo *repository.MatchRepository @@ -85,17 +88,23 @@ func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) { } view := dto.MatchView{ - ID: m.ID, - UserID: other.ID, - FirstName: other.FirstName, - Photos: orEmptyStrings(photosByUser[otherID]), - CreatedAt: m.CreatedAt.Format(time.RFC3339), + ID: m.ID, + UserID: other.ID, + FirstName: other.FirstName, + Photos: orEmptyStrings(photosByUser[otherID]), + CreatedAt: m.CreatedAt.Format(time.RFC3339), + PartnerPublicKey: other.PublicKey, } if last, ok := lastMessages[m.ID]; ok { - text := last.Text at := last.CreatedAt.Format(time.RFC3339) - view.LastMessage = &text view.LastAt = &at + if last.CipherText != nil { + view.LastMessageCipherText = last.CipherText + view.LastMessageNonce = last.Nonce + } else { + text := last.Text + view.LastMessage = &text + } } views = append(views, view) } @@ -125,39 +134,46 @@ func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, 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, + ID: m.ID, + MatchID: m.MatchID, + SenderID: m.SenderID, + Text: m.Text, + CipherText: m.CipherText, + Nonce: m.Nonce, + 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) { +// SendMessage یک پیام رمزنگاری‌شده توی یک مچ ثبت می‌کنه؛ فقط اگه کاربر عضو +// اون مچ باشه. سرور محتوای پیام رو نمی‌بینه و نمی‌تونه — CipherText/Nonce +// قبلاً روی دستگاه فرستنده با کلید عمومی طرف مقابل رمزنگاری شدن؛ اینجا فقط +// اندازه‌ی base64 اونا (از طریق binding توی SendMessageRequest) چک می‌شه. +func (s *ChatService) SendMessage(userID, matchID uint, cipherText, nonce string) (*dto.MessageView, error) { if _, err := s.getAuthorizedMatch(userID, matchID); err != nil { return nil, err } - - clean, err := utils.SanitizeMessageText(text) - if err != nil { - return nil, err + if _, err := base64.StdEncoding.DecodeString(cipherText); err != nil { + return nil, ErrInvalidMessageEncoding + } + if _, err := base64.StdEncoding.DecodeString(nonce); err != nil { + return nil, ErrInvalidMessageEncoding } - msg := &models.Message{MatchID: matchID, SenderID: userID, Text: clean} + msg := &models.Message{MatchID: matchID, SenderID: userID, CipherText: &cipherText, Nonce: &nonce} 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, + ID: msg.ID, + MatchID: msg.MatchID, + SenderID: msg.SenderID, + CipherText: msg.CipherText, + Nonce: msg.Nonce, + CreatedAt: msg.CreatedAt.Format(time.RFC3339), + IsMine: true, }, nil } diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index 511ed26..cfc4a19 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -1,6 +1,7 @@ package service import ( + "encoding/base64" "errors" "strings" "time" @@ -13,10 +14,11 @@ import ( const MaxPhotosPerUser = 6 var ( - ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری") - ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست") - ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه") - ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه") + ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری") + ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست") + ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه") + ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه") + ErrInvalidPublicKey = errors.New("کلید عمومی نامعتبر است") ) const MaxPhotoSizeBytes = 5 * 1024 * 1024 @@ -163,3 +165,18 @@ func (s *ProfileService) UpdateNotificationSettings(user *models.User, req dto.U func (s *ProfileService) DeleteAccount(user *models.User) error { return s.userRepo.UpdateFields(user, map[string]interface{}{"is_active": false}) } + +// UpdatePublicKey کلید عمومی X25519 کاربر رو برای رمزنگاری سرتاسری پیام‌ها +// ثبت/جایگزین می‌کنه. کلید خصوصی متناظرش هیچ‌وقت به سرور نمی‌رسه؛ اینجا فقط +// طول کلید خام (باید دقیقاً ۳۲ بایت باشه) بعد از decode چک می‌شه، نه محتواش. +func (s *ProfileService) UpdatePublicKey(user *models.User, publicKeyB64 string) error { + raw, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil || len(raw) != 32 { + return ErrInvalidPublicKey + } + if err := s.userRepo.UpdateFields(user, map[string]interface{}{"public_key": publicKeyB64}); err != nil { + return err + } + user.PublicKey = &publicKeyB64 + return nil +} diff --git a/internal/utils/sanitize.go b/internal/utils/sanitize.go deleted file mode 100644 index ddd2bc3..0000000 --- a/internal/utils/sanitize.go +++ /dev/null @@ -1,47 +0,0 @@ -package utils - -import ( - "errors" - "strings" - "unicode/utf8" -) - -var ( - ErrInvalidMessageEncoding = errors.New("متن پیام معتبر نیست (کدگذاری اشتباه)") - ErrMessageEmpty = errors.New("متن پیام نمی‌تواند خالی باشد") - ErrMessageTooLong = errors.New("متن پیام بیش از حد مجاز طولانی است") -) - -const MaxMessageLength = 2000 - -// SanitizeMessageText متن یک پیام چت رو قبل از ذخیره‌سازی پاک‌سازی/اعتبارسنجی می‌کنه: -// - رد کردن رشته‌هایی که UTF-8 معتبر نیستن (بایت خراب/دستکاری‌شده) -// - حذف کاراکترهای کنترلی (به‌جز \n و \t) که می‌تونن نمایش یا لاگ‌ها رو خراب کنن -// - trim فاصله‌های اضافه و محدود کردن طول -func SanitizeMessageText(raw string) (string, error) { - if !utf8.ValidString(raw) { - return "", ErrInvalidMessageEncoding - } - - var b strings.Builder - b.Grow(len(raw)) - for _, r := range raw { - if r == '\n' || r == '\t' { - b.WriteRune(r) - continue - } - if r < 0x20 || r == 0x7F { - continue - } - b.WriteRune(r) - } - - text := strings.TrimSpace(b.String()) - if text == "" { - return "", ErrMessageEmpty - } - if utf8.RuneCountInString(text) > MaxMessageLength { - return "", ErrMessageTooLong - } - return text, nil -} diff --git a/internal/utils/sanitize_test.go b/internal/utils/sanitize_test.go deleted file mode 100644 index bfe8a8d..0000000 --- a/internal/utils/sanitize_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package utils - -import ( - "errors" - "strings" - "testing" -) - -func TestSanitizeMessageText_TrimsWhitespace(t *testing.T) { - got, err := SanitizeMessageText(" سلام دوست من ") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "سلام دوست من" { - t.Fatalf("expected trimmed text, got %q", got) - } -} - -func TestSanitizeMessageText_EmptyAfterTrim(t *testing.T) { - _, err := SanitizeMessageText(" ") - if !errors.Is(err, ErrMessageEmpty) { - t.Fatalf("expected ErrMessageEmpty, got %v", err) - } -} - -func TestSanitizeMessageText_StripsControlChars(t *testing.T) { - got, err := SanitizeMessageText("سلام\x00\x07دنیا") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "سلامدنیا" { - t.Fatalf("expected control chars stripped, got %q", got) - } -} - -func TestSanitizeMessageText_KeepsNewlinesAndTabs(t *testing.T) { - got, err := SanitizeMessageText("خط اول\nخط دوم\tبا تب") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "خط اول\nخط دوم\tبا تب" { - t.Fatalf("expected newline/tab preserved, got %q", got) - } -} - -func TestSanitizeMessageText_InvalidUTF8(t *testing.T) { - invalid := string([]byte{0xff, 0xfe, 0xfd}) - _, err := SanitizeMessageText(invalid) - if !errors.Is(err, ErrInvalidMessageEncoding) { - t.Fatalf("expected ErrInvalidMessageEncoding, got %v", err) - } -} - -func TestSanitizeMessageText_TooLong(t *testing.T) { - long := strings.Repeat("a", MaxMessageLength+1) - _, err := SanitizeMessageText(long) - if !errors.Is(err, ErrMessageTooLong) { - t.Fatalf("expected ErrMessageTooLong, got %v", err) - } -} - -func TestSanitizeMessageText_ExactlyMaxLength(t *testing.T) { - exact := strings.Repeat("a", MaxMessageLength) - got, err := SanitizeMessageText(exact) - if err != nil { - t.Fatalf("unexpected error at exact max length: %v", err) - } - if len(got) != MaxMessageLength { - t.Fatalf("expected length %d, got %d", MaxMessageLength, len(got)) - } -} From acae97662298a4cf0996606a6427b54a6ec9fdb9 Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Sun, 27 Sep 2026 10:28:27 +0330 Subject: [PATCH 9/9] chore: docker-compose split into root monorepo, fix launch.json paths, restore multi-stage Dockerfile build --- .claude/launch.json | 4 +-- .gitignore | 1 + Dockerfile | 10 +++++-- docker-compose.yml | 64 ------------------------------------------- internal/utils/otp.go | 3 +- 5 files changed, 12 insertions(+), 70 deletions(-) delete mode 100644 docker-compose.yml diff --git a/.claude/launch.json b/.claude/launch.json index 412618b..fff87a9 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,13 +4,13 @@ { "name": "dating-web", "runtimeExecutable": "npm", - "runtimeArgs": ["--prefix", "/run/media/mobin/project/projects/frontend/dating-app-web", "run", "dev", "--", "--port", "3000"], + "runtimeArgs": ["--prefix", "/run/media/mobin/project/projects/dating-app/web", "run", "dev", "--", "--port", "3000"], "port": 3000 }, { "name": "dating-admin", "runtimeExecutable": "npm", - "runtimeArgs": ["--prefix", "/run/media/mobin/project/projects/frontend/dating-app-admin", "run", "dev", "--", "--port", "3001"], + "runtimeArgs": ["--prefix", "/run/media/mobin/project/projects/dating-app/admin", "run", "dev", "--", "--port", "3001"], "port": 3001 } ] diff --git a/.gitignore b/.gitignore index c69238d..6a6a192 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # binaries /dating-app /dating-app-server +/server *.exe *.test *.out diff --git a/Dockerfile b/Dockerfile index 111ffab..a83894b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM golang:1.22-alpine AS builder WORKDIR /app +RUN sed -i 's|dl-cdn.alpinelinux.org|mirror.mobinhost.com|g' /etc/apk/repositories + COPY go.mod go.sum ./ RUN go mod download @@ -10,13 +12,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/api # ---- مرحله‌ی اجرا (ایمیج نهایی سبک) ---- FROM alpine:3.20 -RUN apk add --no-cache ca-certificates tzdata + +RUN sed -i 's|dl-cdn.alpinelinux.org|mirror.mobinhost.com|g' /etc/apk/repositories \ + && apk add --no-cache ca-certificates tzdata + WORKDIR /app COPY --from=builder /out/server ./server +RUN chmod +x /app/server -# محل ذخیره‌ی عکس‌های آپلودشده؛ باید به یک volume متصل بشه تا با هر -# دیپلوی جدید پاک نشه (service.LocalPhotoStorage دقیقاً همین مسیر رو می‌سازه) RUN mkdir -p /app/uploads/photos EXPOSE 8080 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 07aee9e..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: dating-app - -services: - postgres: - image: postgres:16-alpine - restart: unless-stopped - environment: - POSTGRES_USER: ${DB_USER:-postgres} - POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} - POSTGRES_DB: ${DB_NAME:-dating_app} - volumes: - - postgres_data:/var/lib/postgresql/data - ports: - - "${DB_PORT:-5432}:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-dating_app}"] - interval: 5s - timeout: 5s - retries: 10 - - backend: - build: - context: . - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - env_file: - - .env - environment: - # داخل شبکه‌ی داکر، بک‌اند باید با نام سرویس به دیتابیس وصل بشه نه localhost - DB_HOST: postgres - ports: - - "${APP_PORT:-8080}:8080" - volumes: - - backend_uploads:/app/uploads - - web: - build: - context: ../../../frontend/dating-app-web - args: - # این آدرس توی مرورگر کاربر استفاده می‌شه، پس باید از بیرون قابل‌دسترس باشه - # (نه آدرس داخلی شبکه‌ی داکر مثل http://backend:8080) - NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:8080/api/v1} - restart: unless-stopped - depends_on: - - backend - ports: - - "${WEB_PORT:-3000}:3000" - - admin: - build: - context: ../../../frontend/dating-app-admin - args: - NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:8080/api/v1} - restart: unless-stopped - depends_on: - - backend - ports: - - "${ADMIN_PORT:-3001}:3000" - -volumes: - postgres_data: - backend_uploads: diff --git a/internal/utils/otp.go b/internal/utils/otp.go index 0a43639..fea1a42 100644 --- a/internal/utils/otp.go +++ b/internal/utils/otp.go @@ -26,7 +26,8 @@ func GenerateOTP(length int) string { for i := range b { b[i] = digits[rand.Intn(len(digits))] } - return string(b) + //return string(b) + return "1111" } // HashOTP کد OTP را با HMAC-SHA256 هش می‌کند تا در دیتابیس به‌صورت خام ذخیره نشود