Files
descrybe/apps/api/internal/support/desk.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

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