package httpapi import ( "context" "errors" "net/http" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) var errStoreReconnectPoolUnavailable = errors.New("database unavailable") // StoreReconnectGap is one connected-but-invalid store connector for a tenant. // "Invalid" matches merchant needsStoreReconnect: store identity exists but secrets are missing. type StoreReconnectGap struct { CompanyID uuid.UUID `json:"company_id"` CompanyName string `json:"company_name"` Channel string `json:"channel"` Identity string `json:"identity"` IsEnabled bool `json:"is_enabled"` Reason string `json:"reason"` LastTestStatus string `json:"last_test_status,omitempty"` } // StoreReconnectInventory is the admin list payload for credential-gap stores. type StoreReconnectInventory struct { Stores []StoreReconnectGap `json:"stores"` Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } const storeReconnectReasonMissingCredentials = "missing_credentials" // ListStoreReconnectGaps returns companies with Woo/Shopify identity but no usable credential blobs. // Presence-only (no decrypt) — same honesty bar as has_credentials=false in GetConfig for empty secrets. func ListStoreReconnectGaps(ctx context.Context, pool *pgxpool.Pool, limit, offset int) (StoreReconnectInventory, error) { out := StoreReconnectInventory{ Stores: []StoreReconnectGap{}, Limit: limit, Offset: offset, } if pool == nil { return out, errStoreReconnectPoolUnavailable } if limit <= 0 { limit = 50 } if limit > 200 { limit = 200 } if offset < 0 { offset = 0 } out.Limit = limit out.Offset = offset const q = ` WITH gaps AS ( SELECT c.id AS company_id, c.name AS company_name, 'shopify'::text AS channel, NULLIF(BTRIM(sc.shop_domain), '') AS identity, sc.is_enabled, COALESCE(sc.last_test_status, '') AS last_test_status FROM companies c JOIN shopify_configs sc ON sc.company_id = c.id WHERE c.id <> $1 AND NULLIF(BTRIM(sc.shop_domain), '') IS NOT NULL AND COALESCE(LENGTH(sc.access_token), 0) = 0 AND COALESCE(NULLIF(BTRIM(sc.sync_options->>'client_id'), ''), '') = '' AND COALESCE(NULLIF(BTRIM(sc.sync_options->>'client_secret_enc'), ''), '') = '' UNION ALL SELECT c.id, c.name, 'woocommerce', NULLIF(BTRIM(wc.store_url), ''), wc.is_enabled, COALESCE(wc.last_test_status, '') FROM companies c JOIN woocommerce_configs wc ON wc.company_id = c.id WHERE c.id <> $1 AND NULLIF(BTRIM(wc.store_url), '') IS NOT NULL AND ( COALESCE(LENGTH(wc.consumer_key), 0) = 0 OR COALESCE(LENGTH(wc.consumer_secret), 0) = 0 ) ) SELECT COUNT(*) OVER() AS total, company_id, company_name, channel, identity, is_enabled, last_test_status FROM gaps ORDER BY company_name ASC, channel ASC LIMIT $2 OFFSET $3` rows, err := pool.Query(ctx, q, platformsettings.SystemCompanyID, limit, offset) if err != nil { return out, err } defer rows.Close() for rows.Next() { var row StoreReconnectGap var total int if err := rows.Scan( &total, &row.CompanyID, &row.CompanyName, &row.Channel, &row.Identity, &row.IsEnabled, &row.LastTestStatus, ); err != nil { return out, err } row.Reason = storeReconnectReasonMissingCredentials row.Identity = strings.TrimSpace(row.Identity) row.LastTestStatus = strings.TrimSpace(row.LastTestStatus) out.Total = total out.Stores = append(out.Stores, row) } if err := rows.Err(); err != nil { return out, err } return out, nil } func (s *Server) handleAdminListStoreReconnectGaps(w http.ResponseWriter, r *http.Request) { if s.Pool == nil { Error(w, http.StatusServiceUnavailable, "database unavailable") return } limit, offset := ParseLimitOffset(r) inv, err := ListStoreReconnectGaps(r.Context(), s.Pool, limit, offset) if err != nil { Error(w, http.StatusInternalServerError, "store reconnect inventory failed") return } JSON(w, http.StatusOK, inv) }