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
@@ -0,0 +1,817 @@
package httpapi
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/jobs"
"github.com/descrybe/descrybe-v2/apps/api/internal/metrics"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
const (
adminDiagnosticsTimeout = 3 * time.Second
adminDiagnosticsDefaultFails = 25
adminDiagnosticsMaxFails = 50
adminDiagnosticsStuckAfter = 2 * time.Hour
adminDiagnosticsAIFailDefault = 15
adminDiagnosticsAIFailMax = 30
// Schema head expected by cutover-deploy-check (goose 039042).
adminDiagnosticsGooseExpectedMin = int64(42)
)
// Required goose versions for cutover readiness (match scripts/cutover-deploy-check.mjs).
var adminDiagnosticsGooseRequired = []struct {
ID int64
Name string
}{
{39, "039_worker_heartbeats"},
{40, "040_job_hotpath_indexes"},
{41, "041_password_reset_tokens"},
{42, "042_user_session_version"},
}
// handleAdminDiagnostics returns operational health for platform admins.
// GET /api/admin/diagnostics?failures_limit=25&status=failed
// Never exposes secrets, DSNs, API keys, or passwords.
func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), adminDiagnosticsTimeout)
defer cancel()
failLimit := adminDiagnosticsDefaultFails
if raw := strings.TrimSpace(r.URL.Query().Get("failures_limit")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
failLimit = n
}
}
if failLimit > adminDiagnosticsMaxFails {
failLimit = adminDiagnosticsMaxFails
}
statusFilter := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("status")))
switch statusFilter {
case "", "all", "failed", "running", "pending", "completed", "cancelled":
default:
Error(w, http.StatusBadRequest, "invalid status filter")
return
}
if statusFilter == "all" {
statusFilter = ""
}
checks := make([]map[string]any, 0, 7)
overall := "ok"
stripeCfg := s.resolveStripeDiagCfg(ctx)
dbCheck, dbOK := s.diagDatabase(ctx)
checks = append(checks, dbCheck)
if !dbOK {
overall = "fail"
}
queueCheck, queueSummary, queueOK := s.diagQueue(ctx)
checks = append(checks, queueCheck)
if !queueOK && overall != "fail" {
overall = "degraded"
}
cacheCheck := s.diagCache()
checks = append(checks, cacheCheck)
storageCheck, storageOK := s.diagStorage()
checks = append(checks, storageCheck)
if !storageOK && overall != "fail" {
overall = "degraded"
}
mailCheck := s.diagMail()
checks = append(checks, mailCheck)
stripeCheck, stripeOK := diagStripeReadiness(s.Config.IsProduction(), stripeCfg)
checks = append(checks, stripeCheck)
if !stripeOK && overall == "ok" {
overall = "degraded"
}
if stripeCheck["status"] == "fail" {
overall = "fail"
}
cutover := s.diagCutoverReadiness(ctx)
cutoverCheck := map[string]any{
"name": "cutover",
"status": cutover["status"],
}
if detail, ok := cutover["detail"].(string); ok && detail != "" {
cutoverCheck["detail"] = detail
}
checks = append(checks, cutoverCheck)
if st, _ := cutover["status"].(string); st == "warn" && overall == "ok" {
overall = "degraded"
}
if st, _ := cutover["status"].(string); st == "fail" {
overall = "fail"
}
failures, failErr := s.diagRecentJobFailures(ctx, failLimit, statusFilter)
if failErr != nil && overall == "ok" {
overall = "degraded"
}
if statusFilter == "" || statusFilter == "failed" {
if n, ok := queueSummary["failed"].(int64); ok && n > 0 && overall == "ok" {
overall = "degraded"
}
if n, ok := queueSummary["stuck_running"].(int64); ok && n > 0 && overall == "ok" {
overall = "degraded"
}
}
aiFails, _ := s.diagRecentAIFailures(ctx, adminDiagnosticsAIFailDefault)
migrationInventory := s.diagMigrationInventory(ctx)
JSON(w, http.StatusOK, map[string]any{
"status": overall,
"generated_at": time.Now().UTC().Format(time.RFC3339),
"checks": checks,
"queue": queueSummary,
"cutover": cutover,
"migration_inventory": migrationInventory,
"config": s.diagConfigSanity(stripeCfg),
"runtime_metrics": metrics.Snapshot(),
"recent_failures": failures,
"recent_ai_failures": aiFails,
"filters": map[string]any{
"status": statusFilter,
"failures_limit": failLimit,
},
"links": map[string]string{
"stuck_products": "/admin/stuck-products",
"orphan_processed": "/admin/orphan-processed",
"tasks_cleanup": "/admin/tasks-cleanup",
"logs": "/admin/logs",
"bootstrap": "/admin/bootstrap",
"analytics": "/admin/analytics",
"metrics": "/metrics",
"readiness": "/api/admin/readiness",
},
"notes": []string{
"Diagnostics is for troubleshooting, not marketing analytics.",
"Secrets, passwords, and API keys are never included.",
"/admin/logs redirects here; stuck cleanup lives under Stuck products.",
"Orphan processed: /admin/orphan-processed (dry-run report → confirm delete). API: GET/POST /api/admin/jobs/orphan-processed(-cleanup); POST needs confirm=true.",
"Prometheus scrape: GET /metrics (HTTP RED). In production: loopback only unless METRICS_PUBLIC=1. Worker sync series need METRICS_ADDR on the worker process.",
"Cutover block: goose version hints + worker age + companies_without_plan + companies_without_api_keys (reissue inventory; presence/counts only; no live Stripe/SMTP; no fake key migration).",
"migration_inventory: read-only COUNT of metadata-only files + jobs/history tags — not an import path; blob bytes and default job history stay unmigrated unless ops ran optional domain jobs.",
},
})
}
func (s *Server) diagDatabase(ctx context.Context) (check map[string]any, ok bool) {
start := time.Now()
var pinger dbPinger
if s.Pool != nil {
pinger = s.Pool
}
ready, status, errMsg := databaseReady(ctx, pinger)
check = map[string]any{
"name": "database",
"status": status,
"latency_ms": time.Since(start).Milliseconds(),
}
if !ready {
check["status"] = "fail"
if errMsg != "" {
check["detail"] = errMsg
}
return check, false
}
check["status"] = "ok"
check["detail"] = "ping ok"
return check, true
}
func (s *Server) diagQueue(ctx context.Context) (check map[string]any, summary map[string]any, ok bool) {
summary = map[string]any{
"by_status": map[string]int64{},
"stuck_running": int64(0),
"driver": "postgres_processing_jobs",
}
check = map[string]any{
"name": "queue",
"status": "ok",
"detail": "processing_jobs poller (SKIP LOCKED)",
}
if s.Pool == nil {
check["status"] = "fail"
check["detail"] = "database pool unavailable"
return check, summary, false
}
start := time.Now()
rows, err := s.Pool.Query(ctx, `
SELECT status, COUNT(*)::bigint
FROM processing_jobs
GROUP BY status`)
if err != nil {
check["status"] = "fail"
check["detail"] = "queue status query failed"
check["latency_ms"] = time.Since(start).Milliseconds()
return check, summary, false
}
defer rows.Close()
byStatus := map[string]int64{}
var total int64
for rows.Next() {
var st string
var n int64
if err := rows.Scan(&st, &n); err != nil {
check["status"] = "fail"
check["detail"] = "queue status scan failed"
check["latency_ms"] = time.Since(start).Milliseconds()
return check, summary, false
}
byStatus[st] = n
total += n
summary[st] = n
}
if err := rows.Err(); err != nil {
check["status"] = "fail"
check["detail"] = "queue status rows failed"
check["latency_ms"] = time.Since(start).Milliseconds()
return check, summary, false
}
summary["by_status"] = byStatus
summary["total"] = total
var stuck int64
_ = s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::bigint FROM processing_jobs
WHERE status = 'running'
AND updated_at < now() - make_interval(secs => $1)`,
adminDiagnosticsStuckAfter.Seconds(),
).Scan(&stuck)
summary["stuck_running"] = stuck
check["latency_ms"] = time.Since(start).Milliseconds()
if stuck > 0 {
check["status"] = "warn"
check["detail"] = "stuck running jobs detected"
return check, summary, false
}
return check, summary, true
}
func (s *Server) diagCache() map[string]any {
// No Redis/memcached in this stack — support KB uses process-local cache only.
return map[string]any{
"name": "cache",
"status": "ok",
"detail": "in-process only (no external cache)",
}
}
func (s *Server) diagStorage() (check map[string]any, ok bool) {
check = map[string]any{
"name": "storage",
"status": "ok",
}
dir := strings.TrimSpace(s.Config.UploadDir)
if dir == "" {
check["status"] = "warn"
check["detail"] = "upload dir not configured"
return check, false
}
abs, err := filepath.Abs(dir)
if err != nil {
check["status"] = "fail"
check["detail"] = "upload dir path invalid"
return check, false
}
info, err := os.Stat(abs)
if err != nil {
check["status"] = "fail"
if os.IsNotExist(err) {
check["detail"] = "upload dir missing"
} else {
check["detail"] = "upload dir unavailable"
}
return check, false
}
if !info.IsDir() {
check["status"] = "fail"
check["detail"] = "upload path is not a directory"
return check, false
}
probe := filepath.Join(abs, ".diag_write_probe")
if err := os.WriteFile(probe, []byte("ok"), 0o600); err != nil {
check["status"] = "fail"
check["detail"] = "upload dir not writable"
return check, false
}
_ = os.Remove(probe)
// Never return absolute path (may leak host layout); only configured relative name.
check["detail"] = "upload dir writable"
check["configured"] = true
return check, true
}
func (s *Server) diagMail() map[string]any {
enabled := s.Config.SMTPEnabled
if s.Mail != nil {
enabled = s.Mail.Enabled()
}
dryRun := s.Config.EmailDryRun
hostSet := strings.TrimSpace(s.Config.SMTPHost) != ""
status := "ok"
detail := "smtp disabled (noop)"
switch {
case !enabled:
detail = "smtp disabled (noop)"
case dryRun && hostSet:
detail = "smtp enabled; dry-run; host set"
case dryRun && !hostSet:
detail = "smtp enabled; dry-run; host not set"
status = "warn"
case hostSet:
detail = "smtp enabled; host set"
default:
detail = "smtp enabled; host not set"
status = "warn"
}
// Presence flags only — never host hostname or credentials.
return map[string]any{
"name": "mail",
"status": status,
"detail": detail,
"enabled": enabled,
"dry_run": dryRun,
"host_set": hostSet,
}
}
// diagCutoverReadiness reports deploy/cutover presence signals for platform admins.
// Goose version hints + worker heartbeat age + cheap companies_without_plan /
// companies_without_api_keys counts (reissue inventory; no fake key migration).
// Never runs live Stripe charges or SMTP sends; never returns secrets/DSNs.
func (s *Server) diagCutoverReadiness(ctx context.Context) map[string]any {
goose := s.diagGooseVersionHints(ctx)
worker := s.diagWorkerAge(ctx)
out := map[string]any{
"status": "ok",
"detail": "cutover presence ok",
"goose": goose,
"worker": worker,
}
if n, ok := s.diagCompaniesWithoutPlan(ctx); ok {
out["companies_without_plan"] = n
}
if n, ok := s.diagCompaniesWithoutAPIKeys(ctx); ok {
out["companies_without_api_keys"] = n
}
status := "ok"
detail := "cutover presence ok"
gooseStatus, _ := goose["status"].(string)
workerStatus, _ := worker["status"].(string)
switch {
case gooseStatus == "fail" || workerStatus == "fail":
status = "fail"
detail = "cutover probe failed"
case gooseStatus == "warn" || gooseStatus == "skip":
status = "warn"
if d, ok := goose["detail"].(string); ok && d != "" {
detail = d
} else {
detail = "goose version hints incomplete"
}
case workerStatus == "missing" || workerStatus == "stale" || workerStatus == "unavailable":
status = "warn"
if d, ok := worker["detail"].(string); ok && d != "" {
detail = d
} else {
detail = "worker heartbeat not fresh"
}
}
out["status"] = status
out["detail"] = detail
return out
}
func (s *Server) diagGooseVersionHints(ctx context.Context) map[string]any {
required := make(map[string]bool, len(adminDiagnosticsGooseRequired))
ids := make([]int64, 0, len(adminDiagnosticsGooseRequired))
idToName := make(map[int64]string, len(adminDiagnosticsGooseRequired))
for _, m := range adminDiagnosticsGooseRequired {
required[m.Name] = false
ids = append(ids, m.ID)
idToName[m.ID] = m.Name
}
out := map[string]any{
"status": "skip",
"detail": "database unavailable",
"required": required,
"expected_min": adminDiagnosticsGooseExpectedMin,
}
if s.Pool == nil {
return out
}
var versionMax int64
if err := s.Pool.QueryRow(ctx, `
SELECT COALESCE(MAX(version_id), 0)::bigint
FROM goose_db_version
WHERE is_applied = true`).Scan(&versionMax); err != nil {
out["status"] = "warn"
out["detail"] = "goose version query unavailable"
return out
}
out["version_max"] = versionMax
rows, err := s.Pool.Query(ctx, `
SELECT version_id::bigint
FROM goose_db_version
WHERE is_applied = true AND version_id = ANY($1)`, ids)
if err != nil {
out["status"] = "warn"
out["detail"] = "goose required migration query unavailable"
return out
}
defer rows.Close()
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
out["status"] = "warn"
out["detail"] = "goose required migration scan failed"
return out
}
if name, ok := idToName[id]; ok {
required[name] = true
}
}
if err := rows.Err(); err != nil {
out["status"] = "warn"
out["detail"] = "goose required migration rows failed"
return out
}
out["required"] = required
allApplied := true
for _, m := range adminDiagnosticsGooseRequired {
if !required[m.Name] {
allApplied = false
break
}
}
if !allApplied {
out["status"] = "warn"
out["detail"] = "required cutover migrations missing"
return out
}
if versionMax < adminDiagnosticsGooseExpectedMin {
out["status"] = "warn"
out["detail"] = "schema behind expected head"
return out
}
out["status"] = "ok"
out["detail"] = "required migrations applied"
return out
}
func (s *Server) diagWorkerAge(ctx context.Context) map[string]any {
staleAfterS := int64(jobs.DefaultHeartbeatStaleAfter / time.Second)
out := map[string]any{
"status": "unavailable",
"detail": "worker probe unavailable",
"stale_after_s": staleAfterS,
}
var prober jobs.HeartbeatQuerier
if s.Pool != nil {
prober = s.Pool
}
probe := jobs.ProbeWorkerReadiness(ctx, prober, jobs.DefaultHeartbeatStaleAfter)
out["status"] = probe.WorkerCheck
if probe.LastSeenAgeS >= 0 {
out["last_seen_age_s"] = probe.LastSeenAgeS
}
if probe.Reason != "" {
out["reason"] = probe.Reason
}
switch probe.WorkerCheck {
case "ok":
out["detail"] = "worker heartbeat fresh"
case "missing":
out["detail"] = "worker heartbeat missing"
case "stale":
out["detail"] = "worker heartbeat stale"
case "fail":
out["detail"] = "worker heartbeat query failed"
default:
out["detail"] = "worker probe unavailable"
}
return out
}
// diagCompaniesWithoutPlan is the cheap cutover hypercare count (same shape as /api/admin/readiness).
func (s *Server) diagCompaniesWithoutPlan(ctx context.Context) (count int64, ok bool) {
if s.Pool == nil {
return 0, false
}
err := s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::bigint FROM companies c
WHERE c.id <> $1
AND NOT EXISTS (
SELECT 1 FROM company_plans cp
WHERE cp.company_id = c.id AND cp.is_active = true
)`, platformsettings.SystemCompanyID).Scan(&count)
if err != nil {
return 0, false
}
return count, true
}
// diagCompaniesWithoutAPIKeys counts tenants with no non-revoked api_keys.
// Legacy secrets were not migrated — inventory for reissue only (no key invent/import).
func (s *Server) diagCompaniesWithoutAPIKeys(ctx context.Context) (count int64, ok bool) {
if s.Pool == nil {
return 0, false
}
err := s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::bigint FROM companies c
WHERE c.id <> $1
AND NOT EXISTS (
SELECT 1 FROM api_keys k
WHERE k.company_id = c.id AND k.revoked_at IS NULL
)`, platformsettings.SystemCompanyID).Scan(&count)
if err != nil {
return 0, false
}
return count, true
}
// diagMigrationInventory returns cheap read-only COUNTs for accepted ETL gaps
// (metadata-only file blobs, optional jobs-domain backfill, tasks history).
// Never imports or invents data; never exposes paths/secrets.
func (s *Server) diagMigrationInventory(ctx context.Context) map[string]any {
notes := []string{
"File blob bytes were never ETL'd — files_metadata_only tags metadata_only_resync_paths or _legacy_file_id.",
"Cutover default skips domain jobs; processing_jobs_migrated>0 means optional jobs backfill ran (ai_provider_mode=migrated).",
"tasks_total is present history only — no migrated tag on tasks; empty Processing UI after cutover is expected unless jobs ran.",
"Read-only inventory — no fake blob/job import from this endpoint.",
}
out := map[string]any{
"status": "skip",
"detail": "database unavailable",
"files_total": int64(0),
"files_metadata_only": int64(0),
"processing_jobs_total": int64(0),
"processing_jobs_migrated": int64(0),
"tasks_total": int64(0),
"jobs_domain_ran": false,
"notes": notes,
}
if s.Pool == nil {
return out
}
var filesTotal, filesMeta, jobsTotal, jobsMigrated, tasksTotal int64
err := s.Pool.QueryRow(ctx, `
SELECT
(SELECT COUNT(*)::bigint FROM files),
(SELECT COUNT(*)::bigint FROM files
WHERE COALESCE(metadata->>'_blob_strategy', '') = 'metadata_only_resync_paths'
OR metadata ? '_legacy_file_id'),
(SELECT COUNT(*)::bigint FROM processing_jobs),
(SELECT COUNT(*)::bigint FROM processing_jobs
WHERE COALESCE(ai_provider_mode, '') = 'migrated'),
(SELECT COUNT(*)::bigint FROM tasks)`).Scan(
&filesTotal, &filesMeta, &jobsTotal, &jobsMigrated, &tasksTotal,
)
if err != nil {
out["status"] = "warn"
out["detail"] = "migration inventory query failed"
return out
}
out["files_total"] = filesTotal
out["files_metadata_only"] = filesMeta
out["processing_jobs_total"] = jobsTotal
out["processing_jobs_migrated"] = jobsMigrated
out["tasks_total"] = tasksTotal
out["jobs_domain_ran"] = jobsMigrated > 0
out["status"] = "ok"
out["detail"] = "read-only ETL gap inventory"
return out
}
// resolveStripeDiagCfg merges env bootstrap with platform_settings when available.
// Presence flags only — never returns secret values to callers that stringify cfg.
func (s *Server) resolveStripeDiagCfg(ctx context.Context) billing.StripeConfig {
if s.Stripe != nil {
base := s.Stripe.Cfg
if s.Stripe.ResolveCfg != nil {
if cfg, err := s.Stripe.ResolveCfg(ctx, base); err == nil {
return cfg
}
}
return base
}
return billing.StripeConfig{
SecretKey: s.Config.StripeSecretKey,
WebhookSecret: s.Config.StripeWebhookSecret,
ForceMock: s.Config.StripeMock,
}
}
// diagStripeReadiness reports Stripe ops readiness without leaking secret values.
// Production: mock must be off; missing secret/webhook keys degrade (fail-closed at use).
func diagStripeReadiness(prod bool, cfg billing.StripeConfig) (check map[string]any, ok bool) {
secretSet := strings.TrimSpace(cfg.SecretKey) != ""
webhookSet := strings.TrimSpace(cfg.WebhookSecret) != ""
mock := cfg.ForceMock
mockRejectedInProd := !prod || !mock
check = map[string]any{
"name": "stripe",
"status": "ok",
"secret_key_set": secretSet,
"webhook_secret_set": webhookSet,
"mock": mock,
"mock_rejected_in_prod": mockRejectedInProd,
}
if prod && mock {
check["status"] = "fail"
check["detail"] = "STRIPE_MOCK must be false in production"
return check, false
}
if prod && (!secretSet || !webhookSet) {
check["status"] = "warn"
parts := make([]string, 0, 2)
if !secretSet {
parts = append(parts, "secret key")
}
if !webhookSet {
parts = append(parts, "webhook secret")
}
check["detail"] = "missing " + strings.Join(parts, " and ") + " (checkout/webhooks fail closed)"
return check, false
}
if mock {
check["detail"] = "mock mode enabled"
return check, true
}
if !secretSet {
check["status"] = "warn"
check["detail"] = "secret key not set (mock purchases require STRIPE_MOCK)"
return check, false
}
if !webhookSet {
check["status"] = "warn"
check["detail"] = "webhook secret not set"
return check, false
}
check["detail"] = "keys present"
return check, true
}
func (s *Server) diagConfigSanity(stripe billing.StripeConfig) map[string]any {
smtpEnabled := s.Config.SMTPEnabled
if s.Mail != nil {
smtpEnabled = s.Mail.Enabled()
}
secretSet := strings.TrimSpace(stripe.SecretKey) != ""
webhookSet := strings.TrimSpace(stripe.WebhookSecret) != ""
return map[string]any{
"app_env": s.Config.AppEnv,
"maintenance_mode": s.Config.MaintenanceMode,
"read_only_mode": s.Config.ReadOnlyMode,
"session_secure": s.Config.SessionSecure,
"smtp_enabled": smtpEnabled,
"email_dry_run": s.Config.EmailDryRun,
"smtp_host_set": strings.TrimSpace(s.Config.SMTPHost) != "",
"stripe_mock": stripe.ForceMock,
"eprel_enabled": s.Config.EPRELEnabled,
"processing_rpm": s.Config.ProcessingRPM,
"processing_batch_size": s.Config.ProcessingBatchSize,
"processing_max_retries": s.Config.ProcessingMaxRetries,
"upload_dir_configured": strings.TrimSpace(s.Config.UploadDir) != "",
"trusted_proxies_configured": len(s.Config.TrustedProxies) > 0,
"web_origin_set": strings.TrimSpace(s.Config.WebOrigin) != "",
"public_api_url_set": strings.TrimSpace(s.Config.PublicAPIURL) != "",
// Presence flags only — never the secret values.
"token_signing_secret_set": strings.TrimSpace(s.Config.TokenSigningSecret) != "",
"openai_key_set": strings.TrimSpace(s.Config.OpenAIAPIKey) != "",
"pinecone_key_set": strings.TrimSpace(s.Config.PineconeAPIKey) != "",
"stripe_secret_set": secretSet,
"stripe_webhook_secret_set": webhookSet,
"stripe_mock_rejected_in_prod": !s.Config.IsProduction() || !stripe.ForceMock,
"credentials_encryption_key_set": strings.TrimSpace(s.Config.CredentialsEncryptionKey) != "",
}
}
func (s *Server) diagRecentJobFailures(ctx context.Context, limit int, statusFilter string) ([]map[string]any, error) {
out := make([]map[string]any, 0)
if s.Pool == nil {
return out, errors.New("database unavailable")
}
status := statusFilter
if status == "" {
status = "failed"
}
rows, err := s.Pool.Query(ctx, `
SELECT id, company_id, status, total_products, processed_products, error, created_at, updated_at
FROM processing_jobs
WHERE status = $1
ORDER BY updated_at DESC
LIMIT $2`, status, limit)
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var (
id, companyID uuid.UUID
st string
total, processed int
errMsg *string
createdAt, updatedAt time.Time
)
if err := rows.Scan(&id, &companyID, &st, &total, &processed, &errMsg, &createdAt, &updatedAt); err != nil {
return out, err
}
safeErr := ""
if errMsg != nil && *errMsg != "" {
safeErr = processing.TruncateError(errors.New(*errMsg))
}
out = append(out, map[string]any{
"id": id,
"company_id": companyID,
"status": st,
"total_products": total,
"processed_products": processed,
"error": safeErr,
"created_at": createdAt.UTC().Format(time.RFC3339),
"updated_at": updatedAt.UTC().Format(time.RFC3339),
})
}
return out, rows.Err()
}
func (s *Server) diagRecentAIFailures(ctx context.Context, limit int) ([]map[string]any, error) {
out := make([]map[string]any, 0)
if s.Pool == nil {
return out, nil
}
if limit <= 0 {
limit = adminDiagnosticsAIFailDefault
}
if limit > adminDiagnosticsAIFailMax {
limit = adminDiagnosticsAIFailMax
}
rows, err := s.Pool.Query(ctx, `
SELECT id, ticket_id, company_id, kind, created_at
FROM support_ticket_activity
WHERE kind = 'ai_failed'
ORDER BY created_at DESC
LIMIT $1`, limit)
if err != nil {
// Table may be absent on older DBs — soft-skip.
return out, nil
}
defer rows.Close()
for rows.Next() {
var (
id, ticketID, companyID uuid.UUID
kind string
createdAt time.Time
)
if err := rows.Scan(&id, &ticketID, &companyID, &kind, &createdAt); err != nil {
return out, nil
}
out = append(out, map[string]any{
"id": id,
"ticket_id": ticketID,
"company_id": companyID,
"kind": kind,
"created_at": createdAt.UTC().Format(time.RFC3339),
})
}
return out, nil
}