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.
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user