Files

669 lines
20 KiB
Go
Raw Permalink Normal View History

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