631 lines
28 KiB
Go
631 lines
28 KiB
Go
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"},
|
|
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)
|
|
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 ""
|
|
}
|