140 lines
4.0 KiB
Go
140 lines
4.0 KiB
Go
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)
|
||
|
|
}
|