Files
ai-customer-service/internal/http/handlers/ticket_handler.go
Your Name 087de4e102 fix(audit): use uuid.New() for ticket workflow audit IDs
Fixes 'invalid input syntax for type uuid' error when writing ticket
workflow audit logs. The audit Event.ID field was using fmt.Sprintf
with nanoseconds ('wf-%d') which doesn't match PostgreSQL's uuid type.

Also adds uuid import to ticket_workflow.go.

Verified: full chain webhook→assign→resolve→close produces 3 audit
logs correctly, no more 'invalid uuid' errors in logs.
2026-05-04 13:44:39 +08:00

156 lines
7.4 KiB
Go

package handlers
import (
"context"
"net/http"
"strings"
"time"
"github.com/bridge/ai-customer-service/internal/domain/audit"
"github.com/bridge/ai-customer-service/internal/domain/error/cserrors"
"github.com/bridge/ai-customer-service/internal/domain/ticket"
"github.com/bridge/ai-customer-service/internal/http/middleware"
)
type TicketService interface {
ListOpen(ctx context.Context, limit int) ([]ticket.Ticket, error)
GetByID(ctx context.Context, id string) (*ticket.Ticket, error)
Assign(ctx context.Context, ticketID, agentID, actorID, sourceIP string, now time.Time) error
Resolve(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error
Close(ctx context.Context, ticketID, resolution, actorID, sourceIP string, now time.Time) error
}
type TicketHandler struct {
service TicketService
audit AuditRecorder
now func() time.Time
}
func NewTicketHandler(service TicketService, auditRecorder AuditRecorder) *TicketHandler {
return &TicketHandler{service: service, audit: auditRecorder, now: time.Now}
}
func (h *TicketHandler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.service.ListOpen(r.Context(), 50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"code": cserrors.CS_SYS_5002, "message": cserrors.ErrorMsg(cserrors.CS_SYS_5002)}})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// P1-3: GET /api/v1/customer-service/tickets/{id} — ticket detail (Phase 1 minimum implementation)
func (h *TicketHandler) Get(w http.ResponseWriter, r *http.Request) {
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "")
if ticketID == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4005, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4005)}})
return
}
tkt, err := h.service.GetByID(r.Context(), ticketID)
if err != nil || tkt == nil {
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4001, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4001)}})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ticket": tkt})
}
func (h *TicketHandler) Assign(w http.ResponseWriter, r *http.Request) {
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "/assign")
agentID := strings.TrimSpace(r.URL.Query().Get("agent_id"))
if ticketID == "" || agentID == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4005, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4005)}})
return
}
actor, ok := middleware.ActorFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusForbidden, map[string]any{"error": map[string]any{"code": cserrors.CS_AUTH_4001, "message": cserrors.ErrorMsg(cserrors.CS_AUTH_4001)}})
return
}
actorID := actor.ID
sourceIP := clientIP(r.RemoteAddr)
if err := h.service.Assign(r.Context(), ticketID, agentID, actorID, sourceIP, h.now()); err != nil {
// P0-2 fix: route error based on error code prefix from service layer
errStr := err.Error()
if strings.HasPrefix(errStr, "CS_TICKET_4001") {
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4001, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4001)}})
return
}
writeJSON(w, http.StatusConflict, map[string]any{"error": map[string]any{"code": cserrors.CS_TKT_4002, "message": cserrors.ErrorMsg(cserrors.CS_TKT_4002)}})
return
}
h.auditTicketChange(r.Context(), ticketID, "assign", actorID, map[string]any{"assigned_to": agentID, "status": ticket.StatusAssigned}, r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{"assigned": true})
}
func (h *TicketHandler) Resolve(w http.ResponseWriter, r *http.Request) {
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "/resolve")
resolution := strings.TrimSpace(r.URL.Query().Get("resolution"))
if ticketID == "" || resolution == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4006, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4006)}})
return
}
actor, ok := middleware.ActorFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusForbidden, map[string]any{"error": map[string]any{"code": cserrors.CS_AUTH_4001, "message": cserrors.ErrorMsg(cserrors.CS_AUTH_4001)}})
return
}
actorID := actor.ID
sourceIP := clientIP(r.RemoteAddr)
if err := h.service.Resolve(r.Context(), ticketID, resolution, actorID, sourceIP, h.now()); err != nil {
// P0-2 fix: route error based on error code prefix from service layer
errStr := err.Error()
if strings.HasPrefix(errStr, "CS_TICKET_4001") {
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4001, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4001)}})
return
}
writeJSON(w, http.StatusConflict, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4092, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4092)}})
return
}
h.auditTicketChange(r.Context(), ticketID, "resolve", actorID, map[string]any{"resolution": resolution, "status": ticket.StatusResolved}, r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{"resolved": true})
}
func (h *TicketHandler) Close(w http.ResponseWriter, r *http.Request) {
ticketID := pathParam(r.URL.Path, "/api/v1/customer-service/tickets/", "/close")
resolution := strings.TrimSpace(r.URL.Query().Get("resolution"))
if ticketID == "" || resolution == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"code": cserrors.CS_REQ_4007, "message": cserrors.ErrorMsg(cserrors.CS_REQ_4007)}})
return
}
actor, ok := middleware.ActorFromContext(r.Context())
if !ok {
writeJSON(w, http.StatusForbidden, map[string]any{"error": map[string]any{"code": cserrors.CS_AUTH_4001, "message": cserrors.ErrorMsg(cserrors.CS_AUTH_4001)}})
return
}
actorID := actor.ID
sourceIP := clientIP(r.RemoteAddr)
if err := h.service.Close(r.Context(), ticketID, resolution, actorID, sourceIP, h.now()); err != nil {
// P0-2 fix: route error based on error code prefix from service layer
errStr := err.Error()
if strings.HasPrefix(errStr, "CS_TICKET_4001") {
writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4001, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4001)}})
return
}
writeJSON(w, http.StatusConflict, map[string]any{"error": map[string]any{"code": cserrors.CS_TICKET_4093, "message": cserrors.ErrorMsg(cserrors.CS_TICKET_4093)}})
return
}
h.auditTicketChange(r.Context(), ticketID, "close", actorID, map[string]any{"resolution": resolution, "status": ticket.StatusClosed}, r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{"closed": true})
}
func (h *TicketHandler) auditTicketChange(ctx context.Context, ticketID, action, actorID string, after map[string]any, remoteAddr string) {
if h == nil || h.audit == nil {
return
}
now := h.now()
// P0 quality standard: audit write failure only logs, does not return error
_ = h.audit.Add(ctx, audit.Event{ID: newAuditID("audit", now), Type: "ticket_state_changed", Action: action, TicketID: ticketID, ActorID: actorID, SourceIP: clientIP(remoteAddr), AfterState: after, CreatedAt: now})
}
func pathParam(path, prefix, suffix string) string {
trimmed := strings.TrimPrefix(path, prefix)
trimmed = strings.TrimSuffix(trimmed, suffix)
trimmed = strings.Trim(trimmed, "/")
return trimmed
}