Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+629
View File
@@ -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 ""
}