197 lines
5.4 KiB
Go
197 lines
5.4 KiB
Go
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)
|
||
|
|
}
|