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:
@@ -0,0 +1,30 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// POST /api/admin/settings/ai-roles/{role}/test — probe platform AI role credentials.
|
||||
// Mirrors mail/test: 200 with status ok|failed|skipped; never echoes secrets or upstream bodies.
|
||||
func (s *Server) handleAdminTestAIRole(w http.ResponseWriter, r *http.Request) {
|
||||
if s.AI == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "ai provider unavailable")
|
||||
return
|
||||
}
|
||||
role := strings.TrimSpace(chi.URLParam(r, "role"))
|
||||
if role == "" || !platformsettings.ValidAIRole(role) {
|
||||
Error(w, http.StatusBadRequest, "unknown ai role (want processing|vectorization|docs_api|support)")
|
||||
return
|
||||
}
|
||||
result, err := s.AI.TestPlatformRole(r.Context(), role)
|
||||
if err != nil {
|
||||
// Safe message only — never echo provider error bodies (may contain key fragments).
|
||||
JSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleAdminTestAIRole_unknownRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm, _, token, s := newAdminAIRoleTestServer(t)
|
||||
h := s.Router()
|
||||
csrf := csrfCookieForSession(t, h, sm, token)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/settings/ai-roles/not-a-role/test", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
req.AddCookie(csrf)
|
||||
req.Header.Set("X-CSRF-Token", csrf.Value)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d want 400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminTestAIRole_unconfiguredSkipped(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm, _, token, s := newAdminAIRoleTestServer(t)
|
||||
h := s.Router()
|
||||
csrf := csrfCookieForSession(t, h, sm, token)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/settings/ai-roles/support/test", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
req.AddCookie(csrf)
|
||||
req.Header.Set("X-CSRF-Token", csrf.Value)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body["status"] != "skipped" {
|
||||
t.Fatalf("status=%v want skipped body=%s", body["status"], rec.Body.String())
|
||||
}
|
||||
if body["role"] != "support" {
|
||||
t.Fatalf("role=%v", body["role"])
|
||||
}
|
||||
if raw, exists := body["api_key"]; exists && raw != nil && raw != "" {
|
||||
t.Fatalf("must not leak api_key, got %#v", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func newAdminAIRoleTestServer(t *testing.T) (*scs.SessionManager, uuid.UUID, string, *Server) {
|
||||
t.Helper()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{})
|
||||
ai := aiprovider.NewService(nil, aiprovider.EnvConfig{})
|
||||
ai.Platform = plat
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
AI: ai,
|
||||
PlatformSettings: plat,
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == uid, nil
|
||||
},
|
||||
}
|
||||
return sm, uid, seedAdminSession(t, sm, uid), s
|
||||
}
|
||||
|
||||
func seedAdminSession(t *testing.T, sm *scs.SessionManager, uid uuid.UUID) string {
|
||||
t.Helper()
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
return c.Value
|
||||
}
|
||||
}
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
return ""
|
||||
}
|
||||
|
||||
func csrfCookieForSession(t *testing.T, h http.Handler, sm *scs.SessionManager, sessionToken string) *http.Cookie {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: sessionToken})
|
||||
h.ServeHTTP(rec, req)
|
||||
if c := findCSRFCookie(rec.Result().Cookies()); c != nil {
|
||||
return c
|
||||
}
|
||||
t.Fatal("expected CSRF cookie")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
adminAnalyticsDefaultDays = 30
|
||||
adminAnalyticsMinDays = 7
|
||||
adminAnalyticsMaxDays = 90
|
||||
adminAnalyticsTopCompanies = 25
|
||||
adminAnalyticsRecentCycles = 40
|
||||
adminAnalyticsProviderDetailMax = 40
|
||||
adminAnalyticsStuckAfter = 2 * time.Hour
|
||||
)
|
||||
|
||||
func adminAnalyticsSummaryOnly(r *http.Request) bool {
|
||||
v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("summary")))
|
||||
if v == "" {
|
||||
v = strings.TrimSpace(strings.ToLower(r.URL.Query().Get("summary_only")))
|
||||
}
|
||||
return v == "1" || v == "true" || v == "yes"
|
||||
}
|
||||
|
||||
type adminDayPoint struct {
|
||||
Date string `json:"date"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Products int64 `json:"products,omitempty"`
|
||||
Created int64 `json:"created,omitempty"`
|
||||
Completed int64 `json:"completed,omitempty"`
|
||||
Failed int64 `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
type adminAnalyticsSummary struct {
|
||||
Users int64 `json:"users"`
|
||||
Companies int64 `json:"companies"`
|
||||
UsersPeriod int64 `json:"users_period"`
|
||||
CompaniesPeriod int64 `json:"companies_period"`
|
||||
CreditsAllocated int64 `json:"credits_allocated"`
|
||||
CreditsUsed int64 `json:"credits_used"`
|
||||
CreditsRemaining int64 `json:"credits_remaining"`
|
||||
TokensTotal int64 `json:"tokens_total"`
|
||||
TokensPeriod int64 `json:"tokens_period"`
|
||||
JobsTotal int64 `json:"jobs_total"`
|
||||
JobsByStatus map[string]int64 `json:"jobs_by_status"`
|
||||
JobsStuck int64 `json:"jobs_stuck"`
|
||||
JobsFailedPeriod int64 `json:"jobs_failed_period"`
|
||||
JobsCompletedPeriod int64 `json:"jobs_completed_period"`
|
||||
ProductsProcessed int64 `json:"products_processed"`
|
||||
ProductsRaw int64 `json:"products_raw"`
|
||||
FeedsInput int64 `json:"feeds_input"`
|
||||
FeedsExport int64 `json:"feeds_export"`
|
||||
ApiKeysActive int64 `json:"api_keys_active"`
|
||||
ApiKeysTotal int64 `json:"api_keys_total"`
|
||||
FeedSyncByStatus map[string]int64 `json:"feed_sync_by_status"`
|
||||
TicketsByStatus map[string]int64 `json:"tickets_by_status"`
|
||||
}
|
||||
|
||||
type adminSignupDayPoint struct {
|
||||
Date string `json:"date"`
|
||||
Users int64 `json:"users"`
|
||||
Companies int64 `json:"companies"`
|
||||
}
|
||||
|
||||
type adminCompanyUsage struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TotalCredits int64 `json:"total_credits"`
|
||||
UsedCredits int64 `json:"used_credits"`
|
||||
Remaining int64 `json:"credits_remaining"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Jobs int64 `json:"jobs"`
|
||||
Providers adminProviderBreakdown `json:"providers"`
|
||||
}
|
||||
|
||||
type adminProviderDayPoint struct {
|
||||
Date string `json:"date"`
|
||||
Internal int64 `json:"internal"`
|
||||
Popular int64 `json:"popular"`
|
||||
Custom int64 `json:"custom"`
|
||||
Unknown int64 `json:"unknown"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type adminProviderDetail struct {
|
||||
Provider string `json:"provider"`
|
||||
Class string `json:"class"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Products int64 `json:"products"`
|
||||
}
|
||||
|
||||
type adminBillingCycleRow struct {
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
CompanyName string `json:"company_name"`
|
||||
StartDate time.Time `json:"start_date"`
|
||||
EndDate time.Time `json:"end_date"`
|
||||
CreditsUsed int64 `json:"credits_used"`
|
||||
ProductsProcessed int64 `json:"products_processed"`
|
||||
}
|
||||
|
||||
// adminProviderBucket is per-mode usage (internal / popular / custom / unknown).
|
||||
type adminProviderBucket struct {
|
||||
Tokens int64 `json:"tokens"`
|
||||
Jobs int64 `json:"jobs"`
|
||||
Products int64 `json:"products"`
|
||||
}
|
||||
|
||||
type adminProviderBreakdown struct {
|
||||
Internal adminProviderBucket `json:"internal"`
|
||||
Popular adminProviderBucket `json:"popular"`
|
||||
Custom adminProviderBucket `json:"custom"`
|
||||
Unknown adminProviderBucket `json:"unknown"`
|
||||
}
|
||||
|
||||
// handleAdminAnalytics returns platform-wide aggregates from live tables only.
|
||||
// GET /api/admin/analytics?days=30
|
||||
// Optional: summary=1|true — dashboard cards only (skips series/companies/cycles).
|
||||
func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
days := adminAnalyticsDefaultDays
|
||||
if raw := r.URL.Query().Get("days"); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
days = clampAdminAnalyticsDays(n)
|
||||
}
|
||||
}
|
||||
summaryOnly := adminAnalyticsSummaryOnly(r)
|
||||
ctx := r.Context()
|
||||
since := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -(days - 1))
|
||||
|
||||
summary, err := s.loadAdminAnalyticsSummary(ctx, since)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics summary failed")
|
||||
return
|
||||
}
|
||||
|
||||
providers, providerDetail := s.loadAdminProviderBreakdown(ctx)
|
||||
if summaryOnly {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"days": days,
|
||||
"summary": summary,
|
||||
"providers": providers,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokenByDay := map[string]adminDayPoint{}
|
||||
tokRows, err := s.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
|
||||
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint,
|
||||
COUNT(*)::bigint
|
||||
FROM processed_products
|
||||
WHERE created_at >= $1
|
||||
GROUP BY 1
|
||||
ORDER BY 1`, since)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics token series failed")
|
||||
return
|
||||
}
|
||||
for tokRows.Next() {
|
||||
var d time.Time
|
||||
var tokens, products int64
|
||||
if err := tokRows.Scan(&d, &tokens, &products); err != nil {
|
||||
tokRows.Close()
|
||||
Error(w, http.StatusInternalServerError, "analytics token series scan failed")
|
||||
return
|
||||
}
|
||||
key := d.UTC().Format("2006-01-02")
|
||||
tokenByDay[key] = adminDayPoint{Date: key, Tokens: tokens, Products: products}
|
||||
}
|
||||
tokRows.Close()
|
||||
if err := tokRows.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics token series rows failed")
|
||||
return
|
||||
}
|
||||
|
||||
jobByDay := map[string]adminDayPoint{}
|
||||
jSeries, err := s.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
|
||||
COUNT(*)::bigint,
|
||||
COUNT(*) FILTER (WHERE status = 'completed')::bigint,
|
||||
COUNT(*) FILTER (WHERE status = 'failed')::bigint,
|
||||
COALESCE(SUM(estimated_tokens), 0)::bigint
|
||||
FROM processing_jobs
|
||||
WHERE created_at >= $1
|
||||
GROUP BY 1
|
||||
ORDER BY 1`, since)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics job series failed")
|
||||
return
|
||||
}
|
||||
for jSeries.Next() {
|
||||
var d time.Time
|
||||
var created, completed, failed, tokens int64
|
||||
if err := jSeries.Scan(&d, &created, &completed, &failed, &tokens); err != nil {
|
||||
jSeries.Close()
|
||||
Error(w, http.StatusInternalServerError, "analytics job series scan failed")
|
||||
return
|
||||
}
|
||||
key := d.UTC().Format("2006-01-02")
|
||||
jobByDay[key] = adminDayPoint{
|
||||
Date: key,
|
||||
Created: created,
|
||||
Completed: completed,
|
||||
Failed: failed,
|
||||
Tokens: tokens,
|
||||
}
|
||||
}
|
||||
jSeries.Close()
|
||||
if err := jSeries.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics job series rows failed")
|
||||
return
|
||||
}
|
||||
|
||||
tokenSeries := fillAdminDaySeries(since, days, tokenByDay, func(p adminDayPoint) adminDayPoint {
|
||||
return adminDayPoint{Date: p.Date, Tokens: p.Tokens, Products: p.Products}
|
||||
})
|
||||
jobSeries := fillAdminDaySeries(since, days, jobByDay, func(p adminDayPoint) adminDayPoint {
|
||||
return adminDayPoint{
|
||||
Date: p.Date,
|
||||
Created: p.Created,
|
||||
Completed: p.Completed,
|
||||
Failed: p.Failed,
|
||||
Tokens: p.Tokens,
|
||||
}
|
||||
})
|
||||
|
||||
companies := make([]adminCompanyUsage, 0, adminAnalyticsTopCompanies)
|
||||
companyIDs := make([]uuid.UUID, 0, adminAnalyticsTopCompanies)
|
||||
cRows, err := s.Pool.Query(ctx, `
|
||||
SELECT c.id, c.name,
|
||||
COALESCE(cb.total_credits, 0)::bigint,
|
||||
COALESCE(cb.used_credits, 0)::bigint,
|
||||
COALESCE(tok.tokens, 0)::bigint,
|
||||
COALESCE(jobs.cnt, 0)::bigint
|
||||
FROM companies c
|
||||
LEFT JOIN credit_balances cb ON cb.company_id = c.id
|
||||
LEFT JOIN (
|
||||
SELECT company_id, SUM(COALESCE(total_tokens, 0))::bigint AS tokens
|
||||
FROM processed_products
|
||||
GROUP BY company_id
|
||||
) tok ON tok.company_id = c.id
|
||||
LEFT JOIN (
|
||||
SELECT company_id, COUNT(*)::bigint AS cnt
|
||||
FROM processing_jobs
|
||||
GROUP BY company_id
|
||||
) jobs ON jobs.company_id = c.id
|
||||
ORDER BY COALESCE(tok.tokens, 0) DESC, COALESCE(cb.used_credits, 0) DESC, c.name ASC
|
||||
LIMIT $1`, adminAnalyticsTopCompanies)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics companies usage failed")
|
||||
return
|
||||
}
|
||||
for cRows.Next() {
|
||||
var row adminCompanyUsage
|
||||
if err := cRows.Scan(&row.ID, &row.Name, &row.TotalCredits, &row.UsedCredits, &row.Tokens, &row.Jobs); err != nil {
|
||||
cRows.Close()
|
||||
Error(w, http.StatusInternalServerError, "analytics companies scan failed")
|
||||
return
|
||||
}
|
||||
row.Remaining = row.TotalCredits - row.UsedCredits
|
||||
if row.Remaining < 0 {
|
||||
row.Remaining = 0
|
||||
}
|
||||
companies = append(companies, row)
|
||||
companyIDs = append(companyIDs, row.ID)
|
||||
}
|
||||
cRows.Close()
|
||||
if err := cRows.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics companies rows failed")
|
||||
return
|
||||
}
|
||||
|
||||
cycles := make([]adminBillingCycleRow, 0, adminAnalyticsRecentCycles)
|
||||
cyRows, err := s.Pool.Query(ctx, `
|
||||
SELECT bc.company_id, c.name, bc.start_date, bc.end_date,
|
||||
bc.credits_used::bigint, bc.products_processed::bigint
|
||||
FROM billing_cycles bc
|
||||
JOIN companies c ON c.id = bc.company_id
|
||||
ORDER BY bc.start_date DESC
|
||||
LIMIT $1`, adminAnalyticsRecentCycles)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics billing cycles failed")
|
||||
return
|
||||
}
|
||||
for cyRows.Next() {
|
||||
var row adminBillingCycleRow
|
||||
if err := cyRows.Scan(&row.CompanyID, &row.CompanyName, &row.StartDate, &row.EndDate, &row.CreditsUsed, &row.ProductsProcessed); err != nil {
|
||||
cyRows.Close()
|
||||
Error(w, http.StatusInternalServerError, "analytics billing cycles scan failed")
|
||||
return
|
||||
}
|
||||
cycles = append(cycles, row)
|
||||
}
|
||||
cyRows.Close()
|
||||
if err := cyRows.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "analytics billing cycles rows failed")
|
||||
return
|
||||
}
|
||||
|
||||
providerDaySeries := s.loadAdminProviderDaySeries(ctx, since, days)
|
||||
signupSeries := s.loadAdminSignupDaySeries(ctx, since, days)
|
||||
companyProviders := s.loadAdminCompanyProviderBreakdown(ctx, companyIDs)
|
||||
for i := range companies {
|
||||
if split, ok := companyProviders[companies[i].ID]; ok {
|
||||
companies[i].Providers = split
|
||||
}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"days": days,
|
||||
"summary": summary,
|
||||
"series": map[string]any{
|
||||
"tokens_by_day": tokenSeries,
|
||||
"jobs_by_day": jobSeries,
|
||||
"tokens_by_provider_day": providerDaySeries,
|
||||
"signups_by_day": signupSeries,
|
||||
},
|
||||
"companies": companies,
|
||||
"billing_cycles": cycles,
|
||||
"providers": providers,
|
||||
"tokens_by_provider_detail": providerDetail,
|
||||
"notes": []string{
|
||||
"Tokens come from processed_products.total_tokens (LLM usage recorded per product).",
|
||||
"Provider classes use processed_products.ai_provider_mode: internal | popular:<name> | custom (blank/unknown rolled into the internal card).",
|
||||
"Job tokens use processing_jobs.estimated_tokens (running tally during jobs).",
|
||||
"Jobs stuck = status running with updated_at older than 2 hours (same threshold as diagnostics).",
|
||||
"Credits are live credit_balances snapshots - daily credit debits are not ledgered yet.",
|
||||
"Feeds / feed sync / support tickets / API keys are live table aggregates.",
|
||||
"Billing cycle rows are historical rollups when cycles have been run (may lag the active company_plans window).",
|
||||
"Use /admin/diagnostics for queue health checks and recent failure samples.",
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminAnalyticsSummary(ctx context.Context, since time.Time) (adminAnalyticsSummary, error) {
|
||||
summary := adminAnalyticsSummary{
|
||||
JobsByStatus: map[string]int64{},
|
||||
FeedSyncByStatus: map[string]int64{},
|
||||
TicketsByStatus: map[string]int64{},
|
||||
}
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*)::bigint FROM users),
|
||||
(SELECT COUNT(*)::bigint FROM companies),
|
||||
(SELECT COUNT(*)::bigint FROM users WHERE created_at >= $1),
|
||||
(SELECT COUNT(*)::bigint FROM companies WHERE created_at >= $1),
|
||||
(SELECT COALESCE(SUM(total_credits), 0)::bigint FROM credit_balances),
|
||||
(SELECT COALESCE(SUM(used_credits), 0)::bigint FROM credit_balances),
|
||||
(SELECT COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint FROM processed_products),
|
||||
(SELECT COUNT(*)::bigint FROM processed_products),
|
||||
(SELECT COUNT(*)::bigint FROM raw_products),
|
||||
(SELECT COUNT(*)::bigint FROM input_feeds),
|
||||
(SELECT COUNT(*)::bigint FROM export_feeds),
|
||||
(SELECT COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint FROM processed_products WHERE created_at >= $1),
|
||||
(SELECT COUNT(*)::bigint FROM processing_jobs WHERE created_at >= $1 AND status = 'failed'),
|
||||
(SELECT COUNT(*)::bigint FROM processing_jobs WHERE created_at >= $1 AND status = 'completed'),
|
||||
(SELECT COUNT(*)::bigint FROM processing_jobs
|
||||
WHERE status = 'running' AND updated_at < now() - make_interval(secs => $2)),
|
||||
(SELECT COUNT(*)::bigint FROM api_keys),
|
||||
(SELECT COUNT(*)::bigint FROM api_keys WHERE revoked_at IS NULL)
|
||||
`, since, adminAnalyticsStuckAfter.Seconds()).Scan(
|
||||
&summary.Users,
|
||||
&summary.Companies,
|
||||
&summary.UsersPeriod,
|
||||
&summary.CompaniesPeriod,
|
||||
&summary.CreditsAllocated,
|
||||
&summary.CreditsUsed,
|
||||
&summary.TokensTotal,
|
||||
&summary.ProductsProcessed,
|
||||
&summary.ProductsRaw,
|
||||
&summary.FeedsInput,
|
||||
&summary.FeedsExport,
|
||||
&summary.TokensPeriod,
|
||||
&summary.JobsFailedPeriod,
|
||||
&summary.JobsCompletedPeriod,
|
||||
&summary.JobsStuck,
|
||||
&summary.ApiKeysTotal,
|
||||
&summary.ApiKeysActive,
|
||||
)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
}
|
||||
summary.CreditsRemaining = summary.CreditsAllocated - summary.CreditsUsed
|
||||
if summary.CreditsRemaining < 0 {
|
||||
summary.CreditsRemaining = 0
|
||||
}
|
||||
|
||||
jobRows, err := s.Pool.Query(ctx, `
|
||||
SELECT status, COUNT(*)
|
||||
FROM processing_jobs
|
||||
GROUP BY status`)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
}
|
||||
defer jobRows.Close()
|
||||
for jobRows.Next() {
|
||||
var status string
|
||||
var n int64
|
||||
if err := jobRows.Scan(&status, &n); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
summary.JobsByStatus[status] = n
|
||||
summary.JobsTotal += n
|
||||
}
|
||||
if err := jobRows.Err(); err != nil {
|
||||
return summary, err
|
||||
}
|
||||
|
||||
summary.FeedSyncByStatus = s.loadAdminStatusCounts(ctx,
|
||||
`SELECT status, COUNT(*)::bigint FROM feed_sync_jobs GROUP BY status`)
|
||||
summary.TicketsByStatus = s.loadAdminStatusCounts(ctx,
|
||||
`SELECT status, COUNT(*)::bigint FROM support_tickets GROUP BY status`)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminStatusCounts(ctx context.Context, query string) map[string]int64 {
|
||||
out := map[string]int64{}
|
||||
rows, err := s.Pool.Query(ctx, query)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var n int64
|
||||
if err := rows.Scan(&status, &n); err != nil {
|
||||
return out
|
||||
}
|
||||
out[status] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminSignupDaySeries(ctx context.Context, since time.Time, days int) []adminSignupDayPoint {
|
||||
byDay := map[string]adminSignupDayPoint{}
|
||||
uRows, err := s.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d, COUNT(*)::bigint
|
||||
FROM users WHERE created_at >= $1
|
||||
GROUP BY 1 ORDER BY 1`, since)
|
||||
if err == nil {
|
||||
for uRows.Next() {
|
||||
var d time.Time
|
||||
var n int64
|
||||
if err := uRows.Scan(&d, &n); err != nil {
|
||||
break
|
||||
}
|
||||
key := d.UTC().Format("2006-01-02")
|
||||
pt := byDay[key]
|
||||
pt.Date = key
|
||||
pt.Users = n
|
||||
byDay[key] = pt
|
||||
}
|
||||
uRows.Close()
|
||||
}
|
||||
cRows, err := s.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d, COUNT(*)::bigint
|
||||
FROM companies WHERE created_at >= $1
|
||||
GROUP BY 1 ORDER BY 1`, since)
|
||||
if err == nil {
|
||||
for cRows.Next() {
|
||||
var d time.Time
|
||||
var n int64
|
||||
if err := cRows.Scan(&d, &n); err != nil {
|
||||
break
|
||||
}
|
||||
key := d.UTC().Format("2006-01-02")
|
||||
pt := byDay[key]
|
||||
pt.Date = key
|
||||
pt.Companies = n
|
||||
byDay[key] = pt
|
||||
}
|
||||
cRows.Close()
|
||||
}
|
||||
out := make([]adminSignupDayPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
d := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
|
||||
if p, ok := byDay[d]; ok {
|
||||
p.Date = d
|
||||
out = append(out, p)
|
||||
continue
|
||||
}
|
||||
out = append(out, adminSignupDayPoint{Date: d})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminProviderBreakdown(ctx context.Context) (adminProviderBreakdown, []adminProviderDetail) {
|
||||
out := adminProviderBreakdown{}
|
||||
detail := make([]adminProviderDetail, 0)
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
|
||||
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint,
|
||||
COUNT(*)::bigint
|
||||
FROM processed_products
|
||||
GROUP BY 1
|
||||
ORDER BY 2 DESC`)
|
||||
if err != nil {
|
||||
return out, detail
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var mode string
|
||||
var tokens, products int64
|
||||
if err := rows.Scan(&mode, &tokens, &products); err != nil {
|
||||
return out, detail
|
||||
}
|
||||
class := aiprovider.AnalyticsClass(mode)
|
||||
bucket := adminProviderBucket{Tokens: tokens, Products: products}
|
||||
switch class {
|
||||
case aiprovider.ModePopular:
|
||||
out.Popular.Tokens += bucket.Tokens
|
||||
out.Popular.Products += bucket.Products
|
||||
case aiprovider.ModeCustom:
|
||||
out.Custom.Tokens += bucket.Tokens
|
||||
out.Custom.Products += bucket.Products
|
||||
default:
|
||||
// internal + unknown → internal card (legacy/backfill)
|
||||
out.Internal.Tokens += bucket.Tokens
|
||||
out.Internal.Products += bucket.Products
|
||||
}
|
||||
detail = append(detail, adminProviderDetail{
|
||||
Provider: aiprovider.NormalizeAnalyticsMode(mode),
|
||||
Class: class,
|
||||
Tokens: tokens,
|
||||
Products: products,
|
||||
})
|
||||
}
|
||||
|
||||
jobRows, err := s.Pool.Query(ctx, `
|
||||
SELECT COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
|
||||
COUNT(*)::bigint
|
||||
FROM processing_jobs
|
||||
GROUP BY 1`)
|
||||
if err != nil {
|
||||
return out, detail
|
||||
}
|
||||
defer jobRows.Close()
|
||||
for jobRows.Next() {
|
||||
var mode string
|
||||
var jobs int64
|
||||
if err := jobRows.Scan(&mode, &jobs); err != nil {
|
||||
return out, detail
|
||||
}
|
||||
switch aiprovider.AnalyticsClass(mode) {
|
||||
case aiprovider.ModePopular:
|
||||
out.Popular.Jobs += jobs
|
||||
case aiprovider.ModeCustom:
|
||||
out.Custom.Jobs += jobs
|
||||
default:
|
||||
out.Internal.Jobs += jobs
|
||||
}
|
||||
}
|
||||
if len(detail) > adminAnalyticsProviderDetailMax {
|
||||
detail = detail[:adminAnalyticsProviderDetailMax]
|
||||
}
|
||||
return out, detail
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminProviderDaySeries(ctx context.Context, since time.Time, days int) []adminProviderDayPoint {
|
||||
byDay := map[string]adminProviderDayPoint{}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
|
||||
COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
|
||||
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint
|
||||
FROM processed_products
|
||||
WHERE created_at >= $1
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1`, since)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d time.Time
|
||||
var mode string
|
||||
var tokens int64
|
||||
if err := rows.Scan(&d, &mode, &tokens); err != nil {
|
||||
break
|
||||
}
|
||||
key := d.UTC().Format("2006-01-02")
|
||||
pt := byDay[key]
|
||||
pt.Date = key
|
||||
switch aiprovider.AnalyticsClass(mode) {
|
||||
case aiprovider.ModePopular:
|
||||
pt.Popular += tokens
|
||||
case aiprovider.ModeCustom:
|
||||
pt.Custom += tokens
|
||||
case "unknown":
|
||||
pt.Unknown += tokens
|
||||
default:
|
||||
pt.Internal += tokens
|
||||
}
|
||||
pt.Total += tokens
|
||||
byDay[key] = pt
|
||||
}
|
||||
}
|
||||
out := make([]adminProviderDayPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
d := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
|
||||
if p, ok := byDay[d]; ok {
|
||||
p.Date = d
|
||||
out = append(out, p)
|
||||
continue
|
||||
}
|
||||
out = append(out, adminProviderDayPoint{Date: d})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) loadAdminCompanyProviderBreakdown(ctx context.Context, companyIDs []uuid.UUID) map[uuid.UUID]adminProviderBreakdown {
|
||||
out := map[uuid.UUID]adminProviderBreakdown{}
|
||||
if len(companyIDs) == 0 {
|
||||
return out
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT company_id,
|
||||
COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
|
||||
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint,
|
||||
COUNT(*)::bigint
|
||||
FROM processed_products
|
||||
WHERE company_id = ANY($1)
|
||||
GROUP BY company_id, 2`, companyIDs)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var companyID uuid.UUID
|
||||
var mode string
|
||||
var tokens, products int64
|
||||
if err := rows.Scan(&companyID, &mode, &tokens, &products); err != nil {
|
||||
return out
|
||||
}
|
||||
b := out[companyID]
|
||||
switch aiprovider.AnalyticsClass(mode) {
|
||||
case aiprovider.ModePopular:
|
||||
b.Popular.Tokens += tokens
|
||||
b.Popular.Products += products
|
||||
case aiprovider.ModeCustom:
|
||||
b.Custom.Tokens += tokens
|
||||
b.Custom.Products += products
|
||||
default:
|
||||
b.Internal.Tokens += tokens
|
||||
b.Internal.Products += products
|
||||
}
|
||||
out[companyID] = b
|
||||
}
|
||||
|
||||
jobRows, err := s.Pool.Query(ctx, `
|
||||
SELECT company_id,
|
||||
COALESCE(NULLIF(TRIM(ai_provider_mode), ''), 'unknown') AS mode,
|
||||
COUNT(*)::bigint
|
||||
FROM processing_jobs
|
||||
WHERE company_id = ANY($1)
|
||||
GROUP BY company_id, 2`, companyIDs)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer jobRows.Close()
|
||||
for jobRows.Next() {
|
||||
var companyID uuid.UUID
|
||||
var mode string
|
||||
var jobs int64
|
||||
if err := jobRows.Scan(&companyID, &mode, &jobs); err != nil {
|
||||
return out
|
||||
}
|
||||
b := out[companyID]
|
||||
switch aiprovider.AnalyticsClass(mode) {
|
||||
case aiprovider.ModePopular:
|
||||
b.Popular.Jobs += jobs
|
||||
case aiprovider.ModeCustom:
|
||||
b.Custom.Jobs += jobs
|
||||
default:
|
||||
b.Internal.Jobs += jobs
|
||||
}
|
||||
out[companyID] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clampAdminAnalyticsDays(n int) int {
|
||||
if n < adminAnalyticsMinDays {
|
||||
return adminAnalyticsMinDays
|
||||
}
|
||||
if n > adminAnalyticsMaxDays {
|
||||
return adminAnalyticsMaxDays
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func fillAdminDaySeries(
|
||||
since time.Time,
|
||||
days int,
|
||||
src map[string]adminDayPoint,
|
||||
mapPoint func(adminDayPoint) adminDayPoint,
|
||||
) []adminDayPoint {
|
||||
out := make([]adminDayPoint, 0, days)
|
||||
for i := 0; i < days; i++ {
|
||||
d := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
|
||||
if p, ok := src[d]; ok {
|
||||
p.Date = d
|
||||
out = append(out, mapPoint(p))
|
||||
continue
|
||||
}
|
||||
out = append(out, mapPoint(adminDayPoint{Date: d}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdminAnalyticsSummaryOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"summary=0", false},
|
||||
{"summary=1", true},
|
||||
{"summary=true", true},
|
||||
{"summary_only=yes", true},
|
||||
{"summary_only=no", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/analytics?"+c.raw, nil)
|
||||
if got := adminAnalyticsSummaryOnly(req); got != c.want {
|
||||
t.Fatalf("%q: got %v want %v", c.raw, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampAdminAnalyticsDays(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in, want int
|
||||
}{
|
||||
{0, adminAnalyticsMinDays},
|
||||
{3, adminAnalyticsMinDays},
|
||||
{7, 7},
|
||||
{30, 30},
|
||||
{90, 90},
|
||||
{120, adminAnalyticsMaxDays},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := clampAdminAnalyticsDays(c.in); got != c.want {
|
||||
t.Fatalf("clamp(%d)=%d want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillAdminDaySeries(t *testing.T) {
|
||||
t.Parallel()
|
||||
since := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
src := map[string]adminDayPoint{
|
||||
"2026-08-01": {Date: "2026-08-01", Tokens: 10, Products: 2},
|
||||
"2026-08-03": {Date: "2026-08-03", Tokens: 5, Products: 1},
|
||||
}
|
||||
out := fillAdminDaySeries(since, 3, src, func(p adminDayPoint) adminDayPoint {
|
||||
return adminDayPoint{Date: p.Date, Tokens: p.Tokens, Products: p.Products}
|
||||
})
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len=%d", len(out))
|
||||
}
|
||||
if out[0].Tokens != 10 || out[1].Tokens != 0 || out[2].Tokens != 5 {
|
||||
t.Fatalf("unexpected series: %+v", out)
|
||||
}
|
||||
if out[1].Date != "2026-08-02" {
|
||||
t.Fatalf("gap date=%s", out[1].Date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestMemberForbiddenOnSensitiveMutations(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "member")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn http.HandlerFunc
|
||||
body string
|
||||
}{
|
||||
{name: "create_api_key", fn: s.handleCreateAPIKey, body: `{"name":"x"}`},
|
||||
{name: "revoke_api_key", fn: s.handleRevokeAPIKey, body: ""},
|
||||
{name: "put_email", fn: s.handlePutEmailIntegration, body: `{}`},
|
||||
{name: "verify_email", fn: s.handleVerifyEmailIntegration, body: ""},
|
||||
{name: "test_email", fn: s.handleTestEmailIntegration, body: `{}`},
|
||||
{name: "send_email", fn: s.handleSendEmail, body: `{}`},
|
||||
{name: "put_ai", fn: s.handlePutAIIntegration, body: `{}`},
|
||||
{name: "test_ai", fn: s.handleTestAIIntegration, body: ""},
|
||||
{name: "update_woo", fn: s.handleUpdateWooConfig, body: `{}`},
|
||||
{name: "update_woo_maps", fn: s.handleUpdateWooMaps, body: `{}`},
|
||||
{name: "update_woo_schedule", fn: s.handleUpdateWooSchedule, body: `{}`},
|
||||
{name: "update_shopify", fn: s.handleUpdateShopifyConfig, body: `{}`},
|
||||
{name: "update_shopify_schedule", fn: s.handleUpdateShopifySchedule, body: `{}`},
|
||||
{name: "stripe_checkout", fn: s.handleStripeCheckout, body: `{}`},
|
||||
{name: "stripe_portal", fn: s.handleStripePortal, body: `{}`},
|
||||
{name: "reset_products", fn: s.handleResetProducts, body: `{"product_ids":[],"kind":"raw"}`},
|
||||
{name: "import_csv", fn: s.handleImportCSV, body: ""},
|
||||
{name: "put_category_attributes", fn: s.handlePutCategoryAttributes, body: `{"attribute_ids":[]}`},
|
||||
{name: "delete_category", fn: s.handleDeleteCategory, body: ""},
|
||||
{name: "delete_attribute", fn: s.handleDeleteAttribute, body: ""},
|
||||
{name: "delete_feed", fn: s.handleDeleteFeed, body: ""},
|
||||
{name: "delete_export_feed", fn: s.handleDeleteExportFeed, body: ""},
|
||||
{name: "rotate_export_feed_token", fn: s.handleRotateExportFeedPublicToken, body: ""},
|
||||
{name: "delete_file", fn: s.handleDeleteFile, body: ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(tc.body))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
tc.fn(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompanyAdminAllowedRoles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, "admin")) {
|
||||
t.Fatal("admin role should be allowed")
|
||||
}
|
||||
if !CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, "api")) {
|
||||
t.Fatal("api role should be allowed for catalog destructive ops")
|
||||
}
|
||||
if CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, "member")) {
|
||||
t.Fatal("member role must not be allowed")
|
||||
}
|
||||
if CompanyAdminAllowed(context.Background()) {
|
||||
t.Fatal("missing role must not be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyContextRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := apiKeyContextRole("admin"); got != "api" {
|
||||
t.Fatalf("admin -> api, got %q", got)
|
||||
}
|
||||
if got := apiKeyContextRole("Admin"); got != "api" {
|
||||
t.Fatalf("Admin -> api, got %q", got)
|
||||
}
|
||||
if got := apiKeyContextRole("member"); got != "member" {
|
||||
t.Fatalf("member stays member, got %q", got)
|
||||
}
|
||||
if got := apiKeyContextRole(""); got != "member" {
|
||||
t.Fatalf("empty normalizes to member, got %q", got)
|
||||
}
|
||||
if CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, apiKeyContextRole("member"))) {
|
||||
t.Fatal("member-owned API key must not pass CompanyAdminAllowed")
|
||||
}
|
||||
if !CompanyAdminAllowed(context.WithValue(context.Background(), ctxRole, apiKeyContextRole("admin"))) {
|
||||
t.Fatal("admin-owned API key must pass CompanyAdminAllowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePlatformAdminUnauthorized(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
called := false
|
||||
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("handler must not run without session user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePlatformAdminForbiddenAndAllow(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
t.Run("forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
if got != uid {
|
||||
t.Fatalf("userID = %s, want %s", got, uid)
|
||||
}
|
||||
return false, nil
|
||||
},
|
||||
}
|
||||
called := false
|
||||
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("handler must not run for non-admin")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("db_error_fail_closed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
|
||||
return false, context.DeadlineExceeded
|
||||
},
|
||||
}
|
||||
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 on lookup error", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allow", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
called := false
|
||||
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("handler must run for platform admin")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("support_staff_forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
|
||||
return auth.ResolveStaffAccess(true, auth.StaffRoleSupportStaff), nil
|
||||
},
|
||||
}
|
||||
called := false
|
||||
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/plans", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("support_staff must not reach full admin routes")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequireSupportDesk(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
t.Run("support_staff_allowed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
|
||||
return auth.ResolveStaffAccess(false, auth.StaffRoleSupportStaff), nil
|
||||
},
|
||||
}
|
||||
called := false
|
||||
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent || !called {
|
||||
t.Fatalf("status=%d called=%v", rec.Code, called)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("plain_user_forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
|
||||
return auth.StaffAccess{}, nil
|
||||
},
|
||||
}
|
||||
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueryTruthyWithoutActivePlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/companies?without_active_plan=1", nil)
|
||||
if !QueryTruthy(req, "without_active_plan") {
|
||||
t.Fatal("expected without_active_plan=1 to be truthy")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/admin/companies", nil)
|
||||
if QueryTruthy(req, "without_active_plan") {
|
||||
t.Fatal("expected missing flag to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTruthyWithoutAPIKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/companies?without_api_keys=1", nil)
|
||||
if !QueryTruthy(req, "without_api_keys") {
|
||||
t.Fatal("expected without_api_keys=1 to be truthy")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/admin/companies", nil)
|
||||
if QueryTruthy(req, "without_api_keys") {
|
||||
t.Fatal("expected missing without_api_keys to be false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const defaultDevPassword = "DemoPass123!"
|
||||
|
||||
func isLocalDemoEmail(email string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(email)) {
|
||||
case "demo@descrybe.local", "demo@descrybe.test":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDevImpersonationActor returns the privileged actor allowed to drive non-prod
|
||||
// user switching: the current full admin/demo user, or the stored impersonator.
|
||||
func (s *Server) resolveDevImpersonationActor(ctx context.Context) (actorID uuid.UUID, ok bool, err error) {
|
||||
if s.Config.IsProduction() {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
uid, hasUID := UserIDFromContext(ctx)
|
||||
if !hasUID || uid == uuid.Nil {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
if s.Auth == nil {
|
||||
return uuid.Nil, false, errors.New("auth unavailable")
|
||||
}
|
||||
|
||||
access, err := s.checkStaffAccess(ctx, uid)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
if access.FullAdmin {
|
||||
return uid, true, nil
|
||||
}
|
||||
user, err := s.Auth.GetUser(ctx, uid)
|
||||
if err == nil && isLocalDemoEmail(user.Email) {
|
||||
return uid, true, nil
|
||||
}
|
||||
|
||||
impStr := strings.TrimSpace(s.Sessions.GetString(ctx, auth.SessionImpersonatorIDKey))
|
||||
if impStr == "" {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
impID, err := uuid.Parse(impStr)
|
||||
if err != nil || impID == uuid.Nil {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
impAccess, err := s.checkStaffAccess(ctx, impID)
|
||||
if err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
if impAccess.FullAdmin {
|
||||
return impID, true, nil
|
||||
}
|
||||
impUser, err := s.Auth.GetUser(ctx, impID)
|
||||
if err == nil && isLocalDemoEmail(impUser.Email) {
|
||||
return impID, true, nil
|
||||
}
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
|
||||
// handleAdminDevSetPassword sets a known local password for any active user.
|
||||
// Blocked in production. Intended for @legacy.local migrated accounts (invite emails skip those).
|
||||
func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Config.IsProduction() {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = DecodeJSONOptional(r, &body)
|
||||
password := body.Password
|
||||
if strings.TrimSpace(password) == "" {
|
||||
password = defaultDevPassword
|
||||
}
|
||||
if len(password) < 8 {
|
||||
Error(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
user, err := s.Auth.GetUser(r.Context(), id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if !user.IsActive {
|
||||
Error(w, http.StatusBadRequest, "user is inactive")
|
||||
return
|
||||
}
|
||||
if err := s.Auth.ForceSetPassword(r.Context(), id, password); err != nil {
|
||||
if errors.Is(err, auth.ErrUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
LogAndError(w, http.StatusInternalServerError, "could not set password", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"user_id": id,
|
||||
"email": user.Email,
|
||||
"hint": "Password set for local login. Omit body.password to use the built-in local default.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminDevImpersonate swaps the current session to the target user (non-production only).
|
||||
func (s *Server) handleAdminDevImpersonate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Config.IsProduction() {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||
return
|
||||
}
|
||||
actorID, allowed, err := s.resolveDevImpersonationActor(r.Context())
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "could not authorize user switch", err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "user switch not allowed")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
adminID, ok := UserIDFromContext(r.Context())
|
||||
if !ok || adminID == uuid.Nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if adminID == id {
|
||||
Error(w, http.StatusBadRequest, "already signed in as this user")
|
||||
return
|
||||
}
|
||||
user, err := s.Auth.GetUser(r.Context(), id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if !user.IsActive {
|
||||
Error(w, http.StatusBadRequest, "user is inactive")
|
||||
return
|
||||
}
|
||||
companies, err := s.Auth.ListUserCompanies(r.Context(), id)
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "could not list companies", err)
|
||||
return
|
||||
}
|
||||
var companyID uuid.UUID
|
||||
if len(companies) > 0 {
|
||||
companyID = companies[0].ID
|
||||
}
|
||||
if err := s.beginImpersonatedSession(r.Context(), id, companyID, actorID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "session start failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"user": user,
|
||||
"company_id": companyID,
|
||||
"companies": companies,
|
||||
"hint": "Session switched. Reload the app to view this user's tenant context.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminDevStopImpersonate restores the session to the original admin/demo actor.
|
||||
func (s *Server) handleAdminDevStopImpersonate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Config.IsProduction() {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||
return
|
||||
}
|
||||
actorID, allowed, err := s.resolveDevImpersonationActor(r.Context())
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "could not authorize stop impersonate", err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "user switch not allowed")
|
||||
return
|
||||
}
|
||||
impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey))
|
||||
if impStr == "" {
|
||||
Error(w, http.StatusBadRequest, "not impersonating")
|
||||
return
|
||||
}
|
||||
impID, err := uuid.Parse(impStr)
|
||||
if err != nil || impID == uuid.Nil {
|
||||
Error(w, http.StatusBadRequest, "invalid impersonator")
|
||||
return
|
||||
}
|
||||
if impID != actorID {
|
||||
// Prefer the stored impersonator when it is still the privileged actor.
|
||||
impAccess, aerr := s.checkStaffAccess(r.Context(), impID)
|
||||
if aerr != nil || !impAccess.FullAdmin {
|
||||
impUser, uerr := s.Auth.GetUser(r.Context(), impID)
|
||||
if uerr != nil || !isLocalDemoEmail(impUser.Email) {
|
||||
Error(w, http.StatusForbidden, "user switch not allowed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
user, err := s.Auth.GetUser(r.Context(), impID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "impersonator not found")
|
||||
return
|
||||
}
|
||||
if !user.IsActive {
|
||||
Error(w, http.StatusBadRequest, "impersonator is inactive")
|
||||
return
|
||||
}
|
||||
companies, err := s.Auth.ListUserCompanies(r.Context(), impID)
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "could not list companies", err)
|
||||
return
|
||||
}
|
||||
var companyID uuid.UUID
|
||||
if len(companies) > 0 {
|
||||
companyID = companies[0].ID
|
||||
}
|
||||
// Clear impersonation then start a normal session as the actor.
|
||||
s.Sessions.Remove(r.Context(), auth.SessionImpersonatorIDKey)
|
||||
if err := s.beginAuthenticatedSession(r.Context(), impID, companyID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "session start failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"user": user,
|
||||
"company_id": companyID,
|
||||
"companies": companies,
|
||||
"hint": "Returned to original session. Reload the app.",
|
||||
})
|
||||
}
|
||||
|
||||
// primaryA1LegacyUserID is the Clerk user_id for the A1 contact we care about in local demos
|
||||
// (migrated as …@legacy.local). Used only for non-prod switcher labels.
|
||||
const primaryA1LegacyUserID = "user_30AqqJ8uepxvPUzDSqy81U5w6Ll"
|
||||
|
||||
type switchableUserRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
LegacyUserID *string `json:"legacy_user_id,omitempty"`
|
||||
MembershipRole string `json:"membership_role,omitempty"`
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
CompanyName string `json:"company_name"`
|
||||
CompanyLabel string `json:"company_label"`
|
||||
Label string `json:"label"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
IsDemoAdmin bool `json:"is_demo_admin"`
|
||||
IsPrimaryA1 bool `json:"is_primary_a1"`
|
||||
ClerkSuffix string `json:"clerk_suffix,omitempty"`
|
||||
}
|
||||
|
||||
func companyDisplayLabel(companyName, legacyCompanyID string) string {
|
||||
name := strings.TrimSpace(companyName)
|
||||
if isA1LegacyCompany(legacyCompanyID, name) {
|
||||
// Prefer live company name when already A1 Slovenija; never fake "Local Demo Co".
|
||||
if name != "" && !strings.EqualFold(name, "Local Demo Co") {
|
||||
return name
|
||||
}
|
||||
return "A1 Slovenija"
|
||||
}
|
||||
if name == "" {
|
||||
return "Unknown company"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func clerkIDFromLegacy(email string, legacyUserID *string) string {
|
||||
if legacyUserID != nil {
|
||||
if id := strings.TrimSpace(*legacyUserID); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
if strings.HasSuffix(email, "@legacy.local") {
|
||||
return strings.TrimSuffix(email, "@legacy.local")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shortClerkSuffix(clerkID string) string {
|
||||
id := strings.TrimSpace(clerkID)
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
const n = 8
|
||||
if len(id) <= n {
|
||||
return id
|
||||
}
|
||||
return id[len(id)-n:]
|
||||
}
|
||||
|
||||
func isPrimaryA1User(email, clerkID string) bool {
|
||||
emailNorm := strings.TrimSpace(strings.ToLower(email))
|
||||
if emailNorm == "a1-primary@descrybe.local" {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(clerkID), primaryA1LegacyUserID) {
|
||||
return true
|
||||
}
|
||||
target := strings.ToLower(primaryA1LegacyUserID)
|
||||
local := emailNorm
|
||||
if i := strings.IndexByte(local, '@'); i > 0 {
|
||||
local = local[:i]
|
||||
}
|
||||
return local == target
|
||||
}
|
||||
|
||||
// isA1LegacyCompany is true when the membership company maps to MySQL A1 Slovenija
|
||||
// (legacy_company_id 97e1a309-…, dump name, or the old Local Demo Co rename).
|
||||
// isA1LegacyCompany is true for non-prod switcher labels when the membership
|
||||
// company maps to migrated A1 (immutable legacy_company_id) OR known dump/demo
|
||||
// display names. Name matches are UI-only — billing cohort uses IsA1CohortCompany.
|
||||
func isA1LegacyCompany(legacyCompanyID, companyName string) bool {
|
||||
if billing.IsA1CohortCompany(legacyCompanyID, companyName) {
|
||||
return true
|
||||
}
|
||||
n := strings.TrimSpace(companyName)
|
||||
return strings.EqualFold(n, "A1 Slovenija") ||
|
||||
strings.EqualFold(n, "Local Demo Co") ||
|
||||
strings.EqualFold(n, "A1")
|
||||
}
|
||||
|
||||
// a1SwitcherLabel builds dump-truth labels. MySQL profiles have no human names/emails —
|
||||
// only Clerk user_id — so we show "A1 · …<clerkSuffix>".
|
||||
func a1SwitcherLabel(clerkSuffix string) string {
|
||||
if strings.TrimSpace(clerkSuffix) != "" {
|
||||
return "A1 · …" + clerkSuffix
|
||||
}
|
||||
return "A1 · A1 Slovenija"
|
||||
}
|
||||
|
||||
func enrichSwitchableUser(u *switchableUserRow, legacyCompanyID string) {
|
||||
u.CompanyLabel = companyDisplayLabel(u.CompanyName, legacyCompanyID)
|
||||
clerkID := clerkIDFromLegacy(u.Email, u.LegacyUserID)
|
||||
u.ClerkSuffix = shortClerkSuffix(clerkID)
|
||||
u.IsDemoAdmin = isLocalDemoEmail(u.Email)
|
||||
onA1 := isA1LegacyCompany(legacyCompanyID, u.CompanyName)
|
||||
u.IsPrimaryA1 = onA1 && isPrimaryA1User(u.Email, clerkID)
|
||||
|
||||
switch {
|
||||
case u.IsDemoAdmin:
|
||||
u.Label = "Demo admin"
|
||||
u.Subtitle = u.Email
|
||||
case onA1 && (u.IsPrimaryA1 || clerkID != ""):
|
||||
// Dump-confirmed A1 members (no human name in MySQL profiles/admin_users).
|
||||
u.Label = a1SwitcherLabel(u.ClerkSuffix)
|
||||
if clerkID != "" {
|
||||
u.Subtitle = "A1 Slovenija · " + clerkID
|
||||
} else {
|
||||
u.Subtitle = "A1 Slovenija · " + u.Email
|
||||
}
|
||||
default:
|
||||
if u.Name != nil && strings.TrimSpace(*u.Name) != "" {
|
||||
u.Label = strings.TrimSpace(*u.Name)
|
||||
} else {
|
||||
u.Label = u.Email
|
||||
}
|
||||
u.Subtitle = u.Email
|
||||
}
|
||||
}
|
||||
|
||||
// handleAdminDevListSwitchableUsers lists active users with a preferred company label
|
||||
// for the header user-switch dropdown (non-production only).
|
||||
func (s *Server) handleAdminDevListSwitchableUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Config.IsProduction() {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if s.Pool == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "database unavailable")
|
||||
return
|
||||
}
|
||||
_, allowed, err := s.resolveDevImpersonationActor(r.Context())
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "could not authorize user list", err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "user switch not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
activeCompanyID := uuid.Nil
|
||||
if cidStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)); cidStr != "" {
|
||||
if cid, err := uuid.Parse(cidStr); err == nil {
|
||||
activeCompanyID = cid
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.Pool.Query(r.Context(), `
|
||||
SELECT DISTINCT ON (u.id)
|
||||
u.id, u.email, u.name, u.legacy_user_id, m.role, c.id, c.name, COALESCE(c.legacy_company_id, '')
|
||||
FROM users u
|
||||
INNER JOIN memberships m ON m.user_id = u.id AND m.status = 'active'
|
||||
INNER JOIN companies c ON c.id = m.company_id
|
||||
WHERE u.is_active = true
|
||||
ORDER BY u.id,
|
||||
CASE WHEN c.id = $1 THEN 0 ELSE 1 END,
|
||||
CASE WHEN COALESCE(c.legacy_company_id, '') = $2 THEN 0
|
||||
WHEN c.name IN ('A1 Slovenija', 'Local Demo Co') THEN 0
|
||||
ELSE 1 END,
|
||||
c.name ASC`, activeCompanyID, billing.A1LegacyCompanyID)
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "list failed", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]switchableUserRow, 0)
|
||||
for rows.Next() {
|
||||
var u switchableUserRow
|
||||
var legacyCompanyID string
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.LegacyUserID, &u.MembershipRole,
|
||||
&u.CompanyID, &u.CompanyName, &legacyCompanyID,
|
||||
); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "scan failed")
|
||||
return
|
||||
}
|
||||
enrichSwitchableUser(&u, legacyCompanyID)
|
||||
out = append(out, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
ai := out[i].CompanyID == activeCompanyID
|
||||
aj := out[j].CompanyID == activeCompanyID
|
||||
if ai != aj {
|
||||
return ai
|
||||
}
|
||||
if out[i].CompanyLabel != out[j].CompanyLabel {
|
||||
return out[i].CompanyLabel < out[j].CompanyLabel
|
||||
}
|
||||
// Demo admin + primary A1 first within a company group.
|
||||
rank := func(u switchableUserRow) int {
|
||||
if u.IsDemoAdmin {
|
||||
return 0
|
||||
}
|
||||
if u.IsPrimaryA1 {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
ri, rj := rank(out[i]), rank(out[j])
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return strings.ToLower(out[i].Label) < strings.ToLower(out[j].Label)
|
||||
})
|
||||
|
||||
payload := map[string]any{"users": out}
|
||||
if impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey)); impStr != "" {
|
||||
payload["impersonating"] = true
|
||||
if impID, err := uuid.Parse(impStr); err == nil {
|
||||
if impUser, err := s.Auth.GetUser(r.Context(), impID); err == nil {
|
||||
payload["impersonator"] = map[string]any{
|
||||
"id": impUser.ID,
|
||||
"email": impUser.Email,
|
||||
"name": impUser.Name,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, payload)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
s.Config.AppEnv = "production"
|
||||
h := s.Router()
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/admin/users/00000000-0000-0000-0000-000000000001/impersonate",
|
||||
"/api/admin/dev/stop-impersonate",
|
||||
"/api/admin/dev/switchable-users",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
method := http.MethodPost
|
||||
if path == "/api/admin/dev/switchable-users" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
|
||||
// Unauthenticated session yields 401; production must not expose the route as 200/403 from the handler.
|
||||
// Mounted routes behind RequireSession return 401; unmounted chi paths under /api/admin still hit RequireSession then 404 for unknown — either way not a successful switch.
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("%s returned 200 in production", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
)
|
||||
|
||||
func TestCompanyDisplayLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := companyDisplayLabel("A1 Slovenija", billing.A1LegacyCompanyID)
|
||||
if got != "A1 Slovenija" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
got = companyDisplayLabel("Local Demo Co", billing.A1LegacyCompanyID)
|
||||
if got != "A1 Slovenija" {
|
||||
t.Fatalf("legacy rename alias got %q", got)
|
||||
}
|
||||
got = companyDisplayLabel("Other Co", "")
|
||||
if got != "Other Co" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichSwitchableUserLabels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
demo := switchableUserRow{Email: "demo@descrybe.local", CompanyName: "A1 Slovenija"}
|
||||
enrichSwitchableUser(&demo, billing.A1LegacyCompanyID)
|
||||
if !demo.IsDemoAdmin || demo.Label != "Demo admin" {
|
||||
t.Fatalf("demo: %+v", demo)
|
||||
}
|
||||
if demo.CompanyLabel != "A1 Slovenija" {
|
||||
t.Fatalf("company label: %q", demo.CompanyLabel)
|
||||
}
|
||||
|
||||
legacyID := "user_30AqqJ8uepxvPUzDSqy81U5w6Ll"
|
||||
name := "A1 user"
|
||||
primary := switchableUserRow{
|
||||
Email: "a1-primary@descrybe.local",
|
||||
Name: &name,
|
||||
LegacyUserID: &legacyID,
|
||||
CompanyName: "A1 Slovenija",
|
||||
}
|
||||
enrichSwitchableUser(&primary, billing.A1LegacyCompanyID)
|
||||
if !primary.IsPrimaryA1 {
|
||||
t.Fatalf("expected primary A1")
|
||||
}
|
||||
if primary.Label != "A1 · …81U5w6Ll" {
|
||||
t.Fatalf("primary label: %q", primary.Label)
|
||||
}
|
||||
if primary.Subtitle != "A1 Slovenija · user_30AqqJ8uepxvPUzDSqy81U5w6Ll" {
|
||||
t.Fatalf("primary subtitle: %q", primary.Subtitle)
|
||||
}
|
||||
|
||||
// Fallback path: legacy synthetic email still maps via Clerk id.
|
||||
legacyEmailPrimary := switchableUserRow{
|
||||
Email: "user_30aqqj8uepxvpuzdsqy81u5w6ll@legacy.local",
|
||||
LegacyUserID: &legacyID,
|
||||
CompanyName: "A1 Slovenija",
|
||||
}
|
||||
enrichSwitchableUser(&legacyEmailPrimary, billing.A1LegacyCompanyID)
|
||||
if !legacyEmailPrimary.IsPrimaryA1 {
|
||||
t.Fatalf("expected primary via legacy clerk id")
|
||||
}
|
||||
if legacyEmailPrimary.Label != "A1 · …81U5w6Ll" {
|
||||
t.Fatalf("legacy primary label: %q", legacyEmailPrimary.Label)
|
||||
}
|
||||
|
||||
otherID := "user_2tJxuYMnKOx8u9CrMvNA9sU2QMs"
|
||||
other := switchableUserRow{
|
||||
Email: "user_2tjxuymnkox8u9crmvna9su2qms@legacy.local",
|
||||
LegacyUserID: &otherID,
|
||||
CompanyName: "A1 Slovenija",
|
||||
}
|
||||
enrichSwitchableUser(&other, billing.A1LegacyCompanyID)
|
||||
if other.IsPrimaryA1 || other.IsDemoAdmin {
|
||||
t.Fatalf("other should be plain A1 member: %+v", other)
|
||||
}
|
||||
if other.Label != "A1 · …A9sU2QMs" {
|
||||
t.Fatalf("other label: %q", other.Label)
|
||||
}
|
||||
if other.Subtitle != "A1 Slovenija · user_2tJxuYMnKOx8u9CrMvNA9sU2QMs" {
|
||||
t.Fatalf("other subtitle: %q", other.Subtitle)
|
||||
}
|
||||
|
||||
// Non-A1 company with a clerk id must not get A1 labels.
|
||||
nonA1 := switchableUserRow{
|
||||
Email: "user_2tjxuymnkox8u9crmvna9su2qms@legacy.local",
|
||||
LegacyUserID: &otherID,
|
||||
CompanyName: "Other Co",
|
||||
}
|
||||
enrichSwitchableUser(&nonA1, "")
|
||||
if nonA1.IsPrimaryA1 || nonA1.Label == "A1 · …A9sU2QMs" {
|
||||
t.Fatalf("non-A1 company should not use A1 label: %+v", nonA1)
|
||||
}
|
||||
}
|
||||
@@ -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 039–042).
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleAdminDiagnosticsNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
s := &Server{Config: config.Config{UploadDir: dir, AppEnv: "test"}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminDiagnostics(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
if body["status"] != "fail" {
|
||||
t.Fatalf("overall status=%v want fail", body["status"])
|
||||
}
|
||||
if _, ok := body["runtime_metrics"].(map[string]any); !ok {
|
||||
t.Fatalf("expected runtime_metrics object, got %#v", body["runtime_metrics"])
|
||||
}
|
||||
cutover, ok := body["cutover"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected cutover object, got %#v", body["cutover"])
|
||||
}
|
||||
if _, ok := cutover["goose"].(map[string]any); !ok {
|
||||
t.Fatalf("expected cutover.goose object, got %#v", cutover["goose"])
|
||||
}
|
||||
if _, ok := cutover["worker"].(map[string]any); !ok {
|
||||
t.Fatalf("expected cutover.worker object, got %#v", cutover["worker"])
|
||||
}
|
||||
if _, hasPlans := cutover["companies_without_plan"]; hasPlans {
|
||||
t.Fatal("nil pool must omit companies_without_plan (query skipped)")
|
||||
}
|
||||
links, _ := body["links"].(map[string]any)
|
||||
if links["metrics"] != "/metrics" {
|
||||
t.Fatalf("links.metrics=%v want /metrics", links["metrics"])
|
||||
}
|
||||
if links["readiness"] != "/api/admin/readiness" {
|
||||
t.Fatalf("links.readiness=%v want /api/admin/readiness", links["readiness"])
|
||||
}
|
||||
cfg, _ := body["config"].(map[string]any)
|
||||
for _, secretKey := range []string{
|
||||
"database_url", "token_signing_secret", "openai_api_key", "smtp_password",
|
||||
"stripe_secret_key", "pinecone_api_key", "password",
|
||||
} {
|
||||
if _, ok := cfg[secretKey]; ok {
|
||||
t.Fatalf("config must not expose %q", secretKey)
|
||||
}
|
||||
}
|
||||
raw := strings.ToLower(rec.Body.String())
|
||||
for _, leak := range []string{"sk_live", "password=", "postgres://", "bearer "} {
|
||||
if strings.Contains(raw, leak) {
|
||||
t.Fatalf("response leaked secret-like substring %q", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminDiagnosticsInvalidStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{UploadDir: t.TempDir()}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics?status=bogus", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminDiagnostics(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d want 400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiagConfigSanityNeverEmitsSecretValues seeds Config with realistic secrets and
|
||||
// asserts the diagnostics payload only exposes presence flags — never values/DSNs.
|
||||
func TestDiagConfigSanityNeverEmitsSecretValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
AppEnv: "production",
|
||||
UploadDir: t.TempDir(),
|
||||
DatabaseURL: "postgres://descrybe:s3cret@localhost:5433/descrybe",
|
||||
TokenSigningSecret: "super-secret-token-signing-key",
|
||||
OpenAIAPIKey: "sk-abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
PineconeAPIKey: "pcsk_live_example_key_value",
|
||||
StripeSecretKey: "sk_live_51ExampleSecretValue",
|
||||
StripeWebhookSecret: "whsec_example_webhook_secret",
|
||||
SMTPPassword: "smtp-password-value",
|
||||
SMTPHost: "smtp.secret-host.example",
|
||||
SMTPEnabled: true,
|
||||
EmailDryRun: true,
|
||||
CredentialsEncryptionKey: "creds-encryption-key-32bytes!!",
|
||||
ResendAPIKey: "re_example_resend_key",
|
||||
EPRELAPIKey: "eprel-secret-key",
|
||||
WebOrigin: "https://app.example.com",
|
||||
PublicAPIURL: "https://api.example.com",
|
||||
},
|
||||
}
|
||||
stripeCfg := billing.StripeConfig{
|
||||
SecretKey: s.Config.StripeSecretKey,
|
||||
WebhookSecret: s.Config.StripeWebhookSecret,
|
||||
ForceMock: s.Config.StripeMock,
|
||||
}
|
||||
cfg := s.diagConfigSanity(stripeCfg)
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := strings.ToLower(string(raw))
|
||||
for _, leak := range []string{
|
||||
"postgres://", "s3cret", "super-secret-token",
|
||||
"sk-abcdefghijklmnopqrstuvwxyz", "sk_live_51", "whsec_",
|
||||
"smtp-password", "creds-encryption", "re_example", "eprel-secret",
|
||||
"database_url", "openai_api_key", "smtp_password", "smtp.secret-host",
|
||||
} {
|
||||
if strings.Contains(body, strings.ToLower(leak)) {
|
||||
t.Fatalf("config sanity leaked %q in %s", leak, body)
|
||||
}
|
||||
}
|
||||
if cfg["openai_key_set"] != true || cfg["stripe_secret_set"] != true || cfg["stripe_webhook_secret_set"] != true {
|
||||
t.Fatalf("expected presence flags true, got openai=%v stripe=%v webhook=%v",
|
||||
cfg["openai_key_set"], cfg["stripe_secret_set"], cfg["stripe_webhook_secret_set"])
|
||||
}
|
||||
if cfg["smtp_enabled"] != true || cfg["email_dry_run"] != true || cfg["smtp_host_set"] != true {
|
||||
t.Fatalf("expected mail presence flags true, got enabled=%v dry_run=%v host_set=%v",
|
||||
cfg["smtp_enabled"], cfg["email_dry_run"], cfg["smtp_host_set"])
|
||||
}
|
||||
if cfg["stripe_mock_rejected_in_prod"] != true {
|
||||
t.Fatalf("expected stripe_mock_rejected_in_prod=true, got %v", cfg["stripe_mock_rejected_in_prod"])
|
||||
}
|
||||
if _, ok := cfg["database_url"]; ok {
|
||||
t.Fatal("database_url must not appear in config sanity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagMailConfigStatusPresenceOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ready dry-run with host", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
SMTPEnabled: true,
|
||||
SMTPHost: "smtp.secret-host.example",
|
||||
SMTPPassword: "smtp-password-value",
|
||||
EmailDryRun: true,
|
||||
},
|
||||
}
|
||||
check := s.diagMail()
|
||||
if check["status"] != "ok" || check["enabled"] != true || check["dry_run"] != true || check["host_set"] != true {
|
||||
t.Fatalf("check=%v", check)
|
||||
}
|
||||
raw, err := json.Marshal(check)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := strings.ToLower(string(raw))
|
||||
for _, leak := range []string{"smtp.secret-host", "smtp-password", "smtp_password"} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Fatalf("mail check leaked %q in %s", leak, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enabled without host warns", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{SMTPEnabled: true, EmailDryRun: false}}
|
||||
check := s.diagMail()
|
||||
if check["status"] != "warn" || check["host_set"] != false || check["dry_run"] != false {
|
||||
t.Fatalf("check=%v", check)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled noop", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{EmailDryRun: true}}
|
||||
check := s.diagMail()
|
||||
if check["status"] != "ok" || check["enabled"] != false || check["dry_run"] != true {
|
||||
t.Fatalf("check=%v", check)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiagCutoverReadinessNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{AppEnv: "test"}}
|
||||
cutover := s.diagCutoverReadiness(context.Background())
|
||||
if cutover["status"] != "warn" {
|
||||
t.Fatalf("status=%v want warn", cutover["status"])
|
||||
}
|
||||
goose, _ := cutover["goose"].(map[string]any)
|
||||
if goose["status"] != "skip" {
|
||||
t.Fatalf("goose.status=%v want skip", goose["status"])
|
||||
}
|
||||
if _, ok := goose["version_max"]; ok {
|
||||
t.Fatal("nil pool must not invent goose version_max")
|
||||
}
|
||||
worker, _ := cutover["worker"].(map[string]any)
|
||||
if worker["status"] != "unavailable" {
|
||||
t.Fatalf("worker.status=%v want unavailable", worker["status"])
|
||||
}
|
||||
if _, ok := worker["last_seen_age_s"]; ok {
|
||||
t.Fatal("nil pool must omit last_seen_age_s")
|
||||
}
|
||||
if _, ok := cutover["companies_without_plan"]; ok {
|
||||
t.Fatal("nil pool must omit companies_without_plan")
|
||||
}
|
||||
raw, err := json.Marshal(cutover)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := strings.ToLower(string(raw))
|
||||
for _, leak := range []string{"postgres://", "sk_live", "password=", "smtp_password", "bearer "} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Fatalf("cutover leaked %q in %s", leak, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagMigrationInventoryNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{AppEnv: "test"}}
|
||||
inv := s.diagMigrationInventory(context.Background())
|
||||
if inv["status"] != "skip" {
|
||||
t.Fatalf("status=%v want skip", inv["status"])
|
||||
}
|
||||
if inv["jobs_domain_ran"] != false {
|
||||
t.Fatalf("jobs_domain_ran=%v want false", inv["jobs_domain_ran"])
|
||||
}
|
||||
for _, key := range []string{
|
||||
"files_total", "files_metadata_only",
|
||||
"processing_jobs_total", "processing_jobs_migrated", "tasks_total",
|
||||
} {
|
||||
n, ok := inv[key].(int64)
|
||||
if !ok || n != 0 {
|
||||
t.Fatalf("%s=%v want int64(0)", key, inv[key])
|
||||
}
|
||||
}
|
||||
notes, ok := inv["notes"].([]string)
|
||||
if !ok || len(notes) == 0 {
|
||||
t.Fatalf("notes=%v want non-empty []string", inv["notes"])
|
||||
}
|
||||
raw, err := json.Marshal(inv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := strings.ToLower(string(raw))
|
||||
for _, leak := range []string{"postgres://", "sk_live", "password=", "/var/", "c:\\"} {
|
||||
if strings.Contains(body, leak) {
|
||||
t.Fatalf("migration inventory leaked %q in %s", leak, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminDiagnosticsIncludesMigrationInventory(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
s := &Server{Config: config.Config{UploadDir: dir, AppEnv: "test"}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminDiagnostics(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
inv, ok := body["migration_inventory"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("migration_inventory missing: %#v", body["migration_inventory"])
|
||||
}
|
||||
if inv["status"] != "skip" {
|
||||
t.Fatalf("migration_inventory.status=%v want skip (nil pool)", inv["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagStripeReadiness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("prod mock fails", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
check, ok := diagStripeReadiness(true, billing.StripeConfig{
|
||||
SecretKey: "sk_live_x", WebhookSecret: "whsec_x", ForceMock: true,
|
||||
})
|
||||
if ok || check["status"] != "fail" || check["mock_rejected_in_prod"] != false {
|
||||
t.Fatalf("check=%v ok=%v", check, ok)
|
||||
}
|
||||
raw, _ := json.Marshal(check)
|
||||
if strings.Contains(strings.ToLower(string(raw)), "sk_live") || strings.Contains(string(raw), "whsec_") {
|
||||
t.Fatalf("leaked secret material: %s", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prod keys present", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
check, ok := diagStripeReadiness(true, billing.StripeConfig{
|
||||
SecretKey: "sk_live_x", WebhookSecret: "whsec_x",
|
||||
})
|
||||
if !ok || check["status"] != "ok" || check["secret_key_set"] != true || check["webhook_secret_set"] != true {
|
||||
t.Fatalf("check=%v ok=%v", check, ok)
|
||||
}
|
||||
if check["mock_rejected_in_prod"] != true {
|
||||
t.Fatalf("mock_rejected_in_prod=%v", check["mock_rejected_in_prod"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prod missing webhook warns", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
check, ok := diagStripeReadiness(true, billing.StripeConfig{SecretKey: "sk_live_x"})
|
||||
if ok || check["status"] != "warn" || check["webhook_secret_set"] != false {
|
||||
t.Fatalf("check=%v ok=%v", check, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dev mock ok", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
check, ok := diagStripeReadiness(false, billing.StripeConfig{ForceMock: true})
|
||||
if !ok || check["status"] != "ok" || check["mock"] != true {
|
||||
t.Fatalf("check=%v ok=%v", check, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiagJobErrorUsesTruncateError(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Contract lock: job error strings must go through TruncateError before JSON.
|
||||
secretish := "provider failed authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz012345"
|
||||
redacted := processing.TruncateError(errors.New(secretish))
|
||||
if strings.Contains(strings.ToLower(redacted), "sk-abcdef") || strings.Contains(strings.ToLower(redacted), "bearer sk-") {
|
||||
t.Fatalf("TruncateError did not redact: %q", redacted)
|
||||
}
|
||||
if redacted == "" {
|
||||
t.Fatal("expected non-empty redacted message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminDiagnosticsStorageWritable(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
s := &Server{Config: config.Config{UploadDir: dir}}
|
||||
check, ok := s.diagStorage()
|
||||
if !ok {
|
||||
t.Fatalf("expected writable storage check=%v", check)
|
||||
}
|
||||
if check["status"] != "ok" {
|
||||
t.Fatalf("status=%v", check["status"])
|
||||
}
|
||||
// Absolute path must not appear in detail.
|
||||
if detail, _ := check["detail"].(string); filepath.IsAbs(detail) {
|
||||
t.Fatalf("detail must not be absolute path: %q", detail)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() == ".diag_write_probe" {
|
||||
t.Fatal("probe file should be removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterAdminDiagnosticsMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
UploadDir: t.TempDir(),
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == uid, nil
|
||||
},
|
||||
}
|
||||
|
||||
var token string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := s.Router()
|
||||
|
||||
unauth := httptest.NewRecorder()
|
||||
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil))
|
||||
if unauth.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
|
||||
}
|
||||
|
||||
mounted := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/diagnostics", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
h.ServeHTTP(mounted, req)
|
||||
if mounted.Code == http.StatusNotFound {
|
||||
t.Fatalf("diagnostics not mounted: status=404 body=%s", mounted.Body.String())
|
||||
}
|
||||
if mounted.Code != http.StatusOK {
|
||||
t.Fatalf("mounted status=%d want 200 body=%s", mounted.Code, mounted.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
adminSetPasswordBulkLimit = 100
|
||||
adminSetPasswordReqPerMin = 5
|
||||
adminSetPasswordSendPerMin = 60
|
||||
)
|
||||
|
||||
// handleAdminListUsers / handleAdminListCompanies live in admin_orgs_handlers.go.
|
||||
|
||||
// handleAdminReadiness returns cutover hypercare counts for platform admins (P1-15).
|
||||
// GET /api/admin/readiness
|
||||
//
|
||||
// companies_without_api_keys counts tenants with zero non-revoked keys. Legacy
|
||||
// api_keys were never ETL'd — this is the reissue inventory (not a fake migration).
|
||||
func (s *Server) handleAdminReadiness(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Pool == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "database unavailable")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var mustSetPassword, withoutAdmin, withoutPlan, withoutAPIKeys int64
|
||||
|
||||
if err := s.Pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users
|
||||
WHERE must_set_password = true AND is_active = true),
|
||||
(SELECT COUNT(*) FROM companies c
|
||||
WHERE c.id <> $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM memberships m
|
||||
WHERE m.company_id = c.id AND m.role = 'admin' AND m.status = 'active'
|
||||
)),
|
||||
(SELECT COUNT(*) 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
|
||||
)),
|
||||
(SELECT COUNT(*) 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(&mustSetPassword, &withoutAdmin, &withoutPlan, &withoutAPIKeys); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "readiness counts failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"must_set_password": mustSetPassword,
|
||||
"companies_without_admin": withoutAdmin,
|
||||
"companies_without_plan": withoutPlan,
|
||||
"companies_without_api_keys": withoutAPIKeys,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListJobs(w http.ResponseWriter, r *http.Request) {
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
rows, err := s.Pool.Query(r.Context(), `
|
||||
SELECT id, company_id, status, total_products, processed_products, error, created_at, updated_at
|
||||
FROM processing_jobs
|
||||
ORDER BY created_at DESC LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type row struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
Status string `json:"status"`
|
||||
TotalProducts int `json:"total_products"`
|
||||
ProcessedProducts int `json:"processed_products"`
|
||||
Error *string `json:"error"`
|
||||
CreatedAt any `json:"created_at"`
|
||||
UpdatedAt any `json:"updated_at"`
|
||||
}
|
||||
out := make([]row, 0)
|
||||
for rows.Next() {
|
||||
var j row
|
||||
if err := rows.Scan(&j.ID, &j.CompanyID, &j.Status, &j.TotalProducts, &j.ProcessedProducts, &j.Error, &j.CreatedAt, &j.UpdatedAt); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "scan failed")
|
||||
return
|
||||
}
|
||||
if j.Error != nil && *j.Error != "" {
|
||||
redacted := processing.TruncateError(errors.New(*j.Error))
|
||||
j.Error = &redacted
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"jobs": out, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminStuckCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := processing.CleanupStuck(r.Context(), s.Pool)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "cleanup failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"jobs_marked_failed": res.JobsMarkedFailed,
|
||||
"products_reset": res.ProductsReset,
|
||||
"sync_jobs_marked_failed": res.SyncJobsMarkedFailed,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminOrphanProcessedReport(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := processing.ReportOrphanProcessed(r.Context(), s.Pool)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "orphan report failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminOrphanProcessedCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
confirm := r.URL.Query().Get("confirm") == "true"
|
||||
var body struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
}
|
||||
if err := DecodeJSONOptional(r, &body); err == nil && body.Confirm {
|
||||
confirm = true
|
||||
}
|
||||
res, err := processing.CleanupOrphanProcessed(r.Context(), s.Pool, confirm)
|
||||
if err != nil {
|
||||
if errors.Is(err, processing.ErrOrphanCleanupEmpty) ||
|
||||
errors.Is(err, processing.ErrOrphanCleanupA1Protected) {
|
||||
if msg, ok := processing.ClientError(err); ok {
|
||||
Error(w, http.StatusConflict, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "orphan cleanup failed")
|
||||
return
|
||||
}
|
||||
if !confirm {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"dry_run": true,
|
||||
"deleted": 0,
|
||||
"message": "pass confirm=true (query or JSON body) to delete; report only",
|
||||
"report": res,
|
||||
})
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *Server) ensureAdminSetPasswordLimiters() {
|
||||
s.adminSetPasswordOnce.Do(func() {
|
||||
s.adminSetPasswordReqRL = newSlidingWindowLimiter(adminSetPasswordReqPerMin, time.Minute)
|
||||
s.adminSetPasswordSendRL = newSlidingWindowLimiter(adminSetPasswordSendPerMin, time.Minute)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Mail == nil || s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "mailer unavailable")
|
||||
return
|
||||
}
|
||||
adminID, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
s.ensureAdminSetPasswordLimiters()
|
||||
reqKey := "admin-set-password:" + adminID.String()
|
||||
if !s.adminSetPasswordReqRL.allow(reqKey) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
UserID *uuid.UUID `json:"user_id"`
|
||||
}
|
||||
if err := DecodeJSONOptional(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
|
||||
var targets []uuid.UUID
|
||||
if body.UserID != nil {
|
||||
targets = []uuid.UUID{*body.UserID}
|
||||
} else {
|
||||
users, err := s.Auth.ListUsersNeedingPassword(r.Context(), adminSetPasswordBulkLimit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
for _, u := range users {
|
||||
targets = append(targets, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
smtpOn := s.Mail.Enabled()
|
||||
sent := 0
|
||||
issued := 0
|
||||
skippedSynthetic := 0
|
||||
skippedIneligible := 0
|
||||
skippedRateLimited := 0
|
||||
skippedSend := 0
|
||||
var singleToken string
|
||||
singleUser := body.UserID != nil
|
||||
|
||||
for _, uid := range targets {
|
||||
sendKey := "admin-set-password-send:" + adminID.String()
|
||||
if !s.adminSetPasswordSendRL.allow(sendKey) {
|
||||
skippedRateLimited++
|
||||
if singleUser {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
token, email, mode, err := s.issueSetPasswordDelivery(r.Context(), uid)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrSyntheticEmail):
|
||||
skippedSynthetic++
|
||||
default:
|
||||
skippedIneligible++
|
||||
}
|
||||
continue
|
||||
}
|
||||
issued++
|
||||
|
||||
var msg mail.Message
|
||||
if mode == "invite" {
|
||||
msg = mail.MigratedSetPasswordMessage(s.Config.WebOrigin, email, token)
|
||||
} else {
|
||||
msg = mail.SetPasswordMessage(s.Config.WebOrigin, email, token)
|
||||
}
|
||||
if err := s.Mail.Send(msg); err != nil {
|
||||
log.Printf("admin set-password send failed user_id=%s", uid)
|
||||
skippedSend++
|
||||
continue
|
||||
}
|
||||
if smtpOn {
|
||||
sent++
|
||||
} else if singleUser {
|
||||
// Share token only for single-user reissue when SMTP is off (no email in response).
|
||||
singleToken = token
|
||||
}
|
||||
}
|
||||
|
||||
skipped := skippedSynthetic + skippedIneligible + skippedRateLimited + skippedSend
|
||||
resp := map[string]any{
|
||||
"sent": sent,
|
||||
"issued": issued,
|
||||
"skipped": skipped,
|
||||
"skipped_synthetic": skippedSynthetic,
|
||||
"skipped_ineligible": skippedIneligible,
|
||||
"skipped_rate_limited": skippedRateLimited,
|
||||
"skipped_send": skippedSend,
|
||||
"smtp_enabled": smtpOn,
|
||||
"mode": "invite",
|
||||
}
|
||||
if singleToken != "" {
|
||||
resp["token"] = singleToken
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// issueSetPasswordDelivery prefers a durable invite; falls back to HMAC when the user
|
||||
// still needs a password but has no active membership. Never logs email or token.
|
||||
func (s *Server) issueSetPasswordDelivery(ctx context.Context, userID uuid.UUID) (token, email, mode string, err error) {
|
||||
inv, err := s.Auth.ReissueSetPasswordInvite(ctx, userID, 0)
|
||||
if err == nil {
|
||||
return inv.Token, inv.Email, "invite", nil
|
||||
}
|
||||
if errors.Is(err, auth.ErrSyntheticEmail) {
|
||||
return "", "", "", err
|
||||
}
|
||||
if !errors.Is(err, auth.ErrNotEligibleSetPassword) && !errors.Is(err, auth.ErrUserNotFound) {
|
||||
log.Printf("admin set-password invite failed user_id=%s", userID)
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
u, gerr := s.Auth.GetUser(ctx, userID)
|
||||
if gerr != nil || !u.MustSetPassword || !u.IsActive {
|
||||
return "", "", "", auth.ErrNotEligibleSetPassword
|
||||
}
|
||||
if auth.IsSyntheticLegacyEmail(u.Email) {
|
||||
return "", "", "", auth.ErrSyntheticEmail
|
||||
}
|
||||
token, terr := auth.IssueSetPasswordToken(s.Config.TokenSigningSecret, u.ID, 0)
|
||||
if terr != nil {
|
||||
log.Printf("admin hmac set-password token failed user_id=%s", userID)
|
||||
return "", "", "", auth.ErrNotEligibleSetPassword
|
||||
}
|
||||
return token, u.Email, "hmac", nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
||||
)
|
||||
|
||||
// POST /api/admin/settings/mail/test — send a one-off SMTP probe using platform settings.
|
||||
func (s *Server) handleAdminTestMail(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Mail == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "mailer unavailable")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
To string `json:"to"`
|
||||
}
|
||||
if err := DecodeJSONOptional(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
to := strings.TrimSpace(body.To)
|
||||
if to == "" {
|
||||
if email, ok := s.sessionUserEmail(r.Context()); ok {
|
||||
to = email
|
||||
}
|
||||
}
|
||||
if to == "" {
|
||||
Error(w, http.StatusBadRequest, "to is required")
|
||||
return
|
||||
}
|
||||
normalized, err := campaigns.NormalizeEmail(to)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid email")
|
||||
return
|
||||
}
|
||||
to = normalized
|
||||
if s.PlatformSettings != nil {
|
||||
if dry, err := s.PlatformSettings.ResolveEmailDryRun(r.Context()); err == nil && dry.DryRun {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "skipped",
|
||||
"smtp_enabled": false,
|
||||
"dry_run": true,
|
||||
"message": "Email dry-run is on; disable dry-run in admin platform mail settings to send a real probe",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
enabled := s.Mail.Enabled()
|
||||
if !enabled {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "skipped",
|
||||
"smtp_enabled": false,
|
||||
"message": "SMTP is not configured in admin platform mail settings",
|
||||
})
|
||||
return
|
||||
}
|
||||
msg := mail.Message{
|
||||
To: to,
|
||||
Subject: "Descrybe SMTP test",
|
||||
Text: "This is a Descrybe platform SMTP test message.",
|
||||
HTML: "<p>This is a Descrybe platform SMTP test message.</p>",
|
||||
}
|
||||
if err := s.Mail.Send(msg); err != nil {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "failed",
|
||||
"smtp_enabled": true,
|
||||
"message": "SMTP send failed — check host/credentials in platform mail settings",
|
||||
})
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"smtp_enabled": true,
|
||||
"message": "Test message accepted by SMTP",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// handleAdminListUsers returns a paginated user directory for platform admins.
|
||||
// Query: limit, offset, q|search, staff_only, active_only, inactive_only.
|
||||
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
qSearch := QuerySearch(r)
|
||||
staffOnly := QueryTruthy(r, "staff_only")
|
||||
activeOnly := QueryTruthy(r, "active_only")
|
||||
inactiveOnly := QueryTruthy(r, "inactive_only")
|
||||
|
||||
where := "WHERE 1=1"
|
||||
args := make([]any, 0, 6)
|
||||
next := 1
|
||||
addArg := func(v any) string {
|
||||
args = append(args, v)
|
||||
placeholder := "$" + strconv.Itoa(next)
|
||||
next++
|
||||
return placeholder
|
||||
}
|
||||
|
||||
if staffOnly {
|
||||
where += " AND (is_platform_admin = true OR staff_role IS NOT NULL)"
|
||||
}
|
||||
if activeOnly && !inactiveOnly {
|
||||
where += " AND is_active = true"
|
||||
}
|
||||
if inactiveOnly && !activeOnly {
|
||||
where += " AND is_active = false"
|
||||
}
|
||||
if qSearch != "" {
|
||||
p := addArg("%" + qSearch + "%")
|
||||
where += " AND (email ILIKE " + p + " OR COALESCE(name, '') ILIKE " + p + ")"
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.Pool.QueryRow(r.Context(), "SELECT COUNT(*) FROM users "+where, args...).Scan(&total); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "count failed")
|
||||
return
|
||||
}
|
||||
|
||||
limitP := addArg(limit)
|
||||
offsetP := addArg(offset)
|
||||
rows, err := s.Pool.Query(r.Context(), `
|
||||
SELECT id, email, name, must_set_password, is_platform_admin, staff_role, is_active, created_at
|
||||
FROM users `+where+`
|
||||
ORDER BY created_at DESC LIMIT `+limitP+` OFFSET `+offsetP, args...)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type row struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
MustSetPassword bool `json:"must_set_password"`
|
||||
IsPlatformAdmin bool `json:"is_platform_admin"`
|
||||
StaffRole *string `json:"staff_role,omitempty"`
|
||||
ResolvedRole string `json:"resolved_role,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt any `json:"created_at"`
|
||||
}
|
||||
out := make([]row, 0)
|
||||
for rows.Next() {
|
||||
var u row
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "scan failed")
|
||||
return
|
||||
}
|
||||
stored := ""
|
||||
if u.StaffRole != nil {
|
||||
stored = *u.StaffRole
|
||||
}
|
||||
u.ResolvedRole = auth.ResolveStaffRole(u.IsPlatformAdmin, stored)
|
||||
out = append(out, u)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"users": out,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminListCompanies returns paginated companies with active plan summary.
|
||||
// Query: limit, offset, q|search, without_active_plan, without_api_keys.
|
||||
// without_api_keys filters tenants with no non-revoked keys (cutover reissue inventory).
|
||||
func (s *Server) handleAdminListCompanies(w http.ResponseWriter, r *http.Request) {
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
withoutPlan := QueryTruthy(r, "without_active_plan")
|
||||
withoutAPIKeys := QueryTruthy(r, "without_api_keys")
|
||||
qSearch := QuerySearch(r)
|
||||
|
||||
where := "WHERE c.id <> $1"
|
||||
args := []any{platformsettings.SystemCompanyID}
|
||||
next := 2
|
||||
addArg := func(v any) string {
|
||||
args = append(args, v)
|
||||
placeholder := "$" + strconv.Itoa(next)
|
||||
next++
|
||||
return placeholder
|
||||
}
|
||||
|
||||
if withoutPlan {
|
||||
where += `
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM company_plans cp0
|
||||
WHERE cp0.company_id = c.id AND cp0.is_active = true
|
||||
)`
|
||||
}
|
||||
if withoutAPIKeys {
|
||||
where += `
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM api_keys k0
|
||||
WHERE k0.company_id = c.id AND k0.revoked_at IS NULL
|
||||
)`
|
||||
}
|
||||
if qSearch != "" {
|
||||
p := addArg("%" + qSearch + "%")
|
||||
where += " AND (c.name ILIKE " + p + " OR c.id::text ILIKE " + p + ")"
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.Pool.QueryRow(r.Context(), "SELECT COUNT(*) FROM companies c "+where, args...).Scan(&total); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "count failed")
|
||||
return
|
||||
}
|
||||
|
||||
limitP := addArg(limit)
|
||||
offsetP := addArg(offset)
|
||||
q := `
|
||||
SELECT c.id, c.name, c.language, c.created_at,
|
||||
COALESCE(cb.total_credits, 0), COALESCE(cb.used_credits, 0),
|
||||
cp.plan_id IS NOT NULL AS has_active_plan,
|
||||
cp.plan_id, p.name, COALESCE(p.is_custom, false),
|
||||
EXISTS (
|
||||
SELECT 1 FROM api_keys k
|
||||
WHERE k.company_id = c.id AND k.revoked_at IS NULL
|
||||
) AS has_api_key
|
||||
FROM companies c
|
||||
LEFT JOIN credit_balances cb ON cb.company_id = c.id
|
||||
LEFT JOIN company_plans cp ON cp.company_id = c.id AND cp.is_active = true
|
||||
LEFT JOIN plans p ON p.id = cp.plan_id
|
||||
` + where + `
|
||||
ORDER BY c.created_at DESC LIMIT ` + limitP + ` OFFSET ` + offsetP
|
||||
rows, err := s.Pool.Query(r.Context(), q, args...)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type row struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
CreatedAt any `json:"created_at"`
|
||||
TotalCredits int `json:"total_credits"`
|
||||
UsedCredits int `json:"used_credits"`
|
||||
HasActivePlan bool `json:"has_active_plan"`
|
||||
PlanID *int64 `json:"plan_id,omitempty"`
|
||||
PlanName *string `json:"plan_name,omitempty"`
|
||||
PlanIsCustom bool `json:"plan_is_custom,omitempty"`
|
||||
PlanIsLegacy bool `json:"plan_is_legacy,omitempty"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
}
|
||||
out := make([]row, 0)
|
||||
for rows.Next() {
|
||||
var c row
|
||||
var planID *int64
|
||||
var planName *string
|
||||
var isCustom bool
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Language, &c.CreatedAt, &c.TotalCredits, &c.UsedCredits, &c.HasActivePlan, &planID, &planName, &isCustom, &c.HasAPIKey); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "scan failed")
|
||||
return
|
||||
}
|
||||
c.PlanID = planID
|
||||
c.PlanName = planName
|
||||
c.PlanIsCustom = isCustom
|
||||
if planName != nil {
|
||||
c.PlanIsLegacy = billing.IsLegacyPlan(*planName, false)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"companies": out,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"without_active_plan": withoutPlan,
|
||||
"without_api_keys": withoutAPIKeys,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleAdminReadinessNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/readiness", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminReadiness(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterAdminReadinessMounted locks the P1-15 SPA contract: after session +
|
||||
// platform-admin gates, GET /api/admin/readiness must reach the handler (503 with
|
||||
// nil pool), not chi 404. Unauthed probes alone cannot prove the mount — any
|
||||
// /api/admin/* returns 401 from RequireSession whether or not /readiness exists.
|
||||
func TestRouterAdminReadinessMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == uid, nil
|
||||
},
|
||||
}
|
||||
|
||||
var token string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := s.Router()
|
||||
|
||||
unauth := httptest.NewRecorder()
|
||||
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/readiness", nil))
|
||||
if unauth.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
|
||||
}
|
||||
|
||||
mounted := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/readiness", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
h.ServeHTTP(mounted, req)
|
||||
if mounted.Code == http.StatusNotFound {
|
||||
t.Fatalf("readiness not mounted: status=404 body=%s", mounted.Body.String())
|
||||
}
|
||||
if mounted.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("mounted status=%d want 503 (nil pool) body=%s", mounted.Code, mounted.Body.String())
|
||||
}
|
||||
|
||||
missing := httptest.NewRecorder()
|
||||
missReq := httptest.NewRequest(http.MethodGet, "/api/admin/does-not-exist", nil)
|
||||
missReq.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
h.ServeHTTP(missing, missReq)
|
||||
if missing.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown admin path status=%d want 404 body=%s", missing.Code, missing.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type recordingMailer struct {
|
||||
enabled bool
|
||||
sent []mail.Message
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *recordingMailer) Enabled() bool { return m.enabled }
|
||||
|
||||
func (m *recordingMailer) Send(msg mail.Message) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.sent = append(m.sent, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandleAdminSendSetPasswordEmailsUnauthorized(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Mail: &recordingMailer{enabled: true}, Auth: &auth.Service{}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/emails/set-password", bytes.NewBufferString("{}"))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminSendSetPasswordEmails(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSendSetPasswordEmailsMailerRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
adminID := uuid.New()
|
||||
s := &Server{Auth: &auth.Service{}}
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, adminID)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/emails/set-password", bytes.NewBufferString("{}"))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminSendSetPasswordEmails(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSendSetPasswordEmailsRateLimited(t *testing.T) {
|
||||
t.Parallel()
|
||||
adminID := uuid.New()
|
||||
s := &Server{
|
||||
Mail: &recordingMailer{enabled: true},
|
||||
Auth: &auth.Service{},
|
||||
}
|
||||
s.ensureAdminSetPasswordLimiters()
|
||||
s.adminSetPasswordReqRL = newSlidingWindowLimiter(1, time.Minute)
|
||||
reqKey := "admin-set-password:" + adminID.String()
|
||||
if !s.adminSetPasswordReqRL.allow(reqKey) {
|
||||
t.Fatal("setup: expected first allow")
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, adminID)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/emails/set-password", bytes.NewBufferString("{}"))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminSendSetPasswordEmails(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status=%d want 429 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("expected Retry-After header")
|
||||
}
|
||||
raw := rec.Body.String()
|
||||
if strings.Contains(raw, "@") {
|
||||
t.Fatalf("rate-limit response must not include email addresses: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSendSetPasswordEmailsSendRateLimitedSingleUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
adminID := uuid.New()
|
||||
targetID := uuid.New()
|
||||
s := &Server{
|
||||
Mail: &recordingMailer{enabled: true},
|
||||
Auth: &auth.Service{},
|
||||
}
|
||||
s.ensureAdminSetPasswordLimiters()
|
||||
s.adminSetPasswordReqRL = newSlidingWindowLimiter(10, time.Minute)
|
||||
s.adminSetPasswordSendRL = newSlidingWindowLimiter(1, time.Minute)
|
||||
sendKey := "admin-set-password-send:" + adminID.String()
|
||||
if !s.adminSetPasswordSendRL.allow(sendKey) {
|
||||
t.Fatal("setup: expected first send allow")
|
||||
}
|
||||
|
||||
body := `{"user_id":"` + targetID.String() + `"}`
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, adminID)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/emails/set-password", bytes.NewBufferString(body))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminSendSetPasswordEmails(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status=%d want 429 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("expected Retry-After header")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestRouterAdminSettingsAIConfigPresent locks GET /api/admin/settings AI surface:
|
||||
// legacy openai block always; multi-role ai_roles with all catalog roles masked.
|
||||
func TestRouterAdminSettingsAIConfigPresent(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
PlatformSettings: platformsettings.NewService(nil, platformsettings.EnvConfig{}),
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == uid, nil
|
||||
},
|
||||
}
|
||||
|
||||
var token string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := s.Router()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
openai, ok := body["openai"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing openai object: %s", rec.Body.String())
|
||||
}
|
||||
for _, key := range []string{"configured", "has_api_key", "source"} {
|
||||
if _, ok := openai[key]; !ok {
|
||||
t.Fatalf("openai missing %q: %#v", key, openai)
|
||||
}
|
||||
}
|
||||
if raw, exists := openai["api_key"]; exists && raw != nil && raw != "" {
|
||||
t.Fatalf("openai must not leak api_key, got %#v", raw)
|
||||
}
|
||||
|
||||
rawConfigs, hasConfigs := body["ai_roles"]
|
||||
if !hasConfigs || rawConfigs == nil {
|
||||
t.Fatalf("ai_roles missing body=%s", rec.Body.String())
|
||||
}
|
||||
configs, ok := rawConfigs.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("ai_roles type=%T want object body=%s", rawConfigs, rec.Body.String())
|
||||
}
|
||||
for _, role := range platformsettings.AIRoles {
|
||||
slot, ok := configs[role].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("ai_roles missing role %q: %#v", role, configs)
|
||||
}
|
||||
if slot["role"] != role {
|
||||
t.Fatalf("role %q slot.role=%v", role, slot["role"])
|
||||
}
|
||||
if raw, exists := slot["api_key"]; exists && raw != nil && raw != "" {
|
||||
t.Fatalf("ai_roles.%s must not leak api_key", role)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
)
|
||||
|
||||
// GET /api/admin/settings — platform integration config (secrets masked).
|
||||
func (s *Server) handleGetAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if s.PlatformSettings == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "platform settings unavailable")
|
||||
return
|
||||
}
|
||||
view, err := s.PlatformSettings.GetPublic(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load platform settings")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// PUT /api/admin/settings — partial update; omit secrets to keep existing.
|
||||
func (s *Server) handlePutAdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if s.PlatformSettings == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "platform settings unavailable")
|
||||
return
|
||||
}
|
||||
var body platformsettings.UpdateInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
view, err := s.PlatformSettings.Update(r.Context(), body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update platform settings", err, platformsettings.ClientError)
|
||||
return
|
||||
}
|
||||
// OpenAI / ai_roles / SMTP / OAuth / Stripe / EPREL resolve at use time — no client cache to drop.
|
||||
// Feed private allowlist is process-global; refresh immediately after a successful PUT.
|
||||
if body.Values != nil {
|
||||
if _, ok := body.Values[platformsettings.KeyFeedPrivateAllowlist]; ok {
|
||||
csv, _ := s.PlatformSettings.ResolveFeedPrivateAllowlist(r.Context())
|
||||
feeds.ApplyPrivateAllowlistCSV(csv)
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestRouterAdminSettingsMounted locks GET /api/admin/settings after session +
|
||||
// platform-admin gates (503 with nil pool / nil service path, not chi 404).
|
||||
func TestRouterAdminSettingsMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
PlatformSettings: platformsettings.NewService(nil, platformsettings.EnvConfig{}),
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == uid, nil
|
||||
},
|
||||
}
|
||||
|
||||
var token string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := s.Router()
|
||||
|
||||
unauth := httptest.NewRecorder()
|
||||
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil))
|
||||
if unauth.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
|
||||
}
|
||||
|
||||
mounted := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/settings", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
h.ServeHTTP(mounted, req)
|
||||
if mounted.Code == http.StatusNotFound {
|
||||
t.Fatalf("settings not mounted: status=404 body=%s", mounted.Body.String())
|
||||
}
|
||||
if mounted.Code != http.StatusOK {
|
||||
t.Fatalf("mounted status=%d want 200 body=%s", mounted.Code, mounted.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// handleAdminListStaff returns platform staff users (admin|developer only).
|
||||
// GET /api/admin/staff
|
||||
func (s *Server) handleAdminListStaff(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||
return
|
||||
}
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
users, err := s.Auth.ListStaffUsers(r.Context(), limit, offset)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"staff": users,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminSetStaffRole assigns or clears a platform staff role (admin|developer only).
|
||||
// PATCH /api/admin/users/{id}/staff-role
|
||||
// Body: {"staff_role":"admin"|"developer"|"support_staff"|null}
|
||||
func (s *Server) handleAdminSetStaffRole(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||
return
|
||||
}
|
||||
actorID, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
targetID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if targetID == actorID {
|
||||
Error(w, http.StatusForbidden, "cannot change own staff role")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
StaffRole *string `json:"staff_role"`
|
||||
}
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
role := ""
|
||||
if body.StaffRole != nil {
|
||||
role = strings.TrimSpace(*body.StaffRole)
|
||||
}
|
||||
user, err := s.Auth.SetStaffRole(r.Context(), targetID, role)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrInvalidStaffRole) {
|
||||
Error(w, http.StatusBadRequest, "invalid staff_role")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrStaffUserNotFound) {
|
||||
Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"user": user,
|
||||
"staff_capabilities": auth.StaffCapabilities(user.ResolvedRole),
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminSetSupportAgent grants or revokes support_staff only (full-admin exclusive).
|
||||
// PUT /api/admin/support/agents/{id}
|
||||
// Body: {"enabled": true|false} or {"is_support_agent": true|false}
|
||||
func (s *Server) handleAdminSetSupportAgent(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
actorID, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
targetID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if targetID == actorID {
|
||||
Error(w, http.StatusForbidden, "cannot change own support agent flag")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
IsSupportAgent *bool `json:"is_support_agent"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
enable := false
|
||||
switch {
|
||||
case body.Enabled != nil:
|
||||
enable = *body.Enabled
|
||||
case body.IsSupportAgent != nil:
|
||||
enable = *body.IsSupportAgent
|
||||
default:
|
||||
Error(w, http.StatusBadRequest, "enabled required")
|
||||
return
|
||||
}
|
||||
agent, err := s.Support.SetSupportAgent(r.Context(), targetID, enable)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "update failed", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"agent": agent})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleAdminSetStaffRoleRejectsSelf(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{Auth: &auth.Service{}}
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/users/"+uid.String()+"/staff-role",
|
||||
bytes.NewBufferString(`{"staff_role":"support_staff"}`))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", uid.String())
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminSetStaffRole(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminSetStaffRoleInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
actor := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
target := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
s := &Server{Auth: &auth.Service{}}
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/users/"+target.String()+"/staff-role",
|
||||
bytes.NewBufferString(`{`))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, actor)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("id", target.String())
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminSetStaffRole(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleAdminListStoreReconnectGapsNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/stores/reconnect-needed", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminListStoreReconnectGaps(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestListStoreReconnectGapsNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
inv, err := ListStoreReconnectGaps(context.Background(), nil, 10, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil pool")
|
||||
}
|
||||
if inv.Stores == nil {
|
||||
t.Fatal("expected non-nil stores slice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterAdminStoreReconnectMounted locks GET /api/admin/stores/reconnect-needed
|
||||
// after session + platform-admin (503 with nil pool), not chi 404.
|
||||
func TestRouterAdminStoreReconnectMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == uid, nil
|
||||
},
|
||||
}
|
||||
|
||||
var token string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := s.Router()
|
||||
|
||||
unauth := httptest.NewRecorder()
|
||||
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/stores/reconnect-needed", nil))
|
||||
if unauth.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
|
||||
}
|
||||
|
||||
mounted := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/stores/reconnect-needed", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
h.ServeHTTP(mounted, req)
|
||||
if mounted.Code == http.StatusNotFound {
|
||||
t.Fatalf("reconnect-needed not mounted: status=404 body=%s", mounted.Body.String())
|
||||
}
|
||||
if mounted.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("mounted status=%d want 503 (nil pool) body=%s", mounted.Code, mounted.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
)
|
||||
|
||||
// POST /api/admin/settings/stripe/sync-credit-packs
|
||||
// Creates/updates Stripe Products + one-time Prices for DefaultCreditPacks using
|
||||
// the configured secret key (sk_test_* or sk_live_*), then writes Price IDs into
|
||||
// platform settings (stripe.price.pack.*).
|
||||
func (s *Server) handleAdminSyncStripeCreditPacks(w http.ResponseWriter, r *http.Request) {
|
||||
if s.PlatformSettings == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "platform settings unavailable")
|
||||
return
|
||||
}
|
||||
if s.Stripe == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "stripe unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := s.PlatformSettings.ResolveStripe(r.Context(), s.Stripe.Cfg)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to resolve stripe settings")
|
||||
return
|
||||
}
|
||||
secret := strings.TrimSpace(cfg.SecretKey)
|
||||
if secret == "" || cfg.ForceMock {
|
||||
Error(w, http.StatusBadRequest, "configure a Stripe secret key (test or live) and turn mock off before syncing")
|
||||
return
|
||||
}
|
||||
|
||||
mode := "live"
|
||||
if strings.HasPrefix(secret, "sk_test_") {
|
||||
mode = "test"
|
||||
} else if !strings.HasPrefix(secret, "sk_live_") {
|
||||
mode = "unknown"
|
||||
}
|
||||
|
||||
svc := &billing.StripeService{
|
||||
Pool: s.Stripe.Pool,
|
||||
Cfg: billing.StripeConfig{SecretKey: secret},
|
||||
}
|
||||
results, err := svc.SyncCreditPackProducts(r.Context())
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not sync credit packs to Stripe", err, billing.ClientError)
|
||||
return
|
||||
}
|
||||
|
||||
written := make([]map[string]any, 0, len(results))
|
||||
for _, row := range results {
|
||||
key := billing.CreditPackSettingsKey(row.PackID)
|
||||
if err := s.PlatformSettings.SetKV(r.Context(), key, row.PriceID); err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "synced Stripe but failed to save price id", err, billing.ClientError)
|
||||
return
|
||||
}
|
||||
written = append(written, map[string]any{
|
||||
"pack_id": row.PackID,
|
||||
"product_id": row.ProductID,
|
||||
"price_id": row.PriceID,
|
||||
"credits": row.Credits,
|
||||
"price_usd": row.PriceUSD,
|
||||
"created": row.Created,
|
||||
"settings_key": key,
|
||||
})
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"mode": mode,
|
||||
"packs": written,
|
||||
"message": "Credit pack Products/Prices synced; Price IDs saved to settings.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetAIIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
if s.AI == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "ai integration unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
cfg, err := s.AI.GetConfig(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load ai settings")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutAIIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if s.AI == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "ai integration unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body aiprovider.UpdateInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
cfg, err := s.AI.UpdateConfig(r.Context(), cid, body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update ai settings", err, aiprovider.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) handleTestAIIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if s.AI == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "ai integration unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
result, err := s.AI.TestConnection(r.Context(), cid)
|
||||
if errors.Is(err, aiprovider.ErrNotConfigured) {
|
||||
Error(w, http.StatusBadRequest, "ai provider not configured")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// Safe message only — never echo provider error bodies (may contain key fragments).
|
||||
JSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAIPrompts(w http.ResponseWriter, r *http.Request) {
|
||||
if s.AIPrompts == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "ai prompts unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
lang := strings.TrimSpace(r.URL.Query().Get("language"))
|
||||
if lang == "" {
|
||||
lang = company.LoadLanguage(r.Context(), s.Pool, cid)
|
||||
}
|
||||
bundle, err := s.AIPrompts.GetBundle(r.Context(), cid, lang)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load ai prompts")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, bundle)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutAIPrompts(w http.ResponseWriter, r *http.Request) {
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if s.AIPrompts == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "ai prompts unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body aiprompts.UpdateInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
bundle, err := s.AIPrompts.Update(r.Context(), cid, body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update ai prompts", err, aiprompts.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, bundle)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleListAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
const where = "company_id = $1 AND revoked_at IS NULL"
|
||||
var total int64
|
||||
if err := s.Pool.QueryRow(r.Context(), "SELECT count(*) FROM api_keys WHERE "+where, cid).Scan(&total); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
rows, err := s.Pool.Query(r.Context(), `
|
||||
SELECT id, name, key_prefix, last_used_at, created_at
|
||||
FROM api_keys WHERE `+where+`
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3`, cid, limit, offset)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var name *string
|
||||
var prefix string
|
||||
var lastUsed, created any
|
||||
if err := rows.Scan(&id, &name, &prefix, &lastUsed, &created); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "scan failed")
|
||||
return
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "name": name, "key_prefix": prefix, "last_used_at": lastUsed, "created_at": created,
|
||||
})
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"api_keys": out, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
if !s.requireFeatures(w, r, "settings.api_keys", "capability.api_access") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
raw, err := auth.RandomToken(24)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "key gen failed")
|
||||
return
|
||||
}
|
||||
full := "dk_" + raw
|
||||
prefix := full[:10]
|
||||
var id uuid.UUID
|
||||
err = s.Pool.QueryRow(r.Context(), `
|
||||
INSERT INTO api_keys (company_id, user_id, name, key_hash, key_prefix)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
cid, uid, nullIfEmpty(body.Name), auth.HashAPIKey(full), prefix).Scan(&id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "create failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, map[string]any{
|
||||
"id": id, "name": body.Name, "key": full, "key_prefix": prefix,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
tag, err := s.Pool.Exec(r.Context(), `
|
||||
UPDATE api_keys SET revoked_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2 AND revoked_at IS NULL`, id, cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "revoke failed")
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
CompanyName string `json:"company_name"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
res, err := s.Auth.Register(r.Context(), auth.RegisterInput{
|
||||
Email: body.Email, Password: body.Password, Name: body.Name, CompanyName: body.CompanyName,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrUserExists) {
|
||||
FieldError(w, http.StatusConflict, "user already exists", "user_already_exists", map[string]string{
|
||||
"email": "user already exists",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrPasswordTooShort) {
|
||||
FieldError(w, http.StatusBadRequest, "password must be at least 8 characters", "password_too_short", map[string]string{
|
||||
"password": "password must be at least 8 characters",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrRegisterFieldsRequired) {
|
||||
FieldError(w, http.StatusBadRequest, "email, password, and company name are required", "register_fields_required", map[string]string{
|
||||
"email": "email, password, and company name are required",
|
||||
"password": "email, password, and company name are required",
|
||||
"company_name": "email, password, and company name are required",
|
||||
})
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "registration failed", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
_ = s.Billing.ProvisionFreePlan(r.Context(), res.CompanyID)
|
||||
if err := s.beginAuthenticatedSession(r.Context(), res.User.ID, res.CompanyID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "session start failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
lockout := s.loginAttempts()
|
||||
if locked, retryAfter := lockout.locked(body.Email); locked {
|
||||
writeRateLimited(w, loginLockoutMaxFails, retryAfter)
|
||||
return
|
||||
}
|
||||
res, err := s.Auth.Login(r.Context(), body.Email, body.Password)
|
||||
if errors.Is(err, auth.ErrMustSetPassword) {
|
||||
JSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "password_not_set",
|
||||
"code": "password_not_set",
|
||||
"message": PublicMessage(w, "This account still needs a password. Open your set-password invite link, or ask a company admin to re-issue one to this email."),
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
lockout.recordFailure(body.Email)
|
||||
if locked, retryAfter := lockout.locked(body.Email); locked {
|
||||
writeRateLimited(w, loginLockoutMaxFails, retryAfter)
|
||||
return
|
||||
}
|
||||
FieldError(w, http.StatusUnauthorized, "invalid credentials", "invalid_credentials", map[string]string{
|
||||
"email": "invalid credentials",
|
||||
"password": "invalid credentials",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "login failed")
|
||||
return
|
||||
}
|
||||
lockout.clear(body.Email)
|
||||
if err := s.beginAuthenticatedSession(r.Context(), res.User.ID, res.CompanyID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "session start failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// sessionUserEmail returns the signed-in user's email when a session cookie is present.
|
||||
func (s *Server) sessionUserEmail(ctx context.Context) (string, bool) {
|
||||
uidStr := s.Sessions.GetString(ctx, auth.SessionUserIDKey)
|
||||
if uidStr == "" {
|
||||
return "", false
|
||||
}
|
||||
uid, err := uuid.Parse(uidStr)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
user, err := s.Auth.GetUser(ctx, uid)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
email := strings.TrimSpace(user.Email)
|
||||
if email == "" {
|
||||
return "", false
|
||||
}
|
||||
return email, true
|
||||
}
|
||||
|
||||
func writeEmailMismatch(w http.ResponseWriter, sessionEmail, inviteEmail string) {
|
||||
JSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "email_mismatch",
|
||||
"code": "email_mismatch",
|
||||
"message": PublicMessage(w, "You're signed in as a different email than this invite. Sign out to continue with the invited account, or ask an admin to re-issue the invite to your signed-in email."),
|
||||
"session_email": sessionEmail,
|
||||
"invite_email": inviteEmail,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) beginAuthenticatedSession(ctx context.Context, userID, companyID uuid.UUID) error {
|
||||
if err := s.Sessions.RenewToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Sessions.Put(ctx, auth.SessionUserIDKey, userID.String())
|
||||
s.putSessionVersion(ctx, userID)
|
||||
// Fresh login/register clears any prior impersonation chain.
|
||||
s.Sessions.Remove(ctx, auth.SessionImpersonatorIDKey)
|
||||
if companyID == uuid.Nil {
|
||||
s.Sessions.Put(ctx, auth.SessionCompanyIDKey, "")
|
||||
return nil
|
||||
}
|
||||
s.Sessions.Put(ctx, auth.SessionCompanyIDKey, companyID.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// beginImpersonatedSession swaps the signed-in user while preserving the original actor.
|
||||
func (s *Server) beginImpersonatedSession(ctx context.Context, targetUserID, companyID, actorID uuid.UUID) error {
|
||||
if err := s.Sessions.RenewToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Sessions.Put(ctx, auth.SessionUserIDKey, targetUserID.String())
|
||||
s.putSessionVersion(ctx, targetUserID)
|
||||
// Keep the original impersonator across chained switches.
|
||||
if existing := strings.TrimSpace(s.Sessions.GetString(ctx, auth.SessionImpersonatorIDKey)); existing == "" {
|
||||
s.Sessions.Put(ctx, auth.SessionImpersonatorIDKey, actorID.String())
|
||||
}
|
||||
if companyID == uuid.Nil {
|
||||
s.Sessions.Put(ctx, auth.SessionCompanyIDKey, "")
|
||||
return nil
|
||||
}
|
||||
s.Sessions.Put(ctx, auth.SessionCompanyIDKey, companyID.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// putSessionVersion stamps users.session_version into the cookie session (0 when DB unavailable).
|
||||
func (s *Server) putSessionVersion(ctx context.Context, userID uuid.UUID) {
|
||||
version := 0
|
||||
if s != nil && s.Auth != nil && s.Auth.Pool != nil {
|
||||
if st, err := s.Auth.UserSessionState(ctx, userID); err == nil {
|
||||
version = st.Version
|
||||
}
|
||||
}
|
||||
s.Sessions.Put(ctx, auth.SessionVersionKey, version)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.Sessions.Destroy(r.Context()); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "logout failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleInvitePreview(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
mode := strings.TrimSpace(strings.ToLower(body.Mode))
|
||||
if mode == "" {
|
||||
mode = "invite"
|
||||
}
|
||||
var inviteEmail string
|
||||
switch mode {
|
||||
case "set-password":
|
||||
uid, err := auth.ParseSetPasswordToken(s.Config.TokenSigningSecret, body.Token)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
user, err := s.Auth.GetUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
inviteEmail = user.Email
|
||||
default:
|
||||
mode = "invite"
|
||||
email, err := s.Auth.ResolveInviteEmail(r.Context(), body.Token)
|
||||
if errors.Is(err, auth.ErrInviteInvalid) {
|
||||
Error(w, http.StatusBadRequest, "invite invalid or expired")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "invite preview failed", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
inviteEmail = email
|
||||
}
|
||||
out := map[string]any{
|
||||
"mode": mode,
|
||||
"invite_email": inviteEmail,
|
||||
"valid": true,
|
||||
"mismatch": false,
|
||||
}
|
||||
if sessionEmail, ok := s.sessionUserEmail(r.Context()); ok {
|
||||
out["session_email"] = sessionEmail
|
||||
if !auth.EmailsEqual(sessionEmail, inviteEmail) {
|
||||
out["mismatch"] = true
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleAcceptInvite(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if sessionEmail, ok := s.sessionUserEmail(r.Context()); ok {
|
||||
inviteEmail, err := s.Auth.ResolveInviteEmail(r.Context(), body.Token)
|
||||
if errors.Is(err, auth.ErrInviteInvalid) {
|
||||
Error(w, http.StatusBadRequest, "invite invalid or expired")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "invite accept failed", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
if !auth.EmailsEqual(sessionEmail, inviteEmail) {
|
||||
writeEmailMismatch(w, sessionEmail, inviteEmail)
|
||||
return
|
||||
}
|
||||
}
|
||||
res, err := s.Auth.AcceptInvite(r.Context(), body.Token, body.Password, body.Name)
|
||||
if errors.Is(err, auth.ErrInviteInvalid) {
|
||||
Error(w, http.StatusBadRequest, "invite invalid or expired")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
FieldError(w, http.StatusUnauthorized, "invalid credentials", "invalid_credentials", map[string]string{
|
||||
"password": "invalid credentials",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "invite accept failed", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
if err := s.beginAuthenticatedSession(r.Context(), res.User.ID, res.CompanyID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "session start failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
user, err := s.Auth.GetUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
companies, err := s.Auth.ListUserCompanies(r.Context(), uid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load companies")
|
||||
return
|
||||
}
|
||||
cidStr := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)
|
||||
out := map[string]any{
|
||||
"user": user,
|
||||
"companies": companies,
|
||||
"active_company_id": cidStr,
|
||||
}
|
||||
if access, err := s.Auth.GetStaffAccess(r.Context(), uid); err == nil && (access.FullAdmin || access.SupportDesk) {
|
||||
out["staff_access"] = access
|
||||
out["staff_capabilities"] = auth.StaffCapabilities(access.Role)
|
||||
}
|
||||
if cid, err := uuid.Parse(cidStr); err == nil {
|
||||
for _, c := range companies {
|
||||
if c.ID == cid {
|
||||
out["company"] = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if credits, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold); err == nil {
|
||||
out["credits"] = credits
|
||||
}
|
||||
if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil {
|
||||
out["membership"] = map[string]string{"role": m.Role, "status": m.Status}
|
||||
}
|
||||
}
|
||||
impersonating := false
|
||||
if impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey)); impStr != "" {
|
||||
if impID, err := uuid.Parse(impStr); err == nil && impID != uuid.Nil {
|
||||
impersonating = true
|
||||
out["impersonating"] = true
|
||||
if impUser, err := s.Auth.GetUser(r.Context(), impID); err == nil {
|
||||
out["impersonator"] = map[string]any{
|
||||
"id": impUser.ID,
|
||||
"email": impUser.Email,
|
||||
"name": impUser.Name,
|
||||
}
|
||||
} else {
|
||||
out["impersonator"] = map[string]any{"id": impID}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !s.Config.IsProduction() {
|
||||
canSwitch := impersonating
|
||||
if !canSwitch {
|
||||
access, err := s.checkStaffAccess(r.Context(), uid)
|
||||
if err == nil && access.FullAdmin {
|
||||
canSwitch = true
|
||||
} else if isLocalDemoEmail(user.Email) {
|
||||
canSwitch = true
|
||||
}
|
||||
}
|
||||
out["dev_user_switch"] = canSwitch
|
||||
}
|
||||
JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.Auth.SetPassword(r.Context(), uid, body.Password); err != nil {
|
||||
if errors.Is(err, auth.ErrPasswordAlreadySet) {
|
||||
Error(w, http.StatusBadRequest, "password already set")
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not set password", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
s.putSessionVersion(r.Context(), uid)
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
var body struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.Auth.ChangePassword(r.Context(), uid, body.CurrentPassword, body.Password); err != nil {
|
||||
if errors.Is(err, auth.ErrMustSetPassword) {
|
||||
Error(w, http.StatusBadRequest, "set password first")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
Error(w, http.StatusBadRequest, "current password is incorrect")
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not change password", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
s.putSessionVersion(r.Context(), uid)
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSelectCompany(w http.ResponseWriter, r *http.Request) {
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
var body struct {
|
||||
CompanyID string `json:"company_id"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
cid, err := uuid.Parse(body.CompanyID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid company_id")
|
||||
return
|
||||
}
|
||||
if _, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err != nil {
|
||||
Error(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
s.Sessions.Put(r.Context(), auth.SessionCompanyIDKey, cid.String())
|
||||
JSON(w, http.StatusOK, map[string]string{"company_id": cid.String()})
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TestAuthSessionCoreEndpoints exercises login/me/select-company/company/api-keys/logout
|
||||
// with semi-real fixtures against a live DATABASE_URL (skips when unset).
|
||||
func TestAuthSessionCoreEndpoints(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := t.Context()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
|
||||
companyID := uuid.New()
|
||||
userID := uuid.New()
|
||||
prefix := companyID.String()[:8]
|
||||
email := fmt.Sprintf("auth-smoke-%s@example.test", prefix)
|
||||
password := "AuthSmoke123!"
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hash password: %v", err)
|
||||
}
|
||||
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name, language) VALUES ($1, $2, 'en')`,
|
||||
companyID, "Auth Smoke Co "+prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("seed company: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active)
|
||||
VALUES ($1, $2, $3, $4, false, false, true)`,
|
||||
userID, email, "Auth Smoke", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO memberships (company_id, user_id, role, status)
|
||||
VALUES ($1, $2, 'admin', 'active')`, companyID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed membership: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `INSERT INTO credit_balances (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, companyID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed credits: %v", err)
|
||||
}
|
||||
// Free defaults deny settings.api_keys / capability.api_access; Starter+ matches prod gate.
|
||||
billingSvc := &billing.Service{Pool: pg}
|
||||
if err := billingSvc.EnsureDefaultPlans(ctx); err != nil {
|
||||
t.Fatalf("ensure plans: %v", err)
|
||||
}
|
||||
starterID, err := billingSvc.PlanIDByName(ctx, "Starter")
|
||||
if err != nil {
|
||||
t.Fatalf("starter plan: %v", err)
|
||||
}
|
||||
assigned, err := billingSvc.AssignPlanIfMissing(ctx, companyID, starterID)
|
||||
if err != nil {
|
||||
t.Fatalf("assign starter: %v", err)
|
||||
}
|
||||
if !assigned {
|
||||
t.Fatal("expected Starter plan assignment for api-key entitlement")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := t.Context()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM api_keys WHERE company_id = $1 OR user_id = $2`, companyID, userID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM company_plans WHERE company_id = $1`, companyID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM memberships WHERE company_id = $1 OR user_id = $2`, companyID, userID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM credit_balances WHERE company_id = $1`, companyID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
sessions := auth.NewSessionManager(pg, "descrybe_session", false, 24)
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5174",
|
||||
SessionSecure: false,
|
||||
LowCreditsThreshold: 100,
|
||||
TokenSigningSecret: "test-token-signing-secret-32chars!!",
|
||||
},
|
||||
Pool: pg,
|
||||
Sessions: sessions,
|
||||
Auth: &auth.Service{Pool: pg},
|
||||
Billing: &billing.Service{Pool: pg},
|
||||
Catalog: &catalog.Service{Pool: pg},
|
||||
}
|
||||
h := s.Router()
|
||||
|
||||
jar := map[string]string{}
|
||||
collectCookies := func(rec *httptest.ResponseRecorder) {
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.MaxAge < 0 || (c.Expires.Before(time.Now()) && !c.Expires.IsZero()) {
|
||||
delete(jar, c.Name)
|
||||
continue
|
||||
}
|
||||
if c.Value != "" {
|
||||
jar[c.Name] = c.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
applyCookies := func(req *http.Request) {
|
||||
for name, value := range jar {
|
||||
req.AddCookie(&http.Cookie{Name: name, Value: value})
|
||||
}
|
||||
}
|
||||
do := func(method, path, body string, withCSRF bool) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body == "" {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.RemoteAddr = "127.0.0.1:34567"
|
||||
applyCookies(req)
|
||||
if withCSRF {
|
||||
csrf := jar["descrybe_csrf"]
|
||||
if csrf == "" {
|
||||
t.Fatal("missing CSRF cookie before mutating request")
|
||||
}
|
||||
req.Header.Set("X-CSRF-Token", csrf)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
collectCookies(rec)
|
||||
return rec
|
||||
}
|
||||
decode := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("json status=%d body=%s err=%v", rec.Code, rec.Body.String(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Seed CSRF via unauthenticated /me (401 expected).
|
||||
rec := do(http.MethodGet, "/api/auth/me", "", false)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth me status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if jar["descrybe_csrf"] == "" {
|
||||
t.Fatal("expected descrybe_csrf cookie")
|
||||
}
|
||||
|
||||
// Login without CSRF → 403.
|
||||
rec = do(http.MethodPost, "/api/auth/login",
|
||||
fmt.Sprintf(`{"email":%q,"password":%q}`, email, password), false)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("login without csrf status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Bad password → 401.
|
||||
rec = do(http.MethodPost, "/api/auth/login",
|
||||
fmt.Sprintf(`{"email":%q,"password":"WrongPass999!"}`, email), true)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bad password status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Successful login.
|
||||
rec = do(http.MethodPost, "/api/auth/login",
|
||||
fmt.Sprintf(`{"email":%q,"password":%q}`, email, password), true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
login := decode(t, rec)
|
||||
if fmt.Sprint(login["company_id"]) != companyID.String() {
|
||||
t.Fatalf("login company_id=%v want %s", login["company_id"], companyID)
|
||||
}
|
||||
userObj, _ := login["user"].(map[string]any)
|
||||
if fmt.Sprint(userObj["email"]) != email {
|
||||
t.Fatalf("login email=%v", userObj["email"])
|
||||
}
|
||||
if jar["descrybe_session"] == "" {
|
||||
t.Fatal("expected session cookie after login")
|
||||
}
|
||||
|
||||
// Me.
|
||||
rec = do(http.MethodGet, "/api/auth/me", "", false)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("me status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
me := decode(t, rec)
|
||||
if fmt.Sprint(me["active_company_id"]) != companyID.String() {
|
||||
t.Fatalf("active_company_id=%v", me["active_company_id"])
|
||||
}
|
||||
if _, ok := me["credits"]; !ok {
|
||||
t.Fatalf("me missing credits: %v", me)
|
||||
}
|
||||
|
||||
// Select company (same id).
|
||||
rec = do(http.MethodPost, "/api/auth/select-company",
|
||||
fmt.Sprintf(`{"company_id":%q}`, companyID.String()), true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("select-company status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Core tenant routes.
|
||||
rec = do(http.MethodGet, "/api/company", "", false)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("company status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
co := decode(t, rec)
|
||||
if !strings.Contains(fmt.Sprint(co["name"]), "Auth Smoke Co") {
|
||||
t.Fatalf("company name=%v", co["name"])
|
||||
}
|
||||
|
||||
rec = do(http.MethodGet, "/api/billing/credits", "", false)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("billing credits status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = do(http.MethodGet, "/api/api-keys", "", false)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list api-keys status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = do(http.MethodPost, "/api/api-keys", `{"name":"auth-smoke-key"}`, true)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create api-key status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
created := decode(t, rec)
|
||||
rawKey := fmt.Sprint(created["key"])
|
||||
keyID := fmt.Sprint(created["id"])
|
||||
if !strings.HasPrefix(rawKey, "dk_") || keyID == "" {
|
||||
t.Fatalf("create api-key payload=%v", created)
|
||||
}
|
||||
|
||||
// Public v1 with the new key (CSRF skipped).
|
||||
v1 := httptest.NewRecorder()
|
||||
v1Req := httptest.NewRequest(http.MethodGet, "/api/v1/products?limit=1", nil)
|
||||
v1Req.Header.Set("Authorization", "Bearer "+rawKey)
|
||||
h.ServeHTTP(v1, v1Req)
|
||||
if v1.Code != http.StatusOK {
|
||||
t.Fatalf("v1 products status=%d body=%s", v1.Code, v1.Body.String())
|
||||
}
|
||||
|
||||
rec = do(http.MethodDelete, "/api/api-keys/"+keyID, "", true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke api-key status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = do(http.MethodPost, "/api/auth/logout", `{}`, true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("logout status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = do(http.MethodGet, "/api/auth/me", "", false)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("me after logout status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleCreditsOverview(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
overview, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load credits")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, overview)
|
||||
}
|
||||
|
||||
func (s *Server) handleBillingUsage(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
usage, err := s.Billing.UsageSummary(r.Context(), cid, r.URL.Query().Get("range"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load usage")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, usage)
|
||||
}
|
||||
|
||||
// handleListPlans returns every plan (including client deals) for platform admin.
|
||||
func (s *Server) handleListPlans(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return
|
||||
}
|
||||
_ = s.Billing.EnsureDefaultPlans(r.Context())
|
||||
plans, err := s.Billing.ListPlans(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to list plans")
|
||||
return
|
||||
}
|
||||
// Permission matrices are admin-sensitive; never let shared caches retain them.
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
JSON(w, http.StatusOK, map[string]any{"plans": plans})
|
||||
}
|
||||
|
||||
// handleListPublicPlans returns Free/Starter/Plus/Growth/Business/Scale/Enterprise only.
|
||||
// Hides client-specific deals (A1, Merkur trial, legacy ladders) from company UI.
|
||||
func (s *Server) handleListPublicPlans(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil || s.Billing.Pool == nil {
|
||||
// Empty billing must not 500 on the public pricing surface.
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"plans": []any{},
|
||||
"credits_per_ai_product": billing.CreditsPerAIProduct,
|
||||
"assumed_content_langs": billing.AssumedPrimaryContentLanguages,
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = s.Billing.EnsureDefaultPlans(r.Context())
|
||||
plans, err := s.Billing.ListPublicPlans(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to list plans")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"plans": plans,
|
||||
"credits_per_ai_product": billing.CreditsPerAIProduct,
|
||||
"assumed_content_langs": billing.AssumedPrimaryContentLanguages,
|
||||
})
|
||||
}
|
||||
|
||||
// handleListCreditPacks returns one-time AI credit top-up packages for Checkout.
|
||||
func (s *Server) handleListCreditPacks(w http.ResponseWriter, r *http.Request) {
|
||||
JSON(w, http.StatusOK, map[string]any{"packs": billing.DefaultCreditPacks()})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpsertPlan(w http.ResponseWriter, r *http.Request) {
|
||||
var body billing.Plan
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
plan, err := s.Billing.UpsertPlan(r.Context(), body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not save plan", err, billing.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, plan)
|
||||
}
|
||||
|
||||
func (s *Server) handleAssignPlan(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
CompanyID string `json:"company_id"`
|
||||
PlanID int64 `json:"plan_id"`
|
||||
IsTrial bool `json:"is_trial"`
|
||||
TrialCredits int `json:"trial_credits"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
cid, err := uuid.Parse(body.CompanyID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid company_id")
|
||||
return
|
||||
}
|
||||
if body.PlanID <= 0 {
|
||||
Error(w, http.StatusBadRequest, "invalid plan_id")
|
||||
return
|
||||
}
|
||||
if err := s.Billing.AssignPlan(r.Context(), cid, body.PlanID, body.IsTrial, body.TrialCredits); err != nil {
|
||||
if errors.Is(err, billing.ErrPlanNotFound) {
|
||||
Error(w, http.StatusNotFound, billing.ErrPlanNotFound.Error())
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not assign plan", err, billing.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleAddCredits(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
CompanyID string `json:"company_id"`
|
||||
Amount int `json:"amount"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
cid, err := uuid.Parse(body.CompanyID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid company_id")
|
||||
return
|
||||
}
|
||||
if err := s.Billing.AddCredits(r.Context(), cid, body.Amount); err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not add credits", err, billing.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleRunBillingCycles(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.Billing.RunDueBillingCycles(r.Context())
|
||||
if err != nil && res.Processed == 0 && res.Failed == 0 {
|
||||
LogAndError(w, http.StatusInternalServerError, "billing cycle run failed", err)
|
||||
return
|
||||
}
|
||||
out := map[string]any{"processed": res.Processed, "failed": res.Failed}
|
||||
if err != nil {
|
||||
log.Printf("httpapi: billing cycle run completed with errors: %v", err)
|
||||
out["error"] = "billing cycle run completed with errors"
|
||||
}
|
||||
JSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||
)
|
||||
|
||||
type brandPutBody struct {
|
||||
VoiceTone *string `json:"voice_tone"`
|
||||
Dos []string `json:"dos"`
|
||||
Donts []string `json:"donts"`
|
||||
PrimaryColor *string `json:"primary_color"`
|
||||
SecondaryColor *string `json:"secondary_color"`
|
||||
LogoURL *string `json:"logo_url"`
|
||||
PreferredTerms []string `json:"preferred_terms"`
|
||||
// Optional nested colors alias
|
||||
Colors *struct {
|
||||
Primary *string `json:"primary"`
|
||||
Secondary *string `json:"secondary"`
|
||||
} `json:"colors"`
|
||||
}
|
||||
|
||||
func (s *Server) brandResponse(w http.ResponseWriter, r *http.Request, brand company.BrandKit) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
aiApply := false
|
||||
if s.Billing != nil {
|
||||
aiApply = s.Billing.AIBrandApplyAllowed(r.Context(), cid)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"brand": brand,
|
||||
"ai_apply_allowed": aiApply,
|
||||
"tips": brand.FormulaTips(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetBrand(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
brand, err := company.LoadBrand(r.Context(), s.Pool, cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "load brand failed")
|
||||
return
|
||||
}
|
||||
s.brandResponse(w, r, brand)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutBrand(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
|
||||
var body brandPutBody
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
|
||||
current, err := company.LoadBrand(r.Context(), s.Pool, cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "load brand failed")
|
||||
return
|
||||
}
|
||||
|
||||
if body.VoiceTone != nil {
|
||||
current.VoiceTone = *body.VoiceTone
|
||||
}
|
||||
if body.Dos != nil {
|
||||
current.Dos = body.Dos
|
||||
}
|
||||
if body.Donts != nil {
|
||||
current.Donts = body.Donts
|
||||
}
|
||||
if body.PrimaryColor != nil {
|
||||
current.PrimaryColor = *body.PrimaryColor
|
||||
}
|
||||
if body.SecondaryColor != nil {
|
||||
current.SecondaryColor = *body.SecondaryColor
|
||||
}
|
||||
if body.LogoURL != nil {
|
||||
current.LogoURL = *body.LogoURL
|
||||
}
|
||||
if body.PreferredTerms != nil {
|
||||
current.PreferredTerms = body.PreferredTerms
|
||||
}
|
||||
if body.Colors != nil {
|
||||
if body.Colors.Primary != nil {
|
||||
current.PrimaryColor = *body.Colors.Primary
|
||||
}
|
||||
if body.Colors.Secondary != nil {
|
||||
current.SecondaryColor = *body.Colors.Secondary
|
||||
}
|
||||
}
|
||||
|
||||
saved, err := company.UpsertBrand(r.Context(), s.Pool, cid, current)
|
||||
if err != nil {
|
||||
if isBrandLogoURLError(err) {
|
||||
Error(w, http.StatusBadRequest, "invalid logo_url")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "save brand failed")
|
||||
return
|
||||
}
|
||||
s.brandResponse(w, r, saved)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const brandLogoMaxUpload = 3 << 20 // parse budget slightly above 2 MiB file cap
|
||||
|
||||
func (s *Server) handleUploadBrandLogo(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(brandLogoMaxUpload); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid multipart form")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
file, header, err = r.FormFile("logo")
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "file field required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
logoURL, _, _, _, err := company.SaveBrandLogo(
|
||||
s.Config.UploadDir,
|
||||
cid,
|
||||
header.Filename,
|
||||
header.Header.Get("Content-Type"),
|
||||
file,
|
||||
)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not upload logo", err, company.ClientError)
|
||||
return
|
||||
}
|
||||
|
||||
current, err := company.LoadBrand(r.Context(), s.Pool, cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "load brand failed")
|
||||
return
|
||||
}
|
||||
current.LogoURL = logoURL
|
||||
saved, err := company.UpsertBrand(r.Context(), s.Pool, cid, current)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "save brand failed")
|
||||
return
|
||||
}
|
||||
s.brandResponse(w, r, saved)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetBrandLogoFile(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
name := chi.URLParam(r, "filename")
|
||||
s.serveBrandLogo(w, r, cid, name)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublicBrandLogo(w http.ResponseWriter, r *http.Request) {
|
||||
companyRaw := chi.URLParam(r, "companyID")
|
||||
cid, err := uuid.Parse(companyRaw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid company id")
|
||||
return
|
||||
}
|
||||
name := chi.URLParam(r, "filename")
|
||||
expRaw := strings.TrimSpace(r.URL.Query().Get("exp"))
|
||||
sig := strings.TrimSpace(r.URL.Query().Get("sig"))
|
||||
exp, err := strconv.ParseInt(expRaw, 10, 64)
|
||||
if err != nil {
|
||||
Error(w, http.StatusForbidden, "invalid or expired signature")
|
||||
return
|
||||
}
|
||||
secret := strings.TrimSpace(s.Config.TokenSigningSecret)
|
||||
if secret == "" {
|
||||
Error(w, http.StatusServiceUnavailable, "signed logos unavailable")
|
||||
return
|
||||
}
|
||||
if err := company.VerifyPublicBrandLogoSig(secret, cid, name, exp, sig); err != nil {
|
||||
Error(w, http.StatusForbidden, "invalid or expired signature")
|
||||
return
|
||||
}
|
||||
s.serveBrandLogo(w, r, cid, name)
|
||||
}
|
||||
|
||||
func (s *Server) serveBrandLogo(w http.ResponseWriter, r *http.Request, companyID uuid.UUID, name string) {
|
||||
f, contentType, err := company.OpenBrandLogo(s.Config.UploadDir, companyID, name)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, company.ErrLogoInvalidName), errors.Is(err, company.ErrLogoForbidden):
|
||||
Error(w, http.StatusBadRequest, "invalid logo path")
|
||||
case errors.Is(err, company.ErrLogoNotFound):
|
||||
Error(w, http.StatusNotFound, "logo not found")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "could not open logo")
|
||||
}
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not stat logo")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.ServeContent(w, r, name, st.ModTime(), f)
|
||||
}
|
||||
|
||||
func isBrandLogoURLError(err error) bool {
|
||||
return errors.Is(err, security.ErrInvalidURL) ||
|
||||
errors.Is(err, security.ErrBlockedURL) ||
|
||||
errors.Is(err, security.ErrBlockedHost) ||
|
||||
errors.Is(err, company.ErrLogoInvalidName)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleListCampaignTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, "marketing.campaigns") {
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"templates": campaigns.ListTemplates()})
|
||||
}
|
||||
|
||||
func (s *Server) handleListCampaigns(w http.ResponseWriter, r *http.Request) {
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
if !s.requireFeatures(w, r, "marketing.campaigns") {
|
||||
return
|
||||
}
|
||||
if s.Campaigns == nil {
|
||||
JSON(w, http.StatusOK, map[string]any{"campaigns": []any{}, "total": 0, "limit": limit, "offset": offset})
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
items, total, err := s.Campaigns.List(r.Context(), cid, limit, offset)
|
||||
if err != nil {
|
||||
// First-run / missing migration: empty list so the UI empty-state works.
|
||||
JSON(w, http.StatusOK, map[string]any{"campaigns": []any{}, "total": 0, "limit": limit, "offset": offset})
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []campaigns.Campaign{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"campaigns": items, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Campaigns == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "campaigns unavailable")
|
||||
return
|
||||
}
|
||||
if !s.requireFeatures(w, r, "marketing.campaigns", "marketing.campaigns.create") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
var body campaigns.CreateInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Campaigns.Create(r.Context(), cid, &uid, body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create campaign", err, campaigns.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Campaigns == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "campaigns unavailable")
|
||||
return
|
||||
}
|
||||
if !s.requireFeatures(w, r, "marketing.campaigns") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Campaigns.Get(r.Context(), cid, id)
|
||||
if errors.Is(err, campaigns.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "get failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, "marketing.campaigns", "marketing.campaigns.create") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body campaigns.UpdateInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Campaigns.Update(r.Context(), cid, id, body)
|
||||
if errors.Is(err, campaigns.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update campaign", err, campaigns.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, "marketing.campaigns", "marketing.campaigns.create") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Campaigns.Delete(r.Context(), cid, id); errors.Is(err, campaigns.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGenerateCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, "marketing.campaigns") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body campaigns.GenerateInput
|
||||
err = DecodeJSON(r, &body)
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, errJSONBodyTooLarge) {
|
||||
// Allow empty body (defaults to template mode).
|
||||
if r.ContentLength > 0 {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
}
|
||||
item, err := s.Campaigns.Generate(r.Context(), cid, id, body)
|
||||
if errors.Is(err, campaigns.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if writePlanGate(w, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, campaigns.ErrAIRequiresUpgrade) || errors.Is(err, billing.ErrAIRequiresUpgrade) {
|
||||
JSON(w, http.StatusPaymentRequired, map[string]any{
|
||||
"error": err.Error(),
|
||||
"code": "ai_requires_upgrade",
|
||||
"upgrade_url": "/pricing",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, campaigns.ErrInsufficientCredits) || errors.Is(err, billing.ErrInsufficientCredits) {
|
||||
JSON(w, http.StatusPaymentRequired, map[string]any{
|
||||
"error": err.Error(),
|
||||
"code": "insufficient_credits",
|
||||
"upgrade_url": "/pricing",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, campaigns.ErrRateLimited) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
Error(w, http.StatusTooManyRequests, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if writeCampaignClientErr(w, err) {
|
||||
return
|
||||
}
|
||||
LogAndError(w, http.StatusBadRequest, "campaign generate failed", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleSendTestCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, "marketing.campaigns") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body campaigns.SendTestInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Campaigns.SendTest(r.Context(), cid, id, body)
|
||||
if writeCampaignSendErr(w, err) {
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleScheduleCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, "marketing.campaigns") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body campaigns.ScheduleInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Campaigns.Schedule(r.Context(), cid, id, body)
|
||||
if writeCampaignSendErr(w, err) {
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleSendCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, "marketing.campaigns") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body campaigns.SendInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Campaigns.Send(r.Context(), cid, id, body)
|
||||
if writeCampaignSendErr(w, err) {
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
// writeCampaignSendErr writes an error response and returns true when err != nil.
|
||||
func writeCampaignSendErr(w http.ResponseWriter, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if writePlanGate(w, err) {
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, campaigns.ErrNotFound):
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
case errors.Is(err, campaigns.ErrProviderNotFound), errors.Is(err, campaigns.ErrProviderUnverified):
|
||||
JSON(w, http.StatusPreconditionFailed, map[string]any{
|
||||
"error": err.Error(),
|
||||
"code": "email_not_verified",
|
||||
})
|
||||
case errors.Is(err, campaigns.ErrRateLimited):
|
||||
w.Header().Set("Retry-After", "60")
|
||||
Error(w, http.StatusTooManyRequests, err.Error())
|
||||
default:
|
||||
if writeCampaignClientErr(w, err) {
|
||||
return true
|
||||
}
|
||||
LogAndError(w, http.StatusBadRequest, "campaign send failed", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// writeCampaignClientErr maps known campaign validation sentinels to 400.
|
||||
func writeCampaignClientErr(w http.ResponseWriter, err error) bool {
|
||||
if msg, ok := campaigns.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Server) handleListCategories(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
max := maxPageLimit
|
||||
if r.URL.Query().Get("tree") == "1" {
|
||||
max = maxTreePageLimit
|
||||
}
|
||||
limit, offset := ParseLimitOffsetMax(r, max)
|
||||
f := catalog.ListFilter{
|
||||
Query: QuerySearch(r),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
items, total, err := s.Catalog.ListCategories(r.Context(), cid, f)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"categories": items, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
UniqueID string `json:"unique_id"`
|
||||
ParentUniqueID *string `json:"parent_unique_id"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.CreateCategory(r.Context(), cid, body.Name, body.UniqueID, body.ParentUniqueID, body.Description)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create category", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCategory(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.GetCategory(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.UpdateCategory(r.Context(), cid, id, body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update category", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteCategory(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCompanyAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Catalog.DeleteCategory(r.Context(), cid, id); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTitleFormula(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TitleTemplate any `json:"title_template"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.UpdateTitleFormula(r.Context(), cid, id, body.TitleTemplate)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update title formula", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateDescriptionFormula(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
DescriptionTemplate any `json:"description_template"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.UpdateDescriptionFormula(r.Context(), cid, id, body.DescriptionTemplate)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update description formula", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateCategoryPrompt(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Language string `json:"language"`
|
||||
Prompts map[string]string `json:"prompts"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
prompts := body.Prompts
|
||||
if prompts == nil {
|
||||
prompts = map[string]string{}
|
||||
lang := strings.TrimSpace(body.Language)
|
||||
if lang == "" {
|
||||
lang = company.LoadLanguage(r.Context(), s.Pool, cid)
|
||||
}
|
||||
// Legacy single-prompt body: set/clear one language, preserve others.
|
||||
existing, gerr := s.Catalog.GetCategory(r.Context(), cid, id)
|
||||
if gerr == nil {
|
||||
if m, ok := existing["prompts"].(company.LangPromptMap); ok {
|
||||
for k, v := range m {
|
||||
prompts[k] = v
|
||||
}
|
||||
} else if raw, ok := existing["prompts"].(map[string]string); ok {
|
||||
for k, v := range raw {
|
||||
prompts[k] = v
|
||||
}
|
||||
} else if raw, ok := existing["prompts"].(map[string]any); ok {
|
||||
for k, v := range raw {
|
||||
if s, ok := v.(string); ok {
|
||||
prompts[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prompts[lang] = body.Prompt
|
||||
}
|
||||
item, err := s.Catalog.UpdateCategoryPrompt(r.Context(), cid, id, prompts)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update category prompt", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleListVariables(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit)
|
||||
page, total, err := s.Catalog.ListVariables(r.Context(), cid, catalog.ListFilter{Limit: limit, Offset: offset})
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"variables": page, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateVariable(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
value := body.Value
|
||||
if value == "" && body.Label != "" {
|
||||
value = body.Label
|
||||
}
|
||||
item, err := s.Catalog.CreateVariable(r.Context(), cid, body.Name, value, body.Description)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create variable", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteVariable(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Catalog.DeleteVariable(r.Context(), cid, id); err != nil {
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusNotFound, "could not delete variable", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListAttributes(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
f := catalog.ListFilter{
|
||||
Query: QuerySearch(r),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
RootsOnly: r.URL.Query().Get("roots") == "1",
|
||||
ParentKey: r.URL.Query().Get("parent_key"),
|
||||
}
|
||||
items, total, err := s.Catalog.ListAttributes(r.Context(), cid, f)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"attributes": items, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAttribute(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
AttributeKey string `json:"attribute_key"`
|
||||
Name string `json:"name"`
|
||||
ValueType string `json:"value_type"`
|
||||
Unit *string `json:"unit"`
|
||||
Example *string `json:"example"`
|
||||
ParentKey *string `json:"parent_key"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.CreateAttribute(r.Context(), cid, body.AttributeKey, body.Name, body.ValueType, body.Unit, body.Example, body.ParentKey)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create attribute", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateAttribute(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.UpdateAttribute(r.Context(), cid, id, body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update attribute", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAttribute(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCompanyAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Catalog.DeleteAttribute(r.Context(), cid, id); err != nil {
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListProducts(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
|
||||
afterID := strings.TrimSpace(firstNonEmpty(r.URL.Query().Get("after_id"), r.URL.Query().Get("afterId")))
|
||||
f := catalog.ListFilter{
|
||||
Query: QuerySearch(r),
|
||||
Status: r.URL.Query().Get("status"),
|
||||
Category: r.URL.Query().Get("category"),
|
||||
FeedID: firstNonEmpty(r.URL.Query().Get("feed_id"), r.URL.Query().Get("feedId")),
|
||||
Coverage: firstNonEmpty(r.URL.Query().Get("coverage"), r.URL.Query().Get("missing")),
|
||||
Eprel: firstNonEmpty(r.URL.Query().Get("eprel"), r.URL.Query().Get("has_eprel")),
|
||||
SyncChange: firstNonEmpty(r.URL.Query().Get("sync_change"), r.URL.Query().Get("syncChange"), r.URL.Query().Get("feed_change")),
|
||||
SortBy: firstNonEmpty(r.URL.Query().Get("sort_by"), r.URL.Query().Get("sortBy")),
|
||||
SortOrder: firstNonEmpty(r.URL.Query().Get("sort_order"), r.URL.Query().Get("sortOrder")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Cursor: cursor,
|
||||
AfterID: afterID,
|
||||
}
|
||||
if catalog.HasProductCursor(f) {
|
||||
offset = 0
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("kind")))
|
||||
// UI and some clients send kind=unprocessed for the raw inventory tab.
|
||||
if kind == "raw" || kind == "unprocessed" {
|
||||
items, total, err := s.Catalog.ListRawProducts(r.Context(), cid, f)
|
||||
if err != nil {
|
||||
if msg, ok := catalog.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
resp := map[string]any{"products": items, "total": total, "kind": "raw", "limit": limit, "offset": offset}
|
||||
if nextCursor, nextAfter := catalog.NextProductCursor(f, items, limit); nextCursor != "" || nextAfter != "" {
|
||||
if nextCursor != "" {
|
||||
resp["next_cursor"] = nextCursor
|
||||
}
|
||||
if nextAfter != "" {
|
||||
resp["next_after_id"] = nextAfter
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
detailed := QueryDetailed(r)
|
||||
var (
|
||||
items []map[string]any
|
||||
total int64
|
||||
err error
|
||||
)
|
||||
if detailed {
|
||||
items, total, err = s.Catalog.ListProcessedProductsDetailed(r.Context(), cid, f)
|
||||
} else {
|
||||
items, total, err = s.Catalog.ListProcessedProducts(r.Context(), cid, f)
|
||||
}
|
||||
if err != nil {
|
||||
if msg, ok := catalog.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
if detailed {
|
||||
attachProductQuality(items)
|
||||
}
|
||||
resp := map[string]any{"products": items, "total": total, "kind": "processed", "limit": limit, "offset": offset, "detailed": detailed}
|
||||
if nextCursor, nextAfter := catalog.NextProductCursor(f, items, limit); nextCursor != "" || nextAfter != "" {
|
||||
if nextCursor != "" {
|
||||
resp["next_cursor"] = nextCursor
|
||||
}
|
||||
if nextAfter != "" {
|
||||
resp["next_after_id"] = nextAfter
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProduct(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.GetProcessedProduct(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
Error(w, http.StatusInternalServerError, "lookup failed")
|
||||
return
|
||||
}
|
||||
item, err = s.Catalog.GetRawProduct(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "lookup failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProduct(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.UpdateProcessedProduct(r.Context(), cid, id, body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update product", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const catalogMaxUpload = 6 << 20
|
||||
|
||||
func (s *Server) handleListCategoryAttributes(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
cat, err := s.Catalog.GetCategory(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
uniqueID, _ := cat["unique_id"].(string)
|
||||
items, err := s.Catalog.ListCategoryAttributes(r.Context(), cid, uniqueID)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not list category attributes", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit)
|
||||
page, total := pageSlice(items, limit, offset)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"category_attributes": page, "category_unique_id": uniqueID,
|
||||
"total": total, "limit": limit, "offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePutCategoryAttributes(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCompanyAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
cat, err := s.Catalog.GetCategory(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
uniqueID, _ := cat["unique_id"].(string)
|
||||
var body struct {
|
||||
AttributeIDs []string `json:"attribute_ids"`
|
||||
Required map[string]bool `json:"required"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(body.AttributeIDs))
|
||||
for _, raw := range body.AttributeIDs {
|
||||
aid, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid attribute_ids")
|
||||
return
|
||||
}
|
||||
ids = append(ids, aid)
|
||||
}
|
||||
if body.Required == nil {
|
||||
body.Required = map[string]bool{}
|
||||
}
|
||||
if err := s.Catalog.ReplaceCategoryAttributes(r.Context(), cid, uniqueID, ids, body.Required); err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update category attributes", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
items, err := s.Catalog.ListCategoryAttributes(r.Context(), cid, uniqueID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"category_attributes": items})
|
||||
}
|
||||
|
||||
func (s *Server) handleLinkCategoryAttribute(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
cat, err := s.Catalog.GetCategory(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
uniqueID, _ := cat["unique_id"].(string)
|
||||
var body struct {
|
||||
AttributeID string `json:"attribute_id"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
aid, err := uuid.Parse(body.AttributeID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid attribute_id")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.LinkCategoryAttribute(r.Context(), cid, uniqueID, aid, body.Required)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not link attribute", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnlinkCategoryAttribute(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
aid, err := uuid.Parse(chi.URLParam(r, "attributeID"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid attribute id")
|
||||
return
|
||||
}
|
||||
cat, err := s.Catalog.GetCategory(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
uniqueID, _ := cat["unique_id"].(string)
|
||||
if err := s.Catalog.UnlinkCategoryAttribute(r.Context(), cid, uniqueID, aid); err != nil {
|
||||
if msg, ok := catalog.ClientError(err); ok {
|
||||
Error(w, http.StatusNotFound, msg)
|
||||
return
|
||||
}
|
||||
LogAndError(w, http.StatusNotFound, "could not unlink attribute", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffsetMax(r, maxPageLimit)
|
||||
items, total, err := s.Catalog.ListFiles(r.Context(), cid, catalog.ListFilter{Limit: limit, Offset: offset})
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"files": items, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCompanyAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Catalog.DeleteFile(r.Context(), cid, id, s.Config.UploadDir); err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCompanyAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
kind := importKindFromPath(r)
|
||||
switch kind {
|
||||
case "categories", "attributes", "products":
|
||||
default:
|
||||
Error(w, http.StatusBadRequest, "kind must be categories, attributes, or products")
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(catalogMaxUpload); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid multipart form")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "file field required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
meta, err := s.Catalog.SaveUpload(r.Context(), cid, uid, s.Config.UploadDir, header.Filename, header.Header.Get("Content-Type"), kind, file)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not save upload", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
|
||||
fileIDStr, _ := meta["id"].(string)
|
||||
fileID, _ := uuid.Parse(fileIDStr)
|
||||
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "processing", map[string]any{"kind": kind})
|
||||
|
||||
pathStr, _ := meta["path"].(string)
|
||||
abs, err := s.Catalog.ResolveUploadPath(s.Config.UploadDir, cid, pathStr)
|
||||
if err != nil {
|
||||
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "failed", map[string]any{"kind": kind, "error": "could not resolve upload"})
|
||||
LogAndError(w, http.StatusInternalServerError, "could not resolve upload", err)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "failed", map[string]any{"kind": kind, "error": "could not read upload"})
|
||||
Error(w, http.StatusInternalServerError, "could not read upload")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var result any
|
||||
switch kind {
|
||||
case "categories":
|
||||
result, err = s.Catalog.ImportCategoriesCSV(r.Context(), cid, f)
|
||||
case "attributes":
|
||||
result, err = s.Catalog.ImportAttributesCSV(r.Context(), cid, f)
|
||||
case "products":
|
||||
fid := fileID
|
||||
result, err = s.Catalog.ImportProductsCSV(r.Context(), cid, f, &fid)
|
||||
}
|
||||
if err != nil {
|
||||
public := "import failed"
|
||||
if msg, ok := catalog.ClientError(err); ok {
|
||||
public = msg
|
||||
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "failed", map[string]any{"kind": kind, "error": public})
|
||||
Error(w, http.StatusBadRequest, public)
|
||||
return
|
||||
}
|
||||
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "failed", map[string]any{"kind": kind, "error": public})
|
||||
LogAndError(w, http.StatusBadRequest, public, err)
|
||||
return
|
||||
}
|
||||
|
||||
importMeta := map[string]any{"kind": kind}
|
||||
if ir, ok := result.(catalog.ImportResult); ok {
|
||||
importMeta["created"] = ir.Created
|
||||
importMeta["updated"] = ir.Updated
|
||||
importMeta["skipped"] = ir.Skipped
|
||||
importMeta["total_rows"] = ir.Created + ir.Updated + ir.Skipped
|
||||
if len(ir.Errors) > 0 {
|
||||
importMeta["errors"] = ir.Errors
|
||||
}
|
||||
}
|
||||
meta, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fileID, "completed", importMeta)
|
||||
JSON(w, http.StatusOK, map[string]any{"file": meta, "import": result, "kind": kind})
|
||||
}
|
||||
|
||||
func importKindFromPath(r *http.Request) string {
|
||||
if k := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("kind"))); k != "" {
|
||||
return k
|
||||
}
|
||||
if k := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "kind"))); k != "" {
|
||||
return k
|
||||
}
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.Contains(path, "/categories/import"), strings.Contains(path, "/categories/upload"):
|
||||
return "categories"
|
||||
case strings.Contains(path, "/attributes/import"), strings.Contains(path, "/attributes/upload"):
|
||||
return "attributes"
|
||||
case strings.Contains(path, "/products/import"),
|
||||
strings.Contains(path, "/products/upload-eans"),
|
||||
strings.Contains(path, "/products/upload"):
|
||||
return "products"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func v1CatalogListMeta(page, limit int, total int64) map[string]any {
|
||||
return v1ProductListMeta(page, limit, total)
|
||||
}
|
||||
|
||||
func presentV1Category(item map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"id": item["id"],
|
||||
"unique_id": item["unique_id"],
|
||||
"name": item["name"],
|
||||
"created_at": formatV1Timestamp(item["created_at"]),
|
||||
"updated_at": formatV1Timestamp(item["updated_at"]),
|
||||
}
|
||||
}
|
||||
|
||||
func presentV1Attribute(item map[string]any) map[string]any {
|
||||
out := map[string]any{
|
||||
"id": item["id"],
|
||||
"key": item["attribute_key"],
|
||||
"name": item["name"],
|
||||
"type": item["value_type"],
|
||||
"unit": item["unit"],
|
||||
"required": false,
|
||||
"created_at": formatV1Timestamp(item["created_at"]),
|
||||
"updated_at": formatV1Timestamp(item["updated_at"]),
|
||||
}
|
||||
if v, ok := item["required"]; ok && v != nil {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
out["required"] = t
|
||||
}
|
||||
}
|
||||
if cid, ok := item["category_unique_id"]; ok && cid != nil && asMapString(cid) != "" {
|
||||
out["category_unique_id"] = cid
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleV1ListCategories serves GET /api/v1/categories with legacy { data, meta }.
|
||||
func (s *Server) handleV1ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
page, limit, offset := ParsePageLimit(r)
|
||||
items, total, err := s.Catalog.ListCategories(r.Context(), cid, catalog.ListFilter{
|
||||
Query: QuerySearch(r),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
data := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
data = append(data, presentV1Category(item))
|
||||
}
|
||||
v1OK(w, http.StatusOK, data, v1CatalogListMeta(page, limit, total))
|
||||
}
|
||||
|
||||
// handleV1CreateCategory serves POST /api/v1/categories and /categories/create.
|
||||
// Body: name + unique_id required; parent_id alias for parent_unique_id.
|
||||
func (s *Server) handleV1CreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
UniqueID string `json:"unique_id"`
|
||||
ParentUniqueID *string `json:"parent_unique_id"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
parent := body.ParentUniqueID
|
||||
if (parent == nil || strings.TrimSpace(*parent) == "") && body.ParentID != nil {
|
||||
parent = body.ParentID
|
||||
}
|
||||
item, err := s.Catalog.CreateCategory(r.Context(), cid, body.Name, body.UniqueID, parent, body.Description)
|
||||
if err != nil {
|
||||
if msg, ok := catalog.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create category", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
v1OK(w, http.StatusCreated, map[string]any{
|
||||
"id": item["id"],
|
||||
"unique_id": item["unique_id"],
|
||||
"name": item["name"],
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// handleV1DeleteCategory serves DELETE /api/v1/categories/{id} where {id} is unique_id.
|
||||
func (s *Server) handleV1DeleteCategory(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCompanyAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
uniqueID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if uniqueID == "" {
|
||||
Error(w, http.StatusBadRequest, "invalid category id")
|
||||
return
|
||||
}
|
||||
if err := s.Catalog.DeleteCategoryByUniqueID(r.Context(), cid, uniqueID); err != nil {
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if msg, ok := catalog.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
v1OK(w, http.StatusOK, map[string]any{"message": "Category deleted successfully"}, nil)
|
||||
}
|
||||
|
||||
// handleV1ListAttributes serves GET /api/v1/attributes with legacy { data, meta }.
|
||||
func (s *Server) handleV1ListAttributes(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
page, limit, offset := ParsePageLimit(r)
|
||||
f := catalog.ListFilter{
|
||||
Query: QuerySearch(r),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Category: firstNonEmpty(r.URL.Query().Get("categoryId"), r.URL.Query().Get("category_id")),
|
||||
RootsOnly: r.URL.Query().Get("roots") == "1",
|
||||
ParentKey: firstNonEmpty(r.URL.Query().Get("parent_key"), r.URL.Query().Get("parentKey")),
|
||||
SortBy: firstNonEmpty(r.URL.Query().Get("sortBy"), r.URL.Query().Get("sort_by"), "updatedAt"),
|
||||
SortOrder: firstNonEmpty(r.URL.Query().Get("sortOrder"), r.URL.Query().Get("sort_order"), "desc"),
|
||||
}
|
||||
items, total, err := s.Catalog.ListAttributes(r.Context(), cid, f)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
data := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
data = append(data, presentV1Attribute(item))
|
||||
}
|
||||
v1OK(w, http.StatusOK, data, v1CatalogListMeta(page, limit, total))
|
||||
}
|
||||
|
||||
// handleV1CreateAttribute serves POST /api/v1/attributes and /attributes/create.
|
||||
// Requires name, attribute_key, value_type, category_unique_id (legacy contract).
|
||||
func (s *Server) handleV1CreateAttribute(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
AttributeKey string `json:"attribute_key"`
|
||||
ValueType string `json:"value_type"`
|
||||
Unit *string `json:"unit"`
|
||||
Example *string `json:"example"`
|
||||
ParentKey *string `json:"parent_key"`
|
||||
CategoryUniqueID string `json:"category_unique_id"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.AttributeKey) == "" ||
|
||||
strings.TrimSpace(body.ValueType) == "" || strings.TrimSpace(body.CategoryUniqueID) == "" {
|
||||
Error(w, http.StatusBadRequest, "Missing required fields: name, attribute_key, value_type, category_unique_id")
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.Catalog.CreateAttribute(r.Context(), cid, body.AttributeKey, body.Name, body.ValueType, body.Unit, body.Example, body.ParentKey)
|
||||
if err != nil {
|
||||
if msg, ok := catalog.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create attribute", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
|
||||
attrID, err := parseMapUUID(item["id"])
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not create attribute")
|
||||
return
|
||||
}
|
||||
if _, err := s.Catalog.LinkCategoryAttribute(r.Context(), cid, body.CategoryUniqueID, attrID, body.Required); err != nil {
|
||||
if msg, ok := catalog.ClientError(err); ok {
|
||||
status := http.StatusBadRequest
|
||||
if strings.Contains(strings.ToLower(msg), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
Error(w, status, msg)
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not link attribute", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
|
||||
v1OK(w, http.StatusCreated, map[string]any{
|
||||
"id": item["id"],
|
||||
"key": item["attribute_key"],
|
||||
"name": item["name"],
|
||||
"type": item["value_type"],
|
||||
"unit": item["unit"],
|
||||
"category_unique_id": body.CategoryUniqueID,
|
||||
"required": body.Required,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// handleV1DeleteAttribute serves DELETE /api/v1/attributes/{id} (UUID).
|
||||
func (s *Server) handleV1DeleteAttribute(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCompanyAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid attribute id")
|
||||
return
|
||||
}
|
||||
if err := s.Catalog.DeleteAttribute(r.Context(), cid, id); err != nil {
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
v1OK(w, http.StatusOK, map[string]any{"message": "Attribute deleted successfully"}, nil)
|
||||
}
|
||||
|
||||
func parseMapUUID(v any) (uuid.UUID, error) {
|
||||
switch t := v.(type) {
|
||||
case uuid.UUID:
|
||||
return t, nil
|
||||
case string:
|
||||
return uuid.Parse(t)
|
||||
case [16]byte:
|
||||
return uuid.UUID(t), nil
|
||||
default:
|
||||
s := asMapString(v)
|
||||
if s == "" {
|
||||
return uuid.Nil, errors.New("invalid uuid")
|
||||
}
|
||||
return uuid.Parse(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestPresentV1CategoryAndAttribute(t *testing.T) {
|
||||
id := uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
||||
ts := time.Date(2026, 7, 1, 8, 0, 0, 0, time.UTC)
|
||||
cat := presentV1Category(map[string]any{
|
||||
"id": id, "unique_id": "electronics", "name": "Electronics",
|
||||
"created_at": ts, "updated_at": ts,
|
||||
})
|
||||
if cat["unique_id"] != "electronics" || cat["name"] != "Electronics" {
|
||||
t.Fatalf("category=%v", cat)
|
||||
}
|
||||
if cat["created_at"] != "2026-07-01T08:00:00Z" {
|
||||
t.Fatalf("created_at=%v", cat["created_at"])
|
||||
}
|
||||
|
||||
attr := presentV1Attribute(map[string]any{
|
||||
"id": id, "attribute_key": "color", "name": "Color", "value_type": "string",
|
||||
"unit": nil, "required": true, "category_unique_id": "electronics",
|
||||
"created_at": ts, "updated_at": ts,
|
||||
})
|
||||
if attr["key"] != "color" || attr["type"] != "string" || attr["required"] != true {
|
||||
t.Fatalf("attr=%v", attr)
|
||||
}
|
||||
if attr["category_unique_id"] != "electronics" {
|
||||
t.Fatalf("missing category_unique_id: %v", attr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1OpenAPICategoriesAttributesLegacyContract(t *testing.T) {
|
||||
body := string(v1OpenAPIYAML)
|
||||
needles := []string{
|
||||
"LegacyCategoriesResponse",
|
||||
"LegacyAttributesResponse",
|
||||
"LegacyCategoryCreateResponse",
|
||||
"LegacyAttributeCreateResponse",
|
||||
"LegacySuccessMessage",
|
||||
"category_unique_id",
|
||||
"attribute_key",
|
||||
"parent_id",
|
||||
"/categories/create:",
|
||||
"/attributes/create:",
|
||||
"Delete category by unique_id",
|
||||
"value_type:",
|
||||
"enum: [string, number, list, multiselect]",
|
||||
}
|
||||
for _, n := range needles {
|
||||
if !strings.Contains(body, n) {
|
||||
t.Fatalf("openapi missing %q", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1CreateCategoryBodyAcceptsParentID(t *testing.T) {
|
||||
payload := `{"name":"Headphones","unique_id":"headphones","parent_id":"audio","description":"x"}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/categories", strings.NewReader(payload))
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
UniqueID string `json:"unique_id"`
|
||||
ParentUniqueID *string `json:"parent_unique_id"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body.Name != "Headphones" || body.UniqueID != "headphones" || body.ParentID == nil || *body.ParentID != "audio" {
|
||||
t.Fatalf("body=%+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1CreateAttributeBodyRequiresCategory(t *testing.T) {
|
||||
payload := `{"name":"Color","attribute_key":"color","value_type":"string","category_unique_id":"electronics","required":true}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/attributes", strings.NewReader(payload))
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
AttributeKey string `json:"attribute_key"`
|
||||
ValueType string `json:"value_type"`
|
||||
CategoryUniqueID string `json:"category_unique_id"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body.CategoryUniqueID != "electronics" || !body.Required || body.AttributeKey != "color" {
|
||||
t.Fatalf("body=%+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMapUUID(t *testing.T) {
|
||||
id := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
got, err := parseMapUUID(id)
|
||||
if err != nil || got != id {
|
||||
t.Fatalf("uuid type: %v %v", got, err)
|
||||
}
|
||||
got, err = parseMapUUID(id.String())
|
||||
if err != nil || got != id {
|
||||
t.Fatalf("string: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1CatalogListMetaJSON(t *testing.T) {
|
||||
meta := v1CatalogListMeta(2, 25, 60)
|
||||
b, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, n := range []string{`"page":2`, `"limit":25`, `"total":60`, `"totalPages":3`} {
|
||||
if !strings.Contains(s, n) {
|
||||
t.Fatalf("meta missing %s in %s", n, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetCompany(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var (
|
||||
id uuid.UUID
|
||||
name, language string
|
||||
merge bool
|
||||
contentLangs []string
|
||||
)
|
||||
err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT id, name, language, merge_products_by_gtin, COALESCE(content_languages, '{}')
|
||||
FROM companies WHERE id = $1`, cid).
|
||||
Scan(&id, &name, &language, &merge, &contentLangs)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "company not found")
|
||||
return
|
||||
}
|
||||
parsed, _ := company.ParseContentLanguages(contentLangs, language)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"id": id, "name": name, "language": language,
|
||||
"content_languages": parsed, "merge_products_by_gtin": merge,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateCompany(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Language *string `json:"language"`
|
||||
ContentLanguages []string `json:"content_languages"`
|
||||
MergeProductsByGTIN *bool `json:"merge_products_by_gtin"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
var languageArg any
|
||||
primary := company.LoadLanguage(r.Context(), s.Pool, cid)
|
||||
if body.Language != nil {
|
||||
parsed, err := company.ParseLanguage(*body.Language, false)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "unsupported language")
|
||||
return
|
||||
}
|
||||
languageArg = parsed
|
||||
primary = parsed
|
||||
}
|
||||
var contentLangsArg any
|
||||
if body.ContentLanguages != nil {
|
||||
parsed, err := company.ParseContentLanguages(body.ContentLanguages, primary)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "unsupported language")
|
||||
return
|
||||
}
|
||||
contentLangsArg = parsed
|
||||
} else if body.Language != nil {
|
||||
// Keep primary first when only language changes.
|
||||
existing := company.LoadContentLanguages(r.Context(), s.Pool, cid)
|
||||
parsed, err := company.ParseContentLanguages(existing, primary)
|
||||
if err != nil {
|
||||
parsed = []string{primary}
|
||||
}
|
||||
contentLangsArg = parsed
|
||||
}
|
||||
_, err := s.Pool.Exec(r.Context(), `
|
||||
UPDATE companies SET
|
||||
name = COALESCE($2, name),
|
||||
language = COALESCE($3, language),
|
||||
content_languages = COALESCE($5, content_languages),
|
||||
merge_products_by_gtin = COALESCE($4, merge_products_by_gtin),
|
||||
updated_at = now()
|
||||
WHERE id = $1`, cid, body.Name, languageArg, body.MergeProductsByGTIN, contentLangsArg)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
s.handleGetCompany(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCompanySettings(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var settings []byte
|
||||
err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT settings FROM company_settings WHERE company_id = $1`, cid).Scan(&settings)
|
||||
if err != nil {
|
||||
JSON(w, http.StatusOK, map[string]any{"settings": map[string]any{}})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"settings":`))
|
||||
_, _ = w.Write(settings)
|
||||
_, _ = w.Write([]byte(`}`))
|
||||
}
|
||||
|
||||
func (s *Server) handlePutCompanySettings(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Settings map[string]any `json:"settings"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := company.ValidateSettingsMap(body.Settings); err != nil {
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(body.Settings)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid settings")
|
||||
return
|
||||
}
|
||||
_, err = s.Pool.Exec(r.Context(), `
|
||||
INSERT INTO company_settings (company_id, settings, updated_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (company_id) DO UPDATE SET settings = EXCLUDED.settings, updated_at = now()`,
|
||||
cid, b)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "save failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"settings": body.Settings})
|
||||
}
|
||||
|
||||
func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
var total int64
|
||||
if err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT count(*) FROM memberships m WHERE m.company_id = $1 AND m.status = 'active'`, cid).Scan(&total); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
rows, err := s.Pool.Query(r.Context(), `
|
||||
SELECT m.id, m.user_id, m.role, m.status, u.email, u.name
|
||||
FROM memberships m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.company_id = $1 AND m.status = 'active' ORDER BY m.created_at LIMIT $2 OFFSET $3`, cid, limit, offset)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type member struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
}
|
||||
out := make([]member, 0)
|
||||
for rows.Next() {
|
||||
var m member
|
||||
if err := rows.Scan(&m.ID, &m.UserID, &m.Role, &m.Status, &m.Email, &m.Name); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "scan failed")
|
||||
return
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"members": out, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
inv, token, err := s.Auth.CreateInvite(r.Context(), cid, uid, body.Email, body.Role)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create invite", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
companyName, _ := s.Auth.CompanyName(r.Context(), cid)
|
||||
smtpOn := s.Mail != nil && s.Mail.Enabled()
|
||||
sendOK := false
|
||||
if s.Mail != nil {
|
||||
msg := mail.InviteMessage(s.Config.WebOrigin, inv.Email, token, companyName)
|
||||
if err := s.Mail.Send(msg); err == nil {
|
||||
sendOK = true
|
||||
}
|
||||
}
|
||||
// noop/disabled mailers return nil from Send; only count real SMTP as delivered.
|
||||
mailSent, includeToken := inviteMailResult(smtpOn, sendOK)
|
||||
resp := map[string]any{
|
||||
"id": inv.ID, "email": inv.Email, "role": inv.Role,
|
||||
"expires_at": inv.ExpiresAt, "mail_sent": mailSent, "smtp_enabled": smtpOn,
|
||||
}
|
||||
// Token returned when email was not delivered so operators can share the accept link.
|
||||
if includeToken {
|
||||
resp["token"] = token
|
||||
}
|
||||
JSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// inviteMailResult decides mail_sent and whether the accept token must be returned to the client.
|
||||
func inviteMailResult(smtpEnabled, sendOK bool) (mailSent bool, includeToken bool) {
|
||||
mailSent = smtpEnabled && sendOK
|
||||
includeToken = !mailSent
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
userID, err := uuid.Parse(chi.URLParam(r, "userID"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
var currentRole, status string
|
||||
err = s.Pool.QueryRow(r.Context(), `
|
||||
SELECT role, status FROM memberships
|
||||
WHERE company_id = $1 AND user_id = $2`, cid, userID).Scan(¤tRole, &status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
Error(w, http.StatusNotFound, "member not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "lookup failed")
|
||||
return
|
||||
}
|
||||
if status == "active" && auth.NormalizeMembershipRole(currentRole) == "admin" {
|
||||
var activeAdmins int64
|
||||
if err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT count(*) FROM memberships
|
||||
WHERE company_id = $1 AND role = 'admin' AND status = 'active'`, cid).Scan(&activeAdmins); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "lookup failed")
|
||||
return
|
||||
}
|
||||
if blocksLastAdminRemove(activeAdmins) {
|
||||
Error(w, http.StatusConflict, "cannot remove the last admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
tag, err := s.Pool.Exec(r.Context(), `
|
||||
UPDATE memberships SET status = 'inactive', updated_at = now()
|
||||
WHERE company_id = $1 AND user_id = $2 AND status = 'active'`, cid, userID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "remove failed")
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
Error(w, http.StatusNotFound, "member not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// blocksLastAdminDemote is true when demoting an admin would leave zero active admins.
|
||||
func blocksLastAdminDemote(currentRole, newRole string, activeAdminCount int64) bool {
|
||||
return currentRole == "admin" && newRole == "member" && activeAdminCount <= 1
|
||||
}
|
||||
|
||||
// blocksLastAdminRemove is true when removing an admin would leave zero active admins.
|
||||
func blocksLastAdminRemove(activeAdminCount int64) bool {
|
||||
return activeAdminCount <= 1
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateMemberRole(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
userID, err := uuid.Parse(chi.URLParam(r, "userID"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
newRole, err := auth.ParseMembershipRole(body.Role)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid role")
|
||||
return
|
||||
}
|
||||
var currentRole, status string
|
||||
err = s.Pool.QueryRow(r.Context(), `
|
||||
SELECT role, status FROM memberships
|
||||
WHERE company_id = $1 AND user_id = $2`, cid, userID).Scan(¤tRole, &status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
Error(w, http.StatusNotFound, "member not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "lookup failed")
|
||||
return
|
||||
}
|
||||
if status != "active" {
|
||||
Error(w, http.StatusBadRequest, "member is not active")
|
||||
return
|
||||
}
|
||||
currentRole = auth.NormalizeMembershipRole(currentRole)
|
||||
if currentRole == newRole {
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "ok", "role": newRole, "user_id": userID})
|
||||
return
|
||||
}
|
||||
if currentRole == "admin" && newRole == "member" {
|
||||
var activeAdmins int64
|
||||
if err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT count(*) FROM memberships
|
||||
WHERE company_id = $1 AND role = 'admin' AND status = 'active'`, cid).Scan(&activeAdmins); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "lookup failed")
|
||||
return
|
||||
}
|
||||
if blocksLastAdminDemote(currentRole, newRole, activeAdmins) {
|
||||
Error(w, http.StatusConflict, "cannot demote the last admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
tag, err := s.Pool.Exec(r.Context(), `
|
||||
UPDATE memberships SET role = $3, updated_at = now()
|
||||
WHERE company_id = $1 AND user_id = $2 AND status = 'active'`, cid, userID, newRole)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
Error(w, http.StatusNotFound, "member not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "ok", "role": newRole, "user_id": userID})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package httpapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestInviteMailResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
smtpEnabled bool
|
||||
sendOK bool
|
||||
wantMailSent bool
|
||||
wantToken bool
|
||||
}{
|
||||
{name: "smtp_ok", smtpEnabled: true, sendOK: true, wantMailSent: true, wantToken: false},
|
||||
{name: "smtp_send_fail", smtpEnabled: true, sendOK: false, wantMailSent: false, wantToken: true},
|
||||
{name: "noop_mailer_send_ok", smtpEnabled: false, sendOK: true, wantMailSent: false, wantToken: true},
|
||||
{name: "disabled_no_send", smtpEnabled: false, sendOK: false, wantMailSent: false, wantToken: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
mailSent, includeToken := inviteMailResult(tc.smtpEnabled, tc.sendOK)
|
||||
if mailSent != tc.wantMailSent {
|
||||
t.Fatalf("mailSent=%v want %v", mailSent, tc.wantMailSent)
|
||||
}
|
||||
if includeToken != tc.wantToken {
|
||||
t.Fatalf("includeToken=%v want %v", includeToken, tc.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestBlocksLastAdminDemote(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
currentRole string
|
||||
newRole string
|
||||
activeAdminCount int64
|
||||
want bool
|
||||
}{
|
||||
{name: "demote_last_admin", currentRole: "admin", newRole: "member", activeAdminCount: 1, want: true},
|
||||
{name: "demote_zero_admins", currentRole: "admin", newRole: "member", activeAdminCount: 0, want: true},
|
||||
{name: "demote_with_other_admins", currentRole: "admin", newRole: "member", activeAdminCount: 2, want: false},
|
||||
{name: "promote_member", currentRole: "member", newRole: "admin", activeAdminCount: 1, want: false},
|
||||
{name: "noop_admin", currentRole: "admin", newRole: "admin", activeAdminCount: 1, want: false},
|
||||
{name: "noop_member", currentRole: "member", newRole: "member", activeAdminCount: 0, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := blocksLastAdminDemote(tc.currentRole, tc.newRole, tc.activeAdminCount)
|
||||
if got != tc.want {
|
||||
t.Fatalf("blocksLastAdminDemote(%q,%q,%d)=%v want %v",
|
||||
tc.currentRole, tc.newRole, tc.activeAdminCount, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocksLastAdminRemove(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !blocksLastAdminRemove(1) {
|
||||
t.Fatal("expected last admin remove blocked")
|
||||
}
|
||||
if !blocksLastAdminRemove(0) {
|
||||
t.Fatal("expected zero admins remove blocked")
|
||||
}
|
||||
if blocksLastAdminRemove(2) {
|
||||
t.Fatal("expected remove allowed when other admins remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMemberRoleRejectsInvalidRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("userID", uid.String())
|
||||
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/team/"+uid.String(), bytes.NewBufferString(`{"role":"owner"}`))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUpdateMemberRole(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "invalid role") {
|
||||
t.Fatalf("body = %s, want invalid role", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMemberRoleForbiddenForMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "member")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/team/"+uid.String(), bytes.NewBufferString(`{"role":"admin"}`))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUpdateMemberRole(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowCompanyAdminOrPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
t.Run("company_admin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
if !s.allowCompanyAdminOrPlatform(rec, req) {
|
||||
t.Fatal("company admin should be allowed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member_denied", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) {
|
||||
return false, nil
|
||||
},
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "member")
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
if s.allowCompanyAdminOrPlatform(rec, req) {
|
||||
t.Fatal("member without platform admin must be denied")
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("platform_admin_member_role", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
if got != uid {
|
||||
t.Fatalf("userID = %s, want %s", got, uid)
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "member")
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
if !s.allowCompanyAdminOrPlatform(rec, req) {
|
||||
t.Fatal("platform admin with membership role=member must be allowed for cutover")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dev_impersonator_retains_admin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
actor := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
sm := scs.New()
|
||||
s := &Server{
|
||||
Config: config.Config{AppEnv: "development"},
|
||||
Sessions: sm,
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == actor, nil
|
||||
},
|
||||
}
|
||||
|
||||
var token string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
sm.Put(r.Context(), auth.SessionImpersonatorIDKey, actor.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session cookie")
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "member")
|
||||
req := r.WithContext(ctx)
|
||||
if !s.allowCompanyAdminOrPlatform(w, req) {
|
||||
t.Fatal("impersonating privileged actor must retain company-admin powers")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})).ServeHTTP(rec, func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
return req
|
||||
}())
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dev_impersonator_helper_empty_session", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{AppEnv: "development"}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid))
|
||||
if s.devImpersonatorRetainsCompanyAdmin(req) {
|
||||
t.Fatal("nil Sessions must not retain admin")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestPutCompanySettingsRejectsUnknownKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/company/settings",
|
||||
bytes.NewBufferString(`{"settings":{"prefs.theme":"dark","language":"en"}}`),
|
||||
)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handlePutCompanySettings(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "unknown settings key") {
|
||||
t.Fatalf("body = %s, want unknown settings key", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutCompanySettingsRejectsInvalidLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/company/settings",
|
||||
bytes.NewBufferString(`{"settings":{"language":"not-a-lang"}}`),
|
||||
)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handlePutCompanySettings(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s, want 400", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "unsupported language") {
|
||||
t.Fatalf("body = %s, want unsupported language", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutCompanySettingsForbiddenForMember(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "member")
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/company/settings",
|
||||
bytes.NewBufferString(`{"settings":{"language":"en"}}`),
|
||||
)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handlePutCompanySettings(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func testServerCSRF() *Server {
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
return &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
SessionSecure: false,
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
}
|
||||
}
|
||||
|
||||
func testServerCSRFSecure(secure bool, appEnv string) *Server {
|
||||
s := testServerCSRF()
|
||||
s.Config.SessionSecure = secure
|
||||
s.Config.AppEnv = appEnv
|
||||
return s
|
||||
}
|
||||
|
||||
func findCSRFCookie(cookies []*http.Cookie) *http.Cookie {
|
||||
for _, c := range cookies {
|
||||
if c.Name == "descrybe_csrf" && c.Value != "" {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCSRFAllowsSafeMethodsWithoutHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("GET status = %d, want 204", rec.Code)
|
||||
}
|
||||
found := false
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "descrybe_csrf" && c.Value != "" && !c.HttpOnly {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected non-HttpOnly CSRF cookie on first GET")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFRejectsPOSTWithoutToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/auth/login",
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/reset-password",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s POST without CSRF status = %d, want 403", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFAcceptsMatchingHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
getRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(getRec, getReq)
|
||||
var token string
|
||||
for _, c := range getRec.Result().Cookies() {
|
||||
if c.Name == "descrybe_csrf" {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("missing CSRF cookie from GET")
|
||||
}
|
||||
|
||||
postReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
postReq.AddCookie(&http.Cookie{Name: "descrybe_csrf", Value: token})
|
||||
postReq.Header.Set("X-CSRF-Token", token)
|
||||
postRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(postRec, postReq)
|
||||
if postRec.Code != http.StatusOK {
|
||||
t.Fatalf("POST with CSRF status = %d, want 200", postRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFRejectsMismatchedHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "descrybe_csrf", Value: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})
|
||||
req.Header.Set("X-CSRF-Token", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("mismatched CSRF status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFCookieAttributesDev(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRFSecure(false, "development")
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
c := findCSRFCookie(rec.Result().Cookies())
|
||||
if c == nil {
|
||||
t.Fatal("expected CSRF cookie")
|
||||
}
|
||||
if c.HttpOnly {
|
||||
t.Fatal("CSRF cookie must not be HttpOnly (double-submit)")
|
||||
}
|
||||
if c.Secure {
|
||||
t.Fatal("development without SessionSecure should not set Secure")
|
||||
}
|
||||
if c.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
|
||||
}
|
||||
if c.Path != "/" {
|
||||
t.Fatalf("Path = %q, want /", c.Path)
|
||||
}
|
||||
if c.MaxAge != 7*24*60*60 {
|
||||
t.Fatalf("MaxAge = %d, want 7d", c.MaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFCookieSecureWhenSessionSecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRFSecure(true, "development")
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
c := findCSRFCookie(rec.Result().Cookies())
|
||||
if c == nil {
|
||||
t.Fatal("expected CSRF cookie")
|
||||
}
|
||||
if !c.Secure {
|
||||
t.Fatal("SessionSecure=true should set Secure")
|
||||
}
|
||||
if c.HttpOnly {
|
||||
t.Fatal("CSRF cookie must not be HttpOnly")
|
||||
}
|
||||
if c.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax", c.SameSite)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFCookieSecureWhenProductionAppEnv(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Defense in depth: APP_ENV=production forces Secure even if SessionSecure was left false.
|
||||
s := testServerCSRFSecure(false, "production")
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
c := findCSRFCookie(rec.Result().Cookies())
|
||||
if c == nil {
|
||||
t.Fatal("expected CSRF cookie")
|
||||
}
|
||||
if !c.Secure {
|
||||
t.Fatal("APP_ENV=production must set Secure via CookieSecure")
|
||||
}
|
||||
}
|
||||
|
||||
// Client-mint path: SPA sets descrybe_csrf locally; middleware must accept matching header+cookie
|
||||
// without a prior server-issued Set-Cookie on this request.
|
||||
func TestCSRFAcceptsClientMintedCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
const token = "0123456789abcdef0123456789abcdef"
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "descrybe_csrf", Value: token})
|
||||
req.Header.Set("X-CSRF-Token", token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("client-minted CSRF status = %d, want 204", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFExemptPathSegmentsOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
path string
|
||||
exempt bool
|
||||
}{
|
||||
{"/api/v1", true},
|
||||
{"/api/v1/products", true},
|
||||
{"/api/v10", false},
|
||||
{"/api/v1legacy", false},
|
||||
{"/api/public", true},
|
||||
{"/api/public/plans", true},
|
||||
{"/api/publicish", false},
|
||||
{"/api/webhooks", true},
|
||||
{"/api/webhooks/stripe", true},
|
||||
{"/api/webhooksx", false},
|
||||
{"/api/auth/login", false},
|
||||
{"/api/auth/forgot-password", false},
|
||||
{"/api/auth/reset-password", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := csrfExemptPath(tc.path); got != tc.exempt {
|
||||
t.Fatalf("csrfExemptPath(%q) = %v, want %v", tc.path, got, tc.exempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFRequiresTokenOnV1LookalikePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testServerCSRF()
|
||||
h := s.CSRF(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v10/mutate", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 (lookalike must not skip CSRF)", rec.Code)
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/products", nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusNoContent {
|
||||
t.Fatalf("v1 exempt status = %d, want 204", rec2.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetEmailIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Email == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
cfg, err := s.Email.GetConfig(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load email settings")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutEmailIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if s.Email == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body email.UpdateInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
cfg, err := s.Email.UpdateConfig(r.Context(), cid, body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update email settings", err, email.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) handleVerifyEmailIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if s.Email == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
cfg, msg, err := s.Email.VerifyDomain(r.Context(), cid)
|
||||
if errors.Is(err, email.ErrNotConfigured) {
|
||||
Error(w, http.StatusBadRequest, "email provider not configured")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, email.ErrProviderMisconfig) {
|
||||
Error(w, http.StatusBadRequest, "email provider credentials incomplete")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "email verification failed", err, email.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"config": cfg, "message": msg})
|
||||
}
|
||||
|
||||
func (s *Server) handleTestEmailIntegration(w http.ResponseWriter, r *http.Request) {
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if s.Email == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
to := strings.TrimSpace(body.To)
|
||||
if to == "" {
|
||||
Error(w, http.StatusBadRequest, "to is required")
|
||||
return
|
||||
}
|
||||
normalized, nerr := campaigns.NormalizeEmail(to)
|
||||
if nerr != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid email")
|
||||
return
|
||||
}
|
||||
to = normalized
|
||||
// Fixed probe content — do not accept client HTML/subject (header injection / phishing via test).
|
||||
result, err := s.Email.Send(r.Context(), cid, email.SendRequest{
|
||||
To: []string{to},
|
||||
Subject: "Descrybe email test",
|
||||
Text: "This is a Descrybe email provider test.",
|
||||
HTML: "<p>This is a Descrybe email provider test.</p>",
|
||||
Mode: "test",
|
||||
})
|
||||
if err != nil {
|
||||
writeEmailSendError(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleSendEmail(w http.ResponseWriter, r *http.Request) {
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
if s.Email == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
if s.Billing != nil {
|
||||
if err := s.Billing.AssertFeatures(r.Context(), cid, "capability.email_live_send", "integrations.email.blast"); err != nil {
|
||||
if writePlanGate(w, err) {
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "feature check failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
var body email.SendRequest
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
result, err := s.Email.Send(r.Context(), cid, body)
|
||||
if err != nil {
|
||||
writeEmailSendError(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func writeEmailSendError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, email.ErrMissingConfirm):
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, email.ErrNotVerified):
|
||||
Error(w, http.StatusPreconditionFailed, "email_not_verified")
|
||||
case errors.Is(err, email.ErrNotConfigured):
|
||||
Error(w, http.StatusBadRequest, "email provider not configured")
|
||||
case errors.Is(err, email.ErrNotEnabled):
|
||||
Error(w, http.StatusBadRequest, "email provider is disabled")
|
||||
case errors.Is(err, email.ErrRateLimited):
|
||||
w.Header().Set("Retry-After", "60")
|
||||
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
case errors.Is(err, email.ErrProviderMisconfig):
|
||||
Error(w, http.StatusBadRequest, "email provider credentials incomplete")
|
||||
default:
|
||||
LogAndError(w, http.StatusBadRequest, "email send failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePublicUnsubscribeGet(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Email == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
_, emailAddr, already, err := s.Email.LookupUnsubscribeToken(r.Context(), token)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
Error(w, http.StatusNotFound, "invalid unsubscribe token")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "lookup failed")
|
||||
return
|
||||
}
|
||||
// Mask in response — one-click clients only need status.
|
||||
_ = emailAddr
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"already_unsubscribed": already,
|
||||
"supports_one_click": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePublicUnsubscribePost(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Email == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "email integration unavailable")
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
reason := ""
|
||||
if r.Header.Get("Content-Type") != "" && strings.Contains(r.Header.Get("Content-Type"), "application/json") {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := DecodeJSONOptional(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Token != "" {
|
||||
token = body.Token
|
||||
}
|
||||
reason = body.Reason
|
||||
} else if token == "" {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
token = strings.TrimSpace(r.Form.Get("token"))
|
||||
reason = strings.TrimSpace(r.Form.Get("reason"))
|
||||
}
|
||||
info, err := s.Email.UnsubscribeByToken(r.Context(), token, reason)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "unsubscribe failed")
|
||||
return
|
||||
}
|
||||
if !info.OK {
|
||||
Error(w, http.StatusNotFound, info.Message)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, info)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleExportSelectedProducts(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
feedID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ProductIDs []string `json:"product_ids"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(body.ProductIDs))
|
||||
for _, raw := range body.ProductIDs {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid product_ids")
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
filename, mimeType, content, count, err := s.Feeds.ExportSelectedProducts(r.Context(), cid, feedID, ids)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not export selected products", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.Header().Set("X-Products-Exported", strconv.Itoa(count))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Server) handleListFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
page, total, activeTotal, mappedTotal, err := s.Feeds.List(r.Context(), cid, limit, offset, QuerySearch(r))
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
products, err := s.Feeds.CompanyProductTotals(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"feeds": feeds.PresentFeeds(page), "total": total, "active_total": activeTotal, "mapped_total": mappedTotal,
|
||||
"product_total": products.Total, "processed_total": products.Processed, "unprocessed_total": products.Unprocessed,
|
||||
"limit": limit, "offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
|
||||
ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
s.createFeedFromMultipart(w, r, cid, uid)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
ItemPath string `json:"item_path"`
|
||||
FeedType string `json:"feed_type"`
|
||||
SyncIntervalMinutes int `json:"sync_interval_minutes"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.Create(r.Context(), cid, feeds.CreateInput{
|
||||
Name: body.Name,
|
||||
URL: body.URL,
|
||||
ItemPath: body.ItemPath,
|
||||
FeedType: body.FeedType,
|
||||
SyncIntervalMinutes: body.SyncIntervalMinutes,
|
||||
})
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create feed", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, feeds.PresentFeed(item))
|
||||
}
|
||||
|
||||
func (s *Server) createFeedFromMultipart(w http.ResponseWriter, r *http.Request, cid, uid uuid.UUID) {
|
||||
if err := r.ParseMultipartForm(catalogMaxUpload); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid multipart form")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
url := strings.TrimSpace(r.FormValue("url"))
|
||||
feedType := strings.TrimSpace(r.FormValue("feed_type"))
|
||||
itemPath := strings.TrimSpace(r.FormValue("item_path"))
|
||||
interval, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("sync_interval_minutes")))
|
||||
|
||||
file, header, fileErr := r.FormFile("file")
|
||||
var options map[string]any
|
||||
if fileErr == nil {
|
||||
defer file.Close()
|
||||
meta, err := s.Catalog.SaveUpload(
|
||||
r.Context(),
|
||||
cid,
|
||||
uid,
|
||||
s.Config.UploadDir,
|
||||
header.Filename,
|
||||
header.Header.Get("Content-Type"),
|
||||
"feed",
|
||||
file,
|
||||
)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not save upload", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
pathStr, _ := meta["path"].(string)
|
||||
fileID, _ := meta["id"].(string)
|
||||
fileName, _ := meta["name"].(string)
|
||||
options = map[string]any{
|
||||
"source_path": pathStr,
|
||||
"source_file_id": fileID,
|
||||
"source_filename": fileName,
|
||||
"source_kind": "csv",
|
||||
}
|
||||
if feedType == "" {
|
||||
feedType = "csv"
|
||||
}
|
||||
if fid, err := uuid.Parse(fileID); err == nil {
|
||||
_, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fid, "uploaded", map[string]any{
|
||||
"kind": "feed",
|
||||
"feed": true,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
} else if url == "" && itemPath == "" {
|
||||
Error(w, http.StatusBadRequest, "url or file field required")
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.Feeds.Create(r.Context(), cid, feeds.CreateInput{
|
||||
Name: name,
|
||||
URL: url,
|
||||
ItemPath: itemPath,
|
||||
FeedType: feedType,
|
||||
SyncIntervalMinutes: interval,
|
||||
Options: options,
|
||||
})
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create feed", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, feeds.PresentFeed(item))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetFeed(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.Get(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
if feeds.IsNotFound(err) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "get failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, feeds.PresentFeed(item))
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.Update(r.Context(), cid, id, body)
|
||||
if err != nil {
|
||||
if feeds.IsNotFound(err) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update feed", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, feeds.PresentFeed(item))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteFeed(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Feeds.Delete(r.Context(), cid, id); err != nil {
|
||||
if feeds.IsNotFound(err) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"id": id.String(), "deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncFeed(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
jobID, err := s.Feeds.EnqueueSync(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
if feeds.IsNotFound(err) {
|
||||
Error(w, http.StatusNotFound, "Feed not found")
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not sync feed", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
if s.Jobs != nil {
|
||||
_ = s.Jobs.EnqueueFeedSyncJob(r.Context(), jobID)
|
||||
}
|
||||
job, err := s.Feeds.GetSyncJob(r.Context(), cid, id, jobID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not load sync job")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusAccepted, job)
|
||||
}
|
||||
|
||||
func (s *Server) handleListSyncJobs(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
limit, _ := ParseLimitOffset(r)
|
||||
items, err := s.Feeds.ListSyncJobs(r.Context(), cid, id, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"jobs": items, "limit": limit})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSyncJob(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
feedID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
jobID, err := uuid.Parse(chi.URLParam(r, "jobID"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid job id")
|
||||
return
|
||||
}
|
||||
job, err := s.Feeds.GetSyncJob(r.Context(), cid, feedID, jobID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, job)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetFeedMappings(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.GetMappings(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
JSON(w, http.StatusOK, map[string]any{"mappings": []any{}})
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutFeedMappings(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Mappings any `json:"mappings"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.PutMappings(r.Context(), cid, id, body.Mappings)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not save mappings", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleExtractFeedSchema(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ItemPath string `json:"item_path"`
|
||||
}
|
||||
if err := DecodeJSONOptional(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
result, err := s.Feeds.ExtractSchema(r.Context(), cid, id, body.ItemPath)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not extract schema", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleSyncAndProcessSample syncs a company-scoped feed then starts a processing job
|
||||
// for up to N of that feed's raw products (default 10, max 100).
|
||||
func (s *Server) handleSyncAndProcessSample(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Limit int `json:"limit"`
|
||||
SkipSync bool `json:"skip_sync"`
|
||||
ProcessingType string `json:"processing_type"`
|
||||
}
|
||||
if err := DecodeJSONOptional(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
limit := body.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
processingType := strings.TrimSpace(body.ProcessingType)
|
||||
if processingType == "" {
|
||||
processingType = "full"
|
||||
}
|
||||
|
||||
var syncJob map[string]any
|
||||
if !body.SkipSync {
|
||||
job, syncErr := s.Feeds.Sync(r.Context(), cid, id)
|
||||
if syncErr != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not sync feed", syncErr, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
syncJob = job
|
||||
}
|
||||
|
||||
rawIDs, err := s.Catalog.ListRawProductIDsByFeed(r.Context(), cid, id, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list raw products failed")
|
||||
return
|
||||
}
|
||||
if len(rawIDs) == 0 {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"sync_job": syncJob,
|
||||
"processing_job": nil,
|
||||
"raw_product_ids": []string{},
|
||||
"sample_requested": limit,
|
||||
"sample_queued": 0,
|
||||
"message": "Sync completed but no raw products found for this feed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
procJobs, err := s.Processing.StartJob(r.Context(), cid, uid, rawIDs, processingType)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not start processing", err, processing.ClientError)
|
||||
return
|
||||
}
|
||||
for _, procJob := range procJobs {
|
||||
if err := s.Jobs.EnqueueProcessingJob(r.Context(), procJob.ID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "enqueue failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
var primary any
|
||||
if len(procJobs) > 0 {
|
||||
primary = processing.FormatStartJobsResponse(procJobs)
|
||||
}
|
||||
|
||||
idStrs := make([]string, 0, len(rawIDs))
|
||||
for _, rid := range rawIDs {
|
||||
idStrs = append(idStrs, rid.String())
|
||||
}
|
||||
JSON(w, http.StatusAccepted, map[string]any{
|
||||
"sync_job": syncJob,
|
||||
"processing_job": primary,
|
||||
"processing_jobs": procJobs,
|
||||
"raw_product_ids": idStrs,
|
||||
"sample_requested": limit,
|
||||
"sample_queued": len(rawIDs),
|
||||
"message": fmt.Sprintf("Queued %d product(s) for processing", len(rawIDs)),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleListExportFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
page, total, err := s.Feeds.ListExportFeeds(r.Context(), cid, limit, offset)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"export_feeds": page, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateExportFeed(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
SourceFeedID *string `json:"source_feed_id"`
|
||||
Format string `json:"format"`
|
||||
Template any `json:"template"`
|
||||
Filters any `json:"filters"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.CreateExportFeed(r.Context(), cid, feeds.CreateExportInput{
|
||||
Name: body.Name, SourceFeedID: body.SourceFeedID, Format: body.Format,
|
||||
Template: body.Template, Filters: body.Filters,
|
||||
})
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create export feed", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetExportFeed(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.GetExportFeed(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateExportFeed(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
Template any `json:"template"`
|
||||
Filters any `json:"filters"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.UpdateExportFeed(r.Context(), cid, id, body.Name, body.IsActive, body.Template, body.Filters)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update export feed", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateExportFeedTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Template any `json:"template"`
|
||||
Filters any `json:"filters"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.UpdateExportFeedTemplate(r.Context(), cid, id, body.Template, body.Filters)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update export template", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteExportFeed(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Feeds.DeleteExportFeed(r.Context(), cid, id); err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleRotateExportFeedPublicToken(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.RotateExportFeedPublicToken(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleGenerateExportFeed(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Feeds.GenerateExportFeed(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not generate export", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublicExportXML(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
lw := &lazyHeaderWriter{ResponseWriter: w, contentType: "application/xml; charset=utf-8"}
|
||||
if err := s.Feeds.PublicExportXML(r.Context(), lw, token); err != nil {
|
||||
if !lw.wrote {
|
||||
writePublicExportError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePublicExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
lw := &lazyHeaderWriter{ResponseWriter: w, contentType: "text/csv; charset=utf-8"}
|
||||
if err := s.Feeds.PublicExportCSV(r.Context(), lw, token); err != nil {
|
||||
if !lw.wrote {
|
||||
writePublicExportError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func writePublicExportError(w http.ResponseWriter, err error) {
|
||||
// Format mismatch must look identical to unknown tokens so probing .xml/.csv
|
||||
// cannot confirm whether a guessed public_token exists.
|
||||
switch {
|
||||
case errors.Is(err, feeds.ErrFormatMismatch), errors.Is(err, pgx.ErrNoRows):
|
||||
Error(w, http.StatusNotFound, "export feed not found")
|
||||
default:
|
||||
Error(w, http.StatusNotFound, "export feed not found")
|
||||
}
|
||||
}
|
||||
|
||||
type lazyHeaderWriter struct {
|
||||
http.ResponseWriter
|
||||
contentType string
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (l *lazyHeaderWriter) Write(p []byte) (int, error) {
|
||||
if !l.wrote {
|
||||
l.Header().Set("Content-Type", l.contentType)
|
||||
l.wrote = true
|
||||
}
|
||||
return l.ResponseWriter.Write(p)
|
||||
}
|
||||
|
||||
func (l *lazyHeaderWriter) Flush() {
|
||||
if f, ok := l.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/jobs"
|
||||
)
|
||||
|
||||
const healthServiceName = "api"
|
||||
|
||||
// dbPinger is satisfied by *pgxpool.Pool; kept narrow for unit tests.
|
||||
type dbPinger interface {
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"service": healthServiceName,
|
||||
"maintenance": s.Config.MaintenanceMode,
|
||||
"read_only": s.Config.ReadOnlyMode,
|
||||
"hypercare": s.Config.HypercareMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
||||
var pinger dbPinger
|
||||
var prober jobs.HeartbeatQuerier
|
||||
if s.Pool != nil {
|
||||
pinger = s.Pool
|
||||
prober = s.Pool
|
||||
}
|
||||
s.writeReadyz(w, r, pinger, prober)
|
||||
}
|
||||
|
||||
func (s *Server) writeReadyz(w http.ResponseWriter, r *http.Request, pinger dbPinger, prober jobs.HeartbeatQuerier) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ok, check, errMsg := databaseReady(ctx, pinger)
|
||||
checks := map[string]string{"database": check}
|
||||
body := map[string]any{
|
||||
"status": "ready",
|
||||
"service": healthServiceName,
|
||||
"maintenance": s.Config.MaintenanceMode,
|
||||
"read_only": s.Config.ReadOnlyMode,
|
||||
"hypercare": s.Config.HypercareMode,
|
||||
"checks": checks,
|
||||
}
|
||||
if !ok {
|
||||
body["status"] = "not_ready"
|
||||
body["error"] = errMsg
|
||||
JSON(w, http.StatusServiceUnavailable, body)
|
||||
return
|
||||
}
|
||||
|
||||
probe := jobs.ProbeWorkerReadiness(ctx, prober, jobs.DefaultHeartbeatStaleAfter)
|
||||
checks["worker"] = probe.WorkerCheck
|
||||
checks["queue"] = probe.QueueCheck
|
||||
body["queue_pending"] = probe.PendingJobs
|
||||
if probe.LastSeenAgeS >= 0 {
|
||||
body["worker_last_seen_age_s"] = probe.LastSeenAgeS
|
||||
}
|
||||
if !probe.OK {
|
||||
body["status"] = "not_ready"
|
||||
body["error"] = probe.ErrMsg
|
||||
if probe.Reason != "" {
|
||||
body["reason"] = probe.Reason
|
||||
}
|
||||
JSON(w, http.StatusServiceUnavailable, body)
|
||||
return
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// databaseReady pings Postgres for readiness. check is "ok", "unavailable", or "fail".
|
||||
// errMsg is empty when ok; never includes driver detail (safe for public probes).
|
||||
func databaseReady(ctx context.Context, p dbPinger) (ok bool, check string, errMsg string) {
|
||||
if p == nil {
|
||||
return false, "unavailable", "database pool unavailable"
|
||||
}
|
||||
if err := p.Ping(ctx); err != nil {
|
||||
return false, "fail", "database ping failed"
|
||||
}
|
||||
return true, "ok", ""
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/jobs"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type stubPinger struct{ err error }
|
||||
|
||||
func (p stubPinger) Ping(context.Context) error { return p.err }
|
||||
|
||||
type stubHBRow struct {
|
||||
scan func(dest ...any) error
|
||||
}
|
||||
|
||||
func (r stubHBRow) Scan(dest ...any) error {
|
||||
if r.scan == nil {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
return r.scan(dest...)
|
||||
}
|
||||
|
||||
type stubHeartbeat struct {
|
||||
pending int64
|
||||
lastSeen time.Time
|
||||
seenErr error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (q *stubHeartbeat) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||
q.calls++
|
||||
if q.calls == 1 {
|
||||
return stubHBRow{scan: func(dest ...any) error {
|
||||
*(dest[0].(*int64)) = q.pending
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
return stubHBRow{scan: func(dest ...any) error {
|
||||
if q.seenErr != nil {
|
||||
return q.seenErr
|
||||
}
|
||||
*(dest[0].(*time.Time)) = q.lastSeen
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
|
||||
func liveWorkerProbe() jobs.HeartbeatQuerier {
|
||||
return &stubHeartbeat{pending: 2, lastSeen: time.Now()}
|
||||
}
|
||||
|
||||
func TestHandleHealthzOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{MaintenanceMode: true, ReadOnlyMode: true, HypercareMode: true}}
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleHealthz(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if body["service"] != healthServiceName {
|
||||
t.Fatalf("service = %#v", body["service"])
|
||||
}
|
||||
if body["maintenance"] != true || body["read_only"] != true || body["hypercare"] != true {
|
||||
t.Fatalf("expected maintenance/read_only/hypercare flags, got %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReadyzNilPool(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{MaintenanceMode: true, ReadOnlyMode: true}, Pool: nil}
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "not_ready" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if body["service"] != healthServiceName {
|
||||
t.Fatalf("service = %#v", body["service"])
|
||||
}
|
||||
if body["maintenance"] != true || body["read_only"] != true {
|
||||
t.Fatalf("expected flags on 503, got %#v", body)
|
||||
}
|
||||
checks, _ := body["checks"].(map[string]any)
|
||||
if checks["database"] != "unavailable" {
|
||||
t.Fatalf("checks = %#v", body["checks"])
|
||||
}
|
||||
if body["error"] != "database pool unavailable" {
|
||||
t.Fatalf("error = %#v", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadyzPingOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{ReadOnlyMode: true}}
|
||||
rec := httptest.NewRecorder()
|
||||
s.writeReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil), stubPinger{}, liveWorkerProbe())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "ready" || body["service"] != healthServiceName {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if body["read_only"] != true {
|
||||
t.Fatalf("read_only = %#v", body["read_only"])
|
||||
}
|
||||
checks, _ := body["checks"].(map[string]any)
|
||||
if checks["database"] != "ok" || checks["worker"] != "ok" || checks["queue"] != "ok" {
|
||||
t.Fatalf("checks = %#v", body["checks"])
|
||||
}
|
||||
if body["queue_pending"] != float64(2) {
|
||||
t.Fatalf("queue_pending = %#v", body["queue_pending"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadyzWorkerStale(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
rec := httptest.NewRecorder()
|
||||
stale := &stubHeartbeat{pending: 5, lastSeen: time.Now().Add(-2 * time.Minute)}
|
||||
s.writeReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil), stubPinger{}, stale)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checks, _ := body["checks"].(map[string]any)
|
||||
if body["status"] != "not_ready" || checks["worker"] != "stale" || checks["database"] != "ok" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if body["queue_pending"] != float64(5) {
|
||||
t.Fatalf("queue_pending = %#v", body["queue_pending"])
|
||||
}
|
||||
if body["error"] != "worker heartbeat stale" {
|
||||
t.Fatalf("error = %#v", body["error"])
|
||||
}
|
||||
reason, _ := body["reason"].(string)
|
||||
if reason == "" || !strings.Contains(reason, "npm run dev") {
|
||||
t.Fatalf("reason = %#v", body["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadyzPingFail(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
rec := httptest.NewRecorder()
|
||||
s.writeReadyz(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil), stubPinger{err: errors.New("boom")}, liveWorkerProbe())
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checks, _ := body["checks"].(map[string]any)
|
||||
if body["status"] != "not_ready" || checks["database"] != "fail" {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
if body["error"] != "database ping failed" {
|
||||
t.Fatalf("error leaked detail: %#v", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
ok, check, msg := databaseReady(ctx, nil)
|
||||
if ok || check != "unavailable" || msg == "" {
|
||||
t.Fatalf("nil pinger: ok=%v check=%s msg=%q", ok, check, msg)
|
||||
}
|
||||
ok, check, msg = databaseReady(ctx, stubPinger{err: errors.New("x")})
|
||||
if ok || check != "fail" || msg != "database ping failed" {
|
||||
t.Fatalf("fail pinger: ok=%v check=%s msg=%q", ok, check, msg)
|
||||
}
|
||||
ok, check, msg = databaseReady(ctx, stubPinger{})
|
||||
if !ok || check != "ok" || msg != "" {
|
||||
t.Fatalf("ok pinger: ok=%v check=%s msg=%q", ok, check, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceGateBlocksNonHealth(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{MaintenanceMode: true}}
|
||||
h := s.MaintenanceGate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
blocked := httptest.NewRecorder()
|
||||
h.ServeHTTP(blocked, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
|
||||
if blocked.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("blocked status = %d", blocked.Code)
|
||||
}
|
||||
assertGateBody(t, blocked, "maintenance", true, false)
|
||||
|
||||
ok := httptest.NewRecorder()
|
||||
h.ServeHTTP(ok, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if ok.Code != http.StatusOK {
|
||||
t.Fatalf("health status = %d", ok.Code)
|
||||
}
|
||||
|
||||
ready := httptest.NewRecorder()
|
||||
h.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if ready.Code != http.StatusOK {
|
||||
t.Fatalf("readyz status = %d", ready.Code)
|
||||
}
|
||||
|
||||
// Query string must not defeat the probe exemption (Path is still /healthz).
|
||||
probeQ := httptest.NewRecorder()
|
||||
h.ServeHTTP(probeQ, httptest.NewRequest(http.MethodGet, "/healthz?ping=1", nil))
|
||||
if probeQ.Code != http.StatusOK {
|
||||
t.Fatalf("healthz?query status = %d", probeQ.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyGateBlocksMutations(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{ReadOnlyMode: true}}
|
||||
h := s.MaintenanceGate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(method, "/api/products", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, want 200", method, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(method, "/api/products", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("%s status = %d, want 503", method, rec.Code)
|
||||
}
|
||||
assertGateBody(t, rec, "read_only", false, true)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceGatePrecedenceOverReadOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{MaintenanceMode: true, ReadOnlyMode: true}}
|
||||
h := s.MaintenanceGate(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
getRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(getRec, httptest.NewRequest(http.MethodGet, "/api/products", nil))
|
||||
if getRec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("GET status = %d, want 503", getRec.Code)
|
||||
}
|
||||
assertGateBody(t, getRec, "maintenance", true, true)
|
||||
|
||||
postRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(postRec, httptest.NewRequest(http.MethodPost, "/api/products", nil))
|
||||
if postRec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("POST status = %d, want 503", postRec.Code)
|
||||
}
|
||||
assertGateBody(t, postRec, "maintenance", true, true)
|
||||
|
||||
ok := httptest.NewRecorder()
|
||||
h.ServeHTTP(ok, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if ok.Code != http.StatusOK {
|
||||
t.Fatalf("readyz status = %d", ok.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterMaintenanceAndReadOnlyBeforeCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
maint := testAPIServer()
|
||||
maint.Config.MaintenanceMode = true
|
||||
maintH := maint.Router()
|
||||
|
||||
maintPOST := httptest.NewRecorder()
|
||||
maintH.ServeHTTP(maintPOST, httptest.NewRequest(http.MethodPost, "/api/auth/login", nil))
|
||||
if maintPOST.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("maintenance POST without CSRF status = %d, want 503 (not csrf 403); body=%s", maintPOST.Code, maintPOST.Body.String())
|
||||
}
|
||||
assertGateBody(t, maintPOST, "maintenance", true, false)
|
||||
|
||||
maintGET := httptest.NewRecorder()
|
||||
maintH.ServeHTTP(maintGET, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
|
||||
if maintGET.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("maintenance GET status = %d, want 503", maintGET.Code)
|
||||
}
|
||||
assertGateBody(t, maintGET, "maintenance", true, false)
|
||||
|
||||
health := httptest.NewRecorder()
|
||||
maintH.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if health.Code != http.StatusOK {
|
||||
t.Fatalf("healthz under maintenance status = %d", health.Code)
|
||||
}
|
||||
var healthBody map[string]any
|
||||
if err := json.Unmarshal(health.Body.Bytes(), &healthBody); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if healthBody["maintenance"] != true {
|
||||
t.Fatalf("healthz flags = %#v", healthBody)
|
||||
}
|
||||
|
||||
ro := testAPIServer()
|
||||
ro.Config.ReadOnlyMode = true
|
||||
roH := ro.Router()
|
||||
|
||||
roPOST := httptest.NewRecorder()
|
||||
roH.ServeHTTP(roPOST, httptest.NewRequest(http.MethodPost, "/api/auth/login", nil))
|
||||
if roPOST.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("read-only POST without CSRF status = %d, want 503 (not csrf 403); body=%s", roPOST.Code, roPOST.Body.String())
|
||||
}
|
||||
assertGateBody(t, roPOST, "read_only", false, true)
|
||||
|
||||
roGET := httptest.NewRecorder()
|
||||
roH.ServeHTTP(roGET, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
|
||||
if roGET.Code == http.StatusServiceUnavailable {
|
||||
t.Fatalf("read-only GET must pass the gate; got 503 body=%s", roGET.Body.String())
|
||||
}
|
||||
if roGET.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("read-only GET /api/auth/me status = %d, want 401", roGET.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterMetricsMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := testAPIServer().Router()
|
||||
|
||||
// Drive one request so RED counters are non-empty.
|
||||
health := httptest.NewRecorder()
|
||||
h.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if health.Code != http.StatusOK {
|
||||
t.Fatalf("healthz status=%d", health.Code)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("metrics status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
ct := rec.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "text/plain") {
|
||||
t.Fatalf("Content-Type=%q", ct)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"http_requests_total{",
|
||||
`path="/healthz"`,
|
||||
"# TYPE sync_failures_total counter",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q in metrics:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterMetricsHiddenInProductionForRemote(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
s.Config.AppEnv = "production"
|
||||
s.Config.MetricsPublic = false
|
||||
h := s.Router()
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
req.RemoteAddr = "203.0.113.9:9999"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("prod remote metrics status=%d want 404", rec.Code)
|
||||
}
|
||||
|
||||
loop := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
loop.RemoteAddr = "127.0.0.1:4242"
|
||||
recLoop := httptest.NewRecorder()
|
||||
h.ServeHTTP(recLoop, loop)
|
||||
if recLoop.Code != http.StatusOK {
|
||||
t.Fatalf("prod loopback metrics status=%d", recLoop.Code)
|
||||
}
|
||||
|
||||
s.Config.MetricsPublic = true
|
||||
hPub := s.Router()
|
||||
reqPub := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
reqPub.RemoteAddr = "203.0.113.9:9999"
|
||||
recPub := httptest.NewRecorder()
|
||||
hPub.ServeHTTP(recPub, reqPub)
|
||||
if recPub.Code != http.StatusOK {
|
||||
t.Fatalf("METRICS_PUBLIC remote status=%d", recPub.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertGateBody(t *testing.T, rec *httptest.ResponseRecorder, errorCode string, maintenance, readOnly bool) {
|
||||
t.Helper()
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("json: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body["error"] != errorCode {
|
||||
t.Fatalf("error = %#v, want %q", body["error"], errorCode)
|
||||
}
|
||||
if body["maintenance"] != maintenance {
|
||||
t.Fatalf("maintenance = %#v, want %v", body["maintenance"], maintenance)
|
||||
}
|
||||
if body["read_only"] != readOnly {
|
||||
t.Fatalf("read_only = %#v, want %v", body["read_only"], readOnly)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/i18n"
|
||||
)
|
||||
|
||||
// localeResponseWriter carries the resolved UI/API locale for Error()/CodedError.
|
||||
type localeResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
locale string
|
||||
}
|
||||
|
||||
func (w *localeResponseWriter) Locale() string { return w.locale }
|
||||
|
||||
func (w *localeResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
type localeCarrier interface {
|
||||
Locale() string
|
||||
}
|
||||
|
||||
type responseUnwrapper interface {
|
||||
Unwrap() http.ResponseWriter
|
||||
}
|
||||
|
||||
func localeOf(w http.ResponseWriter) string {
|
||||
for w != nil {
|
||||
if lc, ok := w.(localeCarrier); ok {
|
||||
return lc.Locale()
|
||||
}
|
||||
uw, ok := w.(responseUnwrapper)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
w = uw.Unwrap()
|
||||
}
|
||||
return i18n.Default
|
||||
}
|
||||
|
||||
// Locale resolves Accept-Language into a supported UI locale, stores it on the
|
||||
// request context and ResponseWriter, and sets Vary: Accept-Language.
|
||||
func Locale(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lang := i18n.Resolve(r.Header.Get("Accept-Language"))
|
||||
ctx := i18n.WithLocale(r.Context(), lang)
|
||||
if v := w.Header().Get("Vary"); v == "" {
|
||||
w.Header().Set("Vary", "Accept-Language")
|
||||
} else if !containsCSVToken(v, "Accept-Language") {
|
||||
w.Header().Set("Vary", v+", Accept-Language")
|
||||
}
|
||||
next.ServeHTTP(&localeResponseWriter{ResponseWriter: w, locale: lang}, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func containsCSVToken(header, token string) bool {
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
if strings.TrimSpace(part) == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestErrorLocalizesWithAcceptLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.Header.Set("Accept-Language", "nl")
|
||||
Locale(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "niet geautoriseerd" {
|
||||
t.Fatalf("error=%q", body["error"])
|
||||
}
|
||||
if got := rec.Header().Get("Vary"); got != "Accept-Language" {
|
||||
t.Fatalf("Vary=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorKeepsStableMachineCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.Header.Set("Accept-Language", "fr")
|
||||
Locale(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusForbidden, "password_not_set")
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "password_not_set" {
|
||||
t.Fatalf("stable code changed: %q", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodedErrorLocalizesMessageOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
req.Header.Set("Accept-Language", "de")
|
||||
Locale(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
CodedError(w, http.StatusUnauthorized, "invalid_api_key", "invalid api key")
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errObj, ok := body["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("shape=%#v", body)
|
||||
}
|
||||
if errObj["code"] != "invalid_api_key" {
|
||||
t.Fatalf("code=%v", errObj["code"])
|
||||
}
|
||||
if errObj["message"] != "ungültiger API-Schlüssel" {
|
||||
t.Fatalf("message=%v", errObj["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldErrorLocalizesWithAcceptLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/x", nil)
|
||||
req.Header.Set("Accept-Language", "nl")
|
||||
Locale(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
FieldError(w, http.StatusUnauthorized, "unauthorized", "invalid_credentials", map[string]string{
|
||||
"email": "unauthorized",
|
||||
"password": "unauthorized",
|
||||
})
|
||||
})).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Error != "niet geautoriseerd" {
|
||||
t.Fatalf("error=%q", body.Error)
|
||||
}
|
||||
if body.Code != "invalid_credentials" {
|
||||
t.Fatalf("code=%q (must stay stable)", body.Code)
|
||||
}
|
||||
if body.Fields["email"] != "niet geautoriseerd" || body.Fields["password"] != "niet geautoriseerd" {
|
||||
t.Fatalf("fields=%v", body.Fields)
|
||||
}
|
||||
if got := rec.Header().Get("Vary"); got != "Accept-Language" {
|
||||
t.Fatalf("Vary=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Email-keyed login lockout (in-process, per API replica).
|
||||
//
|
||||
// Complements IP RateLimitAuth: rotating IPs still hit the same email budget.
|
||||
// ASSUMPTION (Product 10): a single API instance (or acknowledged per-replica
|
||||
// memory) is acceptable — same posture as HTTP rate limiters in ratelimit.go.
|
||||
// RATE_LIMIT_REPLICAS does not divide this lockout; multi-replica hard caps need edge/WAF.
|
||||
// Captcha is deferred; lockout + IP RPM are the primary login abuse controls.
|
||||
|
||||
const (
|
||||
loginLockoutMaxFails = 5
|
||||
loginLockoutDuration = 15 * time.Minute
|
||||
)
|
||||
|
||||
type loginLockState struct {
|
||||
fails int
|
||||
windowStart time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
// loginAttemptLockout tracks failed password attempts by normalized email.
|
||||
type loginAttemptLockout struct {
|
||||
mu sync.Mutex
|
||||
maxFails int
|
||||
lockFor time.Duration
|
||||
state map[string]*loginLockState
|
||||
}
|
||||
|
||||
func newLoginAttemptLockout(maxFails int, lockFor time.Duration) *loginAttemptLockout {
|
||||
if maxFails < 1 {
|
||||
maxFails = loginLockoutMaxFails
|
||||
}
|
||||
if lockFor <= 0 {
|
||||
lockFor = loginLockoutDuration
|
||||
}
|
||||
return &loginAttemptLockout{
|
||||
maxFails: maxFails,
|
||||
lockFor: lockFor,
|
||||
state: make(map[string]*loginLockState),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLoginEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// locked reports whether email is currently locked and Retry-After seconds.
|
||||
func (l *loginAttemptLockout) locked(email string) (bool, int) {
|
||||
key := normalizeLoginEmail(email)
|
||||
if key == "" || l == nil {
|
||||
return false, 0
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st := l.state[key]
|
||||
if st == nil {
|
||||
return false, 0
|
||||
}
|
||||
if st.lockedUntil.After(now) {
|
||||
sec := int(st.lockedUntil.Sub(now).Seconds()) + 1
|
||||
if sec < 1 {
|
||||
sec = 1
|
||||
}
|
||||
return true, sec
|
||||
}
|
||||
if !st.lockedUntil.IsZero() && !st.lockedUntil.After(now) {
|
||||
// Lock expired — reset failure window.
|
||||
delete(l.state, key)
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// recordFailure increments the failure count for email; locks after maxFails
|
||||
// within the lock window. No-ops for empty email.
|
||||
func (l *loginAttemptLockout) recordFailure(email string) {
|
||||
key := normalizeLoginEmail(email)
|
||||
if key == "" || l == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st := l.state[key]
|
||||
if st == nil {
|
||||
st = &loginLockState{windowStart: now}
|
||||
l.state[key] = st
|
||||
}
|
||||
if st.lockedUntil.After(now) {
|
||||
return
|
||||
}
|
||||
if !st.lockedUntil.IsZero() && !st.lockedUntil.After(now) {
|
||||
st.fails = 0
|
||||
st.windowStart = now
|
||||
st.lockedUntil = time.Time{}
|
||||
}
|
||||
if now.Sub(st.windowStart) > l.lockFor {
|
||||
st.fails = 0
|
||||
st.windowStart = now
|
||||
}
|
||||
st.fails++
|
||||
if st.fails >= l.maxFails {
|
||||
st.lockedUntil = now.Add(l.lockFor)
|
||||
st.fails = 0
|
||||
st.windowStart = now
|
||||
}
|
||||
}
|
||||
|
||||
// clear resets failures and lock for email (successful login).
|
||||
func (l *loginAttemptLockout) clear(email string) {
|
||||
key := normalizeLoginEmail(email)
|
||||
if key == "" || l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.state, key)
|
||||
}
|
||||
|
||||
func (s *Server) loginAttempts() *loginAttemptLockout {
|
||||
if s == nil {
|
||||
return newLoginAttemptLockout(loginLockoutMaxFails, loginLockoutDuration)
|
||||
}
|
||||
s.loginLockoutOnce.Do(func() {
|
||||
s.loginLockout = newLoginAttemptLockout(loginLockoutMaxFails, loginLockoutDuration)
|
||||
})
|
||||
return s.loginLockout
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoginAttemptLockoutLocksAfterMaxFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := newLoginAttemptLockout(3, 100*time.Millisecond)
|
||||
|
||||
email := "Victim@Example.com"
|
||||
for i := 0; i < 2; i++ {
|
||||
l.recordFailure(email)
|
||||
if locked, _ := l.locked(email); locked {
|
||||
t.Fatalf("unexpected lock after %d failures", i+1)
|
||||
}
|
||||
}
|
||||
l.recordFailure(email)
|
||||
locked, retry := l.locked("victim@example.com")
|
||||
if !locked {
|
||||
t.Fatal("expected lock after max failures")
|
||||
}
|
||||
if retry < 1 {
|
||||
t.Fatalf("retry-after want >=1 got %d", retry)
|
||||
}
|
||||
// Case-normalized key: different casing still locked.
|
||||
if locked2, _ := l.locked("VICTIM@EXAMPLE.COM"); !locked2 {
|
||||
t.Fatal("expected lock for normalized email")
|
||||
}
|
||||
// Other emails are independent.
|
||||
if locked3, _ := l.locked("other@example.com"); locked3 {
|
||||
t.Fatal("other email should not be locked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAttemptLockoutClearOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := newLoginAttemptLockout(2, time.Minute)
|
||||
email := "user@example.com"
|
||||
l.recordFailure(email)
|
||||
l.clear(email)
|
||||
if locked, _ := l.locked(email); locked {
|
||||
t.Fatal("clear should remove lock state")
|
||||
}
|
||||
l.recordFailure(email)
|
||||
if locked, _ := l.locked(email); locked {
|
||||
t.Fatal("one failure after clear should not lock (max=2)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAttemptLockoutExpires(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := newLoginAttemptLockout(1, 30*time.Millisecond)
|
||||
email := "temp@example.com"
|
||||
l.recordFailure(email)
|
||||
if locked, _ := l.locked(email); !locked {
|
||||
t.Fatal("expected immediate lock at maxFails=1")
|
||||
}
|
||||
time.Sleep(45 * time.Millisecond)
|
||||
if locked, _ := l.locked(email); locked {
|
||||
t.Fatal("expected lock to expire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAttemptLockoutIgnoresEmptyEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := newLoginAttemptLockout(1, time.Minute)
|
||||
l.recordFailure(" ")
|
||||
if locked, _ := l.locked(" "); locked {
|
||||
t.Fatal("empty email must not lock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerLoginAttemptsLazyInit(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
a := s.loginAttempts()
|
||||
b := s.loginAttempts()
|
||||
if a == nil || a != b {
|
||||
t.Fatal("loginAttempts should lazy-init once")
|
||||
}
|
||||
a.recordFailure("a@example.com")
|
||||
a.recordFailure("a@example.com")
|
||||
a.recordFailure("a@example.com")
|
||||
a.recordFailure("a@example.com")
|
||||
a.recordFailure("a@example.com")
|
||||
if locked, _ := b.locked("a@example.com"); !locked {
|
||||
t.Fatal("shared lockout state expected on Server")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/marketing"
|
||||
)
|
||||
|
||||
func (s *Server) marketingService() *marketing.Service {
|
||||
return &marketing.Service{Pool: s.Pool, Feeds: s.Feeds}
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMarketingCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
year := time.Now().UTC().Year()
|
||||
if y := r.URL.Query().Get("year"); y != "" {
|
||||
parsed, err := strconv.Atoi(y)
|
||||
if err != nil || parsed < 2000 || parsed > 2100 {
|
||||
Error(w, http.StatusBadRequest, "invalid year")
|
||||
return
|
||||
}
|
||||
year = parsed
|
||||
}
|
||||
prepared, err := s.marketingService().ListPreparedCampaigns(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"year": year,
|
||||
"presets": marketing.ListPresets(year),
|
||||
"prepared": prepared,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handlePrepareMarketingCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
PresetID string `json:"preset_id"`
|
||||
Year int `json:"year"`
|
||||
Format string `json:"format"`
|
||||
ForceNew bool `json:"force_new"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
campaign, err := s.marketingService().PrepareCampaign(r.Context(), cid, marketing.PrepareInput{
|
||||
PresetID: marketing.PresetID(body.PresetID),
|
||||
Year: body.Year,
|
||||
Format: body.Format,
|
||||
ForceNew: body.ForceNew,
|
||||
})
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not prepare campaign", err, marketing.ClientError)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if campaign.Created {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
JSON(w, status, campaign)
|
||||
}
|
||||
|
||||
func (s *Server) handleListProductQuality(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
var minScore *int
|
||||
if raw := r.URL.Query().Get("min_score"); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid min_score")
|
||||
return
|
||||
}
|
||||
minScore = &n
|
||||
}
|
||||
|
||||
f := catalog.ListFilter{
|
||||
Query: QuerySearch(r),
|
||||
Status: r.URL.Query().Get("status"),
|
||||
Category: r.URL.Query().Get("category"),
|
||||
FeedID: firstNonEmpty(r.URL.Query().Get("feed_id"), r.URL.Query().Get("feedId")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
if f.Status == "" {
|
||||
f.Status = "completed"
|
||||
}
|
||||
|
||||
items, total, err := s.Catalog.ListProcessedProductsDetailed(r.Context(), cid, f)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
q := marketing.ScoreFromProductMap(item)
|
||||
if minScore != nil && q.Score < *minScore {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": item["id"],
|
||||
"product_id": item["product_id"],
|
||||
"name": firstNonEmpty(asMapString(item["processed_name"]), asMapString(item["name"])),
|
||||
"quality_score": q.Score,
|
||||
"quality_grade": q.Grade,
|
||||
"quality_checks": q.Checks,
|
||||
})
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"products": out,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func asMapString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func attachProductQuality(items []map[string]any) {
|
||||
for i := range items {
|
||||
q := marketing.ScoreFromProductMap(items[i])
|
||||
items[i]["quality_score"] = q.Score
|
||||
items[i]["quality_grade"] = q.Grade
|
||||
items[i]["quality_checks"] = q.Checks
|
||||
// Drop heavy fields used only for scoring when present on list payloads.
|
||||
delete(items[i], "mapped_data")
|
||||
delete(items[i], "attributes")
|
||||
delete(items[i], "processed_attributes")
|
||||
delete(items[i], "description")
|
||||
delete(items[i], "processed_description")
|
||||
delete(items[i], "meta_title")
|
||||
delete(items[i], "meta_description")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRouterV1MCPInstallGone locks MCP removal: GET /api/v1/mcp/install.json
|
||||
// must be absent from the public router (chi 404), not a live install snippet.
|
||||
func TestRouterV1MCPInstallGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
h := s.Router()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/mcp/install.json", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("mcp install status=%d want 404 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// OpenAPI must remain public after MCP removal.
|
||||
openAPI := httptest.NewRecorder()
|
||||
h.ServeHTTP(openAPI, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil))
|
||||
if openAPI.Code != http.StatusOK {
|
||||
t.Fatalf("openapi status=%d want 200", openAPI.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
ctxUserID ctxKey = "user_id"
|
||||
ctxCompanyID ctxKey = "company_id"
|
||||
ctxRole ctxKey = "role"
|
||||
ctxStaffAccess ctxKey = "staff_access"
|
||||
)
|
||||
|
||||
func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||
v, ok := ctx.Value(ctxUserID).(uuid.UUID)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func CompanyIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||
v, ok := ctx.Value(ctxCompanyID).(uuid.UUID)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func RoleFromContext(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(ctxRole).(string)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// CompanyAdminAllowed reports whether the caller may perform company-admin
|
||||
// mutations. Session role "admin" and API-key auth role "api" (admin-owned keys
|
||||
// only — see apiKeyContextRole) are allowed; members are not.
|
||||
func CompanyAdminAllowed(ctx context.Context) bool {
|
||||
role, _ := RoleFromContext(ctx)
|
||||
return role == "admin" || role == "api"
|
||||
}
|
||||
|
||||
// apiKeyContextRole maps the key owner's membership role onto the request role.
|
||||
// Admin-owned keys keep legacy "api" privileges (CompanyAdminAllowed). Non-admin
|
||||
// owners keep membership role so product reset / admin-gated deletes stay closed.
|
||||
// Full scopes + expiry are deferred: api_keys has no scopes/expires_at columns yet;
|
||||
// dashboard creation remains admin-only (allowCompanyAdminOrPlatform).
|
||||
func apiKeyContextRole(membershipRole string) string {
|
||||
if auth.NormalizeMembershipRole(membershipRole) == "admin" {
|
||||
return "api"
|
||||
}
|
||||
return auth.NormalizeMembershipRole(membershipRole)
|
||||
}
|
||||
|
||||
func requireCompanyAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if CompanyAdminAllowed(r.Context()) {
|
||||
return true
|
||||
}
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return false
|
||||
}
|
||||
|
||||
// allowCompanyAdminOrPlatform allows company admins, API keys, or platform admins.
|
||||
// Platform admins can manage team after migration when all memberships are still "member".
|
||||
// Non-prod: while a privileged demo/platform actor is impersonating, retain company-admin powers
|
||||
// so local user-switch can still create API keys and manage the tenant.
|
||||
func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Request) bool {
|
||||
if CompanyAdminAllowed(r.Context()) {
|
||||
return true
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return false
|
||||
}
|
||||
isAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "authorization check failed")
|
||||
return false
|
||||
}
|
||||
if isAdmin {
|
||||
return true
|
||||
}
|
||||
if s.devImpersonatorRetainsCompanyAdmin(r) {
|
||||
return true
|
||||
}
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return false
|
||||
}
|
||||
|
||||
// devImpersonatorRetainsCompanyAdmin is true in non-production when the session is
|
||||
// impersonating and the stored actor is still a privileged demo/platform admin.
|
||||
func (s *Server) devImpersonatorRetainsCompanyAdmin(r *http.Request) bool {
|
||||
if s.Config.IsProduction() || s.Sessions == nil {
|
||||
return false
|
||||
}
|
||||
impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey))
|
||||
if impStr == "" {
|
||||
return false
|
||||
}
|
||||
impID, err := uuid.Parse(impStr)
|
||||
if err != nil || impID == uuid.Nil {
|
||||
return false
|
||||
}
|
||||
access, err := s.checkStaffAccess(r.Context(), impID)
|
||||
if err == nil && access.FullAdmin {
|
||||
return true
|
||||
}
|
||||
if s.Auth == nil {
|
||||
return false
|
||||
}
|
||||
impUser, err := s.Auth.GetUser(r.Context(), impID)
|
||||
return err == nil && isLocalDemoEmail(impUser.Email)
|
||||
}
|
||||
|
||||
func (s *Server) RequireSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uidStr := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey)
|
||||
if uidStr == "" {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
uid, err := uuid.Parse(uidStr)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
sessionVersion := s.Sessions.GetInt(r.Context(), auth.SessionVersionKey)
|
||||
if active, checked, err := s.sessionUserIsActive(r.Context(), uid, sessionVersion); err != nil || (checked && !active) {
|
||||
_ = s.Sessions.Destroy(r.Context())
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxUserID, uid)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// sessionUserIsActive reports whether the session user may continue.
|
||||
// checked=false means the active flag could not be verified (unit tests without a DB pool).
|
||||
// sessionVersion must match users.session_version (bumped on password reset).
|
||||
func (s *Server) sessionUserIsActive(ctx context.Context, userID uuid.UUID, sessionVersion int) (active bool, checked bool, err error) {
|
||||
if s != nil && s.testUserSessionState != nil {
|
||||
st, err := s.testUserSessionState(ctx, userID)
|
||||
if err != nil {
|
||||
return false, true, err
|
||||
}
|
||||
if !st.Active || st.Version != sessionVersion {
|
||||
return false, true, nil
|
||||
}
|
||||
return true, true, nil
|
||||
}
|
||||
if s != nil && s.testUserActive != nil {
|
||||
ok, err := s.testUserActive(ctx, userID)
|
||||
return ok, true, err
|
||||
}
|
||||
if s == nil || s.Auth == nil || s.Auth.Pool == nil {
|
||||
return true, false, nil
|
||||
}
|
||||
st, err := s.Auth.UserSessionState(ctx, userID)
|
||||
if err != nil {
|
||||
return false, true, err
|
||||
}
|
||||
if !st.Active || st.Version != sessionVersion {
|
||||
return false, true, nil
|
||||
}
|
||||
return true, true, nil
|
||||
}
|
||||
|
||||
func (s *Server) RequireCompany(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
cidStr := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)
|
||||
if cidStr == "" {
|
||||
Error(w, http.StatusBadRequest, "company not selected")
|
||||
return
|
||||
}
|
||||
cid, err := uuid.Parse(cidStr)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid company")
|
||||
return
|
||||
}
|
||||
m, err := s.Auth.EnsureMembership(r.Context(), uid, cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, m.Role)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) CSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Public API-key and token export routes do not use cookie CSRF.
|
||||
// Match path segments only (/api/v1, /api/v1/...) — not prefixes like /api/v10.
|
||||
if csrfExemptPath(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie(s.Config.CSRFCookieName)
|
||||
token := ""
|
||||
if err == nil {
|
||||
token = cookie.Value
|
||||
}
|
||||
if token == "" {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "csrf token unavailable")
|
||||
return
|
||||
}
|
||||
token = hex.EncodeToString(b)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.Config.CSRFCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: false, // readable by SPA for X-CSRF-Token double-submit
|
||||
Secure: s.Config.CookieSecure(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
})
|
||||
}
|
||||
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
header := r.Header.Get("X-CSRF-Token")
|
||||
if header == "" || subtle.ConstantTimeCompare([]byte(header), []byte(token)) != 1 {
|
||||
Error(w, http.StatusForbidden, "csrf token mismatch")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// csrfExemptPath is true for public API-key / token / webhook surfaces that
|
||||
// authenticate without cookie CSRF (Bearer/HMAC/signature).
|
||||
func csrfExemptPath(path string) bool {
|
||||
switch {
|
||||
case path == "/api/v1", strings.HasPrefix(path, "/api/v1/"):
|
||||
return true
|
||||
case path == "/api/public", strings.HasPrefix(path, "/api/public/"):
|
||||
return true
|
||||
case path == "/api/webhooks", strings.HasPrefix(path, "/api/webhooks/"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// extractAPIKey reads the raw key from Authorization Bearer or X-API-Key.
|
||||
// Preference matches legacy Descrybe: Bearer first, then X-API-Key / X-Api-Key
|
||||
// (Go canonicalizes header names; both spellings resolve).
|
||||
func extractAPIKey(r *http.Request) string {
|
||||
authz := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if authz != "" {
|
||||
const bearer = "Bearer "
|
||||
if len(authz) > len(bearer) && strings.EqualFold(authz[:len(bearer)], bearer) {
|
||||
if key := strings.TrimSpace(authz[len(bearer):]); key != "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
}
|
||||
if k := strings.TrimSpace(r.Header.Get("X-API-Key")); k != "" {
|
||||
return k
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RequireAPIKey authenticates via Bearer or X-API-Key and binds company/user context.
|
||||
func (s *Server) RequireAPIKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw := extractAPIKey(r)
|
||||
if raw == "" {
|
||||
CodedError(w, http.StatusUnauthorized, "unauthorized", "Unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := s.Auth.AuthenticateAPIKey(r.Context(), raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, auth.ErrInvalidAPIKey) {
|
||||
CodedError(w, http.StatusUnauthorized, "unauthorized", "Unauthorized")
|
||||
return
|
||||
}
|
||||
CodedError(w, http.StatusInternalServerError, "auth_failed", "Authentication failed")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), ctxUserID, id.UserID)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, id.CompanyID)
|
||||
ctx = context.WithValue(ctx, ctxRole, apiKeyContextRole(id.MembershipRole))
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func LoadSession(sm *scs.SessionManager) func(http.Handler) http.Handler {
|
||||
return sm.LoadAndSave
|
||||
}
|
||||
|
||||
// MaintenanceGate enforces MAINTENANCE_MODE / READ_ONLY_MODE.
|
||||
// /healthz and /readyz always pass so cutover rehearsal probes keep working.
|
||||
func (s *Server) MaintenanceGate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if s.Config.MaintenanceMode {
|
||||
JSON(w, http.StatusServiceUnavailable, map[string]any{
|
||||
"error": "maintenance", "maintenance": true, "read_only": s.Config.ReadOnlyMode,
|
||||
})
|
||||
return
|
||||
}
|
||||
if s.Config.ReadOnlyMode {
|
||||
switch r.Method {
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
JSON(w, http.StatusServiceUnavailable, map[string]any{
|
||||
"error": "read_only", "maintenance": false, "read_only": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RequirePlatformAdmin allows full platform staff (admin/developer or legacy
|
||||
// is_platform_admin with empty staff_role). support_staff is excluded.
|
||||
func (s *Server) RequirePlatformAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
access, err := s.checkStaffAccess(r.Context(), uid)
|
||||
if err != nil || !access.FullAdmin {
|
||||
Error(w, http.StatusForbidden, "platform admin required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withStaffAccess(r.Context(), access)))
|
||||
})
|
||||
}
|
||||
|
||||
// RequireSupportDesk allows full platform admin OR support_staff.
|
||||
// Plan/billing/settings mutations must stay on RequirePlatformAdmin.
|
||||
func (s *Server) RequireSupportDesk(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
access, err := s.checkStaffAccess(r.Context(), uid)
|
||||
if err != nil || !access.SupportDesk {
|
||||
Error(w, http.StatusForbidden, "support desk access required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withStaffAccess(r.Context(), access)))
|
||||
})
|
||||
}
|
||||
|
||||
func withStaffAccess(ctx context.Context, access auth.StaffAccess) context.Context {
|
||||
return context.WithValue(ctx, ctxStaffAccess, access)
|
||||
}
|
||||
|
||||
// StaffAccessFromContext returns capability flags set by RequirePlatformAdmin / RequireSupportDesk.
|
||||
func StaffAccessFromContext(ctx context.Context) (auth.StaffAccess, bool) {
|
||||
v, ok := ctx.Value(ctxStaffAccess).(auth.StaffAccess)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// checkPlatformAdmin prefers an optional test hook, otherwise Auth.IsPlatformAdmin.
|
||||
func (s *Server) checkPlatformAdmin(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
if s != nil && s.testPlatformAdmin != nil {
|
||||
return s.testPlatformAdmin(ctx, userID)
|
||||
}
|
||||
if s == nil || s.Auth == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.Auth.IsPlatformAdmin(ctx, userID)
|
||||
}
|
||||
|
||||
// checkStaffAccess prefers test hooks, otherwise Auth.GetStaffAccess.
|
||||
func (s *Server) checkStaffAccess(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) {
|
||||
if s != nil && s.testStaffAccess != nil {
|
||||
return s.testStaffAccess(ctx, userID)
|
||||
}
|
||||
if s != nil && s.testPlatformAdmin != nil {
|
||||
ok, err := s.testPlatformAdmin(ctx, userID)
|
||||
if err != nil {
|
||||
return auth.StaffAccess{}, err
|
||||
}
|
||||
return auth.ResolveStaffAccess(ok, ""), nil
|
||||
}
|
||||
if s == nil || s.Auth == nil {
|
||||
return auth.StaffAccess{}, nil
|
||||
}
|
||||
return s.Auth.GetStaffAccess(ctx, userID)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// statusRecorder captures the response status for structured request logs.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// RequestLogger emits one structured slog line per request with request_id.
|
||||
// Pair with chi middleware.RequestID (already mounted in Router).
|
||||
func RequestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rec, r)
|
||||
slog.Info("http_request",
|
||||
"request_id", chimw.GetReqID(r.Context()),
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"bytes", rec.bytes,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"remote_ip", r.RemoteAddr,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageLimit = 50
|
||||
maxPageLimit = 200
|
||||
maxTreePageLimit = 2000
|
||||
)
|
||||
|
||||
// QuerySearch returns the list/search text from query params.
|
||||
// Accepts both `q` (canonical) and `search` (UI/legacy alias).
|
||||
func QuerySearch(r *http.Request) string {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q != "" {
|
||||
return q
|
||||
}
|
||||
return strings.TrimSpace(r.URL.Query().Get("search"))
|
||||
}
|
||||
|
||||
// QueryTruthy reports whether a query param is an explicit truthy flag
|
||||
// (1/true/yes/on). Empty or unrecognized values are false.
|
||||
func QueryTruthy(r *http.Request, key string) bool {
|
||||
v := strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key)))
|
||||
switch v {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// QueryDetailed is true when the client opts into full product list fields
|
||||
// (JSONB attributes, descriptions, quality scoring inputs) via detailed=1.
|
||||
func QueryDetailed(r *http.Request) bool {
|
||||
return QueryTruthy(r, "detailed")
|
||||
}
|
||||
|
||||
// ParseLimitOffset reads limit/offset query params with safe defaults and caps.
|
||||
// Oversized limits are clamped to maxPageLimit.
|
||||
func ParseLimitOffset(r *http.Request) (limit, offset int) {
|
||||
return ParseLimitOffsetMax(r, maxPageLimit)
|
||||
}
|
||||
|
||||
// ParseLimitOffsetMax allows a higher per-endpoint cap and clamps to max
|
||||
// (used for category tree loads).
|
||||
func ParseLimitOffsetMax(r *http.Request, max int) (limit, offset int) {
|
||||
if max <= 0 {
|
||||
max = maxPageLimit
|
||||
}
|
||||
limit, _ = strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ = strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if limit <= 0 {
|
||||
limit = defaultPageLimit
|
||||
}
|
||||
if limit > max {
|
||||
limit = max
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
return limit, offset
|
||||
}
|
||||
|
||||
// ParsePageLimitOffset supports legacy page/limit and v2 limit/offset.
|
||||
// When page is set, offset = (page-1)*limit with legacy defaults (limit=25, max 100).
|
||||
// When only offset/limit are set (no page), uses ParseLimitOffset defaults (limit=50, max 200).
|
||||
func ParsePageLimitOffset(r *http.Request) (page, limit, offset int) {
|
||||
pageRaw := strings.TrimSpace(r.URL.Query().Get("page"))
|
||||
if pageRaw == "" {
|
||||
limit, offset = ParseLimitOffset(r)
|
||||
page = 1
|
||||
if limit > 0 {
|
||||
page = offset/limit + 1
|
||||
}
|
||||
return page, limit, offset
|
||||
}
|
||||
page, _ = strconv.Atoi(pageRaw)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
limit, _ = strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
offset = (page - 1) * limit
|
||||
return page, limit, offset
|
||||
}
|
||||
|
||||
// pageSlice returns a bounded page of items and the original total length.
|
||||
func pageSlice[T any](items []T, limit, offset int) (page []T, total int) {
|
||||
total = len(items)
|
||||
if offset >= total {
|
||||
return []T{}, total
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return items[offset:end], total
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuerySearchPrefersQ(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/products?q=alpha&search=beta", nil)
|
||||
if got := QuerySearch(r); got != "alpha" {
|
||||
t.Fatalf("got %q want alpha", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySearchFallsBackToSearch(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/products?search=%20widget%20", nil)
|
||||
if got := QuerySearch(r); got != "widget" {
|
||||
t.Fatalf("got %q want widget", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerySearchEmpty(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/products", nil)
|
||||
if got := QuerySearch(r); got != "" {
|
||||
t.Fatalf("got %q want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTruthy(t *testing.T) {
|
||||
cases := []struct {
|
||||
url string
|
||||
key string
|
||||
want bool
|
||||
}{
|
||||
{"/api/products", "detailed", false},
|
||||
{"/api/products?detailed=", "detailed", false},
|
||||
{"/api/products?detailed=0", "detailed", false},
|
||||
{"/api/products?detailed=false", "detailed", false},
|
||||
{"/api/products?detailed=1", "detailed", true},
|
||||
{"/api/products?detailed=true", "detailed", true},
|
||||
{"/api/products?detailed=YES", "detailed", true},
|
||||
{"/api/products?detailed=on", "detailed", true},
|
||||
{"/api/products?detailed=%201%20", "detailed", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
r := httptest.NewRequest(http.MethodGet, tc.url, nil)
|
||||
if got := QueryTruthy(r, tc.key); got != tc.want {
|
||||
t.Fatalf("%s: got %v want %v", tc.url, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDetailed(t *testing.T) {
|
||||
if QueryDetailed(httptest.NewRequest(http.MethodGet, "/api/products?limit=200", nil)) {
|
||||
t.Fatal("default list must be lean (detailed=false)")
|
||||
}
|
||||
if !QueryDetailed(httptest.NewRequest(http.MethodGet, "/api/products?detailed=1&limit=200", nil)) {
|
||||
t.Fatal("detailed=1 must opt into heavy fields")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
||||
)
|
||||
|
||||
const (
|
||||
forgotPasswordIPPerMin = 10
|
||||
forgotPasswordEmailPerHour = 3
|
||||
)
|
||||
|
||||
func (s *Server) ensureForgotPasswordLimiters() {
|
||||
s.forgotPasswordOnce.Do(func() {
|
||||
s.forgotPasswordIPRL = newSlidingWindowLimiter(forgotPasswordIPPerMin, time.Minute)
|
||||
s.forgotPasswordEmailRL = newSlidingWindowLimiter(forgotPasswordEmailPerHour, time.Hour)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Mail == nil || s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "mailer unavailable")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(body.Email))
|
||||
if email == "" {
|
||||
Error(w, http.StatusBadRequest, "email is required")
|
||||
return
|
||||
}
|
||||
|
||||
s.ensureForgotPasswordLimiters()
|
||||
ipKey := "forgot-password-ip:" + strings.TrimSpace(r.RemoteAddr)
|
||||
if ipKey == "forgot-password-ip:" {
|
||||
ipKey = "forgot-password-ip:unknown"
|
||||
}
|
||||
emailKey := "forgot-password-email:" + email
|
||||
if !s.forgotPasswordIPRL.allow(ipKey) || !s.forgotPasswordEmailRL.allow(emailKey) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
|
||||
// Opaque success for unknown / inactive / synthetic / send failures (anti-enumeration).
|
||||
issue, err := s.Auth.IssuePasswordReset(r.Context(), email, 0)
|
||||
if err == nil {
|
||||
msg := mail.ForgotPasswordMessage(s.Config.WebOrigin, issue.Email, issue.Token)
|
||||
if sendErr := s.Mail.Send(msg); sendErr != nil {
|
||||
log.Printf("forgot-password send failed")
|
||||
}
|
||||
} else if !errors.Is(err, auth.ErrUserNotFound) &&
|
||||
!errors.Is(err, auth.ErrSyntheticEmail) &&
|
||||
!errors.Is(err, auth.ErrEmailRequired) {
|
||||
log.Printf("forgot-password issue failed")
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.Auth.ResetPasswordWithToken(r.Context(), body.Token, body.Password); err != nil {
|
||||
if errors.Is(err, auth.ErrTokenInvalid) {
|
||||
Error(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not reset password", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
||||
)
|
||||
|
||||
func TestHandleForgotPasswordMailerRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Auth: &auth.Service{}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":"a@example.com"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleForgotPassword(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleForgotPasswordRequiresEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
Mail: &recordingMailer{enabled: true},
|
||||
Auth: &auth.Service{},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":" "}`))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleForgotPassword(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d want 400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleForgotPasswordIPRateLimited(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
Mail: &recordingMailer{enabled: true},
|
||||
Auth: &auth.Service{},
|
||||
}
|
||||
s.ensureForgotPasswordLimiters()
|
||||
s.forgotPasswordIPRL = newSlidingWindowLimiter(1, time.Minute)
|
||||
s.forgotPasswordEmailRL = newSlidingWindowLimiter(10, time.Hour)
|
||||
key := "forgot-password-ip:203.0.113.50:1"
|
||||
if !s.forgotPasswordIPRL.allow(key) {
|
||||
t.Fatal("setup: expected first allow")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":"user@example.com"}`))
|
||||
req.RemoteAddr = "203.0.113.50:1"
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleForgotPassword(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status=%d want 429 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("expected Retry-After")
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "@") {
|
||||
t.Fatalf("rate-limit body must not include email: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleForgotPasswordEmailRateLimited(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
Mail: &recordingMailer{enabled: true},
|
||||
Auth: &auth.Service{},
|
||||
}
|
||||
s.ensureForgotPasswordLimiters()
|
||||
s.forgotPasswordIPRL = newSlidingWindowLimiter(10, time.Minute)
|
||||
s.forgotPasswordEmailRL = newSlidingWindowLimiter(1, time.Hour)
|
||||
emailKey := "forgot-password-email:user@example.com"
|
||||
if !s.forgotPasswordEmailRL.allow(emailKey) {
|
||||
t.Fatal("setup: expected first allow")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password", bytes.NewBufferString(`{"email":"User@Example.com"}`))
|
||||
req.RemoteAddr = "198.51.100.10:9"
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleForgotPassword(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status=%d want 429 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("expected Retry-After")
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "@") {
|
||||
t.Fatalf("rate-limit body must not include email: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResetPasswordAuthRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/reset-password", bytes.NewBufferString(`{"token":"x","password":"password12"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleResetPassword(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleForgotPasswordSkipsSyntheticEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
mailer := &recordingMailer{enabled: true}
|
||||
// No Pool: IssuePasswordReset must refuse @legacy.local before any DB access.
|
||||
s := &Server{
|
||||
Mail: mailer,
|
||||
Auth: &auth.Service{},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
||||
bytes.NewBufferString(`{"email":" Synth_User@Legacy.Local "}`))
|
||||
req.RemoteAddr = "203.0.113.83:1"
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleForgotPassword(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d want 200 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var opaque map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &opaque); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
if opaque["status"] != "ok" {
|
||||
t.Fatalf("opaque=%v", opaque)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "legacy.local") || strings.Contains(rec.Body.String(), "synth") {
|
||||
t.Fatalf("response must not leak synthetic email: %s", rec.Body.String())
|
||||
}
|
||||
if len(mailer.sent) != 0 {
|
||||
t.Fatalf("synthetic emails must not receive mail, got %d", len(mailer.sent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgotPasswordMessageLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg := mail.ForgotPasswordMessage("http://localhost:5174/", "a@example.com", "tok123")
|
||||
if msg.To != "a@example.com" {
|
||||
t.Fatalf("to=%q", msg.To)
|
||||
}
|
||||
if !strings.Contains(msg.Text, "/reset-password#token=tok123") {
|
||||
t.Fatalf("text missing reset link: %s", msg.Text)
|
||||
}
|
||||
if strings.Contains(msg.Text, "/accept-invite") {
|
||||
t.Fatal("forgot-password mail must not use accept-invite")
|
||||
}
|
||||
if msg.Subject != "Reset your Descrybe password" {
|
||||
t.Fatalf("subject=%q", msg.Subject)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestForgotPasswordResetIntegration(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := t.Context()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
|
||||
var tableReady bool
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'password_reset_tokens'
|
||||
)`).Scan(&tableReady); err != nil {
|
||||
t.Fatalf("schema probe: %v", err)
|
||||
}
|
||||
if !tableReady {
|
||||
t.Skip("password_reset_tokens missing — run goose up for 041_password_reset_tokens")
|
||||
}
|
||||
|
||||
userID := uuid.New()
|
||||
prefix := userID.String()[:8]
|
||||
email := fmt.Sprintf("forgot-reset-%s@example.test", prefix)
|
||||
oldPassword := "OldPassword123!"
|
||||
newPassword := "NewPassword456!"
|
||||
hash, err := auth.HashPassword(oldPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active)
|
||||
VALUES ($1, $2, $3, $4, false, false, true)`,
|
||||
userID, email, "Forgot Reset", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := t.Context()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM password_reset_tokens WHERE user_id = $1`, userID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
mailer := &recordingMailer{enabled: false}
|
||||
authSvc := &auth.Service{Pool: pg}
|
||||
s := &Server{
|
||||
Config: config.Config{WebOrigin: "http://localhost:5174"},
|
||||
Mail: mailer,
|
||||
Auth: authSvc,
|
||||
}
|
||||
|
||||
// Unknown email — opaque 200, no mail.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
||||
bytes.NewBufferString(`{"email":"missing-`+prefix+`@example.test"}`))
|
||||
req.RemoteAddr = "203.0.113.80:1"
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleForgotPassword(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unknown email status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(mailer.sent) != 0 {
|
||||
t.Fatalf("expected no mail for unknown email, got %d", len(mailer.sent))
|
||||
}
|
||||
|
||||
// Known email — opaque 200 + mail (noop mailer still records Send).
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
||||
bytes.NewBufferString(fmt.Sprintf(`{"email":%q}`, email)))
|
||||
req.RemoteAddr = "203.0.113.81:1"
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleForgotPassword(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("known email status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var opaque map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &opaque); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
if opaque["status"] != "ok" {
|
||||
t.Fatalf("opaque=%v", opaque)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), email) || strings.Contains(rec.Body.String(), "token") {
|
||||
t.Fatalf("response must not leak email/token: %s", rec.Body.String())
|
||||
}
|
||||
if len(mailer.sent) != 1 {
|
||||
t.Fatalf("expected 1 mail, got %d", len(mailer.sent))
|
||||
}
|
||||
token := extractResetTokenFromMail(mailer.sent[0].Text)
|
||||
if token == "" {
|
||||
t.Fatalf("could not extract token from mail text: %s", mailer.sent[0].Text)
|
||||
}
|
||||
|
||||
var storedHash string
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT token_hash FROM password_reset_tokens
|
||||
WHERE user_id = $1 AND consumed_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`, userID).Scan(&storedHash); err != nil {
|
||||
t.Fatalf("load token_hash: %v", err)
|
||||
}
|
||||
if storedHash == token {
|
||||
t.Fatal("DB must store hash only, not plaintext token")
|
||||
}
|
||||
if storedHash != auth.HashInviteToken(token) {
|
||||
t.Fatalf("token_hash=%q want sha256 of raw token", storedHash)
|
||||
}
|
||||
if len(storedHash) != 64 {
|
||||
t.Fatalf("token_hash len=%d want 64", len(storedHash))
|
||||
}
|
||||
|
||||
// Reset succeeds.
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
|
||||
bytes.NewBufferString(fmt.Sprintf(`{"token":%q,"password":%q}`, token, newPassword)))
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleResetPassword(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("reset status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var sessionVersion int
|
||||
err = pg.QueryRow(ctx, `SELECT session_version FROM users WHERE id = $1`, userID).Scan(&sessionVersion)
|
||||
if err != nil {
|
||||
t.Logf("session_version after reset unavailable (apply 042_user_session_version): %v", err)
|
||||
} else if sessionVersion != 1 {
|
||||
t.Fatalf("session_version=%d want 1 after password reset", sessionVersion)
|
||||
}
|
||||
|
||||
// Token reuse fails.
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/auth/reset-password",
|
||||
bytes.NewBufferString(fmt.Sprintf(`{"token":%q,"password":%q}`, token, "AnotherPass789!")))
|
||||
rec = httptest.NewRecorder()
|
||||
s.handleResetPassword(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("reuse status=%d want 400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
login, err := authSvc.Login(ctx, email, newPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("login with new password: %v", err)
|
||||
}
|
||||
if login.User.ID != userID {
|
||||
t.Fatalf("login user=%s want %s", login.User.ID, userID)
|
||||
}
|
||||
if _, err := authSvc.Login(ctx, email, oldPassword); err == nil {
|
||||
t.Fatal("expected old password to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgotPasswordSkipsSyntheticEmail(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := t.Context()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
|
||||
var tableReady bool
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'password_reset_tokens'
|
||||
)`).Scan(&tableReady); err != nil || !tableReady {
|
||||
t.Skip("password_reset_tokens missing — run goose up for 041_password_reset_tokens")
|
||||
}
|
||||
|
||||
userID := uuid.New()
|
||||
email := fmt.Sprintf("synth-%s@legacy.local", userID.String()[:8])
|
||||
hash, err := auth.HashPassword("Password123!")
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active)
|
||||
VALUES ($1, $2, $3, $4, false, false, true)`,
|
||||
userID, email, "Synthetic", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := t.Context()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM password_reset_tokens WHERE user_id = $1`, userID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
mailer := &recordingMailer{enabled: true}
|
||||
authSvc := &auth.Service{Pool: pg}
|
||||
s := &Server{
|
||||
Config: config.Config{WebOrigin: "http://localhost:5174"},
|
||||
Mail: mailer,
|
||||
Auth: authSvc,
|
||||
}
|
||||
// Mixed case / whitespace must still be refused (anti-enumeration opaque 200).
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/forgot-password",
|
||||
bytes.NewBufferString(fmt.Sprintf(`{"email":%q}`, " "+strings.ToUpper(email)+" ")))
|
||||
req.RemoteAddr = "203.0.113.82:1"
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleForgotPassword(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var opaque map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &opaque); err != nil {
|
||||
t.Fatalf("json: %v", err)
|
||||
}
|
||||
if opaque["status"] != "ok" {
|
||||
t.Fatalf("opaque=%v", opaque)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "legacy.local") || strings.Contains(rec.Body.String(), email) {
|
||||
t.Fatalf("response must not leak synthetic email: %s", rec.Body.String())
|
||||
}
|
||||
if len(mailer.sent) != 0 {
|
||||
t.Fatalf("synthetic emails must not receive mail, got %d", len(mailer.sent))
|
||||
}
|
||||
var tokenCount int
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT count(*) FROM password_reset_tokens WHERE user_id = $1`, userID).Scan(&tokenCount); err != nil {
|
||||
t.Fatalf("token count: %v", err)
|
||||
}
|
||||
if tokenCount != 0 {
|
||||
t.Fatalf("expected 0 reset tokens for synthetic user, got %d", tokenCount)
|
||||
}
|
||||
_, err = authSvc.IssuePasswordReset(ctx, email, 0)
|
||||
if !errors.Is(err, auth.ErrSyntheticEmail) {
|
||||
t.Fatalf("IssuePasswordReset err=%v want ErrSyntheticEmail", err)
|
||||
}
|
||||
}
|
||||
|
||||
func extractResetTokenFromMail(text string) string {
|
||||
const marker = "/reset-password#token="
|
||||
i := strings.Index(text, marker)
|
||||
if i < 0 {
|
||||
// Legacy query-string links (pre-fragment).
|
||||
const legacy = "/reset-password?token="
|
||||
i = strings.Index(text, legacy)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := text[i+len(legacy):]
|
||||
end := strings.IndexAny(rest, "\r\n \t")
|
||||
if end < 0 {
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
rest := text[i+len(marker):]
|
||||
end := strings.IndexAny(rest, "\r\n \t")
|
||||
if end < 0 {
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
return strings.TrimSpace(rest[:end])
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GET /api/billing/capabilities — effective plan ∩ global features for the active company.
|
||||
func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "company required")
|
||||
return
|
||||
}
|
||||
caps, err := s.Billing.CapabilitiesForCompany(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load capabilities")
|
||||
return
|
||||
}
|
||||
etag := billing.CapabilitiesResponseETag(caps)
|
||||
// Private: company-scoped. Short max-age + ETag mirrors OpenAPI conditional GET pattern.
|
||||
w.Header().Set("Cache-Control", "private, max-age=30, must-revalidate")
|
||||
w.Header().Set("ETag", etag)
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, caps)
|
||||
}
|
||||
|
||||
// GET /api/admin/plans/{planID}/features
|
||||
func (s *Server) handleAdminGetPlanFeatures(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return
|
||||
}
|
||||
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
|
||||
if err != nil || planID <= 0 {
|
||||
Error(w, http.StatusBadRequest, "invalid plan id")
|
||||
return
|
||||
}
|
||||
view, err := s.Billing.GetPlanFeatures(r.Context(), planID)
|
||||
if err != nil {
|
||||
writePlanFeaturesErr(w, "could not load plan features", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// PUT /api/admin/plans/{planID}/features — replaces stored feature overrides.
|
||||
func (s *Server) handleAdminPutPlanFeatures(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return
|
||||
}
|
||||
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
|
||||
if err != nil || planID <= 0 {
|
||||
Error(w, http.StatusBadRequest, "invalid plan id")
|
||||
return
|
||||
}
|
||||
var body billing.PlanFeaturesUpdate
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Features == nil {
|
||||
Error(w, http.StatusBadRequest, "features required")
|
||||
return
|
||||
}
|
||||
view, err := s.Billing.SetPlanFeatures(r.Context(), planID, body.Features)
|
||||
if err != nil {
|
||||
writePlanFeaturesErr(w, "could not save plan features", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// POST /api/admin/plans/{planID}/features/enable-all — sets every registry key true (custom packages).
|
||||
func (s *Server) handleAdminEnableAllPlanFeatures(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return
|
||||
}
|
||||
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
|
||||
if err != nil || planID <= 0 {
|
||||
Error(w, http.StatusBadRequest, "invalid plan id")
|
||||
return
|
||||
}
|
||||
view, err := s.Billing.EnableAllPlanFeatures(r.Context(), planID)
|
||||
if err != nil {
|
||||
writePlanFeaturesErr(w, "could not enable plan features", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// POST /api/admin/plans/{planID}/features/disable-all — sets every registry key false.
|
||||
func (s *Server) handleAdminDisableAllPlanFeatures(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return
|
||||
}
|
||||
planID, err := strconv.ParseInt(strings.TrimSpace(chi.URLParam(r, "planID")), 10, 64)
|
||||
if err != nil || planID <= 0 {
|
||||
Error(w, http.StatusBadRequest, "invalid plan id")
|
||||
return
|
||||
}
|
||||
view, err := s.Billing.DisableAllPlanFeatures(r.Context(), planID)
|
||||
if err != nil {
|
||||
writePlanFeaturesErr(w, "could not disable plan features", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// GET /api/admin/feature-gates
|
||||
func (s *Server) handleAdminGetFeatureGates(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return
|
||||
}
|
||||
view, err := s.Billing.GetFeatureGates(r.Context())
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load feature gates")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// PUT /api/admin/feature-gates — partial upsert of section/feature master switches.
|
||||
func (s *Server) handleAdminPutFeatureGates(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return
|
||||
}
|
||||
var body billing.FeatureGatesUpdate
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Sections == nil && body.Features == nil {
|
||||
Error(w, http.StatusBadRequest, "sections or features required")
|
||||
return
|
||||
}
|
||||
var updatedBy *uuid.UUID
|
||||
if uid, ok := UserIDFromContext(r.Context()); ok {
|
||||
updatedBy = &uid
|
||||
}
|
||||
view, err := s.Billing.SetFeatureGates(r.Context(), body.Sections, body.Features, updatedBy)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update feature gates", err, billing.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// PUT /api/admin/feature-gates/sections/{section} — enable/disable a section for ALL plans.
|
||||
func (s *Server) handleAdminPutFeatureGateSection(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return
|
||||
}
|
||||
section := strings.TrimSpace(chi.URLParam(r, "section"))
|
||||
if section == "" {
|
||||
Error(w, http.StatusBadRequest, "section required")
|
||||
return
|
||||
}
|
||||
var body billing.SectionGateUpdate
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Enabled == nil {
|
||||
Error(w, http.StatusBadRequest, "enabled required")
|
||||
return
|
||||
}
|
||||
var updatedBy *uuid.UUID
|
||||
if uid, ok := UserIDFromContext(r.Context()); ok {
|
||||
updatedBy = &uid
|
||||
}
|
||||
view, err := s.Billing.SetSectionGate(r.Context(), section, *body.Enabled, updatedBy)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update section gate", err, billing.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// writePlanFeaturesErr maps known billing client errors to the correct status
|
||||
// (404 for missing plan; 400 for validation).
|
||||
func writePlanFeaturesErr(w http.ResponseWriter, publicFallback string, err error) {
|
||||
if errors.Is(err, billing.ErrPlanNotFound) {
|
||||
Error(w, http.StatusNotFound, billing.ErrPlanNotFound.Error())
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, publicFallback, err, billing.ClientError)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestRouterPlanFeaturesMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
Billing: &billing.Service{},
|
||||
testPlatformAdmin: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
return got == uid, nil
|
||||
},
|
||||
}
|
||||
|
||||
var token string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
token = c.Value
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := s.Router()
|
||||
|
||||
unauth := httptest.NewRecorder()
|
||||
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/feature-gates", nil))
|
||||
if unauth.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauth status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
|
||||
}
|
||||
|
||||
adminPaths := []string{
|
||||
"/api/admin/plans/1/features",
|
||||
"/api/admin/feature-gates",
|
||||
}
|
||||
for _, path := range adminPaths {
|
||||
mounted := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
h.ServeHTTP(mounted, req)
|
||||
if mounted.Code == http.StatusNotFound {
|
||||
t.Fatalf("%s not mounted: status=404 body=%s", path, mounted.Body.String())
|
||||
}
|
||||
// No DB pool in this unit test — handlers may 500/503/400, but must not 404.
|
||||
if mounted.Code == http.StatusUnauthorized {
|
||||
t.Fatalf("%s: unexpected 401 for platform admin session", path)
|
||||
}
|
||||
}
|
||||
|
||||
bulkPaths := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/api/admin/plans/1/features/enable-all"},
|
||||
{http.MethodPost, "/api/admin/plans/1/features/disable-all"},
|
||||
{http.MethodPut, "/api/admin/feature-gates/sections/marketing"},
|
||||
}
|
||||
for _, tc := range bulkPaths {
|
||||
mounted := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
h.ServeHTTP(mounted, req)
|
||||
if mounted.Code == http.StatusNotFound {
|
||||
t.Fatalf("%s %s not mounted: status=404", tc.method, tc.path)
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant capabilities require company context — expect 401 without company selection.
|
||||
caps := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/billing/capabilities", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
h.ServeHTTP(caps, req)
|
||||
if caps.Code == http.StatusNotFound {
|
||||
t.Fatalf("capabilities not mounted: status=404")
|
||||
}
|
||||
if caps.Code != http.StatusUnauthorized && caps.Code != http.StatusForbidden {
|
||||
// Company middleware may return 401 or 400 depending on setup; not 404.
|
||||
if caps.Code == http.StatusOK {
|
||||
t.Fatalf("capabilities unexpectedly OK without company")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterPublicPlansMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Billing: &billing.Service{},
|
||||
}
|
||||
h := s.Router()
|
||||
|
||||
for _, path := range []string{"/api/public/plans", "/api/public/credit-packs"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code == http.StatusNotFound {
|
||||
t.Fatalf("%s not mounted: status=404 body=%s", path, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
)
|
||||
|
||||
// writePlanGate writes a 402 plan_gate payload when err is a known billing gate.
|
||||
// Returns true when the response was written.
|
||||
func writePlanGate(w http.ResponseWriter, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if !(errors.Is(err, billing.ErrInsufficientCredits) ||
|
||||
errors.Is(err, billing.ErrProductLimitExceeded) ||
|
||||
errors.Is(err, billing.ErrAIRequiresUpgrade) ||
|
||||
errors.Is(err, billing.ErrEPRELRequiresUpgrade) ||
|
||||
errors.Is(err, billing.ErrFeatureDisabled)) {
|
||||
return false
|
||||
}
|
||||
body := map[string]any{
|
||||
"error": err.Error(),
|
||||
"code": planGateCode(err),
|
||||
"upgrade_url": "/pricing",
|
||||
}
|
||||
if errors.Is(err, billing.ErrFeatureDisabled) {
|
||||
body["error"] = "feature_disabled"
|
||||
if key := billing.FeatureKeyFromError(err); key != "" {
|
||||
body["feature"] = key
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusPaymentRequired, body)
|
||||
return true
|
||||
}
|
||||
|
||||
// requireFeatures rejects with 402 when any key is not effective for the company.
|
||||
// Billing nil: pass-through only outside production; production fails closed with 503.
|
||||
// Returns false when the response was already written.
|
||||
func (s *Server) requireFeatures(w http.ResponseWriter, r *http.Request, keys ...string) bool {
|
||||
if len(keys) == 0 {
|
||||
return true
|
||||
}
|
||||
if s.testAssertFeatures != nil {
|
||||
if err := s.testAssertFeatures(r.Context(), keys...); err != nil {
|
||||
if writePlanGate(w, err) {
|
||||
return false
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "feature check failed")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if s.Billing == nil {
|
||||
if s.Config.IsProduction() {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "company required")
|
||||
return false
|
||||
}
|
||||
if err := s.Billing.AssertFeatures(r.Context(), cid, keys...); err != nil {
|
||||
if writePlanGate(w, err) {
|
||||
return false
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "feature check failed")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// RequireFeature rejects the request with 402 when the company's effective
|
||||
// features do not include key. Billing nil: pass-through only outside production;
|
||||
// production fails closed with 503 (never silently allow all features).
|
||||
func (s *Server) RequireFeature(key string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, key) {
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestRequireFeatureBillingNilFailsClosedInProduction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{Config: config.Config{AppEnv: "production"}}
|
||||
h := s.RequireFeature("capability.api_access")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gated", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireFeatureBillingNilPassThroughOutsideProduction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{Config: config.Config{AppEnv: "development"}}
|
||||
called := false
|
||||
h := s.RequireFeature("capability.api_access")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/gated", nil))
|
||||
if rec.Code != http.StatusNoContent || !called {
|
||||
t.Fatalf("status=%d called=%v want 204 pass-through", rec.Code, called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireFeaturesPlanGateViaHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
s := &Server{
|
||||
testAssertFeatures: func(_ context.Context, keys ...string) error {
|
||||
return fmt.Errorf("%w: %s", billing.ErrFeatureDisabled, keys[0])
|
||||
},
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), ctxCompanyID, cid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
if s.requireFeatures(rec, req, "marketing.campaigns") {
|
||||
t.Fatal("requireFeatures should reject disabled feature")
|
||||
}
|
||||
if rec.Code != http.StatusPaymentRequired {
|
||||
t.Fatalf("status=%d want 402 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "feature_disabled") {
|
||||
t.Fatalf("body=%s want feature_disabled", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCampaignPlanGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
Campaigns: &campaigns.Service{},
|
||||
testAssertFeatures: func(_ context.Context, keys ...string) error {
|
||||
return fmt.Errorf("%w: %s", billing.ErrFeatureDisabled, keys[0])
|
||||
},
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/campaigns", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleListCampaigns(rec, req)
|
||||
if rec.Code != http.StatusPaymentRequired {
|
||||
t.Fatalf("status=%d want 402 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/campaigns", bytes.NewBufferString(`{"name":"x"}`)).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleCreateCampaign(rec, req)
|
||||
if rec.Code != http.StatusPaymentRequired {
|
||||
t.Fatalf("status=%d want 402 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateAPIKeyPlanGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
testAssertFeatures: func(_ context.Context, keys ...string) error {
|
||||
return fmt.Errorf("%w: settings.api_keys", billing.ErrFeatureDisabled)
|
||||
},
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/api-keys", bytes.NewBufferString(`{"name":"x"}`)).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleCreateAPIKey(rec, req)
|
||||
if rec.Code != http.StatusPaymentRequired {
|
||||
t.Fatalf("status=%d want 402 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAPIKeyBillingNilFailsClosedInProduction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{Config: config.Config{AppEnv: "production"}}
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/api-keys", bytes.NewBufferString(`{"name":"x"}`)).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleCreateAPIKey(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateShopifyConfigPlanGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cid := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
testAssertFeatures: func(_ context.Context, keys ...string) error {
|
||||
return fmt.Errorf("%w: stores.shopify", billing.ErrFeatureDisabled)
|
||||
},
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/shopify", bytes.NewBufferString(`{}`)).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUpdateShopifyConfig(rec, req)
|
||||
if rec.Code != http.StatusPaymentRequired {
|
||||
t.Fatalf("status=%d want 402 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListPublicPlansBillingNilReturnsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{Config: config.Config{WebOrigin: "http://localhost:5173"}}
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleListPublicPlans(rec, httptest.NewRequest(http.MethodGet, "/api/public/plans", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("nil billing status=%d want 200 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
s.Billing = &billing.Service{} // non-nil service without pool must not 500
|
||||
rec2 := httptest.NewRecorder()
|
||||
s.handleListPublicPlans(rec2, httptest.NewRequest(http.MethodGet, "/api/public/plans", nil))
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Fatalf("empty billing status=%d want 200 body=%s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListPlansBillingNilServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{}
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleListPlans(rec, httptest.NewRequest(http.MethodGet, "/api/admin/plans", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d want 503 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleCompleteSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
uid, err := auth.ParseSetPasswordToken(s.Config.TokenSigningSecret, body.Token)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if sessionEmail, ok := s.sessionUserEmail(r.Context()); ok {
|
||||
user, gerr := s.Auth.GetUser(r.Context(), uid)
|
||||
if gerr != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if !auth.EmailsEqual(sessionEmail, user.Email) {
|
||||
writeEmailMismatch(w, sessionEmail, user.Email)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.Auth.SetPassword(r.Context(), uid, body.Password); err != nil {
|
||||
if errors.Is(err, auth.ErrPasswordAlreadySet) {
|
||||
Error(w, http.StatusBadRequest, "password already set")
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not set password", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
user, err := s.Auth.UpdateProfile(r.Context(), uid, body.Name)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update profile", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
page, total, err := s.Auth.ListPendingInvites(r.Context(), cid, limit, offset)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"invites": page, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "inviteID"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Auth.RevokeInvite(r.Context(), cid, id); err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not revoke invite", err, auth.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// startProcessingJobRequest is the SPA/API body for POST /processing/jobs.
|
||||
// ProcessingTypes is accepted because the dashboard sends fine-grained types
|
||||
// alongside the coarse ProcessingType used by StartJob; DecodeJSON rejects unknowns.
|
||||
type startProcessingJobRequest struct {
|
||||
RawProductIDs []string `json:"raw_product_ids"`
|
||||
ProcessingType string `json:"processing_type"`
|
||||
ProcessingTypes []string `json:"processing_types"`
|
||||
}
|
||||
|
||||
func (s *Server) handleStartProcessingJob(w http.ResponseWriter, r *http.Request) {
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok || cid == uuid.Nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
var body startProcessingJobRequest
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(body.RawProductIDs))
|
||||
for _, sID := range body.RawProductIDs {
|
||||
id, err := uuid.Parse(sID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid raw_product_id")
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
jobs, err := s.Processing.StartJob(r.Context(), cid, uid, ids, body.ProcessingType)
|
||||
if err != nil {
|
||||
if writePlanGate(w, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, processing.ErrRateLimited) {
|
||||
Error(w, http.StatusTooManyRequests, err.Error())
|
||||
return
|
||||
}
|
||||
if msg, ok := processing.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
LogAndError(w, http.StatusBadRequest, "could not start processing job", err)
|
||||
return
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if err := s.Jobs.EnqueueProcessingJob(r.Context(), job.ID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "enqueue failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusAccepted, processing.FormatStartJobsResponse(jobs))
|
||||
}
|
||||
|
||||
func planGateCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, billing.ErrInsufficientCredits):
|
||||
return "insufficient_credits"
|
||||
case errors.Is(err, billing.ErrProductLimitExceeded):
|
||||
return "product_limit"
|
||||
case errors.Is(err, billing.ErrAIRequiresUpgrade):
|
||||
return "ai_requires_upgrade"
|
||||
case errors.Is(err, billing.ErrEPRELRequiresUpgrade):
|
||||
return "eprel_requires_upgrade"
|
||||
case errors.Is(err, billing.ErrFeatureDisabled):
|
||||
return "plan_gate"
|
||||
default:
|
||||
return "plan_gate"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListProcessingJobs(w http.ResponseWriter, r *http.Request) {
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok || cid == uuid.Nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
limit, _ := ParseLimitOffset(r)
|
||||
items, err := s.Processing.ListJobs(r.Context(), cid, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"jobs": processing.FormatListJobsResponse(items), "limit": limit})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProcessingJob(w http.ResponseWriter, r *http.Request) {
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok || cid == uuid.Nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
job, err := s.getV1ProcessJob(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if processing.JobStatusIncludesProducts(job.Status) {
|
||||
items, loadErr := s.loadV1ProcessJobItems(r.Context(), cid, id, job.ProcessingType)
|
||||
if loadErr != nil {
|
||||
Error(w, http.StatusInternalServerError, "load failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, processing.FormatJobStatusResponse(job, items, true))
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, job)
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelProcessingJob(w http.ResponseWriter, r *http.Request) {
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok || cid == uuid.Nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
job, err := s.Processing.CancelJob(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
if msg, ok := processing.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
LogAndError(w, http.StatusBadRequest, "could not cancel job", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, job)
|
||||
}
|
||||
|
||||
func (s *Server) handleRetryProcessingJob(w http.ResponseWriter, r *http.Request) {
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok || cid == uuid.Nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
job, err := s.Processing.RetryJob(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
if writePlanGate(w, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, processing.ErrRateLimited) {
|
||||
Error(w, http.StatusTooManyRequests, err.Error())
|
||||
return
|
||||
}
|
||||
if msg, ok := processing.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
LogAndError(w, http.StatusBadRequest, "could not retry job", err)
|
||||
return
|
||||
}
|
||||
if err := s.Jobs.EnqueueProcessingJob(r.Context(), job.ID); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "enqueue failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusAccepted, job)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAttachProductQualityStripsHeavyFields(t *testing.T) {
|
||||
items := []map[string]any{
|
||||
{
|
||||
"id": "p1",
|
||||
"name": "Widget",
|
||||
"processed_name": "Great Widget",
|
||||
"category": "Widgets",
|
||||
"description": "raw description long enough for scoring checks",
|
||||
"processed_description": "A detailed product description that is long enough.",
|
||||
"meta_title": "Great Widget | Shop",
|
||||
"meta_description": "Buy Great Widget with free shipping and a two-year warranty today.",
|
||||
"attributes": map[string]any{"color": "red"},
|
||||
"processed_attributes": map[string]any{"color": "red"},
|
||||
"mapped_data": map[string]any{"image": "https://example.com/w.jpg"},
|
||||
},
|
||||
}
|
||||
attachProductQuality(items)
|
||||
row := items[0]
|
||||
if _, ok := row["quality_score"]; !ok {
|
||||
t.Fatal("expected quality_score")
|
||||
}
|
||||
if _, ok := row["quality_grade"]; !ok {
|
||||
t.Fatal("expected quality_grade")
|
||||
}
|
||||
for _, heavy := range []string{
|
||||
"mapped_data", "attributes", "processed_attributes",
|
||||
"description", "processed_description", "meta_title", "meta_description",
|
||||
} {
|
||||
if _, ok := row[heavy]; ok {
|
||||
t.Fatalf("heavy field %q should be stripped from detailed list payload", heavy)
|
||||
}
|
||||
}
|
||||
if got := asMapString(row["processed_name"]); got != "Great Widget" {
|
||||
t.Fatalf("processed_name should remain for display, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeanListFieldSetExcludesHeavyJSON(t *testing.T) {
|
||||
// Contract for default (lean) product list columns — keep in sync with
|
||||
// catalog.ListProcessedProducts SELECT / scanMaps keys.
|
||||
lean := map[string]struct{}{
|
||||
"id": {}, "product_id": {}, "name": {}, "processed_name": {}, "category": {},
|
||||
"category_name": {}, "category_unique_id": {},
|
||||
"status": {}, "raw_product_id": {}, "feed_id": {}, "gtin": {},
|
||||
"feed_name": {}, "feed_last_synced_at": {}, "raw_updated_at": {},
|
||||
"has_name": {}, "has_processed_name": {}, "has_description": {}, "has_processed_description": {},
|
||||
"has_category": {}, "has_attributes": {}, "has_processed_attributes": {},
|
||||
"has_eprel": {},
|
||||
"created_at": {}, "updated_at": {},
|
||||
}
|
||||
for _, heavy := range []string{
|
||||
"attributes", "processed_attributes", "mapped_data",
|
||||
"description", "processed_description", "meta_title", "meta_description",
|
||||
} {
|
||||
if _, ok := lean[heavy]; ok {
|
||||
t.Fatalf("lean field set must not include %q", heavy)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleResetProducts(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCompanyAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
ProductIDs []string `json:"product_ids"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(body.ProductIDs))
|
||||
for _, raw := range body.ProductIDs {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid product_ids")
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
result, err := s.Catalog.ResetProductsToUnprocessed(r.Context(), cid, ids, body.Kind)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not reset products", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/marketing"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyDefaultPageLimit = 25
|
||||
legacyMaxPageLimit = 100
|
||||
)
|
||||
|
||||
// ParsePageLimit reads legacy public-API pagination: page (1-based) + limit.
|
||||
// Defaults match legacy parsePagination: page=1, limit=25, max=100.
|
||||
// When page is absent but offset is present, offset is honored for compatibility.
|
||||
func ParsePageLimit(r *http.Request) (page, limit, offset int) {
|
||||
limit, _ = strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = legacyDefaultPageLimit
|
||||
}
|
||||
if limit > legacyMaxPageLimit {
|
||||
limit = legacyMaxPageLimit
|
||||
}
|
||||
|
||||
page, _ = strconv.Atoi(r.URL.Query().Get("page"))
|
||||
if page > 0 {
|
||||
offset = (page - 1) * limit
|
||||
return page, limit, offset
|
||||
}
|
||||
|
||||
offset, _ = strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
page = offset/limit + 1
|
||||
return page, limit, offset
|
||||
}
|
||||
|
||||
func v1ProductListMeta(page, limit int, total int64) map[string]any {
|
||||
totalPages := 0
|
||||
if limit > 0 {
|
||||
totalPages = int((total + int64(limit) - 1) / int64(limit))
|
||||
}
|
||||
return map[string]any{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"totalPages": totalPages,
|
||||
}
|
||||
}
|
||||
|
||||
func v1ProductStatus(raw string) string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" || strings.EqualFold(s, "all") {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func presentV1Product(item map[string]any) map[string]any {
|
||||
q := marketing.ScoreFromProductMap(item)
|
||||
name := firstNonEmpty(asMapString(item["name"]), asMapString(item["processed_name"]))
|
||||
var nameVal any = name
|
||||
if name == "" {
|
||||
nameVal = nil
|
||||
}
|
||||
category := asMapString(item["category"])
|
||||
var categoryVal any = category
|
||||
if category == "" {
|
||||
categoryVal = nil
|
||||
}
|
||||
return map[string]any{
|
||||
"id": item["id"],
|
||||
"product_id": item["product_id"],
|
||||
"name": nameVal,
|
||||
"category": categoryVal,
|
||||
"status": item["status"],
|
||||
"feed_id": nullIfEmptyAny(item["feed_id"]),
|
||||
"quality_score": q.Score,
|
||||
"quality_grade": q.Grade,
|
||||
"created_at": formatV1Timestamp(item["created_at"]),
|
||||
"updated_at": formatV1Timestamp(item["updated_at"]),
|
||||
}
|
||||
}
|
||||
|
||||
func nullIfEmptyAny(v any) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func formatV1Timestamp(v any) any {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case time.Time:
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
case string:
|
||||
if strings.TrimSpace(t) == "" {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// handleV1ListProducts serves GET /api/v1/products with the legacy public contract:
|
||||
// { data: presentProduct[], meta: { page, limit, total, totalPages } }.
|
||||
func (s *Server) handleV1ListProducts(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
page, limit, offset := ParsePageLimit(r)
|
||||
status := v1ProductStatus(r.URL.Query().Get("status"))
|
||||
f := catalog.ListFilter{
|
||||
Query: QuerySearch(r),
|
||||
Status: status,
|
||||
FeedID: firstNonEmpty(r.URL.Query().Get("feedId"), r.URL.Query().Get("feed_id")),
|
||||
SortBy: firstNonEmpty(r.URL.Query().Get("sortBy"), r.URL.Query().Get("sort_by"), "updatedAt"),
|
||||
SortOrder: firstNonEmpty(r.URL.Query().Get("sortOrder"), r.URL.Query().Get("sort_order"), "desc"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
items, total, err := s.Catalog.ListProcessedProductsDetailed(r.Context(), cid, f)
|
||||
if err != nil {
|
||||
if msg, ok := catalog.ClientError(err); ok {
|
||||
v1Err(w, http.StatusBadRequest, "validation_error", msg)
|
||||
return
|
||||
}
|
||||
v1Err(w, http.StatusInternalServerError, "internal_error", "list failed")
|
||||
return
|
||||
}
|
||||
|
||||
data := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
data = append(data, presentV1Product(item))
|
||||
}
|
||||
v1OK(w, http.StatusOK, data, v1ProductListMeta(page, limit, total))
|
||||
}
|
||||
|
||||
// handleV1ListProductQuality serves GET /api/v1/products/quality with the legacy
|
||||
// { data, meta } envelope (quality rows + page/limit/total).
|
||||
func (s *Server) handleV1ListProductQuality(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
page, limit, offset := ParsePageLimit(r)
|
||||
var minScore *int
|
||||
if raw := r.URL.Query().Get("min_score"); raw != "" {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
v1Err(w, http.StatusBadRequest, "validation_error", "invalid min_score")
|
||||
return
|
||||
}
|
||||
minScore = &n
|
||||
}
|
||||
|
||||
status := v1ProductStatus(r.URL.Query().Get("status"))
|
||||
if status == "" {
|
||||
status = "completed"
|
||||
}
|
||||
f := catalog.ListFilter{
|
||||
Query: QuerySearch(r),
|
||||
Status: status,
|
||||
Category: r.URL.Query().Get("category"),
|
||||
FeedID: firstNonEmpty(r.URL.Query().Get("feedId"), r.URL.Query().Get("feed_id")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
items, total, err := s.Catalog.ListProcessedProductsDetailed(r.Context(), cid, f)
|
||||
if err != nil {
|
||||
v1Err(w, http.StatusInternalServerError, "internal_error", "list failed")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
q := marketing.ScoreFromProductMap(item)
|
||||
if minScore != nil && q.Score < *minScore {
|
||||
continue
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": item["id"],
|
||||
"product_id": item["product_id"],
|
||||
"name": firstNonEmpty(asMapString(item["processed_name"]), asMapString(item["name"])),
|
||||
"quality_score": q.Score,
|
||||
"quality_grade": q.Grade,
|
||||
"quality_checks": q.Checks,
|
||||
})
|
||||
}
|
||||
v1OK(w, http.StatusOK, out, map[string]any{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPresentV1ProductFields(t *testing.T) {
|
||||
ts := time.Date(2026, 8, 1, 10, 15, 0, 0, time.UTC)
|
||||
row := map[string]any{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"product_id": "SKU-1001",
|
||||
"name": "Wireless earbuds",
|
||||
"processed_name": "Acme Wireless Earbuds",
|
||||
"category": "electronics/audio",
|
||||
"status": "completed",
|
||||
"feed_id": "22222222-2222-2222-2222-222222222222",
|
||||
"description": "raw desc",
|
||||
"processed_description": "A detailed product description that is long enough for scoring.",
|
||||
"meta_title": "Acme Wireless Earbuds | Shop",
|
||||
"meta_description": "Buy Acme Wireless Earbuds with free shipping and a two-year warranty today.",
|
||||
"attributes": map[string]any{"color": "Black", "brand": "Acme"},
|
||||
"processed_attributes": map[string]any{"color": "Black", "brand": "Acme"},
|
||||
"mapped_data": map[string]any{"image": "https://example.com/earbuds.jpg"},
|
||||
"created_at": ts,
|
||||
"updated_at": ts,
|
||||
}
|
||||
out := presentV1Product(row)
|
||||
for _, key := range []string{
|
||||
"id", "product_id", "name", "category", "status", "feed_id",
|
||||
"quality_score", "quality_grade", "created_at", "updated_at",
|
||||
} {
|
||||
if _, ok := out[key]; !ok {
|
||||
t.Fatalf("missing field %q", key)
|
||||
}
|
||||
}
|
||||
for _, heavy := range []string{
|
||||
"processed_name", "description", "processed_description",
|
||||
"attributes", "mapped_data", "gtin", "raw_product_id",
|
||||
} {
|
||||
if _, ok := out[heavy]; ok {
|
||||
t.Fatalf("unexpected heavy field %q in presentProduct payload", heavy)
|
||||
}
|
||||
}
|
||||
if out["name"] != "Wireless earbuds" {
|
||||
t.Fatalf("name=%v", out["name"])
|
||||
}
|
||||
if out["created_at"] != "2026-08-01T10:15:00Z" {
|
||||
t.Fatalf("created_at=%v", out["created_at"])
|
||||
}
|
||||
score, _ := out["quality_score"].(int)
|
||||
if score <= 0 {
|
||||
t.Fatalf("expected positive quality_score, got %v", out["quality_score"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1ProductStatusAll(t *testing.T) {
|
||||
if got := v1ProductStatus("all"); got != "" {
|
||||
t.Fatalf("all -> %q want empty", got)
|
||||
}
|
||||
if got := v1ProductStatus("completed"); got != "completed" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1ProductListMetaJSON(t *testing.T) {
|
||||
meta := v1ProductListMeta(2, 25, 1284)
|
||||
b, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(b, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded["page"].(float64) != 2 || decoded["limit"].(float64) != 25 {
|
||||
t.Fatalf("meta=%v", decoded)
|
||||
}
|
||||
if decoded["total"].(float64) != 1284 {
|
||||
t.Fatalf("total=%v", decoded["total"])
|
||||
}
|
||||
if decoded["totalPages"].(float64) != 52 {
|
||||
t.Fatalf("totalPages=%v", decoded["totalPages"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1OpenAPIIncludesLegacyProductsEnvelope(t *testing.T) {
|
||||
body := string(v1OpenAPIYAML)
|
||||
for _, needle := range []string{
|
||||
"/products/quality:",
|
||||
"PresentProduct",
|
||||
"ProductQualityListResponse",
|
||||
"quality_score",
|
||||
"quality_grade",
|
||||
"name: page",
|
||||
"name: search",
|
||||
"name: sortBy",
|
||||
"name: feedId",
|
||||
"totalPages",
|
||||
"LegacyLimit",
|
||||
"required: [data, meta]",
|
||||
} {
|
||||
if !strings.Contains(body, needle) {
|
||||
t.Fatalf("openapi missing %q", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||
emailpkg "github.com/descrybe/descrybe-v2/apps/api/internal/email"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/marketing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/seo"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/shopify"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func TestLogAndErrorHidesInternalDetail(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
LogAndError(rec, http.StatusInternalServerError, "could not resolve upload", errors.New("open /secret/path: permission denied"))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "could not resolve upload" {
|
||||
t.Fatalf("error=%q", body["error"])
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "secret") {
|
||||
t.Fatal("leaked internal path detail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOrLogPreservesAuthValidation(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "registration failed", auth.ErrPasswordTooShort, auth.ClientError)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "password must be at least 8 characters") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOrLogHidesOpaqueAuthDBError(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "registration failed", errors.New("ERROR: duplicate key value violates unique constraint \"users_email_key\" (SQLSTATE 23505)"), auth.ClientError)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "registration failed" {
|
||||
t.Fatalf("error=%q", body["error"])
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "SQLSTATE") || strings.Contains(rec.Body.String(), "users_email") {
|
||||
t.Fatal("leaked DB detail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOrLogPreservesBillingSentinel(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "checkout failed", billing.ErrStripePlanUnsupported, billing.ClientError)
|
||||
if !strings.Contains(rec.Body.String(), "not available for self-serve") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientOrLogHidesStripeProviderError(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "checkout failed", errors.New("stripe api 400: {\"error\":{\"message\":\"No such price: price_secret_abc\"}}"), billing.ClientError)
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "checkout failed" {
|
||||
t.Fatalf("error=%q", body["error"])
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "price_secret") || strings.Contains(rec.Body.String(), "No such price") {
|
||||
t.Fatal("leaked Stripe provider detail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogClientErrorPreservesValidation(t *testing.T) {
|
||||
msg, ok := catalog.ClientError(catalog.ClientMsg("name and unique_id required"))
|
||||
if !ok || msg != "name and unique_id required" {
|
||||
t.Fatalf("msg=%q ok=%v", msg, ok)
|
||||
}
|
||||
if _, ok := catalog.ClientError(errors.New("pq: relation \"categories\" does not exist")); ok {
|
||||
t.Fatal("opaque DB error must not be client-facing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShopifyWooClientErrorSentinels(t *testing.T) {
|
||||
if msg, ok := shopify.ClientError(shopify.ErrMissingCreds); !ok || msg == "" {
|
||||
t.Fatal("shopify missing creds")
|
||||
}
|
||||
if _, ok := shopify.ClientError(errors.New("dial tcp 10.0.0.1:443: i/o timeout")); ok {
|
||||
t.Fatal("shopify opaque must not be client-facing")
|
||||
}
|
||||
if msg, ok := woocommerce.ClientError(woocommerce.ErrInvalidStoreURL); !ok || !strings.Contains(msg, "store url") {
|
||||
t.Fatalf("woo invalid url msg=%q ok=%v", msg, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePublicExportErrorUsesFormatMismatchSentinel(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
writePublicExportError(rec, feeds.ErrFormatMismatch)
|
||||
// Must match unknown-token responses so format probes cannot confirm a token.
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "export feed not found") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "format mismatch") {
|
||||
t.Fatalf("must not leak format mismatch: body=%s", rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
writePublicExportError(rec, pgx.ErrNoRows)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedsClientErrorPreservesValidation(t *testing.T) {
|
||||
msg, ok := feeds.ClientError(feeds.ClientMsg("name required"))
|
||||
if !ok || msg != "name required" {
|
||||
t.Fatalf("msg=%q ok=%v", msg, ok)
|
||||
}
|
||||
if _, ok := feeds.ClientError(errors.New("pq: relation \"input_feeds\" does not exist")); ok {
|
||||
t.Fatal("opaque DB error must not be client-facing")
|
||||
}
|
||||
ClientOrLog(httptest.NewRecorder(), http.StatusBadRequest, "could not create feed", errors.New("dial tcp timeout"), feeds.ClientError)
|
||||
}
|
||||
|
||||
func TestCampaignsClientErrorPreservesSentinel(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "could not create campaign", campaigns.ErrNameRequired, campaigns.ClientError)
|
||||
if !strings.Contains(rec.Body.String(), "name required") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "could not create campaign", errors.New("ERROR: duplicate key"), campaigns.ClientError)
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "could not create campaign" {
|
||||
t.Fatalf("error=%q", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailClientErrorHidesProviderDetail(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "could not update email settings", emailpkg.ClientMsg("provider must be resend or smtp"), emailpkg.ClientError)
|
||||
if !strings.Contains(rec.Body.String(), "provider must be resend or smtp") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "email verification failed", errors.New("resend api 500: internal secret"), emailpkg.ClientError)
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "email verification failed" {
|
||||
t.Fatalf("error=%q", body["error"])
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "secret") {
|
||||
t.Fatal("leaked provider detail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIProviderClientErrorHidesBaseURLDetail(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "could not update ai settings", aiprovider.ErrInvalidMode, aiprovider.ClientError)
|
||||
if !strings.Contains(rec.Body.String(), "mode must be") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "could not update ai settings", errors.New("encrypt: cipher: message authentication failed"), aiprovider.ClientError)
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "could not update ai settings" {
|
||||
t.Fatalf("error=%q", body["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketingClientErrorPreservesPresetValidation(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "could not prepare campaign", marketing.ClientMsg("preset_id must be black_friday or christmas"), marketing.ClientError)
|
||||
if !strings.Contains(rec.Body.String(), "preset_id must be") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthInviteEmailRequired(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "could not create invite", auth.ErrEmailRequired, auth.ClientError)
|
||||
if !strings.Contains(rec.Body.String(), "email is required") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessingRateLimitStatusViaSentinel(t *testing.T) {
|
||||
// Mirror handler status selection without spinning up Server deps.
|
||||
err := processing.ErrRateLimited
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, processing.ErrRateLimited) {
|
||||
status = http.StatusTooManyRequests
|
||||
}
|
||||
if status != http.StatusTooManyRequests {
|
||||
t.Fatalf("status=%d", status)
|
||||
}
|
||||
if strings.Contains(err.Error(), "rate limit") && !errors.Is(err, processing.ErrRateLimited) {
|
||||
t.Fatal("regression: string matching alone is insufficient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSEONotFoundUsesSentinel(t *testing.T) {
|
||||
if !errors.Is(seo.ErrNotFound, seo.ErrNotFound) {
|
||||
t.Fatal("seo.ErrNotFound identity broken")
|
||||
}
|
||||
opaque := errors.New("product row not found in warehouse")
|
||||
if errors.Is(opaque, seo.ErrNotFound) {
|
||||
t.Fatal("opaque message must not match ErrNotFound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSEOClientErrorPreservesInvalidMode(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "seo apply failed", seo.ErrInvalidMode, seo.ClientError)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "mode must be template or ai") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "seo apply failed", errors.New("openai: api key sk-secret leaked"), seo.ClientError)
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "seo apply failed" {
|
||||
t.Fatalf("error=%q", body["error"])
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "sk-secret") {
|
||||
t.Fatal("leaked opaque SEO apply detail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandLogoClientErrorPreservesValidation(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "could not upload logo", company.ErrLogoInvalidType, company.ClientError)
|
||||
if !strings.Contains(rec.Body.String(), "logo must be PNG, JPEG, or WebP") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "could not upload logo", company.ErrLogoTooLarge, company.ClientError)
|
||||
if !strings.Contains(rec.Body.String(), "logo exceeds 2 MiB limit") {
|
||||
t.Fatalf("body=%s", rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
ClientOrLog(rec, http.StatusBadRequest, "could not upload logo", errors.New("open /secret/uploads: permission denied"), company.ClientError)
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["error"] != "could not upload logo" {
|
||||
t.Fatalf("error=%q", body["error"])
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "secret") || strings.Contains(rec.Body.String(), "permission denied") {
|
||||
t.Fatal("leaked filesystem detail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HTTP rate limiters in this file are in-process (per API OS process / replica).
|
||||
//
|
||||
// MULTI-REPLICA CUTOVER (see docs/production-readiness.md § edge rate limits):
|
||||
// - There is no Redis (or other shared store) in the Descrybe stack today.
|
||||
// - Without edge caps, effective HTTP budget across N replicas is roughly
|
||||
// N× the per-process base RPM.
|
||||
// - RATE_LIMIT_REPLICAS=N (optional) divides only the HTTP middleware caps in
|
||||
// this file via rateLimitEffectiveCap (ceil) so aggregate under even load
|
||||
// approximates the documented RPM. It is not a shared counter and does not
|
||||
// affect login email lockout, processing.StartLimiter, support.AIRateLimiter,
|
||||
// or email send limiters — those stay per-process until edge/shared infra.
|
||||
// - Cutover for multi-replica hard global RPM: enforce cluster caps at the
|
||||
// edge (CDN/ingress/WAF). RATE_LIMIT_REPLICAS alone is not a substitute.
|
||||
// - RATE_LIMIT_MULTI_REPLICA=true acknowledges multi-replica deploy without a
|
||||
// shared backend; the API logs a boot warning (config.RateLimitWarningMessage).
|
||||
// - RATE_LIMIT_BACKEND=redis|postgres is accepted as documentation only and
|
||||
// forced to memory until a shared backend is implemented — do not assume
|
||||
// distributed counters exist.
|
||||
|
||||
// slidingWindowLimiter is a light in-process rate limiter (per-key).
|
||||
// Suitable for a single API instance; not shared across replicas.
|
||||
type slidingWindowLimiter struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
limit int
|
||||
hits map[string][]time.Time
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
func newSlidingWindowLimiter(limit int, window time.Duration) *slidingWindowLimiter {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
if window <= 0 {
|
||||
window = time.Minute
|
||||
}
|
||||
return &slidingWindowLimiter{
|
||||
window: window,
|
||||
limit: limit,
|
||||
hits: make(map[string][]time.Time),
|
||||
lastGC: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *slidingWindowLimiter) allow(key string) bool {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-l.window)
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if now.Sub(l.lastGC) > l.window {
|
||||
for k, ts := range l.hits {
|
||||
kept := ts[:0]
|
||||
for _, t := range ts {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(l.hits, k)
|
||||
} else {
|
||||
l.hits[k] = kept
|
||||
}
|
||||
}
|
||||
l.lastGC = now
|
||||
}
|
||||
ts := l.hits[key]
|
||||
kept := ts[:0]
|
||||
for _, t := range ts {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= l.limit {
|
||||
l.hits[key] = kept
|
||||
return false
|
||||
}
|
||||
l.hits[key] = append(kept, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// writeRateLimited responds 429 with Retry-After plus IETF RateLimit headers
|
||||
// (draft-ietf-httpapi-ratelimit-headers) so clients can back off before retrying.
|
||||
// On deny, remaining is always 0; t/w use the limiter window in seconds.
|
||||
func writeRateLimited(w http.ResponseWriter, limit, windowSec int) {
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if windowSec < 1 {
|
||||
windowSec = 60
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(windowSec))
|
||||
w.Header().Set("RateLimit", fmt.Sprintf(`"http";r=0;t=%d`, windowSec))
|
||||
w.Header().Set("RateLimit-Policy", fmt.Sprintf(`"http";q=%d;w=%d`, limit, windowSec))
|
||||
Error(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
}
|
||||
|
||||
// rateLimitEffectiveCap divides a per-process HTTP base cap across RATE_LIMIT_REPLICAS
|
||||
// (ceil) so aggregate traffic under even load approximates the documented RPM.
|
||||
// replicas<=1 leaves the base unchanged (default single-instance behavior).
|
||||
// Scope: HTTP middleware in this file only — not lockout / StartLimiter / AI / email.
|
||||
func rateLimitEffectiveCap(base, replicas int) int {
|
||||
if base <= 0 {
|
||||
return 1
|
||||
}
|
||||
if replicas <= 1 {
|
||||
return base
|
||||
}
|
||||
n := (base + replicas - 1) / replicas
|
||||
if n < 1 {
|
||||
return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Server) rateLimitReplicas() int {
|
||||
if s == nil || s.Config.RateLimitReplicas < 1 {
|
||||
return 1
|
||||
}
|
||||
return s.Config.RateLimitReplicas
|
||||
}
|
||||
|
||||
// heavyMutationRPM is the per-company HTTP budget for sync / process / export mutations.
|
||||
// In-process only (not shared across replicas). Counts requests, not products in a bulk body.
|
||||
const heavyMutationRPM = 30
|
||||
|
||||
func isHeavyFeedOrProcessMutation(r *http.Request) bool {
|
||||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
switch path {
|
||||
case "/api/v1/process", "/api/v1/products/process", "/api/processing/jobs":
|
||||
return true
|
||||
default:
|
||||
if strings.HasSuffix(path, "/sync-process-sample") || strings.HasSuffix(path, "/extract-schema") {
|
||||
return true
|
||||
}
|
||||
// Export generate / selected-product export — heavy CPU + IO per request.
|
||||
if strings.Contains(path, "/export-feeds/") &&
|
||||
(strings.HasSuffix(path, "/generate") || strings.HasSuffix(path, "/export-products")) {
|
||||
return true
|
||||
}
|
||||
// Process job retries also consume StartLimiter capacity.
|
||||
if strings.HasSuffix(path, "/retry") &&
|
||||
(strings.Contains(path, "/processing/jobs/") || strings.Contains(path, "/process/")) {
|
||||
return true
|
||||
}
|
||||
// Feed sync downloads/parses remote content — throttle both /api and /api/v1.
|
||||
// Store connector syncs (/woocommerce/sync, /shopify/…) are intentionally excluded;
|
||||
// they use connector-specific workers and are not part of this shared bucket.
|
||||
return strings.HasSuffix(path, "/sync") && strings.Contains(path, "/feeds/")
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitV1Process throttles heavy process / feed sync / export mutations per company.
|
||||
// Limit is in-process (per API replica); set RATE_LIMIT_REPLICAS or prefer edge limits when running multiple replicas.
|
||||
func (s *Server) RateLimitV1Process(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(heavyMutationRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isHeavyFeedOrProcessMutation(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
key := "anon"
|
||||
if ok && cid != uuid.Nil {
|
||||
key = cid.String()
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// publicRPM is the per-IP budget for unauthenticated /api/public routes (plans, logos, …).
|
||||
const publicRPM = 30
|
||||
|
||||
// RateLimitPublic throttles unauthenticated /api/public routes per client IP
|
||||
// (RemoteAddr; rewritten only via TrustedRealIP when TRUSTED_PROXIES is set).
|
||||
func (s *Server) RateLimitPublic(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(publicRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimSpace(r.RemoteAddr)
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Public export token scraping budgets (in-process; see file header for replicas).
|
||||
const (
|
||||
publicExportIPRPM = 30 // well-formed export GETs per IP
|
||||
publicExportProbeRPM = 15 // invalid-shape token probes per IP (enumeration)
|
||||
publicExportTokenRPM = 30 // polls per public_token (known-token scrape)
|
||||
)
|
||||
|
||||
// RateLimitPublicExport throttles tokenized export GETs harder than generic /api/public.
|
||||
// Invalid token shapes are rejected here (no DB) and counted against a probe budget.
|
||||
func (s *Server) RateLimitPublicExport(next http.Handler) http.Handler {
|
||||
ipCap := rateLimitEffectiveCap(publicExportIPRPM, s.rateLimitReplicas())
|
||||
probeCap := rateLimitEffectiveCap(publicExportProbeRPM, s.rateLimitReplicas())
|
||||
tokenCap := rateLimitEffectiveCap(publicExportTokenRPM, s.rateLimitReplicas())
|
||||
ipLimiter := newSlidingWindowLimiter(ipCap, time.Minute)
|
||||
probeLimiter := newSlidingWindowLimiter(probeCap, time.Minute)
|
||||
tokenLimiter := newSlidingWindowLimiter(tokenCap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := strings.TrimSpace(r.RemoteAddr)
|
||||
if ip == "" {
|
||||
ip = "unknown"
|
||||
}
|
||||
token := strings.ToLower(strings.TrimSpace(chi.URLParam(r, "token")))
|
||||
if !feeds.ValidPublicToken(token) {
|
||||
if !probeLimiter.allow(ip) {
|
||||
writeRateLimited(w, probeCap, 60)
|
||||
return
|
||||
}
|
||||
// Same body as writePublicExportError — no oracle for token existence.
|
||||
Error(w, http.StatusNotFound, "export feed not found")
|
||||
return
|
||||
}
|
||||
if !ipLimiter.allow(ip) {
|
||||
writeRateLimited(w, ipCap, 60)
|
||||
return
|
||||
}
|
||||
if !tokenLimiter.allow("t:" + token) {
|
||||
writeRateLimited(w, tokenCap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// API key surface budgets (in-process).
|
||||
const (
|
||||
apiKeyAttemptRPM = 60 // keyed /api/v1 requests per IP (brute-force / spray)
|
||||
apiKeyCompanyRPM = 120 // authenticated /api/v1 requests per company
|
||||
)
|
||||
|
||||
// RateLimitAPIKeyAttempts throttles /api/v1 requests that present an API key, per IP.
|
||||
// Mount before RequireAPIKey so invalid keys still consume budget.
|
||||
func (s *Server) RateLimitAPIKeyAttempts(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(apiKeyAttemptRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if extractAPIKey(r) == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(r.RemoteAddr)
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// RateLimitAPIKey throttles authenticated /api/v1 traffic per company (API4 abuse cap).
|
||||
func (s *Server) RateLimitAPIKey(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(apiKeyCompanyRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
key := "anon"
|
||||
if ok && cid != uuid.Nil {
|
||||
key = cid.String()
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func isMarketingGenerateOrSend(r *http.Request) bool {
|
||||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
switch {
|
||||
case path == "/api/seo/apply":
|
||||
return true
|
||||
case path == "/api/campaigns/generate":
|
||||
return true
|
||||
case strings.HasSuffix(path, "/generate") && strings.Contains(path, "/campaigns/"):
|
||||
return true
|
||||
case path == "/api/campaigns/send" || path == "/api/campaigns/send-test":
|
||||
return true
|
||||
case strings.HasSuffix(path, "/send") || strings.HasSuffix(path, "/send-test") || strings.HasSuffix(path, "/schedule"):
|
||||
return strings.Contains(path, "/campaigns/")
|
||||
case path == "/api/integrations/email/send" || path == "/api/email/send":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitMarketing throttles campaign generate/send and SEO AI apply per company.
|
||||
// In-process only (per API replica); set RATE_LIMIT_REPLICAS or prefer edge limits when running multiple replicas.
|
||||
func (s *Server) RateLimitMarketing(next http.Handler) http.Handler {
|
||||
genCap := rateLimitEffectiveCap(10, s.rateLimitReplicas())
|
||||
sendCap := rateLimitEffectiveCap(30, s.rateLimitReplicas())
|
||||
genLimiter := newSlidingWindowLimiter(genCap, time.Minute)
|
||||
sendLimiter := newSlidingWindowLimiter(sendCap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isMarketingGenerateOrSend(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
key := "anon"
|
||||
if ok && cid != uuid.Nil {
|
||||
key = cid.String()
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
limiter := sendLimiter
|
||||
cap := sendCap
|
||||
if strings.Contains(path, "generate") || path == "/api/seo/apply" {
|
||||
limiter = genLimiter
|
||||
cap = genCap
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
authLoginRPM = 10 // login / invite / set-password / sales-contact per IP
|
||||
authRegisterRPM = 5 // registration spam bucket (stricter than login)
|
||||
)
|
||||
|
||||
func isAuthRegister(r *http.Request) bool {
|
||||
return r.Method == http.MethodPost && strings.TrimSuffix(r.URL.Path, "/") == "/api/auth/register"
|
||||
}
|
||||
|
||||
func isAuthMutation(r *http.Request) bool {
|
||||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSuffix(r.URL.Path, "/") {
|
||||
case "/api/auth/login",
|
||||
"/api/auth/register",
|
||||
"/api/auth/forgot-password",
|
||||
"/api/auth/reset-password",
|
||||
"/api/auth/invite-preview",
|
||||
"/api/auth/accept-invite",
|
||||
"/api/auth/complete-set-password",
|
||||
"/api/sales/contact":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitAuth throttles unauthenticated auth POSTs per client IP
|
||||
// (RemoteAddr; rewritten only via TrustedRealIP when TRUSTED_PROXIES is set).
|
||||
// Register uses a stricter bucket so login brute-force and signup spam do not share budget.
|
||||
func (s *Server) RateLimitAuth(next http.Handler) http.Handler {
|
||||
loginCap := rateLimitEffectiveCap(authLoginRPM, s.rateLimitReplicas())
|
||||
registerCap := rateLimitEffectiveCap(authRegisterRPM, s.rateLimitReplicas())
|
||||
loginLimiter := newSlidingWindowLimiter(loginCap, time.Minute)
|
||||
registerLimiter := newSlidingWindowLimiter(registerCap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAuthMutation(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(r.RemoteAddr)
|
||||
if key == "" {
|
||||
key = "unknown"
|
||||
}
|
||||
limiter := loginLimiter
|
||||
cap := loginCap
|
||||
if isAuthRegister(r) {
|
||||
limiter = registerLimiter
|
||||
cap = registerCap
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// adminPlanFeaturesGetRPM caps GET /api/admin/plans/{id}/features per user.
|
||||
// In-process only; stops client refetch storms from saturating the API.
|
||||
const adminPlanFeaturesGetRPM = 60
|
||||
|
||||
func isAdminPlanFeaturesGet(r *http.Request) bool {
|
||||
if r.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
if !strings.HasPrefix(path, "/api/admin/plans/") {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(path, "/features")
|
||||
}
|
||||
|
||||
// RateLimitAdminPlanFeatures throttles repeated GET plan-feature matrix fetches per user.
|
||||
func (s *Server) RateLimitAdminPlanFeatures(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(adminPlanFeaturesGetRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAdminPlanFeaturesGet(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
key := "anon"
|
||||
if ok && uid != uuid.Nil {
|
||||
key = uid.String()
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// adminAnalyticsGetRPM caps expensive admin diagnostic/analytics GETs per user.
|
||||
const adminAnalyticsGetRPM = 20
|
||||
|
||||
func isAdminAnalyticsGet(r *http.Request) bool {
|
||||
if r.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
return path == "/api/admin/analytics" || path == "/api/admin/diagnostics"
|
||||
}
|
||||
|
||||
// RateLimitAdminAnalytics throttles expensive platform analytics/diagnostics reads per user.
|
||||
func (s *Server) RateLimitAdminAnalytics(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(adminAnalyticsGetRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAdminAnalyticsGet(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
key := "anon"
|
||||
if ok && uid != uuid.Nil {
|
||||
key = uid.String()
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// aiProbeRPM caps LLM/mail credential probes (cost / abuse).
|
||||
const aiProbeRPM = 10
|
||||
|
||||
func isAIOrMailProbePOST(r *http.Request) bool {
|
||||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
switch {
|
||||
case path == "/api/integrations/ai/test":
|
||||
return true
|
||||
case path == "/api/admin/settings/mail/test":
|
||||
return true
|
||||
case strings.HasPrefix(path, "/api/admin/settings/ai-roles/") && strings.HasSuffix(path, "/test"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitAIProbes throttles AI/mail test probes per user (admin) or company (tenant).
|
||||
func (s *Server) RateLimitAIProbes(next http.Handler) http.Handler {
|
||||
cap := rateLimitEffectiveCap(aiProbeRPM, s.rateLimitReplicas())
|
||||
limiter := newSlidingWindowLimiter(cap, time.Minute)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAIOrMailProbePOST(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
key := "anon"
|
||||
if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil {
|
||||
key = "u:" + uid.String()
|
||||
} else if cid, ok := CompanyIDFromContext(r.Context()); ok && cid != uuid.Nil {
|
||||
key = "c:" + cid.String()
|
||||
}
|
||||
if !limiter.allow(key) {
|
||||
writeRateLimited(w, cap, 60)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSlidingWindowLimiterAllowConcurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := newSlidingWindowLimiter(15, time.Minute)
|
||||
var allowed atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
const n = 50
|
||||
wg.Add(n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if l.allow("company-a") {
|
||||
allowed.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := allowed.Load(); got != 15 {
|
||||
t.Fatalf("allowed=%d want 15", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidingWindowLimiterSeparateKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := newSlidingWindowLimiter(5, time.Minute)
|
||||
var a, b atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(20)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if l.allow("a") {
|
||||
a.Add(1)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if l.allow("b") {
|
||||
b.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if a.Load() != 5 || b.Load() != 5 {
|
||||
t.Fatalf("a=%d b=%d want 5 each", a.Load(), b.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestRateLimitAuthBlocksBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
var saw429 bool
|
||||
for i := 0; i < authLoginRPM+5; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
req.RemoteAddr = "203.0.113.10:12345"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after auth burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAuthRegisterStricterThanLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
var saw429 bool
|
||||
for i := 0; i < authRegisterRPM+3; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil)
|
||||
req.RemoteAddr = "203.0.113.20:12345"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after register burst")
|
||||
}
|
||||
// Login budget is independent — register exhaust must not block login.
|
||||
login := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
login.RemoteAddr = "203.0.113.20:12345"
|
||||
recLogin := httptest.NewRecorder()
|
||||
h.ServeHTTP(recLogin, login)
|
||||
if recLogin.Code != http.StatusNoContent {
|
||||
t.Fatalf("login should use separate bucket, got %d", recLogin.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAuthIncludesSalesContact(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
var saw429 bool
|
||||
for i := 0; i < authLoginRPM+3; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/sales/contact", nil)
|
||||
req.RemoteAddr = "203.0.113.21:12345"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after sales contact burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAuthSkipsSafeMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
for i := 0; i < 30; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
req.RemoteAddr = "203.0.113.11:12345"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("GET should not be rate-limited, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAdminPlanFeaturesBlocksBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
uid := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
h := s.RateLimitAdminPlanFeatures(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
var saw429 bool
|
||||
for i := 0; i < adminPlanFeaturesGetRPM+5; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/plans/1/features", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after plan-features GET burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAdminAnalyticsBlocksBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
uid := uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc")
|
||||
h := s.RateLimitAdminAnalytics(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
var saw429 bool
|
||||
for i := 0; i < adminAnalyticsGetRPM+5; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/analytics", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after analytics GET burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAdminAnalyticsGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{http.MethodGet, "/api/admin/analytics", true},
|
||||
{http.MethodGet, "/api/admin/analytics/", true},
|
||||
{http.MethodGet, "/api/admin/diagnostics", true},
|
||||
{http.MethodGet, "/api/admin/diagnostics/", true},
|
||||
{http.MethodPost, "/api/admin/analytics", false},
|
||||
{http.MethodGet, "/api/admin/readiness", false},
|
||||
{http.MethodGet, "/api/admin/jobs", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
if got := isAdminAnalyticsGet(req); got != tc.want {
|
||||
t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAIOrMailProbePOST(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{http.MethodPost, "/api/integrations/ai/test", true},
|
||||
{http.MethodPost, "/api/admin/settings/mail/test", true},
|
||||
{http.MethodPost, "/api/admin/settings/ai-roles/support/test", true},
|
||||
{http.MethodGet, "/api/integrations/ai/test", false},
|
||||
{http.MethodPost, "/api/admin/settings", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
if got := isAIOrMailProbePOST(req); got != tc.want {
|
||||
t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAIProbesBlocksBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
uid := uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd")
|
||||
h := s.RateLimitAIProbes(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
var saw429 bool
|
||||
for i := 0; i < aiProbeRPM+5; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/integrations/ai/test", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after AI probe burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAdminPlanFeaturesGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{http.MethodGet, "/api/admin/plans/1/features", true},
|
||||
{http.MethodGet, "/api/admin/plans/99/features/", true},
|
||||
{http.MethodPut, "/api/admin/plans/1/features", false},
|
||||
{http.MethodGet, "/api/admin/plans", false},
|
||||
{http.MethodGet, "/api/admin/feature-gates", false},
|
||||
{http.MethodPost, "/api/admin/plans/1/features/enable-all", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
if got := isAdminPlanFeaturesGet(req); got != tc.want {
|
||||
t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHeavyFeedOrProcessMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/feeds/abc/sync", true},
|
||||
{http.MethodPost, "/api/feeds/abc/sync", true},
|
||||
{http.MethodPost, "/api/v1/feeds/abc/extract-schema", true},
|
||||
{http.MethodPost, "/api/feeds/abc/extract-schema", true},
|
||||
{http.MethodPost, "/api/v1/feeds/abc/sync-process-sample", true},
|
||||
{http.MethodPost, "/api/v1/process", true},
|
||||
{http.MethodPost, "/api/processing/jobs", true},
|
||||
{http.MethodPost, "/api/processing/jobs/abc/retry", true},
|
||||
{http.MethodPost, "/api/v1/process/abc/retry", true},
|
||||
{http.MethodPost, "/api/export-feeds/abc/generate", true},
|
||||
{http.MethodPost, "/api/v1/export-feeds/abc/generate", true},
|
||||
{http.MethodPost, "/api/export-feeds/abc/export-products", true},
|
||||
{http.MethodPost, "/api/v1/export-feeds/abc/export-products", true},
|
||||
{http.MethodGet, "/api/v1/feeds/abc/sync", false},
|
||||
{http.MethodPost, "/api/v1/feeds/abc/mappings", false},
|
||||
{http.MethodPost, "/api/integrations/shopify/sync", false},
|
||||
{http.MethodPost, "/api/woocommerce/sync", false},
|
||||
{http.MethodPost, "/api/processing/jobs/abc/cancel", false},
|
||||
{http.MethodPost, "/api/campaigns/abc/generate", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
if got := isHeavyFeedOrProcessMutation(req); got != tc.want {
|
||||
t.Fatalf("%s %s: got %v want %v", tc.method, tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitV1ProcessBlocksBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
h := s.RateLimitV1Process(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
var saw429 bool
|
||||
for i := 0; i < heavyMutationRPM+5; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/export-feeds/abc/generate", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
if rec.Header().Get("Retry-After") == "" {
|
||||
t.Fatal("expected Retry-After on 429")
|
||||
}
|
||||
if rec.Header().Get("RateLimit") == "" {
|
||||
t.Fatal("expected RateLimit on 429")
|
||||
}
|
||||
if rec.Header().Get("RateLimit-Policy") == "" {
|
||||
t.Fatal("expected RateLimit-Policy on 429")
|
||||
}
|
||||
break
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after heavy mutation burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitEffectiveCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := rateLimitEffectiveCap(30, 1); got != 30 {
|
||||
t.Fatalf("replicas=1 want 30 got %d", got)
|
||||
}
|
||||
if got := rateLimitEffectiveCap(30, 3); got != 10 {
|
||||
t.Fatalf("replicas=3 want 10 got %d", got)
|
||||
}
|
||||
if got := rateLimitEffectiveCap(30, 7); got != 5 {
|
||||
t.Fatalf("replicas=7 want ceil(30/7)=5 got %d", got)
|
||||
}
|
||||
if got := rateLimitEffectiveCap(0, 2); got != 1 {
|
||||
t.Fatalf("base<=0 want 1 got %d", got)
|
||||
}
|
||||
if got := rateLimitEffectiveCap(10, 0); got != 10 {
|
||||
t.Fatalf("replicas<=1 want base got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAuthRespectsReplicas(t *testing.T) {
|
||||
t.Parallel()
|
||||
// ceil(authRegisterRPM/5)=1 → second register must 429
|
||||
s := &Server{Config: config.Config{RateLimitReplicas: authRegisterRPM}}
|
||||
h := s.RateLimitAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil)
|
||||
req1.RemoteAddr = "203.0.113.50:40000"
|
||||
rec1 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec1, req1)
|
||||
if rec1.Code != http.StatusNoContent {
|
||||
t.Fatalf("first status=%d", rec1.Code)
|
||||
}
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil)
|
||||
req2.RemoteAddr = "203.0.113.50:40000"
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second status=%d want 429", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitV1ProcessRespectsReplicas(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{RateLimitReplicas: heavyMutationRPM}}
|
||||
h := s.RateLimitV1Process(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
// ceil(30/30)=1 → second request must 429
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil)
|
||||
rec1 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec1, req1)
|
||||
if rec1.Code != http.StatusNoContent {
|
||||
t.Fatalf("first status=%d", rec1.Code)
|
||||
}
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second status=%d want 429", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitV1ProcessSeparateCompanies(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
h := s.RateLimitV1Process(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
cidA := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
cidB := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
for i := 0; i < heavyMutationRPM; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxCompanyID, cidA))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("company A request %d: status %d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
blocked := httptest.NewRequest(http.MethodPost, "/api/v1/process", nil)
|
||||
blocked = blocked.WithContext(context.WithValue(blocked.Context(), ctxCompanyID, cidA))
|
||||
recBlocked := httptest.NewRecorder()
|
||||
h.ServeHTTP(recBlocked, blocked)
|
||||
if recBlocked.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("company A should be limited, got %d", recBlocked.Code)
|
||||
}
|
||||
okB := httptest.NewRequest(http.MethodPost, "/api/export-feeds/abc/generate", nil)
|
||||
okB = okB.WithContext(context.WithValue(okB.Context(), ctxCompanyID, cidB))
|
||||
recB := httptest.NewRecorder()
|
||||
h.ServeHTTP(recB, okB)
|
||||
if recB.Code != http.StatusNoContent {
|
||||
t.Fatalf("company B should not share A budget, got %d", recB.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitPublicBlocksBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
h := s.RateLimitPublic(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
var saw429 bool
|
||||
for i := 0; i < 40; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/public/unsubscribe", nil)
|
||||
req.RemoteAddr = "203.0.113.50:12345"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after public burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitPublicExportRejectsBadTokenShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
r := chi.NewRouter()
|
||||
r.With(s.RateLimitPublicExport).Get("/export-feeds/{token}.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/export-feeds/short.xml", nil)
|
||||
req.RemoteAddr = "203.0.113.60:1"
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for bad token shape, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitPublicExportBlocksIPBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
r := chi.NewRouter()
|
||||
r.With(s.RateLimitPublicExport).Get("/export-feeds/{token}.xml", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
token := "0123456789abcdef0123456789abcdef"
|
||||
var saw429 bool
|
||||
for i := 0; i < 40; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/export-feeds/"+token+".xml", nil)
|
||||
req.RemoteAddr = "203.0.113.61:1"
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after public export burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAPIKeyAttemptsBlocksBurst(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
h := s.RateLimitAPIKeyAttempts(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
var saw429 bool
|
||||
for i := 0; i < apiKeyAttemptRPM+5; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
|
||||
req.RemoteAddr = "203.0.113.70:12345"
|
||||
req.Header.Set("X-API-Key", "dk_test_key")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after API key attempt burst")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitAPIKeyCompanyBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{}}
|
||||
h := s.RateLimitAPIKey(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
cid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
var saw429 bool
|
||||
for i := 0; i < apiKeyCompanyRPM+5; i++ {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxCompanyID, cid))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusTooManyRequests {
|
||||
saw429 = true
|
||||
break
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("unexpected status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
if !saw429 {
|
||||
t.Fatal("expected 429 after API key company burst")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/i18n"
|
||||
)
|
||||
|
||||
const maxJSONBodyBytes = 2 << 20 // 2 MiB
|
||||
|
||||
var errJSONBodyTooLarge = errors.New("request body too large")
|
||||
var errJSONTrailingContent = errors.New("request body must contain a single JSON object")
|
||||
|
||||
// secretLikeRE matches common secret material that must never appear in logs.
|
||||
var secretLikeRE = regexp.MustCompile(`(?i)(password|passwd|secret|api[_-]?key|token|authorization|bearer|sk_live|sk_test|whsec_)[^\s]{0,64}`)
|
||||
|
||||
func redactForLog(msg string) string {
|
||||
if msg == "" {
|
||||
return msg
|
||||
}
|
||||
return secretLikeRE.ReplaceAllStringFunc(msg, func(m string) string {
|
||||
parts := strings.SplitN(m, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0] + "=[REDACTED]"
|
||||
}
|
||||
if i := strings.IndexByte(m, ':'); i > 0 && i < 24 {
|
||||
return m[:i+1] + "[REDACTED]"
|
||||
}
|
||||
return "[REDACTED]"
|
||||
})
|
||||
}
|
||||
|
||||
func JSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func Error(w http.ResponseWriter, status int, msg string) {
|
||||
JSON(w, status, map[string]string{"error": PublicMessage(w, msg)})
|
||||
}
|
||||
|
||||
// FieldError writes the usual public error string plus an additive field map:
|
||||
//
|
||||
// { "error": "...", "code": "...?", "fields": { "<field>": "..." } }
|
||||
//
|
||||
// `error` stays the localized human message. Optional `code` is a stable
|
||||
// machine token (never translated). `fields` lets dashboards highlight inputs
|
||||
// under any Accept-Language without English substring matching.
|
||||
// Clients that only read `error` keep working (no BREAKING change).
|
||||
func FieldError(w http.ResponseWriter, status int, msg string, code string, fields map[string]string) {
|
||||
localized := PublicMessage(w, msg)
|
||||
out := map[string]any{"error": localized}
|
||||
if code != "" {
|
||||
out["code"] = code
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
lf := make(map[string]string, len(fields))
|
||||
for k, v := range fields {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
text := v
|
||||
if text == "" {
|
||||
text = msg
|
||||
}
|
||||
lf[k] = PublicMessage(w, text)
|
||||
}
|
||||
if len(lf) > 0 {
|
||||
out["fields"] = lf
|
||||
}
|
||||
}
|
||||
JSON(w, status, out)
|
||||
}
|
||||
|
||||
// CodedError writes the legacy public-API error envelope:
|
||||
//
|
||||
// { "error": { "code": "...", "message": "..." } }
|
||||
//
|
||||
// Used for /api/v1 API-key auth failures so clients migrating from Descrybe
|
||||
// see the same shape as err(code, message) in the Next.js app.
|
||||
// Code is never translated; message respects Accept-Language via Locale middleware.
|
||||
func CodedError(w http.ResponseWriter, status int, code, message string) {
|
||||
JSON(w, status, map[string]any{
|
||||
"error": map[string]string{"code": code, "message": PublicMessage(w, message)},
|
||||
})
|
||||
}
|
||||
|
||||
// PublicMessage localizes a client-facing string for the request locale.
|
||||
// Stable machine codes (password_not_set, maintenance, …) stay unchanged.
|
||||
func PublicMessage(w http.ResponseWriter, msg string) string {
|
||||
return i18n.T(localeOf(w), msg)
|
||||
}
|
||||
|
||||
// LogAndError logs the real error server-side (secrets redacted) and returns a safe public message.
|
||||
func LogAndError(w http.ResponseWriter, status int, publicMsg string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("httpapi: %s: %s", publicMsg, redactForLog(err.Error()))
|
||||
}
|
||||
Error(w, status, publicMsg)
|
||||
}
|
||||
|
||||
// ClientOrLog writes a known client message, or logs and returns publicFallback.
|
||||
func ClientOrLog(w http.ResponseWriter, status int, publicFallback string, err error, clientMsg func(error) (string, bool)) {
|
||||
if msg, ok := clientMsg(err); ok {
|
||||
Error(w, status, msg)
|
||||
return
|
||||
}
|
||||
LogAndError(w, status, publicFallback, err)
|
||||
}
|
||||
|
||||
func DecodeJSON(r *http.Request, dst any) error {
|
||||
return decodeJSON(r, dst, true)
|
||||
}
|
||||
|
||||
// DecodeJSONAllowUnknown decodes JSON without DisallowUnknownFields.
|
||||
// Used for legacy public process payloads that may include extra item keys.
|
||||
func DecodeJSONAllowUnknown(r *http.Request, dst any) error {
|
||||
return decodeJSON(r, dst, false)
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dst any, disallowUnknown bool) error {
|
||||
defer r.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBodyBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > maxJSONBodyBytes {
|
||||
return errJSONBodyTooLarge
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
if disallowUnknown {
|
||||
dec.DisallowUnknownFields()
|
||||
}
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return errJSONTrailingContent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DecodeJSONOptional(r *http.Request, dst any) error {
|
||||
err := DecodeJSON(r, dst)
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCodedErrorLegacyEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
CodedError(rec, http.StatusUnauthorized, "unauthorized", "Unauthorized")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("json: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body.Error.Code != "unauthorized" || body.Error.Message != "Unauthorized" {
|
||||
t.Fatalf("got %+v", body.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldErrorAdditiveShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
FieldError(rec, http.StatusUnauthorized, "invalid credentials", "invalid_credentials", map[string]string{
|
||||
"email": "invalid credentials",
|
||||
"password": "invalid credentials",
|
||||
})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("json: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body.Error != "invalid credentials" || body.Code != "invalid_credentials" {
|
||||
t.Fatalf("got error=%q code=%q", body.Error, body.Code)
|
||||
}
|
||||
if body.Fields["email"] != "invalid credentials" || body.Fields["password"] != "invalid credentials" {
|
||||
t.Fatalf("fields=%v", body.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOKLegacyDataMetaEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
OK(rec, http.StatusOK, map[string]any{"id": "x"}, map[string]any{"page": 1, "limit": 25, "total": 10})
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := body["data"].(map[string]any)
|
||||
meta, _ := body["meta"].(map[string]any)
|
||||
if data["id"] != "x" {
|
||||
t.Fatalf("data=%v", data)
|
||||
}
|
||||
if meta["page"].(float64) != 1 || meta["total"].(float64) != 10 {
|
||||
t.Fatalf("meta=%v", meta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeJSONRejectsTrailingContent(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(`{"name":"ok"}{"extra":true}`))
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
err := DecodeJSON(r, &body)
|
||||
if err == nil {
|
||||
t.Fatal("expected trailing content error")
|
||||
}
|
||||
if err != errJSONTrailingContent {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONOptionalAllowsEmptyBody(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/x", http.NoBody)
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := DecodeJSONOptional(r, &body); err != nil {
|
||||
t.Fatalf("expected empty body to be allowed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONOptionalRejectsMalformedJSON(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(`{"name":`))
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := DecodeJSONOptional(r, &body); err == nil {
|
||||
t.Fatal("expected malformed json error")
|
||||
}
|
||||
}
|
||||
|
||||
// SPA start-job payload includes processing_types alongside processing_type.
|
||||
// DecodeJSON DisallowUnknownFields must accept both or POST /api/processing/jobs returns 400.
|
||||
func TestDecodeJSONAcceptsStartJobSPAPayload(t *testing.T) {
|
||||
payload := `{"raw_product_ids":["11111111-1111-1111-1111-111111111111"],"processing_type":"full","processing_types":["category","title"]}`
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/processing/jobs", strings.NewReader(payload))
|
||||
|
||||
var body startProcessingJobRequest
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
t.Fatalf("expected SPA start-job payload to decode, got %v", err)
|
||||
}
|
||||
if body.ProcessingType != "full" {
|
||||
t.Fatalf("processing_type = %q", body.ProcessingType)
|
||||
}
|
||||
if len(body.RawProductIDs) != 1 || len(body.ProcessingTypes) != 2 {
|
||||
t.Fatalf("unexpected body: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONRejectsUnknownStartJobField(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(`{"raw_product_ids":[],"processing_type":"full","unknown":true}`))
|
||||
|
||||
var body startProcessingJobRequest
|
||||
if err := DecodeJSON(r, &body); err == nil {
|
||||
t.Fatal("expected unknown field to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/sales"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) salesSvc() *sales.Service {
|
||||
if s.Sales != nil {
|
||||
return s.Sales
|
||||
}
|
||||
s.Sales = &sales.Service{Pool: s.Pool}
|
||||
return s.Sales
|
||||
}
|
||||
|
||||
type salesContactBody struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
CompanyName string `json:"company_name"`
|
||||
Phone string `json:"phone"`
|
||||
Message string `json:"message"`
|
||||
EstimatedSKUs *int `json:"estimated_skus"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// handleSalesContact is public (CSRF required, session optional).
|
||||
func (s *Server) handleSalesContact(w http.ResponseWriter, r *http.Request) {
|
||||
var body salesContactBody
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
in := sales.CreateLeadInput{
|
||||
Name: body.Name,
|
||||
Email: body.Email,
|
||||
CompanyName: body.CompanyName,
|
||||
Phone: body.Phone,
|
||||
Message: body.Message,
|
||||
EstimatedSKUs: body.EstimatedSKUs,
|
||||
Source: body.Source,
|
||||
}
|
||||
if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil {
|
||||
in.UserID = &uid
|
||||
}
|
||||
if cid, ok := CompanyIDFromContext(r.Context()); ok && cid != uuid.Nil {
|
||||
in.CompanyID = &cid
|
||||
}
|
||||
lead, err := s.salesSvc().CreateLead(r.Context(), in)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not submit contact request", err, sales.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, map[string]any{"lead": lead})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListSalesLeads(w http.ResponseWriter, r *http.Request) {
|
||||
status := r.URL.Query().Get("status")
|
||||
q := r.URL.Query().Get("q")
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
leads, total, err := s.salesSvc().ListLeads(r.Context(), status, q, limit, offset)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusInternalServerError, "could not list sales leads", err, sales.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"leads": leads, "total": total})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetSalesLead(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
lead, err := s.salesSvc().GetLead(r.Context(), id)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusNotFound, "lead not found", err, sales.ClientError)
|
||||
return
|
||||
}
|
||||
quotes, err := s.salesSvc().ListQuotesForLead(r.Context(), id)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusInternalServerError, "could not list quotes", err, sales.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"lead": lead, "quotes": quotes})
|
||||
}
|
||||
|
||||
type adminUpdateSalesLeadBody struct {
|
||||
Status *string `json:"status"`
|
||||
CompanyID *uuid.UUID `json:"company_id"`
|
||||
ClearCompany bool `json:"clear_company"`
|
||||
AdminNotes *string `json:"admin_notes"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateSalesLead(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body adminUpdateSalesLeadBody
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
lead, err := s.salesSvc().UpdateLead(r.Context(), id, sales.UpdateLeadInput{
|
||||
Status: body.Status,
|
||||
CompanyID: body.CompanyID,
|
||||
ClearCompany: body.ClearCompany,
|
||||
AdminNotes: body.AdminNotes,
|
||||
})
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update lead", err, sales.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"lead": lead})
|
||||
}
|
||||
|
||||
type adminCreateSalesQuoteBody struct {
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
PlanName string `json:"plan_name"`
|
||||
MonthlyCredits int `json:"monthly_credits"`
|
||||
MaxProducts *int `json:"max_products"`
|
||||
Currency string `json:"currency"`
|
||||
TotalAmountCents int `json:"total_amount_cents"`
|
||||
InstallmentCount int `json:"installment_count"`
|
||||
InstallmentInterval string `json:"installment_interval"`
|
||||
TermMonths *int `json:"term_months"`
|
||||
PrepareCheckout bool `json:"prepare_checkout"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateSalesQuote(w http.ResponseWriter, r *http.Request) {
|
||||
leadID, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body adminCreateSalesQuoteBody
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
var createdBy *uuid.UUID
|
||||
if uid, ok := UserIDFromContext(r.Context()); ok && uid != uuid.Nil {
|
||||
createdBy = &uid
|
||||
}
|
||||
quote, err := s.salesSvc().CreateQuote(r.Context(), leadID, sales.CreateQuoteInput{
|
||||
CompanyID: body.CompanyID,
|
||||
PlanName: body.PlanName,
|
||||
MonthlyCredits: body.MonthlyCredits,
|
||||
MaxProducts: body.MaxProducts,
|
||||
Currency: body.Currency,
|
||||
TotalAmountCents: body.TotalAmountCents,
|
||||
InstallmentCount: body.InstallmentCount,
|
||||
InstallmentInterval: body.InstallmentInterval,
|
||||
TermMonths: body.TermMonths,
|
||||
CreatedByUserID: createdBy,
|
||||
})
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create quote", err, sales.ClientError)
|
||||
return
|
||||
}
|
||||
if body.PrepareCheckout {
|
||||
quote, err = s.prepareSalesQuoteCheckout(r, quote)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "quote created but checkout failed", err, func(e error) (string, bool) {
|
||||
if msg, ok := sales.ClientError(e); ok {
|
||||
return msg, true
|
||||
}
|
||||
return billing.ClientError(e)
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusCreated, map[string]any{"quote": quote})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPrepareSalesQuoteCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
quoteID, err := uuid.Parse(chi.URLParam(r, "quoteID"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid quote id")
|
||||
return
|
||||
}
|
||||
quote, err := s.salesSvc().GetQuote(r.Context(), quoteID)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusNotFound, "quote not found", err, sales.ClientError)
|
||||
return
|
||||
}
|
||||
quote, err = s.prepareSalesQuoteCheckout(r, quote)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not prepare checkout", err, func(e error) (string, bool) {
|
||||
if msg, ok := sales.ClientError(e); ok {
|
||||
return msg, true
|
||||
}
|
||||
return billing.ClientError(e)
|
||||
})
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quote": quote})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminMarkSalesQuoteSent(w http.ResponseWriter, r *http.Request) {
|
||||
quoteID, err := uuid.Parse(chi.URLParam(r, "quoteID"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid quote id")
|
||||
return
|
||||
}
|
||||
quote, err := s.salesSvc().MarkQuoteSent(r.Context(), quoteID)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not mark quote sent", err, sales.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"quote": quote})
|
||||
}
|
||||
|
||||
func (s *Server) prepareSalesQuoteCheckout(r *http.Request, quote sales.Quote) (sales.Quote, error) {
|
||||
if quote.PlanID == nil || *quote.PlanID <= 0 {
|
||||
return quote, sales.ErrQuoteNotReady
|
||||
}
|
||||
if quote.Status == "paid" || quote.Status == "canceled" {
|
||||
return quote, sales.ErrQuoteNotReady
|
||||
}
|
||||
var companyName, billingEmail string
|
||||
_ = s.Pool.QueryRow(r.Context(), `SELECT name FROM companies WHERE id = $1`, quote.CompanyID).Scan(&companyName)
|
||||
lead, err := s.salesSvc().GetLead(r.Context(), quote.LeadID)
|
||||
if err == nil {
|
||||
billingEmail = lead.Email
|
||||
}
|
||||
res, err := s.stripeSvc().CreateSalesQuoteCheckout(r.Context(), billing.SalesQuoteCheckoutInput{
|
||||
QuoteID: quote.ID,
|
||||
CompanyID: quote.CompanyID,
|
||||
PlanID: *quote.PlanID,
|
||||
PlanName: quote.PlanName,
|
||||
Email: billingEmail,
|
||||
CompanyName: companyName,
|
||||
Currency: quote.Currency,
|
||||
TotalAmountCents: quote.TotalAmountCents,
|
||||
InstallmentCount: quote.InstallmentCount,
|
||||
InstallmentInterval: quote.InstallmentInterval,
|
||||
InstallmentAmountCents: quote.InstallmentAmountCents,
|
||||
})
|
||||
if err != nil {
|
||||
return quote, err
|
||||
}
|
||||
if res.Applied {
|
||||
updated, getErr := s.salesSvc().GetQuote(r.Context(), quote.ID)
|
||||
if getErr != nil {
|
||||
return quote, getErr
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
return s.salesSvc().MarkQuoteCheckoutReady(r.Context(), quote.ID, res.ProductID, res.PriceID, res.SessionID, res.URL)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func TestRouterSalesRoutesMounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
s := &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
}
|
||||
h := s.Router()
|
||||
|
||||
// CSRF rejects anonymous POST without token.
|
||||
contact := httptest.NewRecorder()
|
||||
h.ServeHTTP(contact, httptest.NewRequest(http.MethodPost, "/api/sales/contact", nil))
|
||||
if contact.Code == http.StatusNotFound {
|
||||
t.Fatal("POST /api/sales/contact not mounted")
|
||||
}
|
||||
if contact.Code != http.StatusForbidden {
|
||||
t.Fatalf("contact status=%d want 403 body=%s", contact.Code, contact.Body.String())
|
||||
}
|
||||
|
||||
unauth := httptest.NewRecorder()
|
||||
h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/admin/sales/leads", nil))
|
||||
if unauth.Code == http.StatusNotFound {
|
||||
t.Fatal("GET /api/admin/sales/leads not mounted")
|
||||
}
|
||||
if unauth.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("admin leads status=%d want 401 body=%s", unauth.Code, unauth.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
)
|
||||
|
||||
// TrustedRealIP rewrites RemoteAddr from client IP headers only when the
|
||||
// immediate peer is listed in TRUSTED_PROXIES. Empty allowlist leaves
|
||||
// RemoteAddr unchanged (ignores spoofable X-Forwarded-For / X-Real-IP).
|
||||
func TrustedRealIP(trusted []string) func(http.Handler) http.Handler {
|
||||
nets, err := config.ParseTrustedProxyNets(trusted)
|
||||
if err != nil || len(nets) == 0 {
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isTrustedPeer(r.RemoteAddr, nets) {
|
||||
if rip := clientIPFromProxyHeaders(r); rip != "" {
|
||||
r.RemoteAddr = rip
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// apiContentSecurityPolicy is a strict CSP for JSON API responses (no HTML/scripts).
|
||||
const apiContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
|
||||
|
||||
// SecurityHeaders sets baseline API response headers. HSTS is only emitted
|
||||
// when session cookies are marked Secure (HTTPS deployments).
|
||||
func SecurityHeaders(sessionSecure bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
h.Set("Content-Security-Policy", apiContentSecurityPolicy)
|
||||
if sessionSecure {
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func isTrustedPeer(remoteAddr string, nets []*net.IPNet) bool {
|
||||
ip := peerIP(remoteAddr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range nets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func peerIP(remoteAddr string) net.IP {
|
||||
host := strings.TrimSpace(remoteAddr)
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
return net.ParseIP(host)
|
||||
}
|
||||
|
||||
func clientIPFromProxyHeaders(r *http.Request) string {
|
||||
var ip string
|
||||
if tcip := r.Header.Get("True-Client-IP"); tcip != "" {
|
||||
ip = tcip
|
||||
} else if xrip := r.Header.Get("X-Real-IP"); xrip != "" {
|
||||
ip = xrip
|
||||
} else if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
i := strings.Index(xff, ",")
|
||||
if i == -1 {
|
||||
i = len(xff)
|
||||
}
|
||||
ip = xff[:i]
|
||||
}
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" || net.ParseIP(ip) == nil {
|
||||
return ""
|
||||
}
|
||||
return ip
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTrustedRealIPIgnoresHeadersWithoutAllowlist(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := TrustedRealIP(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.RemoteAddr != "203.0.113.10:12345" {
|
||||
t.Fatalf("RemoteAddr = %q, want peer unchanged", r.RemoteAddr)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
req.RemoteAddr = "203.0.113.10:12345"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedRealIPIgnoresHeadersFromUntrustedPeer(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := TrustedRealIP([]string{"10.0.0.0/8"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.RemoteAddr != "203.0.113.10:12345" {
|
||||
t.Fatalf("RemoteAddr = %q, want peer unchanged", r.RemoteAddr)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
req.RemoteAddr = "203.0.113.10:12345"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedRealIPRewritesFromTrustedPeer(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := TrustedRealIP([]string{"10.0.0.1"})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.RemoteAddr != "198.51.100.1" {
|
||||
t.Fatalf("RemoteAddr = %q, want client IP from XFF", r.RemoteAddr)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
req.RemoteAddr = "10.0.0.1:443"
|
||||
req.Header.Set("X-Forwarded-For", "198.51.100.1, 10.0.0.1")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersBaseline(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := SecurityHeaders(false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Fatalf("X-Content-Type-Options = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
|
||||
t.Fatalf("X-Frame-Options = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" {
|
||||
t.Fatalf("Referrer-Policy = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Security-Policy"); got != apiContentSecurityPolicy {
|
||||
t.Fatalf("Content-Security-Policy = %q, want %q", got, apiContentSecurityPolicy)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Security-Policy-Report-Only"); got != "" {
|
||||
t.Fatalf("unexpected Report-Only CSP: %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
|
||||
t.Fatalf("HSTS unexpectedly set: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersAPIContentSecurityPolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !strings.Contains(apiContentSecurityPolicy, "default-src 'none'") {
|
||||
t.Fatalf("API CSP missing default-src 'none': %q", apiContentSecurityPolicy)
|
||||
}
|
||||
if !strings.Contains(apiContentSecurityPolicy, "frame-ancestors 'none'") {
|
||||
t.Fatalf("API CSP missing frame-ancestors 'none': %q", apiContentSecurityPolicy)
|
||||
}
|
||||
if !strings.Contains(apiContentSecurityPolicy, "form-action 'none'") {
|
||||
t.Fatalf("API CSP missing form-action 'none': %q", apiContentSecurityPolicy)
|
||||
}
|
||||
if strings.Contains(apiContentSecurityPolicy, "'unsafe-inline'") || strings.Contains(apiContentSecurityPolicy, "'unsafe-eval'") {
|
||||
t.Fatalf("API CSP must not allow unsafe script: %q", apiContentSecurityPolicy)
|
||||
}
|
||||
if strings.Contains(apiContentSecurityPolicy, "ws:") || strings.Contains(apiContentSecurityPolicy, "wss:") {
|
||||
t.Fatalf("API CSP must not allow websocket schemes: %q", apiContentSecurityPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersHSTSWhenSecure(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := SecurityHeaders(true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if got := rec.Header().Get("Strict-Transport-Security"); got == "" {
|
||||
t.Fatal("expected HSTS when SessionSecure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSAllowsConfiguredOriginOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
s.Config.WebOrigin = "http://localhost:5174"
|
||||
h := s.Router()
|
||||
|
||||
ok := httptest.NewRecorder()
|
||||
reqOK := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||
reqOK.Header.Set("Origin", "http://localhost:5174")
|
||||
reqOK.Header.Set("Access-Control-Request-Method", "POST")
|
||||
h.ServeHTTP(ok, reqOK)
|
||||
if got := ok.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5174" {
|
||||
t.Fatalf("allow origin = %q", got)
|
||||
}
|
||||
if got := ok.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||
t.Fatalf("allow credentials = %q", got)
|
||||
}
|
||||
|
||||
twin := httptest.NewRecorder()
|
||||
reqTwin := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||
reqTwin.Header.Set("Origin", "http://127.0.0.1:5174")
|
||||
reqTwin.Header.Set("Access-Control-Request-Method", "POST")
|
||||
h.ServeHTTP(twin, reqTwin)
|
||||
if got := twin.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:5174" {
|
||||
t.Fatalf("loopback twin allow origin = %q", got)
|
||||
}
|
||||
|
||||
bad := httptest.NewRecorder()
|
||||
reqBad := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||
reqBad.Header.Set("Origin", "https://evil.example")
|
||||
reqBad.Header.Set("Access-Control-Request-Method", "POST")
|
||||
h.ServeHTTP(bad, reqBad)
|
||||
if got := bad.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Fatalf("unexpected allow origin for evil: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/seo"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleSEORecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok || cid == uuid.Nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if s.SEO == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "seo service unavailable")
|
||||
return
|
||||
}
|
||||
report, err := s.SEO.Recommendations(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "seo analysis failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, report)
|
||||
}
|
||||
|
||||
func (s *Server) handleSEOApply(w http.ResponseWriter, r *http.Request) {
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok || cid == uuid.Nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
if s.SEO == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "seo service unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
var body seo.ApplyRequest
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
productID, err := uuid.Parse(strings.TrimSpace(body.ProductID))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid product_id")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.SEO.Apply(r.Context(), cid, productID, body.Mode)
|
||||
if err != nil {
|
||||
switch {
|
||||
case writePlanGate(w, err):
|
||||
return
|
||||
case errors.Is(err, seo.ErrNotFound):
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
default:
|
||||
ClientOrLog(w, http.StatusBadRequest, "seo apply failed", err, seo.ClientError)
|
||||
return
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/campaigns"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/jobs"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
|
||||
"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/descrybe/descrybe-v2/apps/api/internal/sales"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/seo"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/shopify"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Config config.Config
|
||||
Pool *pgxpool.Pool
|
||||
Sessions *scs.SessionManager
|
||||
Auth *auth.Service
|
||||
Catalog *catalog.Service
|
||||
Feeds *feeds.Service
|
||||
Billing *billing.Service
|
||||
Stripe *billing.StripeService
|
||||
Processing *processing.Pipeline
|
||||
SEO *seo.Service
|
||||
Jobs *jobs.Queue
|
||||
Woo *woocommerce.Service
|
||||
Shopify *shopify.Service
|
||||
Mail mail.Mailer
|
||||
Email *email.Service
|
||||
AI *aiprovider.Service
|
||||
AIPrompts *aiprompts.Service
|
||||
Campaigns *campaigns.Service
|
||||
Support *support.Service
|
||||
Sales *sales.Service
|
||||
PlatformSettings *platformsettings.Service
|
||||
|
||||
// testPlatformAdmin optional override for RequirePlatformAdmin unit tests.
|
||||
testPlatformAdmin func(ctx context.Context, userID uuid.UUID) (bool, error)
|
||||
// testStaffAccess optional override for RequireSupportDesk / RequirePlatformAdmin tests.
|
||||
testStaffAccess func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error)
|
||||
// testAssertFeatures optional override for requireFeatures / plan-gate unit tests.
|
||||
testAssertFeatures func(ctx context.Context, keys ...string) error
|
||||
// testUserActive optional override for RequireSession active-account checks.
|
||||
testUserActive func(ctx context.Context, userID uuid.UUID) (active bool, err error)
|
||||
// testUserSessionState optional override for RequireSession active+version checks.
|
||||
testUserSessionState func(ctx context.Context, userID uuid.UUID) (auth.UserSessionState, error)
|
||||
|
||||
// Optional overrides for legacy POST /products/process unit tests.
|
||||
testEnsureRawV1Items func(ctx context.Context, companyID uuid.UUID, items []catalog.V1ProcessItem) (ids []uuid.UUID, results []catalog.EnsureRawResult, errs []string, err error)
|
||||
testStartJobs func(ctx context.Context, companyID, userID uuid.UUID, rawIDs []uuid.UUID, processingType string) ([]processing.Job, error)
|
||||
testEnqueueJob func(ctx context.Context, jobID uuid.UUID) error
|
||||
testGetJob func(ctx context.Context, companyID, id uuid.UUID) (processing.Job, error)
|
||||
testLoadV1ProcessJobItems func(ctx context.Context, companyID, jobID uuid.UUID, processingType string) ([]processing.V1ProcessJobItem, error)
|
||||
|
||||
adminSetPasswordOnce sync.Once
|
||||
adminSetPasswordReqRL *slidingWindowLimiter
|
||||
adminSetPasswordSendRL *slidingWindowLimiter
|
||||
|
||||
forgotPasswordOnce sync.Once
|
||||
forgotPasswordIPRL *slidingWindowLimiter
|
||||
forgotPasswordEmailRL *slidingWindowLimiter
|
||||
|
||||
// loginLockout is email-keyed failed-password lockout (in-process; see login_lockout.go).
|
||||
loginLockoutOnce sync.Once
|
||||
loginLockout *loginAttemptLockout
|
||||
}
|
||||
|
||||
func NewServer(
|
||||
cfg config.Config,
|
||||
pool *pgxpool.Pool,
|
||||
sessions *scs.SessionManager,
|
||||
) *Server {
|
||||
billingSvc := &billing.Service{Pool: pool}
|
||||
emailSvc := email.NewService(pool, email.EnvConfig{
|
||||
AppEncryptionKey: cfg.AppEncryptionKey,
|
||||
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
|
||||
TokenSigningSecret: cfg.TokenSigningSecret,
|
||||
DatabaseURL: cfg.DatabaseURL,
|
||||
PublicAPIURL: cfg.PublicAPIURL,
|
||||
WebOrigin: cfg.WebOrigin,
|
||||
EmailDryRun: cfg.EmailDryRun,
|
||||
ResendAPIKey: cfg.ResendAPIKey,
|
||||
SMTPHost: cfg.SMTPHost,
|
||||
SMTPPort: cfg.SMTPPort,
|
||||
SMTPUser: cfg.SMTPUser,
|
||||
SMTPPassword: cfg.SMTPPassword,
|
||||
SMTPFrom: cfg.SMTPFrom,
|
||||
SendRPM: cfg.EmailSendRPM,
|
||||
SendRPH: cfg.EmailSendRPH,
|
||||
})
|
||||
aiSvc := aiprovider.NewService(pool, aiprovider.EnvConfig{
|
||||
AppEncryptionKey: cfg.AppEncryptionKey,
|
||||
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
|
||||
TokenSigningSecret: cfg.TokenSigningSecret,
|
||||
DatabaseURL: cfg.DatabaseURL,
|
||||
OpenAIAPIKey: cfg.OpenAIAPIKey,
|
||||
OpenAIBaseURL: cfg.OpenAIBaseURL,
|
||||
OpenAIModel: cfg.OpenAIModel,
|
||||
ProcessingRPM: cfg.ProcessingRPM,
|
||||
ProcessingMaxRetries: cfg.ProcessingMaxRetries,
|
||||
})
|
||||
promptSvc := aiprompts.NewService(pool)
|
||||
platformSettings := platformsettings.NewService(pool, platformsettings.EnvConfig{
|
||||
AppEncryptionKey: cfg.AppEncryptionKey,
|
||||
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
|
||||
TokenSigningSecret: cfg.TokenSigningSecret,
|
||||
DatabaseURL: cfg.DatabaseURL,
|
||||
OpenAIAPIKey: cfg.OpenAIAPIKey,
|
||||
OpenAIBaseURL: cfg.OpenAIBaseURL,
|
||||
OpenAIModel: cfg.OpenAIModel,
|
||||
OpenAIEmbeddingAPIKey: cfg.OpenAIEmbeddingAPIKey,
|
||||
OpenAIEmbeddingBaseURL: cfg.OpenAIEmbeddingBaseURL,
|
||||
OpenAIEmbeddingModel: cfg.OpenAIEmbeddingModel,
|
||||
SMTPEnabled: cfg.SMTPEnabled,
|
||||
SMTPHost: cfg.SMTPHost,
|
||||
SMTPPort: cfg.SMTPPort,
|
||||
SMTPUser: cfg.SMTPUser,
|
||||
SMTPPassword: cfg.SMTPPassword,
|
||||
SMTPFrom: cfg.SMTPFrom,
|
||||
ResendAPIKey: cfg.ResendAPIKey,
|
||||
EmailDryRun: cfg.EmailDryRun,
|
||||
EmailDryRunSet: cfg.EmailDryRunSet,
|
||||
StripeSecretKey: cfg.StripeSecretKey,
|
||||
StripeWebhookSecret: cfg.StripeWebhookSecret,
|
||||
StripeMock: cfg.StripeMock,
|
||||
StripePriceIDs: cfg.StripePriceIDs,
|
||||
EPRELEnabled: cfg.EPRELEnabled,
|
||||
EPRELBaseURL: cfg.EPRELBaseURL,
|
||||
EPRELTimeout: cfg.EPRELTimeout,
|
||||
EPRELFicheLanguage: cfg.EPRELFicheLanguage,
|
||||
EPRELAPIKey: cfg.EPRELAPIKey,
|
||||
PineconeAPIKey: cfg.PineconeAPIKey,
|
||||
PineconeHost: cfg.PineconeHost,
|
||||
PineconeNamespace: cfg.PineconeNamespace,
|
||||
})
|
||||
aiSvc.Platform = platformSettings
|
||||
emailSvc.Platform = platformSettings
|
||||
bootCtx, bootCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer bootCancel()
|
||||
if csv, err := platformSettings.ResolveFeedPrivateAllowlist(bootCtx); err == nil {
|
||||
feeds.ApplyPrivateAllowlistCSV(csv)
|
||||
}
|
||||
|
||||
// OpenAI is resolved per request/job via aiprovider → platformsettings.ResolveOpenAI
|
||||
// (no boot-time client snapshot).
|
||||
_ = seo.EnsureCost(context.Background(), pool)
|
||||
campaignSvc := campaigns.NewService(pool, billingSvc, emailSvc)
|
||||
campaignSvc.WebOrigin = cfg.WebOrigin
|
||||
campaignSvc.PublicAPIURL = cfg.PublicAPIURL
|
||||
campaignSvc.TokenSigningSecret = cfg.TokenSigningSecret
|
||||
campaignSvc.AI = aiSvc
|
||||
campaignSvc.Prompts = promptSvc
|
||||
pipeline := processing.NewPipeline(pool)
|
||||
pipeline.AI = aiSvc
|
||||
pipeline.Prompts = promptSvc
|
||||
pipeline.Engine = &processing.Engine{
|
||||
Vector: &platformsettings.DynamicPinecone{Settings: platformSettings},
|
||||
ProviderMode: processing.AIProviderInternal,
|
||||
}
|
||||
s := &Server{
|
||||
Config: cfg,
|
||||
Pool: pool,
|
||||
Sessions: sessions,
|
||||
Auth: &auth.Service{Pool: pool},
|
||||
Catalog: &catalog.Service{Pool: pool},
|
||||
Feeds: &feeds.Service{Pool: pool, UploadDir: cfg.UploadDir},
|
||||
Billing: billingSvc,
|
||||
Stripe: &billing.StripeService{
|
||||
Pool: pool,
|
||||
Billing: billingSvc,
|
||||
Cfg: billing.StripeConfig{
|
||||
SecretKey: cfg.StripeSecretKey,
|
||||
WebhookSecret: cfg.StripeWebhookSecret,
|
||||
WebOrigin: cfg.WebOrigin,
|
||||
PublicAPIURL: cfg.PublicAPIURL,
|
||||
PriceIDs: cfg.StripePriceIDs,
|
||||
ForceMock: cfg.StripeMock,
|
||||
},
|
||||
ResolveCfg: platformSettings.ResolveStripe,
|
||||
},
|
||||
Processing: pipeline,
|
||||
SEO: &seo.Service{
|
||||
Pool: pool,
|
||||
Billing: billingSvc,
|
||||
AI: aiSvc,
|
||||
Prompts: promptSvc,
|
||||
},
|
||||
Jobs: jobs.NewQueue(pool),
|
||||
Woo: woocommerce.NewService(pool, woocommerce.DeriveKey(
|
||||
firstNonEmpty(cfg.AppEncryptionKey, cfg.CredentialsEncryptionKey, cfg.TokenSigningSecret),
|
||||
cfg.DatabaseURL,
|
||||
)),
|
||||
Shopify: shopify.NewService(pool, shopify.DeriveKey(
|
||||
firstNonEmpty(cfg.AppEncryptionKey, cfg.CredentialsEncryptionKey, cfg.TokenSigningSecret),
|
||||
cfg.DatabaseURL,
|
||||
)),
|
||||
Mail: mail.NewDynamic(func() (mail.Config, error) {
|
||||
ctx := context.Background()
|
||||
dry, err := platformSettings.ResolveEmailDryRun(ctx)
|
||||
if err != nil {
|
||||
return mail.Config{}, err
|
||||
}
|
||||
resolved, err := platformSettings.ResolveSMTP(ctx)
|
||||
if err != nil {
|
||||
return mail.Config{}, err
|
||||
}
|
||||
return mail.ApplyDryRun(dry.DryRun, mail.ConfigFromParts(
|
||||
resolved.Enabled,
|
||||
resolved.Host,
|
||||
resolved.Port,
|
||||
resolved.User,
|
||||
resolved.Password,
|
||||
resolved.From,
|
||||
)), nil
|
||||
}),
|
||||
Email: emailSvc,
|
||||
AI: aiSvc,
|
||||
AIPrompts: promptSvc,
|
||||
Campaigns: campaignSvc,
|
||||
Support: support.NewService(pool),
|
||||
Sales: &sales.Service{Pool: pool},
|
||||
PlatformSettings: platformSettings,
|
||||
}
|
||||
if s.Support != nil {
|
||||
s.Support.SupportAI = support.NewCompleterSupportAI(aiSvc)
|
||||
s.Support.AIRateLimiter = support.NewAIRateLimiter(0, 0)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(TrustedRealIP(s.Config.TrustedProxies))
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(metrics.Middleware)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(chimw.Timeout(60 * time.Second))
|
||||
r.Use(SecurityHeaders(s.Config.SessionSecure))
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: config.CORSAllowedOrigins(s.Config.WebOrigin),
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Accept-Language", "Authorization", "Content-Type", "X-API-Key", "X-CSRF-Token", "X-Company-ID"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
// After CORS so handlers see localeResponseWriter as the immediate writer.
|
||||
r.Use(Locale)
|
||||
|
||||
// Liveness / readiness / metrics — no session dependency
|
||||
r.Get("/healthz", s.handleHealthz)
|
||||
r.Get("/readyz", s.handleReadyz)
|
||||
metricsH := metrics.Gate(s.Config.IsProduction(), s.Config.MetricsPublic)(metrics.Handler())
|
||||
r.Method(http.MethodGet, "/metrics", metricsH)
|
||||
r.Method(http.MethodHead, "/metrics", metricsH)
|
||||
|
||||
// Public API-key surface: no session/CSRF; maintenance still applies.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.MaintenanceGate)
|
||||
s.mountV1(r)
|
||||
})
|
||||
|
||||
// Public token/HMAC routes (no session/CSRF).
|
||||
// Mounted at /api/public BEFORE authenticated /api so unmatched public paths
|
||||
// return 404 instead of falling into RequireSession (401).
|
||||
r.Route("/api/public", func(r chi.Router) {
|
||||
r.Use(s.MaintenanceGate)
|
||||
r.Use(s.RateLimitPublic)
|
||||
r.Get("/plans", s.handleListPublicPlans)
|
||||
r.Get("/credit-packs", s.handleListCreditPacks)
|
||||
r.Get("/brand-logo/{companyID}/{filename}", s.handlePublicBrandLogo)
|
||||
r.Get("/support-kb/{filename}", s.handlePublicKBImage)
|
||||
r.With(s.RateLimitPublicExport).Get("/export-feeds/{token}.xml", s.handlePublicExportXML)
|
||||
r.With(s.RateLimitPublicExport).Get("/export-feeds/{token}.csv", s.handlePublicExportCSV)
|
||||
r.Get("/unsubscribe", s.handlePublicUnsubscribeGet)
|
||||
r.Post("/unsubscribe", s.handlePublicUnsubscribePost)
|
||||
r.NotFound(func(w http.ResponseWriter, _ *http.Request) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
})
|
||||
r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
})
|
||||
})
|
||||
|
||||
// Stripe webhooks (signature-verified; no session/CSRF).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.MaintenanceGate)
|
||||
r.Post("/api/webhooks/stripe", s.handleStripeWebhook)
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
// Maintenance/read-only before session+CSRF so freeze returns 503 (not csrf 403).
|
||||
r.Use(s.MaintenanceGate)
|
||||
r.Use(LoadSession(s.Sessions))
|
||||
r.Use(s.CSRF)
|
||||
|
||||
r.Route("/api/auth", func(r chi.Router) {
|
||||
r.Use(s.RateLimitAuth)
|
||||
r.Post("/register", s.handleRegister)
|
||||
r.Post("/login", s.handleLogin)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/forgot-password", s.handleForgotPassword)
|
||||
r.Post("/reset-password", s.handleResetPassword)
|
||||
r.Post("/invite-preview", s.handleInvitePreview)
|
||||
r.Post("/accept-invite", s.handleAcceptInvite)
|
||||
r.Post("/complete-set-password", s.handleCompleteSetPassword)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.RequireSession)
|
||||
r.Get("/me", s.handleMe)
|
||||
r.Patch("/me", s.handleUpdateProfile)
|
||||
r.Post("/set-password", s.handleSetPassword)
|
||||
r.Post("/change-password", s.handleChangePassword)
|
||||
r.Post("/select-company", s.handleSelectCompany)
|
||||
})
|
||||
})
|
||||
|
||||
// Public sales contact (CSRF + rate limit; session optional for company/user attach).
|
||||
r.With(s.RateLimitAuth).Post("/api/sales/contact", s.handleSalesContact)
|
||||
|
||||
r.Route("/api/admin", func(r chi.Router) {
|
||||
r.Use(s.RequireSession)
|
||||
|
||||
// Non-prod only: user switch / impersonation (handlers also fail closed).
|
||||
if !s.Config.IsProduction() {
|
||||
r.Get("/dev/switchable-users", s.handleAdminDevListSwitchableUsers)
|
||||
r.Post("/dev/stop-impersonate", s.handleAdminDevStopImpersonate)
|
||||
r.Post("/users/{id}/impersonate", s.handleAdminDevImpersonate)
|
||||
}
|
||||
|
||||
// Support desk: full admin OR support_staff (least privilege).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.RequireSupportDesk)
|
||||
r.Get("/support/tickets", s.handleAdminListSupportTickets)
|
||||
r.Get("/support/tickets/{id}", s.handleAdminGetSupportTicket)
|
||||
r.Post("/support/tickets/{id}/messages", s.handleAdminReplySupportTicket)
|
||||
r.Patch("/support/tickets/{id}", s.handleAdminUpdateSupportTicket)
|
||||
r.Post("/support/tickets/{id}/claim", s.handleAdminClaimSupportTicket)
|
||||
r.Post("/support/tickets/{id}/release", s.handleAdminReleaseSupportTicket)
|
||||
r.Post("/support/tickets/{id}/ai-draft/approve", s.handleAdminApproveSupportAIDraft)
|
||||
r.Post("/support/tickets/{id}/ai-draft/discard", s.handleAdminDiscardSupportAIDraft)
|
||||
r.Get("/support/agents", s.handleAdminListSupportAgents)
|
||||
})
|
||||
|
||||
// Full platform admin only (billing, settings, users, plan features).
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.RequirePlatformAdmin)
|
||||
r.Use(s.RateLimitAdminPlanFeatures)
|
||||
r.Use(s.RateLimitAdminAnalytics)
|
||||
r.Use(s.RateLimitAIProbes)
|
||||
r.Get("/support/csat", s.handleAdminSupportCSATAggregate)
|
||||
r.Get("/support/kb/articles", s.handleAdminListKBArticles)
|
||||
r.Post("/support/kb/articles", s.handleAdminCreateKBArticle)
|
||||
r.Get("/support/kb/articles/{id}", s.handleAdminGetKBArticle)
|
||||
r.Patch("/support/kb/articles/{id}", s.handleAdminUpdateKBArticle)
|
||||
r.Delete("/support/kb/articles/{id}", s.handleAdminDeleteKBArticle)
|
||||
r.Get("/support/kb/categories", s.handleAdminListKBCategories)
|
||||
r.Post("/support/kb/images", s.handleAdminUploadKBImage)
|
||||
r.Get("/support/kb/images/{filename}", s.handleAdminGetKBImage)
|
||||
r.Get("/support/templates", s.handleAdminListReplyTemplates)
|
||||
r.Post("/support/templates", s.handleAdminCreateReplyTemplate)
|
||||
r.Get("/support/templates/{id}", s.handleAdminGetReplyTemplate)
|
||||
r.Patch("/support/templates/{id}", s.handleAdminUpdateReplyTemplate)
|
||||
r.Delete("/support/templates/{id}", s.handleAdminDeleteReplyTemplate)
|
||||
r.Get("/support/auto-config", s.handleAdminGetSupportAutoConfig)
|
||||
r.Put("/support/auto-config", s.handleAdminPutSupportAutoConfig)
|
||||
r.Get("/users", s.handleAdminListUsers)
|
||||
r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole)
|
||||
r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent)
|
||||
r.Get("/staff", s.handleAdminListStaff)
|
||||
if !s.Config.IsProduction() {
|
||||
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
|
||||
}
|
||||
r.Get("/companies", s.handleAdminListCompanies)
|
||||
r.Get("/readiness", s.handleAdminReadiness)
|
||||
r.Get("/diagnostics", s.handleAdminDiagnostics)
|
||||
r.Get("/analytics", s.handleAdminAnalytics)
|
||||
r.Get("/jobs", s.handleAdminListJobs)
|
||||
r.Post("/jobs/stuck-cleanup", s.handleAdminStuckCleanup)
|
||||
r.Get("/jobs/orphan-processed", s.handleAdminOrphanProcessedReport)
|
||||
r.Post("/jobs/orphan-processed-cleanup", s.handleAdminOrphanProcessedCleanup)
|
||||
r.Get("/stores/reconnect-needed", s.handleAdminListStoreReconnectGaps)
|
||||
r.Get("/settings", s.handleGetAdminSettings)
|
||||
r.Put("/settings", s.handlePutAdminSettings)
|
||||
r.Post("/settings/mail/test", s.handleAdminTestMail)
|
||||
r.Post("/settings/ai-roles/{role}/test", s.handleAdminTestAIRole)
|
||||
r.Post("/settings/stripe/sync-credit-packs", s.handleAdminSyncStripeCreditPacks)
|
||||
r.Get("/plans", s.handleListPlans)
|
||||
r.Post("/plans", s.handleUpsertPlan)
|
||||
r.Get("/plans/{planID}/features", s.handleAdminGetPlanFeatures)
|
||||
r.Put("/plans/{planID}/features", s.handleAdminPutPlanFeatures)
|
||||
r.Post("/plans/{planID}/features/enable-all", s.handleAdminEnableAllPlanFeatures)
|
||||
r.Post("/plans/{planID}/features/disable-all", s.handleAdminDisableAllPlanFeatures)
|
||||
r.Get("/feature-gates", s.handleAdminGetFeatureGates)
|
||||
r.Put("/feature-gates", s.handleAdminPutFeatureGates)
|
||||
r.Put("/feature-gates/sections/{section}", s.handleAdminPutFeatureGateSection)
|
||||
r.Post("/plans/assign", s.handleAssignPlan)
|
||||
r.Post("/credits", s.handleAddCredits)
|
||||
r.Post("/billing/run-cycles", s.handleRunBillingCycles)
|
||||
r.Post("/emails/set-password", s.handleAdminSendSetPasswordEmails)
|
||||
r.Get("/sales/leads", s.handleAdminListSalesLeads)
|
||||
r.Get("/sales/leads/{id}", s.handleAdminGetSalesLead)
|
||||
r.Patch("/sales/leads/{id}", s.handleAdminUpdateSalesLead)
|
||||
r.Post("/sales/leads/{id}/quotes", s.handleAdminCreateSalesQuote)
|
||||
r.Post("/sales/quotes/{quoteID}/checkout", s.handleAdminPrepareSalesQuoteCheckout)
|
||||
r.Post("/sales/quotes/{quoteID}/mark-sent", s.handleAdminMarkSalesQuoteSent)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(s.RequireSession)
|
||||
r.Use(s.RequireCompany)
|
||||
r.Use(s.RateLimitMarketing)
|
||||
r.Use(s.RateLimitAIProbes)
|
||||
r.Use(s.RateLimitV1Process)
|
||||
|
||||
r.Get("/company", s.handleGetCompany)
|
||||
r.Patch("/company", s.handleUpdateCompany)
|
||||
r.Get("/company/settings", s.handleGetCompanySettings)
|
||||
r.Put("/company/settings", s.handlePutCompanySettings)
|
||||
r.Get("/brand", s.handleGetBrand)
|
||||
r.Put("/brand", s.handlePutBrand)
|
||||
r.Post("/brand/logo", s.handleUploadBrandLogo)
|
||||
r.Get("/brand/logo/files/{filename}", s.handleGetBrandLogoFile)
|
||||
r.Get("/team", s.handleListTeam)
|
||||
r.Post("/team/invites", s.handleCreateInvite)
|
||||
r.Get("/team/invites", s.handleListInvites)
|
||||
r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite)
|
||||
r.Patch("/team/{userID}", s.handleUpdateMemberRole)
|
||||
r.Delete("/team/{userID}", s.handleRemoveMember)
|
||||
|
||||
r.Get("/api-keys", s.handleListAPIKeys)
|
||||
r.Post("/api-keys", s.handleCreateAPIKey)
|
||||
r.Delete("/api-keys/{id}", s.handleRevokeAPIKey)
|
||||
|
||||
r.Get("/billing/credits", s.handleCreditsOverview)
|
||||
r.Get("/billing/capabilities", s.handleGetCapabilities)
|
||||
r.Get("/billing/usage", s.handleBillingUsage)
|
||||
r.Get("/billing/plans", s.handleListPublicPlans)
|
||||
r.Get("/billing/credit-packs", s.handleListCreditPacks)
|
||||
r.Get("/billing/stripe", s.handleStripeStatus)
|
||||
r.Post("/billing/checkout", s.handleStripeCheckout)
|
||||
r.Post("/billing/portal", s.handleStripePortal)
|
||||
|
||||
r.Get("/field-groups", s.handleListFieldGroups)
|
||||
r.Post("/field-groups", s.handleCreateFieldGroup)
|
||||
r.Patch("/field-groups/{id}", s.handleUpdateFieldGroup)
|
||||
r.Delete("/field-groups/{id}", s.handleDeleteFieldGroup)
|
||||
|
||||
r.Get("/standard-fields", s.handleListStandardFields)
|
||||
r.Post("/standard-fields", s.handleCreateStandardField)
|
||||
r.Post("/standard-fields/bulk-enable", s.handleBulkStandardFieldsEnabled)
|
||||
r.Post("/standard-fields/enable-recommended", s.handleEnableRecommendedStandardFields)
|
||||
r.Patch("/standard-fields/{id}", s.handleUpdateStandardField)
|
||||
r.Delete("/standard-fields/{id}", s.handleDeleteStandardField)
|
||||
|
||||
r.Get("/structured-descriptions", s.handleListStructuredDescriptions)
|
||||
r.Post("/structured-descriptions", s.handleCreateStructuredDescription)
|
||||
r.Delete("/structured-descriptions/{id}", s.handleDeleteStructuredDescription)
|
||||
|
||||
r.Post("/vector-categories/create-index", s.handleVectorCreateIndex)
|
||||
r.Post("/vector-categories/initialize", s.handleVectorInitialize)
|
||||
r.Post("/vector-categories/search", s.handleVectorSearch)
|
||||
|
||||
r.Get("/categories", s.handleListCategories)
|
||||
r.Post("/categories", s.handleCreateCategory)
|
||||
r.Post("/categories/import", s.handleImportCSV)
|
||||
r.Post("/categories/upload", s.handleImportCSV) // legacy/pixel alias
|
||||
r.Get("/categories/{id}", s.handleGetCategory)
|
||||
r.Patch("/categories/{id}", s.handleUpdateCategory)
|
||||
r.Delete("/categories/{id}", s.handleDeleteCategory)
|
||||
r.Patch("/categories/{id}/title-formula", s.handleUpdateTitleFormula)
|
||||
r.Patch("/categories/{id}/description-formula", s.handleUpdateDescriptionFormula)
|
||||
r.Patch("/categories/{id}/prompt", s.handleUpdateCategoryPrompt)
|
||||
r.Get("/categories/{id}/attributes", s.handleListCategoryAttributes)
|
||||
r.Put("/categories/{id}/attributes", s.handlePutCategoryAttributes)
|
||||
r.Post("/categories/{id}/attributes", s.handleLinkCategoryAttribute)
|
||||
r.Delete("/categories/{id}/attributes/{attributeID}", s.handleUnlinkCategoryAttribute)
|
||||
|
||||
r.Get("/variables", s.handleListVariables)
|
||||
r.Post("/variables", s.handleCreateVariable)
|
||||
r.Delete("/variables/{id}", s.handleDeleteVariable)
|
||||
|
||||
r.Get("/attributes", s.handleListAttributes)
|
||||
r.Post("/attributes", s.handleCreateAttribute)
|
||||
r.Post("/attributes/import", s.handleImportCSV)
|
||||
r.Post("/attributes/upload", s.handleImportCSV) // legacy/pixel alias
|
||||
r.Patch("/attributes/{id}", s.handleUpdateAttribute)
|
||||
r.Delete("/attributes/{id}", s.handleDeleteAttribute)
|
||||
|
||||
r.Get("/files", s.handleListFiles)
|
||||
r.Delete("/files/{id}", s.handleDeleteFile)
|
||||
|
||||
r.Get("/products", s.handleListProducts)
|
||||
r.Get("/products/quality", s.handleListProductQuality)
|
||||
r.Post("/products/import", s.handleImportCSV)
|
||||
r.Post("/products/upload", s.handleImportCSV) // legacy/pixel alias
|
||||
r.Post("/products/upload-eans", s.handleImportCSV) // legacy EAN CSV alias
|
||||
r.Post("/products/reset", s.handleResetProducts)
|
||||
r.Get("/products/{id}", s.handleGetProduct)
|
||||
r.Patch("/products/{id}", s.handleUpdateProduct)
|
||||
|
||||
r.Get("/seo/recommendations", s.handleSEORecommendations)
|
||||
r.Post("/seo/apply", s.handleSEOApply)
|
||||
|
||||
// Content calendar (seasonal export prep) — NOT email campaigns (/api/campaigns).
|
||||
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
|
||||
r.Post("/marketing/calendar/prepare", s.handlePrepareMarketingCalendar)
|
||||
|
||||
r.Get("/feeds", s.handleListFeeds)
|
||||
r.Post("/feeds", s.handleCreateFeed)
|
||||
r.Get("/feeds/{id}", s.handleGetFeed)
|
||||
r.Patch("/feeds/{id}", s.handleUpdateFeed)
|
||||
r.Delete("/feeds/{id}", s.handleDeleteFeed)
|
||||
r.Post("/feeds/{id}/sync", s.handleSyncFeed)
|
||||
r.Get("/feeds/{id}/sync-jobs", s.handleListSyncJobs)
|
||||
r.Get("/feeds/{id}/sync-jobs/{jobID}", s.handleGetSyncJob)
|
||||
r.Get("/feeds/{id}/mappings", s.handleGetFeedMappings)
|
||||
r.Put("/feeds/{id}/mappings", s.handlePutFeedMappings)
|
||||
r.Post("/feeds/{id}/extract-schema", s.handleExtractFeedSchema)
|
||||
r.Post("/feeds/{id}/sync-process-sample", s.handleSyncAndProcessSample)
|
||||
|
||||
r.Get("/export-feeds", s.handleListExportFeeds)
|
||||
r.Post("/export-feeds", s.handleCreateExportFeed)
|
||||
r.Get("/export-feeds/{id}", s.handleGetExportFeed)
|
||||
r.Patch("/export-feeds/{id}", s.handleUpdateExportFeed)
|
||||
r.Put("/export-feeds/{id}/template", s.handleUpdateExportFeedTemplate)
|
||||
r.Delete("/export-feeds/{id}", s.handleDeleteExportFeed)
|
||||
r.Post("/export-feeds/{id}/rotate-token", s.handleRotateExportFeedPublicToken)
|
||||
r.Post("/export-feeds/{id}/generate", s.handleGenerateExportFeed)
|
||||
r.Post("/export-feeds/{id}/export-products", s.handleExportSelectedProducts)
|
||||
|
||||
r.Post("/processing/jobs", s.handleStartProcessingJob)
|
||||
r.Get("/processing/jobs", s.handleListProcessingJobs)
|
||||
r.Get("/processing/jobs/{id}", s.handleGetProcessingJob)
|
||||
r.Post("/processing/jobs/{id}/cancel", s.handleCancelProcessingJob)
|
||||
r.Post("/processing/jobs/{id}/terminate", s.handleCancelProcessingJob) // pixel naming alias
|
||||
r.Post("/processing/jobs/{id}/retry", s.handleRetryProcessingJob)
|
||||
|
||||
r.Get("/woocommerce", s.handleGetWooConfig)
|
||||
r.Put("/woocommerce", s.handleUpdateWooConfig)
|
||||
r.Put("/woocommerce/maps", s.handleUpdateWooMaps)
|
||||
r.Put("/woocommerce/schedule", s.handleUpdateWooSchedule)
|
||||
r.Get("/woocommerce/remote-maps", s.handleFetchWooRemoteMaps)
|
||||
r.Post("/woocommerce/test", s.handleTestWoo)
|
||||
r.Post("/woocommerce/sync", s.handleSyncWoo)
|
||||
r.Post("/woocommerce/sync-orders", s.handleSyncWooOrders)
|
||||
r.Post("/woocommerce/sync-reviews", s.handleSyncWooReviews)
|
||||
r.Get("/woocommerce/orders", s.handleListWooOrders)
|
||||
r.Get("/woocommerce/reviews", s.handleListWooReviews)
|
||||
r.Post("/woocommerce/audience", s.handleWooAudience)
|
||||
|
||||
r.Get("/shopify", s.handleGetShopifyConfig)
|
||||
r.Put("/shopify", s.handleUpdateShopifyConfig)
|
||||
r.Put("/shopify/schedule", s.handleUpdateShopifySchedule)
|
||||
r.Post("/shopify/test", s.handleTestShopify)
|
||||
r.Post("/shopify/sync", s.handleSyncShopify)
|
||||
r.Post("/shopify/sync-orders", s.handleSyncShopifyOrders)
|
||||
r.Get("/shopify/orders", s.handleListShopifyOrders)
|
||||
|
||||
r.Get("/support/tickets", s.handleListSupportTickets)
|
||||
r.Post("/support/tickets", s.handleCreateSupportTicket)
|
||||
r.Get("/support/tickets/{id}", s.handleGetSupportTicket)
|
||||
r.Post("/support/tickets/{id}/messages", s.handleReplySupportTicket)
|
||||
r.Post("/support/tickets/{id}/csat", s.handleSubmitSupportCSAT)
|
||||
r.Get("/support/notifications", s.handleListNotifications)
|
||||
r.Post("/support/notifications/read-all", s.handleMarkAllNotificationsRead)
|
||||
r.Post("/support/notifications/{id}/read", s.handleMarkNotificationRead)
|
||||
|
||||
r.Get("/campaigns/templates", s.handleListCampaignTemplates)
|
||||
r.Get("/campaigns", s.handleListCampaigns)
|
||||
r.Post("/campaigns", s.handleCreateCampaign)
|
||||
r.Get("/campaigns/{id}", s.handleGetCampaign)
|
||||
r.Patch("/campaigns/{id}", s.handleUpdateCampaign)
|
||||
r.Delete("/campaigns/{id}", s.handleDeleteCampaign)
|
||||
r.Post("/campaigns/{id}/generate", s.handleGenerateCampaign)
|
||||
r.Post("/campaigns/{id}/send-test", s.handleSendTestCampaign)
|
||||
r.Post("/campaigns/{id}/schedule", s.handleScheduleCampaign)
|
||||
r.Post("/campaigns/{id}/send", s.handleSendCampaign)
|
||||
|
||||
r.Get("/integrations/email", s.handleGetEmailIntegration)
|
||||
r.Put("/integrations/email", s.handlePutEmailIntegration)
|
||||
r.Patch("/integrations/email", s.handlePutEmailIntegration)
|
||||
r.Post("/integrations/email/verify", s.handleVerifyEmailIntegration)
|
||||
r.Post("/integrations/email/test", s.handleTestEmailIntegration)
|
||||
r.Post("/email/send", s.handleSendEmail)
|
||||
|
||||
r.Get("/integrations/ai", s.handleGetAIIntegration)
|
||||
r.Put("/integrations/ai", s.handlePutAIIntegration)
|
||||
r.Patch("/integrations/ai", s.handlePutAIIntegration)
|
||||
r.Post("/integrations/ai/test", s.handleTestAIIntegration)
|
||||
r.Get("/integrations/ai/prompts", s.handleGetAIPrompts)
|
||||
r.Put("/integrations/ai/prompts", s.handlePutAIPrompts)
|
||||
r.Patch("/integrations/ai/prompts", s.handlePutAIPrompts)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/shopify"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetShopifyConfig(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
cfg, err := s.Shopify.GetConfig(r.Context(), cid)
|
||||
if err != nil {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"shop_domain": "", "api_version": "2024-10", "is_enabled": false,
|
||||
"configured": false, "has_credentials": false, "reviews_supported": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateShopifyConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
if !s.requireFeatures(w, r, "stores.shopify") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
ShopDomain string `json:"shop_domain"`
|
||||
AccessToken string `json:"access_token"`
|
||||
APIVersion string `json:"api_version"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
cfg, err := s.Shopify.UpdateConfig(r.Context(), cid, shopify.UpdateInput{
|
||||
ShopDomain: body.ShopDomain,
|
||||
AccessToken: body.AccessToken,
|
||||
APIVersion: body.APIVersion,
|
||||
ClientID: body.ClientID,
|
||||
ClientSecret: body.ClientSecret,
|
||||
IsEnabled: body.IsEnabled,
|
||||
DryRun: body.DryRun,
|
||||
})
|
||||
if msg, ok := shopify.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "update failed", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) handleTestShopify(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, "stores.shopify") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
result, err := s.Shopify.TestConnection(r.Context(), cid)
|
||||
if result == nil {
|
||||
result = map[string]any{"status": "failed", "message": "connection failed"}
|
||||
}
|
||||
if err != nil {
|
||||
JSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncShopify(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, "stores.shopify") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var scope shopify.ProductSyncScope
|
||||
if err := DecodeJSONOptional(r, &scope); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
result, err := s.Shopify.EnqueueSync(r.Context(), cid, scope)
|
||||
if msg, ok := shopify.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "sync enqueue failed", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusAccepted, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncShopifyOrders(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireFeatures(w, r, "stores.shopify") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
result, err := s.Shopify.EnqueueOrdersSync(r.Context(), cid)
|
||||
if msg, ok := shopify.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "orders sync enqueue failed", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusAccepted, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleListShopifyOrders(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Shopify == nil {
|
||||
JSON(w, http.StatusOK, map[string]any{"orders": []any{}, "total": 0, "limit": 50, "offset": 0})
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
f := shopify.OrderListFilter{
|
||||
Status: strings.TrimSpace(r.URL.Query().Get("status")),
|
||||
Email: strings.TrimSpace(r.URL.Query().Get("email")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
if since := strings.TrimSpace(r.URL.Query().Get("since")); since != "" {
|
||||
if t, err := time.Parse(time.RFC3339, since); err == nil {
|
||||
f.Since = &t
|
||||
} else {
|
||||
Error(w, http.StatusBadRequest, "invalid since (use RFC3339)")
|
||||
return
|
||||
}
|
||||
}
|
||||
items, total, err := s.Shopify.ListOrders(r.Context(), cid, f)
|
||||
if err != nil {
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"orders": []any{},
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []shopify.OrderRow{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"orders": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateShopifySchedule(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
if !s.requireFeatures(w, r, "stores.shopify") {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
ScheduleIntervalHours int `json:"schedule_interval_hours"`
|
||||
SchedulePaused bool `json:"schedule_paused"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
cfg, err := s.Shopify.UpdateSchedule(r.Context(), cid, body.ScheduleIntervalHours, body.SchedulePaused)
|
||||
if msg, ok := shopify.ClientError(err); ok {
|
||||
Error(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "update schedule failed", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestRequireSupportDeskForbiddenAndAllow(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
|
||||
t.Run("unauthorized", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
called := false
|
||||
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("handler must not run without session user")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member_forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
|
||||
return auth.StaffAccess{}, nil
|
||||
},
|
||||
}
|
||||
called := false
|
||||
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("handler must not run for non-staff")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("support_staff_allowed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
|
||||
return auth.ResolveStaffAccess(false, auth.StaffRoleSupportStaff), nil
|
||||
},
|
||||
}
|
||||
called := false
|
||||
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
called = true
|
||||
access, ok := StaffAccessFromContext(req.Context())
|
||||
if !ok || !access.SupportDesk || !access.IsSupportOnly {
|
||||
t.Fatalf("expected support-only access in context, got ok=%v %+v", ok, access)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("handler must run for support_staff")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSupportStaffForbiddenOnPlanFeatures(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc")
|
||||
s := &Server{
|
||||
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
|
||||
return auth.ResolveStaffAccess(true, auth.StaffRoleSupportStaff), nil
|
||||
},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "get_plan_features", body: ""},
|
||||
{name: "put_plan_features", body: `{"features":{}}`},
|
||||
{name: "enable_all", body: ""},
|
||||
{name: "disable_all", body: ""},
|
||||
{name: "get_gates", body: ""},
|
||||
{name: "put_gates", body: `{"features":{}}`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := false
|
||||
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/admin/plans/1/features", bytes.NewBufferString(tc.body)).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
if called {
|
||||
t.Fatal("plan feature handler must not run for support_staff")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberForbiddenOnAdminSupportAndPlanRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd")
|
||||
s := &Server{
|
||||
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
|
||||
return auth.StaffAccess{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("support_desk", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := s.RequireSupportDesk(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/support/tickets", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("platform_admin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/plans/1/features", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserReplyMassAssignmentRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
// UserReplyInput only accepts "body"; DisallowUnknownFields rejects status / is_internal_note.
|
||||
var dst support.UserReplyInput
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"body":"hi","is_internal_note":true,"status":"closed"}`))
|
||||
err := DecodeJSON(req, &dst)
|
||||
if err == nil {
|
||||
t.Fatal("expected DecodeJSON to reject mass-assignment fields on UserReplyInput")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactForLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := "smtp dial failed password=SuperSecret123 api_key=sk_live_abc token:xyz"
|
||||
out := redactForLog(in)
|
||||
if strings.Contains(out, "SuperSecret123") || strings.Contains(out, "sk_live_abc") || strings.Contains(out, ":xyz") {
|
||||
t.Fatalf("secrets leaked in log: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "[REDACTED]") {
|
||||
t.Fatalf("expected redaction markers, got %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffMayAccessTicket(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
actor := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
other := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
|
||||
t.Run("full_admin_sees_all", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := withStaffAccess(context.Background(), auth.ResolveStaffAccess(true, ""))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||
ticket := support.Ticket{AssigneeAdminUserID: &other}
|
||||
if !s.staffMayAccessTicket(req, actor, ticket) {
|
||||
t.Fatal("full admin must see assigned tickets")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("support_staff_own_or_unassigned", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := withStaffAccess(context.Background(), auth.ResolveStaffAccess(false, auth.StaffRoleSupportStaff))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||
if !s.staffMayAccessTicket(req, actor, support.Ticket{Status: "open"}) {
|
||||
t.Fatal("unassigned open must be visible")
|
||||
}
|
||||
if s.staffMayAccessTicket(req, actor, support.Ticket{Status: "resolved"}) {
|
||||
t.Fatal("unassigned resolved must be hidden from claim queue")
|
||||
}
|
||||
own := actor
|
||||
if !s.staffMayAccessTicket(req, actor, support.Ticket{AssigneeAdminUserID: &own}) {
|
||||
t.Fatal("own assignment must be visible")
|
||||
}
|
||||
if s.staffMayAccessTicket(req, actor, support.Ticket{AssigneeAdminUserID: &other}) {
|
||||
t.Fatal("other assignee must be hidden")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequirePlatformAdminExcludesSupportStaff(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
s := &Server{
|
||||
testStaffAccess: func(context.Context, uuid.UUID) (auth.StaffAccess, error) {
|
||||
return auth.ResolveStaffAccess(true, auth.StaffRoleSupportStaff), nil
|
||||
},
|
||||
}
|
||||
called := false
|
||||
h := s.RequirePlatformAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/users", nil).WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("full admin routes must reject support_staff even with is_platform_admin")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleListFieldGroups(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
items, err := s.Catalog.ListFieldGroups(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit)
|
||||
page, total := pageSlice(items, limit, offset)
|
||||
JSON(w, http.StatusOK, map[string]any{"groups": page, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateFieldGroup(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body map[string]any
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
name, _ := body["name"].(string)
|
||||
var desc *string
|
||||
if v, ok := body["description"]; ok {
|
||||
if v == nil {
|
||||
empty := ""
|
||||
desc = &empty
|
||||
} else if str, ok := v.(string); ok {
|
||||
desc = &str
|
||||
}
|
||||
}
|
||||
order := 0
|
||||
if v, ok := body["order"].(float64); ok {
|
||||
order = int(v)
|
||||
}
|
||||
item, err := s.Catalog.CreateFieldGroup(r.Context(), cid, name, desc, order)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create field group", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateFieldGroup(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.UpdateFieldGroup(r.Context(), cid, id, body)
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, catalog.ErrSystemImmutable) {
|
||||
Error(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update field group", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteFieldGroup(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
err = s.Catalog.DeleteFieldGroup(r.Context(), cid, id)
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, catalog.ErrSystemImmutable) {
|
||||
Error(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListStandardFields(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
enabledOnly := false
|
||||
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("enabled"))) {
|
||||
case "1", "true", "yes":
|
||||
enabledOnly = true
|
||||
}
|
||||
items, err := s.Catalog.ListStandardFields(r.Context(), cid, enabledOnly)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit)
|
||||
page, total := pageSlice(items, limit, offset)
|
||||
JSON(w, http.StatusOK, map[string]any{"fields": page, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateStandardField(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body map[string]any
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.CreateStandardField(r.Context(), cid, body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create standard field", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateStandardField(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Catalog.UpdateStandardField(r.Context(), cid, id, body)
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, catalog.ErrSystemImmutable) {
|
||||
Error(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update standard field", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteStandardField(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
err = s.Catalog.DeleteStandardField(r.Context(), cid, id)
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, catalog.ErrSystemImmutable) {
|
||||
Error(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleBulkStandardFieldsEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
IDs []string `json:"ids"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Enabled == nil {
|
||||
Error(w, http.StatusBadRequest, "enabled required")
|
||||
return
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(body.IDs))
|
||||
for _, raw := range body.IDs {
|
||||
id, err := uuid.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
n, err := s.Catalog.BulkSetStandardFieldsEnabled(r.Context(), cid, ids, *body.Enabled)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update standard fields", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"updated": n, "enabled": *body.Enabled})
|
||||
}
|
||||
|
||||
func (s *Server) handleEnableRecommendedStandardFields(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
items, err := s.Catalog.EnableRecommendedEcommerce(r.Context(), cid)
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "enable recommended fields failed", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"fields": items, "status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListStructuredDescriptions(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
items, err := s.Catalog.ListStructuredDescriptions(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
limit, offset := ParseLimitOffsetMax(r, maxTreePageLimit)
|
||||
page, total := pageSlice(items, limit, offset)
|
||||
JSON(w, http.StatusOK, map[string]any{"fields": page, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateStructuredDescription(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body map[string]any
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
fieldKey := ""
|
||||
if v, ok := body["field_key"].(string); ok {
|
||||
fieldKey = v
|
||||
} else if v, ok := body["fieldKey"].(string); ok {
|
||||
fieldKey = v
|
||||
}
|
||||
typ := "text"
|
||||
if v, ok := body["type"].(string); ok && v != "" {
|
||||
typ = v
|
||||
}
|
||||
item, err := s.Catalog.CreateStructuredDescription(r.Context(), cid, fieldKey, typ)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create structured description", err, catalog.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteStructuredDescription(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
err = s.Catalog.DeleteStructuredDescription(r.Context(), cid, id)
|
||||
if errors.Is(err, catalog.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/shopify"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleUpdateShopifyScheduleRejectsInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Shopify: &shopify.Service{}}
|
||||
ctx := context.WithValue(context.Background(), ctxCompanyID, uuid.MustParse("11111111-1111-1111-1111-111111111111"))
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/shopify/schedule", strings.NewReader(`{bad`))
|
||||
req = req.WithContext(ctx)
|
||||
s.handleUpdateShopifySchedule(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateShopifyScheduleRejectsInvalidInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Shopify: &shopify.Service{}} // Pool nil — interval check runs before DB
|
||||
ctx := context.WithValue(context.Background(), ctxCompanyID, uuid.MustParse("11111111-1111-1111-1111-111111111111"))
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/shopify/schedule", strings.NewReader(`{"schedule_interval_hours":999,"schedule_paused":false}`))
|
||||
req = req.WithContext(ctx)
|
||||
s.handleUpdateShopifySchedule(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSyncShopifyRejectsInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{} // Shopify unused — DecodeJSONOptional fails first
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/shopify/sync", strings.NewReader(`{"status":`))
|
||||
s.handleSyncShopify(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateWooScheduleRejectsInvalidInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Woo: &woocommerce.Service{}}
|
||||
ctx := context.WithValue(context.Background(), ctxCompanyID, uuid.MustParse("11111111-1111-1111-1111-111111111111"))
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/woocommerce/schedule", strings.NewReader(`{"schedule_interval_hours":-2,"schedule_paused":true}`))
|
||||
req = req.WithContext(ctx)
|
||||
s.handleUpdateWooSchedule(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSyncWooRejectsInvalidJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/woocommerce/sync", strings.NewReader(`[`))
|
||||
s.handleSyncWoo(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
)
|
||||
|
||||
func (s *Server) stripeSvc() *billing.StripeService {
|
||||
if s.Stripe != nil {
|
||||
return s.Stripe
|
||||
}
|
||||
s.Stripe = &billing.StripeService{
|
||||
Pool: s.Pool,
|
||||
Billing: s.Billing,
|
||||
Cfg: billing.StripeConfig{
|
||||
SecretKey: s.Config.StripeSecretKey,
|
||||
WebhookSecret: s.Config.StripeWebhookSecret,
|
||||
WebOrigin: s.Config.WebOrigin,
|
||||
PublicAPIURL: s.Config.PublicAPIURL,
|
||||
PriceIDs: s.Config.StripePriceIDs,
|
||||
ForceMock: s.Config.StripeMock,
|
||||
},
|
||||
}
|
||||
return s.Stripe
|
||||
}
|
||||
|
||||
func (s *Server) handleStripeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
st, err := s.stripeSvc().Status(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load stripe status")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
var body billing.CheckoutRequest
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
email, name := "", ""
|
||||
_ = s.Pool.QueryRow(r.Context(), `SELECT email, COALESCE(name, '') FROM users WHERE id = $1`, uid).Scan(&email, &name)
|
||||
var companyName string
|
||||
_ = s.Pool.QueryRow(r.Context(), `SELECT name FROM companies WHERE id = $1`, cid).Scan(&companyName)
|
||||
res, err := s.stripeSvc().CreateCheckoutSession(r.Context(), cid, email, companyName, body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "checkout failed", err, billing.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleStripePortal(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
role, _ := RoleFromContext(r.Context())
|
||||
if role != "admin" {
|
||||
Error(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
res, err := s.stripeSvc().CreatePortalSession(r.Context(), cid)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "portal session failed", err, billing.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// handleStripeWebhook is public (no session/CSRF). Signature verified when STRIPE_WEBHOOK_SECRET is set.
|
||||
func (s *Server) handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
const maxBody = 1 << 20 // 1 MiB
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "failed to read body")
|
||||
return
|
||||
}
|
||||
if len(body) > maxBody {
|
||||
Error(w, http.StatusRequestEntityTooLarge, "body too large")
|
||||
return
|
||||
}
|
||||
sig := r.Header.Get("Stripe-Signature")
|
||||
if err := s.stripeSvc().HandleWebhook(r.Context(), body, sig); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, billing.ErrStripeBadSignature):
|
||||
Error(w, http.StatusBadRequest, "invalid signature")
|
||||
case errors.Is(err, billing.ErrStripeNotConfigured):
|
||||
Error(w, http.StatusServiceUnavailable, "stripe webhooks not configured")
|
||||
default:
|
||||
// Avoid leaking internal apply/DB details to an unauthenticated caller.
|
||||
LogAndError(w, http.StatusBadRequest, "webhook processing failed", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestHandleStripePortalMockLocal(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
Config: config.Config{WebOrigin: "http://localhost:5174", StripeMock: true},
|
||||
Stripe: &billing.StripeService{
|
||||
Cfg: billing.StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
|
||||
},
|
||||
}
|
||||
cid := uuid.New()
|
||||
uid := uuid.New()
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "admin")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/billing/portal", nil)
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleStripePortal(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body billing.PortalResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !body.Mock || body.URL != "http://localhost:5174/billing?portal=mock" {
|
||||
t.Fatalf("got %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStripeWebhookRejectsBadSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
secret := "whsec_handler_test"
|
||||
s := &Server{
|
||||
Stripe: &billing.StripeService{
|
||||
Cfg: billing.StripeConfig{ForceMock: true, WebhookSecret: secret},
|
||||
},
|
||||
}
|
||||
payload := []byte(`{"id":"evt_bad","type":"ping"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/billing/webhook", bytes.NewReader(payload))
|
||||
req.Header.Set("Stripe-Signature", "t=1,v1=deadbeef")
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleStripeWebhook(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s want 400", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStripeWebhookUnsignedWithoutSecretNeedsForceMock(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Misconfigured live (no webhook secret, no ForceMock) must 503 — never process unsigned.
|
||||
s := &Server{
|
||||
Stripe: &billing.StripeService{Cfg: billing.StripeConfig{}},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/billing/webhook", bytes.NewReader([]byte(`{"id":"evt_x","type":"ping"}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleStripeWebhook(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d body=%s want 503", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStripeCheckoutMockRequiresAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{
|
||||
Config: config.Config{StripeMock: true},
|
||||
Stripe: &billing.StripeService{Cfg: billing.StripeConfig{ForceMock: true}},
|
||||
}
|
||||
cid := uuid.New()
|
||||
uid := uuid.New()
|
||||
ctx := context.WithValue(context.Background(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, cid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "member")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/billing/checkout", bytes.NewBufferString(`{"plan":"starter","term":"monthly"}`))
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleStripeCheckout(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func signHandlerStripePayload(t *testing.T, secret string, payload []byte) string {
|
||||
t.Helper()
|
||||
ts := time.Now().Unix()
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = fmt.Fprintf(mac, "%d.", ts)
|
||||
_, _ = mac.Write(payload)
|
||||
return fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func TestHandleStripeWebhookValidSignatureStillVerifiedUnderMock(t *testing.T) {
|
||||
t.Parallel()
|
||||
secret := "whsec_handler_ok"
|
||||
// No Pool: claim fails closed after signature passes — proves verify runs before apply.
|
||||
s := &Server{
|
||||
Stripe: &billing.StripeService{
|
||||
Cfg: billing.StripeConfig{ForceMock: true, WebhookSecret: secret},
|
||||
},
|
||||
}
|
||||
payload := []byte(`{"id":"evt_ok","type":"ping"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/billing/webhook", bytes.NewReader(payload))
|
||||
req.Header.Set("Stripe-Signature", signHandlerStripePayload(t, secret, payload))
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleStripeWebhook(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s — expect apply/store failure after verify", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// supportAuthPaths are the session-gated support CRUD / notification probes.
|
||||
// Skip the suite when none are mounted yet (sibling HTTP wiring in progress).
|
||||
var supportAuthPaths = []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/support/tickets"},
|
||||
{http.MethodPost, "/api/support/tickets"},
|
||||
{http.MethodGet, "/api/support/notifications"},
|
||||
{http.MethodGet, "/api/admin/support/tickets"},
|
||||
{http.MethodGet, "/api/admin/support/csat"},
|
||||
{http.MethodGet, "/api/admin/support/kb/articles"},
|
||||
{http.MethodGet, "/api/admin/support/kb/categories"},
|
||||
{http.MethodGet, "/api/admin/support/templates"},
|
||||
{http.MethodGet, "/api/admin/support/auto-config"},
|
||||
}
|
||||
|
||||
// TestRouterSupportTicketCRUDAuthRequiresSession asserts unauthenticated callers
|
||||
// get 401 (not 200) on support routes when those routes are mounted.
|
||||
func TestRouterSupportTicketCRUDAuthRequiresSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
h := s.Router()
|
||||
|
||||
mounted := 0
|
||||
for _, tc := range supportAuthPaths {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
switch rec.Code {
|
||||
case http.StatusNotFound:
|
||||
continue
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
mounted++
|
||||
default:
|
||||
t.Fatalf("%s %s status=%d want 401/403 when mounted (body=%s)",
|
||||
tc.method, tc.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if mounted == 0 {
|
||||
t.Skip("support ticket HTTP routes not mounted yet")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRouterSupportCSATAuthRequiresSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
h := s.Router()
|
||||
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/api/support/tickets/00000000-0000-0000-0000-000000000001/csat"},
|
||||
{http.MethodGet, "/api/admin/support/csat"},
|
||||
}
|
||||
mounted := 0
|
||||
for _, tc := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(`{"score":5}`))
|
||||
if tc.method == http.MethodPost {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
switch rec.Code {
|
||||
case http.StatusNotFound:
|
||||
continue
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
mounted++
|
||||
default:
|
||||
t.Fatalf("%s %s status=%d want 401/403 (body=%s)",
|
||||
tc.method, tc.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if mounted == 0 {
|
||||
t.Skip("csat routes not mounted yet")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleSubmitSupportCSAT(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body support.CSATInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.SubmitCSAT(r.Context(), cid, uid, id, body)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, support.ErrAlreadyRated) {
|
||||
Error(w, http.StatusConflict, "already rated")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not submit rating", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSupportCSATAggregate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
JSON(w, http.StatusOK, support.CSATAggregate{
|
||||
Total: 0,
|
||||
Average: 0,
|
||||
Distribution: map[string]int64{"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
|
||||
})
|
||||
return
|
||||
}
|
||||
var from, to *time.Time
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("from")); raw != "" {
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid from (use RFC3339)")
|
||||
return
|
||||
}
|
||||
t = t.UTC()
|
||||
from = &t
|
||||
}
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("to")); raw != "" {
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid to (use RFC3339)")
|
||||
return
|
||||
}
|
||||
t = t.UTC()
|
||||
to = &t
|
||||
}
|
||||
agg, err := s.Support.AggregateCSAT(r.Context(), from, to)
|
||||
if err != nil {
|
||||
if support.IsMissingRelation(err) {
|
||||
JSON(w, http.StatusOK, support.CSATAggregate{
|
||||
Total: 0,
|
||||
Average: 0,
|
||||
Distribution: map[string]int64{"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
|
||||
From: from,
|
||||
To: to,
|
||||
})
|
||||
return
|
||||
}
|
||||
LogAndError(w, http.StatusInternalServerError, "could not load csat aggregate", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, agg)
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleListSupportTickets(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": 0, "offset": 0})
|
||||
return
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
items, total, err := s.Support.ListForUser(r.Context(), cid, uid, status, limit, offset)
|
||||
if err != nil {
|
||||
if support.IsMissingRelation(err) {
|
||||
JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": limit, "offset": offset})
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not list tickets", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []support.Ticket{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"tickets": items, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSupportTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var body support.CreateInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.Create(r.Context(), cid, uid, body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create ticket", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
// Stage A FAQ match (sync). Never awaits LLM — agent 4 owns AI fallback.
|
||||
if updated, _, matchErr := s.Support.MaybeAutoReplyOnCreate(r.Context(), item); matchErr == nil {
|
||||
item = updated
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSupportTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.GetForUser(r.Context(), cid, uid, id)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "get failed", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleReplySupportTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
// Mass-assignment guard: customers cannot set is_internal_note / status.
|
||||
var body support.UserReplyInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.ReplyAsUser(r.Context(), cid, uid, id, support.ReplyInput{Body: body.Body})
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not reply", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
if updated, _, matchErr := s.Support.MaybeAutoReplyOnCustomerReply(r.Context(), item); matchErr == nil {
|
||||
item = updated
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListSupportTickets(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": 0, "offset": 0})
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
access, _ := StaffAccessFromContext(r.Context())
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
f := support.ListFilter{
|
||||
Status: strings.TrimSpace(r.URL.Query().Get("status")),
|
||||
Search: QuerySearch(r),
|
||||
Scope: strings.TrimSpace(r.URL.Query().Get("scope")),
|
||||
Flag: strings.ToLower(strings.TrimSpace(r.URL.Query().Get("flag"))),
|
||||
ActorID: uid,
|
||||
FullAdmin: access.FullAdmin,
|
||||
}
|
||||
if f.Flag != "" && f.Flag != support.FlagNeedsHuman && f.Flag != support.FlagAIDraft {
|
||||
Error(w, http.StatusBadRequest, "invalid flag")
|
||||
return
|
||||
}
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("company_id")); raw != "" {
|
||||
cid, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid company_id")
|
||||
return
|
||||
}
|
||||
f.CompanyID = &cid
|
||||
}
|
||||
if access.FullAdmin {
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("assignee_id")); raw != "" {
|
||||
aid, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid assignee_id")
|
||||
return
|
||||
}
|
||||
f.AssigneeID = &aid
|
||||
}
|
||||
}
|
||||
items, total, err := s.Support.ListAdmin(r.Context(), f, limit, offset)
|
||||
if err != nil {
|
||||
if errors.Is(err, support.ErrForbidden) {
|
||||
Error(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if support.IsMissingRelation(err) {
|
||||
JSON(w, http.StatusOK, map[string]any{"tickets": []any{}, "total": 0, "limit": limit, "offset": offset})
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not list tickets", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []support.Ticket{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"tickets": items, "total": total, "limit": limit, "offset": offset, "scope": f.Scope})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetSupportTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.GetAdmin(r.Context(), id)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "get failed", err)
|
||||
return
|
||||
}
|
||||
if !s.staffMayAccessTicket(r, uid, item) {
|
||||
// Anti-enumeration: same as missing for support_staff.
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminReplySupportTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
existing, err := s.Support.GetAdmin(r.Context(), id)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "get failed", err)
|
||||
return
|
||||
}
|
||||
if !s.staffMayAccessTicket(r, uid, existing) {
|
||||
access, _ := StaffAccessFromContext(r.Context())
|
||||
if access.IsSupportOnly && existing.AssigneeAdminUserID != nil && *existing.AssigneeAdminUserID != uid {
|
||||
CodedError(w, http.StatusConflict, "already_claimed", "assigned to another agent")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
var body support.ReplyInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.ReplyAsAgent(r.Context(), uid, id, body)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not reply", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateSupportTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
existing, err := s.Support.GetAdmin(r.Context(), id)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "get failed", err)
|
||||
return
|
||||
}
|
||||
if !s.staffMayAccessTicket(r, uid, existing) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
var body support.AdminUpdateInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
// Mass-assignment: only allowlisted fields; validate assignee is support-capable.
|
||||
if body.AssigneeAdminUserID != nil && !body.ClearAssignee {
|
||||
if *body.AssigneeAdminUserID == uuid.Nil {
|
||||
Error(w, http.StatusBadRequest, "invalid assignee")
|
||||
return
|
||||
}
|
||||
access, _ := StaffAccessFromContext(r.Context())
|
||||
if access.IsSupportOnly && *body.AssigneeAdminUserID != uid {
|
||||
// support_staff may only claim for self (or clear).
|
||||
Error(w, http.StatusForbidden, "cannot assign to other staff")
|
||||
return
|
||||
}
|
||||
ok, err := s.assigneeIsSupportCapable(r, *body.AssigneeAdminUserID)
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "authorization check failed", err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
Error(w, http.StatusBadRequest, "invalid assignee")
|
||||
return
|
||||
}
|
||||
}
|
||||
item, err := s.Support.UpdateAdmin(r.Context(), id, uid, body)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update ticket", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) staffActor(r *http.Request, uid uuid.UUID) support.AgentActor {
|
||||
access, _ := StaffAccessFromContext(r.Context())
|
||||
return support.AgentActor{UserID: uid, FullAdmin: access.FullAdmin}
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminClaimSupportTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.Claim(r.Context(), id, s.staffActor(r, uid))
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, support.ErrAlreadyClaimed) || errors.Is(err, support.ErrNotClaimable) {
|
||||
Error(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not claim ticket", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminReleaseSupportTicket(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.Release(r.Context(), id, s.staffActor(r, uid))
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, support.ErrForbidden) {
|
||||
Error(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not release ticket", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminApproveSupportAIDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
existing, err := s.Support.GetAdmin(r.Context(), id)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "get failed", err)
|
||||
return
|
||||
}
|
||||
if !s.staffMayAccessTicket(r, uid, existing) {
|
||||
access, _ := StaffAccessFromContext(r.Context())
|
||||
if access.IsSupportOnly && existing.AssigneeAdminUserID != nil && *existing.AssigneeAdminUserID != uid {
|
||||
CodedError(w, http.StatusConflict, "already_claimed", "assigned to another agent")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
var body support.ApproveAIDraftInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.ApproveAIDraft(r.Context(), uid, id, body)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, support.ErrNoAIDraft) {
|
||||
Error(w, http.StatusConflict, "no AI draft to approve")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not approve AI draft", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDiscardSupportAIDraft(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
existing, err := s.Support.GetAdmin(r.Context(), id)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "get failed", err)
|
||||
return
|
||||
}
|
||||
if !s.staffMayAccessTicket(r, uid, existing) {
|
||||
access, _ := StaffAccessFromContext(r.Context())
|
||||
if access.IsSupportOnly && existing.AssigneeAdminUserID != nil && *existing.AssigneeAdminUserID != uid {
|
||||
CodedError(w, http.StatusConflict, "already_claimed", "assigned to another agent")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.DiscardAIDraft(r.Context(), uid, id)
|
||||
if errors.Is(err, support.ErrNotFound) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, support.ErrNoAIDraft) {
|
||||
Error(w, http.StatusConflict, "no AI draft to discard")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not discard AI draft", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListSupportAgents(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
JSON(w, http.StatusOK, map[string]any{"agents": []any{}, "total": 0})
|
||||
return
|
||||
}
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
includeAdmins := !QueryTruthy(r, "agents_only")
|
||||
items, total, err := s.Support.ListAgents(r.Context(), includeAdmins, limit, offset)
|
||||
if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "list failed", err)
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []support.SupportAgent{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"agents": items, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
// staffMayAccessTicket enforces least-privilege visibility for support_staff.
|
||||
func (s *Server) staffMayAccessTicket(r *http.Request, actor uuid.UUID, t support.Ticket) bool {
|
||||
access, ok := StaffAccessFromContext(r.Context())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if access.FullAdmin {
|
||||
return true
|
||||
}
|
||||
if !access.SupportDesk {
|
||||
return false
|
||||
}
|
||||
if t.AssigneeAdminUserID != nil {
|
||||
return *t.AssigneeAdminUserID == actor
|
||||
}
|
||||
// Unassigned queue: claimable open/pending only.
|
||||
return t.Status == "open" || t.Status == "pending"
|
||||
}
|
||||
|
||||
func (s *Server) assigneeIsSupportCapable(r *http.Request, assignee uuid.UUID) (bool, error) {
|
||||
if s.testStaffAccess != nil {
|
||||
access, err := s.testStaffAccess(r.Context(), assignee)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return access.SupportDesk, nil
|
||||
}
|
||||
if s.Auth == nil {
|
||||
return false, nil
|
||||
}
|
||||
return s.Auth.IsAssignableSupportStaff(r.Context(), assignee)
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
JSON(w, http.StatusOK, map[string]any{"notifications": []any{}, "total": 0, "unread": 0, "limit": 0, "offset": 0})
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
unreadOnly := QueryTruthy(r, "unread")
|
||||
items, total, err := s.Support.ListNotifications(r.Context(), uid, unreadOnly, limit, offset)
|
||||
if err != nil {
|
||||
if support.IsMissingRelation(err) {
|
||||
JSON(w, http.StatusOK, map[string]any{"notifications": []any{}, "total": 0, "unread": 0, "limit": limit, "offset": offset})
|
||||
return
|
||||
}
|
||||
LogAndError(w, http.StatusInternalServerError, "list failed", err)
|
||||
return
|
||||
}
|
||||
unread, err := s.Support.UnreadNotificationCount(r.Context(), uid)
|
||||
if err != nil {
|
||||
if support.IsMissingRelation(err) {
|
||||
unread = 0
|
||||
} else {
|
||||
LogAndError(w, http.StatusInternalServerError, "list failed", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if items == nil {
|
||||
items = []support.Notification{}
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"notifications": items,
|
||||
"total": total,
|
||||
"unread": unread,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Support.MarkNotificationRead(r.Context(), uid, id); errors.Is(err, support.ErrNotificationGone) {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
LogAndError(w, http.StatusInternalServerError, "update failed", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
n, err := s.Support.MarkAllNotificationsRead(r.Context(), uid)
|
||||
if err != nil {
|
||||
if support.IsMissingRelation(err) {
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "ok", "updated": 0})
|
||||
return
|
||||
}
|
||||
LogAndError(w, http.StatusInternalServerError, "update failed", err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"status": "ok", "updated": n})
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/support"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Server) handleAdminListKBArticles(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
publishedOnly := r.URL.Query().Get("published") == "1" || r.URL.Query().Get("published") == "true"
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
items, total, err := s.Support.ListKBArticlesOpts(r.Context(), support.KBArticleListOpts{
|
||||
PublishedOnly: publishedOnly,
|
||||
Category: r.URL.Query().Get("category"),
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not list kb articles", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"items": items, "total": total})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListKBCategories(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
items, err := s.Support.ListKBCategories(r.Context())
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not list kb categories", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
const kbImageMaxUpload = 3 << 20 // parse budget slightly above 2 MiB file cap
|
||||
|
||||
func (s *Server) handleAdminUploadKBImage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(kbImageMaxUpload); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid multipart form")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
file, header, err = r.FormFile("image")
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "file field required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
out, err := support.SaveKBImage(
|
||||
s.Config.UploadDir,
|
||||
s.Config.PublicAPIURL,
|
||||
s.Config.TokenSigningSecret,
|
||||
header.Filename,
|
||||
header.Header.Get("Content-Type"),
|
||||
file,
|
||||
)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not upload kb image", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetKBImage(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "filename")
|
||||
s.serveKBImage(w, r, name)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublicKBImage(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "filename")
|
||||
sig := strings.TrimSpace(r.URL.Query().Get("sig"))
|
||||
secret := strings.TrimSpace(s.Config.TokenSigningSecret)
|
||||
if err := support.VerifyKBImageSig(secret, name, sig); err != nil {
|
||||
Error(w, http.StatusForbidden, "invalid image signature")
|
||||
return
|
||||
}
|
||||
s.serveKBImage(w, r, name)
|
||||
}
|
||||
|
||||
func (s *Server) serveKBImage(w http.ResponseWriter, r *http.Request, name string) {
|
||||
f, contentType, err := support.OpenKBImage(s.Config.UploadDir, name)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, support.ErrKBImageInvalidName), errors.Is(err, support.ErrKBImageForbidden):
|
||||
Error(w, http.StatusBadRequest, "invalid image path")
|
||||
case errors.Is(err, support.ErrKBImageNotFound):
|
||||
Error(w, http.StatusNotFound, "image not found")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "could not open image")
|
||||
}
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "could not stat image")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.ServeContent(w, r, name, st.ModTime(), f)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetKBArticle(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.GetKBArticle(r.Context(), id)
|
||||
if err != nil {
|
||||
if err == support.ErrKBNotFound {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not get kb article", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateKBArticle(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
var body support.KBArticleInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.CreateKBArticle(r.Context(), body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create kb article", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateKBArticle(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body support.KBArticleInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.UpdateKBArticle(r.Context(), id, body)
|
||||
if err != nil {
|
||||
if err == support.ErrKBNotFound {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update kb article", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteKBArticle(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Support.DeleteKBArticle(r.Context(), id); err != nil {
|
||||
if err == support.ErrKBNotFound {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not delete kb article", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListReplyTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
activeOnly := r.URL.Query().Get("active") == "1" || r.URL.Query().Get("active") == "true"
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
items, total, err := s.Support.ListReplyTemplates(r.Context(), activeOnly, limit, offset)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not list templates", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"items": items, "total": total})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetReplyTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.GetReplyTemplate(r.Context(), id)
|
||||
if err != nil {
|
||||
if err == support.ErrTemplateNotFound {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not get template", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateReplyTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
var body support.ReplyTemplateInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.CreateReplyTemplate(r.Context(), body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create template", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateReplyTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
var body support.ReplyTemplateInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
item, err := s.Support.UpdateReplyTemplate(r.Context(), id, body)
|
||||
if err != nil {
|
||||
if err == support.ErrTemplateNotFound {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update template", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteReplyTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if err := s.Support.DeleteReplyTemplate(r.Context(), id); err != nil {
|
||||
if err == support.ErrTemplateNotFound {
|
||||
Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not delete template", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminGetSupportAutoConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
cfg, err := s.Support.GetAutoConfig(r.Context())
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not load auto config", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPutSupportAutoConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Support == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "support unavailable")
|
||||
return
|
||||
}
|
||||
var body support.AutoConfigInput
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
cfg, err := s.Support.UpdateAutoConfig(r.Context(), body)
|
||||
if err != nil {
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not update auto config", err, support.ClientError)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, cfg)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestContextTenantKeysDoNotCross(t *testing.T) {
|
||||
t.Parallel()
|
||||
companyA := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
companyB := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
userA := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
userB := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
|
||||
ctxA := context.WithValue(context.Background(), ctxUserID, userA)
|
||||
ctxA = context.WithValue(ctxA, ctxCompanyID, companyA)
|
||||
ctxA = context.WithValue(ctxA, ctxRole, "admin")
|
||||
|
||||
ctxB := context.WithValue(context.Background(), ctxUserID, userB)
|
||||
ctxB = context.WithValue(ctxB, ctxCompanyID, companyB)
|
||||
ctxB = context.WithValue(ctxB, ctxRole, "member")
|
||||
|
||||
gotUserA, ok := UserIDFromContext(ctxA)
|
||||
if !ok || gotUserA != userA {
|
||||
t.Fatalf("user A = %v ok=%v", gotUserA, ok)
|
||||
}
|
||||
gotCompanyA, ok := CompanyIDFromContext(ctxA)
|
||||
if !ok || gotCompanyA != companyA {
|
||||
t.Fatalf("company A = %v ok=%v", gotCompanyA, ok)
|
||||
}
|
||||
gotCompanyB, ok := CompanyIDFromContext(ctxB)
|
||||
if !ok || gotCompanyB != companyB {
|
||||
t.Fatalf("company B = %v ok=%v", gotCompanyB, ok)
|
||||
}
|
||||
if gotCompanyA == gotCompanyB {
|
||||
t.Fatal("tenant company IDs unexpectedly equal")
|
||||
}
|
||||
roleA, _ := RoleFromContext(ctxA)
|
||||
roleB, _ := RoleFromContext(ctxB)
|
||||
if roleA == roleB {
|
||||
t.Fatal("roles should differ across tenants")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSessionUnauthorized(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
s := &Server{Sessions: sm, Config: config.Config{}, Auth: &auth.Service{}}
|
||||
h := LoadSession(sm)(s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSessionRejectsInactiveUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
uid := uuid.New()
|
||||
var capturedToken string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
capturedToken = c.Value
|
||||
}
|
||||
}
|
||||
if capturedToken == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
Sessions: sm,
|
||||
Config: config.Config{},
|
||||
testUserActive: func(_ context.Context, got uuid.UUID) (bool, error) {
|
||||
if got != uid {
|
||||
t.Fatalf("user id = %s, want %s", got, uid)
|
||||
}
|
||||
return false, nil
|
||||
},
|
||||
}
|
||||
h := LoadSession(sm)(s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken})
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 for inactive user", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireSessionRejectsStaleSessionVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
uid := uuid.New()
|
||||
var capturedToken string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
sm.Put(r.Context(), auth.SessionVersionKey, 0)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
capturedToken = c.Value
|
||||
}
|
||||
}
|
||||
if capturedToken == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
Sessions: sm,
|
||||
Config: config.Config{},
|
||||
testUserSessionState: func(_ context.Context, got uuid.UUID) (auth.UserSessionState, error) {
|
||||
if got != uid {
|
||||
t.Fatalf("user id = %s, want %s", got, uid)
|
||||
}
|
||||
// Simulate password-reset bump while cookie still carries version 0.
|
||||
return auth.UserSessionState{Active: true, Version: 1}, nil
|
||||
},
|
||||
}
|
||||
h := LoadSession(sm)(s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken})
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 for stale session_version", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireCompanyRequiresSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
sm := scs.New()
|
||||
s := &Server{Sessions: sm, Config: config.Config{}, Auth: &auth.Service{}}
|
||||
uid := uuid.New()
|
||||
|
||||
var capturedToken string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
capturedToken = c.Value
|
||||
}
|
||||
}
|
||||
if capturedToken == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := LoadSession(sm)(s.RequireSession(s.RequireCompany(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/company", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken})
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 company not selected", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireCompanyRejectsUnprovenMembership(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Without a DB pool, membership cannot be proven — gate must not panic and should reject.
|
||||
// Live round-trip requires DATABASE_URL (documented blocker for integration tests).
|
||||
sm := scs.New()
|
||||
s := &Server{Sessions: sm, Config: config.Config{}, Auth: &auth.Service{Pool: nil}}
|
||||
uid := uuid.New()
|
||||
cid := uuid.New()
|
||||
|
||||
var capturedToken string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionUserIDKey, uid.String())
|
||||
sm.Put(r.Context(), auth.SessionCompanyIDKey, cid.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
capturedToken = c.Value
|
||||
}
|
||||
}
|
||||
if capturedToken == "" {
|
||||
t.Fatal("expected session cookie from seed request")
|
||||
}
|
||||
|
||||
h := LoadSession(sm)(s.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Simulate RequireCompany's invalid-company path without hitting nil pool.
|
||||
cidStr := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)
|
||||
if cidStr == "" {
|
||||
Error(w, http.StatusBadRequest, "company not selected")
|
||||
return
|
||||
}
|
||||
parsed, err := uuid.Parse(cidStr)
|
||||
if err != nil || parsed == uuid.Nil {
|
||||
Error(w, http.StatusBadRequest, "invalid company")
|
||||
return
|
||||
}
|
||||
// Tenant isolation: company from session must match what handlers would use.
|
||||
if parsed != cid {
|
||||
Error(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusForbidden, "forbidden")
|
||||
})))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/company", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: capturedToken})
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 when membership cannot be proven", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeginAuthenticatedSessionRenewsTokenAndClearsCompany(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
s := &Server{Sessions: sm, Config: config.Config{}, Auth: &auth.Service{}}
|
||||
userID := uuid.New()
|
||||
staleCompanyID := uuid.New()
|
||||
|
||||
var originalToken string
|
||||
seed := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sm.Put(r.Context(), auth.SessionCompanyIDKey, staleCompanyID.String())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
seedRec := httptest.NewRecorder()
|
||||
seed.ServeHTTP(seedRec, httptest.NewRequest(http.MethodGet, "/seed", nil))
|
||||
for _, c := range seedRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
originalToken = c.Value
|
||||
}
|
||||
}
|
||||
if originalToken == "" {
|
||||
t.Fatal("expected seeded session cookie")
|
||||
}
|
||||
|
||||
var renewedToken string
|
||||
authenticate := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.beginAuthenticatedSession(r.Context(), userID, uuid.Nil); err != nil {
|
||||
t.Fatalf("beginAuthenticatedSession error: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
authReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
authReq.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: originalToken})
|
||||
authRec := httptest.NewRecorder()
|
||||
authenticate.ServeHTTP(authRec, authReq)
|
||||
for _, c := range authRec.Result().Cookies() {
|
||||
if c.Name == sm.Cookie.Name {
|
||||
renewedToken = c.Value
|
||||
}
|
||||
}
|
||||
if renewedToken == "" {
|
||||
t.Fatal("expected renewed session cookie")
|
||||
}
|
||||
if renewedToken == originalToken {
|
||||
t.Fatal("expected session token rotation after authentication")
|
||||
}
|
||||
|
||||
verify := LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey); got != userID.String() {
|
||||
t.Fatalf("user session = %q, want %q", got, userID.String())
|
||||
}
|
||||
if got := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey); got != "" {
|
||||
t.Fatalf("company session = %q, want cleared value", got)
|
||||
}
|
||||
if got := s.Sessions.GetInt(r.Context(), auth.SessionVersionKey); got != 0 {
|
||||
t.Fatalf("session_version = %d, want 0 without DB", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
verifyReq := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
verifyReq.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: renewedToken})
|
||||
verifyRec := httptest.NewRecorder()
|
||||
verify.ServeHTTP(verifyRec, verifyReq)
|
||||
if verifyRec.Code != http.StatusNoContent {
|
||||
t.Fatalf("verify status = %d, want 204", verifyRec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Stable ETag for the embedded public OpenAPI document (compile-time bytes).
|
||||
var v1OpenAPIETag = func() string {
|
||||
sum := sha256.Sum256(v1OpenAPIYAML)
|
||||
return `"` + hex.EncodeToString(sum[:16]) + `"`
|
||||
}()
|
||||
|
||||
// Precompressed OpenAPI body (~12KB vs ~74KB raw) for Accept-Encoding: gzip.
|
||||
var v1OpenAPIGzip = func() []byte {
|
||||
var buf bytes.Buffer
|
||||
zw, err := gzip.NewWriterLevel(&buf, gzip.BestCompression)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := zw.Write(v1OpenAPIYAML); err != nil {
|
||||
_ = zw.Close()
|
||||
return nil
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil
|
||||
}
|
||||
return buf.Bytes()
|
||||
}()
|
||||
|
||||
func acceptEncodingIncludesGzip(header string) bool {
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
encoding := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
|
||||
if strings.EqualFold(encoding, "gzip") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mountV1 registers the public API-key surface under /api/v1.
|
||||
// Handlers reuse dashboard services with company isolation from RequireAPIKey.
|
||||
func (s *Server) mountV1(r chi.Router) {
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/openapi.yaml", s.handleV1OpenAPI)
|
||||
r.Get("/health", s.handleHealthz)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.RateLimitAPIKeyAttempts)
|
||||
r.Use(s.RequireAPIKey)
|
||||
r.Use(s.RateLimitAPIKey)
|
||||
r.Use(s.RateLimitV1Process)
|
||||
|
||||
r.Get("/products", s.handleV1ListProducts)
|
||||
r.Get("/products/quality", s.handleV1ListProductQuality)
|
||||
r.Post("/products/reset", s.handleResetProducts)
|
||||
// Legacy public contract (items[].ean → 200 { data: { process_id } }).
|
||||
// Not an alias of POST/GET /process (flat ProcessingJob).
|
||||
r.Post("/products/process", s.handleV1StartProcess)
|
||||
r.Get("/products/process/{id}", s.handleV1GetProcess)
|
||||
r.Get("/products/{id}", s.handleGetProduct)
|
||||
r.Patch("/products/{id}", s.handleUpdateProduct)
|
||||
|
||||
// Content calendar — separate from email /api/campaigns (session UI).
|
||||
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
|
||||
r.Post("/marketing/calendar/prepare", s.handlePrepareMarketingCalendar)
|
||||
// Legacy public aliases (Next.js /api/v1/campaigns).
|
||||
r.Get("/campaigns", s.handleV1ListCampaigns)
|
||||
r.Post("/campaigns/prepare", s.handleV1PrepareCampaign)
|
||||
|
||||
r.Get("/categories", s.handleV1ListCategories)
|
||||
r.Post("/categories", s.handleV1CreateCategory)
|
||||
r.Post("/categories/create", s.handleV1CreateCategory) // legacy alias
|
||||
r.Get("/categories/{id}", s.handleGetCategory)
|
||||
r.Patch("/categories/{id}", s.handleUpdateCategory)
|
||||
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
|
||||
|
||||
r.Get("/attributes", s.handleV1ListAttributes)
|
||||
r.Post("/attributes", s.handleV1CreateAttribute)
|
||||
r.Post("/attributes/create", s.handleV1CreateAttribute) // legacy alias
|
||||
r.Patch("/attributes/{id}", s.handleUpdateAttribute)
|
||||
r.Delete("/attributes/{id}", s.handleV1DeleteAttribute)
|
||||
|
||||
r.Get("/feeds", s.handleV1ListFeeds)
|
||||
r.Post("/feeds", s.handleV1CreateFeed)
|
||||
r.Get("/feeds/{id}", s.handleV1GetFeed)
|
||||
r.Patch("/feeds/{id}", s.handleUpdateFeed)
|
||||
r.Delete("/feeds/{id}", s.handleDeleteFeed)
|
||||
r.Post("/feeds/{id}/sync", s.handleV1SyncFeed)
|
||||
r.Get("/feeds/{id}/mappings", s.handleGetFeedMappings)
|
||||
r.Put("/feeds/{id}/mappings", s.handlePutFeedMappings)
|
||||
r.Post("/feeds/{id}/extract-schema", s.handleExtractFeedSchema)
|
||||
r.Post("/feeds/{id}/sync-process-sample", s.handleSyncAndProcessSample)
|
||||
|
||||
r.Get("/export-feeds", s.handleV1ListExportFeeds)
|
||||
r.Post("/export-feeds", s.handleV1CreateExportFeed)
|
||||
r.Get("/export-feeds/{id}", s.handleGetExportFeed)
|
||||
r.Patch("/export-feeds/{id}", s.handleUpdateExportFeed)
|
||||
r.Put("/export-feeds/{id}/template", s.handleUpdateExportFeedTemplate)
|
||||
r.Delete("/export-feeds/{id}", s.handleDeleteExportFeed)
|
||||
r.Post("/export-feeds/{id}/rotate-token", s.handleRotateExportFeedPublicToken)
|
||||
r.Post("/export-feeds/{id}/generate", s.handleV1GenerateExportFeed)
|
||||
r.Post("/export-feeds/{id}/export-products", s.handleExportSelectedProducts)
|
||||
|
||||
// Dashboard-style jobs (flat JSON / 202). Prefer /products/process for legacy integrations.
|
||||
r.Post("/process", s.handleStartProcessingJob)
|
||||
r.Get("/process", s.handleV1ListProcessJobs)
|
||||
r.Get("/process/{id}", s.handleGetProcessingJob)
|
||||
r.Post("/process/{id}/cancel", s.handleCancelProcessingJob)
|
||||
r.Post("/process/{id}/terminate", s.handleCancelProcessingJob)
|
||||
r.Post("/process/{id}/retry", s.handleRetryProcessingJob)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleV1ListProcessJobs(w http.ResponseWriter, r *http.Request) {
|
||||
cid, ok := CompanyIDFromContext(r.Context())
|
||||
if !ok || cid == uuid.Nil {
|
||||
Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
limit, _ := ParseLimitOffset(r)
|
||||
items, err := s.Processing.ListJobs(r.Context(), cid, limit)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"jobs": processing.FormatListJobsResponse(items), "limit": limit})
|
||||
}
|
||||
|
||||
func (s *Server) handleV1OpenAPI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||
// Public, immutable-for-process document: browsers / API clients can reuse across visits.
|
||||
w.Header().Set("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
|
||||
w.Header().Set("ETag", v1OpenAPIETag)
|
||||
w.Header().Set("Vary", "Accept-Encoding")
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == v1OpenAPIETag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
if len(v1OpenAPIGzip) > 0 && acceptEncodingIncludesGzip(r.Header.Get("Accept-Encoding")) {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(v1OpenAPIGzip)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(v1OpenAPIYAML)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractAPIKeyBearerAndHeader(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
|
||||
r.Header.Set("Authorization", "Bearer dk_abc")
|
||||
if got := extractAPIKey(r); got != "dk_abc" {
|
||||
t.Fatalf("bearer: got %q", got)
|
||||
}
|
||||
|
||||
r2 := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
|
||||
r2.Header.Set("X-API-Key", "dk_xyz")
|
||||
if got := extractAPIKey(r2); got != "dk_xyz" {
|
||||
t.Fatalf("x-api-key: got %q", got)
|
||||
}
|
||||
|
||||
r3 := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
|
||||
r3.Header.Set("X-API-Key", "dk_header")
|
||||
r3.Header.Set("Authorization", "Bearer dk_bearer")
|
||||
if got := extractAPIKey(r3); got != "dk_bearer" {
|
||||
t.Fatalf("bearer should win (legacy): got %q", got)
|
||||
}
|
||||
|
||||
r3b := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
|
||||
r3b.Header.Set("X-Api-Key", "dk_legacy_spelling")
|
||||
if got := extractAPIKey(r3b); got != "dk_legacy_spelling" {
|
||||
t.Fatalf("X-Api-Key: got %q", got)
|
||||
}
|
||||
|
||||
r4 := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
|
||||
if got := extractAPIKey(r4); got != "" {
|
||||
t.Fatalf("missing key: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLimitOffset(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/x?limit=10&offset=5", nil)
|
||||
limit, offset := ParseLimitOffset(r)
|
||||
if limit != 10 || offset != 5 {
|
||||
t.Fatalf("got limit=%d offset=%d", limit, offset)
|
||||
}
|
||||
|
||||
r2 := httptest.NewRequest(http.MethodGet, "/x?limit=999&offset=-1", nil)
|
||||
limit, offset = ParseLimitOffset(r2)
|
||||
if limit != maxPageLimit || offset != 0 {
|
||||
t.Fatalf("caps: got limit=%d offset=%d want max=%d", limit, offset, maxPageLimit)
|
||||
}
|
||||
|
||||
// Invalid / missing params are silently normalized (not 400).
|
||||
r3 := httptest.NewRequest(http.MethodGet, "/x?limit=abc&offset=xyz", nil)
|
||||
limit, offset = ParseLimitOffset(r3)
|
||||
if limit != defaultPageLimit || offset != 0 {
|
||||
t.Fatalf("invalid normalize: got limit=%d offset=%d", limit, offset)
|
||||
}
|
||||
|
||||
r4 := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
limit, offset = ParseLimitOffset(r4)
|
||||
if limit != defaultPageLimit || offset != 0 {
|
||||
t.Fatalf("defaults: got limit=%d offset=%d", limit, offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLimitOffsetMax(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/x?limit=1500", nil)
|
||||
limit, _ := ParseLimitOffsetMax(r, maxTreePageLimit)
|
||||
if limit != 1500 {
|
||||
t.Fatalf("got limit=%d want 1500", limit)
|
||||
}
|
||||
limit, _ = ParseLimitOffsetMax(r, maxPageLimit)
|
||||
if limit != maxPageLimit {
|
||||
t.Fatalf("got limit=%d want %d", limit, maxPageLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageSlice(t *testing.T) {
|
||||
items := []int{1, 2, 3, 4, 5}
|
||||
page, total := pageSlice(items, 2, 1)
|
||||
if total != 5 || len(page) != 2 || page[0] != 2 || page[1] != 3 {
|
||||
t.Fatalf("page=%v total=%d", page, total)
|
||||
}
|
||||
page, total = pageSlice(items, 10, 10)
|
||||
if total != 5 || len(page) != 0 {
|
||||
t.Fatalf("empty page expected, got %v total=%d", page, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCampaignsNilServicePreservesParsedLimit(t *testing.T) {
|
||||
s := &Server{}
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/campaigns?limit=7&offset=3", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleListCampaigns(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"limit":7`) || !strings.Contains(body, `"offset":3`) || !strings.Contains(body, `"total":0`) {
|
||||
t.Fatalf("unexpected body %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1OpenAPIDocumentsPublicAPIAuth(t *testing.T) {
|
||||
body := string(v1OpenAPIYAML)
|
||||
for _, want := range []string{
|
||||
"BearerAuth:",
|
||||
"ApiKeyAuth:",
|
||||
"name: X-API-Key",
|
||||
"## Authentication",
|
||||
"/settings?tab=api-keys",
|
||||
"https://descrybe.io/api/v1",
|
||||
"Use my API key",
|
||||
"30 requests per minute",
|
||||
"Retry-After",
|
||||
"rate limit exceeded",
|
||||
"code: unauthorized",
|
||||
"LegacyAPIError",
|
||||
"security: []",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("OpenAPI missing auth doc %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "Forbidden:") {
|
||||
t.Fatal("OpenAPI missing Forbidden response component")
|
||||
}
|
||||
// Public probes must opt out of document-level API-key security.
|
||||
if !strings.Contains(body, "/health:") || !strings.Contains(body, "/openapi.yaml:") {
|
||||
t.Fatal("OpenAPI missing public health/openapi paths")
|
||||
}
|
||||
for _, heavy := range []string{
|
||||
"/products/process:",
|
||||
"/feeds/{id}/sync:",
|
||||
"/feeds/{id}/extract-schema:",
|
||||
"/feeds/{id}/sync-process-sample:",
|
||||
"/export-feeds/{id}/generate:",
|
||||
"/export-feeds/{id}/export-products:",
|
||||
"/process:",
|
||||
"/process/{id}/retry:",
|
||||
} {
|
||||
if !strings.Contains(body, heavy) {
|
||||
t.Fatalf("OpenAPI missing heavy path %q", heavy)
|
||||
}
|
||||
}
|
||||
// Heavy mutations document 429 via shared component.
|
||||
if strings.Count(body, `"429": { $ref: "#/components/responses/TooManyRequests" }`) < 6 {
|
||||
t.Fatalf("expected multiple TooManyRequests refs on heavy mutations, got %d",
|
||||
strings.Count(body, `"429": { $ref: "#/components/responses/TooManyRequests" }`))
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1OpenAPIRouteMounted(t *testing.T) {
|
||||
s := &Server{}
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleV1OpenAPI(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", w.Code)
|
||||
}
|
||||
if body := w.Body.String(); len(body) < 20 || body[:8] != "openapi:" {
|
||||
t.Fatalf("unexpected body prefix %q", body[:min(20, len(body))])
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=") || !strings.Contains(cc, "stale-while-revalidate=") {
|
||||
t.Fatalf("unexpected Cache-Control %q", cc)
|
||||
}
|
||||
if vary := w.Header().Get("Vary"); !strings.Contains(vary, "Accept-Encoding") {
|
||||
t.Fatalf("unexpected Vary %q", vary)
|
||||
}
|
||||
etag := w.Header().Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("missing ETag")
|
||||
}
|
||||
r304 := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
|
||||
r304.Header.Set("If-None-Match", etag)
|
||||
w304 := httptest.NewRecorder()
|
||||
s.handleV1OpenAPI(w304, r304)
|
||||
if w304.Code != http.StatusNotModified {
|
||||
t.Fatalf("If-None-Match status %d", w304.Code)
|
||||
}
|
||||
|
||||
rGzip := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
|
||||
rGzip.Header.Set("Accept-Encoding", "gzip")
|
||||
wGzip := httptest.NewRecorder()
|
||||
s.handleV1OpenAPI(wGzip, rGzip)
|
||||
if wGzip.Code != http.StatusOK {
|
||||
t.Fatalf("gzip status %d", wGzip.Code)
|
||||
}
|
||||
if wGzip.Header().Get("Content-Encoding") != "gzip" {
|
||||
t.Fatalf("expected Content-Encoding gzip, got %q", wGzip.Header().Get("Content-Encoding"))
|
||||
}
|
||||
if len(wGzip.Body.Bytes()) == 0 || wGzip.Body.Len() >= len(v1OpenAPIYAML) {
|
||||
t.Fatalf("gzip body should be non-empty and smaller than raw (%d vs %d)", wGzip.Body.Len(), len(v1OpenAPIYAML))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func testAPIServer() *Server {
|
||||
sm := scs.New()
|
||||
sm.Cookie.Name = "descrybe_session"
|
||||
return &Server{
|
||||
Config: config.Config{
|
||||
CSRFCookieName: "descrybe_csrf",
|
||||
WebOrigin: "http://localhost:5173",
|
||||
},
|
||||
Sessions: sm,
|
||||
Auth: &auth.Service{},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAPIKeyUnauthorizedWithoutKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
h := s.RequireAPIKey(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/products", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `"code":"unauthorized"`) || !strings.Contains(body, `"message":"Unauthorized"`) {
|
||||
t.Fatalf("want legacy coded error envelope, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAPIKeyBindsTenantContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
companyID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
userID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), ctxUserID, userID)
|
||||
ctx = context.WithValue(ctx, ctxCompanyID, companyID)
|
||||
ctx = context.WithValue(ctx, ctxRole, "api")
|
||||
cid, ok := CompanyIDFromContext(ctx)
|
||||
if !ok || cid != companyID {
|
||||
t.Fatalf("company binding failed: %v ok=%v", cid, ok)
|
||||
}
|
||||
uid, ok := UserIDFromContext(ctx)
|
||||
if !ok || uid != userID {
|
||||
t.Fatalf("user binding failed: %v ok=%v", uid, ok)
|
||||
}
|
||||
role, _ := RoleFromContext(ctx)
|
||||
if role != "api" {
|
||||
t.Fatalf("role = %q", role)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/products", nil))
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterV1POSTSkipsCSRFDashboardStillRequires(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
h := s.Router()
|
||||
|
||||
// /api/v1 mutating call without CSRF cookie/header must not be 403 csrf;
|
||||
// without a valid API key it should be 401 from RequireAPIKey.
|
||||
v1 := httptest.NewRecorder()
|
||||
reqV1 := httptest.NewRequest(http.MethodPost, "/api/v1/categories", nil)
|
||||
h.ServeHTTP(v1, reqV1)
|
||||
if v1.Code == http.StatusForbidden {
|
||||
t.Fatalf("v1 must skip CSRF; got 403 body=%s", v1.Body.String())
|
||||
}
|
||||
if v1.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("v1 without API key status = %d, want 401", v1.Code)
|
||||
}
|
||||
|
||||
dash := httptest.NewRecorder()
|
||||
reqDash := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
h.ServeHTTP(dash, reqDash)
|
||||
if dash.Code != http.StatusForbidden {
|
||||
t.Fatalf("dashboard POST without CSRF status = %d, want 403", dash.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterV1OpenAPIAndHealthNoAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
h := s.Router()
|
||||
|
||||
openAPI := httptest.NewRecorder()
|
||||
h.ServeHTTP(openAPI, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil))
|
||||
if openAPI.Code != http.StatusOK {
|
||||
t.Fatalf("openapi status = %d", openAPI.Code)
|
||||
}
|
||||
|
||||
health := httptest.NewRecorder()
|
||||
h.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
|
||||
if health.Code != http.StatusOK {
|
||||
t.Fatalf("v1 health status = %d", health.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterV1LegacyAliasesRequireAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
h := s.Router()
|
||||
|
||||
paths := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/products/process"},
|
||||
{http.MethodGet, "/api/v1/products/process/11111111-1111-1111-1111-111111111111"},
|
||||
{http.MethodPost, "/api/v1/categories/create"},
|
||||
{http.MethodPost, "/api/v1/attributes/create"},
|
||||
{http.MethodPost, "/api/v1/process"},
|
||||
}
|
||||
for _, tc := range paths {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s status = %d, want 401", tc.method, tc.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := string(v1OpenAPIYAML)
|
||||
for _, needle := range []string{
|
||||
"/products/process:",
|
||||
"/process:",
|
||||
"/feeds:",
|
||||
"/categories/create:",
|
||||
"/attributes/create:",
|
||||
"raw_product_ids",
|
||||
"items[].ean",
|
||||
"process_id",
|
||||
"LegacyStartProcessByEAN",
|
||||
"StartProcessByRawIDs",
|
||||
"LegacyProcessCompleted",
|
||||
"X-API-Key",
|
||||
"https://descrybe.io/api/v1",
|
||||
"BearerAuth",
|
||||
"ApiKeyAuth",
|
||||
"Use my API key",
|
||||
"Settings -> API keys",
|
||||
"mapped_total",
|
||||
"active_total",
|
||||
"needs_review",
|
||||
"HealthStatus",
|
||||
"maintenance",
|
||||
"read_only",
|
||||
"FeedListResponse",
|
||||
"ProductListResponse",
|
||||
"PresentProduct",
|
||||
"ProductQualityListResponse",
|
||||
"/products/quality:",
|
||||
"Wireless earbuds",
|
||||
"ProcessingJobAccepted",
|
||||
// Team/Admin paths are dashboard OFF_SURFACE (session+CSRF under /api),
|
||||
// not part of the public API-key contract needles for this doc.
|
||||
"ReissueSetPasswordInvite",
|
||||
"cannot demote the last admin",
|
||||
"skipped_synthetic",
|
||||
"SessionCookie",
|
||||
"CSRFHeader",
|
||||
"code: unauthorized",
|
||||
"message: Unauthorized",
|
||||
"legacy envelope",
|
||||
"/process/{id}/retry:",
|
||||
} {
|
||||
if !strings.Contains(body, needle) {
|
||||
t.Fatalf("openapi missing %q", needle)
|
||||
}
|
||||
}
|
||||
// Dual-mode: legacy EAN path must not be described as an alias of /process.
|
||||
if strings.Contains(body, "Alias of POST /process") {
|
||||
t.Fatal("openapi still treats /products/process as alias of /process")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TestV1DomainResourceCRUD exercises public /api/v1 catalog+feed CRUD with
|
||||
// semi-real merchant data against a live DATABASE_URL (skips when unset).
|
||||
func TestV1DomainResourceCRUD(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
userID := uuid.New()
|
||||
prefix := companyID.String()[:8]
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
|
||||
companyID, "merchant-crud-"+prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("seed company: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
s := &Server{
|
||||
Config: config.Config{WebOrigin: "http://localhost:5173"},
|
||||
Pool: pg,
|
||||
Catalog: &catalog.Service{Pool: pg},
|
||||
Feeds: &feeds.Service{Pool: pg},
|
||||
}
|
||||
h := mountV1DomainTestRouter(s)
|
||||
|
||||
withTenant := func(r *http.Request) *http.Request {
|
||||
c := context.WithValue(r.Context(), ctxCompanyID, companyID)
|
||||
c = context.WithValue(c, ctxUserID, userID)
|
||||
c = context.WithValue(c, ctxRole, "api")
|
||||
return r.WithContext(c)
|
||||
}
|
||||
do := func(method, path, body string) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body == "" {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, withTenant(req))
|
||||
return rec
|
||||
}
|
||||
decode := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("json status=%d body=%s err=%v", rec.Code, rec.Body.String(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Categories CRUD ---
|
||||
catUnique := "electronics-" + prefix
|
||||
rec := do(http.MethodPost, "/api/v1/categories", fmt.Sprintf(
|
||||
`{"name":"Electronics","unique_id":%q,"description":"Consumer electronics for Nordic merchants"}`, catUnique))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create category status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
createdCat := decode(t, rec)
|
||||
catData, _ := createdCat["data"].(map[string]any)
|
||||
if catData["unique_id"] != catUnique || catData["name"] != "Electronics" {
|
||||
t.Fatalf("create category data=%v", catData)
|
||||
}
|
||||
catUUID, err := uuid.Parse(fmt.Sprint(catData["id"]))
|
||||
if err != nil {
|
||||
t.Fatalf("category id: %v", err)
|
||||
}
|
||||
|
||||
childUnique := "headphones-" + prefix
|
||||
rec = do(http.MethodPost, "/api/v1/categories/create", fmt.Sprintf(
|
||||
`{"name":"Headphones","unique_id":%q,"parent_id":%q}`, childUnique, catUnique))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create child category status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = do(http.MethodGet, "/api/v1/categories?page=1&limit=25&search=Electronics", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list categories status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
listCat := decode(t, rec)
|
||||
if _, ok := listCat["data"]; !ok {
|
||||
t.Fatalf("list categories missing data envelope: %v", listCat)
|
||||
}
|
||||
meta, _ := listCat["meta"].(map[string]any)
|
||||
if meta["page"].(float64) != 1 || meta["limit"].(float64) != 25 {
|
||||
t.Fatalf("list categories meta=%v", meta)
|
||||
}
|
||||
|
||||
rec = do(http.MethodGet, "/api/v1/categories/"+catUUID.String(), "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get category status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
gotCat := decode(t, rec)
|
||||
if _, hasData := gotCat["data"]; hasData {
|
||||
t.Fatalf("GET /categories/{uuid} should be flat dashboard JSON, got envelope: %v", gotCat)
|
||||
}
|
||||
if gotCat["unique_id"] != catUnique {
|
||||
t.Fatalf("get category=%v", gotCat)
|
||||
}
|
||||
|
||||
rec = do(http.MethodPatch, "/api/v1/categories/"+catUUID.String(),
|
||||
`{"name":"Electronics & Audio"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch category status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
patchedCat := decode(t, rec)
|
||||
if patchedCat["name"] != "Electronics & Audio" {
|
||||
t.Fatalf("patched category=%v", patchedCat)
|
||||
}
|
||||
|
||||
// --- Attributes CRUD ---
|
||||
rec = do(http.MethodPost, "/api/v1/attributes", fmt.Sprintf(
|
||||
`{"name":"Color","attribute_key":"color_%s","value_type":"string","category_unique_id":%q,"required":true}`,
|
||||
prefix, catUnique))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create attribute status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
attrEnv := decode(t, rec)
|
||||
attrData, _ := attrEnv["data"].(map[string]any)
|
||||
if attrData["key"] == nil || attrData["category_unique_id"] != catUnique || attrData["required"] != true {
|
||||
t.Fatalf("create attribute data=%v", attrData)
|
||||
}
|
||||
attrID := fmt.Sprint(attrData["id"])
|
||||
|
||||
rec = do(http.MethodGet, "/api/v1/attributes?page=1&limit=25&categoryId="+catUnique, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list attributes status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
listAttr := decode(t, rec)
|
||||
if _, ok := listAttr["data"]; !ok {
|
||||
t.Fatalf("list attributes missing data: %v", listAttr)
|
||||
}
|
||||
|
||||
rec = do(http.MethodPatch, "/api/v1/attributes/"+attrID, `{"name":"Colour"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch attribute status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// --- Feeds CRUD ---
|
||||
// Legacy create: name + item_path without URL (avoids live SSRF DNS for merchant hosts).
|
||||
rec = do(http.MethodPost, "/api/v1/feeds", `{
|
||||
"name":"Main catalog XML",
|
||||
"item_path":"channel/item",
|
||||
"feed_type":"xml",
|
||||
"sync_interval_minutes":60,
|
||||
"is_active":true
|
||||
}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create feed status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
feedEnv := decode(t, rec)
|
||||
feedData, _ := feedEnv["data"].(map[string]any)
|
||||
if feedData["name"] != "Main catalog XML" || feedData["item_path"] != "channel/item" {
|
||||
t.Fatalf("create feed data=%v", feedData)
|
||||
}
|
||||
if feedData["is_active"] != true {
|
||||
t.Fatalf("create feed is_active should be true after flip, got %v", feedData)
|
||||
}
|
||||
feedID := fmt.Sprint(feedData["id"])
|
||||
|
||||
rec = do(http.MethodGet, "/api/v1/feeds?page=1&limit=25", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list feeds status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
listFeeds := decode(t, rec)
|
||||
if _, ok := listFeeds["data"]; !ok {
|
||||
t.Fatalf("list feeds missing data: %v", listFeeds)
|
||||
}
|
||||
|
||||
rec = do(http.MethodGet, "/api/v1/feeds/"+feedID, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get feed status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
getFeed := decode(t, rec)
|
||||
getFeedData, _ := getFeed["data"].(map[string]any)
|
||||
if getFeedData["id"] != feedID {
|
||||
t.Fatalf("get feed=%v", getFeed)
|
||||
}
|
||||
|
||||
rec = do(http.MethodPatch, "/api/v1/feeds/"+feedID, `{"name":"Main catalog XML (Nordic)"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch feed status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
patchFeed := decode(t, rec)
|
||||
if _, hasData := patchFeed["data"]; hasData {
|
||||
t.Fatalf("PATCH /feeds/{id} should be flat PresentFeed JSON, got envelope: %v", patchFeed)
|
||||
}
|
||||
if patchFeed["name"] != "Main catalog XML (Nordic)" {
|
||||
t.Fatalf("patched feed=%v", patchFeed)
|
||||
}
|
||||
|
||||
rec = do(http.MethodPut, "/api/v1/feeds/"+feedID+"/mappings",
|
||||
`{"mappings":{"title":"g:title","gtin":"g:gtin","description":"g:description"}}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put mappings status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// --- Export feeds CRUD ---
|
||||
rec = do(http.MethodPost, "/api/v1/export-feeds", `{
|
||||
"name":"Google Shopping XML",
|
||||
"format":"xml",
|
||||
"source_feed_id":`+fmt.Sprintf("%q", feedID)+`
|
||||
}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create export feed status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
expEnv := decode(t, rec)
|
||||
expData, _ := expEnv["data"].(map[string]any)
|
||||
if expData["name"] != "Google Shopping XML" || expData["format"] != "xml" {
|
||||
t.Fatalf("create export=%v", expData)
|
||||
}
|
||||
if expData["public_token"] == nil || expData["public_token"] == "" {
|
||||
t.Fatalf("export missing public_token: %v", expData)
|
||||
}
|
||||
oldPublicToken := fmt.Sprint(expData["public_token"])
|
||||
expID := fmt.Sprint(expData["id"])
|
||||
|
||||
rec = do(http.MethodPost, "/api/v1/export-feeds/"+expID+"/rotate-token", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("rotate export token status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rotated := decode(t, rec)
|
||||
newPublicToken := fmt.Sprint(rotated["public_token"])
|
||||
if newPublicToken == "" || newPublicToken == "<nil>" || newPublicToken == oldPublicToken {
|
||||
t.Fatalf("rotate did not replace public_token old=%q new=%q body=%v", oldPublicToken, newPublicToken, rotated)
|
||||
}
|
||||
if len(newPublicToken) != 64 {
|
||||
t.Fatalf("rotated public_token len=%d want 64", len(newPublicToken))
|
||||
}
|
||||
|
||||
rec = do(http.MethodGet, "/api/v1/export-feeds?page=1&limit=25", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list export feeds status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
listExp := decode(t, rec)
|
||||
if _, ok := listExp["data"]; !ok {
|
||||
t.Fatalf("list export missing data: %v", listExp)
|
||||
}
|
||||
|
||||
rec = do(http.MethodGet, "/api/v1/export-feeds/"+expID, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get export feed status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
getExp := decode(t, rec)
|
||||
if _, hasData := getExp["data"]; hasData {
|
||||
t.Fatalf("GET /export-feeds/{id} should be flat JSON, got envelope: %v", getExp)
|
||||
}
|
||||
|
||||
rec = do(http.MethodPatch, "/api/v1/export-feeds/"+expID, `{"name":"Google Shopping XML v2","is_active":true}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch export status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = do(http.MethodPut, "/api/v1/export-feeds/"+expID+"/template",
|
||||
`{"template":{"root":"rss/channel","item":"item","mappings":{"title":"title"}}}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put template status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// --- Products list + seed get/patch ---
|
||||
rec = do(http.MethodGet, "/api/v1/products?page=1&limit=25", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list products status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
prodList := decode(t, rec)
|
||||
if _, ok := prodList["data"]; !ok {
|
||||
t.Fatalf("list products missing data: %v", prodList)
|
||||
}
|
||||
|
||||
productID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO processed_products (
|
||||
id, company_id, product_id, name, category, description, status,
|
||||
processed_name, processed_description, attributes, processed_attributes, feed_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, 'completed',
|
||||
$7, $8, '{}'::jsonb, '{}'::jsonb, $9
|
||||
)`,
|
||||
productID, companyID, "SKU-1001", "Wireless earbuds", catUnique,
|
||||
"Original catalog description",
|
||||
"Acme Wireless Earbuds ANC Black",
|
||||
"Noise-cancelling wireless earbuds with 24h battery life.",
|
||||
uuid.MustParse(feedID),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seed processed product: %v", err)
|
||||
}
|
||||
|
||||
rec = do(http.MethodGet, "/api/v1/products/"+productID.String(), "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get product status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
gotProd := decode(t, rec)
|
||||
if _, hasData := gotProd["data"]; hasData {
|
||||
t.Fatalf("GET /products/{id} should be flat JSON, got envelope: %v", gotProd)
|
||||
}
|
||||
if fmt.Sprint(gotProd["product_id"]) != "SKU-1001" {
|
||||
t.Fatalf("get product=%v", gotProd)
|
||||
}
|
||||
|
||||
rec = do(http.MethodPatch, "/api/v1/products/"+productID.String(),
|
||||
`{"processed_name":"Acme Wireless Earbuds ANC Midnight","status":"completed"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch product status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = do(http.MethodGet, "/api/v1/products/quality?page=1&limit=25", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list quality status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// --- Campaigns / marketing calendar ---
|
||||
rec = do(http.MethodGet, "/api/v1/campaigns?year=2026", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list campaigns status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
camp := decode(t, rec)
|
||||
campData, _ := camp["data"].(map[string]any)
|
||||
if campData["year"].(float64) != 2026 {
|
||||
t.Fatalf("campaigns data=%v", campData)
|
||||
}
|
||||
presets, _ := campData["presets"].([]any)
|
||||
if len(presets) == 0 {
|
||||
t.Fatalf("expected seasonal presets, got %v", campData)
|
||||
}
|
||||
|
||||
rec = do(http.MethodGet, "/api/v1/marketing/calendar?year=2026", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("marketing calendar status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cal := decode(t, rec)
|
||||
if _, hasData := cal["data"]; hasData {
|
||||
t.Fatalf("GET /marketing/calendar should be flat JSON per OpenAPI, got envelope: %v", cal)
|
||||
}
|
||||
|
||||
rec = do(http.MethodPost, "/api/v1/campaigns/prepare",
|
||||
`{"preset_id":"black_friday","year":2026,"format":"csv"}`)
|
||||
if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
|
||||
t.Fatalf("prepare campaign status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
prep := decode(t, rec)
|
||||
prepData, _ := prep["data"].(map[string]any)
|
||||
if prepData["preset_id"] != "black_friday" {
|
||||
t.Fatalf("prepare data=%v", prepData)
|
||||
}
|
||||
|
||||
// --- Deletes (reverse dependency order) ---
|
||||
rec = do(http.MethodDelete, "/api/v1/export-feeds/"+expID, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete export status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = do(http.MethodDelete, "/api/v1/feeds/"+feedID, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete feed status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
delFeed := decode(t, rec)
|
||||
if delFeed["deleted"] != true {
|
||||
t.Fatalf("delete feed response=%v", delFeed)
|
||||
}
|
||||
|
||||
rec = do(http.MethodDelete, "/api/v1/attributes/"+attrID, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete attribute status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
delAttr := decode(t, rec)
|
||||
delAttrData, _ := delAttr["data"].(map[string]any)
|
||||
if delAttrData["message"] != "Attribute deleted successfully" {
|
||||
t.Fatalf("delete attribute=%v", delAttr)
|
||||
}
|
||||
|
||||
rec = do(http.MethodDelete, "/api/v1/categories/"+childUnique, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete child category status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = do(http.MethodDelete, "/api/v1/categories/"+catUnique, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete category status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
delCat := decode(t, rec)
|
||||
delCatData, _ := delCat["data"].(map[string]any)
|
||||
if delCatData["message"] != "Category deleted successfully" {
|
||||
t.Fatalf("delete category=%v", delCat)
|
||||
}
|
||||
|
||||
// Confirm 404 after delete
|
||||
rec = do(http.MethodGet, "/api/v1/feeds/"+feedID, "")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("get deleted feed status=%d want 404 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func mountV1DomainTestRouter(s *Server) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/products", s.handleV1ListProducts)
|
||||
r.Get("/products/quality", s.handleV1ListProductQuality)
|
||||
r.Get("/products/{id}", s.handleGetProduct)
|
||||
r.Patch("/products/{id}", s.handleUpdateProduct)
|
||||
|
||||
r.Get("/marketing/calendar", s.handleGetMarketingCalendar)
|
||||
r.Post("/marketing/calendar/prepare", s.handlePrepareMarketingCalendar)
|
||||
r.Get("/campaigns", s.handleV1ListCampaigns)
|
||||
r.Post("/campaigns/prepare", s.handleV1PrepareCampaign)
|
||||
|
||||
r.Get("/categories", s.handleV1ListCategories)
|
||||
r.Post("/categories", s.handleV1CreateCategory)
|
||||
r.Post("/categories/create", s.handleV1CreateCategory)
|
||||
r.Get("/categories/{id}", s.handleGetCategory)
|
||||
r.Patch("/categories/{id}", s.handleUpdateCategory)
|
||||
r.Delete("/categories/{id}", s.handleV1DeleteCategory)
|
||||
|
||||
r.Get("/attributes", s.handleV1ListAttributes)
|
||||
r.Post("/attributes", s.handleV1CreateAttribute)
|
||||
r.Post("/attributes/create", s.handleV1CreateAttribute)
|
||||
r.Patch("/attributes/{id}", s.handleUpdateAttribute)
|
||||
r.Delete("/attributes/{id}", s.handleV1DeleteAttribute)
|
||||
|
||||
r.Get("/feeds", s.handleV1ListFeeds)
|
||||
r.Post("/feeds", s.handleV1CreateFeed)
|
||||
r.Get("/feeds/{id}", s.handleV1GetFeed)
|
||||
r.Patch("/feeds/{id}", s.handleUpdateFeed)
|
||||
r.Delete("/feeds/{id}", s.handleDeleteFeed)
|
||||
r.Get("/feeds/{id}/mappings", s.handleGetFeedMappings)
|
||||
r.Put("/feeds/{id}/mappings", s.handlePutFeedMappings)
|
||||
|
||||
r.Get("/export-feeds", s.handleV1ListExportFeeds)
|
||||
r.Post("/export-feeds", s.handleV1CreateExportFeed)
|
||||
r.Get("/export-feeds/{id}", s.handleGetExportFeed)
|
||||
r.Patch("/export-feeds/{id}", s.handleUpdateExportFeed)
|
||||
r.Put("/export-feeds/{id}/template", s.handleUpdateExportFeedTemplate)
|
||||
r.Delete("/export-feeds/{id}", s.handleDeleteExportFeed)
|
||||
r.Post("/export-feeds/{id}/rotate-token", s.handleRotateExportFeedPublicToken)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestV1OpenAPIDocumentsDomainCRUDSurface(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := string(v1OpenAPIYAML)
|
||||
needles := []string{
|
||||
"/categories:",
|
||||
"/attributes:",
|
||||
"/feeds:",
|
||||
"/export-feeds:",
|
||||
"/export-feeds/{id}/rotate-token:",
|
||||
"/campaigns:",
|
||||
"/marketing/calendar:",
|
||||
"/products:",
|
||||
"/feeds/{id}/sync-process-sample:",
|
||||
"Flat category JSON",
|
||||
"flat PresentFeed",
|
||||
"flat ProcessedProduct",
|
||||
"CategoryDetail",
|
||||
"FeedDeleted",
|
||||
"FeedMappings",
|
||||
"is_active:",
|
||||
}
|
||||
for _, n := range needles {
|
||||
if !strings.Contains(body, n) {
|
||||
t.Fatalf("openapi missing %q", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/marketing"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// presentV1ExportFeed shapes an export feed for the legacy public API
|
||||
// (presentExportFeed + v2 public_token extras).
|
||||
func presentV1ExportFeed(item map[string]any) map[string]any {
|
||||
if item == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": item["id"],
|
||||
"name": item["name"],
|
||||
"format": item["format"],
|
||||
"root_xpath": nil,
|
||||
"item_xpath": nil,
|
||||
"mappings": map[string]any{},
|
||||
"structure": nil,
|
||||
"last_generated_at": item["last_generated_at"],
|
||||
"created_at": item["created_at"],
|
||||
"updated_at": item["updated_at"],
|
||||
"public_token": item["public_token"],
|
||||
"is_active": item["is_active"],
|
||||
"source_feed_id": item["source_feed_id"],
|
||||
}
|
||||
if v, ok := item["template"]; ok && v != nil {
|
||||
out["structure"] = v
|
||||
}
|
||||
if v, ok := item["filters"]; ok && v != nil {
|
||||
out["filters"] = v
|
||||
}
|
||||
if token, _ := item["public_token"].(string); token != "" {
|
||||
format := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["format"])))
|
||||
ext := "xml"
|
||||
if format == "csv" {
|
||||
ext = "csv"
|
||||
}
|
||||
path := "/api/public/export-feeds/" + token + "." + ext
|
||||
// Only advertise the matching extension — wrong-format URLs 404 and must not
|
||||
// be suggested (also avoids encouraging token-existence probes).
|
||||
out["public_urls"] = map[string]string{
|
||||
ext: path,
|
||||
"token": path,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleV1ListExportFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
page, limit, offset := ParsePageLimit(r)
|
||||
items, total, err := s.Feeds.ListExportFeeds(r.Context(), cid, limit, offset)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, presentV1ExportFeed(item))
|
||||
}
|
||||
v1OK(w, http.StatusOK, out, map[string]any{
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleV1CreateExportFeed(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Format string `json:"format"`
|
||||
SourceFeedID *string `json:"source_feed_id"`
|
||||
Template any `json:"template"`
|
||||
Structure any `json:"structure"`
|
||||
Mappings any `json:"mappings"`
|
||||
Filters any `json:"filters"`
|
||||
RootXpath *string `json:"root_xpath"`
|
||||
ItemXpath *string `json:"item_xpath"`
|
||||
OutputPath *string `json:"output_path"`
|
||||
AttributeExportMode any `json:"attribute_export_mode"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
v1Err(w, http.StatusBadRequest, "validation_error", "invalid json")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Format) == "" {
|
||||
v1Err(w, http.StatusBadRequest, "validation_error", "Missing required fields: name, format")
|
||||
return
|
||||
}
|
||||
tpl := body.Template
|
||||
if tpl == nil {
|
||||
tpl = body.Structure
|
||||
}
|
||||
if tpl == nil && body.Mappings != nil {
|
||||
tpl = map[string]any{"mappings": body.Mappings}
|
||||
}
|
||||
if tpl == nil && (body.RootXpath != nil || body.ItemXpath != nil) {
|
||||
m := map[string]any{}
|
||||
if body.RootXpath != nil {
|
||||
m["root"] = *body.RootXpath
|
||||
}
|
||||
if body.ItemXpath != nil {
|
||||
m["item"] = *body.ItemXpath
|
||||
}
|
||||
tpl = m
|
||||
}
|
||||
item, err := s.Feeds.CreateExportFeed(r.Context(), cid, feeds.CreateExportInput{
|
||||
Name: body.Name, SourceFeedID: body.SourceFeedID, Format: body.Format,
|
||||
Template: tpl, Filters: body.Filters,
|
||||
})
|
||||
if err != nil {
|
||||
if msg, ok := feeds.ClientError(err); ok {
|
||||
v1Err(w, http.StatusBadRequest, "validation_error", msg)
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not create export feed", err, feeds.ClientError)
|
||||
return
|
||||
}
|
||||
v1OK(w, http.StatusCreated, presentV1ExportFeed(item), nil)
|
||||
}
|
||||
|
||||
func (s *Server) handleV1GenerateExportFeed(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
v1Err(w, http.StatusBadRequest, "validation_error", "invalid id")
|
||||
return
|
||||
}
|
||||
feed, err := s.Feeds.GetExportFeed(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
v1Err(w, http.StatusNotFound, "not_found", "Export feed not found")
|
||||
return
|
||||
}
|
||||
result, err := s.Feeds.GenerateExportFeed(r.Context(), cid, id)
|
||||
if err != nil {
|
||||
if msg, ok := feeds.ClientError(err); ok {
|
||||
v1Err(w, http.StatusBadRequest, "generation_failed", msg)
|
||||
return
|
||||
}
|
||||
v1Err(w, http.StatusInternalServerError, "generation_failed", "Failed to generate export feed")
|
||||
return
|
||||
}
|
||||
format := strings.ToLower(strings.TrimSpace(fmt.Sprint(feed["format"])))
|
||||
if format == "" {
|
||||
format = strings.ToLower(strings.TrimSpace(fmt.Sprint(result["format"])))
|
||||
}
|
||||
ext := "xml"
|
||||
if format == "csv" {
|
||||
ext = "csv"
|
||||
}
|
||||
token, _ := feed["public_token"].(string)
|
||||
downloadURL := fmt.Sprintf("/api/export-feeds/%s/%s", id.String(), ext)
|
||||
if token != "" {
|
||||
downloadURL = fmt.Sprintf("/api/public/export-feeds/%s.%s", token, ext)
|
||||
}
|
||||
v1OK(w, http.StatusOK, map[string]any{
|
||||
"generated": true,
|
||||
"format": format,
|
||||
"filePath": nil,
|
||||
"downloadUrl": downloadURL,
|
||||
"products_exported": result["products_exported"],
|
||||
"last_generated_at": result["last_generated_at"],
|
||||
"status": result["status"],
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// handleV1ListCampaigns is the legacy alias for GET /marketing/calendar
|
||||
// (seasonal export prep — not email /api/campaigns).
|
||||
func (s *Server) handleV1ListCampaigns(w http.ResponseWriter, r *http.Request) {
|
||||
payload, status, errCode, errMsg := s.v1MarketingCalendar(r)
|
||||
if errMsg != "" {
|
||||
v1Err(w, status, errCode, errMsg)
|
||||
return
|
||||
}
|
||||
v1OK(w, http.StatusOK, payload, nil)
|
||||
}
|
||||
|
||||
// handleV1PrepareCampaign is the legacy alias for POST /marketing/calendar/prepare.
|
||||
func (s *Server) handleV1PrepareCampaign(w http.ResponseWriter, r *http.Request) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
var body struct {
|
||||
PresetID string `json:"preset_id"`
|
||||
Year int `json:"year"`
|
||||
Format string `json:"format"`
|
||||
ForceNew bool `json:"force_new"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
v1Err(w, http.StatusBadRequest, "validation_error", "invalid json")
|
||||
return
|
||||
}
|
||||
campaign, err := s.marketingService().PrepareCampaign(r.Context(), cid, marketing.PrepareInput{
|
||||
PresetID: marketing.PresetID(body.PresetID),
|
||||
Year: body.Year,
|
||||
Format: body.Format,
|
||||
ForceNew: body.ForceNew,
|
||||
})
|
||||
if err != nil {
|
||||
if msg, ok := marketing.ClientError(err); ok {
|
||||
v1Err(w, http.StatusBadRequest, "validation_error", msg)
|
||||
return
|
||||
}
|
||||
ClientOrLog(w, http.StatusBadRequest, "could not prepare campaign", err, marketing.ClientError)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if campaign.Created {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
v1OK(w, status, map[string]any{
|
||||
"preset_id": campaign.PresetID,
|
||||
"name": campaign.Name,
|
||||
"start_date": campaign.StartDate,
|
||||
"end_date": campaign.EndDate,
|
||||
"year": campaign.Year,
|
||||
"export_feed_id": campaign.ExportFeedID,
|
||||
"export_feed_name": campaign.ExportFeedName,
|
||||
"created": campaign.Created,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (s *Server) v1MarketingCalendar(r *http.Request) (payload map[string]any, status int, errCode, errMsg string) {
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
year := time.Now().UTC().Year()
|
||||
if y := r.URL.Query().Get("year"); y != "" {
|
||||
parsed, err := strconv.Atoi(y)
|
||||
if err != nil || parsed < 2000 || parsed > 2100 {
|
||||
return nil, http.StatusBadRequest, "validation_error", "Invalid year"
|
||||
}
|
||||
year = parsed
|
||||
}
|
||||
prepared, err := s.marketingService().ListPreparedCampaigns(r.Context(), cid)
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, "list_failed", "list failed"
|
||||
}
|
||||
preparedOut := make([]map[string]any, 0, len(prepared))
|
||||
for _, c := range prepared {
|
||||
preparedOut = append(preparedOut, map[string]any{
|
||||
"preset_id": c.PresetID,
|
||||
"name": c.Name,
|
||||
"start_date": c.StartDate,
|
||||
"end_date": c.EndDate,
|
||||
"year": c.Year,
|
||||
"export_feed_id": c.ExportFeedID,
|
||||
"export_feed_name": c.ExportFeedName,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"year": year,
|
||||
"presets": marketing.ListPresets(year),
|
||||
"prepared": preparedOut,
|
||||
}, http.StatusOK, "", ""
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOKEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
v1OK(rec, http.StatusOK, []string{"a"}, map[string]any{"page": 1, "limit": 25, "total": 1})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := body["data"]; !ok {
|
||||
t.Fatalf("missing data: %#v", body)
|
||||
}
|
||||
meta, _ := body["meta"].(map[string]any)
|
||||
if meta["page"] != float64(1) || meta["limit"] != float64(25) {
|
||||
t.Fatalf("meta = %#v", body["meta"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentV1ExportFeedPublicURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := presentV1ExportFeed(map[string]any{
|
||||
"id": "799ba83a-6a7e-4e1d-b5de-c02182aacec5",
|
||||
"name": "XML",
|
||||
"format": "xml",
|
||||
"public_token": "9bf8905985e4c2bb2e5f6a0f89ddc1b6",
|
||||
"is_active": true,
|
||||
})
|
||||
urls, _ := out["public_urls"].(map[string]string)
|
||||
if urls["xml"] != "/api/public/export-feeds/9bf8905985e4c2bb2e5f6a0f89ddc1b6.xml" {
|
||||
t.Fatalf("public_urls = %#v", urls)
|
||||
}
|
||||
if urls["token"] != urls["xml"] {
|
||||
t.Fatalf("token url should match format: %#v", urls)
|
||||
}
|
||||
if _, hasCSV := urls["csv"]; hasCSV {
|
||||
t.Fatalf("must not advertise wrong-format url: %#v", urls)
|
||||
}
|
||||
if out["root_xpath"] != nil || out["mappings"] == nil {
|
||||
t.Fatalf("legacy fields missing: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageLimitOffset(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := httptest.NewRequest(http.MethodGet, "/x?page=2&limit=10", nil)
|
||||
page, limit, offset := ParsePageLimitOffset(r)
|
||||
if page != 2 || limit != 10 || offset != 10 {
|
||||
t.Fatalf("page=%d limit=%d offset=%d", page, limit, offset)
|
||||
}
|
||||
r2 := httptest.NewRequest(http.MethodGet, "/x?offset=5&limit=10", nil)
|
||||
page, limit, offset = ParsePageLimitOffset(r2)
|
||||
if page != 1 || limit != 10 || offset != 5 {
|
||||
t.Fatalf("offset mode: page=%d limit=%d offset=%d", page, limit, offset)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user