feat: add complete support ticket CRUD operations

Backend Service:
- UpdateTicket: update status/priority (owner or admin)
- CloseTicket: convenience method to set status=closed
- DeleteTicket: admin only, removes ticket and messages
- ListAllTickets: admin only, with optional status filter

Handlers:
- PATCH /api/v1/support/tickets/{id} - update ticket
- PATCH /api/v1/support/tickets/{id}/close - close ticket
- DELETE /api/v1/support/tickets/{id} - delete ticket (admin)
- GET /api/v1/support/tickets/all - list all tickets (admin)

All endpoints with Swagger annotations
This commit is contained in:
Tiago Yamamoto 2025-12-26 16:16:05 -03:00
parent 4712193ade
commit 786ef42d8a
3 changed files with 287 additions and 0 deletions

View file

@ -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
}

View file

@ -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)))

View file

@ -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
}