package httpapi import ( "context" "net/http" "sync" "time" "github.com/alexedwards/scs/v2" "github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit" "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) // testCompanyOwner optional override for allowCompanyAdminOrPlatform owner checks. testCompanyOwner 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, }) // Campaign / SEO / support AI calls run in this process; capture them for // /admin/ai-calls the same way the worker captures processing. aiSvc.WithAudit(aiaudit.NewRecorder(pool)) 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"}, ExposedHeaders: []string{"X-CSRF-Token", "X-Products-Exported"}, 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) // Collapse stale host-only + Domain session cookie duplicates before scs // reads the request (see DedupeSessionCookies). r.Use(s.DedupeSessionCookies) 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) // User switch / impersonation: platform staff_role=admin (handlers enforce). 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) r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword) r.Get("/companies", s.handleAdminListCompanies) r.Post("/companies/{id}/clone-catalog", s.handleAdminCloneCompanyCatalog) r.Post("/companies/{id}/sync-a1", s.handleAdminSyncCompanyA1) r.Post("/companies/{id}/fix-catalog", s.handleAdminFixCompanyCatalog) // compat alias → Sync A1 r.Get("/readiness", s.handleAdminReadiness) r.Get("/diagnostics", s.handleAdminDiagnostics) r.Get("/ai-calls", s.handleAdminListAICalls) r.Get("/ai-costs", s.handleAdminAICosts) r.Get("/ai-calls/{id}", s.handleAdminGetAICall) 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.RequireMemberFeature) 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.Post("/team/transfer-ownership", s.handleTransferOwnership) r.Get("/team/permission-catalog", s.handleGetPermissionCatalog) r.Patch("/team/{userID}", s.handleUpdateMemberRole) r.Delete("/team/{userID}", s.handleRemoveMember) r.Get("/team/{userID}/permissions", s.handleGetMemberPermissions) r.Put("/team/{userID}/permissions", s.handlePutMemberPermissions) r.Get("/api-keys", s.handleListAPIKeys) r.Post("/api-keys", s.handleCreateAPIKey) r.Patch("/api-keys/{id}", s.handleUpdateAPIKey) 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 "" }