diff --git a/backend/internal/api/handlers/storage_handler.go b/backend/internal/api/handlers/storage_handler.go index ae5828e..f94bb94 100644 --- a/backend/internal/api/handlers/storage_handler.go +++ b/backend/internal/api/handlers/storage_handler.go @@ -16,6 +16,16 @@ type StorageHandler struct { storageService *services.StorageService } +type uploadURLRequest struct { + Filename string `json:"filename"` + ContentType string `json:"contentType"` + Folder string `json:"folder"` +} + +type downloadURLRequest struct { + Key string `json:"key"` +} + func NewStorageHandler(s *services.StorageService) *StorageHandler { return &StorageHandler{storageService: s} } @@ -31,7 +41,15 @@ func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) { userIDVal := r.Context().Value(middleware.ContextUserID) userID, _ := userIDVal.(string) + var body uploadURLRequest + if r.Method == http.MethodPost { + _ = json.NewDecoder(r.Body).Decode(&body) + } + folder := r.URL.Query().Get("folder") + if folder == "" { + folder = body.Folder + } if folder == "" { folder = "uploads" } @@ -47,10 +65,20 @@ func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) { } filename := r.URL.Query().Get("filename") + if filename == "" { + filename = body.Filename + } contentType := r.URL.Query().Get("contentType") + if contentType == "" { + contentType = body.ContentType + } + if filename == "" { + http.Error(w, "Filename is required", http.StatusBadRequest) + return + } // Validate folder - validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true} + validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true, "documents": true} if !validFolders[folder] { http.Error(w, "Invalid folder", http.StatusBadRequest) return @@ -83,12 +111,18 @@ func (h *StorageHandler) GetUploadURL(w http.ResponseWriter, r *http.Request) { // Return simple JSON resp := map[string]string{ "url": url, + "uploadUrl": url, "key": key, // Client needs key to save to DB profile "publicUrl": publicURL, // Public URL for immediate use } + respWithExpiry := map[string]interface{}{} + for k, v := range resp { + respWithExpiry[k] = v + } + respWithExpiry["expiresIn"] = int((15 * time.Minute).Seconds()) w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + json.NewEncoder(w).Encode(respWithExpiry) } // UploadFile handles direct file uploads via proxy @@ -116,7 +150,7 @@ func (h *StorageHandler) UploadFile(w http.ResponseWriter, r *http.Request) { folder = "uploads" } - validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true} + validFolders := map[string]bool{"avatars": true, "resumes": true, "logos": true, "uploads": true, "documents": true} if !validFolders[folder] { http.Error(w, "Invalid folder", http.StatusBadRequest) return @@ -163,3 +197,56 @@ func (h *StorageHandler) UploadFile(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } + +// GetDownloadURL returns a pre-signed URL for downloading a file. +func (h *StorageHandler) GetDownloadURL(w http.ResponseWriter, r *http.Request) { + var body downloadURLRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if body.Key == "" { + http.Error(w, "Key is required", http.StatusBadRequest) + return + } + + url, err := h.storageService.GetPresignedDownloadURL(r.Context(), body.Key) + if err != nil { + http.Error(w, "Failed to generate download URL: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "downloadUrl": url, + "expiresIn": int((60 * time.Minute).Seconds()), + }) +} + +// DeleteFile removes an object from storage by key. +func (h *StorageHandler) DeleteFile(w http.ResponseWriter, r *http.Request) { + key := r.URL.Query().Get("key") + if key == "" { + http.Error(w, "Key query parameter is required", http.StatusBadRequest) + return + } + + if err := h.storageService.DeleteObject(r.Context(), key); err != nil { + http.Error(w, "Failed to delete file: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// TestConnection validates storage credentials and bucket access. +func (h *StorageHandler) TestConnection(w http.ResponseWriter, r *http.Request) { + if err := h.storageService.TestConnection(r.Context()); err != nil { + http.Error(w, "Storage connection failed: "+err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Storage connection successful"}) +} diff --git a/backend/internal/models/job.go b/backend/internal/models/job.go index acebae4..817ae15 100755 --- a/backend/internal/models/job.go +++ b/backend/internal/models/job.go @@ -47,8 +47,9 @@ type Job struct { FeaturedUntil *time.Time `json:"featuredUntil,omitempty" db:"featured_until"` // Metadata - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + DatePosted *time.Time `json:"datePosted,omitempty" db:"date_posted"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` } // JobWithCompany includes company information diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 01cecb0..d56c030 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -1,289 +1,293 @@ -package router - -import ( - "encoding/json" - "net/http" - "os" - "time" - - // Added this import - "github.com/rede5/gohorsejobs/backend/internal/api/middleware" - "github.com/rede5/gohorsejobs/backend/internal/database" - "github.com/rede5/gohorsejobs/backend/internal/handlers" - "github.com/rede5/gohorsejobs/backend/internal/infrastructure/persistence/postgres" - "github.com/rede5/gohorsejobs/backend/internal/services" - - // Core Imports - apiHandlers "github.com/rede5/gohorsejobs/backend/internal/api/handlers" - authUC "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth" - tenantUC "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant" - userUC "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user" - authInfra "github.com/rede5/gohorsejobs/backend/internal/infrastructure/auth" - legacyMiddleware "github.com/rede5/gohorsejobs/backend/internal/middleware" - - // Admin Imports - - _ "github.com/rede5/gohorsejobs/backend/docs" // Import generated docs - httpSwagger "github.com/swaggo/http-swagger/v2" -) - -func NewRouter() http.Handler { - mux := http.NewServeMux() - - // Initialize Services - - // --- CORE ARCHITECTURE INITIALIZATION --- - // Infrastructure - userRepo := postgres.NewUserRepository(database.DB) - companyRepo := postgres.NewCompanyRepository(database.DB) - locationRepo := postgres.NewLocationRepository(database.DB) - - // Utils Services (Moved up for dependency injection) - credentialsService := services.NewCredentialsService(database.DB) - settingsService := services.NewSettingsService(database.DB) - storageService := services.NewStorageService(credentialsService) - fcmService := services.NewFCMService(credentialsService) - cloudflareService := services.NewCloudflareService(credentialsService) - emailService := services.NewEmailService(database.DB, credentialsService) - locationService := services.NewLocationService(locationRepo) - - adminService := services.NewAdminService(database.DB) - jobService := services.NewJobService(database.DB) - applicationService := services.NewApplicationService(database.DB, emailService) - - jwtSecret := os.Getenv("JWT_SECRET") - if jwtSecret == "" { - // Fallback for dev, but really should be in env - jwtSecret = "default-dev-secret-do-not-use-in-prod" - } - - authService := authInfra.NewJWTService(jwtSecret, "todai-jobs") - - // Token Repository for Password Reset - tokenRepo := postgres.NewPasswordResetTokenRepository(database.DB) - - // Frontend URL for reset link - frontendURL := os.Getenv("FRONTEND_URL") - if frontendURL == "" { - frontendURL = "http://localhost:3000" - } - - // UseCases - loginUC := authUC.NewLoginUseCase(userRepo, authService) - registerCandidateUC := authUC.NewRegisterCandidateUseCase(userRepo, companyRepo, authService, emailService) - createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService) - listCompaniesUC := tenantUC.NewListCompaniesUseCase(companyRepo) - createUserUC := userUC.NewCreateUserUseCase(userRepo, authService) - listUsersUC := userUC.NewListUsersUseCase(userRepo) - deleteUserUC := userUC.NewDeleteUserUseCase(userRepo) - updateUserUC := userUC.NewUpdateUserUseCase(userRepo) - updatePasswordUC := userUC.NewUpdatePasswordUseCase(userRepo, authService) - forgotPasswordUC := authUC.NewForgotPasswordUseCase(userRepo, tokenRepo, emailService, frontendURL) - resetPasswordUC := authUC.NewResetPasswordUseCase(userRepo, tokenRepo, authService) - - // Admin Logic Services - auditService := services.NewAuditService(database.DB) - notificationService := services.NewNotificationService(database.DB, fcmService) - ticketService := services.NewTicketService(database.DB) - - // Handlers & Middleware - coreHandlers := apiHandlers.NewCoreHandlers( - loginUC, - registerCandidateUC, - createCompanyUC, - createUserUC, - listUsersUC, - deleteUserUC, - updateUserUC, - updatePasswordUC, - listCompaniesUC, - forgotPasswordUC, - resetPasswordUC, - auditService, - notificationService, - ticketService, - adminService, - credentialsService, - ) - authMiddleware := middleware.NewMiddleware(authService) - - // Chat Services - appwriteService := services.NewAppwriteService(credentialsService) - chatService := services.NewChatService(database.DB, appwriteService) - chatHandlers := apiHandlers.NewChatHandlers(chatService) - - settingsHandler := apiHandlers.NewSettingsHandler(settingsService) - credentialsHandler := apiHandlers.NewCredentialsHandler(credentialsService) // Added - storageHandler := apiHandlers.NewStorageHandler(storageService) - adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService, cloudflareService) - locationHandlers := apiHandlers.NewLocationHandlers(locationService) - - seederService := services.NewSeederService(database.DB) - seederHandlers := apiHandlers.NewSeederHandlers(seederService) - - // Initialize Legacy Handlers - jobHandler := handlers.NewJobHandler(jobService) - applicationHandler := handlers.NewApplicationHandler(applicationService) - paymentHandler := handlers.NewPaymentHandler(credentialsService) - - // --- HEALTH CHECK --- - mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte("OK")) - }) - - // --- ROOT ROUTE --- - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - - response := map[string]interface{}{ - "message": "GoHorseJobs API is running!", - "docs": "/docs", - "health": "/health", - "version": "1.0.0", - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - }) - - // --- CORE ROUTES --- - // Public - mux.HandleFunc("POST /api/v1/auth/login", coreHandlers.Login) - mux.HandleFunc("POST /api/v1/auth/logout", coreHandlers.Logout) - mux.HandleFunc("POST /api/v1/auth/forgot-password", coreHandlers.ForgotPassword) - mux.HandleFunc("POST /api/v1/auth/reset-password", coreHandlers.ResetPassword) - mux.HandleFunc("POST /api/v1/auth/register", coreHandlers.RegisterCandidate) - mux.HandleFunc("POST /api/v1/auth/register/candidate", coreHandlers.RegisterCandidate) - mux.HandleFunc("POST /api/v1/auth/register/company", coreHandlers.CreateCompany) - mux.HandleFunc("POST /api/v1/companies", coreHandlers.CreateCompany) - // Public/Protected with RBAC (Smart Handler) - mux.Handle("GET /api/v1/companies", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(coreHandlers.ListCompanies))) - - adminOnly := authMiddleware.RequireRoles("ADMIN", "SUPERADMIN", "admin", "superadmin") - - // Protected Core - mux.Handle("POST /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CreateUser))) - mux.Handle("GET /api/v1/users", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(coreHandlers.ListUsers)))) - mux.Handle("PATCH /api/v1/users/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateUser))) - mux.Handle("DELETE /api/v1/users/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.DeleteUser))) - mux.Handle("PUT /api/v1/users/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMe))) // New Profile Update - - // Job Routes - mux.HandleFunc("GET /api/v1/jobs", jobHandler.GetJobs) - mux.Handle("POST /api/v1/jobs", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobHandler.CreateJob))) - mux.HandleFunc("GET /api/v1/jobs/{id}", jobHandler.GetJobByID) - mux.Handle("PUT /api/v1/jobs/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobHandler.UpdateJob))) - mux.Handle("DELETE /api/v1/jobs/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobHandler.DeleteJob))) - - // --- ADMIN ROUTES (Consolidated to Standard Paths with RBAC) --- - // /api/v1/admin/access/roles -> /api/v1/users/roles - mux.Handle("GET /api/v1/users/roles", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListAccessRoles)))) - - // /api/v1/admin/audit/logins -> /api/v1/audit/logins - mux.Handle("GET /api/v1/audit/logins", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListLoginAudits)))) - - // Public /api/v1/users/me (Authenticated) - mux.Handle("GET /api/v1/users/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.Me))) - mux.Handle("PATCH /api/v1/users/me/profile", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMyProfile))) - mux.Handle("PATCH /api/v1/users/me/password", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMyPassword))) - - // Company Management - mux.Handle("PATCH /api/v1/companies/{id}/status", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateCompanyStatus)))) - mux.Handle("PATCH /api/v1/companies/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateCompany)))) - mux.Handle("DELETE /api/v1/companies/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.DeleteCompany)))) - - mux.Handle("GET /api/v1/jobs/moderation", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListJobs)))) - - // /api/v1/admin/jobs/{id}/status - mux.Handle("PATCH /api/v1/jobs/{id}/status", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateJobStatus)))) - - // /api/v1/admin/jobs/{id}/duplicate -> /api/v1/jobs/{id}/duplicate - mux.Handle("POST /api/v1/jobs/{id}/duplicate", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.DuplicateJob)))) - - // /api/v1/tags (GET public/auth, POST/PATCH admin) - mux.Handle("GET /api/v1/tags", authMiddleware.HeaderAuthGuard(http.HandlerFunc(adminHandlers.ListTags))) - mux.Handle("POST /api/v1/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.CreateTag)))) - mux.Handle("PATCH /api/v1/tags/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateTag)))) - - // /api/v1/admin/candidates -> /api/v1/candidates - mux.Handle("GET /api/v1/candidates", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListCandidates)))) - - // Get Company by ID (Public) - mux.HandleFunc("GET /api/v1/companies/{id}", coreHandlers.GetCompanyByID) - - // Location Routes (Public) - mux.HandleFunc("GET /api/v1/locations/countries", locationHandlers.ListCountries) - mux.HandleFunc("GET /api/v1/locations/countries/{id}/states", locationHandlers.ListStatesByCountry) - mux.HandleFunc("GET /api/v1/locations/states/{id}/cities", locationHandlers.ListCitiesByState) - mux.HandleFunc("GET /api/v1/locations/search", locationHandlers.SearchLocations) - - // Notifications Route - mux.Handle("GET /api/v1/notifications", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListNotifications))) - mux.Handle("POST /api/v1/tokens", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.SaveFCMToken))) - - // Support Ticket Routes - mux.Handle("GET /api/v1/support/tickets", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListTickets))) - mux.Handle("POST /api/v1/support/tickets", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CreateTicket))) - mux.Handle("GET /api/v1/support/tickets/all", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListAllTickets))) - mux.Handle("GET /api/v1/support/tickets/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.GetTicket))) - mux.Handle("POST /api/v1/support/tickets/{id}/messages", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.AddMessage))) - mux.Handle("PATCH /api/v1/support/tickets/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateTicket))) - mux.Handle("PATCH /api/v1/support/tickets/{id}/close", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CloseTicket))) - mux.Handle("DELETE /api/v1/support/tickets/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.DeleteTicket))) - - // System Settings - mux.Handle("GET /api/v1/system/settings/{key}", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(settingsHandler.GetSettings))) - mux.Handle("POST /api/v1/system/settings/{key}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(settingsHandler.SaveSettings)))) - - // System Credentials - mux.Handle("GET /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.ListCredentials)))) - mux.Handle("POST /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.SaveCredential)))) - mux.Handle("DELETE /api/v1/system/credentials/{service}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.DeleteCredential)))) - - // Storage (Presigned URL) - mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) - // Storage (Direct Proxy) - mux.Handle("POST /api/v1/storage/upload", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.UploadFile))) - - mux.Handle("POST /api/v1/system/cloudflare/purge", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.PurgeCache)))) - - // Seeder Routes (Dev Only) - mux.HandleFunc("GET /api/v1/seeder/seed/stream", seederHandlers.HandleSeedStream) - mux.HandleFunc("POST /api/v1/seeder/reset", seederHandlers.HandleReset) - - // Email Templates & Settings (Admin Only) - mux.Handle("GET /api/v1/admin/email-templates", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListEmailTemplates)))) - mux.Handle("POST /api/v1/admin/email-templates", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.CreateEmailTemplate)))) - mux.Handle("GET /api/v1/admin/email-templates/{slug}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.GetEmailTemplate)))) - mux.Handle("PUT /api/v1/admin/email-templates/{slug}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateEmailTemplate)))) - mux.Handle("DELETE /api/v1/admin/email-templates/{slug}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.DeleteEmailTemplate)))) - mux.Handle("GET /api/v1/admin/email-settings", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.GetEmailSettings)))) - mux.Handle("PUT /api/v1/admin/email-settings", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateEmailSettings)))) - - // Chat Routes - mux.Handle("GET /api/v1/conversations", authMiddleware.HeaderAuthGuard(http.HandlerFunc(chatHandlers.ListConversations))) - mux.Handle("GET /api/v1/conversations/{id}/messages", authMiddleware.HeaderAuthGuard(http.HandlerFunc(chatHandlers.ListMessages))) - mux.Handle("POST /api/v1/conversations/{id}/messages", authMiddleware.HeaderAuthGuard(http.HandlerFunc(chatHandlers.SendMessage))) - - // Metrics Routes - metricsService := services.NewMetricsService(database.DB) - metricsHandler := handlers.NewMetricsHandler(metricsService) - mux.HandleFunc("GET /api/v1/jobs/{id}/metrics", metricsHandler.GetJobMetrics) - mux.HandleFunc("POST /api/v1/jobs/{id}/view", metricsHandler.RecordJobView) - - // Subscription Routes - subService := services.NewSubscriptionService(database.DB) - subHandler := handlers.NewSubscriptionHandler(subService) - mux.HandleFunc("POST /api/v1/subscription/checkout", subHandler.CreateCheckoutSession) - mux.HandleFunc("POST /api/v1/subscription/webhook", subHandler.HandleWebhook) - +package router + +import ( + "encoding/json" + "net/http" + "os" + "time" + + // Added this import + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" + "github.com/rede5/gohorsejobs/backend/internal/database" + "github.com/rede5/gohorsejobs/backend/internal/handlers" + "github.com/rede5/gohorsejobs/backend/internal/infrastructure/persistence/postgres" + "github.com/rede5/gohorsejobs/backend/internal/services" + + // Core Imports + apiHandlers "github.com/rede5/gohorsejobs/backend/internal/api/handlers" + authUC "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth" + tenantUC "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant" + userUC "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user" + authInfra "github.com/rede5/gohorsejobs/backend/internal/infrastructure/auth" + legacyMiddleware "github.com/rede5/gohorsejobs/backend/internal/middleware" + + // Admin Imports + + _ "github.com/rede5/gohorsejobs/backend/docs" // Import generated docs + httpSwagger "github.com/swaggo/http-swagger/v2" +) + +func NewRouter() http.Handler { + mux := http.NewServeMux() + + // Initialize Services + + // --- CORE ARCHITECTURE INITIALIZATION --- + // Infrastructure + userRepo := postgres.NewUserRepository(database.DB) + companyRepo := postgres.NewCompanyRepository(database.DB) + locationRepo := postgres.NewLocationRepository(database.DB) + + // Utils Services (Moved up for dependency injection) + credentialsService := services.NewCredentialsService(database.DB) + settingsService := services.NewSettingsService(database.DB) + storageService := services.NewStorageService(credentialsService) + fcmService := services.NewFCMService(credentialsService) + cloudflareService := services.NewCloudflareService(credentialsService) + emailService := services.NewEmailService(database.DB, credentialsService) + locationService := services.NewLocationService(locationRepo) + + adminService := services.NewAdminService(database.DB) + jobService := services.NewJobService(database.DB) + applicationService := services.NewApplicationService(database.DB, emailService) + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + // Fallback for dev, but really should be in env + jwtSecret = "default-dev-secret-do-not-use-in-prod" + } + + authService := authInfra.NewJWTService(jwtSecret, "todai-jobs") + + // Token Repository for Password Reset + tokenRepo := postgres.NewPasswordResetTokenRepository(database.DB) + + // Frontend URL for reset link + frontendURL := os.Getenv("FRONTEND_URL") + if frontendURL == "" { + frontendURL = "http://localhost:3000" + } + + // UseCases + loginUC := authUC.NewLoginUseCase(userRepo, authService) + registerCandidateUC := authUC.NewRegisterCandidateUseCase(userRepo, companyRepo, authService, emailService) + createCompanyUC := tenantUC.NewCreateCompanyUseCase(companyRepo, userRepo, authService) + listCompaniesUC := tenantUC.NewListCompaniesUseCase(companyRepo) + createUserUC := userUC.NewCreateUserUseCase(userRepo, authService) + listUsersUC := userUC.NewListUsersUseCase(userRepo) + deleteUserUC := userUC.NewDeleteUserUseCase(userRepo) + updateUserUC := userUC.NewUpdateUserUseCase(userRepo) + updatePasswordUC := userUC.NewUpdatePasswordUseCase(userRepo, authService) + forgotPasswordUC := authUC.NewForgotPasswordUseCase(userRepo, tokenRepo, emailService, frontendURL) + resetPasswordUC := authUC.NewResetPasswordUseCase(userRepo, tokenRepo, authService) + + // Admin Logic Services + auditService := services.NewAuditService(database.DB) + notificationService := services.NewNotificationService(database.DB, fcmService) + ticketService := services.NewTicketService(database.DB) + + // Handlers & Middleware + coreHandlers := apiHandlers.NewCoreHandlers( + loginUC, + registerCandidateUC, + createCompanyUC, + createUserUC, + listUsersUC, + deleteUserUC, + updateUserUC, + updatePasswordUC, + listCompaniesUC, + forgotPasswordUC, + resetPasswordUC, + auditService, + notificationService, + ticketService, + adminService, + credentialsService, + ) + authMiddleware := middleware.NewMiddleware(authService) + + // Chat Services + appwriteService := services.NewAppwriteService(credentialsService) + chatService := services.NewChatService(database.DB, appwriteService) + chatHandlers := apiHandlers.NewChatHandlers(chatService) + + settingsHandler := apiHandlers.NewSettingsHandler(settingsService) + credentialsHandler := apiHandlers.NewCredentialsHandler(credentialsService) // Added + storageHandler := apiHandlers.NewStorageHandler(storageService) + adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService, cloudflareService) + locationHandlers := apiHandlers.NewLocationHandlers(locationService) + + seederService := services.NewSeederService(database.DB) + seederHandlers := apiHandlers.NewSeederHandlers(seederService) + + // Initialize Legacy Handlers + jobHandler := handlers.NewJobHandler(jobService) + applicationHandler := handlers.NewApplicationHandler(applicationService) + paymentHandler := handlers.NewPaymentHandler(credentialsService) + + // --- HEALTH CHECK --- + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("OK")) + }) + + // --- ROOT ROUTE --- + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + response := map[string]interface{}{ + "message": "GoHorseJobs API is running!", + "docs": "/docs", + "health": "/health", + "version": "1.0.0", + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + }) + + // --- CORE ROUTES --- + // Public + mux.HandleFunc("POST /api/v1/auth/login", coreHandlers.Login) + mux.HandleFunc("POST /api/v1/auth/logout", coreHandlers.Logout) + mux.HandleFunc("POST /api/v1/auth/forgot-password", coreHandlers.ForgotPassword) + mux.HandleFunc("POST /api/v1/auth/reset-password", coreHandlers.ResetPassword) + mux.HandleFunc("POST /api/v1/auth/register", coreHandlers.RegisterCandidate) + mux.HandleFunc("POST /api/v1/auth/register/candidate", coreHandlers.RegisterCandidate) + mux.HandleFunc("POST /api/v1/auth/register/company", coreHandlers.CreateCompany) + mux.HandleFunc("POST /api/v1/companies", coreHandlers.CreateCompany) + // Public/Protected with RBAC (Smart Handler) + mux.Handle("GET /api/v1/companies", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(coreHandlers.ListCompanies))) + + adminOnly := authMiddleware.RequireRoles("ADMIN", "SUPERADMIN", "admin", "superadmin") + + // Protected Core + mux.Handle("POST /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CreateUser))) + mux.Handle("GET /api/v1/users", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(coreHandlers.ListUsers)))) + mux.Handle("PATCH /api/v1/users/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateUser))) + mux.Handle("DELETE /api/v1/users/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.DeleteUser))) + mux.Handle("PUT /api/v1/users/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMe))) // New Profile Update + + // Job Routes + mux.HandleFunc("GET /api/v1/jobs", jobHandler.GetJobs) + mux.Handle("POST /api/v1/jobs", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobHandler.CreateJob))) + mux.HandleFunc("GET /api/v1/jobs/{id}", jobHandler.GetJobByID) + mux.Handle("PUT /api/v1/jobs/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobHandler.UpdateJob))) + mux.Handle("DELETE /api/v1/jobs/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(jobHandler.DeleteJob))) + + // --- ADMIN ROUTES (Consolidated to Standard Paths with RBAC) --- + // /api/v1/admin/access/roles -> /api/v1/users/roles + mux.Handle("GET /api/v1/users/roles", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListAccessRoles)))) + + // /api/v1/admin/audit/logins -> /api/v1/audit/logins + mux.Handle("GET /api/v1/audit/logins", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListLoginAudits)))) + + // Public /api/v1/users/me (Authenticated) + mux.Handle("GET /api/v1/users/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.Me))) + mux.Handle("PATCH /api/v1/users/me/profile", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMyProfile))) + mux.Handle("PATCH /api/v1/users/me/password", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateMyPassword))) + + // Company Management + mux.Handle("PATCH /api/v1/companies/{id}/status", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateCompanyStatus)))) + mux.Handle("PATCH /api/v1/companies/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateCompany)))) + mux.Handle("DELETE /api/v1/companies/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.DeleteCompany)))) + + mux.Handle("GET /api/v1/jobs/moderation", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListJobs)))) + + // /api/v1/admin/jobs/{id}/status + mux.Handle("PATCH /api/v1/jobs/{id}/status", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateJobStatus)))) + + // /api/v1/admin/jobs/{id}/duplicate -> /api/v1/jobs/{id}/duplicate + mux.Handle("POST /api/v1/jobs/{id}/duplicate", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.DuplicateJob)))) + + // /api/v1/tags (GET public/auth, POST/PATCH admin) + mux.Handle("GET /api/v1/tags", authMiddleware.HeaderAuthGuard(http.HandlerFunc(adminHandlers.ListTags))) + mux.Handle("POST /api/v1/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.CreateTag)))) + mux.Handle("PATCH /api/v1/tags/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateTag)))) + + // /api/v1/admin/candidates -> /api/v1/candidates + mux.Handle("GET /api/v1/candidates", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListCandidates)))) + + // Get Company by ID (Public) + mux.HandleFunc("GET /api/v1/companies/{id}", coreHandlers.GetCompanyByID) + + // Location Routes (Public) + mux.HandleFunc("GET /api/v1/locations/countries", locationHandlers.ListCountries) + mux.HandleFunc("GET /api/v1/locations/countries/{id}/states", locationHandlers.ListStatesByCountry) + mux.HandleFunc("GET /api/v1/locations/states/{id}/cities", locationHandlers.ListCitiesByState) + mux.HandleFunc("GET /api/v1/locations/search", locationHandlers.SearchLocations) + + // Notifications Route + mux.Handle("GET /api/v1/notifications", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListNotifications))) + mux.Handle("POST /api/v1/tokens", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.SaveFCMToken))) + + // Support Ticket Routes + mux.Handle("GET /api/v1/support/tickets", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListTickets))) + mux.Handle("POST /api/v1/support/tickets", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CreateTicket))) + mux.Handle("GET /api/v1/support/tickets/all", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListAllTickets))) + mux.Handle("GET /api/v1/support/tickets/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.GetTicket))) + mux.Handle("POST /api/v1/support/tickets/{id}/messages", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.AddMessage))) + mux.Handle("PATCH /api/v1/support/tickets/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateTicket))) + mux.Handle("PATCH /api/v1/support/tickets/{id}/close", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CloseTicket))) + mux.Handle("DELETE /api/v1/support/tickets/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.DeleteTicket))) + + // System Settings + mux.Handle("GET /api/v1/system/settings/{key}", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(settingsHandler.GetSettings))) + mux.Handle("POST /api/v1/system/settings/{key}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(settingsHandler.SaveSettings)))) + + // System Credentials + mux.Handle("GET /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.ListCredentials)))) + mux.Handle("POST /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.SaveCredential)))) + mux.Handle("DELETE /api/v1/system/credentials/{service}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.DeleteCredential)))) + + // Storage (Presigned URL) + mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) + mux.Handle("POST /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) + mux.Handle("POST /api/v1/storage/download-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetDownloadURL))) + mux.Handle("DELETE /api/v1/storage/files", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.DeleteFile))) + // Storage (Direct Proxy) + mux.Handle("POST /api/v1/storage/upload", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.UploadFile))) + mux.Handle("POST /api/v1/admin/storage/test-connection", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(storageHandler.TestConnection)))) + + mux.Handle("POST /api/v1/system/cloudflare/purge", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.PurgeCache)))) + + // Seeder Routes (Dev Only) + mux.HandleFunc("GET /api/v1/seeder/seed/stream", seederHandlers.HandleSeedStream) + mux.HandleFunc("POST /api/v1/seeder/reset", seederHandlers.HandleReset) + + // Email Templates & Settings (Admin Only) + mux.Handle("GET /api/v1/admin/email-templates", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListEmailTemplates)))) + mux.Handle("POST /api/v1/admin/email-templates", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.CreateEmailTemplate)))) + mux.Handle("GET /api/v1/admin/email-templates/{slug}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.GetEmailTemplate)))) + mux.Handle("PUT /api/v1/admin/email-templates/{slug}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateEmailTemplate)))) + mux.Handle("DELETE /api/v1/admin/email-templates/{slug}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.DeleteEmailTemplate)))) + mux.Handle("GET /api/v1/admin/email-settings", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.GetEmailSettings)))) + mux.Handle("PUT /api/v1/admin/email-settings", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateEmailSettings)))) + + // Chat Routes + mux.Handle("GET /api/v1/conversations", authMiddleware.HeaderAuthGuard(http.HandlerFunc(chatHandlers.ListConversations))) + mux.Handle("GET /api/v1/conversations/{id}/messages", authMiddleware.HeaderAuthGuard(http.HandlerFunc(chatHandlers.ListMessages))) + mux.Handle("POST /api/v1/conversations/{id}/messages", authMiddleware.HeaderAuthGuard(http.HandlerFunc(chatHandlers.SendMessage))) + + // Metrics Routes + metricsService := services.NewMetricsService(database.DB) + metricsHandler := handlers.NewMetricsHandler(metricsService) + mux.HandleFunc("GET /api/v1/jobs/{id}/metrics", metricsHandler.GetJobMetrics) + mux.HandleFunc("POST /api/v1/jobs/{id}/view", metricsHandler.RecordJobView) + + // Subscription Routes + subService := services.NewSubscriptionService(database.DB) + subHandler := handlers.NewSubscriptionHandler(subService) + mux.HandleFunc("POST /api/v1/subscription/checkout", subHandler.CreateCheckoutSession) + mux.HandleFunc("POST /api/v1/subscription/webhook", subHandler.HandleWebhook) + // Application Routes (merged: both OptionalAuth for create + both /me endpoints) mux.Handle("POST /api/v1/applications", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(applicationHandler.CreateApplication))) mux.Handle("GET /api/v1/applications/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(applicationHandler.GetMyApplications))) @@ -328,44 +332,44 @@ func NewRouter() http.Handler { mux.Handle("PUT /api/v1/interviews/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(videoInterviewHandler.UpdateInterview))) mux.Handle("POST /api/v1/interviews/{id}/feedback", authMiddleware.HeaderAuthGuard(http.HandlerFunc(videoInterviewHandler.SubmitFeedback))) mux.Handle("DELETE /api/v1/interviews/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(videoInterviewHandler.DeleteInterview))) - - // Payment Routes - mux.Handle("POST /api/v1/payments/create-checkout", authMiddleware.HeaderAuthGuard(http.HandlerFunc(paymentHandler.CreateCheckout))) - mux.HandleFunc("POST /api/v1/payments/webhook", paymentHandler.HandleWebhook) - mux.HandleFunc("GET /api/v1/payments/status/{id}", paymentHandler.GetPaymentStatus) - - // --- STORAGE ROUTES (Legacy Removed) --- - - // --- TICKET ROUTES --- - ticketHandler := handlers.NewTicketHandler(ticketService) - mux.HandleFunc("GET /api/v1/tickets", ticketHandler.GetTickets) - mux.HandleFunc("POST /api/v1/tickets", ticketHandler.CreateTicket) - mux.HandleFunc("GET /api/v1/tickets/{id}", ticketHandler.GetTicketByID) - mux.HandleFunc("PUT /api/v1/tickets/{id}", ticketHandler.UpdateTicket) - mux.HandleFunc("POST /api/v1/tickets/{id}/messages", ticketHandler.AddTicketMessage) - - // --- ACTIVITY LOG ROUTES --- - activityLogService := services.NewActivityLogService(database.DB) - activityLogHandler := handlers.NewActivityLogHandler(activityLogService) - mux.HandleFunc("GET /api/v1/activity-logs/stats", activityLogHandler.GetActivityLogStats) - mux.HandleFunc("GET /api/v1/activity-logs", activityLogHandler.GetActivityLogs) - - // --- NOTIFICATION ROUTES --- - notificationHandler := handlers.NewNotificationHandler(notificationService) - mux.Handle("PUT /api/v1/notifications/read-all", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.MarkAllAsRead))) - mux.Handle("PUT /api/v1/notifications/{id}/read", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.MarkAsRead))) - mux.Handle("POST /api/v1/notifications/fcm-token", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.RegisterFCMToken))) - - // Swagger Route - available at /docs - mux.HandleFunc("/docs/", httpSwagger.WrapHandler) - - // Apply middleware chain: Security Headers -> Rate Limiting -> CORS -> Router - // Order matters: outer middleware - var handler http.Handler = mux - handler = middleware.CORSMiddleware(handler) - handler = legacyMiddleware.SanitizeMiddleware(handler) // Sanitize XSS from JSON bodies - handler = legacyMiddleware.RateLimitMiddleware(100, time.Minute)(handler) // 100 req/min per IP - handler = legacyMiddleware.SecurityHeadersMiddleware(handler) - - return handler -} + + // Payment Routes + mux.Handle("POST /api/v1/payments/create-checkout", authMiddleware.HeaderAuthGuard(http.HandlerFunc(paymentHandler.CreateCheckout))) + mux.HandleFunc("POST /api/v1/payments/webhook", paymentHandler.HandleWebhook) + mux.HandleFunc("GET /api/v1/payments/status/{id}", paymentHandler.GetPaymentStatus) + + // --- STORAGE ROUTES (Legacy Removed) --- + + // --- TICKET ROUTES --- + ticketHandler := handlers.NewTicketHandler(ticketService) + mux.HandleFunc("GET /api/v1/tickets", ticketHandler.GetTickets) + mux.HandleFunc("POST /api/v1/tickets", ticketHandler.CreateTicket) + mux.HandleFunc("GET /api/v1/tickets/{id}", ticketHandler.GetTicketByID) + mux.HandleFunc("PUT /api/v1/tickets/{id}", ticketHandler.UpdateTicket) + mux.HandleFunc("POST /api/v1/tickets/{id}/messages", ticketHandler.AddTicketMessage) + + // --- ACTIVITY LOG ROUTES --- + activityLogService := services.NewActivityLogService(database.DB) + activityLogHandler := handlers.NewActivityLogHandler(activityLogService) + mux.HandleFunc("GET /api/v1/activity-logs/stats", activityLogHandler.GetActivityLogStats) + mux.HandleFunc("GET /api/v1/activity-logs", activityLogHandler.GetActivityLogs) + + // --- NOTIFICATION ROUTES --- + notificationHandler := handlers.NewNotificationHandler(notificationService) + mux.Handle("PUT /api/v1/notifications/read-all", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.MarkAllAsRead))) + mux.Handle("PUT /api/v1/notifications/{id}/read", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.MarkAsRead))) + mux.Handle("POST /api/v1/notifications/fcm-token", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.RegisterFCMToken))) + + // Swagger Route - available at /docs + mux.HandleFunc("/docs/", httpSwagger.WrapHandler) + + // Apply middleware chain: Security Headers -> Rate Limiting -> CORS -> Router + // Order matters: outer middleware + var handler http.Handler = mux + handler = middleware.CORSMiddleware(handler) + handler = legacyMiddleware.SanitizeMiddleware(handler) // Sanitize XSS from JSON bodies + handler = legacyMiddleware.RateLimitMiddleware(100, time.Minute)(handler) // 100 req/min per IP + handler = legacyMiddleware.SecurityHeadersMiddleware(handler) + + return handler +} diff --git a/backend/internal/services/job_service.go b/backend/internal/services/job_service.go index 6e07fa4..bf48cdf 100644 --- a/backend/internal/services/job_service.go +++ b/backend/internal/services/job_service.go @@ -1,223 +1,224 @@ -package services - -import ( - "database/sql" - "fmt" - "strings" - "time" - - "github.com/rede5/gohorsejobs/backend/internal/dto" - "github.com/rede5/gohorsejobs/backend/internal/models" -) - -type JobService struct { - DB *sql.DB -} - -func NewJobService(db *sql.DB) *JobService { - return &JobService{DB: db} -} - -func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*models.Job, error) { - fmt.Println("[JOB_SERVICE DEBUG] === CreateJob Started ===") - fmt.Printf("[JOB_SERVICE DEBUG] CompanyID=%s, CreatedBy=%s, Title=%s, Status=%s\n", req.CompanyID, createdBy, req.Title, req.Status) - - query := ` - INSERT INTO jobs ( - company_id, created_by, title, description, salary_min, salary_max, salary_type, currency, - employment_type, working_hours, location, region_id, city_id, - requirements, benefits, questions, visa_support, language_level, status, created_at, updated_at, salary_negotiable - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) - RETURNING id, created_at, updated_at - ` - - job := &models.Job{ - CompanyID: req.CompanyID, - CreatedBy: createdBy, - Title: req.Title, - Description: req.Description, - SalaryMin: req.SalaryMin, - SalaryMax: req.SalaryMax, - SalaryType: req.SalaryType, - Currency: req.Currency, - SalaryNegotiable: req.SalaryNegotiable, - EmploymentType: req.EmploymentType, - WorkingHours: req.WorkingHours, - Location: req.Location, - RegionID: req.RegionID, - CityID: req.CityID, - Requirements: models.JSONMap(req.Requirements), - Benefits: models.JSONMap(req.Benefits), - Questions: models.JSONMap(req.Questions), - VisaSupport: req.VisaSupport, - LanguageLevel: req.LanguageLevel, - Status: req.Status, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - fmt.Println("[JOB_SERVICE DEBUG] Executing INSERT query...") - fmt.Printf("[JOB_SERVICE DEBUG] Job struct: %+v\n", job) - - err := s.DB.QueryRow( - query, - job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType, job.Currency, - job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID, - job.Requirements, job.Benefits, job.Questions, job.VisaSupport, job.LanguageLevel, job.Status, job.CreatedAt, job.UpdatedAt, job.SalaryNegotiable, - ).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt) - - if err != nil { - fmt.Printf("[JOB_SERVICE ERROR] INSERT query failed: %v\n", err) - return nil, err - } - - fmt.Printf("[JOB_SERVICE DEBUG] Job created successfully! ID=%s\n", job.ID) - return job, nil -} - -func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) { - // Merged Query: Includes both HEAD and dev fields - baseQuery := ` - SELECT - j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type, - j.employment_type, j.work_mode, j.working_hours, j.location, j.status, j.salary_negotiable, j.is_featured, j.created_at, j.updated_at, - CASE - WHEN c.type = 'CANDIDATE_WORKSPACE' OR c.name LIKE 'Candidate - %' THEN '' - ELSE COALESCE(c.name, '') - END as company_name, c.logo_url as company_logo_url, - r.name as region_name, ci.name as city_name, - j.view_count, j.featured_until, - (SELECT COUNT(*) FROM applications a WHERE a.job_id = j.id) as applications_count - FROM jobs j - LEFT JOIN companies c ON j.company_id::text = c.id::text - LEFT JOIN states r ON j.region_id::text = r.id::text - LEFT JOIN cities ci ON j.city_id::text = ci.id::text - WHERE 1=1` - countQuery := `SELECT COUNT(*) FROM jobs j WHERE 1=1` - - var args []interface{} - argId := 1 - - // Search (merged logic) - if filter.Search != nil && *filter.Search != "" { - searchTerm := fmt.Sprintf("%%%s%%", *filter.Search) - clause := fmt.Sprintf(" AND (j.title ILIKE $%d OR j.description ILIKE $%d OR c.name ILIKE $%d)", argId, argId, argId) - baseQuery += clause - countQuery += clause - args = append(args, searchTerm) - argId++ - } - - // Company filter - if filter.CompanyID != nil { - baseQuery += fmt.Sprintf(" AND j.company_id = $%d", argId) - countQuery += fmt.Sprintf(" AND j.company_id = $%d", argId) - args = append(args, *filter.CompanyID) - argId++ - } - - // Region filter - if filter.RegionID != nil { - baseQuery += fmt.Sprintf(" AND j.region_id = $%d", argId) - countQuery += fmt.Sprintf(" AND j.region_id = $%d", argId) - args = append(args, *filter.RegionID) - argId++ - } - - // City filter - if filter.CityID != nil { - baseQuery += fmt.Sprintf(" AND j.city_id = $%d", argId) - countQuery += fmt.Sprintf(" AND j.city_id = $%d", argId) - args = append(args, *filter.CityID) - argId++ - } - - // Employment type filter - if filter.EmploymentType != nil && *filter.EmploymentType != "" && *filter.EmploymentType != "all" { - baseQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId) - countQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId) - args = append(args, *filter.EmploymentType) - argId++ - } - - // Work mode filter - if filter.WorkMode != nil && *filter.WorkMode != "" && *filter.WorkMode != "all" { - baseQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId) - countQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId) - args = append(args, *filter.WorkMode) - argId++ - } - - // Location filter (Partial Match) - if filter.Location != nil && *filter.Location != "" && *filter.Location != "all" { - locTerm := fmt.Sprintf("%%%s%%", *filter.Location) - baseQuery += fmt.Sprintf(" AND j.location ILIKE $%d", argId) - countQuery += fmt.Sprintf(" AND j.location ILIKE $%d", argId) - args = append(args, locTerm) - argId++ - } - // Support HEAD's LocationSearch explicitly if different - if filter.LocationSearch != nil && *filter.LocationSearch != "" && (filter.Location == nil || *filter.Location != *filter.LocationSearch) { - locTerm := fmt.Sprintf("%%%s%%", *filter.LocationSearch) - baseQuery += fmt.Sprintf(" AND j.location ILIKE $%d", argId) - countQuery += fmt.Sprintf(" AND j.location ILIKE $%d", argId) - args = append(args, locTerm) - argId++ - } - - // Status filter - if filter.Status != nil && *filter.Status != "" { - baseQuery += fmt.Sprintf(" AND j.status = $%d", argId) - countQuery += fmt.Sprintf(" AND j.status = $%d", argId) - args = append(args, *filter.Status) - argId++ - } - - // Featured filter - if filter.IsFeatured != nil { - baseQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId) - countQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId) - args = append(args, *filter.IsFeatured) - argId++ - } - - // Visa support filter - if filter.VisaSupport != nil { - baseQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId) - countQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId) - args = append(args, *filter.VisaSupport) - argId++ - } - - // Language Level - if filter.LanguageLevel != nil && *filter.LanguageLevel != "" && *filter.LanguageLevel != "all" { - baseQuery += fmt.Sprintf(" AND j.language_level = $%d", argId) - countQuery += fmt.Sprintf(" AND j.language_level = $%d", argId) - args = append(args, *filter.LanguageLevel) - argId++ - } - - // Currency - if filter.Currency != nil && *filter.Currency != "" && *filter.Currency != "all" { - baseQuery += fmt.Sprintf(" AND j.currency = $%d", argId) - countQuery += fmt.Sprintf(" AND j.currency = $%d", argId) - args = append(args, *filter.Currency) - argId++ - } - - // Salary range filters - if filter.SalaryMin != nil { - baseQuery += fmt.Sprintf(" AND j.salary_min >= $%d", argId) - countQuery += fmt.Sprintf(" AND j.salary_min >= $%d", argId) - args = append(args, *filter.SalaryMin) - argId++ - } - if filter.SalaryMax != nil { - baseQuery += fmt.Sprintf(" AND j.salary_max <= $%d", argId) - countQuery += fmt.Sprintf(" AND j.salary_max <= $%d", argId) - args = append(args, *filter.SalaryMax) - argId++ - } +package services + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "github.com/rede5/gohorsejobs/backend/internal/dto" + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +type JobService struct { + DB *sql.DB +} + +func NewJobService(db *sql.DB) *JobService { + return &JobService{DB: db} +} + +func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*models.Job, error) { + fmt.Println("[JOB_SERVICE DEBUG] === CreateJob Started ===") + fmt.Printf("[JOB_SERVICE DEBUG] CompanyID=%s, CreatedBy=%s, Title=%s, Status=%s\n", req.CompanyID, createdBy, req.Title, req.Status) + + query := ` + INSERT INTO jobs ( + company_id, created_by, title, description, salary_min, salary_max, salary_type, currency, + employment_type, working_hours, location, region_id, city_id, + requirements, benefits, questions, visa_support, language_level, status, date_posted, created_at, updated_at, salary_negotiable + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) + RETURNING id, date_posted, created_at, updated_at + ` + + job := &models.Job{ + CompanyID: req.CompanyID, + CreatedBy: createdBy, + Title: req.Title, + Description: req.Description, + SalaryMin: req.SalaryMin, + SalaryMax: req.SalaryMax, + SalaryType: req.SalaryType, + Currency: req.Currency, + SalaryNegotiable: req.SalaryNegotiable, + EmploymentType: req.EmploymentType, + WorkingHours: req.WorkingHours, + Location: req.Location, + RegionID: req.RegionID, + CityID: req.CityID, + Requirements: models.JSONMap(req.Requirements), + Benefits: models.JSONMap(req.Benefits), + Questions: models.JSONMap(req.Questions), + VisaSupport: req.VisaSupport, + LanguageLevel: req.LanguageLevel, + Status: req.Status, + DatePosted: ptrTime(time.Now()), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + fmt.Println("[JOB_SERVICE DEBUG] Executing INSERT query...") + fmt.Printf("[JOB_SERVICE DEBUG] Job struct: %+v\n", job) + + err := s.DB.QueryRow( + query, + job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType, job.Currency, + job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID, + job.Requirements, job.Benefits, job.Questions, job.VisaSupport, job.LanguageLevel, job.Status, job.DatePosted, job.CreatedAt, job.UpdatedAt, job.SalaryNegotiable, + ).Scan(&job.ID, &job.DatePosted, &job.CreatedAt, &job.UpdatedAt) + + if err != nil { + fmt.Printf("[JOB_SERVICE ERROR] INSERT query failed: %v\n", err) + return nil, err + } + + fmt.Printf("[JOB_SERVICE DEBUG] Job created successfully! ID=%s\n", job.ID) + return job, nil +} + +func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) { + // Merged Query: Includes both HEAD and dev fields + baseQuery := ` + SELECT + j.id, j.company_id, j.title, j.description, j.salary_min, j.salary_max, j.salary_type, + j.employment_type, j.work_mode, j.working_hours, j.location, j.status, j.salary_negotiable, j.is_featured, COALESCE(j.date_posted, j.created_at) AS date_posted, j.created_at, j.updated_at, + CASE + WHEN c.type = 'CANDIDATE_WORKSPACE' OR c.name LIKE 'Candidate - %' THEN '' + ELSE COALESCE(c.name, '') + END as company_name, c.logo_url as company_logo_url, + r.name as region_name, ci.name as city_name, + j.view_count, j.featured_until, + (SELECT COUNT(*) FROM applications a WHERE a.job_id = j.id) as applications_count + FROM jobs j + LEFT JOIN companies c ON j.company_id::text = c.id::text + LEFT JOIN states r ON j.region_id::text = r.id::text + LEFT JOIN cities ci ON j.city_id::text = ci.id::text + WHERE 1=1` + countQuery := `SELECT COUNT(*) FROM jobs j WHERE 1=1` + + var args []interface{} + argId := 1 + + // Search (merged logic) + if filter.Search != nil && *filter.Search != "" { + searchTerm := fmt.Sprintf("%%%s%%", *filter.Search) + clause := fmt.Sprintf(" AND (j.title ILIKE $%d OR j.description ILIKE $%d OR c.name ILIKE $%d)", argId, argId, argId) + baseQuery += clause + countQuery += clause + args = append(args, searchTerm) + argId++ + } + + // Company filter + if filter.CompanyID != nil { + baseQuery += fmt.Sprintf(" AND j.company_id = $%d", argId) + countQuery += fmt.Sprintf(" AND j.company_id = $%d", argId) + args = append(args, *filter.CompanyID) + argId++ + } + + // Region filter + if filter.RegionID != nil { + baseQuery += fmt.Sprintf(" AND j.region_id = $%d", argId) + countQuery += fmt.Sprintf(" AND j.region_id = $%d", argId) + args = append(args, *filter.RegionID) + argId++ + } + + // City filter + if filter.CityID != nil { + baseQuery += fmt.Sprintf(" AND j.city_id = $%d", argId) + countQuery += fmt.Sprintf(" AND j.city_id = $%d", argId) + args = append(args, *filter.CityID) + argId++ + } + + // Employment type filter + if filter.EmploymentType != nil && *filter.EmploymentType != "" && *filter.EmploymentType != "all" { + baseQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId) + countQuery += fmt.Sprintf(" AND j.employment_type = $%d", argId) + args = append(args, *filter.EmploymentType) + argId++ + } + + // Work mode filter + if filter.WorkMode != nil && *filter.WorkMode != "" && *filter.WorkMode != "all" { + baseQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId) + countQuery += fmt.Sprintf(" AND j.work_mode = $%d", argId) + args = append(args, *filter.WorkMode) + argId++ + } + + // Location filter (Partial Match) + if filter.Location != nil && *filter.Location != "" && *filter.Location != "all" { + locTerm := fmt.Sprintf("%%%s%%", *filter.Location) + baseQuery += fmt.Sprintf(" AND j.location ILIKE $%d", argId) + countQuery += fmt.Sprintf(" AND j.location ILIKE $%d", argId) + args = append(args, locTerm) + argId++ + } + // Support HEAD's LocationSearch explicitly if different + if filter.LocationSearch != nil && *filter.LocationSearch != "" && (filter.Location == nil || *filter.Location != *filter.LocationSearch) { + locTerm := fmt.Sprintf("%%%s%%", *filter.LocationSearch) + baseQuery += fmt.Sprintf(" AND j.location ILIKE $%d", argId) + countQuery += fmt.Sprintf(" AND j.location ILIKE $%d", argId) + args = append(args, locTerm) + argId++ + } + + // Status filter + if filter.Status != nil && *filter.Status != "" { + baseQuery += fmt.Sprintf(" AND j.status = $%d", argId) + countQuery += fmt.Sprintf(" AND j.status = $%d", argId) + args = append(args, *filter.Status) + argId++ + } + + // Featured filter + if filter.IsFeatured != nil { + baseQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId) + countQuery += fmt.Sprintf(" AND j.is_featured = $%d", argId) + args = append(args, *filter.IsFeatured) + argId++ + } + + // Visa support filter + if filter.VisaSupport != nil { + baseQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId) + countQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId) + args = append(args, *filter.VisaSupport) + argId++ + } + + // Language Level + if filter.LanguageLevel != nil && *filter.LanguageLevel != "" && *filter.LanguageLevel != "all" { + baseQuery += fmt.Sprintf(" AND j.language_level = $%d", argId) + countQuery += fmt.Sprintf(" AND j.language_level = $%d", argId) + args = append(args, *filter.LanguageLevel) + argId++ + } + + // Currency + if filter.Currency != nil && *filter.Currency != "" && *filter.Currency != "all" { + baseQuery += fmt.Sprintf(" AND j.currency = $%d", argId) + countQuery += fmt.Sprintf(" AND j.currency = $%d", argId) + args = append(args, *filter.Currency) + argId++ + } + + // Salary range filters + if filter.SalaryMin != nil { + baseQuery += fmt.Sprintf(" AND j.salary_min >= $%d", argId) + countQuery += fmt.Sprintf(" AND j.salary_min >= $%d", argId) + args = append(args, *filter.SalaryMin) + argId++ + } + if filter.SalaryMax != nil { + baseQuery += fmt.Sprintf(" AND j.salary_max <= $%d", argId) + countQuery += fmt.Sprintf(" AND j.salary_max <= $%d", argId) + args = append(args, *filter.SalaryMax) + argId++ + } if filter.SalaryType != nil && *filter.SalaryType != "" { baseQuery += fmt.Sprintf(" AND j.salary_type = $%d", argId) countQuery += fmt.Sprintf(" AND j.salary_type = $%d", argId) @@ -240,233 +241,237 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany } if hours > 0 { cutoffTime := time.Now().Add(-time.Duration(hours) * time.Hour) - baseQuery += fmt.Sprintf(" AND j.created_at >= $%d", argId) - countQuery += fmt.Sprintf(" AND j.created_at >= $%d", argId) + baseQuery += fmt.Sprintf(" AND COALESCE(j.date_posted, j.created_at) >= $%d", argId) + countQuery += fmt.Sprintf(" AND COALESCE(j.date_posted, j.created_at) >= $%d", argId) args = append(args, cutoffTime) argId++ } } - - // Sorting - sortClause := " ORDER BY j.is_featured DESC, j.created_at DESC" // default - if filter.SortBy != nil { - switch *filter.SortBy { - case "recent", "date": - sortClause = " ORDER BY j.is_featured DESC, j.created_at DESC" - case "salary", "salary_asc": - sortClause = " ORDER BY j.salary_min ASC NULLS LAST" - case "salary_desc": - sortClause = " ORDER BY j.salary_max DESC NULLS LAST" - case "relevance": - sortClause = " ORDER BY j.is_featured DESC, j.created_at DESC" - } - } - - // Override sort order if explicit - if filter.SortOrder != nil { - if *filter.SortOrder == "asc" { - // Rely on SortBy providing correct default or direction. - } - } - - baseQuery += sortClause - - // Pagination - limit := filter.Limit - if limit == 0 { - limit = 10 - } - if limit > 100 { - limit = 100 - } - offset := (filter.Page - 1) * limit - if offset < 0 { - offset = 0 - } - - paginationQuery := baseQuery + fmt.Sprintf(" LIMIT $%d OFFSET $%d", argId, argId+1) - paginationArgs := append(args, limit, offset) - - rows, err := s.DB.Query(paginationQuery, paginationArgs...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - jobs := []models.JobWithCompany{} - for rows.Next() { - var j models.JobWithCompany - if err := rows.Scan( - &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, - &j.EmploymentType, &j.WorkMode, &j.WorkingHours, &j.Location, &j.Status, &j.SalaryNegotiable, &j.IsFeatured, &j.CreatedAt, &j.UpdatedAt, - &j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName, - &j.ViewCount, &j.FeaturedUntil, &j.ApplicationsCount, - ); err != nil { - return nil, 0, err - } - jobs = append(jobs, j) - } - - var total int - err = s.DB.QueryRow(countQuery, args...).Scan(&total) - if err != nil { - return nil, 0, err - } - - return jobs, total, nil -} - -func (s *JobService) GetJobByID(id string) (*models.Job, error) { - var j models.Job - query := ` - SELECT id, company_id, title, description, salary_min, salary_max, salary_type, - employment_type, working_hours, location, region_id, city_id, - requirements, benefits, visa_support, language_level, status, is_featured, featured_until, view_count, created_at, updated_at, - salary_negotiable, currency, work_mode - FROM jobs WHERE id = $1 - ` - err := s.DB.QueryRow(query, id).Scan( - &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, - &j.EmploymentType, &j.WorkingHours, &j.Location, &j.RegionID, &j.CityID, - &j.Requirements, &j.Benefits, &j.VisaSupport, &j.LanguageLevel, &j.Status, &j.IsFeatured, &j.FeaturedUntil, &j.ViewCount, &j.CreatedAt, &j.UpdatedAt, - &j.SalaryNegotiable, &j.Currency, &j.WorkMode, - ) - if err != nil { - return nil, err - } - return &j, nil -} - -func (s *JobService) UpdateJob(id string, req dto.UpdateJobRequest) (*models.Job, error) { - var setClauses []string - var args []interface{} - argId := 1 - - if req.Title != nil { - setClauses = append(setClauses, fmt.Sprintf("title = $%d", argId)) - args = append(args, *req.Title) - argId++ - } - if req.Description != nil { - setClauses = append(setClauses, fmt.Sprintf("description = $%d", argId)) - args = append(args, *req.Description) - argId++ - } - if req.SalaryMin != nil { - setClauses = append(setClauses, fmt.Sprintf("salary_min = $%d", argId)) - args = append(args, *req.SalaryMin) - argId++ - } - if req.SalaryMax != nil { - setClauses = append(setClauses, fmt.Sprintf("salary_max = $%d", argId)) - args = append(args, *req.SalaryMax) - argId++ - } - if req.SalaryType != nil { - setClauses = append(setClauses, fmt.Sprintf("salary_type = $%d", argId)) - args = append(args, *req.SalaryType) - argId++ - } - if req.Currency != nil { - setClauses = append(setClauses, fmt.Sprintf("currency = $%d", argId)) - args = append(args, *req.Currency) - argId++ - } - if req.EmploymentType != nil { - setClauses = append(setClauses, fmt.Sprintf("employment_type = $%d", argId)) - args = append(args, *req.EmploymentType) - argId++ - } - if req.WorkingHours != nil { - setClauses = append(setClauses, fmt.Sprintf("working_hours = $%d", argId)) - args = append(args, *req.WorkingHours) - argId++ - } - if req.Location != nil { - setClauses = append(setClauses, fmt.Sprintf("location = $%d", argId)) - args = append(args, *req.Location) - argId++ - } - if req.RegionID != nil { - setClauses = append(setClauses, fmt.Sprintf("region_id = $%d", argId)) - args = append(args, *req.RegionID) - argId++ - } - if req.CityID != nil { - setClauses = append(setClauses, fmt.Sprintf("city_id = $%d", argId)) - args = append(args, *req.CityID) - argId++ - } - if req.Requirements != nil { - setClauses = append(setClauses, fmt.Sprintf("requirements = $%d", argId)) - args = append(args, req.Requirements) - argId++ - } - if req.Benefits != nil { - setClauses = append(setClauses, fmt.Sprintf("benefits = $%d", argId)) - args = append(args, req.Benefits) - argId++ - } - if req.Questions != nil { - setClauses = append(setClauses, fmt.Sprintf("questions = $%d", argId)) - args = append(args, req.Questions) - argId++ - } - if req.VisaSupport != nil { - setClauses = append(setClauses, fmt.Sprintf("visa_support = $%d", argId)) - args = append(args, *req.VisaSupport) - argId++ - } - if req.LanguageLevel != nil { - setClauses = append(setClauses, fmt.Sprintf("language_level = $%d", argId)) - args = append(args, *req.LanguageLevel) - argId++ - } - if req.Status != nil { - setClauses = append(setClauses, fmt.Sprintf("status = $%d", argId)) - args = append(args, *req.Status) - argId++ - } - if req.IsFeatured != nil { - setClauses = append(setClauses, fmt.Sprintf("is_featured = $%d", argId)) - args = append(args, *req.IsFeatured) - argId++ - } - if req.FeaturedUntil != nil { - setClauses = append(setClauses, fmt.Sprintf("featured_until = $%d", argId)) - parsedTime, err := time.Parse(time.RFC3339, *req.FeaturedUntil) - if err == nil { - args = append(args, parsedTime) - } else { - args = append(args, nil) - } - argId++ - } - if req.SalaryNegotiable != nil { - setClauses = append(setClauses, fmt.Sprintf("salary_negotiable = $%d", argId)) - args = append(args, *req.SalaryNegotiable) - argId++ - } - - if len(setClauses) == 0 { - return s.GetJobByID(id) - } - - setClauses = append(setClauses, "updated_at = NOW()") - - query := fmt.Sprintf("UPDATE jobs SET %s WHERE id = $%d RETURNING id, updated_at", strings.Join(setClauses, ", "), argId) - args = append(args, id) - - var j models.Job - err := s.DB.QueryRow(query, args...).Scan(&j.ID, &j.UpdatedAt) - if err != nil { - return nil, err - } - - return s.GetJobByID(id) -} - -func (s *JobService) DeleteJob(id string) error { - _, err := s.DB.Exec("DELETE FROM jobs WHERE id = $1", id) - return err -} + + // Sorting + sortClause := " ORDER BY j.is_featured DESC, COALESCE(j.date_posted, j.created_at) DESC" // default + if filter.SortBy != nil { + switch *filter.SortBy { + case "recent", "date": + sortClause = " ORDER BY j.is_featured DESC, COALESCE(j.date_posted, j.created_at) DESC" + case "salary", "salary_asc": + sortClause = " ORDER BY j.salary_min ASC NULLS LAST" + case "salary_desc": + sortClause = " ORDER BY j.salary_max DESC NULLS LAST" + case "relevance": + sortClause = " ORDER BY j.is_featured DESC, j.created_at DESC" + } + } + + // Override sort order if explicit + if filter.SortOrder != nil { + if *filter.SortOrder == "asc" { + // Rely on SortBy providing correct default or direction. + } + } + + baseQuery += sortClause + + // Pagination + limit := filter.Limit + if limit == 0 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + offset := (filter.Page - 1) * limit + if offset < 0 { + offset = 0 + } + + paginationQuery := baseQuery + fmt.Sprintf(" LIMIT $%d OFFSET $%d", argId, argId+1) + paginationArgs := append(args, limit, offset) + + rows, err := s.DB.Query(paginationQuery, paginationArgs...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + jobs := []models.JobWithCompany{} + for rows.Next() { + var j models.JobWithCompany + if err := rows.Scan( + &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, + &j.EmploymentType, &j.WorkMode, &j.WorkingHours, &j.Location, &j.Status, &j.SalaryNegotiable, &j.IsFeatured, &j.DatePosted, &j.CreatedAt, &j.UpdatedAt, + &j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName, + &j.ViewCount, &j.FeaturedUntil, &j.ApplicationsCount, + ); err != nil { + return nil, 0, err + } + jobs = append(jobs, j) + } + + var total int + err = s.DB.QueryRow(countQuery, args...).Scan(&total) + if err != nil { + return nil, 0, err + } + + return jobs, total, nil +} + +func (s *JobService) GetJobByID(id string) (*models.Job, error) { + var j models.Job + query := ` + SELECT id, company_id, title, description, salary_min, salary_max, salary_type, + employment_type, working_hours, location, region_id, city_id, + requirements, benefits, visa_support, language_level, status, is_featured, featured_until, view_count, date_posted, created_at, updated_at, + salary_negotiable, currency, work_mode + FROM jobs WHERE id = $1 + ` + err := s.DB.QueryRow(query, id).Scan( + &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, + &j.EmploymentType, &j.WorkingHours, &j.Location, &j.RegionID, &j.CityID, + &j.Requirements, &j.Benefits, &j.VisaSupport, &j.LanguageLevel, &j.Status, &j.IsFeatured, &j.FeaturedUntil, &j.ViewCount, &j.DatePosted, &j.CreatedAt, &j.UpdatedAt, + &j.SalaryNegotiable, &j.Currency, &j.WorkMode, + ) + if err != nil { + return nil, err + } + return &j, nil +} + +func (s *JobService) UpdateJob(id string, req dto.UpdateJobRequest) (*models.Job, error) { + var setClauses []string + var args []interface{} + argId := 1 + + if req.Title != nil { + setClauses = append(setClauses, fmt.Sprintf("title = $%d", argId)) + args = append(args, *req.Title) + argId++ + } + if req.Description != nil { + setClauses = append(setClauses, fmt.Sprintf("description = $%d", argId)) + args = append(args, *req.Description) + argId++ + } + if req.SalaryMin != nil { + setClauses = append(setClauses, fmt.Sprintf("salary_min = $%d", argId)) + args = append(args, *req.SalaryMin) + argId++ + } + if req.SalaryMax != nil { + setClauses = append(setClauses, fmt.Sprintf("salary_max = $%d", argId)) + args = append(args, *req.SalaryMax) + argId++ + } + if req.SalaryType != nil { + setClauses = append(setClauses, fmt.Sprintf("salary_type = $%d", argId)) + args = append(args, *req.SalaryType) + argId++ + } + if req.Currency != nil { + setClauses = append(setClauses, fmt.Sprintf("currency = $%d", argId)) + args = append(args, *req.Currency) + argId++ + } + if req.EmploymentType != nil { + setClauses = append(setClauses, fmt.Sprintf("employment_type = $%d", argId)) + args = append(args, *req.EmploymentType) + argId++ + } + if req.WorkingHours != nil { + setClauses = append(setClauses, fmt.Sprintf("working_hours = $%d", argId)) + args = append(args, *req.WorkingHours) + argId++ + } + if req.Location != nil { + setClauses = append(setClauses, fmt.Sprintf("location = $%d", argId)) + args = append(args, *req.Location) + argId++ + } + if req.RegionID != nil { + setClauses = append(setClauses, fmt.Sprintf("region_id = $%d", argId)) + args = append(args, *req.RegionID) + argId++ + } + if req.CityID != nil { + setClauses = append(setClauses, fmt.Sprintf("city_id = $%d", argId)) + args = append(args, *req.CityID) + argId++ + } + if req.Requirements != nil { + setClauses = append(setClauses, fmt.Sprintf("requirements = $%d", argId)) + args = append(args, req.Requirements) + argId++ + } + if req.Benefits != nil { + setClauses = append(setClauses, fmt.Sprintf("benefits = $%d", argId)) + args = append(args, req.Benefits) + argId++ + } + if req.Questions != nil { + setClauses = append(setClauses, fmt.Sprintf("questions = $%d", argId)) + args = append(args, req.Questions) + argId++ + } + if req.VisaSupport != nil { + setClauses = append(setClauses, fmt.Sprintf("visa_support = $%d", argId)) + args = append(args, *req.VisaSupport) + argId++ + } + if req.LanguageLevel != nil { + setClauses = append(setClauses, fmt.Sprintf("language_level = $%d", argId)) + args = append(args, *req.LanguageLevel) + argId++ + } + if req.Status != nil { + setClauses = append(setClauses, fmt.Sprintf("status = $%d", argId)) + args = append(args, *req.Status) + argId++ + } + if req.IsFeatured != nil { + setClauses = append(setClauses, fmt.Sprintf("is_featured = $%d", argId)) + args = append(args, *req.IsFeatured) + argId++ + } + if req.FeaturedUntil != nil { + setClauses = append(setClauses, fmt.Sprintf("featured_until = $%d", argId)) + parsedTime, err := time.Parse(time.RFC3339, *req.FeaturedUntil) + if err == nil { + args = append(args, parsedTime) + } else { + args = append(args, nil) + } + argId++ + } + if req.SalaryNegotiable != nil { + setClauses = append(setClauses, fmt.Sprintf("salary_negotiable = $%d", argId)) + args = append(args, *req.SalaryNegotiable) + argId++ + } + + if len(setClauses) == 0 { + return s.GetJobByID(id) + } + + setClauses = append(setClauses, "updated_at = NOW()") + + query := fmt.Sprintf("UPDATE jobs SET %s WHERE id = $%d RETURNING id, updated_at", strings.Join(setClauses, ", "), argId) + args = append(args, id) + + var j models.Job + err := s.DB.QueryRow(query, args...).Scan(&j.ID, &j.UpdatedAt) + if err != nil { + return nil, err + } + + return s.GetJobByID(id) +} + +func (s *JobService) DeleteJob(id string) error { + _, err := s.DB.Exec("DELETE FROM jobs WHERE id = $1", id) + return err +} + +func ptrTime(t time.Time) *time.Time { + return &t +} diff --git a/backend/internal/services/storage_service.go b/backend/internal/services/storage_service.go index 1de9256..2f6d68f 100644 --- a/backend/internal/services/storage_service.go +++ b/backend/internal/services/storage_service.go @@ -85,6 +85,16 @@ func (s *StorageService) getConfig(ctx context.Context) (UploadConfig, error) { } func (s *StorageService) getClient(ctx context.Context) (*s3.PresignClient, string, error) { + client, bucket, err := s.getS3Client(ctx) + if err != nil { + return nil, "", err + } + + psClient := s3.NewPresignClient(client) + return psClient, bucket, nil +} + +func (s *StorageService) getS3Client(ctx context.Context) (*s3.Client, string, error) { uCfg, err := s.getConfig(ctx) if err != nil { return nil, "", err @@ -104,13 +114,20 @@ func (s *StorageService) getClient(ctx context.Context) (*s3.PresignClient, stri o.BaseEndpoint = aws.String(uCfg.Endpoint) o.UsePathStyle = true // Often needed for R2/MinIO }) + return client, uCfg.Bucket, nil +} - psClient := s3.NewPresignClient(client) - return psClient, uCfg.Bucket, nil +func (s *StorageService) sanitizeObjectKey(key string) string { + return strings.TrimLeft(strings.TrimSpace(key), "/") } // GetPresignedUploadURL generates a URL for PUT requests func (s *StorageService) GetPresignedUploadURL(ctx context.Context, key string, contentType string) (string, error) { + key = s.sanitizeObjectKey(key) + if key == "" { + return "", fmt.Errorf("key is required") + } + psClient, bucket, err := s.getClient(ctx) if err != nil { return "", err @@ -131,6 +148,54 @@ func (s *StorageService) GetPresignedUploadURL(ctx context.Context, key string, return req.URL, nil } +// GetPresignedDownloadURL generates a URL for GET requests. +func (s *StorageService) GetPresignedDownloadURL(ctx context.Context, key string) (string, error) { + key = s.sanitizeObjectKey(key) + if key == "" { + return "", fmt.Errorf("key is required") + } + + psClient, bucket, err := s.getClient(ctx) + if err != nil { + return "", err + } + + req, err := psClient.PresignGetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }, func(o *s3.PresignOptions) { + o.Expires = 60 * time.Minute + }) + if err != nil { + return "", fmt.Errorf("failed to presign download: %w", err) + } + + return req.URL, nil +} + +// DeleteObject removes an object from storage. +func (s *StorageService) DeleteObject(ctx context.Context, key string) error { + key = s.sanitizeObjectKey(key) + if key == "" { + return fmt.Errorf("key is required") + } + + client, bucket, err := s.getS3Client(ctx) + if err != nil { + return err + } + + _, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + return fmt.Errorf("failed to delete object: %w", err) + } + + return nil +} + // TestConnection checks if the creds are valid and bucket is accessible func (s *StorageService) TestConnection(ctx context.Context) error { psClient, bucket, err := s.getClient(ctx) diff --git a/backend/migrations/037_add_date_posted_to_jobs.sql b/backend/migrations/037_add_date_posted_to_jobs.sql new file mode 100644 index 0000000..827c3d3 --- /dev/null +++ b/backend/migrations/037_add_date_posted_to_jobs.sql @@ -0,0 +1,16 @@ +-- Migration: Add date_posted to jobs +-- Description: Supports explicit posting date field consumed by frontend filters/sorting. + +ALTER TABLE jobs + ADD COLUMN IF NOT EXISTS date_posted TIMESTAMP WITH TIME ZONE; + +UPDATE jobs +SET date_posted = created_at +WHERE date_posted IS NULL; + +ALTER TABLE jobs + ALTER COLUMN date_posted SET DEFAULT NOW(); + +CREATE INDEX IF NOT EXISTS idx_jobs_date_posted ON jobs(date_posted DESC); + +COMMENT ON COLUMN jobs.date_posted IS 'Public posting timestamp used by listing/filtering UX';