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 }