package httpapi import ( "errors" "net/http" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/support" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) func (s *Server) handleListSupportTickets(w http.ResponseWriter, r *http.Request) { if s.Support == nil { JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": 0, "offset": 0}) return } cid, ok := CompanyIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } limit, offset := ParseLimitOffset(r) status := strings.TrimSpace(r.URL.Query().Get("status")) items, total, err := s.Support.ListForUser(r.Context(), cid, uid, status, limit, offset) if err != nil { if support.IsMissingRelation(err) { JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": limit, "offset": offset}) return } ClientOrLog(w, http.StatusBadRequest, "could not list tickets", err, support.ClientError) return } if items == nil { items = []support.Ticket{} } JSON(w, http.StatusOK, map[string]any{"tickets": items, "total": total, "limit": limit, "offset": offset}) } func (s *Server) handleCreateSupportTicket(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } cid, ok := CompanyIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } var body support.CreateInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Support.Create(r.Context(), cid, uid, body) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not create ticket", err, support.ClientError) return } // Stage A FAQ match (sync). Never awaits LLM — agent 4 owns AI fallback. if updated, _, matchErr := s.Support.MaybeAutoReplyOnCreate(r.Context(), item); matchErr == nil { item = updated } JSON(w, http.StatusCreated, item) } func (s *Server) handleGetSupportTicket(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } cid, ok := CompanyIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } item, err := s.Support.GetForUser(r.Context(), cid, uid, id) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { LogAndError(w, http.StatusInternalServerError, "get failed", err) return } JSON(w, http.StatusOK, item) } func (s *Server) handleReplySupportTicket(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } cid, ok := CompanyIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } // Mass-assignment guard: customers cannot set is_internal_note / status. var body support.UserReplyInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Support.ReplyAsUser(r.Context(), cid, uid, id, support.ReplyInput{Body: body.Body}) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not reply", err, support.ClientError) return } if updated, _, matchErr := s.Support.MaybeAutoReplyOnCustomerReply(r.Context(), item); matchErr == nil { item = updated } JSON(w, http.StatusOK, item) } func (s *Server) handleAdminListSupportTickets(w http.ResponseWriter, r *http.Request) { if s.Support == nil { JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": 0, "offset": 0}) return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } access, _ := StaffAccessFromContext(r.Context()) limit, offset := ParseLimitOffset(r) f := support.ListFilter{ Status: strings.TrimSpace(r.URL.Query().Get("status")), Search: QuerySearch(r), Scope: strings.TrimSpace(r.URL.Query().Get("scope")), Flag: strings.ToLower(strings.TrimSpace(r.URL.Query().Get("flag"))), ActorID: uid, FullAdmin: access.FullAdmin, } if f.Flag != "" && f.Flag != support.FlagNeedsHuman && f.Flag != support.FlagAIDraft { Error(w, http.StatusBadRequest, "invalid flag") return } if raw := strings.TrimSpace(r.URL.Query().Get("company_id")); raw != "" { cid, err := uuid.Parse(raw) if err != nil { Error(w, http.StatusBadRequest, "invalid company_id") return } f.CompanyID = &cid } if access.FullAdmin { if raw := strings.TrimSpace(r.URL.Query().Get("assignee_id")); raw != "" { aid, err := uuid.Parse(raw) if err != nil { Error(w, http.StatusBadRequest, "invalid assignee_id") return } f.AssigneeID = &aid } } items, total, err := s.Support.ListAdmin(r.Context(), f, limit, offset) if err != nil { if errors.Is(err, support.ErrForbidden) { Error(w, http.StatusForbidden, "forbidden") return } if support.IsMissingRelation(err) { JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": limit, "offset": offset}) return } ClientOrLog(w, http.StatusBadRequest, "could not list tickets", err, support.ClientError) return } if items == nil { items = []support.Ticket{} } JSON(w, http.StatusOK, map[string]any{"tickets": items, "total": total, "limit": limit, "offset": offset, "scope": f.Scope}) } func (s *Server) handleAdminGetSupportTicket(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } item, err := s.Support.GetAdmin(r.Context(), id) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { LogAndError(w, http.StatusInternalServerError, "get failed", err) return } if !s.staffMayAccessTicket(r, uid, item) { // Anti-enumeration: same as missing for support_staff. Error(w, http.StatusNotFound, "not found") return } JSON(w, http.StatusOK, item) } func (s *Server) handleAdminReplySupportTicket(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } existing, err := s.Support.GetAdmin(r.Context(), id) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { LogAndError(w, http.StatusInternalServerError, "get failed", err) return } if !s.staffMayAccessTicket(r, uid, existing) { access, _ := StaffAccessFromContext(r.Context()) if access.IsSupportOnly && existing.AssigneeAdminUserID != nil && *existing.AssigneeAdminUserID != uid { CodedError(w, http.StatusConflict, "already_claimed", "assigned to another agent") return } Error(w, http.StatusNotFound, "not found") return } var body support.ReplyInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Support.ReplyAsAgent(r.Context(), uid, id, body) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not reply", err, support.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleAdminUpdateSupportTicket(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } existing, err := s.Support.GetAdmin(r.Context(), id) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { LogAndError(w, http.StatusInternalServerError, "get failed", err) return } if !s.staffMayAccessTicket(r, uid, existing) { Error(w, http.StatusNotFound, "not found") return } var body support.AdminUpdateInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } // Mass-assignment: only allowlisted fields; validate assignee is support-capable. if body.AssigneeAdminUserID != nil && !body.ClearAssignee { if *body.AssigneeAdminUserID == uuid.Nil { Error(w, http.StatusBadRequest, "invalid assignee") return } access, _ := StaffAccessFromContext(r.Context()) if access.IsSupportOnly && *body.AssigneeAdminUserID != uid { // support_staff may only claim for self (or clear). Error(w, http.StatusForbidden, "cannot assign to other staff") return } ok, err := s.assigneeIsSupportCapable(r, *body.AssigneeAdminUserID) if err != nil { LogAndError(w, http.StatusInternalServerError, "authorization check failed", err) return } if !ok { Error(w, http.StatusBadRequest, "invalid assignee") return } } item, err := s.Support.UpdateAdmin(r.Context(), id, uid, body) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update ticket", err, support.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) staffActor(r *http.Request, uid uuid.UUID) support.AgentActor { access, _ := StaffAccessFromContext(r.Context()) return support.AgentActor{UserID: uid, FullAdmin: access.FullAdmin} } func (s *Server) handleAdminClaimSupportTicket(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } item, err := s.Support.Claim(r.Context(), id, s.staffActor(r, uid)) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if errors.Is(err, support.ErrAlreadyClaimed) || errors.Is(err, support.ErrNotClaimable) { Error(w, http.StatusConflict, err.Error()) return } if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not claim ticket", err, support.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleAdminReleaseSupportTicket(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } item, err := s.Support.Release(r.Context(), id, s.staffActor(r, uid)) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if errors.Is(err, support.ErrForbidden) { Error(w, http.StatusForbidden, "forbidden") return } if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not release ticket", err, support.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleAdminApproveSupportAIDraft(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } existing, err := s.Support.GetAdmin(r.Context(), id) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { LogAndError(w, http.StatusInternalServerError, "get failed", err) return } if !s.staffMayAccessTicket(r, uid, existing) { access, _ := StaffAccessFromContext(r.Context()) if access.IsSupportOnly && existing.AssigneeAdminUserID != nil && *existing.AssigneeAdminUserID != uid { CodedError(w, http.StatusConflict, "already_claimed", "assigned to another agent") return } Error(w, http.StatusNotFound, "not found") return } var body support.ApproveAIDraftInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Support.ApproveAIDraft(r.Context(), uid, id, body) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if errors.Is(err, support.ErrNoAIDraft) { Error(w, http.StatusConflict, "no AI draft to approve") return } if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not approve AI draft", err, support.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleAdminDiscardSupportAIDraft(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } existing, err := s.Support.GetAdmin(r.Context(), id) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { LogAndError(w, http.StatusInternalServerError, "get failed", err) return } if !s.staffMayAccessTicket(r, uid, existing) { access, _ := StaffAccessFromContext(r.Context()) if access.IsSupportOnly && existing.AssigneeAdminUserID != nil && *existing.AssigneeAdminUserID != uid { CodedError(w, http.StatusConflict, "already_claimed", "assigned to another agent") return } Error(w, http.StatusNotFound, "not found") return } item, err := s.Support.DiscardAIDraft(r.Context(), uid, id) if errors.Is(err, support.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if errors.Is(err, support.ErrNoAIDraft) { Error(w, http.StatusConflict, "no AI draft to discard") return } if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not discard AI draft", err, support.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleAdminListSupportAgents(w http.ResponseWriter, r *http.Request) { if s.Support == nil { JSON(w, http.StatusOK, map[string]any{"agents": []any{}, "total": 0}) return } limit, offset := ParseLimitOffset(r) includeAdmins := !QueryTruthy(r, "agents_only") items, total, err := s.Support.ListAgents(r.Context(), includeAdmins, limit, offset) if err != nil { LogAndError(w, http.StatusInternalServerError, "list failed", err) return } if items == nil { items = []support.SupportAgent{} } JSON(w, http.StatusOK, map[string]any{"agents": items, "total": total, "limit": limit, "offset": offset}) } // staffMayAccessTicket enforces least-privilege visibility for support_staff. func (s *Server) staffMayAccessTicket(r *http.Request, actor uuid.UUID, t support.Ticket) bool { access, ok := StaffAccessFromContext(r.Context()) if !ok { return false } if access.FullAdmin { return true } if !access.SupportDesk { return false } if t.AssigneeAdminUserID != nil { return *t.AssigneeAdminUserID == actor } // Unassigned queue: claimable open/pending only. return t.Status == "open" || t.Status == "pending" } func (s *Server) assigneeIsSupportCapable(r *http.Request, assignee uuid.UUID) (bool, error) { if s.testStaffAccess != nil { access, err := s.testStaffAccess(r.Context(), assignee) if err != nil { return false, err } return access.SupportDesk, nil } if s.Auth == nil { return false, nil } return s.Auth.IsAssignableSupportStaff(r.Context(), assignee) } func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) { if s.Support == nil { JSON(w, http.StatusOK, map[string]any{"notifications": []any{}, "total": 0, "unread": 0, "limit": 0, "offset": 0}) return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } limit, offset := ParseLimitOffset(r) unreadOnly := QueryTruthy(r, "unread") items, total, err := s.Support.ListNotifications(r.Context(), uid, unreadOnly, limit, offset) if err != nil { if support.IsMissingRelation(err) { JSON(w, http.StatusOK, map[string]any{"notifications": []any{}, "total": 0, "unread": 0, "limit": limit, "offset": offset}) return } LogAndError(w, http.StatusInternalServerError, "list failed", err) return } unread, err := s.Support.UnreadNotificationCount(r.Context(), uid) if err != nil { if support.IsMissingRelation(err) { unread = 0 } else { LogAndError(w, http.StatusInternalServerError, "list failed", err) return } } if items == nil { items = []support.Notification{} } JSON(w, http.StatusOK, map[string]any{ "notifications": items, "total": total, "unread": unread, "limit": limit, "offset": offset, }) } func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } if err := s.Support.MarkNotificationRead(r.Context(), uid, id); errors.Is(err, support.ErrNotificationGone) { Error(w, http.StatusNotFound, "not found") return } else if err != nil { LogAndError(w, http.StatusInternalServerError, "update failed", err) return } JSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) { if s.Support == nil { Error(w, http.StatusServiceUnavailable, "support unavailable") return } uid, ok := UserIDFromContext(r.Context()) if !ok { Error(w, http.StatusUnauthorized, "unauthorized") return } n, err := s.Support.MarkAllNotificationsRead(r.Context(), uid) if err != nil { if support.IsMissingRelation(err) { JSON(w, http.StatusOK, map[string]any{"status": "ok", "updated": 0}) return } LogAndError(w, http.StatusInternalServerError, "update failed", err) return } JSON(w, http.StatusOK, map[string]any{"status": "ok", "updated": n}) }