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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+266
View File
@@ -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
}