From 3583ef89d87b9d86aac8a8a350ef095ec4b4462d Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Mon, 23 Feb 2026 13:44:34 -0600 Subject: [PATCH] fix: set cookie Secure=true and SameSite=None for cross-origin auth --- .../internal/api/handlers/core_handlers.go | 2690 ++++++++--------- 1 file changed, 1345 insertions(+), 1345 deletions(-) diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index cc16047..c298c7c 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -1,1345 +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 -} - -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 -} +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: true, + SameSite: http.SameSiteNoneMode, + }) + + 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: true, + SameSite: http.SameSiteNoneMode, + 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 +}