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:
parent
4712193ade
commit
786ef42d8a
3 changed files with 287 additions and 0 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue