package support import ( "context" "errors" "fmt" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // NormalizeListScope returns a valid staff list scope. func NormalizeListScope(raw string, fullAdmin bool) (string, error) { scope := strings.ToLower(strings.TrimSpace(raw)) if scope == "" { if fullAdmin { return ScopeAll, nil } return ScopeInbox, nil } switch scope { case ScopeInbox, ScopeMine, ScopeUnassigned, ScopeAll: if scope == ScopeAll && !fullAdmin { return "", ErrForbidden } return scope, nil default: return "", ErrInvalidScope } } // applyStaffListScope mutates filter WHERE clauses for queue+claim visibility. func applyStaffListScope(f *ListFilter, args *[]any, where *string) error { scope, err := NormalizeListScope(f.Scope, f.FullAdmin) if err != nil { return err } f.Scope = scope // Legacy shortcut still honored when Scope empty path already normalized. if f.UnassignedOrSelf != nil && scope == ScopeInbox { *args = append(*args, *f.UnassignedOrSelf) *where += fmt.Sprintf(` AND (t.assignee_admin_user_id IS NULL OR t.assignee_admin_user_id = $%d) AND t.status IN ('open','pending')`, len(*args)) return nil } switch scope { case ScopeAll: // platform admin: optional AssigneeID / CompanyID / Status already applied by caller return nil case ScopeInbox: if f.ActorID == uuid.Nil { return ErrForbidden } *args = append(*args, f.ActorID) *where += fmt.Sprintf(` AND (t.assignee_admin_user_id IS NULL OR t.assignee_admin_user_id = $%d) AND t.status IN ('open','pending')`, len(*args)) case ScopeMine: if f.ActorID == uuid.Nil { return ErrForbidden } *args = append(*args, f.ActorID) *where += fmt.Sprintf(` AND t.assignee_admin_user_id = $%d`, len(*args)) case ScopeUnassigned: *where += ` AND t.assignee_admin_user_id IS NULL AND t.status IN ('open','pending')` } // Agents may not filter arbitrary assignee_id (admin-only). if !f.FullAdmin && f.AssigneeID != nil { return ErrForbidden } return nil } func agentCanViewTicket(t Ticket, actor AgentActor) bool { if actor.FullAdmin { return true } if t.AssigneeAdminUserID != nil && *t.AssigneeAdminUserID == actor.UserID { return true } if t.AssigneeAdminUserID == nil && (t.Status == "open" || t.Status == "pending") { return true } return false } // GetAdminForActor loads a ticket with staff visibility rules (404 when hidden). func (s *Service) GetAdminForActor(ctx context.Context, ticketID uuid.UUID, actor AgentActor) (Ticket, error) { t, err := s.GetAdmin(ctx, ticketID) if err != nil { return Ticket{}, err } if !agentCanViewTicket(t, actor) { return Ticket{}, ErrNotFound } return t, nil } // Claim atomically assigns an unassigned open/pending ticket to the actor. func (s *Service) Claim(ctx context.Context, ticketID uuid.UUID, actor AgentActor) (Ticket, error) { tx, err := s.Pool.Begin(ctx) if err != nil { return Ticket{}, err } defer func() { _ = tx.Rollback(ctx) }() var status string var assignee *uuid.UUID err = tx.QueryRow(ctx, ` SELECT status, assignee_admin_user_id FROM support_tickets WHERE id = $1 FOR UPDATE`, ticketID, ).Scan(&status, &assignee) if errors.Is(err, pgx.ErrNoRows) { return Ticket{}, ErrNotFound } if err != nil { return Ticket{}, err } if assignee != nil { if *assignee == actor.UserID { if err := tx.Commit(ctx); err != nil { return Ticket{}, err } return s.GetAdmin(ctx, ticketID) } return Ticket{}, ErrAlreadyClaimed } if status != "open" && status != "pending" { return Ticket{}, ErrNotClaimable } tag, err := tx.Exec(ctx, ` UPDATE support_tickets SET assignee_admin_user_id = $2, updated_at = now() WHERE id = $1 AND assignee_admin_user_id IS NULL AND status IN ('open', 'pending')`, ticketID, actor.UserID) if err != nil { return Ticket{}, err } if tag.RowsAffected() == 0 { return Ticket{}, ErrAlreadyClaimed } // Optional notify — failed kind CHECK must not abort the claim transaction. if _, spErr := tx.Exec(ctx, `SAVEPOINT support_claim_notify`); spErr == nil { if nerr := insertNotification(ctx, tx, actor.UserID, ticketID, nil, "ticket_claimed"); nerr != nil { _, _ = tx.Exec(ctx, `ROLLBACK TO SAVEPOINT support_claim_notify`) } else { _, _ = tx.Exec(ctx, `RELEASE SAVEPOINT support_claim_notify`) } } if err := tx.Commit(ctx); err != nil { return Ticket{}, err } return s.GetAdmin(ctx, ticketID) } // Release clears assignee when the actor owns the ticket (or is full admin). func (s *Service) Release(ctx context.Context, ticketID uuid.UUID, actor AgentActor) (Ticket, error) { tx, err := s.Pool.Begin(ctx) if err != nil { return Ticket{}, err } defer func() { _ = tx.Rollback(ctx) }() var assignee *uuid.UUID err = tx.QueryRow(ctx, ` SELECT assignee_admin_user_id FROM support_tickets WHERE id = $1 FOR UPDATE`, ticketID, ).Scan(&assignee) if errors.Is(err, pgx.ErrNoRows) { return Ticket{}, ErrNotFound } if err != nil { return Ticket{}, err } if assignee == nil { if err := tx.Commit(ctx); err != nil { return Ticket{}, err } return s.GetAdmin(ctx, ticketID) } if !actor.FullAdmin && *assignee != actor.UserID { return Ticket{}, ErrForbidden } _, err = tx.Exec(ctx, ` UPDATE support_tickets SET assignee_admin_user_id = NULL, updated_at = now() WHERE id = $1`, ticketID) if err != nil { return Ticket{}, err } if err := tx.Commit(ctx); err != nil { return Ticket{}, err } return s.GetAdmin(ctx, ticketID) }