Files

140 lines
4.0 KiB
Go
Raw Permalink Normal View History

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)
}