From 948858eca040e7064ffbaa01119dec75083bf4ef Mon Sep 17 00:00:00 2001 From: Rede5 Date: Sat, 14 Feb 2026 17:21:10 +0000 Subject: [PATCH] fix: resolve remaining merge conflicts --- .../internal/api/handlers/core_handlers.go | 2715 ++++++++--------- .../api/handlers/core_handlers_test.go | 718 +++-- backend/internal/core/domain/entity/user.go | 184 +- backend/internal/core/dto/user_auth.go | 176 +- backend/internal/handlers/job_handler.go | 547 ++-- .../persistence/postgres/user_repository.go | 833 +++-- backend/internal/router/router.go | 704 ++--- backend/internal/services/job_service.go | 943 +++--- 8 files changed, 3292 insertions(+), 3528 deletions(-) diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index a54ccad..cc16047 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -1,1370 +1,1345 @@ -package handlers - -import ( - "encoding/json" - "net" - "net/http" - "strconv" - "strings" - "time" - - "github.com/rede5/gohorsejobs/backend/internal/api/middleware" - "github.com/rede5/gohorsejobs/backend/internal/core/dto" - "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth" - tenant "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant" - user "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user" - "github.com/rede5/gohorsejobs/backend/internal/services" -) - -type CoreHandlers struct { - loginUC *auth.LoginUseCase - registerCandidateUC *auth.RegisterCandidateUseCase - createCompanyUC *tenant.CreateCompanyUseCase - createUserUC *user.CreateUserUseCase - listUsersUC *user.ListUsersUseCase - deleteUserUC *user.DeleteUserUseCase - updateUserUC *user.UpdateUserUseCase - updatePasswordUC *user.UpdatePasswordUseCase - listCompaniesUC *tenant.ListCompaniesUseCase - forgotPasswordUC *auth.ForgotPasswordUseCase - resetPasswordUC *auth.ResetPasswordUseCase - auditService *services.AuditService - notificationService *services.NotificationService - ticketService *services.TicketService - adminService *services.AdminService - credentialsService *services.CredentialsService -} - -<<<<<<< HEAD -func NewCoreHandlers( - l *auth.LoginUseCase, - reg *auth.RegisterCandidateUseCase, - c *tenant.CreateCompanyUseCase, - u *user.CreateUserUseCase, - list *user.ListUsersUseCase, - del *user.DeleteUserUseCase, - upd *user.UpdateUserUseCase, - lc *tenant.ListCompaniesUseCase, - fp *auth.ForgotPasswordUseCase, - rp *auth.ResetPasswordUseCase, - auditService *services.AuditService, - notificationService *services.NotificationService, - ticketService *services.TicketService, - adminService *services.AdminService, - credentialsService *services.CredentialsService, -) *CoreHandlers { -======= -func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, upd *user.UpdateUserUseCase, updatePasswordUC *user.UpdatePasswordUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService, notificationService *services.NotificationService, ticketService *services.TicketService, adminService *services.AdminService, credentialsService *services.CredentialsService) *CoreHandlers { ->>>>>>> dev - return &CoreHandlers{ - loginUC: l, - registerCandidateUC: reg, - createCompanyUC: c, - createUserUC: u, - listUsersUC: list, - deleteUserUC: del, - updateUserUC: upd, - updatePasswordUC: updatePasswordUC, - listCompaniesUC: lc, - forgotPasswordUC: fp, - resetPasswordUC: rp, - auditService: auditService, - notificationService: notificationService, - ticketService: ticketService, - adminService: adminService, - credentialsService: credentialsService, - } -} - -// Login authenticates a user and returns a token. -// @Summary User Login -// @Description Authenticates a user by email and password. Returns JWT and user info. -// @Tags Auth -// @Accept json -// @Produce json -// @Param login body dto.LoginRequest true "Login Credentials" -// @Success 200 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 401 {string} string "Unauthorized" -// @Router /api/v1/auth/login [post] -func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) { - var req dto.LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - resp, err := h.loginUC.Execute(r.Context(), req) - if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return - } - - if h.auditService != nil { - ipAddress := extractClientIP(r) - userAgent := r.UserAgent() - var userAgentPtr *string - if userAgent != "" { - userAgentPtr = &userAgent - } - - _ = h.auditService.RecordLogin(r.Context(), services.LoginAuditInput{ - UserID: resp.User.ID, - Identifier: resp.User.Email, - Roles: resp.User.Roles, - IPAddress: ipAddress, - UserAgent: userAgentPtr, - }) - } - - // Set HttpOnly Cookie - http.SetCookie(w, &http.Cookie{ - Name: "jwt", - Value: resp.Token, - Path: "/", - // Domain: "localhost", // Or separate based on env - Expires: time.Now().Add(24 * time.Hour), - HttpOnly: true, - Secure: false, // Set to true in production with HTTPS - SameSite: http.SameSiteLaxMode, - }) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// Logout clears the authentication cookie. -// @Summary User Logout -// @Description Clears the httpOnly JWT cookie, effectively logging the user out. -// @Tags Auth -// @Success 200 {object} object -// @Router /api/v1/auth/logout [post] -func (h *CoreHandlers) Logout(w http.ResponseWriter, r *http.Request) { - // Clear the JWT cookie by setting it to expire in the past - http.SetCookie(w, &http.Cookie{ - Name: "jwt", - Value: "", - Path: "/", - Expires: time.Now().Add(-24 * time.Hour), // Expire in the past - HttpOnly: true, - Secure: false, // Set to true in production with HTTPS - SameSite: http.SameSiteLaxMode, - MaxAge: -1, // Delete cookie immediately - }) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"}) -} - -// RegisterCandidate handles public registration for candidates -// @Summary Register Candidate -// @Description Register a new candidate account. -// @Tags Auth -// @Accept json -// @Produce json -// @Param register body dto.RegisterCandidateRequest true "Registration Details" -// @Success 201 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 409 {string} string "Conflict" -// @Router /api/v1/auth/register [post] -func (h *CoreHandlers) RegisterCandidate(w http.ResponseWriter, r *http.Request) { - var req dto.RegisterCandidateRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request payload", http.StatusBadRequest) - return - } - - if req.Email == "" || req.Password == "" || req.Name == "" { - http.Error(w, "Name, Email and Password are required", http.StatusBadRequest) - return - } - - resp, err := h.registerCandidateUC.Execute(r.Context(), req) - if err != nil { - // Log removed to fix compilation error (LogAction missing) - http.Error(w, err.Error(), http.StatusConflict) - return - } - - w.WriteHeader(http.StatusCreated) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// CreateCompany registers a new tenant (Company) and its admin. -// @Summary Create Company (Tenant) -// @Description Registers a new company and creates an initial admin user. -// @Tags Companies -// @Accept json -// @Produce json -// @Param company body object true "Company Details" -// @Success 200 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/companies [post] -func (h *CoreHandlers) CreateCompany(w http.ResponseWriter, r *http.Request) { - var req dto.CreateCompanyRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - resp, err := h.createCompanyUC.Execute(r.Context(), req) - if err != nil { - if strings.Contains(err.Error(), "already exists") { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// ListCompanies returns all companies (superadmin or public depending on rule, usually superadmin). -// @Summary List Companies -// @Description Returns a list of all companies. -// @Tags Companies -// @Accept json -// @Produce json -// @Success 200 {array} object -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/companies [get] -func (h *CoreHandlers) ListCompanies(w http.ResponseWriter, r *http.Request) { - // Check if user is admin - ctx := r.Context() - roles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) - isAdmin := false - for _, role := range roles { - if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { - isAdmin = true - break - } - } - - if isAdmin { - // Admin View: Use AdminService for paginated, detailed list - page, _ := strconv.Atoi(r.URL.Query().Get("page")) - if page < 1 { - page = 1 - } - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - if limit < 1 { - limit = 10 - } - - var verified *bool - if verifiedParam := r.URL.Query().Get("verified"); verifiedParam != "" { - value := verifiedParam == "true" - verified = &value - } - - companies, total, err := h.adminService.ListCompanies(ctx, verified, page, limit) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - response := map[string]interface{}{ - "data": companies, - "pagination": map[string]interface{}{ - "page": page, - "limit": limit, - "total": total, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - return - } - - // Public/User View: Use existing usecase (simple list) - resp, err := h.listCompaniesUC.Execute(r.Context()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// GetCompanyByID retrieves a single company by ID. -// @Summary Get Company -// @Description Retrieves a company by its ID. -// @Tags Companies -// @Accept json -// @Produce json -// @Param id path string true "Company ID" -// @Success 200 {object} object -// @Failure 404 {string} string "Not Found" -// @Router /api/v1/companies/{id} [get] -func (h *CoreHandlers) GetCompanyByID(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if id == "" { - http.Error(w, "Company ID is required", http.StatusBadRequest) - return - } - - company, err := h.adminService.GetCompanyByID(r.Context(), id) - if err != nil { - http.Error(w, "Company not found", http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(company) -} - -// CreateUser creates a new user within the authenticated tenant. -// @Summary Create User -// @Description Creates a new user under the current tenant. Requires Admin role. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param user body object true "User Details" -// @Success 200 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users [post] -func (h *CoreHandlers) CreateUser(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) - - userRoles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) - isAdmin := false - for _, role := range userRoles { - if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { - isAdmin = true - break - } - } - - if !isAdmin && tenantID == "" { - http.Error(w, "Tenant ID not found in context", http.StatusForbidden) - return - } - - var req dto.CreateUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - resp, err := h.createUserUC.Execute(ctx, req, tenantID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// ListUsers returns all users in the current tenant. -// @Summary List Users -// @Description Returns a list of users belonging to the authenticated tenant. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param page query int false "Page number (default: 1)" -// @Param limit query int false "Items per page (default: 10, max: 100)" -// @Success 200 {object} object -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users [get] -func (h *CoreHandlers) ListUsers(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Check if user is admin/superadmin - roles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) - isAdmin := false - isSuperadmin := false - for _, role := range roles { - if role == "SUPERADMIN" || role == "superadmin" { - isSuperadmin = true - isAdmin = true - break - } - if role == "ADMIN" || role == "admin" { - isAdmin = true - } - } - - page, _ := strconv.Atoi(r.URL.Query().Get("page")) - if page < 1 { - page = 1 - } - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - if limit < 1 { - limit = 10 - } - if limit > 100 { - limit = 100 - } - - if isSuperadmin { - // Superadmin view: List all users using AdminService - users, total, err := h.adminService.ListUsers(ctx, page, limit, nil) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - response := map[string]interface{}{ - "data": users, - "pagination": map[string]interface{}{ - "page": page, - "limit": limit, - "total": total, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - return - } - - if isAdmin { - // Admin view: List users from their company only - tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) - users, total, err := h.adminService.ListUsers(ctx, page, limit, &tenantID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - response := map[string]interface{}{ - "data": users, - "pagination": map[string]interface{}{ - "page": page, - "limit": limit, - "total": total, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - return - } - - // Non-admin: require tenant ID - tenantID, ok := ctx.Value(middleware.ContextTenantID).(string) - if !ok || tenantID == "" { - http.Error(w, "Tenant ID not found in context", http.StatusForbidden) - return - } - - users, err := h.listUsersUC.Execute(ctx, tenantID, page, limit) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(users) -} - -// DeleteUser removes a user from the tenant. -// @Summary Delete User -// @Description Deletes a user by ID. Must belong to the same tenant. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "User ID" -// @Success 200 {string} string "User deleted" -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/{id} [delete] -func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) - - // Check for admin role to bypass tenant check - userRoles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) - isAdmin := false - for _, role := range userRoles { - if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { - isAdmin = true - break - } - } - - if !isAdmin && tenantID == "" { - http.Error(w, "Tenant ID not found in context", http.StatusForbidden) - return - } - - id := r.PathValue("id") - if id == "" { - http.Error(w, "Missing User ID", http.StatusBadRequest) - return - } - - targetTenantID := tenantID - if isAdmin { - targetTenantID = "" // Signal bypass - } - - if err := h.deleteUserUC.Execute(ctx, id, targetTenantID); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"}) -} - -// UpdateMe updates the profile of the logged-in user. -// @Summary Update Profile -// @Description Update the profile of the current user. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param user body dto.UpdateUserRequest true "Profile Data" -// @Success 200 {object} dto.UserResponse -// @Failure 400 {string} string "Invalid Request" -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me [put] -func (h *CoreHandlers) UpdateMe(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userID, ok := ctx.Value(middleware.ContextUserID).(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var req dto.UpdateUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - resp, err := h.updateUserUC.Execute(ctx, userID, "", req) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// UpdateUser modifies a user in the tenant. -// @Summary Update User -// @Description Updates user details (Name, Email, Active Status) -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "User ID" -// @Param user body object true "User Updates" -// @Success 200 {object} object -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/{id} [patch] -func (h *CoreHandlers) UpdateUser(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) - - // Check for admin role to bypass tenant check - userRoles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) - isAdmin := false - for _, role := range userRoles { - if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { - isAdmin = true - break - } - } - - if !isAdmin && tenantID == "" { - http.Error(w, "Tenant ID not found in context", http.StatusForbidden) - return - } - - id := r.PathValue("id") - if id == "" { - http.Error(w, "Missing User ID", http.StatusBadRequest) - return - } - - var req dto.UpdateUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - resp, err := h.updateUserUC.Execute(ctx, id, tenantID, req) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -func extractClientIP(r *http.Request) *string { - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - parts := strings.Split(forwarded, ",") - if len(parts) > 0 { - ip := strings.TrimSpace(parts[0]) - if ip != "" { - return &ip - } - } - } - - if realIP := r.Header.Get("X-Real-IP"); realIP != "" { - return &realIP - } - - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err == nil && host != "" { - return &host - } - - if r.RemoteAddr != "" { - return &r.RemoteAddr - } - - return nil -} - -// ListNotifications returns all notifications for the authenticated user. -// @Summary List Notifications -// @Description Returns a list of notifications for the current user. -// @Tags Notifications -// @Accept json -// @Produce json -// @Security BearerAuth -// @Success 200 {array} object -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/notifications [get] -func (h *CoreHandlers) ListNotifications(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - notifications, err := h.notificationService.ListNotifications(r.Context(), userID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(notifications) -} - -// MarkNotificationAsRead marks a single notification as read. -// @Summary Mark Notification Read -// @Description Marks a notification as read. -// @Tags Notifications -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Notification ID" -// @Success 200 {string} string "Marked as read" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/notifications/{id}/read [patch] -func (h *CoreHandlers) MarkNotificationAsRead(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - id := r.PathValue("id") - if id == "" { - // Fallback - parts := strings.Split(r.URL.Path, "/") - if len(parts) > 1 { - id = parts[len(parts)-2] // .../notifications/{id}/read - } - } - - if err := h.notificationService.MarkAsRead(r.Context(), id, userID); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} - -// MarkAllNotificationsAsRead marks all notifications as read for the user. -// @Summary Mark All Notifications Read -// @Description Marks all notifications as read. -// @Tags Notifications -// @Accept json -// @Produce json -// @Security BearerAuth -// @Success 200 {string} string "All marked as read" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/notifications/read-all [patch] -func (h *CoreHandlers) MarkAllNotificationsAsRead(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - if err := h.notificationService.MarkAllAsRead(r.Context(), userID); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) -} - -// ForgotPassword initiates password reset flow. -// @Summary Forgot Password -// @Description Sends a password reset link to the user's email. -// @Tags Auth -// @Accept json -// @Produce json -// @Param request body dto.ForgotPasswordRequest true "Email" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid Request" -// @Router /api/v1/auth/forgot-password [post] -func (h *CoreHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) { - var req dto.ForgotPasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - // Always return success (security: don't reveal if email exists) - _ = h.forgotPasswordUC.Execute(r.Context(), req) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "Se o email estiver cadastrado, você receberá um link de recuperação."}) -} - -// ResetPassword resets the user's password. -// @Summary Reset Password -// @Description Resets the user's password using a valid token. -// @Tags Auth -// @Accept json -// @Produce json -// @Param request body dto.ResetPasswordRequest true "Token and New Password" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid Request" -// @Failure 401 {string} string "Invalid or Expired Token" -// @Router /api/v1/auth/reset-password [post] -func (h *CoreHandlers) ResetPassword(w http.ResponseWriter, r *http.Request) { - var req dto.ResetPasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - if err := h.resetPasswordUC.Execute(r.Context(), req); err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "Password updated successfully"}) -} - -// CreateTicket creates a new support ticket. -// @Summary Create Ticket -// @Description Creates a new support ticket. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param ticket body object true "Ticket Details" -// @Success 201 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets [post] -func (h *CoreHandlers) CreateTicket(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - var req dto.CreateTicketRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - ticket, err := h.ticketService.CreateTicket(r.Context(), userID, req.Subject, req.Priority) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // Create initial message if provided - if req.Message != "" { - _, _ = h.ticketService.AddMessage(r.Context(), ticket.ID, userID, req.Message, false) - } - - w.WriteHeader(http.StatusCreated) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ticket) -} - -// ListTickets returns all tickets for the user. -// @Summary List Tickets -// @Description Returns a list of tickets for the current user. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Success 200 {array} object -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets [get] -func (h *CoreHandlers) ListTickets(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - tickets, err := h.ticketService.ListTickets(r.Context(), userID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tickets) -} - -// GetTicket returns details and messages for a ticket. -// @Summary Get Ticket Details -// @Description Returns ticket details and chat history. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Ticket ID" -// @Success 200 {object} object -// @Failure 404 {string} string "Not Found" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets/{id} [get] -func (h *CoreHandlers) GetTicket(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - id := r.PathValue("id") - if id == "" { - parts := strings.Split(r.URL.Path, "/") - if len(parts) > 0 { - id = parts[len(parts)-1] - } - } - - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - isAdmin := hasAdminRole(roles) - - ticket, messages, err := h.ticketService.GetTicket(r.Context(), id, userID, isAdmin) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - - resp := dto.TicketDetailsResponse{ - Ticket: *ticket, - Messages: messages, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// AddMessage adds a message to a ticket. -// @Summary Add Ticket Message -// @Description Adds a message to an existing ticket. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Ticket ID" -// @Param message body object true "Message" -// @Success 201 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets/{id}/messages [post] -func (h *CoreHandlers) AddMessage(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - id := r.PathValue("id") - if id == "" { - parts := strings.Split(r.URL.Path, "/") - // .../tickets/{id}/messages - if len(parts) > 1 { - id = parts[len(parts)-2] - } - } - - var req dto.MessageRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - isAdmin := hasAdminRole(roles) - - msg, err := h.ticketService.AddMessage(r.Context(), id, userID, req.Message, isAdmin) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusCreated) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(msg) -} - -// UpdateTicket updates a ticket's status and/or priority. -// @Summary Update Ticket -// @Description Updates a ticket's status and/or priority. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Ticket ID" -// @Param ticket body object true "Update data (status, priority)" -// @Success 200 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets/{id} [patch] -func (h *CoreHandlers) UpdateTicket(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - isAdmin := hasAdminRole(roles) - - id := r.PathValue("id") - - var req struct { - Status *string `json:"status"` - Priority *string `json:"priority"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - ticket, err := h.ticketService.UpdateTicket(r.Context(), id, userID, req.Status, req.Priority, isAdmin) - if err != nil { - if err.Error() == "unauthorized" { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ticket) -} - -// CloseTicket closes a ticket. -// @Summary Close Ticket -// @Description Closes a support ticket. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Ticket ID" -// @Success 200 {object} object -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets/{id}/close [patch] -func (h *CoreHandlers) CloseTicket(w http.ResponseWriter, r *http.Request) { - userIDVal := r.Context().Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - isAdmin := hasAdminRole(roles) - - id := r.PathValue("id") - - ticket, err := h.ticketService.CloseTicket(r.Context(), id, userID, isAdmin) - if err != nil { - if err.Error() == "unauthorized" { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(ticket) -} - -// DeleteTicket removes a ticket (admin only). -// @Summary Delete Ticket -// @Description Deletes a support ticket (admin only). -// @Tags Support -// @Param id path string true "Ticket ID" -// @Success 204 "No Content" -// @Failure 401 {string} string "Unauthorized" -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Security BearerAuth -// @Router /api/v1/support/tickets/{id} [delete] -func (h *CoreHandlers) DeleteTicket(w http.ResponseWriter, r *http.Request) { - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - if !hasAdminRole(roles) { - http.Error(w, "Forbidden: Admin only", http.StatusForbidden) - return - } - - id := r.PathValue("id") - - if err := h.ticketService.DeleteTicket(r.Context(), id); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -// ListAllTickets returns all tickets (admin only). -// @Summary List All Tickets (Admin) -// @Description Returns all support tickets for admin review. -// @Tags Support -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param status query string false "Filter by status (open, in_progress, closed)" -// @Success 200 {array} object -// @Failure 403 {string} string "Forbidden" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/support/tickets/all [get] -func (h *CoreHandlers) ListAllTickets(w http.ResponseWriter, r *http.Request) { - roleVal := r.Context().Value(middleware.ContextRoles) - roles := middleware.ExtractRoles(roleVal) - if !hasAdminRole(roles) { - http.Error(w, "Forbidden: Admin only", http.StatusForbidden) - return - } - - status := r.URL.Query().Get("status") - - tickets, err := h.ticketService.ListAllTickets(r.Context(), status) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(tickets) -} - -// UpdateMyProfile updates the authenticated user's profile. -// @Summary Update My Profile -// @Description Updates the current user's profile. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param user body object true "Profile Details" -// @Success 200 {object} object -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me/profile [patch] -func (h *CoreHandlers) UpdateMyProfile(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - // TenantID needed? updateUC takes tenantID. - tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) - - var req dto.UpdateUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - // userID (string) passed directly as first arg - // log.Printf("[UpdateMyProfile] UserID: %s, TenantID: %s", userID, tenantID) - resp, err := h.updateUserUC.Execute(ctx, userID, tenantID, req) - if err != nil { - // log.Printf("[UpdateMyProfile] Error: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) -} - -// UpdateMyPassword updates the authenticated user's password. -// @Summary Update My Password -// @Description Updates the current user's password. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param password body object true "Password Details" -// @Success 204 {string} string "No Content" -// @Failure 400 {string} string "Invalid Request" -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me/password [patch] -func (h *CoreHandlers) UpdateMyPassword(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - var req dto.UpdatePasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return - } - - if req.CurrentPassword == "" || req.NewPassword == "" { - http.Error(w, "Current and new password are required", http.StatusBadRequest) - return - } - - if err := h.updatePasswordUC.Execute(ctx, userID, req.CurrentPassword, req.NewPassword); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -// UploadMyAvatar handles profile picture upload. -// @Summary Upload Avatar -// @Description Uploads a profile picture for the current user. -// @Tags Users -// @Accept multipart/form-data -// @Produce json -// @Security BearerAuth -// @Param file formData file true "Avatar File" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me/avatar [post] -func (h *CoreHandlers) UploadMyAvatar(w http.ResponseWriter, r *http.Request) { - // Mock implementation as S3 service is pending injection - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "url": "https://avatar.vercel.sh/uploaded-mock", - "message": "Avatar upload mocked (S3 service pending injection)", - }) -} - -// Me returns the current user profile including company info. -// @Summary Get My Profile -// @Description Returns the profile of the authenticated user. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Success 200 {object} object -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me [get] -<<<<<<< HEAD -======= -// Me returns the current user profile including company info. -// @Summary Get My Profile -// @Description Returns the profile of the authenticated user. -// @Tags Users -// @Accept json -// @Produce json -// @Security BearerAuth -// @Success 200 {object} object -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/users/me [get] ->>>>>>> dev -func (h *CoreHandlers) Me(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - if userIDVal == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var userID string - switch v := userIDVal.(type) { - case int: - userID = strconv.Itoa(v) - case string: - userID = v - case float64: - userID = strconv.Itoa(int(v)) - } - - user, err := h.adminService.GetUser(ctx, userID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - company, _ := h.adminService.GetCompanyByUserID(ctx, userID) - if company != nil { - id := company.ID - user.CompanyID = &id - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(user) -} - -// SaveFCMToken saves the FCM token for the user. -// @Summary Save FCM Token -// @Description Saves or updates the FCM token for push notifications. -// @Tags Notifications -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body dto.SaveFCMTokenRequest true "FCM Token" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/tokens [post] -func (h *CoreHandlers) SaveFCMToken(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var req dto.SaveFCMTokenRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - // Manual validation - if req.Token == "" { - http.Error(w, "Token is required", http.StatusBadRequest) - return - } - if req.Platform != "web" && req.Platform != "android" && req.Platform != "ios" { - http.Error(w, "Invalid platform (must be web, android, or ios)", http.StatusBadRequest) - return - } - - if err := h.notificationService.SaveFCMToken(ctx, userID, req.Token, req.Platform); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "Token saved successfully"}) -} - -<<<<<<< HEAD -// SaveCredentials saves encrypted credentials for external services. -// @Summary Save Credentials -// @Description Saves encrypted credentials payload (e.g. Stripe key encrypted by Backoffice) -// @Tags System -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body map[string]string true "Credentials Payload" -// @Success 200 {object} map[string]string -// @Failure 400 {string} string "Invalid Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/system/credentials [post] -func (h *CoreHandlers) SaveCredentials(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - userIDVal := ctx.Value(middleware.ContextUserID) - userID, ok := userIDVal.(string) - if !ok || userID == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - var req struct { - ServiceName string `json:"serviceName"` - EncryptedPayload string `json:"encryptedPayload"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.ServiceName == "" || req.EncryptedPayload == "" { - http.Error(w, "serviceName and encryptedPayload are required", http.StatusBadRequest) - return - } - - if err := h.credentialsService.SaveCredentials(ctx, req.ServiceName, req.EncryptedPayload, userID); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "Credentials saved successfully"}) -} - -======= ->>>>>>> dev -// hasAdminRole checks if roles array contains admin or superadmin -func hasAdminRole(roles []string) bool { - for _, r := range roles { - lower := strings.ToLower(r) - if lower == "admin" || lower == "superadmin" { - return true - } - } - return false -} +package handlers + +import ( + "encoding/json" + "net" + "net/http" + "strconv" + "strings" + "time" + + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" + "github.com/rede5/gohorsejobs/backend/internal/core/dto" + "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth" + tenant "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant" + user "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user" + "github.com/rede5/gohorsejobs/backend/internal/services" +) + +type CoreHandlers struct { + loginUC *auth.LoginUseCase + registerCandidateUC *auth.RegisterCandidateUseCase + createCompanyUC *tenant.CreateCompanyUseCase + createUserUC *user.CreateUserUseCase + listUsersUC *user.ListUsersUseCase + deleteUserUC *user.DeleteUserUseCase + updateUserUC *user.UpdateUserUseCase + updatePasswordUC *user.UpdatePasswordUseCase + listCompaniesUC *tenant.ListCompaniesUseCase + forgotPasswordUC *auth.ForgotPasswordUseCase + resetPasswordUC *auth.ResetPasswordUseCase + auditService *services.AuditService + notificationService *services.NotificationService + ticketService *services.TicketService + adminService *services.AdminService + credentialsService *services.CredentialsService +} + +func NewCoreHandlers( + l *auth.LoginUseCase, + reg *auth.RegisterCandidateUseCase, + c *tenant.CreateCompanyUseCase, + u *user.CreateUserUseCase, + list *user.ListUsersUseCase, + del *user.DeleteUserUseCase, + upd *user.UpdateUserUseCase, + updatePasswordUC *user.UpdatePasswordUseCase, + lc *tenant.ListCompaniesUseCase, + fp *auth.ForgotPasswordUseCase, + rp *auth.ResetPasswordUseCase, + auditService *services.AuditService, + notificationService *services.NotificationService, + ticketService *services.TicketService, + adminService *services.AdminService, + credentialsService *services.CredentialsService, +) *CoreHandlers { + return &CoreHandlers{ + loginUC: l, + registerCandidateUC: reg, + createCompanyUC: c, + createUserUC: u, + listUsersUC: list, + deleteUserUC: del, + updateUserUC: upd, + updatePasswordUC: updatePasswordUC, + listCompaniesUC: lc, + forgotPasswordUC: fp, + resetPasswordUC: rp, + auditService: auditService, + notificationService: notificationService, + ticketService: ticketService, + adminService: adminService, + credentialsService: credentialsService, + } +} + +// Login authenticates a user and returns a token. +// @Summary User Login +// @Description Authenticates a user by email and password. Returns JWT and user info. +// @Tags Auth +// @Accept json +// @Produce json +// @Param login body dto.LoginRequest true "Login Credentials" +// @Success 200 {object} object +// @Failure 400 {string} string "Invalid Request" +// @Failure 401 {string} string "Unauthorized" +// @Router /api/v1/auth/login [post] +func (h *CoreHandlers) Login(w http.ResponseWriter, r *http.Request) { + var req dto.LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + resp, err := h.loginUC.Execute(r.Context(), req) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + if h.auditService != nil { + ipAddress := extractClientIP(r) + userAgent := r.UserAgent() + var userAgentPtr *string + if userAgent != "" { + userAgentPtr = &userAgent + } + + _ = h.auditService.RecordLogin(r.Context(), services.LoginAuditInput{ + UserID: resp.User.ID, + Identifier: resp.User.Email, + Roles: resp.User.Roles, + IPAddress: ipAddress, + UserAgent: userAgentPtr, + }) + } + + // Set HttpOnly Cookie + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: resp.Token, + Path: "/", + Expires: time.Now().Add(24 * time.Hour), + HttpOnly: true, + Secure: false, // Set to true in production with HTTPS + SameSite: http.SameSiteLaxMode, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// Logout clears the authentication cookie. +// @Summary User Logout +// @Description Clears the httpOnly JWT cookie, effectively logging the user out. +// @Tags Auth +// @Success 200 {object} object +// @Router /api/v1/auth/logout [post] +func (h *CoreHandlers) Logout(w http.ResponseWriter, r *http.Request) { + // Clear the JWT cookie by setting it to expire in the past + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: "", + Path: "/", + Expires: time.Now().Add(-24 * time.Hour), // Expire in the past + HttpOnly: true, + Secure: false, // Set to true in production with HTTPS + SameSite: http.SameSiteLaxMode, + MaxAge: -1, // Delete cookie immediately + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"}) +} + +// RegisterCandidate handles public registration for candidates +// @Summary Register Candidate +// @Description Register a new candidate account. +// @Tags Auth +// @Accept json +// @Produce json +// @Param register body dto.RegisterCandidateRequest true "Registration Details" +// @Success 201 {object} object +// @Failure 400 {string} string "Invalid Request" +// @Failure 409 {string} string "Conflict" +// @Router /api/v1/auth/register [post] +func (h *CoreHandlers) RegisterCandidate(w http.ResponseWriter, r *http.Request) { + var req dto.RegisterCandidateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + if req.Email == "" || req.Password == "" || req.Name == "" { + http.Error(w, "Name, Email and Password are required", http.StatusBadRequest) + return + } + + resp, err := h.registerCandidateUC.Execute(r.Context(), req) + if err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// CreateCompany registers a new tenant (Company) and its admin. +// @Summary Create Company (Tenant) +// @Description Registers a new company and creates an initial admin user. +// @Tags Companies +// @Accept json +// @Produce json +// @Param company body object true "Company Details" +// @Success 200 {object} object +// @Failure 400 {string} string "Invalid Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/companies [post] +func (h *CoreHandlers) CreateCompany(w http.ResponseWriter, r *http.Request) { + var req dto.CreateCompanyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + resp, err := h.createCompanyUC.Execute(r.Context(), req) + if err != nil { + if strings.Contains(err.Error(), "already exists") { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// ListCompanies returns all companies (superadmin or public depending on rule, usually superadmin). +// @Summary List Companies +// @Description Returns a list of all companies. +// @Tags Companies +// @Accept json +// @Produce json +// @Success 200 {array} object +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/companies [get] +func (h *CoreHandlers) ListCompanies(w http.ResponseWriter, r *http.Request) { + // Check if user is admin + ctx := r.Context() + roles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) + isAdmin := false + for _, role := range roles { + if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { + isAdmin = true + break + } + } + + if isAdmin { + // Admin View: Use AdminService for paginated, detailed list + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit < 1 { + limit = 10 + } + + var verified *bool + if verifiedParam := r.URL.Query().Get("verified"); verifiedParam != "" { + value := verifiedParam == "true" + verified = &value + } + + companies, total, err := h.adminService.ListCompanies(ctx, verified, page, limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + response := map[string]interface{}{ + "data": companies, + "pagination": map[string]interface{}{ + "page": page, + "limit": limit, + "total": total, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + + // Public/User View: Use existing usecase (simple list) + resp, err := h.listCompaniesUC.Execute(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// GetCompanyByID retrieves a single company by ID. +// @Summary Get Company +// @Description Retrieves a company by its ID. +// @Tags Companies +// @Accept json +// @Produce json +// @Param id path string true "Company ID" +// @Success 200 {object} object +// @Failure 404 {string} string "Not Found" +// @Router /api/v1/companies/{id} [get] +func (h *CoreHandlers) GetCompanyByID(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + http.Error(w, "Company ID is required", http.StatusBadRequest) + return + } + + company, err := h.adminService.GetCompanyByID(r.Context(), id) + if err != nil { + http.Error(w, "Company not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(company) +} + +// CreateUser creates a new user within the authenticated tenant. +// @Summary Create User +// @Description Creates a new user under the current tenant. Requires Admin role. +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param user body object true "User Details" +// @Success 200 {object} object +// @Failure 400 {string} string "Invalid Request" +// @Failure 403 {string} string "Forbidden" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users [post] +func (h *CoreHandlers) CreateUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) + + userRoles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) + isAdmin := false + for _, role := range userRoles { + if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { + isAdmin = true + break + } + } + + if !isAdmin && tenantID == "" { + http.Error(w, "Tenant ID not found in context", http.StatusForbidden) + return + } + + var req dto.CreateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + resp, err := h.createUserUC.Execute(ctx, req, tenantID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// ListUsers returns all users in the current tenant. +// @Summary List Users +// @Description Returns a list of users belonging to the authenticated tenant. +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param page query int false "Page number (default: 1)" +// @Param limit query int false "Items per page (default: 10, max: 100)" +// @Success 200 {object} object +// @Failure 403 {string} string "Forbidden" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users [get] +func (h *CoreHandlers) ListUsers(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Check if user is admin/superadmin + roles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) + isAdmin := false + isSuperadmin := false + for _, role := range roles { + if role == "SUPERADMIN" || role == "superadmin" { + isSuperadmin = true + isAdmin = true + break + } + if role == "ADMIN" || role == "admin" { + isAdmin = true + } + } + + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit < 1 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + + if isSuperadmin { + // Superadmin view: List all users using AdminService + users, total, err := h.adminService.ListUsers(ctx, page, limit, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + response := map[string]interface{}{ + "data": users, + "pagination": map[string]interface{}{ + "page": page, + "limit": limit, + "total": total, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + + if isAdmin { + // Admin view: List users from their company only + tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) + users, total, err := h.adminService.ListUsers(ctx, page, limit, &tenantID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + response := map[string]interface{}{ + "data": users, + "pagination": map[string]interface{}{ + "page": page, + "limit": limit, + "total": total, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + return + } + + // Non-admin: require tenant ID + tenantID, ok := ctx.Value(middleware.ContextTenantID).(string) + if !ok || tenantID == "" { + http.Error(w, "Tenant ID not found in context", http.StatusForbidden) + return + } + + users, err := h.listUsersUC.Execute(ctx, tenantID, page, limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(users) +} + +// DeleteUser removes a user from the tenant. +// @Summary Delete User +// @Description Deletes a user by ID. Must belong to the same tenant. +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Success 200 {string} string "User deleted" +// @Failure 403 {string} string "Forbidden" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users/{id} [delete] +func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) + + // Check for admin role to bypass tenant check + userRoles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) + isAdmin := false + for _, role := range userRoles { + if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { + isAdmin = true + break + } + } + + if !isAdmin && tenantID == "" { + http.Error(w, "Tenant ID not found in context", http.StatusForbidden) + return + } + + id := r.PathValue("id") + if id == "" { + http.Error(w, "Missing User ID", http.StatusBadRequest) + return + } + + targetTenantID := tenantID + if isAdmin { + targetTenantID = "" // Signal bypass + } + + if err := h.deleteUserUC.Execute(ctx, id, targetTenantID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"}) +} + +// UpdateMe updates the profile of the logged-in user. +// @Summary Update Profile +// @Description Update the profile of the current user. +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param user body dto.UpdateUserRequest true "Profile Data" +// @Success 200 {object} dto.UserResponse +// @Failure 400 {string} string "Invalid Request" +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users/me [put] +func (h *CoreHandlers) UpdateMe(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userID, ok := ctx.Value(middleware.ContextUserID).(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req dto.UpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + resp, err := h.updateUserUC.Execute(ctx, userID, "", req) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// UpdateUser modifies a user in the tenant. +// @Summary Update User +// @Description Updates user details (Name, Email, Active Status) +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "User ID" +// @Param user body object true "User Updates" +// @Success 200 {object} object +// @Failure 403 {string} string "Forbidden" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users/{id} [patch] +func (h *CoreHandlers) UpdateUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) + + // Check for admin role to bypass tenant check + userRoles := middleware.ExtractRoles(ctx.Value(middleware.ContextRoles)) + isAdmin := false + for _, role := range userRoles { + if role == "ADMIN" || role == "SUPERADMIN" || role == "admin" || role == "superadmin" { + isAdmin = true + break + } + } + + if !isAdmin && tenantID == "" { + http.Error(w, "Tenant ID not found in context", http.StatusForbidden) + return + } + + id := r.PathValue("id") + if id == "" { + http.Error(w, "Missing User ID", http.StatusBadRequest) + return + } + + var req dto.UpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + resp, err := h.updateUserUC.Execute(ctx, id, tenantID, req) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func extractClientIP(r *http.Request) *string { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + if len(parts) > 0 { + ip := strings.TrimSpace(parts[0]) + if ip != "" { + return &ip + } + } + } + + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return &realIP + } + + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil && host != "" { + return &host + } + + if r.RemoteAddr != "" { + return &r.RemoteAddr + } + + return nil +} + +// ListNotifications returns all notifications for the authenticated user. +// @Summary List Notifications +// @Description Returns a list of notifications for the current user. +// @Tags Notifications +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {array} object +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/notifications [get] +func (h *CoreHandlers) ListNotifications(w http.ResponseWriter, r *http.Request) { + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + notifications, err := h.notificationService.ListNotifications(r.Context(), userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(notifications) +} + +// MarkNotificationAsRead marks a single notification as read. +// @Summary Mark Notification Read +// @Description Marks a notification as read. +// @Tags Notifications +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Notification ID" +// @Success 200 {string} string "Marked as read" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/notifications/{id}/read [patch] +func (h *CoreHandlers) MarkNotificationAsRead(w http.ResponseWriter, r *http.Request) { + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + id := r.PathValue("id") + if id == "" { + // Fallback + parts := strings.Split(r.URL.Path, "/") + if len(parts) > 1 { + id = parts[len(parts)-2] // .../notifications/{id}/read + } + } + + if err := h.notificationService.MarkAsRead(r.Context(), id, userID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// MarkAllNotificationsAsRead marks all notifications as read for the user. +// @Summary Mark All Notifications Read +// @Description Marks all notifications as read. +// @Tags Notifications +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {string} string "All marked as read" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/notifications/read-all [patch] +func (h *CoreHandlers) MarkAllNotificationsAsRead(w http.ResponseWriter, r *http.Request) { + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + if err := h.notificationService.MarkAllAsRead(r.Context(), userID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +// ForgotPassword initiates password reset flow. +// @Summary Forgot Password +// @Description Sends a password reset link to the user's email. +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body dto.ForgotPasswordRequest true "Email" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid Request" +// @Router /api/v1/auth/forgot-password [post] +func (h *CoreHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) { + var req dto.ForgotPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + // Always return success (security: don't reveal if email exists) + _ = h.forgotPasswordUC.Execute(r.Context(), req) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Se o email estiver cadastrado, voce recebera um link de recuperacao."}) +} + +// ResetPassword resets the user's password. +// @Summary Reset Password +// @Description Resets the user's password using a valid token. +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body dto.ResetPasswordRequest true "Token and New Password" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid Request" +// @Failure 401 {string} string "Invalid or Expired Token" +// @Router /api/v1/auth/reset-password [post] +func (h *CoreHandlers) ResetPassword(w http.ResponseWriter, r *http.Request) { + var req dto.ResetPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + if err := h.resetPasswordUC.Execute(r.Context(), req); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Password updated successfully"}) +} + +// CreateTicket creates a new support ticket. +// @Summary Create Ticket +// @Description Creates a new support ticket. +// @Tags Support +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param ticket body object true "Ticket Details" +// @Success 201 {object} object +// @Failure 400 {string} string "Invalid Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/support/tickets [post] +func (h *CoreHandlers) CreateTicket(w http.ResponseWriter, r *http.Request) { + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + var req dto.CreateTicketRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + ticket, err := h.ticketService.CreateTicket(r.Context(), userID, req.Subject, req.Priority) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Create initial message if provided + if req.Message != "" { + _, _ = h.ticketService.AddMessage(r.Context(), ticket.ID, userID, req.Message, false) + } + + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ticket) +} + +// ListTickets returns all tickets for the user. +// @Summary List Tickets +// @Description Returns a list of tickets for the current user. +// @Tags Support +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {array} object +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/support/tickets [get] +func (h *CoreHandlers) ListTickets(w http.ResponseWriter, r *http.Request) { + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + tickets, err := h.ticketService.ListTickets(r.Context(), userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tickets) +} + +// GetTicket returns details and messages for a ticket. +// @Summary Get Ticket Details +// @Description Returns ticket details and chat history. +// @Tags Support +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Ticket ID" +// @Success 200 {object} object +// @Failure 404 {string} string "Not Found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/support/tickets/{id} [get] +func (h *CoreHandlers) GetTicket(w http.ResponseWriter, r *http.Request) { + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + id := r.PathValue("id") + if id == "" { + parts := strings.Split(r.URL.Path, "/") + if len(parts) > 0 { + id = parts[len(parts)-1] + } + } + + roleVal := r.Context().Value(middleware.ContextRoles) + roles := middleware.ExtractRoles(roleVal) + isAdmin := hasAdminRole(roles) + + ticket, messages, err := h.ticketService.GetTicket(r.Context(), id, userID, isAdmin) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + resp := dto.TicketDetailsResponse{ + Ticket: *ticket, + Messages: messages, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// AddMessage adds a message to a ticket. +// @Summary Add Ticket Message +// @Description Adds a message to an existing ticket. +// @Tags Support +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Ticket ID" +// @Param message body object true "Message" +// @Success 201 {object} object +// @Failure 400 {string} string "Invalid Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/support/tickets/{id}/messages [post] +func (h *CoreHandlers) AddMessage(w http.ResponseWriter, r *http.Request) { + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + id := r.PathValue("id") + if id == "" { + parts := strings.Split(r.URL.Path, "/") + // .../tickets/{id}/messages + if len(parts) > 1 { + id = parts[len(parts)-2] + } + } + + var req dto.MessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + roleVal := r.Context().Value(middleware.ContextRoles) + roles := middleware.ExtractRoles(roleVal) + isAdmin := hasAdminRole(roles) + + msg, err := h.ticketService.AddMessage(r.Context(), id, userID, req.Message, isAdmin) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(msg) +} + +// UpdateTicket updates a ticket's status and/or priority. +// @Summary Update Ticket +// @Description Updates a ticket's status and/or priority. +// @Tags Support +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Ticket ID" +// @Param ticket body object true "Update data (status, priority)" +// @Success 200 {object} object +// @Failure 400 {string} string "Invalid Request" +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/support/tickets/{id} [patch] +func (h *CoreHandlers) UpdateTicket(w http.ResponseWriter, r *http.Request) { + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + roleVal := r.Context().Value(middleware.ContextRoles) + roles := middleware.ExtractRoles(roleVal) + isAdmin := hasAdminRole(roles) + + id := r.PathValue("id") + + var req struct { + Status *string `json:"status"` + Priority *string `json:"priority"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + ticket, err := h.ticketService.UpdateTicket(r.Context(), id, userID, req.Status, req.Priority, isAdmin) + if err != nil { + if err.Error() == "unauthorized" { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ticket) +} + +// CloseTicket closes a ticket. +// @Summary Close Ticket +// @Description Closes a support ticket. +// @Tags Support +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Ticket ID" +// @Success 200 {object} object +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/support/tickets/{id}/close [patch] +func (h *CoreHandlers) CloseTicket(w http.ResponseWriter, r *http.Request) { + userIDVal := r.Context().Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + roleVal := r.Context().Value(middleware.ContextRoles) + roles := middleware.ExtractRoles(roleVal) + isAdmin := hasAdminRole(roles) + + id := r.PathValue("id") + + ticket, err := h.ticketService.CloseTicket(r.Context(), id, userID, isAdmin) + if err != nil { + if err.Error() == "unauthorized" { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(ticket) +} + +// DeleteTicket removes a ticket (admin only). +// @Summary Delete Ticket +// @Description Deletes a support ticket (admin only). +// @Tags Support +// @Param id path string true "Ticket ID" +// @Success 204 "No Content" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "Forbidden" +// @Failure 500 {string} string "Internal Server Error" +// @Security BearerAuth +// @Router /api/v1/support/tickets/{id} [delete] +func (h *CoreHandlers) DeleteTicket(w http.ResponseWriter, r *http.Request) { + roleVal := r.Context().Value(middleware.ContextRoles) + roles := middleware.ExtractRoles(roleVal) + if !hasAdminRole(roles) { + http.Error(w, "Forbidden: Admin only", http.StatusForbidden) + return + } + + id := r.PathValue("id") + + if err := h.ticketService.DeleteTicket(r.Context(), id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ListAllTickets returns all tickets (admin only). +// @Summary List All Tickets (Admin) +// @Description Returns all support tickets for admin review. +// @Tags Support +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param status query string false "Filter by status (open, in_progress, closed)" +// @Success 200 {array} object +// @Failure 403 {string} string "Forbidden" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/support/tickets/all [get] +func (h *CoreHandlers) ListAllTickets(w http.ResponseWriter, r *http.Request) { + roleVal := r.Context().Value(middleware.ContextRoles) + roles := middleware.ExtractRoles(roleVal) + if !hasAdminRole(roles) { + http.Error(w, "Forbidden: Admin only", http.StatusForbidden) + return + } + + status := r.URL.Query().Get("status") + + tickets, err := h.ticketService.ListAllTickets(r.Context(), status) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tickets) +} + +// UpdateMyProfile updates the authenticated user's profile. +// @Summary Update My Profile +// @Description Updates the current user's profile. +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param user body object true "Profile Details" +// @Success 200 {object} object +// @Failure 400 {string} string "Invalid Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users/me/profile [patch] +func (h *CoreHandlers) UpdateMyProfile(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userIDVal := ctx.Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + // TenantID needed? updateUC takes tenantID. + tenantID, _ := ctx.Value(middleware.ContextTenantID).(string) + + var req dto.UpdateUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + resp, err := h.updateUserUC.Execute(ctx, userID, tenantID, req) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// UpdateMyPassword updates the authenticated user's password. +// @Summary Update My Password +// @Description Updates the current user's password. +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param password body object true "Password Details" +// @Success 204 {string} string "No Content" +// @Failure 400 {string} string "Invalid Request" +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users/me/password [patch] +func (h *CoreHandlers) UpdateMyPassword(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userIDVal := ctx.Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + var req dto.UpdatePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + if req.CurrentPassword == "" || req.NewPassword == "" { + http.Error(w, "Current and new password are required", http.StatusBadRequest) + return + } + + if err := h.updatePasswordUC.Execute(ctx, userID, req.CurrentPassword, req.NewPassword); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// UploadMyAvatar handles profile picture upload. +// @Summary Upload Avatar +// @Description Uploads a profile picture for the current user. +// @Tags Users +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param file formData file true "Avatar File" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users/me/avatar [post] +func (h *CoreHandlers) UploadMyAvatar(w http.ResponseWriter, r *http.Request) { + // Mock implementation as S3 service is pending injection + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "url": "https://avatar.vercel.sh/uploaded-mock", + "message": "Avatar upload mocked (S3 service pending injection)", + }) +} + +// Me returns the current user profile including company info. +// @Summary Get My Profile +// @Description Returns the profile of the authenticated user. +// @Tags Users +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} object +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/users/me [get] +func (h *CoreHandlers) Me(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userIDVal := ctx.Value(middleware.ContextUserID) + if userIDVal == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var userID string + switch v := userIDVal.(type) { + case int: + userID = strconv.Itoa(v) + case string: + userID = v + case float64: + userID = strconv.Itoa(int(v)) + } + + user, err := h.adminService.GetUser(ctx, userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + company, _ := h.adminService.GetCompanyByUserID(ctx, userID) + if company != nil { + id := company.ID + user.CompanyID = &id + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(user) +} + +// SaveFCMToken saves the FCM token for the user. +// @Summary Save FCM Token +// @Description Saves or updates the FCM token for push notifications. +// @Tags Notifications +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body dto.SaveFCMTokenRequest true "FCM Token" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/tokens [post] +func (h *CoreHandlers) SaveFCMToken(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userIDVal := ctx.Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req dto.SaveFCMTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Manual validation + if req.Token == "" { + http.Error(w, "Token is required", http.StatusBadRequest) + return + } + if req.Platform != "web" && req.Platform != "android" && req.Platform != "ios" { + http.Error(w, "Invalid platform (must be web, android, or ios)", http.StatusBadRequest) + return + } + + if err := h.notificationService.SaveFCMToken(ctx, userID, req.Token, req.Platform); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Token saved successfully"}) +} + +// SaveCredentials saves encrypted credentials for external services. +// @Summary Save Credentials +// @Description Saves encrypted credentials payload (e.g. Stripe key encrypted by Backoffice) +// @Tags System +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body map[string]string true "Credentials Payload" +// @Success 200 {object} map[string]string +// @Failure 400 {string} string "Invalid Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/system/credentials [post] +func (h *CoreHandlers) SaveCredentials(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userIDVal := ctx.Value(middleware.ContextUserID) + userID, ok := userIDVal.(string) + if !ok || userID == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + var req struct { + ServiceName string `json:"serviceName"` + EncryptedPayload string `json:"encryptedPayload"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.ServiceName == "" || req.EncryptedPayload == "" { + http.Error(w, "serviceName and encryptedPayload are required", http.StatusBadRequest) + return + } + + if err := h.credentialsService.SaveCredentials(ctx, req.ServiceName, req.EncryptedPayload, userID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"message": "Credentials saved successfully"}) +} + +// hasAdminRole checks if roles array contains admin or superadmin +func hasAdminRole(roles []string) bool { + for _, r := range roles { + lower := strings.ToLower(r) + if lower == "admin" || lower == "superadmin" { + return true + } + } + return false +} diff --git a/backend/internal/api/handlers/core_handlers_test.go b/backend/internal/api/handlers/core_handlers_test.go index 7d501fc..751521f 100644 --- a/backend/internal/api/handlers/core_handlers_test.go +++ b/backend/internal/api/handlers/core_handlers_test.go @@ -1,364 +1,354 @@ -package handlers_test - -import ( - "bytes" - "context" - "database/sql" - "encoding/json" - "net/http" - "net/http/httptest" - "regexp" - "testing" - "time" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/rede5/gohorsejobs/backend/internal/api/handlers" - "github.com/rede5/gohorsejobs/backend/internal/api/middleware" - "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" - "github.com/rede5/gohorsejobs/backend/internal/core/dto" - auth "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth" - tenant "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant" - user "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user" - "github.com/rede5/gohorsejobs/backend/internal/services" -) - -// --- Mock Implementations --- - -type mockUserRepo struct { - saveFunc func(user *entity.User) (*entity.User, error) - findByEmailFunc func(email string) (*entity.User, error) -} - -func (m *mockUserRepo) Save(ctx context.Context, user *entity.User) (*entity.User, error) { - if m.saveFunc != nil { - return m.saveFunc(user) - } - return user, nil -} - -func (m *mockUserRepo) FindByEmail(ctx context.Context, email string) (*entity.User, error) { - if m.findByEmailFunc != nil { - return m.findByEmailFunc(email) - } - return nil, nil -} - -func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entity.User, error) { - return nil, nil -} - -func (m *mockUserRepo) FindAllByTenant(ctx context.Context, tenantID string, l, o int) ([]*entity.User, int, error) { - return nil, 0, nil -} -func (m *mockUserRepo) Update(ctx context.Context, user *entity.User) (*entity.User, error) { - return nil, nil -} -func (m *mockUserRepo) Delete(ctx context.Context, id string) error { return nil } -func (m *mockUserRepo) LinkGuestApplications(ctx context.Context, email string, userID string) error { - return nil -} - -type mockAuthService struct{} - -func (m *mockAuthService) HashPassword(password string) (string, error) { - return "hashed_" + password, nil -} -func (m *mockAuthService) GenerateToken(userID, tenantID string, roles []string) (string, error) { - return "mock_token", nil -} -func (m *mockAuthService) VerifyPassword(hash, password string) bool { return true } -func (m *mockAuthService) ValidateToken(token string) (map[string]interface{}, error) { - return nil, nil -} - -// --- Test Cases --- - -func TestRegisterCandidateHandler_Success(t *testing.T) { - t.Skip("Integration test requires full DI setup") -} - -func TestRegisterCandidateHandler_InvalidPayload(t *testing.T) { - coreHandlers := createTestCoreHandlers(t, nil, nil) - - body := bytes.NewBufferString("{invalid json}") - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", body) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - coreHandlers.RegisterCandidate(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rec.Code) - } -} - -func TestRegisterCandidateHandler_MissingFields(t *testing.T) { - coreHandlers := createTestCoreHandlers(t, nil, nil) - - testCases := []struct { - name string - payload dto.RegisterCandidateRequest - wantCode int - }{ - { - name: "Missing Email", - payload: dto.RegisterCandidateRequest{Name: "John", Password: "123456"}, - wantCode: http.StatusBadRequest, - }, - { - name: "Missing Password", - payload: dto.RegisterCandidateRequest{Name: "John", Email: "john@example.com"}, - wantCode: http.StatusBadRequest, - }, - { - name: "Missing Name", - payload: dto.RegisterCandidateRequest{Email: "john@example.com", Password: "123456"}, - wantCode: http.StatusBadRequest, - }, - { - name: "All Empty", - payload: dto.RegisterCandidateRequest{}, - wantCode: http.StatusBadRequest, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - body, _ := json.Marshal(tc.payload) - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - coreHandlers.RegisterCandidate(rec, req) - - if rec.Code != tc.wantCode { - t.Errorf("Expected status %d, got %d", tc.wantCode, rec.Code) - } - }) - } -} - -func TestLoginHandler_InvalidPayload(t *testing.T) { - coreHandlers := createTestCoreHandlers(t, nil, nil) - - body := bytes.NewBufferString("{invalid}") - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", body) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - coreHandlers.Login(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rec.Code) - } -} - -func TestLoginHandler_Success(t *testing.T) { - // Mocks - mockRepo := &mockUserRepo{ - findByEmailFunc: func(email string) (*entity.User, error) { - if email == "john@example.com" { - // Return entity.User - u := entity.NewUser("u1", "t1", "John", "john@example.com") - u.PasswordHash = "hashed_123456" - // Add Role if needed (mocked) - // u.Roles = ... - return u, nil - } - return nil, nil // Not found - }, - } - mockAuth := &mockAuthService{} - - // Real UseCase with Mocks - loginUC := auth.NewLoginUseCase(mockRepo, mockAuth) - - coreHandlers := createTestCoreHandlers(t, nil, loginUC) - - // Request - payload := dto.LoginRequest{Email: "john@example.com", Password: "123456"} - body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - coreHandlers.Login(rec, req) - - // Assert Response Code - if rec.Code != http.StatusOK { - t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rec.Code, rec.Body.String()) - } - - // Assert Cookie - cookies := rec.Result().Cookies() - var jwtCookie *http.Cookie - for _, c := range cookies { - if c.Name == "jwt" { - jwtCookie = c - break - } - } - - if jwtCookie == nil { - t.Fatal("Expected jwt cookie not found") - } - if !jwtCookie.HttpOnly { - t.Error("Cookie should be HttpOnly") - } - if jwtCookie.Value != "mock_token" { - t.Errorf("Expected cookie value 'mock_token', got '%s'", jwtCookie.Value) - } -} - -// createTestCoreHandlers creates handlers with mocks and optional DB -func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase) *handlers.CoreHandlers { - t.Helper() - - // Init services if DB provided - var auditSvc *services.AuditService - var notifSvc *services.NotificationService - var ticketSvc *services.TicketService - var adminSvc *services.AdminService - var credSvc *services.CredentialsService - - if db != nil { - auditSvc = services.NewAuditService(db) - notifSvc = services.NewNotificationService(db, nil) - ticketSvc = services.NewTicketService(db) - adminSvc = services.NewAdminService(db) - credSvc = services.NewCredentialsService(db) - } - - return handlers.NewCoreHandlers( - loginUC, - (*auth.RegisterCandidateUseCase)(nil), - (*tenant.CreateCompanyUseCase)(nil), - (*user.CreateUserUseCase)(nil), - (*user.ListUsersUseCase)(nil), - (*user.DeleteUserUseCase)(nil), - (*user.UpdateUserUseCase)(nil), - (*user.UpdatePasswordUseCase)(nil), - (*tenant.ListCompaniesUseCase)(nil), -<<<<<<< HEAD - nil, - nil, - nil, - nil, - nil, - nil, - nil, -======= - auditSvc, - notifSvc, - ticketSvc, - adminSvc, - credSvc, ->>>>>>> dev - ) -} - -func TestCoreHandlers_ListNotifications(t *testing.T) { - // Setup DB Mock - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) - } - defer db.Close() - - // Setup Handlers with DB - handlers := createTestCoreHandlers(t, db, nil) - - // User ID - userID := "user-123" - - // Mock DB Query for ListNotifications - mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, type, title, message, link, read_at, created_at, updated_at FROM notifications`)). - WithArgs(userID). - WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "type", "title", "message", "link", "read_at", "created_at", "updated_at"}). - AddRow("1", userID, "info", "Welcome", "Hello", nil, nil, time.Now(), time.Now())) - - // Request - req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications", nil) - - // Inject Context - ctx := context.WithValue(req.Context(), middleware.ContextUserID, userID) - req = req.WithContext(ctx) - - rec := httptest.NewRecorder() - - // Execute - handlers.ListNotifications(rec, req) - - // Assert - if rec.Code != http.StatusOK { - t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) - } - - // Check Body (simple check) - if !bytes.Contains(rec.Body.Bytes(), []byte("Welcome")) { - t.Errorf("Expected body to contain 'Welcome'") - } -} - -func TestCoreHandlers_Tickets(t *testing.T) { - userID := "user-123" - - t.Run("CreateTicket", func(t *testing.T) { - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) - } - defer db.Close() - - handlers := createTestCoreHandlers(t, db, nil) - - // Mock Insert: user_id, subject, priority - mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO tickets`)). - WithArgs(userID, "Issue", "low"). - WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}). - AddRow("ticket-1", userID, "Issue", "open", "low", time.Now(), time.Now())) - - payload := map[string]string{ - "subject": "Issue", - "message": "Help", - "priority": "low", - } - body, _ := json.Marshal(payload) - req := httptest.NewRequest(http.MethodPost, "/api/v1/support/tickets", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") - ctx := context.WithValue(req.Context(), middleware.ContextUserID, userID) - rec := httptest.NewRecorder() - - handlers.CreateTicket(rec, req.WithContext(ctx)) - - if rec.Code != http.StatusCreated { - t.Errorf("CreateTicket status = %d, want %d. Body: %s", rec.Code, http.StatusCreated, rec.Body.String()) - } - }) - - t.Run("ListTickets", func(t *testing.T) { - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) - } - defer db.Close() - - handlers := createTestCoreHandlers(t, db, nil) - - // Mock Select - mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets`)). - WithArgs(userID). - WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}). - AddRow("ticket-1", userID, "Issue", "open", "low", time.Now(), time.Now())) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/support/tickets", nil) - ctx := context.WithValue(req.Context(), middleware.ContextUserID, userID) - rec := httptest.NewRecorder() - - handlers.ListTickets(rec, req.WithContext(ctx)) - - if rec.Code != http.StatusOK { - t.Errorf("ListTickets status = %d, want %d. Body: %s", rec.Code, http.StatusOK, rec.Body.String()) - } - }) -} +package handlers_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/rede5/gohorsejobs/backend/internal/api/handlers" + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" + "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" + "github.com/rede5/gohorsejobs/backend/internal/core/dto" + auth "github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth" + tenant "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant" + user "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user" + "github.com/rede5/gohorsejobs/backend/internal/services" +) + +// --- Mock Implementations --- + +type mockUserRepo struct { + saveFunc func(user *entity.User) (*entity.User, error) + findByEmailFunc func(email string) (*entity.User, error) +} + +func (m *mockUserRepo) Save(ctx context.Context, user *entity.User) (*entity.User, error) { + if m.saveFunc != nil { + return m.saveFunc(user) + } + return user, nil +} + +func (m *mockUserRepo) FindByEmail(ctx context.Context, email string) (*entity.User, error) { + if m.findByEmailFunc != nil { + return m.findByEmailFunc(email) + } + return nil, nil +} + +func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entity.User, error) { + return nil, nil +} + +func (m *mockUserRepo) FindAllByTenant(ctx context.Context, tenantID string, l, o int) ([]*entity.User, int, error) { + return nil, 0, nil +} +func (m *mockUserRepo) Update(ctx context.Context, user *entity.User) (*entity.User, error) { + return nil, nil +} +func (m *mockUserRepo) Delete(ctx context.Context, id string) error { return nil } +func (m *mockUserRepo) LinkGuestApplications(ctx context.Context, email string, userID string) error { + return nil +} + +type mockAuthService struct{} + +func (m *mockAuthService) HashPassword(password string) (string, error) { + return "hashed_" + password, nil +} +func (m *mockAuthService) GenerateToken(userID, tenantID string, roles []string) (string, error) { + return "mock_token", nil +} +func (m *mockAuthService) VerifyPassword(hash, password string) bool { return true } +func (m *mockAuthService) ValidateToken(token string) (map[string]interface{}, error) { + return nil, nil +} + +// --- Test Cases --- + +func TestRegisterCandidateHandler_Success(t *testing.T) { + t.Skip("Integration test requires full DI setup") +} + +func TestRegisterCandidateHandler_InvalidPayload(t *testing.T) { + coreHandlers := createTestCoreHandlers(t, nil, nil) + + body := bytes.NewBufferString("{invalid json}") + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + coreHandlers.RegisterCandidate(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +func TestRegisterCandidateHandler_MissingFields(t *testing.T) { + coreHandlers := createTestCoreHandlers(t, nil, nil) + + testCases := []struct { + name string + payload dto.RegisterCandidateRequest + wantCode int + }{ + { + name: "Missing Email", + payload: dto.RegisterCandidateRequest{Name: "John", Password: "123456"}, + wantCode: http.StatusBadRequest, + }, + { + name: "Missing Password", + payload: dto.RegisterCandidateRequest{Name: "John", Email: "john@example.com"}, + wantCode: http.StatusBadRequest, + }, + { + name: "Missing Name", + payload: dto.RegisterCandidateRequest{Email: "john@example.com", Password: "123456"}, + wantCode: http.StatusBadRequest, + }, + { + name: "All Empty", + payload: dto.RegisterCandidateRequest{}, + wantCode: http.StatusBadRequest, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + body, _ := json.Marshal(tc.payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + coreHandlers.RegisterCandidate(rec, req) + + if rec.Code != tc.wantCode { + t.Errorf("Expected status %d, got %d", tc.wantCode, rec.Code) + } + }) + } +} + +func TestLoginHandler_InvalidPayload(t *testing.T) { + coreHandlers := createTestCoreHandlers(t, nil, nil) + + body := bytes.NewBufferString("{invalid}") + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + coreHandlers.Login(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +func TestLoginHandler_Success(t *testing.T) { + // Mocks + mockRepo := &mockUserRepo{ + findByEmailFunc: func(email string) (*entity.User, error) { + if email == "john@example.com" { + // Return entity.User + u := entity.NewUser("u1", "t1", "John", "john@example.com") + u.PasswordHash = "hashed_123456" + return u, nil + } + return nil, nil // Not found + }, + } + mockAuth := &mockAuthService{} + + // Real UseCase with Mocks + loginUC := auth.NewLoginUseCase(mockRepo, mockAuth) + + coreHandlers := createTestCoreHandlers(t, nil, loginUC) + + // Request + payload := dto.LoginRequest{Email: "john@example.com", Password: "123456"} + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + coreHandlers.Login(rec, req) + + // Assert Response Code + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + // Assert Cookie + cookies := rec.Result().Cookies() + var jwtCookie *http.Cookie + for _, c := range cookies { + if c.Name == "jwt" { + jwtCookie = c + break + } + } + + if jwtCookie == nil { + t.Fatal("Expected jwt cookie not found") + } + if !jwtCookie.HttpOnly { + t.Error("Cookie should be HttpOnly") + } + if jwtCookie.Value != "mock_token" { + t.Errorf("Expected cookie value 'mock_token', got '%s'", jwtCookie.Value) + } +} + +// createTestCoreHandlers creates handlers with mocks and optional DB +func createTestCoreHandlers(t *testing.T, db *sql.DB, loginUC *auth.LoginUseCase) *handlers.CoreHandlers { + t.Helper() + + // Init services if DB provided + var auditSvc *services.AuditService + var notifSvc *services.NotificationService + var ticketSvc *services.TicketService + var adminSvc *services.AdminService + var credSvc *services.CredentialsService + + if db != nil { + auditSvc = services.NewAuditService(db) + notifSvc = services.NewNotificationService(db, nil) + ticketSvc = services.NewTicketService(db) + adminSvc = services.NewAdminService(db) + credSvc = services.NewCredentialsService(db) + } + + return handlers.NewCoreHandlers( + loginUC, + (*auth.RegisterCandidateUseCase)(nil), + (*tenant.CreateCompanyUseCase)(nil), + (*user.CreateUserUseCase)(nil), + (*user.ListUsersUseCase)(nil), + (*user.DeleteUserUseCase)(nil), + (*user.UpdateUserUseCase)(nil), + (*user.UpdatePasswordUseCase)(nil), + (*tenant.ListCompaniesUseCase)(nil), + nil, // forgotPasswordUC + nil, // resetPasswordUC + auditSvc, + notifSvc, + ticketSvc, + adminSvc, + credSvc, + ) +} + +func TestCoreHandlers_ListNotifications(t *testing.T) { + // Setup DB Mock + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + // Setup Handlers with DB + handlers := createTestCoreHandlers(t, db, nil) + + // User ID + userID := "user-123" + + // Mock DB Query for ListNotifications + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, type, title, message, link, read_at, created_at, updated_at FROM notifications`)). + WithArgs(userID). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "type", "title", "message", "link", "read_at", "created_at", "updated_at"}). + AddRow("1", userID, "info", "Welcome", "Hello", nil, nil, time.Now(), time.Now())) + + // Request + req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications", nil) + + // Inject Context + ctx := context.WithValue(req.Context(), middleware.ContextUserID, userID) + req = req.WithContext(ctx) + + rec := httptest.NewRecorder() + + // Execute + handlers.ListNotifications(rec, req) + + // Assert + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + // Check Body (simple check) + if !bytes.Contains(rec.Body.Bytes(), []byte("Welcome")) { + t.Errorf("Expected body to contain 'Welcome'") + } +} + +func TestCoreHandlers_Tickets(t *testing.T) { + userID := "user-123" + + t.Run("CreateTicket", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + handlers := createTestCoreHandlers(t, db, nil) + + // Mock Insert: user_id, subject, priority + mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO tickets`)). + WithArgs(userID, "Issue", "low"). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}). + AddRow("ticket-1", userID, "Issue", "open", "low", time.Now(), time.Now())) + + payload := map[string]string{ + "subject": "Issue", + "message": "Help", + "priority": "low", + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/support/tickets", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + ctx := context.WithValue(req.Context(), middleware.ContextUserID, userID) + rec := httptest.NewRecorder() + + handlers.CreateTicket(rec, req.WithContext(ctx)) + + if rec.Code != http.StatusCreated { + t.Errorf("CreateTicket status = %d, want %d. Body: %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + }) + + t.Run("ListTickets", func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + handlers := createTestCoreHandlers(t, db, nil) + + // Mock Select + mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, user_id, subject, status, priority, created_at, updated_at FROM tickets`)). + WithArgs(userID). + WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "subject", "status", "priority", "created_at", "updated_at"}). + AddRow("ticket-1", userID, "Issue", "open", "low", time.Now(), time.Now())) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/support/tickets", nil) + ctx := context.WithValue(req.Context(), middleware.ContextUserID, userID) + rec := httptest.NewRecorder() + + handlers.ListTickets(rec, req.WithContext(ctx)) + + if rec.Code != http.StatusOK { + t.Errorf("ListTickets status = %d, want %d. Body: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + }) +} diff --git a/backend/internal/core/domain/entity/user.go b/backend/internal/core/domain/entity/user.go index c4d5f75..25caa69 100644 --- a/backend/internal/core/domain/entity/user.go +++ b/backend/internal/core/domain/entity/user.go @@ -1,102 +1,82 @@ -package entity - -import "time" - -// Role type alias -type RoleString string - -const ( - // RoleSuperAdmin is the platform administrator - RoleSuperAdmin = "superadmin" - // RoleAdmin is the company administrator (formerly admin) - RoleAdmin = "admin" - // RoleRecruiter is a recruiter within a company - RoleRecruiter = "recruiter" - // RoleCandidate is a job seeker (formerly candidate) - RoleCandidate = "candidate" - - // User Status - UserStatusActive = "active" - UserStatusInactive = "inactive" - UserStatusForceChangePassword = "force_change_password" -) - -// User represents a user within a specific Tenant (Company). -type User struct { -<<<<<<< HEAD - ID string `json:"id"` - TenantID string `json:"tenant_id"` // Link to Company - Name string `json:"name"` - Email string `json:"email"` - PasswordHash string `json:"-"` - Roles []Role `json:"roles"` - Status string `json:"status"` // "ACTIVE", "INACTIVE" - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - - // HML Fields - AvatarUrl string `json:"avatar_url"` - Metadata map[string]interface{} `json:"metadata"` - - // HEAD Fields (Profile Profile) - Bio string `json:"bio"` - ProfilePictureURL string `json:"profile_picture_url"` - Skills []string `json:"skills"` // Stored as JSONB, mapped to slice - Experience []any `json:"experience,omitempty"` // Flexible JSON structure - Education []any `json:"education,omitempty"` // Flexible JSON structure -======= - ID string `json:"id"` - TenantID string `json:"tenant_id"` // Link to Company - Name string `json:"name"` - Email string `json:"email"` - Phone *string `json:"phone,omitempty"` - Bio *string `json:"bio,omitempty"` - Address *string `json:"address,omitempty"` - City *string `json:"city,omitempty"` - State *string `json:"state,omitempty"` - ZipCode *string `json:"zip_code,omitempty"` - BirthDate *time.Time `json:"birth_date,omitempty"` - Education *string `json:"education,omitempty"` - Experience *string `json:"experience,omitempty"` - Skills []string `json:"skills,omitempty"` - Objective *string `json:"objective,omitempty"` - Title *string `json:"title,omitempty"` - PasswordHash string `json:"-"` - AvatarUrl string `json:"avatar_url"` - Roles []Role `json:"roles"` - Status string `json:"status"` // "ACTIVE", "INACTIVE" - Metadata map[string]interface{} `json:"metadata"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` ->>>>>>> dev -} - -// NewUser creates a new User instance. -func NewUser(id, tenantID, name, email string) *User { - return &User{ - ID: id, - TenantID: tenantID, - Name: name, - Email: email, - Status: UserStatusActive, - Roles: []Role{}, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } -} - -func (u *User) AssignRole(role Role) { - u.Roles = append(u.Roles, role) - u.UpdatedAt = time.Now() -} - -func (u *User) HasPermission(permissionCode string) bool { - for _, role := range u.Roles { - for _, perm := range role.Permissions { - if perm.Code == permissionCode { - return true - } - } - } - return false -} +package entity + +import "time" + +// Role type alias +type RoleString string + +const ( + // RoleSuperAdmin is the platform administrator + RoleSuperAdmin = "superadmin" + // RoleAdmin is the company administrator (formerly admin) + RoleAdmin = "admin" + // RoleRecruiter is a recruiter within a company + RoleRecruiter = "recruiter" + // RoleCandidate is a job seeker (formerly candidate) + RoleCandidate = "candidate" + + // User Status + UserStatusActive = "active" + UserStatusInactive = "inactive" + UserStatusForceChangePassword = "force_change_password" +) + +// User represents a user within a specific Tenant (Company). +type User struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` // Link to Company + Name string `json:"name"` + Email string `json:"email"` + Phone *string `json:"phone,omitempty"` + Bio *string `json:"bio,omitempty"` + Address *string `json:"address,omitempty"` + City *string `json:"city,omitempty"` + State *string `json:"state,omitempty"` + ZipCode *string `json:"zip_code,omitempty"` + BirthDate *time.Time `json:"birth_date,omitempty"` + Education *string `json:"education,omitempty"` + Experience *string `json:"experience,omitempty"` + Skills []string `json:"skills,omitempty"` + Objective *string `json:"objective,omitempty"` + Title *string `json:"title,omitempty"` + PasswordHash string `json:"-"` + AvatarUrl string `json:"avatar_url"` + Roles []Role `json:"roles"` + Status string `json:"status"` // "ACTIVE", "INACTIVE" + Metadata map[string]interface{} `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // HEAD Fields (Profile) + ProfilePictureURL string `json:"profile_picture_url,omitempty"` +} + +// NewUser creates a new User instance. +func NewUser(id, tenantID, name, email string) *User { + return &User{ + ID: id, + TenantID: tenantID, + Name: name, + Email: email, + Status: UserStatusActive, + Roles: []Role{}, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +func (u *User) AssignRole(role Role) { + u.Roles = append(u.Roles, role) + u.UpdatedAt = time.Now() +} + +func (u *User) HasPermission(permissionCode string) bool { + for _, role := range u.Roles { + for _, perm := range role.Permissions { + if perm.Code == permissionCode { + return true + } + } + } + return false +} diff --git a/backend/internal/core/dto/user_auth.go b/backend/internal/core/dto/user_auth.go index 064c224..8bdce0b 100644 --- a/backend/internal/core/dto/user_auth.go +++ b/backend/internal/core/dto/user_auth.go @@ -1,91 +1,85 @@ -package dto - -import "time" - -type LoginRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -type AuthResponse struct { - Token string `json:"token"` - User UserResponse `json:"user"` - MustChangePassword bool `json:"mustChangePassword"` -} - -type CreateUserRequest struct { - Name string `json:"name"` - Email string `json:"email"` - Password string `json:"password"` - Roles []string `json:"roles"` - Status *string `json:"status,omitempty"` - TenantID *string `json:"companyId,omitempty"` // Optional, mainly for superads to assign user to company -} - -type UpdateUserRequest struct { - Name *string `json:"name,omitempty"` - Email *string `json:"email,omitempty"` - Phone *string `json:"phone,omitempty"` - Bio *string `json:"bio,omitempty"` - Active *bool `json:"active,omitempty"` - Status *string `json:"status,omitempty"` - Roles *[]string `json:"roles,omitempty"` - AvatarUrl *string `json:"avatarUrl,omitempty"` - - // HEAD Fields - Bio *string `json:"bio,omitempty"` - ProfilePictureURL *string `json:"profilePictureUrl,omitempty"` - Skills []string `json:"skills,omitempty"` - Experience []any `json:"experience,omitempty"` - Education []any `json:"education,omitempty"` -} - -type UserResponse struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Roles []string `json:"roles"` - Status string `json:"status"` -<<<<<<< HEAD -======= - AvatarUrl string `json:"avatar_url"` - Phone *string `json:"phone,omitempty"` - Bio *string `json:"bio,omitempty"` ->>>>>>> dev - CreatedAt time.Time `json:"created_at"` - - // Merged Fields - AvatarUrl string `json:"avatar_url,omitempty"` // hml - Bio string `json:"bio,omitempty"` // HEAD - ProfilePictureURL string `json:"profile_picture_url,omitempty"` // HEAD - Skills []string `json:"skills,omitempty"` // HEAD - Experience []any `json:"experience,omitempty"` // HEAD - Education []any `json:"education,omitempty"` // HEAD -} - -type UpdatePasswordRequest struct { - CurrentPassword string `json:"currentPassword"` - NewPassword string `json:"newPassword"` -} - -type RegisterCandidateRequest struct { - Name string `json:"name"` - Email string `json:"email"` - Password string `json:"password"` - Username string `json:"username"` - Phone string `json:"phone"` - Address string `json:"address,omitempty"` - City string `json:"city,omitempty"` - State string `json:"state,omitempty"` - ZipCode string `json:"zipCode,omitempty"` - BirthDate string `json:"birthDate,omitempty"` - Education string `json:"education,omitempty"` - Experience string `json:"experience,omitempty"` - Skills string `json:"skills,omitempty"` - Objective string `json:"objective,omitempty"` -} - -type SaveFCMTokenRequest struct { - Token string `json:"token"` - Platform string `json:"platform"` -} +package dto + +import "time" + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type AuthResponse struct { + Token string `json:"token"` + User UserResponse `json:"user"` + MustChangePassword bool `json:"mustChangePassword"` +} + +type CreateUserRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + Roles []string `json:"roles"` + Status *string `json:"status,omitempty"` + TenantID *string `json:"companyId,omitempty"` // Optional, mainly for superads to assign user to company +} + +type UpdateUserRequest struct { + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + Active *bool `json:"active,omitempty"` + Status *string `json:"status,omitempty"` + Roles *[]string `json:"roles,omitempty"` + AvatarUrl *string `json:"avatarUrl,omitempty"` + + // Profile Fields + Bio *string `json:"bio,omitempty"` + ProfilePictureURL *string `json:"profilePictureUrl,omitempty"` + Skills []string `json:"skills,omitempty"` + Experience []any `json:"experience,omitempty"` + Education []any `json:"education,omitempty"` +} + +type UserResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Roles []string `json:"roles"` + Status string `json:"status"` + AvatarUrl string `json:"avatar_url,omitempty"` + Phone *string `json:"phone,omitempty"` + Bio *string `json:"bio,omitempty"` + CreatedAt time.Time `json:"created_at"` + + // Profile Fields + ProfilePictureURL string `json:"profile_picture_url,omitempty"` + Skills []string `json:"skills,omitempty"` + Experience []any `json:"experience,omitempty"` + Education []any `json:"education,omitempty"` +} + +type UpdatePasswordRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + +type RegisterCandidateRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` + Username string `json:"username"` + Phone string `json:"phone"` + Address string `json:"address,omitempty"` + City string `json:"city,omitempty"` + State string `json:"state,omitempty"` + ZipCode string `json:"zipCode,omitempty"` + BirthDate string `json:"birthDate,omitempty"` + Education string `json:"education,omitempty"` + Experience string `json:"experience,omitempty"` + Skills string `json:"skills,omitempty"` + Objective string `json:"objective,omitempty"` +} + +type SaveFCMTokenRequest struct { + Token string `json:"token"` + Platform string `json:"platform"` +} diff --git a/backend/internal/handlers/job_handler.go b/backend/internal/handlers/job_handler.go index becddb2..ead9e12 100755 --- a/backend/internal/handlers/job_handler.go +++ b/backend/internal/handlers/job_handler.go @@ -1,277 +1,270 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" - - "github.com/rede5/gohorsejobs/backend/internal/api/middleware" - "github.com/rede5/gohorsejobs/backend/internal/dto" - "github.com/rede5/gohorsejobs/backend/internal/models" -) - -// JobServiceInterface describes the service needed by JobHandler -type JobServiceInterface interface { - GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) - CreateJob(req dto.CreateJobRequest, createdBy string) (*models.Job, error) - GetJobByID(id string) (*models.Job, error) - UpdateJob(id string, req dto.UpdateJobRequest) (*models.Job, error) - DeleteJob(id string) error -} - -type JobHandler struct { - Service JobServiceInterface -} - -func NewJobHandler(service JobServiceInterface) *JobHandler { - return &JobHandler{Service: service} -} - -// GetJobs godoc -// @Summary List all jobs -// @Description Get a paginated list of job postings with optional filters -// @Tags Jobs -// @Accept json -// @Produce json -<<<<<<< HEAD -// @Param page query int false "Page number (default: 1)" -// @Param limit query int false "Items per page (default: 10, max: 100)" -// @Param companyId query int false "Filter by company ID" -// @Param featured query bool false "Filter by featured status" -// @Param search query string false "Full-text search query" -// @Param employmentType query string false "Filter by employment type" -// @Param workMode query string false "Filter by work mode (onsite, hybrid, remote)" -// @Param location query string false "Filter by location text" -// @Param salaryMin query number false "Minimum salary filter" -// @Param salaryMax query number false "Maximum salary filter" -// @Param sortBy query string false "Sort by: date, salary, relevance" -// @Param sortOrder query string false "Sort order: asc, desc" -======= -// @Param page query int false "Page number (default: 1)" -// @Param limit query int false "Items per page (default: 10, max: 100)" -// @Param companyId query string false "Filter by company ID" -// @Param featured query bool false "Filter by featured status" ->>>>>>> dev -// @Success 200 {object} dto.PaginatedResponse -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/jobs [get] -func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) { - page, _ := strconv.Atoi(r.URL.Query().Get("page")) - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - companyID := r.URL.Query().Get("companyId") - isFeaturedStr := r.URL.Query().Get("featured") - - // Legacy and New Filter Handling - search := r.URL.Query().Get("search") - if search == "" { - search = r.URL.Query().Get("q") - } - - employmentType := r.URL.Query().Get("employmentType") - if employmentType == "" { - employmentType = r.URL.Query().Get("type") - } - - workMode := r.URL.Query().Get("workMode") - location := r.URL.Query().Get("location") - salaryMinStr := r.URL.Query().Get("salaryMin") - salaryMaxStr := r.URL.Query().Get("salaryMax") - sortBy := r.URL.Query().Get("sortBy") - sortOrder := r.URL.Query().Get("sortOrder") - - filter := dto.JobFilterQuery{ - PaginationQuery: dto.PaginationQuery{ - Page: page, - Limit: limit, - }, - SortBy: &sortBy, - SortOrder: &sortOrder, - } - - if companyID != "" { - filter.CompanyID = &companyID - } - if isFeaturedStr == "true" { - val := true - filter.IsFeatured = &val - } - if search != "" { - filter.Search = &search - } - if employmentType != "" { - filter.EmploymentType = &employmentType - } - if workMode != "" { - filter.WorkMode = &workMode - } - if location != "" { - filter.Location = &location - filter.LocationSearch = &location // Map to both for compatibility - } - if salaryMinStr != "" { - if val, err := strconv.ParseFloat(salaryMinStr, 64); err == nil { - filter.SalaryMin = &val - } - } - if salaryMaxStr != "" { - if val, err := strconv.ParseFloat(salaryMaxStr, 64); err == nil { - filter.SalaryMax = &val - } - } - - jobs, total, err := h.Service.GetJobs(filter) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if page == 0 { - page = 1 - } - if limit == 0 { - limit = 10 - } - - response := dto.PaginatedResponse{ - Data: jobs, - Pagination: dto.Pagination{ - Page: page, - Limit: limit, - Total: total, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) -} - -// CreateJob godoc -// @Summary Create a new job -// @Description Create a new job posting -// @Tags Jobs -// @Accept json -// @Produce json -// @Param job body dto.CreateJobRequest true "Job data" -// @Success 201 {object} models.Job -// @Failure 400 {string} string "Bad Request" -// @Failure 401 {string} string "Unauthorized" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/jobs [post] -func (h *JobHandler) CreateJob(w http.ResponseWriter, r *http.Request) { - fmt.Println("[CREATE_JOB DEBUG] === CreateJob Handler Started ===") - - var req dto.CreateJobRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - fmt.Printf("[CREATE_JOB ERROR] Failed to decode request body: %v\n", err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - fmt.Printf("[CREATE_JOB DEBUG] Request received: title=%s, companyId=%s, status=%s\n", req.Title, req.CompanyID, req.Status) - fmt.Printf("[CREATE_JOB DEBUG] Full request: %+v\n", req) - - // Extract UserID from context - val := r.Context().Value(middleware.ContextUserID) - fmt.Printf("[CREATE_JOB DEBUG] Context UserID value: %v (type: %T)\n", val, val) - - userID, ok := val.(string) - if !ok || userID == "" { - fmt.Printf("[CREATE_JOB ERROR] UserID extraction failed. ok=%v, userID='%s'\n", ok, userID) - http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) - return - } - - fmt.Printf("[CREATE_JOB DEBUG] UserID extracted: %s\n", userID) - fmt.Println("[CREATE_JOB DEBUG] Calling service.CreateJob...") - - job, err := h.Service.CreateJob(req, userID) - if err != nil { - fmt.Printf("[CREATE_JOB ERROR] Service.CreateJob failed: %v\n", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - fmt.Printf("[CREATE_JOB DEBUG] Job created successfully! ID=%s\n", job.ID) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(job) -} - -// GetJobByID godoc -// @Summary Get job by ID -// @Description Get a single job posting by its ID -// @Tags Jobs -// @Accept json -// @Produce json -// @Param id path string true "Job ID" -// @Success 200 {object} models.Job -// @Failure 400 {string} string "Bad Request" -// @Failure 404 {string} string "Not Found" -// @Router /api/v1/jobs/{id} [get] -func (h *JobHandler) GetJobByID(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - - job, err := h.Service.GetJobByID(id) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(job) -} - -// UpdateJob godoc -// @Summary Update a job -// @Description Update an existing job posting -// @Tags Jobs -// @Accept json -// @Produce json -// @Param id path string true "Job ID" -// @Param job body dto.UpdateJobRequest true "Updated job data" -// @Success 200 {object} models.Job -// @Failure 400 {string} string "Bad Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/jobs/{id} [put] -func (h *JobHandler) UpdateJob(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - - var req dto.UpdateJobRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - job, err := h.Service.UpdateJob(id, req) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(job) -} - -// DeleteJob godoc -// @Summary Delete a job -// @Description Delete a job posting -// @Tags Jobs -// @Accept json -// @Produce json -// @Param id path string true "Job ID" -// @Success 204 "No Content" -// @Failure 400 {string} string "Bad Request" -// @Failure 500 {string} string "Internal Server Error" -// @Router /api/v1/jobs/{id} [delete] -func (h *JobHandler) DeleteJob(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - - if err := h.Service.DeleteJob(id); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) -} +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/rede5/gohorsejobs/backend/internal/api/middleware" + "github.com/rede5/gohorsejobs/backend/internal/dto" + "github.com/rede5/gohorsejobs/backend/internal/models" +) + +// JobServiceInterface describes the service needed by JobHandler +type JobServiceInterface interface { + GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany, int, error) + CreateJob(req dto.CreateJobRequest, createdBy string) (*models.Job, error) + GetJobByID(id string) (*models.Job, error) + UpdateJob(id string, req dto.UpdateJobRequest) (*models.Job, error) + DeleteJob(id string) error +} + +type JobHandler struct { + Service JobServiceInterface +} + +func NewJobHandler(service JobServiceInterface) *JobHandler { + return &JobHandler{Service: service} +} + +// GetJobs godoc +// @Summary List all jobs +// @Description Get a paginated list of job postings with optional filters +// @Tags Jobs +// @Accept json +// @Produce json +// @Param page query int false "Page number (default: 1)" +// @Param limit query int false "Items per page (default: 10, max: 100)" +// @Param companyId query string false "Filter by company ID" +// @Param featured query bool false "Filter by featured status" +// @Param search query string false "Full-text search query" +// @Param employmentType query string false "Filter by employment type" +// @Param workMode query string false "Filter by work mode (onsite, hybrid, remote)" +// @Param location query string false "Filter by location text" +// @Param salaryMin query number false "Minimum salary filter" +// @Param salaryMax query number false "Maximum salary filter" +// @Param sortBy query string false "Sort by: date, salary, relevance" +// @Param sortOrder query string false "Sort order: asc, desc" +// @Success 200 {object} dto.PaginatedResponse +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/jobs [get] +func (h *JobHandler) GetJobs(w http.ResponseWriter, r *http.Request) { + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + companyID := r.URL.Query().Get("companyId") + isFeaturedStr := r.URL.Query().Get("featured") + + // Legacy and New Filter Handling + search := r.URL.Query().Get("search") + if search == "" { + search = r.URL.Query().Get("q") + } + + employmentType := r.URL.Query().Get("employmentType") + if employmentType == "" { + employmentType = r.URL.Query().Get("type") + } + + workMode := r.URL.Query().Get("workMode") + location := r.URL.Query().Get("location") + salaryMinStr := r.URL.Query().Get("salaryMin") + salaryMaxStr := r.URL.Query().Get("salaryMax") + sortBy := r.URL.Query().Get("sortBy") + sortOrder := r.URL.Query().Get("sortOrder") + + filter := dto.JobFilterQuery{ + PaginationQuery: dto.PaginationQuery{ + Page: page, + Limit: limit, + }, + SortBy: &sortBy, + SortOrder: &sortOrder, + } + + if companyID != "" { + filter.CompanyID = &companyID + } + if isFeaturedStr == "true" { + val := true + filter.IsFeatured = &val + } + if search != "" { + filter.Search = &search + } + if employmentType != "" { + filter.EmploymentType = &employmentType + } + if workMode != "" { + filter.WorkMode = &workMode + } + if location != "" { + filter.Location = &location + filter.LocationSearch = &location // Map to both for compatibility + } + if salaryMinStr != "" { + if val, err := strconv.ParseFloat(salaryMinStr, 64); err == nil { + filter.SalaryMin = &val + } + } + if salaryMaxStr != "" { + if val, err := strconv.ParseFloat(salaryMaxStr, 64); err == nil { + filter.SalaryMax = &val + } + } + + jobs, total, err := h.Service.GetJobs(filter) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if page == 0 { + page = 1 + } + if limit == 0 { + limit = 10 + } + + response := dto.PaginatedResponse{ + Data: jobs, + Pagination: dto.Pagination{ + Page: page, + Limit: limit, + Total: total, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// CreateJob godoc +// @Summary Create a new job +// @Description Create a new job posting +// @Tags Jobs +// @Accept json +// @Produce json +// @Param job body dto.CreateJobRequest true "Job data" +// @Success 201 {object} models.Job +// @Failure 400 {string} string "Bad Request" +// @Failure 401 {string} string "Unauthorized" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/jobs [post] +func (h *JobHandler) CreateJob(w http.ResponseWriter, r *http.Request) { + fmt.Println("[CREATE_JOB DEBUG] === CreateJob Handler Started ===") + + var req dto.CreateJobRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + fmt.Printf("[CREATE_JOB ERROR] Failed to decode request body: %v\n", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + fmt.Printf("[CREATE_JOB DEBUG] Request received: title=%s, companyId=%s, status=%s\n", req.Title, req.CompanyID, req.Status) + fmt.Printf("[CREATE_JOB DEBUG] Full request: %+v\n", req) + + // Extract UserID from context + val := r.Context().Value(middleware.ContextUserID) + fmt.Printf("[CREATE_JOB DEBUG] Context UserID value: %v (type: %T)\n", val, val) + + userID, ok := val.(string) + if !ok || userID == "" { + fmt.Printf("[CREATE_JOB ERROR] UserID extraction failed. ok=%v, userID='%s'\n", ok, userID) + http.Error(w, "Unauthorized: User ID missing", http.StatusUnauthorized) + return + } + + fmt.Printf("[CREATE_JOB DEBUG] UserID extracted: %s\n", userID) + fmt.Println("[CREATE_JOB DEBUG] Calling service.CreateJob...") + + job, err := h.Service.CreateJob(req, userID) + if err != nil { + fmt.Printf("[CREATE_JOB ERROR] Service.CreateJob failed: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + fmt.Printf("[CREATE_JOB DEBUG] Job created successfully! ID=%s\n", job.ID) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(job) +} + +// GetJobByID godoc +// @Summary Get job by ID +// @Description Get a single job posting by its ID +// @Tags Jobs +// @Accept json +// @Produce json +// @Param id path string true "Job ID" +// @Success 200 {object} models.Job +// @Failure 400 {string} string "Bad Request" +// @Failure 404 {string} string "Not Found" +// @Router /api/v1/jobs/{id} [get] +func (h *JobHandler) GetJobByID(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + job, err := h.Service.GetJobByID(id) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(job) +} + +// UpdateJob godoc +// @Summary Update a job +// @Description Update an existing job posting +// @Tags Jobs +// @Accept json +// @Produce json +// @Param id path string true "Job ID" +// @Param job body dto.UpdateJobRequest true "Updated job data" +// @Success 200 {object} models.Job +// @Failure 400 {string} string "Bad Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/jobs/{id} [put] +func (h *JobHandler) UpdateJob(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + var req dto.UpdateJobRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + job, err := h.Service.UpdateJob(id, req) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(job) +} + +// DeleteJob godoc +// @Summary Delete a job +// @Description Delete a job posting +// @Tags Jobs +// @Accept json +// @Produce json +// @Param id path string true "Job ID" +// @Success 204 "No Content" +// @Failure 400 {string} string "Bad Request" +// @Failure 500 {string} string "Internal Server Error" +// @Router /api/v1/jobs/{id} [delete] +func (h *JobHandler) DeleteJob(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + if err := h.Service.DeleteJob(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/infrastructure/persistence/postgres/user_repository.go b/backend/internal/infrastructure/persistence/postgres/user_repository.go index 3484b29..96aba2c 100644 --- a/backend/internal/infrastructure/persistence/postgres/user_repository.go +++ b/backend/internal/infrastructure/persistence/postgres/user_repository.go @@ -1,461 +1,372 @@ -package postgres - -import ( - "context" - "database/sql" - "encoding/json" - "time" - - "github.com/lib/pq" - "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" -) - -type UserRepository struct { - db *sql.DB -} - -func NewUserRepository(db *sql.DB) *UserRepository { - return &UserRepository{db: db} -} - -func (r *UserRepository) LinkGuestApplications(ctx context.Context, email string, userID string) error { - query := ` - UPDATE applications - SET user_id = $1 - WHERE email = $2 AND (user_id IS NULL OR user_id LIKE 'guest_%') - ` - _, err := r.db.ExecContext(ctx, query, userID, email) - return err -} - -func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.User, error) { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer tx.Rollback() - - // TenantID is string (UUID) or empty - var tenantID *string - if user.TenantID != "" { - tenantID = &user.TenantID - } - - // 1. Insert User - query := ` - INSERT INTO users ( - identifier, password_hash, role, full_name, email, name, tenant_id, status, created_at, updated_at, avatar_url, - phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title - ) - 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 - ` - - var id string - // Map the first role to the role column, default to 'candidate' - role := "candidate" - if len(user.Roles) > 0 { - role = user.Roles[0].Name - } - - // Prepare pq Array for skills - // IMPORTANT: import "github.com/lib/pq" needed at top - - err = tx.QueryRowContext(ctx, query, - user.Email, // identifier = email - user.PasswordHash, - role, - user.Name, - user.Email, - user.Name, - tenantID, - user.Status, - user.CreatedAt, - user.UpdatedAt, - user.AvatarUrl, - user.Phone, - user.Bio, - user.Address, - user.City, - user.State, - user.ZipCode, - user.BirthDate, - user.Education, - user.Experience, - pq.Array(user.Skills), - user.Objective, - user.Title, - ).Scan(&id) - - if err != nil { - return nil, err - } - user.ID = id - - // 2. Insert Roles into user_roles table - if len(user.Roles) > 0 { - roleQuery := `INSERT INTO user_roles (user_id, role) VALUES ($1, $2) ON CONFLICT DO NOTHING` - for _, role := range user.Roles { - _, err := tx.ExecContext(ctx, roleQuery, id, role.Name) - if err != nil { - return nil, err - } - } - } - - if err := tx.Commit(); err != nil { - return nil, err - } - - return user, nil -} - -func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) { -<<<<<<< HEAD - query := ` - SELECT - id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), - COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), - bio, profile_picture_url, skills, experience, education - FROM users WHERE email = $1 OR identifier = $1 - ` -======= - query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), - COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), - phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title - FROM users WHERE email = $1 OR identifier = $1` ->>>>>>> dev - row := r.db.QueryRowContext(ctx, query, email) - - u := &entity.User{} - var dbID string -<<<<<<< HEAD - var skills, experience, education []byte // temp for Scanning - - err := row.Scan( - &dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt, &u.AvatarUrl, - &u.Bio, &u.ProfilePictureURL, &skills, &experience, &education, -======= - var phone sql.NullString - var bio sql.NullString - err := row.Scan( - &dbID, - &u.TenantID, - &u.Name, - &u.Email, - &u.PasswordHash, - &u.Status, - &u.CreatedAt, - &u.UpdatedAt, - &u.AvatarUrl, - &phone, - &bio, - &u.Address, - &u.City, - &u.State, - &u.ZipCode, - &u.BirthDate, - &u.Education, - &u.Experience, - pq.Array(&u.Skills), - &u.Objective, - &u.Title, ->>>>>>> dev - ) - if err != nil { - if err == sql.ErrNoRows { - return nil, nil // Return nil if not found - } - return nil, err - } - u.ID = dbID -<<<<<<< HEAD - - // Unmarshal JSONB fields - if len(skills) > 0 { - _ = json.Unmarshal(skills, &u.Skills) - } - if len(experience) > 0 { - _ = json.Unmarshal(experience, &u.Experience) - } - if len(education) > 0 { - _ = json.Unmarshal(education, &u.Education) - } - -======= - u.Phone = nullStringPtr(phone) - u.Bio = nullStringPtr(bio) ->>>>>>> dev - u.Roles, _ = r.getRoles(ctx, dbID) - return u, nil -} - -func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) { -<<<<<<< HEAD - query := ` - SELECT - id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), - COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), - bio, profile_picture_url, skills, experience, education - FROM users WHERE id = $1 - ` -======= - query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), - COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), - phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title - FROM users WHERE id = $1` ->>>>>>> dev - row := r.db.QueryRowContext(ctx, query, id) - - u := &entity.User{} - var dbID string -<<<<<<< HEAD - var skills, experience, education []byte // temp for Scanning - - err := row.Scan( - &dbID, &u.TenantID, &u.Name, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt, &u.UpdatedAt, &u.AvatarUrl, - &u.Bio, &u.ProfilePictureURL, &skills, &experience, &education, -======= - var phone sql.NullString - var bio sql.NullString - err := row.Scan( - &dbID, - &u.TenantID, - &u.Name, - &u.Email, - &u.PasswordHash, - &u.Status, - &u.CreatedAt, - &u.UpdatedAt, - &u.AvatarUrl, - &phone, - &bio, - &u.Address, - &u.City, - &u.State, - &u.ZipCode, - &u.BirthDate, - &u.Education, - &u.Experience, - pq.Array(&u.Skills), - &u.Objective, - &u.Title, ->>>>>>> dev - ) - if err != nil { - return nil, err - } - u.ID = dbID -<<<<<<< HEAD - - // Unmarshal JSONB fields - if len(skills) > 0 { - _ = json.Unmarshal(skills, &u.Skills) - } - if len(experience) > 0 { - _ = json.Unmarshal(experience, &u.Experience) - } - if len(education) > 0 { - _ = json.Unmarshal(education, &u.Education) - } - -======= - u.Phone = nullStringPtr(phone) - u.Bio = nullStringPtr(bio) ->>>>>>> dev - u.Roles, _ = r.getRoles(ctx, dbID) - return u, nil -} - -func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error) { - var total int - countQuery := `SELECT COUNT(*) FROM users WHERE tenant_id = $1` - if err := r.db.QueryRowContext(ctx, countQuery, tenantID).Scan(&total); err != nil { - return nil, 0, err - } - - query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), - COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), - phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title - FROM users - WHERE tenant_id = $1 - ORDER BY created_at DESC - LIMIT $2 OFFSET $3` - rows, err := r.db.QueryContext(ctx, query, tenantID, limit, offset) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var users []*entity.User - for rows.Next() { - u := &entity.User{} - var dbID string - var phone sql.NullString - var bio sql.NullString - if err := rows.Scan( - &dbID, - &u.TenantID, - &u.Name, - &u.Email, - &u.PasswordHash, - &u.Status, - &u.CreatedAt, - &u.UpdatedAt, - &u.AvatarUrl, - &phone, - &bio, - &u.Address, - &u.City, - &u.State, - &u.ZipCode, - &u.BirthDate, - &u.Education, - &u.Experience, - pq.Array(&u.Skills), - &u.Objective, - &u.Title, - ); err != nil { - return nil, 0, err - } - u.ID = dbID - u.Phone = nullStringPtr(phone) - u.Bio = nullStringPtr(bio) - u.Roles, _ = r.getRoles(ctx, dbID) - users = append(users, u) - } - return users, total, nil -} - -func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) { - user.UpdatedAt = time.Now() - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer tx.Rollback() - - // 1. Update basic fields + legacy role column + Profile fields - // We use the first role as the "legacy" role for compatibility - // Prepare pq Array for skills - // 1. Update basic fields + legacy role column - // We use the first role as the "legacy" role for compatibility - primaryRole := "" - if len(user.Roles) > 0 { - primaryRole = user.Roles[0].Name - } - -<<<<<<< HEAD - skillsJSON, _ := json.Marshal(user.Skills) - experienceJSON, _ := json.Marshal(user.Experience) - educationJSON, _ := json.Marshal(user.Education) - - query := ` - UPDATE users - SET name=$1, email=$2, status=$3, role=$4, updated_at=$5, avatar_url=$6, - bio=$7, profile_picture_url=$8, skills=$9, experience=$10, education=$11 - WHERE id=$12 - ` - _, err = tx.ExecContext(ctx, query, - user.Name, user.Email, user.Status, primaryRole, user.UpdatedAt, user.AvatarUrl, - user.Bio, user.ProfilePictureURL, skillsJSON, experienceJSON, educationJSON, -======= - query := ` - UPDATE users - SET name=$1, full_name=$2, email=$3, status=$4, role=$5, updated_at=$6, avatar_url=$7, - phone=$8, bio=$9, password_hash=$10, - address=$11, city=$12, state=$13, zip_code=$14, birth_date=$15, - education=$16, experience=$17, skills=$18, objective=$19, title=$20 - WHERE id=$21 - ` - _, err = tx.ExecContext( - ctx, - query, - user.Name, - user.Name, - user.Email, - user.Status, - primaryRole, - user.UpdatedAt, - user.AvatarUrl, - user.Phone, - user.Bio, - user.PasswordHash, - user.Address, - user.City, - user.State, - user.ZipCode, - user.BirthDate, - user.Education, - user.Experience, - pq.Array(user.Skills), - user.Objective, - user.Title, ->>>>>>> dev - user.ID, - ) - if err != nil { - return nil, err - } - - // 2. Update user_roles (Delete all and re-insert) - _, err = tx.ExecContext(ctx, `DELETE FROM user_roles WHERE user_id=$1`, user.ID) - if err != nil { - return nil, err - } - - if len(user.Roles) > 0 { - stmt, err := tx.PrepareContext(ctx, `INSERT INTO user_roles (user_id, role) VALUES ($1, $2)`) - if err != nil { - return nil, err - } - defer stmt.Close() - - for _, role := range user.Roles { - if _, err := stmt.ExecContext(ctx, user.ID, role.Name); err != nil { - return nil, err - } - } - } - - if err := tx.Commit(); err != nil { - return nil, err - } - - return user, nil -} - -func (r *UserRepository) Delete(ctx context.Context, id string) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, id) - return err -} - -func (r *UserRepository) getRoles(ctx context.Context, userID string) ([]entity.Role, error) { - // Query both user_roles table AND legacy role column from users table - // This ensures backward compatibility with users who have role set in users.role - query := ` - SELECT role FROM user_roles WHERE user_id = $1 - UNION - SELECT role FROM users WHERE id = $1 AND role IS NOT NULL AND role != '' - ` - rows, err := r.db.QueryContext(ctx, query, userID) - if err != nil { - return nil, err - } - defer rows.Close() - var roles []entity.Role - for rows.Next() { - var roleName string - rows.Scan(&roleName) - roles = append(roles, entity.Role{Name: roleName}) - } - return roles, nil -} - -func nullStringPtr(value sql.NullString) *string { - if value.Valid { - return &value.String - } - return nil -} +package postgres + +import ( + "context" + "database/sql" + "time" + + "github.com/lib/pq" + "github.com/rede5/gohorsejobs/backend/internal/core/domain/entity" +) + +type UserRepository struct { + db *sql.DB +} + +func NewUserRepository(db *sql.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) LinkGuestApplications(ctx context.Context, email string, userID string) error { + query := ` + UPDATE applications + SET user_id = $1 + WHERE email = $2 AND (user_id IS NULL OR user_id LIKE 'guest_%') + ` + _, err := r.db.ExecContext(ctx, query, userID, email) + return err +} + +func (r *UserRepository) Save(ctx context.Context, user *entity.User) (*entity.User, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + + // TenantID is string (UUID) or empty + var tenantID *string + if user.TenantID != "" { + tenantID = &user.TenantID + } + + // 1. Insert User + query := ` + INSERT INTO users ( + identifier, password_hash, role, full_name, email, name, tenant_id, status, created_at, updated_at, avatar_url, + phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title + ) + 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 + ` + + var id string + // Map the first role to the role column, default to 'candidate' + role := "candidate" + if len(user.Roles) > 0 { + role = user.Roles[0].Name + } + + err = tx.QueryRowContext(ctx, query, + user.Email, // identifier = email + user.PasswordHash, + role, + user.Name, + user.Email, + user.Name, + tenantID, + user.Status, + user.CreatedAt, + user.UpdatedAt, + user.AvatarUrl, + user.Phone, + user.Bio, + user.Address, + user.City, + user.State, + user.ZipCode, + user.BirthDate, + user.Education, + user.Experience, + pq.Array(user.Skills), + user.Objective, + user.Title, + ).Scan(&id) + + if err != nil { + return nil, err + } + user.ID = id + + // 2. Insert Roles into user_roles table + if len(user.Roles) > 0 { + roleQuery := `INSERT INTO user_roles (user_id, role) VALUES ($1, $2) ON CONFLICT DO NOTHING` + for _, role := range user.Roles { + _, err := tx.ExecContext(ctx, roleQuery, id, role.Name) + if err != nil { + return nil, err + } + } + } + + if err := tx.Commit(); err != nil { + return nil, err + } + + return user, nil +} + +func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entity.User, error) { + query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), + COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), + phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title + FROM users WHERE email = $1 OR identifier = $1` + row := r.db.QueryRowContext(ctx, query, email) + + u := &entity.User{} + var dbID string + var phone sql.NullString + var bio sql.NullString + err := row.Scan( + &dbID, + &u.TenantID, + &u.Name, + &u.Email, + &u.PasswordHash, + &u.Status, + &u.CreatedAt, + &u.UpdatedAt, + &u.AvatarUrl, + &phone, + &bio, + &u.Address, + &u.City, + &u.State, + &u.ZipCode, + &u.BirthDate, + &u.Education, + &u.Experience, + pq.Array(&u.Skills), + &u.Objective, + &u.Title, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil // Return nil if not found + } + return nil, err + } + u.ID = dbID + u.Phone = nullStringPtr(phone) + u.Bio = nullStringPtr(bio) + u.Roles, _ = r.getRoles(ctx, dbID) + return u, nil +} + +func (r *UserRepository) FindByID(ctx context.Context, id string) (*entity.User, error) { + query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), + COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), + phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title + FROM users WHERE id = $1` + row := r.db.QueryRowContext(ctx, query, id) + + u := &entity.User{} + var dbID string + var phone sql.NullString + var bio sql.NullString + err := row.Scan( + &dbID, + &u.TenantID, + &u.Name, + &u.Email, + &u.PasswordHash, + &u.Status, + &u.CreatedAt, + &u.UpdatedAt, + &u.AvatarUrl, + &phone, + &bio, + &u.Address, + &u.City, + &u.State, + &u.ZipCode, + &u.BirthDate, + &u.Education, + &u.Experience, + pq.Array(&u.Skills), + &u.Objective, + &u.Title, + ) + if err != nil { + return nil, err + } + u.ID = dbID + u.Phone = nullStringPtr(phone) + u.Bio = nullStringPtr(bio) + u.Roles, _ = r.getRoles(ctx, dbID) + return u, nil +} + +func (r *UserRepository) FindAllByTenant(ctx context.Context, tenantID string, limit, offset int) ([]*entity.User, int, error) { + var total int + countQuery := `SELECT COUNT(*) FROM users WHERE tenant_id = $1` + if err := r.db.QueryRowContext(ctx, countQuery, tenantID).Scan(&total); err != nil { + return nil, 0, err + } + + query := `SELECT id, COALESCE(tenant_id::text, ''), COALESCE(name, full_name, ''), + COALESCE(email, identifier), password_hash, COALESCE(status, 'active'), created_at, updated_at, COALESCE(avatar_url, ''), + phone, bio, address, city, state, zip_code, birth_date, education, experience, skills, objective, title + FROM users + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3` + rows, err := r.db.QueryContext(ctx, query, tenantID, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var users []*entity.User + for rows.Next() { + u := &entity.User{} + var dbID string + var phone sql.NullString + var bio sql.NullString + if err := rows.Scan( + &dbID, + &u.TenantID, + &u.Name, + &u.Email, + &u.PasswordHash, + &u.Status, + &u.CreatedAt, + &u.UpdatedAt, + &u.AvatarUrl, + &phone, + &bio, + &u.Address, + &u.City, + &u.State, + &u.ZipCode, + &u.BirthDate, + &u.Education, + &u.Experience, + pq.Array(&u.Skills), + &u.Objective, + &u.Title, + ); err != nil { + return nil, 0, err + } + u.ID = dbID + u.Phone = nullStringPtr(phone) + u.Bio = nullStringPtr(bio) + u.Roles, _ = r.getRoles(ctx, dbID) + users = append(users, u) + } + return users, total, nil +} + +func (r *UserRepository) Update(ctx context.Context, user *entity.User) (*entity.User, error) { + user.UpdatedAt = time.Now() + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + + // 1. Update basic fields + legacy role column + // We use the first role as the "legacy" role for compatibility + primaryRole := "" + if len(user.Roles) > 0 { + primaryRole = user.Roles[0].Name + } + + query := ` + UPDATE users + SET name=$1, full_name=$2, email=$3, status=$4, role=$5, updated_at=$6, avatar_url=$7, + phone=$8, bio=$9, password_hash=$10, + address=$11, city=$12, state=$13, zip_code=$14, birth_date=$15, + education=$16, experience=$17, skills=$18, objective=$19, title=$20 + WHERE id=$21 + ` + _, err = tx.ExecContext( + ctx, + query, + user.Name, + user.Name, + user.Email, + user.Status, + primaryRole, + user.UpdatedAt, + user.AvatarUrl, + user.Phone, + user.Bio, + user.PasswordHash, + user.Address, + user.City, + user.State, + user.ZipCode, + user.BirthDate, + user.Education, + user.Experience, + pq.Array(user.Skills), + user.Objective, + user.Title, + user.ID, + ) + if err != nil { + return nil, err + } + + // 2. Update user_roles (Delete all and re-insert) + _, err = tx.ExecContext(ctx, `DELETE FROM user_roles WHERE user_id=$1`, user.ID) + if err != nil { + return nil, err + } + + if len(user.Roles) > 0 { + stmt, err := tx.PrepareContext(ctx, `INSERT INTO user_roles (user_id, role) VALUES ($1, $2)`) + if err != nil { + return nil, err + } + defer stmt.Close() + + for _, role := range user.Roles { + if _, err := stmt.ExecContext(ctx, user.ID, role.Name); err != nil { + return nil, err + } + } + } + + if err := tx.Commit(); err != nil { + return nil, err + } + + return user, nil +} + +func (r *UserRepository) Delete(ctx context.Context, id string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, id) + return err +} + +func (r *UserRepository) getRoles(ctx context.Context, userID string) ([]entity.Role, error) { + // Query both user_roles table AND legacy role column from users table + // This ensures backward compatibility with users who have role set in users.role + query := ` + SELECT role FROM user_roles WHERE user_id = $1 + UNION + SELECT role FROM users WHERE id = $1 AND role IS NOT NULL AND role != '' + ` + rows, err := r.db.QueryContext(ctx, query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var roles []entity.Role + for rows.Next() { + var roleName string + rows.Scan(&roleName) + roles = append(roles, entity.Role{Name: roleName}) + } + return roles, nil +} + +func nullStringPtr(value sql.NullString) *string { + if value.Valid { + return &value.String + } + return nil +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index fa4cae8..d033784 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -1,370 +1,334 @@ -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 - // 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) -<<<<<<< HEAD - forgotPasswordUC := authUC.NewForgotPasswordUseCase(userRepo, tokenRepo, emailService, frontendURL) - resetPasswordUC := authUC.NewResetPasswordUseCase(userRepo, tokenRepo, authService) - - // Admin Logic Services -======= - updatePasswordUC := userUC.NewUpdatePasswordUseCase(userRepo, authService) ->>>>>>> dev - 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) -<<<<<<< HEAD - 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/logout", coreHandlers.Logout) ->>>>>>> dev - 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))) - - // /api/v1/admin/companies -> Handled by coreHandlers.ListCompanies (Smart Branching) - // Needs to be wired with Optional Auth to support both Public and Admin. - // I will create OptionalHeaderAuthGuard in middleware next. - - // 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)))) - - // /api/v1/admin/jobs -> /api/v1/jobs?mode=admin (Need Smart Handler) or just separate path /api/v1/jobs/management? - // User said "remove admin from ALL routes". - // Maybe /api/v1/management/jobs? - // Or just /api/v1/jobs (guarded)? - // JobHandler.GetJobs is Public. - // I will leave /api/v1/admin/jobs mapped to `GET /api/v1/jobs` for now (Collision). - // OK, I will map it to `GET /api/v1/jobs/moderation` for clearer distinction without "admin" prefix? - // Or simply `GET /api/v1/jobs` handle it? - // Given safe constraints, `GET /api/v1/jobs/moderation` is safer than breaking public `GET /api/v1/jobs`. - 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) - // Guarded by Admin Roles, or you could make it Dev only via env check - mux.HandleFunc("GET /api/v1/seeder/seed/stream", seederHandlers.HandleSeedStream) // Has its own auth or unrestricted for dev? Better unrestricted for simplicity in dev if safe. - // Actually, let's keep it open for now or simple admin guard if user is logged in. - // The frontend uses EventSource which sends cookies but not custom headers easily without polyfill. - // We'll leave it public for the requested "Dev" purpose, or rely on internal network. - // If needed, we can add query param token. - 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 -<<<<<<< HEAD - mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication) - mux.Handle("GET /api/v1/applications/me", authMiddleware.HeaderAuthGuard(http.HandlerFunc(applicationHandler.ListUserApplications))) // New endpoint -======= - 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))) ->>>>>>> dev - mux.HandleFunc("GET /api/v1/applications", applicationHandler.GetApplications) - mux.HandleFunc("GET /api/v1/applications/{id}", applicationHandler.GetApplicationByID) - mux.HandleFunc("PUT /api/v1/applications/{id}/status", applicationHandler.UpdateApplicationStatus) - mux.HandleFunc("DELETE /api/v1/applications/{id}", applicationHandler.DeleteApplication) - - // 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/stats", ticketHandler.GetTicketStats) // Removed in hml - 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("GET /api/v1/tickets/{id}/messages", ticketHandler.GetTicketMessages) // Merged into GetByID - 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("GET /api/v1/notifications", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.GetNotifications))) - // mux.Handle("GET /api/v1/notifications/unread-count", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.GetUnreadCount))) // Removed in hml - 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("DELETE /api/v1/notifications/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.DeleteNotification))) // Removed in hml - mux.Handle("POST /api/v1/notifications/fcm-token", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.RegisterFCMToken))) - // mux.Handle("DELETE /api/v1/notifications/fcm-token", authMiddleware.HeaderAuthGuard(http.HandlerFunc(notificationHandler.UnregisterFCMToken))) // Removed in hml - - // 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 -} +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) + + // 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))) + mux.HandleFunc("GET /api/v1/applications", applicationHandler.GetApplications) + mux.HandleFunc("GET /api/v1/applications/{id}", applicationHandler.GetApplicationByID) + mux.HandleFunc("PUT /api/v1/applications/{id}/status", applicationHandler.UpdateApplicationStatus) + mux.HandleFunc("DELETE /api/v1/applications/{id}", applicationHandler.DeleteApplication) + + // 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 59f22db..c3a316b 100644 --- a/backend/internal/services/job_service.go +++ b/backend/internal/services/job_service.go @@ -1,493 +1,450 @@ -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 hml fields + key HEAD logic - baseQuery := ` -<<<<<<< HEAD - 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, - COALESCE(c.name, '') 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 - 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` - -======= - 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, - (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` ->>>>>>> dev - countQuery := `SELECT COUNT(*) FROM jobs j WHERE 1=1` - - var args []interface{} - argId := 1 - - // Search (merged logic) - // Supports full text search if available, or ILIKE fallback. - // Using generic ILIKE for broad compatibility as hml did, but incorporating HEAD's concept. - if filter.Search != nil && *filter.Search != "" { - searchTerm := fmt.Sprintf("%%%s%%", *filter.Search) - // HEAD had tsvector. If DB supports it great. But to avoid "function not found" if extension missing, safe bet is ILIKE. - // hml used ILIKE. - 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 (mapped to same in requests.go but just in case) - 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) - args = append(args, *filter.SalaryType) - 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": - // Simple relevance if no fulltext rank - sortClause = " ORDER BY j.is_featured DESC, j.created_at DESC" - } - } - - // Override sort order if explicit - if filter.SortOrder != nil { - if *filter.SortOrder == "asc" { - // Naive replace/append. hml logic didn't support generic SortOrder param well (it embedded in SortBy). - // If SortBy was one of the above, we might just append ASC? - // But for now, rely on SortBy providing correct default or direction. - // HEAD relied on SortOrder. - } - } - - 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, -<<<<<<< HEAD - &j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName, - &j.ViewCount, &j.FeaturedUntil, -======= - &j.CompanyName, &j.CompanyLogoURL, &j.RegionName, &j.CityName, &j.ApplicationsCount, ->>>>>>> dev - ); 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 - ` - // Added extra fields to SELECT to cover both models - err := s.DB.QueryRow(query, id).Scan( - &j.ID, &j.CompanyID, &j.Title, &j.Description, &j.SalaryMin, &j.SalaryMax, &j.SalaryType, -<<<<<<< HEAD - &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, -======= - &j.EmploymentType, &j.WorkingHours, &j.Location, &j.RegionID, &j.CityID, &j.SalaryNegotiable, - &j.Requirements, &j.Benefits, &j.VisaSupport, &j.LanguageLevel, &j.Status, &j.CreatedAt, &j.UpdatedAt, ->>>>>>> dev - ) - 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++ - } -<<<<<<< HEAD -======= - 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++ - } ->>>>>>> dev - 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)) - // HEAD had string parsing. hml didn't show parsing logic but request field might be string. - // Assuming ISO8601 string from DTO. - parsedTime, err := time.Parse(time.RFC3339, *req.FeaturedUntil) - if err == nil { - args = append(args, parsedTime) - } else { - // Fallback or error? For now fallback null or skip - 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 req.Currency != nil { - setClauses = append(setClauses, fmt.Sprintf("currency = $%d", argId)) - args = append(args, *req.Currency) - 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 -} +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++ + } + if filter.SalaryType != nil && *filter.SalaryType != "" { + baseQuery += fmt.Sprintf(" AND j.salary_type = $%d", argId) + countQuery += fmt.Sprintf(" AND j.salary_type = $%d", argId) + args = append(args, *filter.SalaryType) + 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 +}