Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
267 lines
7.1 KiB
Go
267 lines
7.1 KiB
Go
package support
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
// Activity kinds for support_ticket_activity.
|
|
const (
|
|
ActivityCreated = "created"
|
|
ActivityCustomerMessage = "customer_message"
|
|
ActivityAgentMessage = "agent_message"
|
|
ActivitySystemMessage = "system_message"
|
|
ActivityAutoReply = "auto_reply"
|
|
ActivityAIDraft = "ai_draft"
|
|
ActivityAISent = "ai_sent"
|
|
ActivityAIFailed = "ai_failed"
|
|
ActivityHandedOff = "handed_off"
|
|
ActivityClaimed = "claimed"
|
|
ActivityReleased = "released"
|
|
ActivityStatusChanged = "status_changed"
|
|
ActivityAutoDisabled = "auto_disabled"
|
|
ActivityAutoEnabled = "auto_enabled"
|
|
ActivityNote = "note"
|
|
)
|
|
|
|
func insertActivity(
|
|
ctx context.Context,
|
|
tx pgx.Tx,
|
|
ticketID, companyID uuid.UUID,
|
|
kind, actorRole string,
|
|
actorUserID, messageID *uuid.UUID,
|
|
meta json.RawMessage,
|
|
) error {
|
|
if len(meta) == 0 {
|
|
meta = json.RawMessage(`{}`)
|
|
}
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO support_ticket_activity (
|
|
ticket_id, company_id, kind, actor_role, actor_user_id, message_id, metadata
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
|
ticketID, companyID, kind, actorRole, actorUserID, messageID, meta,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *Service) listActivity(ctx context.Context, ticketID uuid.UUID) ([]ActivityEvent, error) {
|
|
rows, err := s.Pool.Query(ctx, `
|
|
SELECT id, ticket_id, company_id, kind, actor_role, actor_user_id, message_id,
|
|
COALESCE(metadata, '{}'::jsonb), created_at
|
|
FROM support_ticket_activity
|
|
WHERE ticket_id = $1
|
|
ORDER BY created_at ASC`, ticketID)
|
|
if err != nil {
|
|
if IsMissingRelation(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]ActivityEvent, 0)
|
|
for rows.Next() {
|
|
var e ActivityEvent
|
|
var meta []byte
|
|
if err := rows.Scan(
|
|
&e.ID, &e.TicketID, &e.CompanyID, &e.Kind, &e.ActorRole,
|
|
&e.ActorUserID, &e.MessageID, &meta, &e.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
e.Metadata = json.RawMessage(meta)
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RecordAutoReplyOutcome updates ticket auto-reply fields and appends a timeline event.
|
|
// Used by FAQ match (agent 3) and AI fallback (agent 4). Safe no-op if detail migration missing.
|
|
func (s *Service) RecordAutoReplyOutcome(
|
|
ctx context.Context,
|
|
ticketID uuid.UUID,
|
|
status string,
|
|
disabled bool,
|
|
messageID *uuid.UUID,
|
|
meta json.RawMessage,
|
|
activityKind string,
|
|
) error {
|
|
switch status {
|
|
case AutoReplyNone, AutoReplyMatched, AutoReplyAIDraft, AutoReplyAISent,
|
|
AutoReplySkipped, AutoReplyFailed, AutoReplyHandedOff:
|
|
default:
|
|
return ErrInvalidStatus
|
|
}
|
|
if activityKind == "" {
|
|
activityKind = ActivityAutoReply
|
|
}
|
|
if len(meta) == 0 {
|
|
meta = json.RawMessage(`{}`)
|
|
}
|
|
|
|
tx, err := s.Pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var companyID uuid.UUID
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT company_id FROM support_tickets WHERE id = $1 FOR UPDATE`, ticketID,
|
|
).Scan(&companyID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE support_tickets SET
|
|
auto_reply_status = $2,
|
|
auto_reply_disabled = $3,
|
|
auto_reply_attempted_at = $4,
|
|
auto_reply_message_id = COALESCE($5, auto_reply_message_id),
|
|
auto_reply_meta = COALESCE($6::jsonb, auto_reply_meta),
|
|
updated_at = $4
|
|
WHERE id = $1`,
|
|
ticketID, status, disabled, now, messageID, meta,
|
|
)
|
|
if err != nil {
|
|
if IsMissingRelation(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
actorRole := "system"
|
|
if activityKind == ActivityAIDraft || activityKind == ActivityAISent || activityKind == ActivityAIFailed {
|
|
actorRole = "ai"
|
|
}
|
|
if err := insertActivity(ctx, tx, ticketID, companyID, activityKind, actorRole, nil, messageID, meta); err != nil {
|
|
if IsMissingRelation(err) {
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// captureCustomerContext builds a denormalized snapshot at ticket create (no secrets).
|
|
func (s *Service) captureCustomerContext(ctx context.Context, companyID, userID uuid.UUID, relatedProductID *uuid.UUID, relatedSKU string) json.RawMessage {
|
|
snap := map[string]any{
|
|
"captured_at": time.Now().UTC().Format(time.RFC3339),
|
|
"company_id": companyID.String(),
|
|
"user_id": userID.String(),
|
|
}
|
|
if s == nil || s.Pool == nil {
|
|
b, _ := json.Marshal(snap)
|
|
return b
|
|
}
|
|
|
|
var companyName, userEmail, userName, planSlug *string
|
|
_ = s.Pool.QueryRow(ctx, `SELECT name FROM companies WHERE id = $1`, companyID).Scan(&companyName)
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
SELECT email, NULLIF(trim(COALESCE(name, '')), '') FROM users WHERE id = $1`, userID,
|
|
).Scan(&userEmail, &userName)
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
SELECT p.name FROM company_plans cp
|
|
JOIN plans p ON p.id = cp.plan_id
|
|
WHERE cp.company_id = $1 AND cp.is_active = true
|
|
ORDER BY cp.created_at DESC LIMIT 1`, companyID,
|
|
).Scan(&planSlug)
|
|
|
|
if companyName != nil {
|
|
snap["company_name"] = *companyName
|
|
}
|
|
if userEmail != nil {
|
|
snap["user_email"] = *userEmail
|
|
}
|
|
if userName != nil {
|
|
snap["user_name"] = *userName
|
|
}
|
|
if planSlug != nil {
|
|
snap["plan_slug"] = *planSlug
|
|
}
|
|
|
|
related := map[string]any{}
|
|
if relatedProductID != nil {
|
|
related["id"] = relatedProductID.String()
|
|
var title, sku *string
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
SELECT NULLIF(trim(COALESCE(processed_name, name, '')), ''),
|
|
NULLIF(trim(COALESCE(product_id, '')), '')
|
|
FROM processed_products
|
|
WHERE id = $1 AND company_id = $2`, *relatedProductID, companyID,
|
|
).Scan(&title, &sku)
|
|
if title != nil {
|
|
related["title"] = *title
|
|
}
|
|
if sku != nil {
|
|
related["sku"] = *sku
|
|
}
|
|
}
|
|
if relatedSKU != "" {
|
|
related["sku"] = relatedSKU
|
|
}
|
|
if len(related) > 0 {
|
|
snap["related_product"] = related
|
|
}
|
|
|
|
var openCount int64
|
|
var lastCat *string
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
SELECT count(*) FROM support_tickets
|
|
WHERE company_id = $1 AND created_by_user_id = $2 AND status IN ('open','pending')`,
|
|
companyID, userID,
|
|
).Scan(&openCount)
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
SELECT category FROM support_tickets
|
|
WHERE company_id = $1 AND created_by_user_id = $2
|
|
ORDER BY created_at DESC LIMIT 1`, companyID, userID,
|
|
).Scan(&lastCat)
|
|
signals := map[string]any{"open_ticket_count": openCount}
|
|
if lastCat != nil {
|
|
signals["last_ticket_category"] = *lastCat
|
|
}
|
|
snap["signals"] = signals
|
|
|
|
b, err := json.Marshal(snap)
|
|
if err != nil || len(b) > 4096 {
|
|
// Drop signals if over budget.
|
|
delete(snap, "signals")
|
|
b, _ = json.Marshal(snap)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ensureRelatedProductInCompany validates optional product FK stays tenant-scoped.
|
|
func (s *Service) ensureRelatedProductInCompany(ctx context.Context, companyID uuid.UUID, productID *uuid.UUID) error {
|
|
if productID == nil {
|
|
return nil
|
|
}
|
|
if s == nil || s.Pool == nil {
|
|
return ErrInvalidRelatedProduct
|
|
}
|
|
var ok bool
|
|
err := s.Pool.QueryRow(ctx, `
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM processed_products WHERE id = $1 AND company_id = $2
|
|
)`, *productID, companyID,
|
|
).Scan(&ok)
|
|
if err != nil {
|
|
if IsMissingRelation(err) {
|
|
return ErrInvalidRelatedProduct
|
|
}
|
|
return err
|
|
}
|
|
if !ok {
|
|
return ErrInvalidRelatedProduct
|
|
}
|
|
return nil
|
|
}
|