diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index a2ee02c..197fae4 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -798,6 +798,158 @@ func (h *CoreHandlers) AddMessage(w http.ResponseWriter, r *http.Request) { 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. @@ -1030,3 +1182,14 @@ func (h *CoreHandlers) SaveCredentials(w http.ResponseWriter, r *http.Request) { 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/router/router.go b/backend/internal/router/router.go index 35e9bf3..76512cb 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -227,8 +227,12 @@ func NewRouter() http.Handler { // 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))) diff --git a/backend/internal/services/ticket_service.go b/backend/internal/services/ticket_service.go index a87cd74..c4eee9c 100644 --- a/backend/internal/services/ticket_service.go +++ b/backend/internal/services/ticket_service.go @@ -135,3 +135,123 @@ func (s *TicketService) AddMessage(ctx context.Context, ticketID string, userID return &m, nil } + +// UpdateTicket updates a ticket's status and/or priority +func (s *TicketService) UpdateTicket(ctx context.Context, ticketID string, userID string, status *string, priority *string, isAdmin bool) (*models.Ticket, error) { + // Verify ownership (or admin access) + var ownerID string + err := s.DB.QueryRowContext(ctx, "SELECT user_id FROM tickets WHERE id = $1", ticketID).Scan(&ownerID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errors.New("ticket not found") + } + return nil, err + } + + // Only owner or admin can update + if ownerID != userID && !isAdmin { + return nil, errors.New("unauthorized") + } + + // Build dynamic update + setClauses := []string{"updated_at = NOW()"} + args := []interface{}{} + argIdx := 1 + + if status != nil { + setClauses = append(setClauses, "status = $"+string(rune('0'+argIdx))) + args = append(args, *status) + argIdx++ + } + if priority != nil { + setClauses = append(setClauses, "priority = $"+string(rune('0'+argIdx))) + args = append(args, *priority) + argIdx++ + } + + args = append(args, ticketID) + query := "UPDATE tickets SET " + joinStrings(setClauses, ", ") + " WHERE id = $" + string(rune('0'+argIdx)) + " RETURNING id, user_id, subject, status, priority, created_at, updated_at" + + var t models.Ticket + err = s.DB.QueryRowContext(ctx, query, args...).Scan( + &t.ID, &t.UserID, &t.Subject, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &t, nil +} + +// CloseTicket is a convenience method to close a ticket +func (s *TicketService) CloseTicket(ctx context.Context, ticketID string, userID string, isAdmin bool) (*models.Ticket, error) { + status := "closed" + return s.UpdateTicket(ctx, ticketID, userID, &status, nil, isAdmin) +} + +// DeleteTicket removes a ticket (admin only) +func (s *TicketService) DeleteTicket(ctx context.Context, ticketID string) error { + // First delete messages + _, err := s.DB.ExecContext(ctx, "DELETE FROM ticket_messages WHERE ticket_id = $1", ticketID) + if err != nil { + return err + } + + // Then delete ticket + result, err := s.DB.ExecContext(ctx, "DELETE FROM tickets WHERE id = $1", ticketID) + if err != nil { + return err + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return errors.New("ticket not found") + } + + return nil +} + +// ListAllTickets returns all tickets (for admin) +func (s *TicketService) ListAllTickets(ctx context.Context, status string) ([]models.Ticket, error) { + query := ` + SELECT id, user_id, subject, status, priority, created_at, updated_at + FROM tickets + ` + args := []interface{}{} + + if status != "" { + query += " WHERE status = $1" + args = append(args, status) + } + + query += " ORDER BY updated_at DESC" + + rows, err := s.DB.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + tickets := []models.Ticket{} + for rows.Next() { + var t models.Ticket + if err := rows.Scan( + &t.ID, &t.UserID, &t.Subject, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt, + ); err != nil { + return nil, err + } + tickets = append(tickets, t) + } + return tickets, nil +} + +// Helper function +func joinStrings(strs []string, sep string) string { + if len(strs) == 0 { + return "" + } + result := strs[0] + for i := 1; i < len(strs); i++ { + result += sep + strs[i] + } + return result +}