refactor: consolidate integration handlers and clean up UI flows
This commit is contained in:
115
clean-code-review.json
Normal file
115
clean-code-review.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"generated_at": "2026-02-24",
|
||||
"scope": [
|
||||
"cmd/**/*.go",
|
||||
"internal/**/*.go"
|
||||
],
|
||||
"issues": [
|
||||
{
|
||||
"id": "CC-001",
|
||||
"dimension": "functions",
|
||||
"title": "main function does too many things",
|
||||
"principle": "Small Functions + SRP",
|
||||
"location": "cmd/api/main.go:31-620",
|
||||
"severity": "High",
|
||||
"issue": "main is 590 lines and mixes startup configuration, dependency wiring, routes, background jobs, and server lifecycle.",
|
||||
"suggestion": "Extract focused bootstrap and registration functions."
|
||||
},
|
||||
{
|
||||
"id": "CC-002",
|
||||
"dimension": "functions",
|
||||
"title": "SyncConversations is oversized and multi-responsibility",
|
||||
"principle": "Small Functions + SRP",
|
||||
"location": "internal/service/intercom_sync.go:127-247",
|
||||
"severity": "High",
|
||||
"issue": "Method combines pagination, mapping, lookup, persistence, and event emission in a 121-line block.",
|
||||
"suggestion": "Split into dedicated helper functions and keep the outer orchestration thin."
|
||||
},
|
||||
{
|
||||
"id": "CC-003",
|
||||
"dimension": "duplication",
|
||||
"title": "Provider integration handlers duplicate control flow",
|
||||
"principle": "DRY",
|
||||
"location": "internal/handler/integration_stripe.go:27-117; internal/handler/integration_hubspot.go:27-115; internal/handler/integration_intercom.go:27-115",
|
||||
"severity": "High",
|
||||
"issue": "Connect/Callback/Status/Disconnect/TriggerSync patterns are nearly identical across providers.",
|
||||
"suggestion": "Create shared handler helpers or a provider strategy abstraction."
|
||||
},
|
||||
{
|
||||
"id": "CC-004",
|
||||
"dimension": "duplication",
|
||||
"title": "Full and incremental sync logic is repeated",
|
||||
"principle": "DRY",
|
||||
"location": "internal/service/stripe_sync.go:56-428; internal/service/intercom_sync.go:44-411; internal/service/hubspot_sync.go:48-507",
|
||||
"severity": "Medium",
|
||||
"issue": "Multiple full/incremental methods duplicate mapping and upsert pipelines.",
|
||||
"suggestion": "Refactor shared processing paths and keep source-fetch differences isolated."
|
||||
},
|
||||
{
|
||||
"id": "CC-005",
|
||||
"dimension": "functions",
|
||||
"title": "NewAlertScheduler has high parameter count",
|
||||
"principle": "Small Functions + SRP",
|
||||
"location": "internal/service/alert_scheduler.go:29-38",
|
||||
"severity": "Medium",
|
||||
"issue": "Constructor takes 9 parameters, indicating tight coupling.",
|
||||
"suggestion": "Use a dependency struct or grouped dependency bundles."
|
||||
},
|
||||
{
|
||||
"id": "CC-006",
|
||||
"dimension": "magic_numbers",
|
||||
"title": "Hardcoded limits and intervals repeated inline",
|
||||
"principle": "Avoid Hardcoding",
|
||||
"location": "cmd/api/main.go:85,383; internal/handler/integration_stripe.go:138,165; internal/handler/integration_hubspot.go:130; internal/handler/integration_intercom.go:130; internal/handler/billing.go:143",
|
||||
"severity": "Medium",
|
||||
"issue": "Values 300, 60, 65536, and 1024 are embedded directly and repeated.",
|
||||
"suggestion": "Promote to named constants and/or config-driven values."
|
||||
},
|
||||
{
|
||||
"id": "CC-007",
|
||||
"dimension": "yagni",
|
||||
"title": "Service error helper is indirectly coupled to AuthHandler",
|
||||
"principle": "YAGNI",
|
||||
"location": "internal/handler/integration_stripe.go:121-124",
|
||||
"severity": "Medium",
|
||||
"issue": "handleServiceError allocates AuthHandler only to forward error handling.",
|
||||
"suggestion": "Extract a standalone shared HTTP error translation utility."
|
||||
},
|
||||
{
|
||||
"id": "CC-008",
|
||||
"dimension": "structural_clarity",
|
||||
"title": "Route setup is deeply nested",
|
||||
"principle": "Readability First",
|
||||
"location": "cmd/api/main.go:90-578",
|
||||
"severity": "Medium",
|
||||
"issue": "Nested Route/Group closures and conditional blocks reduce readability and local reasoning.",
|
||||
"suggestion": "Split route registration into domain-specific functions."
|
||||
},
|
||||
{
|
||||
"id": "CC-009",
|
||||
"dimension": "conventions",
|
||||
"title": "String-based error matching",
|
||||
"principle": "Consistency",
|
||||
"location": "internal/repository/customer_event.go:47",
|
||||
"severity": "Low",
|
||||
"issue": "Compares error text (\"no rows in result set\") rather than typed error handling.",
|
||||
"suggestion": "Use typed checks (e.g., errors.Is with pgx no-rows sentinel)."
|
||||
},
|
||||
{
|
||||
"id": "CC-010",
|
||||
"dimension": "naming",
|
||||
"title": "Vague temporary buffer names in body reader",
|
||||
"principle": "Meaningful Names",
|
||||
"location": "internal/handler/integration_stripe.go:163-172",
|
||||
"severity": "Low",
|
||||
"issue": "Names like buf/tmp communicate structure but not intent.",
|
||||
"suggestion": "Use intent-revealing names like bodyBytes/chunkBuffer."
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"high": 3,
|
||||
"medium": 5,
|
||||
"low": 2,
|
||||
"total": 10
|
||||
}
|
||||
}
|
||||
94
clean-code-review.md
Normal file
94
clean-code-review.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Clean Code Review
|
||||
|
||||
Scope: `cmd/` and `internal/` Go source files (focus on production code).
|
||||
|
||||
## High Severity
|
||||
|
||||
### Function Issues: `main` function does too many things
|
||||
|
||||
- **Principle**: Small Functions + SRP
|
||||
- **Location**: `cmd/api/main.go:31-620`
|
||||
- **Severity**: High
|
||||
- **Issue**: `main` is 590 lines and mixes configuration, DB wiring, dependency construction, route registration, scheduler startup, and server lifecycle. This creates a high-change-risk hotspot.
|
||||
- **Suggestion**: Extract cohesive units such as `initRuntime()`, `buildRepositories()`, `buildServices()`, `registerRoutes()`, `startBackgroundJobs()`, and `runHTTPServer()`.
|
||||
|
||||
### Function Issues: `SyncConversations` is oversized and multi-responsibility
|
||||
|
||||
- **Principle**: Small Functions + SRP
|
||||
- **Location**: `internal/service/intercom_sync.go:127-247`
|
||||
- **Severity**: High
|
||||
- **Issue**: 121-line method combines API pagination, model mapping, customer lookup, persistence, and event emission.
|
||||
- **Suggestion**: Split into helper methods (`resolveCustomerID`, `mapConversation`, `upsertConversation`, `emitConversationEvent`) and keep orchestration in the top-level method.
|
||||
|
||||
### Duplication Issues: Provider integration handlers repeat near-identical flows
|
||||
|
||||
- **Principle**: DRY
|
||||
- **Location**: `internal/handler/integration_stripe.go:27-117`, `internal/handler/integration_hubspot.go:27-115`, `internal/handler/integration_intercom.go:27-115`
|
||||
- **Severity**: High
|
||||
- **Issue**: `Connect`, `Callback`, `Status`, `Disconnect`, and `TriggerSync` follow almost the same control flow across three files.
|
||||
- **Suggestion**: Introduce shared helper(s) or a provider strategy abstraction for common auth/org lookup, error handling, and sync triggering.
|
||||
|
||||
## Medium Severity
|
||||
|
||||
### Duplication Issues: Full-sync and incremental-sync paths duplicate mapping/upsert logic
|
||||
|
||||
- **Principle**: DRY
|
||||
- **Location**: `internal/service/stripe_sync.go:56-428`, `internal/service/intercom_sync.go:44-411`, `internal/service/hubspot_sync.go:48-507`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Full and incremental methods duplicate entity mapping and persistence logic, increasing drift risk and bug-fix overhead.
|
||||
- **Suggestion**: Refactor to shared processing pipelines where only source iterators differ.
|
||||
|
||||
### Function Issues: Constructor has high parameter count
|
||||
|
||||
- **Principle**: Small Functions + SRP
|
||||
- **Location**: `internal/service/alert_scheduler.go:29-38`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: `NewAlertScheduler` requires 9 parameters, signaling high coupling and reducing readability/testability.
|
||||
- **Suggestion**: Use a dependency struct (e.g., `AlertSchedulerDeps`) or grouped sub-dependencies.
|
||||
|
||||
### Magic Numbers: Repeated hardcoded limits and intervals
|
||||
|
||||
- **Principle**: Avoid Hardcoding
|
||||
- **Location**: `cmd/api/main.go:85`, `cmd/api/main.go:383`, `internal/handler/integration_stripe.go:138`, `internal/handler/integration_stripe.go:165`, `internal/handler/integration_hubspot.go:130`, `internal/handler/integration_intercom.go:130`, `internal/handler/billing.go:143`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Values like `300`, `60`, `65536`, and `1024` appear inline and in multiple places.
|
||||
- **Suggestion**: Replace with named constants (`corsMaxAgeSeconds`, `connectionMonitorIntervalSeconds`, `maxWebhookBodyBytes`, `readChunkBytes`) in shared config/constants.
|
||||
|
||||
### Over-Engineering: Error handling helper is indirectly wired through unrelated handler
|
||||
|
||||
- **Principle**: YAGNI
|
||||
- **Location**: `internal/handler/integration_stripe.go:121-124`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: `handleServiceError` creates an `AuthHandler` solely to call its method, introducing hidden coupling.
|
||||
- **Suggestion**: Move service-error translation into a package-level utility used directly by all handlers.
|
||||
|
||||
### Structural Clarity: Deep nesting in route setup reduces scanability
|
||||
|
||||
- **Principle**: Readability First
|
||||
- **Location**: `cmd/api/main.go:90-578`
|
||||
- **Severity**: Medium
|
||||
- **Issue**: Multiple nested `Route`/`Group` closures and conditional blocks make control flow difficult to follow.
|
||||
- **Suggestion**: Split route registration by domain (`registerAuthRoutes`, `registerBillingRoutes`, `registerIntegrationRoutes`, etc.).
|
||||
|
||||
## Low Severity
|
||||
|
||||
### Project Conventions: String-based error matching is brittle
|
||||
|
||||
- **Principle**: Consistency
|
||||
- **Location**: `internal/repository/customer_event.go:47`
|
||||
- **Severity**: Low
|
||||
- **Issue**: `err.Error() == "no rows in result set"` relies on message text instead of typed errors.
|
||||
- **Suggestion**: Prefer typed checks (e.g., `errors.Is(err, pgx.ErrNoRows)`) to align with idiomatic Go error handling.
|
||||
|
||||
### Naming Issues: Temporary buffer names are vague
|
||||
|
||||
- **Principle**: Meaningful Names
|
||||
- **Location**: `internal/handler/integration_stripe.go:163-172`
|
||||
- **Severity**: Low
|
||||
- **Issue**: `buf` and `tmp` are generic in request body parsing, reducing local readability.
|
||||
- **Suggestion**: Rename to intent-revealing names like `bodyBytes` and `chunkBuffer`.
|
||||
|
||||
## Notes
|
||||
|
||||
- I did **not** flag import ordering style issues; files appear gofmt-consistent.
|
||||
- Severity is maintainability-focused and does not imply current runtime bugs.
|
||||
117
cmd/api/main.go
117
cmd/api/main.go
@@ -28,49 +28,66 @@ import (
|
||||
"github.com/onnwee/pulse-score/internal/service/scoring"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Structured logger
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
const (
|
||||
corsMaxAgeSeconds = 300
|
||||
connectionMonitorIntervalSeconds = 60
|
||||
)
|
||||
|
||||
// Configuration
|
||||
func main() {
|
||||
initLogger()
|
||||
|
||||
cfg := loadAndValidateConfig()
|
||||
pool := openDatabase(cfg)
|
||||
if pool != nil {
|
||||
defer pool.P.Close()
|
||||
}
|
||||
|
||||
jwtMgr := auth.NewJWTManager(cfg.JWT.Secret, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL)
|
||||
r := newRouter(cfg, pool, jwtMgr)
|
||||
srv := newHTTPServer(cfg, r)
|
||||
runServerWithGracefulShutdown(srv, cfg.Server.Environment)
|
||||
}
|
||||
|
||||
func initLogger() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
}
|
||||
|
||||
func loadAndValidateConfig() *config.Config {
|
||||
cfg := config.Load()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
slog.Error("invalid configuration", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Database connection (pgxpool)
|
||||
var pool *database.Pool
|
||||
if cfg.Database.URL != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
dbPool, err := database.NewPool(ctx, database.PoolConfig{
|
||||
URL: cfg.Database.URL,
|
||||
MaxConns: int32(cfg.Database.MaxOpenConns),
|
||||
MinConns: int32(cfg.Database.MaxIdleConns),
|
||||
MaxConnLifetime: time.Duration(cfg.Database.MaxConnLifetime) * time.Second,
|
||||
HealthCheckPeriod: time.Duration(cfg.Database.HealthCheckSec) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("database not reachable at startup", "error", err)
|
||||
} else {
|
||||
pool = &database.Pool{P: dbPool}
|
||||
defer dbPool.Close()
|
||||
}
|
||||
func openDatabase(cfg *config.Config) *database.Pool {
|
||||
if cfg.Database.URL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// JWT manager
|
||||
jwtMgr := auth.NewJWTManager(cfg.JWT.Secret, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Router
|
||||
dbPool, err := database.NewPool(ctx, database.PoolConfig{
|
||||
URL: cfg.Database.URL,
|
||||
MaxConns: int32(cfg.Database.MaxOpenConns),
|
||||
MinConns: int32(cfg.Database.MaxIdleConns),
|
||||
MaxConnLifetime: time.Duration(cfg.Database.MaxConnLifetime) * time.Second,
|
||||
HealthCheckPeriod: time.Duration(cfg.Database.HealthCheckSec) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("database not reachable at startup", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &database.Pool{P: dbPool}
|
||||
}
|
||||
|
||||
func newRouter(cfg *config.Config, pool *database.Pool, jwtMgr *auth.JWTManager) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Global middleware
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(chimw.RealIP)
|
||||
r.Use(chimw.Logger)
|
||||
@@ -82,16 +99,19 @@ func main() {
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Request-ID", "X-Organization-ID"},
|
||||
ExposedHeaders: []string{"X-Request-ID"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
MaxAge: corsMaxAgeSeconds,
|
||||
}))
|
||||
r.Use(httprate.LimitByIP(cfg.Rate.RequestsPerMinute, time.Minute))
|
||||
|
||||
// Health checks (no auth required)
|
||||
health := handler.NewHealthHandler(pool)
|
||||
r.Get("/healthz", health.Liveness)
|
||||
r.Get("/readyz", health.Readiness)
|
||||
|
||||
// API v1 route group
|
||||
registerAPIRoutes(r, cfg, pool, jwtMgr)
|
||||
return r
|
||||
}
|
||||
|
||||
func registerAPIRoutes(r *chi.Mux, cfg *config.Config, pool *database.Pool, jwtMgr *auth.JWTManager) {
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -331,10 +351,17 @@ func main() {
|
||||
notifPrefSvc := service.NewNotificationPreferenceService(notifPrefRepo)
|
||||
|
||||
alertScheduler := service.NewAlertScheduler(
|
||||
alertEngine, emailSvc, emailTemplateSvc,
|
||||
alertHistoryRepo, alertRuleRepo, userRepo,
|
||||
notifPrefSvc,
|
||||
cfg.Alert.EvalIntervalMin, cfg.SendGrid.FrontendURL,
|
||||
service.AlertSchedulerDeps{
|
||||
Engine: alertEngine,
|
||||
EmailService: emailSvc,
|
||||
Templates: emailTemplateSvc,
|
||||
AlertHistory: alertHistoryRepo,
|
||||
AlertRules: alertRuleRepo,
|
||||
UserRepo: userRepo,
|
||||
NotifPrefSvc: notifPrefSvc,
|
||||
},
|
||||
cfg.Alert.EvalIntervalMin,
|
||||
cfg.SendGrid.FrontendURL,
|
||||
)
|
||||
|
||||
// Wire in-app notifications into the alert scheduler
|
||||
@@ -380,7 +407,7 @@ func main() {
|
||||
hubspotClient,
|
||||
intercomOAuthSvc,
|
||||
intercomClient,
|
||||
60,
|
||||
connectionMonitorIntervalSeconds,
|
||||
)
|
||||
go connMonitor.Start(bgCtx)
|
||||
|
||||
@@ -582,23 +609,25 @@ func main() {
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Server
|
||||
func newHTTPServer(cfg *config.Config, handler http.Handler) *http.Server {
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
|
||||
srv := &http.Server{
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
Handler: handler,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
IdleTimeout: cfg.Server.IdleTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
func runServerWithGracefulShutdown(srv *http.Server, environment string) {
|
||||
done := make(chan os.Signal, 1)
|
||||
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
slog.Info("starting PulseScore API", "addr", addr, "env", cfg.Server.Environment)
|
||||
slog.Info("starting PulseScore API", "addr", srv.Addr, "env", environment)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -2,8 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/onnwee/pulse-score/internal/service"
|
||||
@@ -100,30 +98,7 @@ func (h *AuthHandler) CompletePasswordReset(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *AuthHandler) handleServiceError(w http.ResponseWriter, err error) {
|
||||
var validationErr *service.ValidationError
|
||||
var conflictErr *service.ConflictError
|
||||
var authErr *service.AuthError
|
||||
var notFoundErr *service.NotFoundError
|
||||
var forbiddenErr *service.ForbiddenError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &validationErr):
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": validationErr.Message,
|
||||
"field": validationErr.Field,
|
||||
})
|
||||
case errors.As(err, &conflictErr):
|
||||
writeJSON(w, http.StatusConflict, errorResponse(conflictErr.Message))
|
||||
case errors.As(err, &authErr):
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse(authErr.Message))
|
||||
case errors.As(err, ¬FoundErr):
|
||||
writeJSON(w, http.StatusNotFound, errorResponse(notFoundErr.Message))
|
||||
case errors.As(err, &forbiddenErr):
|
||||
writeJSON(w, http.StatusForbidden, errorResponse(forbiddenErr.Message))
|
||||
default:
|
||||
slog.Error("internal error", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse("internal server error"))
|
||||
}
|
||||
handleServiceError(w, err)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
|
||||
@@ -140,8 +140,7 @@ func NewWebhookStripeBillingHandler(webhookSvc billingWebhookServicer) *WebhookS
|
||||
|
||||
// HandleWebhook handles POST /api/v1/webhooks/stripe-billing.
|
||||
func (h *WebhookStripeBillingHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
const maxBodySize = 65536
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, webhookMaxBodyBytes)
|
||||
|
||||
payload, err := readBody(r)
|
||||
if err != nil {
|
||||
|
||||
37
internal/handler/errors.go
Normal file
37
internal/handler/errors.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/onnwee/pulse-score/internal/service"
|
||||
)
|
||||
|
||||
// handleServiceError maps service-layer errors to HTTP responses.
|
||||
func handleServiceError(w http.ResponseWriter, err error) {
|
||||
var validationErr *service.ValidationError
|
||||
var conflictErr *service.ConflictError
|
||||
var authErr *service.AuthError
|
||||
var notFoundErr *service.NotFoundError
|
||||
var forbiddenErr *service.ForbiddenError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &validationErr):
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": validationErr.Message,
|
||||
"field": validationErr.Field,
|
||||
})
|
||||
case errors.As(err, &conflictErr):
|
||||
writeJSON(w, http.StatusConflict, errorResponse(conflictErr.Message))
|
||||
case errors.As(err, &authErr):
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse(authErr.Message))
|
||||
case errors.As(err, ¬FoundErr):
|
||||
writeJSON(w, http.StatusNotFound, errorResponse(notFoundErr.Message))
|
||||
case errors.As(err, &forbiddenErr):
|
||||
writeJSON(w, http.StatusForbidden, errorResponse(forbiddenErr.Message))
|
||||
default:
|
||||
slog.Error("internal error", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, errorResponse("internal server error"))
|
||||
}
|
||||
}
|
||||
127
internal/handler/integration_common.go
Normal file
127
internal/handler/integration_common.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/onnwee/pulse-score/internal/auth"
|
||||
)
|
||||
|
||||
func integrationOrgID(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return orgID, true
|
||||
}
|
||||
|
||||
func integrationConnect(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
connectURLFn func(orgID uuid.UUID) (string, error),
|
||||
) {
|
||||
orgID, ok := integrationOrgID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
connectURL, err := connectURLFn(orgID)
|
||||
if err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": connectURL})
|
||||
}
|
||||
|
||||
func integrationStatus(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
getStatusFn func(ctx context.Context, orgID uuid.UUID) (any, error),
|
||||
) {
|
||||
orgID, ok := integrationOrgID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := getStatusFn(r.Context(), orgID)
|
||||
if err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func integrationDisconnect(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
disconnectFn func(ctx context.Context, orgID uuid.UUID) error,
|
||||
disconnectedMessage string,
|
||||
) {
|
||||
orgID, ok := integrationOrgID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := disconnectFn(r.Context(), orgID); err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": disconnectedMessage})
|
||||
}
|
||||
|
||||
func integrationTriggerSync(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
runFullSyncFn func(ctx context.Context, orgID uuid.UUID),
|
||||
startedMessage string,
|
||||
) {
|
||||
orgID, ok := integrationOrgID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
go runFullSyncFn(r.Context(), orgID)
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"message": startedMessage})
|
||||
}
|
||||
|
||||
func integrationCallback(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
providerLogName string,
|
||||
providerDisplayName string,
|
||||
connectedMessage string,
|
||||
exchangeCodeFn func(ctx context.Context, orgID uuid.UUID, code, state string) error,
|
||||
runFullSyncFn func(ctx context.Context, orgID uuid.UUID),
|
||||
) {
|
||||
orgID, ok := integrationOrgID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
|
||||
errDesc := r.URL.Query().Get("error_description")
|
||||
slog.Warn(providerLogName+" oauth error", "error", errMsg, "description", errDesc)
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse(providerDisplayName+" connection failed: "+errDesc))
|
||||
return
|
||||
}
|
||||
|
||||
if err := exchangeCodeFn(r.Context(), orgID, code, state); err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
go runFullSyncFn(r.Context(), orgID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": connectedMessage})
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/onnwee/pulse-score/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
"github.com/onnwee/pulse-score/internal/service"
|
||||
)
|
||||
|
||||
@@ -25,94 +26,42 @@ func NewIntegrationHubSpotHandler(oauthSvc *service.HubSpotOAuthService, orchest
|
||||
|
||||
// Connect handles GET /api/v1/integrations/hubspot/connect.
|
||||
func (h *IntegrationHubSpotHandler) Connect(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
connectURL, err := h.oauthSvc.ConnectURL(orgID)
|
||||
if err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": connectURL})
|
||||
integrationConnect(w, r, h.oauthSvc.ConnectURL)
|
||||
}
|
||||
|
||||
// Callback handles GET /api/v1/integrations/hubspot/callback.
|
||||
func (h *IntegrationHubSpotHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
|
||||
errDesc := r.URL.Query().Get("error_description")
|
||||
slog.Warn("hubspot oauth error", "error", errMsg, "description", errDesc)
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("HubSpot connection failed: "+errDesc))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.oauthSvc.ExchangeCode(r.Context(), orgID, code, state); err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger initial full sync in background
|
||||
go h.orchestrator.RunFullSync(r.Context(), orgID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "HubSpot connected successfully. Initial sync started."})
|
||||
integrationCallback(
|
||||
w,
|
||||
r,
|
||||
"hubspot",
|
||||
"HubSpot",
|
||||
"HubSpot connected successfully. Initial sync started.",
|
||||
h.oauthSvc.ExchangeCode,
|
||||
func(ctx context.Context, orgID uuid.UUID) { h.orchestrator.RunFullSync(ctx, orgID) },
|
||||
)
|
||||
}
|
||||
|
||||
// Status handles GET /api/v1/integrations/hubspot/status.
|
||||
func (h *IntegrationHubSpotHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.oauthSvc.GetStatus(r.Context(), orgID)
|
||||
if err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
integrationStatus(w, r, func(ctx context.Context, orgID uuid.UUID) (any, error) {
|
||||
return h.oauthSvc.GetStatus(ctx, orgID)
|
||||
})
|
||||
}
|
||||
|
||||
// Disconnect handles DELETE /api/v1/integrations/hubspot.
|
||||
func (h *IntegrationHubSpotHandler) Disconnect(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.oauthSvc.Disconnect(r.Context(), orgID); err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "HubSpot disconnected"})
|
||||
integrationDisconnect(w, r, h.oauthSvc.Disconnect, "HubSpot disconnected")
|
||||
}
|
||||
|
||||
// TriggerSync handles POST /api/v1/integrations/hubspot/sync.
|
||||
func (h *IntegrationHubSpotHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
go h.orchestrator.RunFullSync(r.Context(), orgID)
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"message": "HubSpot sync started"})
|
||||
integrationTriggerSync(
|
||||
w,
|
||||
r,
|
||||
func(ctx context.Context, orgID uuid.UUID) { h.orchestrator.RunFullSync(ctx, orgID) },
|
||||
"HubSpot sync started",
|
||||
)
|
||||
}
|
||||
|
||||
// WebhookHubSpotHandler provides HubSpot webhook HTTP endpoints.
|
||||
@@ -127,8 +76,7 @@ func NewWebhookHubSpotHandler(webhookSvc *service.HubSpotWebhookService) *Webhoo
|
||||
|
||||
// HandleWebhook handles POST /api/v1/webhooks/hubspot.
|
||||
func (h *WebhookHubSpotHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
const maxBodySize = 65536
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, webhookMaxBodyBytes)
|
||||
|
||||
payload, err := readBody(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/onnwee/pulse-score/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
"github.com/onnwee/pulse-score/internal/service"
|
||||
)
|
||||
|
||||
@@ -25,94 +26,42 @@ func NewIntegrationIntercomHandler(oauthSvc *service.IntercomOAuthService, orche
|
||||
|
||||
// Connect handles GET /api/v1/integrations/intercom/connect.
|
||||
func (h *IntegrationIntercomHandler) Connect(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
connectURL, err := h.oauthSvc.ConnectURL(orgID)
|
||||
if err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": connectURL})
|
||||
integrationConnect(w, r, h.oauthSvc.ConnectURL)
|
||||
}
|
||||
|
||||
// Callback handles GET /api/v1/integrations/intercom/callback.
|
||||
func (h *IntegrationIntercomHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
|
||||
errDesc := r.URL.Query().Get("error_description")
|
||||
slog.Warn("intercom oauth error", "error", errMsg, "description", errDesc)
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("Intercom connection failed: "+errDesc))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.oauthSvc.ExchangeCode(r.Context(), orgID, code, state); err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger initial full sync in background
|
||||
go h.orchestrator.RunFullSync(r.Context(), orgID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Intercom connected successfully. Initial sync started."})
|
||||
integrationCallback(
|
||||
w,
|
||||
r,
|
||||
"intercom",
|
||||
"Intercom",
|
||||
"Intercom connected successfully. Initial sync started.",
|
||||
h.oauthSvc.ExchangeCode,
|
||||
func(ctx context.Context, orgID uuid.UUID) { h.orchestrator.RunFullSync(ctx, orgID) },
|
||||
)
|
||||
}
|
||||
|
||||
// Status handles GET /api/v1/integrations/intercom/status.
|
||||
func (h *IntegrationIntercomHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.oauthSvc.GetStatus(r.Context(), orgID)
|
||||
if err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
integrationStatus(w, r, func(ctx context.Context, orgID uuid.UUID) (any, error) {
|
||||
return h.oauthSvc.GetStatus(ctx, orgID)
|
||||
})
|
||||
}
|
||||
|
||||
// Disconnect handles DELETE /api/v1/integrations/intercom.
|
||||
func (h *IntegrationIntercomHandler) Disconnect(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.oauthSvc.Disconnect(r.Context(), orgID); err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Intercom disconnected"})
|
||||
integrationDisconnect(w, r, h.oauthSvc.Disconnect, "Intercom disconnected")
|
||||
}
|
||||
|
||||
// TriggerSync handles POST /api/v1/integrations/intercom/sync.
|
||||
func (h *IntegrationIntercomHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
go h.orchestrator.RunFullSync(r.Context(), orgID)
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"message": "Intercom sync started"})
|
||||
integrationTriggerSync(
|
||||
w,
|
||||
r,
|
||||
func(ctx context.Context, orgID uuid.UUID) { h.orchestrator.RunFullSync(ctx, orgID) },
|
||||
"Intercom sync started",
|
||||
)
|
||||
}
|
||||
|
||||
// WebhookIntercomHandler provides Intercom webhook HTTP endpoints.
|
||||
@@ -127,8 +76,7 @@ func NewWebhookIntercomHandler(webhookSvc *service.IntercomWebhookService) *Webh
|
||||
|
||||
// HandleWebhook handles POST /api/v1/webhooks/intercom.
|
||||
func (h *WebhookIntercomHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
const maxBodySize = 65536
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, webhookMaxBodyBytes)
|
||||
|
||||
payload, err := readBody(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/onnwee/pulse-score/internal/auth"
|
||||
"github.com/google/uuid"
|
||||
"github.com/onnwee/pulse-score/internal/service"
|
||||
)
|
||||
|
||||
// IntegrationStripeHandler provides Stripe integration HTTP endpoints.
|
||||
type IntegrationStripeHandler struct {
|
||||
oauthSvc *service.StripeOAuthService
|
||||
orchestrator *service.SyncOrchestratorService
|
||||
oauthSvc *service.StripeOAuthService
|
||||
orchestrator *service.SyncOrchestratorService
|
||||
}
|
||||
|
||||
// NewIntegrationStripeHandler creates a new IntegrationStripeHandler.
|
||||
@@ -25,102 +26,43 @@ func NewIntegrationStripeHandler(oauthSvc *service.StripeOAuthService, orchestra
|
||||
// Connect handles GET /api/v1/integrations/stripe/connect.
|
||||
// Returns the OAuth URL to redirect the user to Stripe.
|
||||
func (h *IntegrationStripeHandler) Connect(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
connectURL, err := h.oauthSvc.ConnectURL(orgID)
|
||||
if err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": connectURL})
|
||||
integrationConnect(w, r, h.oauthSvc.ConnectURL)
|
||||
}
|
||||
|
||||
// Callback handles GET /api/v1/integrations/stripe/callback.
|
||||
// Exchanges the code for tokens and initiates a full sync.
|
||||
func (h *IntegrationStripeHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
// Check for Stripe error
|
||||
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
|
||||
errDesc := r.URL.Query().Get("error_description")
|
||||
slog.Warn("stripe oauth error", "error", errMsg, "description", errDesc)
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse("Stripe connection failed: "+errDesc))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.oauthSvc.ExchangeCode(r.Context(), orgID, code, state); err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger initial full sync in background
|
||||
go h.orchestrator.RunFullSync(r.Context(), orgID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Stripe connected successfully. Initial sync started."})
|
||||
integrationCallback(
|
||||
w,
|
||||
r,
|
||||
"stripe",
|
||||
"Stripe",
|
||||
"Stripe connected successfully. Initial sync started.",
|
||||
h.oauthSvc.ExchangeCode,
|
||||
func(ctx context.Context, orgID uuid.UUID) { h.orchestrator.RunFullSync(ctx, orgID) },
|
||||
)
|
||||
}
|
||||
|
||||
// Status handles GET /api/v1/integrations/stripe/status.
|
||||
func (h *IntegrationStripeHandler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
status, err := h.oauthSvc.GetStatus(r.Context(), orgID)
|
||||
if err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
integrationStatus(w, r, func(ctx context.Context, orgID uuid.UUID) (any, error) {
|
||||
return h.oauthSvc.GetStatus(ctx, orgID)
|
||||
})
|
||||
}
|
||||
|
||||
// Disconnect handles DELETE /api/v1/integrations/stripe.
|
||||
func (h *IntegrationStripeHandler) Disconnect(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.oauthSvc.Disconnect(r.Context(), orgID); err != nil {
|
||||
handleServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Stripe disconnected"})
|
||||
integrationDisconnect(w, r, h.oauthSvc.Disconnect, "Stripe disconnected")
|
||||
}
|
||||
|
||||
// TriggerSync handles POST /api/v1/integrations/stripe/sync.
|
||||
func (h *IntegrationStripeHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
|
||||
orgID, ok := auth.GetOrgID(r.Context())
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
go h.orchestrator.RunFullSync(r.Context(), orgID)
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"message": "sync started"})
|
||||
}
|
||||
|
||||
// handleServiceError writes service errors to HTTP response.
|
||||
func handleServiceError(w http.ResponseWriter, err error) {
|
||||
handler := &AuthHandler{}
|
||||
handler.handleServiceError(w, err)
|
||||
integrationTriggerSync(
|
||||
w,
|
||||
r,
|
||||
func(ctx context.Context, orgID uuid.UUID) { h.orchestrator.RunFullSync(ctx, orgID) },
|
||||
"sync started",
|
||||
)
|
||||
}
|
||||
|
||||
// WebhookStripeHandler provides Stripe webhook HTTP endpoints.
|
||||
@@ -135,8 +77,7 @@ func NewWebhookStripeHandler(webhookSvc *service.StripeWebhookService) *WebhookS
|
||||
|
||||
// HandleWebhook handles POST /api/v1/webhooks/stripe.
|
||||
func (h *WebhookStripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
const maxBodySize = 65536
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, webhookMaxBodyBytes)
|
||||
|
||||
payload, err := readBody(r)
|
||||
if err != nil {
|
||||
@@ -160,21 +101,6 @@ func (h *WebhookStripeHandler) HandleWebhook(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func readBody(r *http.Request) ([]byte, error) {
|
||||
var buf []byte
|
||||
tmp := make([]byte, 1024)
|
||||
for {
|
||||
n, err := r.Body.Read(tmp)
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// StripeStatusResponse is returned by GET /api/v1/integrations/stripe/status.
|
||||
type StripeStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
@@ -183,4 +109,3 @@ type StripeStatusResponse struct {
|
||||
LastSyncError string `json:"last_sync_error,omitempty"`
|
||||
CustomerCount int `json:"customer_count,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
23
internal/handler/webhook_helpers.go
Normal file
23
internal/handler/webhook_helpers.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package handler
|
||||
|
||||
import "net/http"
|
||||
|
||||
const (
|
||||
webhookMaxBodyBytes int64 = 64 * 1024
|
||||
webhookReadChunkBytes = 1024
|
||||
)
|
||||
|
||||
func readBody(r *http.Request) ([]byte, error) {
|
||||
var bodyBytes []byte
|
||||
chunkBuffer := make([]byte, webhookReadChunkBytes)
|
||||
for {
|
||||
n, err := r.Body.Read(chunkBuffer)
|
||||
if n > 0 {
|
||||
bodyBytes = append(bodyBytes, chunkBuffer[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return bodyBytes, nil
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -44,7 +46,7 @@ func (r *CustomerEventRepository) Upsert(ctx context.Context, e *CustomerEvent)
|
||||
e.OrgID, e.CustomerID, e.EventType, e.Source, e.ExternalEventID, e.OccurredAt, e.Data,
|
||||
).Scan(&e.ID, &e.CreatedAt)
|
||||
// ON CONFLICT DO NOTHING returns no rows — that's fine
|
||||
if err != nil && err.Error() == "no rows in result set" {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -25,26 +25,31 @@ type AlertScheduler struct {
|
||||
frontendURL string
|
||||
}
|
||||
|
||||
// AlertSchedulerDeps holds constructor dependencies for AlertScheduler.
|
||||
type AlertSchedulerDeps struct {
|
||||
Engine *AlertEngine
|
||||
EmailService EmailService
|
||||
Templates *EmailTemplateService
|
||||
AlertHistory *repository.AlertHistoryRepository
|
||||
AlertRules *repository.AlertRuleRepository
|
||||
UserRepo *repository.UserRepository
|
||||
NotifPrefSvc *NotificationPreferenceService
|
||||
}
|
||||
|
||||
// NewAlertScheduler creates a new AlertScheduler.
|
||||
func NewAlertScheduler(
|
||||
engine *AlertEngine,
|
||||
emailService EmailService,
|
||||
templates *EmailTemplateService,
|
||||
alertHistory *repository.AlertHistoryRepository,
|
||||
alertRules *repository.AlertRuleRepository,
|
||||
userRepo *repository.UserRepository,
|
||||
notifPrefSvc *NotificationPreferenceService,
|
||||
deps AlertSchedulerDeps,
|
||||
intervalMinutes int,
|
||||
frontendURL string,
|
||||
) *AlertScheduler {
|
||||
return &AlertScheduler{
|
||||
engine: engine,
|
||||
emailService: emailService,
|
||||
templates: templates,
|
||||
alertHistory: alertHistory,
|
||||
alertRules: alertRules,
|
||||
userRepo: userRepo,
|
||||
notifPrefSvc: notifPrefSvc,
|
||||
engine: deps.Engine,
|
||||
emailService: deps.EmailService,
|
||||
templates: deps.Templates,
|
||||
alertHistory: deps.AlertHistory,
|
||||
alertRules: deps.AlertRules,
|
||||
userRepo: deps.UserRepo,
|
||||
notifPrefSvc: deps.NotifPrefSvc,
|
||||
interval: time.Duration(intervalMinutes) * time.Minute,
|
||||
frontendURL: frontendURL,
|
||||
}
|
||||
|
||||
@@ -14,15 +14,18 @@ import (
|
||||
|
||||
// HubSpotSyncService handles syncing data from HubSpot to local database.
|
||||
type HubSpotSyncService struct {
|
||||
oauthSvc *HubSpotOAuthService
|
||||
client *HubSpotClient
|
||||
contacts *repository.HubSpotContactRepository
|
||||
deals *repository.HubSpotDealRepository
|
||||
companies *repository.HubSpotCompanyRepository
|
||||
customers *repository.CustomerRepository
|
||||
events *repository.CustomerEventRepository
|
||||
oauthSvc *HubSpotOAuthService
|
||||
client *HubSpotClient
|
||||
contacts *repository.HubSpotContactRepository
|
||||
deals *repository.HubSpotDealRepository
|
||||
companies *repository.HubSpotCompanyRepository
|
||||
customers *repository.CustomerRepository
|
||||
events *repository.CustomerEventRepository
|
||||
}
|
||||
|
||||
type hubspotContactPageFetcher func(ctx context.Context, accessToken, after string) (*HubSpotContactListResponse, error)
|
||||
type hubspotDealPageFetcher func(ctx context.Context, accessToken, after string) (*HubSpotDealListResponse, error)
|
||||
|
||||
// NewHubSpotSyncService creates a new HubSpotSyncService.
|
||||
func NewHubSpotSyncService(
|
||||
oauthSvc *HubSpotOAuthService,
|
||||
@@ -46,187 +49,12 @@ func NewHubSpotSyncService(
|
||||
|
||||
// SyncContacts fetches all contacts from HubSpot and upserts them locally.
|
||||
func (s *HubSpotSyncService) SyncContacts(ctx context.Context, orgID uuid.UUID) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "hubspot_contacts"}
|
||||
after := ""
|
||||
|
||||
for {
|
||||
resp, err := s.client.ListContacts(ctx, accessToken, after)
|
||||
if err != nil {
|
||||
return progress, fmt.Errorf("list contacts: %w", err)
|
||||
}
|
||||
|
||||
for _, c := range resp.Results {
|
||||
progress.Total++
|
||||
|
||||
name := buildFullName(c.Properties.FirstName, c.Properties.LastName)
|
||||
|
||||
hsContact := &repository.HubSpotContact{
|
||||
OrgID: orgID,
|
||||
HubSpotContactID: c.ID,
|
||||
Email: c.Properties.Email,
|
||||
FirstName: c.Properties.FirstName,
|
||||
LastName: c.Properties.LastName,
|
||||
HubSpotCompanyID: c.Properties.AssociatedCompanyID,
|
||||
LifecycleStage: c.Properties.LifecycleStage,
|
||||
LeadStatus: c.Properties.LeadStatus,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.contacts.Upsert(ctx, hsContact); err != nil {
|
||||
slog.Error("failed to upsert hubspot contact", "hubspot_id", c.ID, "error", err)
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Also upsert into customers table
|
||||
now := time.Now()
|
||||
localCustomer := &repository.Customer{
|
||||
OrgID: orgID,
|
||||
ExternalID: c.ID,
|
||||
Source: "hubspot",
|
||||
Email: c.Properties.Email,
|
||||
Name: name,
|
||||
CompanyName: c.Properties.Company,
|
||||
FirstSeenAt: &now,
|
||||
LastSeenAt: &now,
|
||||
Metadata: map[string]any{
|
||||
"hubspot": map[string]any{
|
||||
"lifecycle_stage": c.Properties.LifecycleStage,
|
||||
"lead_status": c.Properties.LeadStatus,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.customers.UpsertByExternal(ctx, localCustomer); err != nil {
|
||||
slog.Error("failed to upsert customer from hubspot", "hubspot_id", c.ID, "error", err)
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Link the HubSpot contact to the local customer
|
||||
if err := s.contacts.LinkCustomer(ctx, hsContact.ID, localCustomer.ID); err != nil {
|
||||
slog.Error("failed to link hubspot contact to customer", "error", err)
|
||||
}
|
||||
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
if resp.Paging == nil || resp.Paging.Next == nil || resp.Paging.Next.After == "" {
|
||||
break
|
||||
}
|
||||
after = resp.Paging.Next.After
|
||||
}
|
||||
|
||||
slog.Info("hubspot contact sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
|
||||
return progress, nil
|
||||
return s.syncContacts(ctx, orgID, "hubspot_contacts", "list contacts", true, true, s.client.ListContacts)
|
||||
}
|
||||
|
||||
// SyncDeals fetches all deals from HubSpot and upserts them locally.
|
||||
func (s *HubSpotSyncService) SyncDeals(ctx context.Context, orgID uuid.UUID) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "hubspot_deals"}
|
||||
after := ""
|
||||
|
||||
for {
|
||||
resp, err := s.client.ListDeals(ctx, accessToken, after)
|
||||
if err != nil {
|
||||
return progress, fmt.Errorf("list deals: %w", err)
|
||||
}
|
||||
|
||||
for _, d := range resp.Results {
|
||||
progress.Total++
|
||||
|
||||
amountCents := parseAmountToCents(d.Properties.Amount)
|
||||
closeDate := parseHubSpotDate(d.Properties.CloseDate)
|
||||
|
||||
// Find associated contact ID
|
||||
contactID := ""
|
||||
if d.Associations != nil && d.Associations.Contacts != nil && len(d.Associations.Contacts.Results) > 0 {
|
||||
contactID = d.Associations.Contacts.Results[0].ID
|
||||
}
|
||||
|
||||
// Find local customer for this contact
|
||||
var customerID *uuid.UUID
|
||||
if contactID != "" {
|
||||
hsContact, err := s.contacts.GetByHubSpotID(ctx, orgID, contactID)
|
||||
if err == nil && hsContact != nil && hsContact.CustomerID != nil {
|
||||
customerID = hsContact.CustomerID
|
||||
}
|
||||
}
|
||||
|
||||
hsDeal := &repository.HubSpotDeal{
|
||||
OrgID: orgID,
|
||||
CustomerID: customerID,
|
||||
HubSpotDealID: d.ID,
|
||||
HubSpotContactID: contactID,
|
||||
DealName: d.Properties.DealName,
|
||||
Stage: d.Properties.DealStage,
|
||||
AmountCents: amountCents,
|
||||
Currency: "USD",
|
||||
CloseDate: closeDate,
|
||||
Pipeline: d.Properties.Pipeline,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.deals.Upsert(ctx, hsDeal); err != nil {
|
||||
slog.Error("failed to upsert hubspot deal", "hubspot_id", d.ID, "error", err)
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Create customer event for deal stage
|
||||
if customerID != nil {
|
||||
event := &repository.CustomerEvent{
|
||||
OrgID: orgID,
|
||||
CustomerID: *customerID,
|
||||
EventType: "deal_stage_change",
|
||||
Source: "hubspot",
|
||||
ExternalEventID: "deal_" + d.ID + "_" + d.Properties.DealStage,
|
||||
OccurredAt: time.Now(),
|
||||
Data: map[string]any{
|
||||
"deal_name": d.Properties.DealName,
|
||||
"stage": d.Properties.DealStage,
|
||||
"amount_cents": amountCents,
|
||||
"pipeline": d.Properties.Pipeline,
|
||||
},
|
||||
}
|
||||
if err := s.events.Upsert(ctx, event); err != nil {
|
||||
slog.Error("failed to create deal event", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
if resp.Paging == nil || resp.Paging.Next == nil || resp.Paging.Next.After == "" {
|
||||
break
|
||||
}
|
||||
after = resp.Paging.Next.After
|
||||
}
|
||||
|
||||
slog.Info("hubspot deal sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
|
||||
return progress, nil
|
||||
return s.syncDeals(ctx, orgID, "hubspot_deals", "list deals", true, true, s.client.ListDeals)
|
||||
}
|
||||
|
||||
// SyncCompanies fetches all companies from HubSpot and upserts them locally.
|
||||
@@ -337,14 +165,6 @@ func (s *HubSpotSyncService) EnrichCustomersWithCompanyData(ctx context.Context,
|
||||
|
||||
// SyncContactsSince fetches contacts modified since the given time (incremental sync).
|
||||
func (s *HubSpotSyncService) SyncContactsSince(ctx context.Context, orgID uuid.UUID, since time.Time) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "hubspot_contacts_incremental"}
|
||||
after := ""
|
||||
|
||||
filterGroups := []HubSpotFilterGroup{{
|
||||
Filters: []HubSpotFilter{{
|
||||
PropertyName: "lastmodifieddate",
|
||||
@@ -352,62 +172,52 @@ func (s *HubSpotSyncService) SyncContactsSince(ctx context.Context, orgID uuid.U
|
||||
Value: fmt.Sprintf("%d", since.UnixMilli()),
|
||||
}},
|
||||
}}
|
||||
fetchPage := func(ctx context.Context, accessToken, after string) (*HubSpotContactListResponse, error) {
|
||||
return s.client.SearchContacts(ctx, accessToken, filterGroups, after)
|
||||
}
|
||||
|
||||
return s.syncContacts(
|
||||
ctx,
|
||||
orgID,
|
||||
"hubspot_contacts_incremental",
|
||||
"search contacts",
|
||||
false,
|
||||
false,
|
||||
fetchPage,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *HubSpotSyncService) syncContacts(
|
||||
ctx context.Context,
|
||||
orgID uuid.UUID,
|
||||
step string,
|
||||
listErrorPrefix string,
|
||||
logUpsertErrors bool,
|
||||
logCompletion bool,
|
||||
fetchPage hubspotContactPageFetcher,
|
||||
) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: step}
|
||||
after := ""
|
||||
|
||||
for {
|
||||
resp, err := s.client.SearchContacts(ctx, accessToken, filterGroups, after)
|
||||
resp, err := fetchPage(ctx, accessToken, after)
|
||||
if err != nil {
|
||||
return progress, fmt.Errorf("search contacts: %w", err)
|
||||
return progress, fmt.Errorf("%s: %w", listErrorPrefix, err)
|
||||
}
|
||||
|
||||
for _, c := range resp.Results {
|
||||
progress.Total++
|
||||
|
||||
name := buildFullName(c.Properties.FirstName, c.Properties.LastName)
|
||||
|
||||
hsContact := &repository.HubSpotContact{
|
||||
OrgID: orgID,
|
||||
HubSpotContactID: c.ID,
|
||||
Email: c.Properties.Email,
|
||||
FirstName: c.Properties.FirstName,
|
||||
LastName: c.Properties.LastName,
|
||||
HubSpotCompanyID: c.Properties.AssociatedCompanyID,
|
||||
LifecycleStage: c.Properties.LifecycleStage,
|
||||
LeadStatus: c.Properties.LeadStatus,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.contacts.Upsert(ctx, hsContact); err != nil {
|
||||
if err := s.upsertContactAndCustomer(ctx, orgID, c, logUpsertErrors); err != nil {
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
localCustomer := &repository.Customer{
|
||||
OrgID: orgID,
|
||||
ExternalID: c.ID,
|
||||
Source: "hubspot",
|
||||
Email: c.Properties.Email,
|
||||
Name: name,
|
||||
CompanyName: c.Properties.Company,
|
||||
FirstSeenAt: &now,
|
||||
LastSeenAt: &now,
|
||||
Metadata: map[string]any{
|
||||
"hubspot": map[string]any{
|
||||
"lifecycle_stage": c.Properties.LifecycleStage,
|
||||
"lead_status": c.Properties.LeadStatus,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.customers.UpsertByExternal(ctx, localCustomer); err != nil {
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.contacts.LinkCustomer(ctx, hsContact.ID, localCustomer.ID); err != nil {
|
||||
slog.Error("failed to link hubspot contact to customer", "error", err)
|
||||
}
|
||||
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
@@ -417,19 +227,20 @@ func (s *HubSpotSyncService) SyncContactsSince(ctx context.Context, orgID uuid.U
|
||||
after = resp.Paging.Next.After
|
||||
}
|
||||
|
||||
if logCompletion {
|
||||
slog.Info("hubspot contact sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
}
|
||||
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
// SyncDealsSince fetches deals modified since the given time (incremental sync).
|
||||
func (s *HubSpotSyncService) SyncDealsSince(ctx context.Context, orgID uuid.UUID, since time.Time) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "hubspot_deals_incremental"}
|
||||
after := ""
|
||||
|
||||
filterGroups := []HubSpotFilterGroup{{
|
||||
Filters: []HubSpotFilter{{
|
||||
PropertyName: "hs_lastmodifieddate",
|
||||
@@ -437,71 +248,52 @@ func (s *HubSpotSyncService) SyncDealsSince(ctx context.Context, orgID uuid.UUID
|
||||
Value: fmt.Sprintf("%d", since.UnixMilli()),
|
||||
}},
|
||||
}}
|
||||
fetchPage := func(ctx context.Context, accessToken, after string) (*HubSpotDealListResponse, error) {
|
||||
return s.client.SearchDeals(ctx, accessToken, filterGroups, after)
|
||||
}
|
||||
|
||||
return s.syncDeals(
|
||||
ctx,
|
||||
orgID,
|
||||
"hubspot_deals_incremental",
|
||||
"search deals",
|
||||
false,
|
||||
false,
|
||||
fetchPage,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *HubSpotSyncService) syncDeals(
|
||||
ctx context.Context,
|
||||
orgID uuid.UUID,
|
||||
step string,
|
||||
listErrorPrefix string,
|
||||
logUpsertErrors bool,
|
||||
logCompletion bool,
|
||||
fetchPage hubspotDealPageFetcher,
|
||||
) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: step}
|
||||
after := ""
|
||||
|
||||
for {
|
||||
resp, err := s.client.SearchDeals(ctx, accessToken, filterGroups, after)
|
||||
resp, err := fetchPage(ctx, accessToken, after)
|
||||
if err != nil {
|
||||
return progress, fmt.Errorf("search deals: %w", err)
|
||||
return progress, fmt.Errorf("%s: %w", listErrorPrefix, err)
|
||||
}
|
||||
|
||||
for _, d := range resp.Results {
|
||||
progress.Total++
|
||||
|
||||
amountCents := parseAmountToCents(d.Properties.Amount)
|
||||
closeDate := parseHubSpotDate(d.Properties.CloseDate)
|
||||
|
||||
contactID := ""
|
||||
if d.Associations != nil && d.Associations.Contacts != nil && len(d.Associations.Contacts.Results) > 0 {
|
||||
contactID = d.Associations.Contacts.Results[0].ID
|
||||
}
|
||||
|
||||
var customerID *uuid.UUID
|
||||
if contactID != "" {
|
||||
hsContact, err := s.contacts.GetByHubSpotID(ctx, orgID, contactID)
|
||||
if err == nil && hsContact != nil && hsContact.CustomerID != nil {
|
||||
customerID = hsContact.CustomerID
|
||||
}
|
||||
}
|
||||
|
||||
hsDeal := &repository.HubSpotDeal{
|
||||
OrgID: orgID,
|
||||
CustomerID: customerID,
|
||||
HubSpotDealID: d.ID,
|
||||
HubSpotContactID: contactID,
|
||||
DealName: d.Properties.DealName,
|
||||
Stage: d.Properties.DealStage,
|
||||
AmountCents: amountCents,
|
||||
Currency: "USD",
|
||||
CloseDate: closeDate,
|
||||
Pipeline: d.Properties.Pipeline,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.deals.Upsert(ctx, hsDeal); err != nil {
|
||||
if err := s.upsertDeal(ctx, orgID, d, logUpsertErrors); err != nil {
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
if customerID != nil {
|
||||
event := &repository.CustomerEvent{
|
||||
OrgID: orgID,
|
||||
CustomerID: *customerID,
|
||||
EventType: "deal_stage_change",
|
||||
Source: "hubspot",
|
||||
ExternalEventID: "deal_" + d.ID + "_" + d.Properties.DealStage,
|
||||
OccurredAt: time.Now(),
|
||||
Data: map[string]any{
|
||||
"deal_name": d.Properties.DealName,
|
||||
"stage": d.Properties.DealStage,
|
||||
"amount_cents": amountCents,
|
||||
"pipeline": d.Properties.Pipeline,
|
||||
},
|
||||
}
|
||||
if err := s.events.Upsert(ctx, event); err != nil {
|
||||
slog.Error("failed to create deal event", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
@@ -511,9 +303,150 @@ func (s *HubSpotSyncService) SyncDealsSince(ctx context.Context, orgID uuid.UUID
|
||||
after = resp.Paging.Next.After
|
||||
}
|
||||
|
||||
if logCompletion {
|
||||
slog.Info("hubspot deal sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
}
|
||||
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
func (s *HubSpotSyncService) upsertContactAndCustomer(ctx context.Context, orgID uuid.UUID, c HubSpotAPIContact, logUpsertErrors bool) error {
|
||||
name := buildFullName(c.Properties.FirstName, c.Properties.LastName)
|
||||
|
||||
hsContact := &repository.HubSpotContact{
|
||||
OrgID: orgID,
|
||||
HubSpotContactID: c.ID,
|
||||
Email: c.Properties.Email,
|
||||
FirstName: c.Properties.FirstName,
|
||||
LastName: c.Properties.LastName,
|
||||
HubSpotCompanyID: c.Properties.AssociatedCompanyID,
|
||||
LifecycleStage: c.Properties.LifecycleStage,
|
||||
LeadStatus: c.Properties.LeadStatus,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.contacts.Upsert(ctx, hsContact); err != nil {
|
||||
if logUpsertErrors {
|
||||
slog.Error("failed to upsert hubspot contact", "hubspot_id", c.ID, "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
localCustomer := &repository.Customer{
|
||||
OrgID: orgID,
|
||||
ExternalID: c.ID,
|
||||
Source: "hubspot",
|
||||
Email: c.Properties.Email,
|
||||
Name: name,
|
||||
CompanyName: c.Properties.Company,
|
||||
FirstSeenAt: &now,
|
||||
LastSeenAt: &now,
|
||||
Metadata: map[string]any{
|
||||
"hubspot": map[string]any{
|
||||
"lifecycle_stage": c.Properties.LifecycleStage,
|
||||
"lead_status": c.Properties.LeadStatus,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.customers.UpsertByExternal(ctx, localCustomer); err != nil {
|
||||
if logUpsertErrors {
|
||||
slog.Error("failed to upsert customer from hubspot", "hubspot_id", c.ID, "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.contacts.LinkCustomer(ctx, hsContact.ID, localCustomer.ID); err != nil {
|
||||
slog.Error("failed to link hubspot contact to customer", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HubSpotSyncService) upsertDeal(ctx context.Context, orgID uuid.UUID, d HubSpotAPIDeal, logUpsertErrors bool) error {
|
||||
amountCents := parseAmountToCents(d.Properties.Amount)
|
||||
closeDate := parseHubSpotDate(d.Properties.CloseDate)
|
||||
|
||||
contactID := hubSpotDealContactID(d)
|
||||
customerID := s.resolveHubSpotDealCustomerID(ctx, orgID, contactID)
|
||||
|
||||
hsDeal := &repository.HubSpotDeal{
|
||||
OrgID: orgID,
|
||||
CustomerID: customerID,
|
||||
HubSpotDealID: d.ID,
|
||||
HubSpotContactID: contactID,
|
||||
DealName: d.Properties.DealName,
|
||||
Stage: d.Properties.DealStage,
|
||||
AmountCents: amountCents,
|
||||
Currency: "USD",
|
||||
CloseDate: closeDate,
|
||||
Pipeline: d.Properties.Pipeline,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.deals.Upsert(ctx, hsDeal); err != nil {
|
||||
if logUpsertErrors {
|
||||
slog.Error("failed to upsert hubspot deal", "hubspot_id", d.ID, "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
s.emitDealStageEvent(ctx, orgID, customerID, d, amountCents)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hubSpotDealContactID(d HubSpotAPIDeal) string {
|
||||
if d.Associations == nil || d.Associations.Contacts == nil || len(d.Associations.Contacts.Results) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return d.Associations.Contacts.Results[0].ID
|
||||
}
|
||||
|
||||
func (s *HubSpotSyncService) resolveHubSpotDealCustomerID(ctx context.Context, orgID uuid.UUID, contactID string) *uuid.UUID {
|
||||
if contactID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
hsContact, err := s.contacts.GetByHubSpotID(ctx, orgID, contactID)
|
||||
if err != nil || hsContact == nil || hsContact.CustomerID == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return hsContact.CustomerID
|
||||
}
|
||||
|
||||
func (s *HubSpotSyncService) emitDealStageEvent(ctx context.Context, orgID uuid.UUID, customerID *uuid.UUID, d HubSpotAPIDeal, amountCents int64) {
|
||||
if customerID == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := &repository.CustomerEvent{
|
||||
OrgID: orgID,
|
||||
CustomerID: *customerID,
|
||||
EventType: "deal_stage_change",
|
||||
Source: "hubspot",
|
||||
ExternalEventID: "deal_" + d.ID + "_" + d.Properties.DealStage,
|
||||
OccurredAt: time.Now(),
|
||||
Data: map[string]any{
|
||||
"deal_name": d.Properties.DealName,
|
||||
"stage": d.Properties.DealStage,
|
||||
"amount_cents": amountCents,
|
||||
"pipeline": d.Properties.Pipeline,
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.events.Upsert(ctx, event); err != nil {
|
||||
slog.Error("failed to create deal event", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildFullName(first, last string) string {
|
||||
name := first
|
||||
if last != "" {
|
||||
|
||||
@@ -21,6 +21,9 @@ type IntercomSyncService struct {
|
||||
events *repository.CustomerEventRepository
|
||||
}
|
||||
|
||||
type intercomContactPageFetcher func(ctx context.Context, accessToken, cursor string) (*IntercomContactListResponse, error)
|
||||
type intercomConversationPageFetcher func(ctx context.Context, accessToken, cursor string) (*IntercomConversationListResponse, error)
|
||||
|
||||
// NewIntercomSyncService creates a new IntercomSyncService.
|
||||
func NewIntercomSyncService(
|
||||
oauthSvc *IntercomOAuthService,
|
||||
@@ -42,358 +45,191 @@ func NewIntercomSyncService(
|
||||
|
||||
// SyncContacts fetches all contacts from Intercom and upserts them locally.
|
||||
func (s *IntercomSyncService) SyncContacts(ctx context.Context, orgID uuid.UUID) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "intercom_contacts"}
|
||||
cursor := ""
|
||||
|
||||
for {
|
||||
resp, err := s.client.ListContacts(ctx, accessToken, cursor)
|
||||
if err != nil {
|
||||
return progress, fmt.Errorf("list contacts: %w", err)
|
||||
}
|
||||
|
||||
for _, c := range resp.Data {
|
||||
progress.Total++
|
||||
|
||||
icContact := &repository.IntercomContact{
|
||||
OrgID: orgID,
|
||||
IntercomContactID: c.ID,
|
||||
Email: c.Email,
|
||||
Name: c.Name,
|
||||
Role: c.Role,
|
||||
IntercomCompanyID: c.CompanyID,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.contacts.Upsert(ctx, icContact); err != nil {
|
||||
slog.Error("failed to upsert intercom contact", "intercom_id", c.ID, "error", err)
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Upsert into customers table
|
||||
now := time.Now()
|
||||
localCustomer := &repository.Customer{
|
||||
OrgID: orgID,
|
||||
ExternalID: c.ID,
|
||||
Source: "intercom",
|
||||
Email: c.Email,
|
||||
Name: c.Name,
|
||||
FirstSeenAt: &now,
|
||||
LastSeenAt: &now,
|
||||
Metadata: map[string]any{
|
||||
"intercom": map[string]any{
|
||||
"role": c.Role,
|
||||
"company_id": c.CompanyID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.customers.UpsertByExternal(ctx, localCustomer); err != nil {
|
||||
slog.Error("failed to upsert customer from intercom", "intercom_id", c.ID, "error", err)
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Link the Intercom contact to the local customer
|
||||
if err := s.contacts.LinkCustomer(ctx, icContact.ID, localCustomer.ID); err != nil {
|
||||
slog.Error("failed to link intercom contact to customer", "error", err)
|
||||
}
|
||||
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
if resp.Pages == nil || resp.Pages.Next == nil || resp.Pages.Next.StartingAfter == "" {
|
||||
break
|
||||
}
|
||||
cursor = resp.Pages.Next.StartingAfter
|
||||
}
|
||||
|
||||
slog.Info("intercom contact sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
|
||||
return progress, nil
|
||||
return s.syncContacts(ctx, orgID, "intercom_contacts", "list contacts", true, true, s.client.ListContacts)
|
||||
}
|
||||
|
||||
// SyncConversations fetches all conversations from Intercom and upserts them locally.
|
||||
func (s *IntercomSyncService) SyncConversations(ctx context.Context, orgID uuid.UUID) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "intercom_conversations"}
|
||||
cursor := ""
|
||||
|
||||
for {
|
||||
resp, err := s.client.ListConversations(ctx, accessToken, cursor)
|
||||
if err != nil {
|
||||
return progress, fmt.Errorf("list conversations: %w", err)
|
||||
}
|
||||
|
||||
for _, conv := range resp.Conversations {
|
||||
progress.Total++
|
||||
|
||||
contactID := ""
|
||||
if conv.Contacts != nil && len(conv.Contacts.Contacts) > 0 {
|
||||
contactID = conv.Contacts.Contacts[0].ID
|
||||
}
|
||||
|
||||
// Find local customer for this contact
|
||||
var customerID *uuid.UUID
|
||||
if contactID != "" {
|
||||
icContact, err := s.contacts.GetByIntercomID(ctx, orgID, contactID)
|
||||
if err == nil && icContact != nil && icContact.CustomerID != nil {
|
||||
customerID = icContact.CustomerID
|
||||
}
|
||||
}
|
||||
|
||||
createdAt := timeFromUnix(conv.CreatedAt)
|
||||
updatedAt := timeFromUnix(conv.UpdatedAt)
|
||||
|
||||
var closedAt *time.Time
|
||||
var firstResponseAt *time.Time
|
||||
|
||||
if conv.Statistics != nil {
|
||||
if conv.Statistics.FirstCloseAt > 0 {
|
||||
t := time.Unix(conv.Statistics.FirstCloseAt, 0)
|
||||
closedAt = &t
|
||||
}
|
||||
if conv.Statistics.FirstAdminReplyAt > 0 {
|
||||
t := time.Unix(conv.Statistics.FirstAdminReplyAt, 0)
|
||||
firstResponseAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
var rating int
|
||||
var ratingRemark string
|
||||
if conv.ConversationRating != nil {
|
||||
rating = conv.ConversationRating.Rating
|
||||
ratingRemark = conv.ConversationRating.Remark
|
||||
}
|
||||
|
||||
icConv := &repository.IntercomConversation{
|
||||
OrgID: orgID,
|
||||
CustomerID: customerID,
|
||||
IntercomConversationID: conv.ID,
|
||||
IntercomContactID: contactID,
|
||||
State: conv.State,
|
||||
Rating: rating,
|
||||
RatingRemark: ratingRemark,
|
||||
Open: conv.Open,
|
||||
Read: conv.Read,
|
||||
Priority: conv.Priority,
|
||||
Subject: conv.Title,
|
||||
CreatedAtRemote: createdAt,
|
||||
UpdatedAtRemote: updatedAt,
|
||||
ClosedAt: closedAt,
|
||||
FirstResponseAt: firstResponseAt,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.conversations.Upsert(ctx, icConv); err != nil {
|
||||
slog.Error("failed to upsert intercom conversation", "intercom_id", conv.ID, "error", err)
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Create customer event for conversation
|
||||
if customerID != nil {
|
||||
eventType := "conversation_" + conv.State
|
||||
event := &repository.CustomerEvent{
|
||||
OrgID: orgID,
|
||||
CustomerID: *customerID,
|
||||
EventType: eventType,
|
||||
Source: "intercom",
|
||||
ExternalEventID: "conv_" + conv.ID,
|
||||
OccurredAt: time.Now(),
|
||||
Data: map[string]any{
|
||||
"conversation_id": conv.ID,
|
||||
"state": conv.State,
|
||||
"priority": conv.Priority,
|
||||
"subject": conv.Title,
|
||||
},
|
||||
}
|
||||
if err := s.events.Upsert(ctx, event); err != nil {
|
||||
slog.Error("failed to create conversation event", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
if resp.Pages == nil || resp.Pages.Next == nil || resp.Pages.Next.StartingAfter == "" {
|
||||
break
|
||||
}
|
||||
cursor = resp.Pages.Next.StartingAfter
|
||||
}
|
||||
|
||||
slog.Info("intercom conversation sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
return s.syncConversations(
|
||||
ctx,
|
||||
orgID,
|
||||
"intercom_conversations",
|
||||
"list conversations",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
s.client.ListConversations,
|
||||
)
|
||||
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
// SyncContactsSince fetches contacts updated since the given time (incremental sync).
|
||||
func (s *IntercomSyncService) SyncContactsSince(ctx context.Context, orgID uuid.UUID, since time.Time) (*SyncProgress, error) {
|
||||
fetchPage := func(ctx context.Context, accessToken, cursor string) (*IntercomContactListResponse, error) {
|
||||
return s.client.ListContactsUpdatedSince(ctx, accessToken, since.Unix(), cursor)
|
||||
}
|
||||
|
||||
return s.syncContacts(
|
||||
ctx,
|
||||
orgID,
|
||||
"intercom_contacts_incremental",
|
||||
"list contacts since",
|
||||
false,
|
||||
false,
|
||||
fetchPage,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *IntercomSyncService) syncContacts(
|
||||
ctx context.Context,
|
||||
orgID uuid.UUID,
|
||||
step string,
|
||||
listErrorPrefix string,
|
||||
logUpsertErrors bool,
|
||||
logCompletion bool,
|
||||
fetchPage intercomContactPageFetcher,
|
||||
) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "intercom_contacts_incremental"}
|
||||
progress := &SyncProgress{Step: step}
|
||||
cursor := ""
|
||||
|
||||
for {
|
||||
resp, err := s.client.ListContactsUpdatedSince(ctx, accessToken, since.Unix(), cursor)
|
||||
resp, err := fetchPage(ctx, accessToken, cursor)
|
||||
if err != nil {
|
||||
return progress, fmt.Errorf("list contacts since: %w", err)
|
||||
return progress, fmt.Errorf("%s: %w", listErrorPrefix, err)
|
||||
}
|
||||
|
||||
for _, c := range resp.Data {
|
||||
progress.Total++
|
||||
|
||||
icContact := &repository.IntercomContact{
|
||||
OrgID: orgID,
|
||||
IntercomContactID: c.ID,
|
||||
Email: c.Email,
|
||||
Name: c.Name,
|
||||
Role: c.Role,
|
||||
IntercomCompanyID: c.CompanyID,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.contacts.Upsert(ctx, icContact); err != nil {
|
||||
if err := s.upsertContactAndCustomer(ctx, orgID, c, logUpsertErrors); err != nil {
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
localCustomer := &repository.Customer{
|
||||
OrgID: orgID,
|
||||
ExternalID: c.ID,
|
||||
Source: "intercom",
|
||||
Email: c.Email,
|
||||
Name: c.Name,
|
||||
FirstSeenAt: &now,
|
||||
LastSeenAt: &now,
|
||||
Metadata: map[string]any{
|
||||
"intercom": map[string]any{
|
||||
"role": c.Role,
|
||||
"company_id": c.CompanyID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.customers.UpsertByExternal(ctx, localCustomer); err != nil {
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.contacts.LinkCustomer(ctx, icContact.ID, localCustomer.ID); err != nil {
|
||||
slog.Error("failed to link intercom contact to customer", "error", err)
|
||||
}
|
||||
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
if resp.Pages == nil || resp.Pages.Next == nil || resp.Pages.Next.StartingAfter == "" {
|
||||
nextCursor := nextIntercomCursor(resp.Pages)
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = resp.Pages.Next.StartingAfter
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if logCompletion {
|
||||
slog.Info("intercom contact sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
}
|
||||
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
func (s *IntercomSyncService) upsertContactAndCustomer(ctx context.Context, orgID uuid.UUID, c IntercomAPIContact, logUpsertErrors bool) error {
|
||||
icContact := &repository.IntercomContact{
|
||||
OrgID: orgID,
|
||||
IntercomContactID: c.ID,
|
||||
Email: c.Email,
|
||||
Name: c.Name,
|
||||
Role: c.Role,
|
||||
IntercomCompanyID: c.CompanyID,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.contacts.Upsert(ctx, icContact); err != nil {
|
||||
if logUpsertErrors {
|
||||
slog.Error("failed to upsert intercom contact", "intercom_id", c.ID, "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
localCustomer := &repository.Customer{
|
||||
OrgID: orgID,
|
||||
ExternalID: c.ID,
|
||||
Source: "intercom",
|
||||
Email: c.Email,
|
||||
Name: c.Name,
|
||||
FirstSeenAt: &now,
|
||||
LastSeenAt: &now,
|
||||
Metadata: map[string]any{
|
||||
"intercom": map[string]any{
|
||||
"role": c.Role,
|
||||
"company_id": c.CompanyID,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.customers.UpsertByExternal(ctx, localCustomer); err != nil {
|
||||
if logUpsertErrors {
|
||||
slog.Error("failed to upsert customer from intercom", "intercom_id", c.ID, "error", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.contacts.LinkCustomer(ctx, icContact.ID, localCustomer.ID); err != nil {
|
||||
slog.Error("failed to link intercom contact to customer", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncConversationsSince fetches conversations updated since the given time (incremental sync).
|
||||
func (s *IntercomSyncService) SyncConversationsSince(ctx context.Context, orgID uuid.UUID, since time.Time) (*SyncProgress, error) {
|
||||
fetchPage := func(ctx context.Context, accessToken, cursor string) (*IntercomConversationListResponse, error) {
|
||||
return s.client.ListConversationsUpdatedSince(ctx, accessToken, since.Unix(), cursor)
|
||||
}
|
||||
|
||||
return s.syncConversations(
|
||||
ctx,
|
||||
orgID,
|
||||
"intercom_conversations_incremental",
|
||||
"list conversations since",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
fetchPage,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *IntercomSyncService) syncConversations(
|
||||
ctx context.Context,
|
||||
orgID uuid.UUID,
|
||||
step string,
|
||||
listErrorPrefix string,
|
||||
emitEvents bool,
|
||||
logUpsertErrors bool,
|
||||
logCompletion bool,
|
||||
fetchPage intercomConversationPageFetcher,
|
||||
) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "intercom_conversations_incremental"}
|
||||
progress := &SyncProgress{Step: step}
|
||||
cursor := ""
|
||||
|
||||
for {
|
||||
resp, err := s.client.ListConversationsUpdatedSince(ctx, accessToken, since.Unix(), cursor)
|
||||
resp, err := fetchPage(ctx, accessToken, cursor)
|
||||
if err != nil {
|
||||
return progress, fmt.Errorf("list conversations since: %w", err)
|
||||
return progress, fmt.Errorf("%s: %w", listErrorPrefix, err)
|
||||
}
|
||||
|
||||
for _, conv := range resp.Conversations {
|
||||
progress.Total++
|
||||
|
||||
contactID := ""
|
||||
if conv.Contacts != nil && len(conv.Contacts.Contacts) > 0 {
|
||||
contactID = conv.Contacts.Contacts[0].ID
|
||||
}
|
||||
|
||||
var customerID *uuid.UUID
|
||||
if contactID != "" {
|
||||
icContact, err := s.contacts.GetByIntercomID(ctx, orgID, contactID)
|
||||
if err == nil && icContact != nil && icContact.CustomerID != nil {
|
||||
customerID = icContact.CustomerID
|
||||
if err := s.upsertConversation(ctx, orgID, conv, emitEvents); err != nil {
|
||||
if logUpsertErrors {
|
||||
slog.Error("failed to upsert intercom conversation", "intercom_id", conv.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
createdAt := timeFromUnix(conv.CreatedAt)
|
||||
updatedAt := timeFromUnix(conv.UpdatedAt)
|
||||
|
||||
var closedAt *time.Time
|
||||
var firstResponseAt *time.Time
|
||||
|
||||
if conv.Statistics != nil {
|
||||
if conv.Statistics.FirstCloseAt > 0 {
|
||||
t := time.Unix(conv.Statistics.FirstCloseAt, 0)
|
||||
closedAt = &t
|
||||
}
|
||||
if conv.Statistics.FirstAdminReplyAt > 0 {
|
||||
t := time.Unix(conv.Statistics.FirstAdminReplyAt, 0)
|
||||
firstResponseAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
var rating int
|
||||
var ratingRemark string
|
||||
if conv.ConversationRating != nil {
|
||||
rating = conv.ConversationRating.Rating
|
||||
ratingRemark = conv.ConversationRating.Remark
|
||||
}
|
||||
|
||||
icConv := &repository.IntercomConversation{
|
||||
OrgID: orgID,
|
||||
CustomerID: customerID,
|
||||
IntercomConversationID: conv.ID,
|
||||
IntercomContactID: contactID,
|
||||
State: conv.State,
|
||||
Rating: rating,
|
||||
RatingRemark: ratingRemark,
|
||||
Open: conv.Open,
|
||||
Read: conv.Read,
|
||||
Priority: conv.Priority,
|
||||
Subject: conv.Title,
|
||||
CreatedAtRemote: createdAt,
|
||||
UpdatedAtRemote: updatedAt,
|
||||
ClosedAt: closedAt,
|
||||
FirstResponseAt: firstResponseAt,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
|
||||
if err := s.conversations.Upsert(ctx, icConv); err != nil {
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
@@ -401,15 +237,147 @@ func (s *IntercomSyncService) SyncConversationsSince(ctx context.Context, orgID
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
if resp.Pages == nil || resp.Pages.Next == nil || resp.Pages.Next.StartingAfter == "" {
|
||||
nextCursor := nextIntercomCursor(resp.Pages)
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = resp.Pages.Next.StartingAfter
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if logCompletion {
|
||||
slog.Info("intercom conversation sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
}
|
||||
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
func (s *IntercomSyncService) upsertConversation(ctx context.Context, orgID uuid.UUID, conv IntercomAPIConversation, emitEvent bool) error {
|
||||
contactID := intercomConversationContactID(conv)
|
||||
customerID := s.resolveConversationCustomerID(ctx, orgID, contactID)
|
||||
|
||||
icConv := mapIntercomConversation(orgID, conv, contactID, customerID)
|
||||
if err := s.conversations.Upsert(ctx, icConv); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if emitEvent {
|
||||
s.emitConversationEvent(ctx, orgID, customerID, conv)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IntercomSyncService) resolveConversationCustomerID(ctx context.Context, orgID uuid.UUID, contactID string) *uuid.UUID {
|
||||
if contactID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
icContact, err := s.contacts.GetByIntercomID(ctx, orgID, contactID)
|
||||
if err != nil || icContact == nil || icContact.CustomerID == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return icContact.CustomerID
|
||||
}
|
||||
|
||||
func mapIntercomConversation(orgID uuid.UUID, conv IntercomAPIConversation, contactID string, customerID *uuid.UUID) *repository.IntercomConversation {
|
||||
closedAt, firstResponseAt := intercomConversationTiming(conv.Statistics)
|
||||
rating, ratingRemark := intercomConversationRating(conv.ConversationRating)
|
||||
|
||||
return &repository.IntercomConversation{
|
||||
OrgID: orgID,
|
||||
CustomerID: customerID,
|
||||
IntercomConversationID: conv.ID,
|
||||
IntercomContactID: contactID,
|
||||
State: conv.State,
|
||||
Rating: rating,
|
||||
RatingRemark: ratingRemark,
|
||||
Open: conv.Open,
|
||||
Read: conv.Read,
|
||||
Priority: conv.Priority,
|
||||
Subject: conv.Title,
|
||||
CreatedAtRemote: timeFromUnix(conv.CreatedAt),
|
||||
UpdatedAtRemote: timeFromUnix(conv.UpdatedAt),
|
||||
ClosedAt: closedAt,
|
||||
FirstResponseAt: firstResponseAt,
|
||||
Metadata: map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func intercomConversationContactID(conv IntercomAPIConversation) string {
|
||||
if conv.Contacts == nil || len(conv.Contacts.Contacts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return conv.Contacts.Contacts[0].ID
|
||||
}
|
||||
|
||||
func intercomConversationTiming(stats *IntercomConvStatistics) (*time.Time, *time.Time) {
|
||||
if stats == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var closedAt *time.Time
|
||||
if stats.FirstCloseAt > 0 {
|
||||
t := time.Unix(stats.FirstCloseAt, 0)
|
||||
closedAt = &t
|
||||
}
|
||||
|
||||
var firstResponseAt *time.Time
|
||||
if stats.FirstAdminReplyAt > 0 {
|
||||
t := time.Unix(stats.FirstAdminReplyAt, 0)
|
||||
firstResponseAt = &t
|
||||
}
|
||||
|
||||
return closedAt, firstResponseAt
|
||||
}
|
||||
|
||||
func intercomConversationRating(rating *IntercomConvRating) (int, string) {
|
||||
if rating == nil {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
return rating.Rating, rating.Remark
|
||||
}
|
||||
|
||||
func (s *IntercomSyncService) emitConversationEvent(ctx context.Context, orgID uuid.UUID, customerID *uuid.UUID, conv IntercomAPIConversation) {
|
||||
if customerID == nil {
|
||||
return
|
||||
}
|
||||
|
||||
event := &repository.CustomerEvent{
|
||||
OrgID: orgID,
|
||||
CustomerID: *customerID,
|
||||
EventType: "conversation_" + conv.State,
|
||||
Source: "intercom",
|
||||
ExternalEventID: "conv_" + conv.ID,
|
||||
OccurredAt: time.Now(),
|
||||
Data: map[string]any{
|
||||
"conversation_id": conv.ID,
|
||||
"state": conv.State,
|
||||
"priority": conv.Priority,
|
||||
"subject": conv.Title,
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.events.Upsert(ctx, event); err != nil {
|
||||
slog.Error("failed to create conversation event", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func nextIntercomCursor(pages *IntercomPages) string {
|
||||
if pages == nil || pages.Next == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return pages.Next.StartingAfter
|
||||
}
|
||||
|
||||
func timeFromUnix(ts int64) *time.Time {
|
||||
if ts == 0 {
|
||||
return nil
|
||||
|
||||
@@ -17,12 +17,12 @@ import (
|
||||
|
||||
// StripeSyncService handles syncing data from Stripe to local database.
|
||||
type StripeSyncService struct {
|
||||
customers *repository.CustomerRepository
|
||||
subs *repository.StripeSubscriptionRepository
|
||||
payments *repository.StripePaymentRepository
|
||||
events *repository.CustomerEventRepository
|
||||
oauthSvc *StripeOAuthService
|
||||
paymentDays int
|
||||
customers *repository.CustomerRepository
|
||||
subs *repository.StripeSubscriptionRepository
|
||||
payments *repository.StripePaymentRepository
|
||||
events *repository.CustomerEventRepository
|
||||
oauthSvc *StripeOAuthService
|
||||
paymentDays int
|
||||
}
|
||||
|
||||
// NewStripeSyncService creates a new StripeSyncService.
|
||||
@@ -46,79 +46,49 @@ func NewStripeSyncService(
|
||||
|
||||
// SyncProgress tracks the progress of a sync operation.
|
||||
type SyncProgress struct {
|
||||
Step string `json:"step"`
|
||||
Total int `json:"total"`
|
||||
Current int `json:"current"`
|
||||
Errors int `json:"errors"`
|
||||
Step string `json:"step"`
|
||||
Total int `json:"total"`
|
||||
Current int `json:"current"`
|
||||
Errors int `json:"errors"`
|
||||
}
|
||||
|
||||
type stripePaymentSyncOptions struct {
|
||||
logLookupErrors bool
|
||||
logUpsertErrors bool
|
||||
emitFailedPaymentEvent bool
|
||||
logCompletion bool
|
||||
}
|
||||
|
||||
// SyncCustomers fetches all customers from Stripe and upserts them locally.
|
||||
func (s *StripeSyncService) SyncCustomers(ctx context.Context, orgID uuid.UUID) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "customers"}
|
||||
|
||||
params := &stripe.CustomerListParams{}
|
||||
params.Limit = stripe.Int64(100)
|
||||
|
||||
client := newStripeCustomerClient(accessToken)
|
||||
iter := client.List(params)
|
||||
|
||||
for iter.Next() {
|
||||
c := iter.Customer()
|
||||
progress.Total++
|
||||
|
||||
now := time.Now()
|
||||
created := time.Unix(c.Created, 0)
|
||||
localCustomer := &repository.Customer{
|
||||
OrgID: orgID,
|
||||
ExternalID: c.ID,
|
||||
Source: "stripe",
|
||||
Email: c.Email,
|
||||
Name: c.Name,
|
||||
Currency: string(c.Currency),
|
||||
FirstSeenAt: &created,
|
||||
LastSeenAt: &now,
|
||||
Metadata: stripeMetadataToMap(c.Metadata),
|
||||
}
|
||||
|
||||
if err := s.customers.UpsertByExternal(ctx, localCustomer); err != nil {
|
||||
slog.Error("failed to upsert customer", "stripe_id", c.ID, "error", err)
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
return progress, fmt.Errorf("iterate customers: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("customer sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
|
||||
return progress, nil
|
||||
return s.syncCustomers(ctx, orgID, "customers", params, true)
|
||||
}
|
||||
|
||||
// SyncCustomersSince fetches customers modified since the given time (incremental sync).
|
||||
func (s *StripeSyncService) SyncCustomersSince(ctx context.Context, orgID uuid.UUID, since time.Time) (*SyncProgress, error) {
|
||||
params := &stripe.CustomerListParams{}
|
||||
params.Limit = stripe.Int64(100)
|
||||
params.CreatedRange = &stripe.RangeQueryParams{GreaterThanOrEqual: since.Unix()}
|
||||
|
||||
return s.syncCustomers(ctx, orgID, "customers_incremental", params, false)
|
||||
}
|
||||
|
||||
func (s *StripeSyncService) syncCustomers(
|
||||
ctx context.Context,
|
||||
orgID uuid.UUID,
|
||||
step string,
|
||||
params *stripe.CustomerListParams,
|
||||
logCompletion bool,
|
||||
) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "customers_incremental"}
|
||||
|
||||
params := &stripe.CustomerListParams{}
|
||||
params.Limit = stripe.Int64(100)
|
||||
params.CreatedRange = &stripe.RangeQueryParams{GreaterThanOrEqual: since.Unix()}
|
||||
progress := &SyncProgress{Step: step}
|
||||
|
||||
client := newStripeCustomerClient(accessToken)
|
||||
iter := client.List(params)
|
||||
@@ -127,25 +97,12 @@ func (s *StripeSyncService) SyncCustomersSince(ctx context.Context, orgID uuid.U
|
||||
c := iter.Customer()
|
||||
progress.Total++
|
||||
|
||||
now := time.Now()
|
||||
created := time.Unix(c.Created, 0)
|
||||
localCustomer := &repository.Customer{
|
||||
OrgID: orgID,
|
||||
ExternalID: c.ID,
|
||||
Source: "stripe",
|
||||
Email: c.Email,
|
||||
Name: c.Name,
|
||||
Currency: string(c.Currency),
|
||||
FirstSeenAt: &created,
|
||||
LastSeenAt: &now,
|
||||
Metadata: stripeMetadataToMap(c.Metadata),
|
||||
}
|
||||
|
||||
if err := s.customers.UpsertByExternal(ctx, localCustomer); err != nil {
|
||||
if err := s.upsertCustomer(ctx, orgID, c); err != nil {
|
||||
slog.Error("failed to upsert customer", "stripe_id", c.ID, "error", err)
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
@@ -153,9 +110,37 @@ func (s *StripeSyncService) SyncCustomersSince(ctx context.Context, orgID uuid.U
|
||||
return progress, fmt.Errorf("iterate customers: %w", err)
|
||||
}
|
||||
|
||||
if logCompletion {
|
||||
slog.Info("customer sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
}
|
||||
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
func (s *StripeSyncService) upsertCustomer(ctx context.Context, orgID uuid.UUID, c *stripe.Customer) error {
|
||||
now := time.Now()
|
||||
created := time.Unix(c.Created, 0)
|
||||
|
||||
localCustomer := &repository.Customer{
|
||||
OrgID: orgID,
|
||||
ExternalID: c.ID,
|
||||
Source: "stripe",
|
||||
Email: c.Email,
|
||||
Name: c.Name,
|
||||
Currency: string(c.Currency),
|
||||
FirstSeenAt: &created,
|
||||
LastSeenAt: &now,
|
||||
Metadata: stripeMetadataToMap(c.Metadata),
|
||||
}
|
||||
|
||||
return s.customers.UpsertByExternal(ctx, localCustomer)
|
||||
}
|
||||
|
||||
// SyncSubscriptions fetches all subscriptions from Stripe and upserts them locally.
|
||||
func (s *StripeSyncService) SyncSubscriptions(ctx context.Context, orgID uuid.UUID) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
@@ -253,123 +238,42 @@ func (s *StripeSyncService) SyncSubscriptions(ctx context.Context, orgID uuid.UU
|
||||
|
||||
// SyncPayments fetches charges from Stripe and upserts them locally.
|
||||
func (s *StripeSyncService) SyncPayments(ctx context.Context, orgID uuid.UUID) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "payments"}
|
||||
since := time.Now().AddDate(0, 0, -s.paymentDays)
|
||||
|
||||
params := &stripe.ChargeListParams{}
|
||||
params.Limit = stripe.Int64(100)
|
||||
params.CreatedRange = &stripe.RangeQueryParams{GreaterThanOrEqual: since.Unix()}
|
||||
|
||||
client := newStripeChargeClient(accessToken)
|
||||
iter := client.List(params)
|
||||
|
||||
for iter.Next() {
|
||||
ch := iter.Charge()
|
||||
progress.Total++
|
||||
|
||||
if ch.Customer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
localCustomer, err := s.customers.GetByExternalID(ctx, orgID, "stripe", ch.Customer.ID)
|
||||
if err != nil {
|
||||
slog.Error("failed to lookup customer for charge",
|
||||
"stripe_charge_id", ch.ID,
|
||||
"error", err,
|
||||
)
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
if localCustomer == nil {
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
status := "succeeded"
|
||||
if ch.Status == "failed" {
|
||||
status = "failed"
|
||||
} else if !ch.Paid {
|
||||
status = "pending"
|
||||
}
|
||||
|
||||
var paidAt *time.Time
|
||||
if ch.Created > 0 {
|
||||
t := time.Unix(ch.Created, 0)
|
||||
paidAt = &t
|
||||
}
|
||||
|
||||
localPayment := &repository.StripePayment{
|
||||
OrgID: orgID,
|
||||
CustomerID: localCustomer.ID,
|
||||
StripePaymentID: ch.ID,
|
||||
AmountCents: int(ch.Amount),
|
||||
Currency: string(ch.Currency),
|
||||
Status: status,
|
||||
FailureCode: string(ch.FailureCode),
|
||||
FailureMessage: ch.FailureMessage,
|
||||
PaidAt: paidAt,
|
||||
}
|
||||
|
||||
if err := s.payments.Upsert(ctx, localPayment); err != nil {
|
||||
slog.Error("failed to upsert payment", "stripe_charge_id", ch.ID, "error", err)
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
progress.Current++
|
||||
|
||||
// Create customer event for failed payments
|
||||
if status == "failed" {
|
||||
event := &repository.CustomerEvent{
|
||||
OrgID: orgID,
|
||||
CustomerID: localCustomer.ID,
|
||||
EventType: "payment.failed",
|
||||
Source: "stripe",
|
||||
ExternalEventID: "charge_failed_" + ch.ID,
|
||||
OccurredAt: time.Unix(ch.Created, 0),
|
||||
Data: map[string]any{
|
||||
"amount_cents": ch.Amount,
|
||||
"currency": string(ch.Currency),
|
||||
"failure_code": string(ch.FailureCode),
|
||||
"failure_message": ch.FailureMessage,
|
||||
},
|
||||
}
|
||||
if err := s.events.Upsert(ctx, event); err != nil {
|
||||
slog.Error("failed to create payment failed event", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
return progress, fmt.Errorf("iterate charges: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("payment sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
|
||||
return progress, nil
|
||||
return s.syncPayments(ctx, orgID, "payments", params, stripePaymentSyncOptions{
|
||||
logLookupErrors: true,
|
||||
logUpsertErrors: true,
|
||||
emitFailedPaymentEvent: true,
|
||||
logCompletion: true,
|
||||
})
|
||||
}
|
||||
|
||||
// SyncPaymentsSince fetches charges modified since the given time.
|
||||
func (s *StripeSyncService) SyncPaymentsSince(ctx context.Context, orgID uuid.UUID, since time.Time) (*SyncProgress, error) {
|
||||
params := &stripe.ChargeListParams{}
|
||||
params.Limit = stripe.Int64(100)
|
||||
params.CreatedRange = &stripe.RangeQueryParams{GreaterThanOrEqual: since.Unix()}
|
||||
|
||||
return s.syncPayments(ctx, orgID, "payments_incremental", params, stripePaymentSyncOptions{})
|
||||
}
|
||||
|
||||
func (s *StripeSyncService) syncPayments(
|
||||
ctx context.Context,
|
||||
orgID uuid.UUID,
|
||||
step string,
|
||||
params *stripe.ChargeListParams,
|
||||
options stripePaymentSyncOptions,
|
||||
) (*SyncProgress, error) {
|
||||
accessToken, err := s.oauthSvc.GetAccessToken(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get access token: %w", err)
|
||||
}
|
||||
|
||||
progress := &SyncProgress{Step: "payments_incremental"}
|
||||
|
||||
params := &stripe.ChargeListParams{}
|
||||
params.Limit = stripe.Int64(100)
|
||||
params.CreatedRange = &stripe.RangeQueryParams{GreaterThanOrEqual: since.Unix()}
|
||||
progress := &SyncProgress{Step: step}
|
||||
|
||||
client := newStripeChargeClient(accessToken)
|
||||
iter := client.List(params)
|
||||
@@ -378,55 +282,124 @@ func (s *StripeSyncService) SyncPaymentsSince(ctx context.Context, orgID uuid.UU
|
||||
ch := iter.Charge()
|
||||
progress.Total++
|
||||
|
||||
if ch.Customer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
localCustomer, err := s.customers.GetByExternalID(ctx, orgID, "stripe", ch.Customer.ID)
|
||||
if err != nil || localCustomer == nil {
|
||||
synced, err := s.processPaymentCharge(ctx, orgID, ch, options)
|
||||
if err != nil {
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
|
||||
status := "succeeded"
|
||||
if ch.Status == "failed" {
|
||||
status = "failed"
|
||||
} else if !ch.Paid {
|
||||
status = "pending"
|
||||
if synced {
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
var paidAt *time.Time
|
||||
if ch.Created > 0 {
|
||||
t := time.Unix(ch.Created, 0)
|
||||
paidAt = &t
|
||||
}
|
||||
|
||||
localPayment := &repository.StripePayment{
|
||||
OrgID: orgID,
|
||||
CustomerID: localCustomer.ID,
|
||||
StripePaymentID: ch.ID,
|
||||
AmountCents: int(ch.Amount),
|
||||
Currency: string(ch.Currency),
|
||||
Status: status,
|
||||
FailureCode: string(ch.FailureCode),
|
||||
FailureMessage: ch.FailureMessage,
|
||||
PaidAt: paidAt,
|
||||
}
|
||||
|
||||
if err := s.payments.Upsert(ctx, localPayment); err != nil {
|
||||
progress.Errors++
|
||||
continue
|
||||
}
|
||||
progress.Current++
|
||||
}
|
||||
|
||||
if err := iter.Err(); err != nil {
|
||||
return progress, fmt.Errorf("iterate charges: %w", err)
|
||||
}
|
||||
|
||||
if options.logCompletion {
|
||||
slog.Info("payment sync complete",
|
||||
"org_id", orgID,
|
||||
"total", progress.Total,
|
||||
"synced", progress.Current,
|
||||
"errors", progress.Errors,
|
||||
)
|
||||
}
|
||||
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
func (s *StripeSyncService) processPaymentCharge(
|
||||
ctx context.Context,
|
||||
orgID uuid.UUID,
|
||||
ch *stripe.Charge,
|
||||
options stripePaymentSyncOptions,
|
||||
) (bool, error) {
|
||||
if ch.Customer == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
localCustomer, err := s.customers.GetByExternalID(ctx, orgID, "stripe", ch.Customer.ID)
|
||||
if err != nil {
|
||||
if options.logLookupErrors {
|
||||
slog.Error("failed to lookup customer for charge",
|
||||
"stripe_charge_id", ch.ID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if localCustomer == nil {
|
||||
return false, fmt.Errorf("customer not found for stripe charge: %s", ch.ID)
|
||||
}
|
||||
|
||||
localPayment := buildStripePayment(orgID, localCustomer.ID, ch)
|
||||
if err := s.payments.Upsert(ctx, localPayment); err != nil {
|
||||
if options.logUpsertErrors {
|
||||
slog.Error("failed to upsert payment", "stripe_charge_id", ch.ID, "error", err)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
if options.emitFailedPaymentEvent && localPayment.Status == "failed" {
|
||||
s.emitFailedPaymentEvent(ctx, orgID, localCustomer.ID, ch)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func buildStripePayment(orgID, customerID uuid.UUID, ch *stripe.Charge) *repository.StripePayment {
|
||||
var paidAt *time.Time
|
||||
if ch.Created > 0 {
|
||||
t := time.Unix(ch.Created, 0)
|
||||
paidAt = &t
|
||||
}
|
||||
|
||||
return &repository.StripePayment{
|
||||
OrgID: orgID,
|
||||
CustomerID: customerID,
|
||||
StripePaymentID: ch.ID,
|
||||
AmountCents: int(ch.Amount),
|
||||
Currency: string(ch.Currency),
|
||||
Status: stripeChargeStatus(ch),
|
||||
FailureCode: string(ch.FailureCode),
|
||||
FailureMessage: ch.FailureMessage,
|
||||
PaidAt: paidAt,
|
||||
}
|
||||
}
|
||||
|
||||
func stripeChargeStatus(ch *stripe.Charge) string {
|
||||
if ch.Status == "failed" {
|
||||
return "failed"
|
||||
}
|
||||
if !ch.Paid {
|
||||
return "pending"
|
||||
}
|
||||
|
||||
return "succeeded"
|
||||
}
|
||||
|
||||
func (s *StripeSyncService) emitFailedPaymentEvent(ctx context.Context, orgID, customerID uuid.UUID, ch *stripe.Charge) {
|
||||
event := &repository.CustomerEvent{
|
||||
OrgID: orgID,
|
||||
CustomerID: customerID,
|
||||
EventType: "payment.failed",
|
||||
Source: "stripe",
|
||||
ExternalEventID: "charge_failed_" + ch.ID,
|
||||
OccurredAt: time.Unix(ch.Created, 0),
|
||||
Data: map[string]any{
|
||||
"amount_cents": ch.Amount,
|
||||
"currency": string(ch.Currency),
|
||||
"failure_code": string(ch.FailureCode),
|
||||
"failure_message": ch.FailureMessage,
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.events.Upsert(ctx, event); err != nil {
|
||||
slog.Error("failed to create payment failed event", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// extractSubscriptionDetails extracts plan name, amount, interval, and currency from a subscription.
|
||||
func extractSubscriptionDetails(sub *stripe.Subscription) (planName string, amountCents int, interval, currency string) {
|
||||
if sub.Items == nil || len(sub.Items.Data) == 0 {
|
||||
|
||||
259
plans/clean-code-refactor-plan.md
Normal file
259
plans/clean-code-refactor-plan.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Clean Code Refactor Plan (PR-Sized)
|
||||
|
||||
Date: 2026-02-24
|
||||
Source: `clean-code-review.json` (`CC-001` ... `CC-010`)
|
||||
|
||||
## Goal
|
||||
|
||||
Refactor high/medium/low clean-code findings **without changing runtime behavior**.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Preserve existing request/response behavior and background job behavior.
|
||||
- Keep all public route paths, payloads, and status codes unchanged.
|
||||
- Run validation after each PR:
|
||||
- `make lint`
|
||||
- `make test`
|
||||
- `make web-lint`
|
||||
- `make web-format-check`
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
Work from low-risk, high-confidence extractions toward larger architectural splits.
|
||||
|
||||
---
|
||||
|
||||
## PR-01: Shared constants + webhook body helper
|
||||
|
||||
**Targets**: `CC-006`, `CC-010`
|
||||
**Risk**: Low
|
||||
**Estimated size**: ~150-250 LOC touched
|
||||
|
||||
### Scope
|
||||
|
||||
- Introduce shared constants for repeated literals:
|
||||
- webhook body max bytes (`65536`)
|
||||
- read chunk bytes (`1024`)
|
||||
- CORS max age (`300`)
|
||||
- connection monitor interval (`60`)
|
||||
- Replace inline usage in:
|
||||
- `cmd/api/main.go`
|
||||
- `internal/handler/integration_stripe.go`
|
||||
- `internal/handler/integration_hubspot.go`
|
||||
- `internal/handler/integration_intercom.go`
|
||||
- `internal/handler/billing.go`
|
||||
- Rename vague local names in the request-body reader (`buf/tmp` → `bodyBytes/chunkBuffer`).
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- No behavior change in webhook handling.
|
||||
- No remaining inline `65536` / `1024` for webhook-body handling.
|
||||
- All checks pass.
|
||||
|
||||
---
|
||||
|
||||
## PR-02: Centralized HTTP service error translation
|
||||
|
||||
**Targets**: `CC-007`
|
||||
**Risk**: Low
|
||||
**Estimated size**: ~80-140 LOC touched
|
||||
|
||||
### Scope
|
||||
|
||||
- Create a package-level handler utility (e.g., `internal/handler/errors.go`) for mapping service errors to HTTP responses.
|
||||
- Remove indirect coupling from `internal/handler/integration_stripe.go:121-124` (`AuthHandler` proxy call).
|
||||
- Switch affected handlers to the shared utility.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Existing error status codes/messages remain unchanged.
|
||||
- No `AuthHandler` instantiation solely for error forwarding.
|
||||
- All checks pass.
|
||||
|
||||
---
|
||||
|
||||
## PR-03: Deduplicate integration handler control flow
|
||||
|
||||
**Targets**: `CC-003`
|
||||
**Risk**: Medium
|
||||
**Estimated size**: ~250-450 LOC touched
|
||||
|
||||
### Scope
|
||||
|
||||
- Refactor repeated patterns across:
|
||||
- `internal/handler/integration_stripe.go`
|
||||
- `internal/handler/integration_hubspot.go`
|
||||
- `internal/handler/integration_intercom.go`
|
||||
- Extract common patterns:
|
||||
- org resolution + unauthorized response
|
||||
- status/disconnect flow
|
||||
- trigger sync response flow
|
||||
- OAuth callback shared structure (provider-specific error text retained)
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Endpoint behavior and response payloads are unchanged for all 3 providers.
|
||||
- Duplicate handler logic reduced materially (target: eliminate copy-paste blocks for Connect/Status/Disconnect/TriggerSync).
|
||||
- All checks pass.
|
||||
|
||||
---
|
||||
|
||||
## PR-04: Refactor `IntercomSyncService.SyncConversations`
|
||||
|
||||
**Targets**: `CC-002`, `CC-004` (partial)
|
||||
**Risk**: Medium
|
||||
**Estimated size**: ~220-380 LOC touched
|
||||
|
||||
### Scope
|
||||
|
||||
- Split `SyncConversations` into focused helpers:
|
||||
- customer resolution
|
||||
- conversation-to-repository mapping
|
||||
- optional event emission
|
||||
- Keep outer method responsible only for pagination + orchestration.
|
||||
- Align helper shapes so incremental method can reuse them.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `SyncConversations` reduced below 100 lines.
|
||||
- No change in persistence fields written to `intercom_conversations` and `customer_events`.
|
||||
- All checks pass.
|
||||
|
||||
---
|
||||
|
||||
## PR-05: Consolidate full/incremental sync pipelines (Stripe)
|
||||
|
||||
**Targets**: `CC-004` (Stripe portion)
|
||||
**Risk**: Medium
|
||||
**Estimated size**: ~220-360 LOC touched
|
||||
|
||||
### Scope
|
||||
|
||||
- In `internal/service/stripe_sync.go`, extract shared processors used by:
|
||||
- `SyncCustomers` + `SyncCustomersSince`
|
||||
- `SyncPayments` + `SyncPaymentsSince`
|
||||
- Keep fetch-source differences isolated to iterator setup.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- No change in event creation behavior for failed payments.
|
||||
- Shared transformation/upsert logic has single implementation path per entity type.
|
||||
- All checks pass.
|
||||
|
||||
---
|
||||
|
||||
## PR-06: Consolidate full/incremental sync pipelines (HubSpot + Intercom contacts)
|
||||
|
||||
**Targets**: `CC-004` (remaining scope)
|
||||
**Risk**: Medium
|
||||
**Estimated size**: ~250-420 LOC touched
|
||||
|
||||
### Scope
|
||||
|
||||
- Reuse shared processing functions for:
|
||||
- HubSpot `SyncContacts` + `SyncContactsSince`
|
||||
- HubSpot `SyncDeals` + `SyncDealsSince`
|
||||
- Intercom `SyncContacts` + `SyncContactsSince`
|
||||
- Preserve existing logging semantics.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Data mapping/parsing behavior unchanged.
|
||||
- Reduced duplication in listed pairs.
|
||||
- All checks pass.
|
||||
|
||||
---
|
||||
|
||||
## PR-07: Constructor dependency object for alert scheduler
|
||||
|
||||
**Targets**: `CC-005`
|
||||
**Risk**: Medium
|
||||
**Estimated size**: ~100-180 LOC touched
|
||||
|
||||
### Scope
|
||||
|
||||
- Introduce `AlertSchedulerDeps` struct.
|
||||
- Replace `NewAlertScheduler(...)` long parameter list with `NewAlertScheduler(deps AlertSchedulerDeps, intervalMinutes int, frontendURL string)` (or similar minimal delta approach).
|
||||
- Update callsite(s) in `cmd/api/main.go`.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Constructor call is clearer and parameter order mistakes are less likely.
|
||||
- Runtime wiring remains unchanged.
|
||||
- All checks pass.
|
||||
|
||||
---
|
||||
|
||||
## PR-08: Split `cmd/api/main.go` bootstrap flow
|
||||
|
||||
**Targets**: `CC-001`, `CC-008`
|
||||
**Risk**: High
|
||||
**Estimated size**: ~400-700 LOC touched
|
||||
|
||||
### Scope
|
||||
|
||||
Refactor `main` into explicit phases with small functions:
|
||||
|
||||
- `initLogger()`
|
||||
- `loadAndValidateConfig()`
|
||||
- `openDatabase()`
|
||||
- `buildDependencies()` (repos/services)
|
||||
- `registerPublicRoutes()`
|
||||
- `registerProtectedRoutes()`
|
||||
- `startBackgroundWorkers()`
|
||||
- `newHTTPServer()`
|
||||
- `runServerWithGracefulShutdown()`
|
||||
|
||||
Keep behavior identical and avoid route/middleware drift.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `main` reduced well below 150 lines (target).
|
||||
- Route registration still exposes identical endpoint map.
|
||||
- Startup/shutdown behavior unchanged.
|
||||
- All checks pass.
|
||||
|
||||
---
|
||||
|
||||
## PR-09: Repository error handling convention fix
|
||||
|
||||
**Targets**: `CC-009`
|
||||
**Risk**: Low
|
||||
**Estimated size**: ~20-60 LOC touched
|
||||
|
||||
### Scope
|
||||
|
||||
- Replace string comparison in `internal/repository/customer_event.go:47` with typed error handling (`errors.Is(..., pgx.ErrNoRows)` or equivalent).
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Same logical behavior (`DO NOTHING` conflict returns nil path) is preserved.
|
||||
- No string-comparison-based row-not-found checks remain at this location.
|
||||
- All checks pass.
|
||||
|
||||
---
|
||||
|
||||
## Suggested Merge Order
|
||||
|
||||
1. PR-01
|
||||
2. PR-02
|
||||
3. PR-03
|
||||
4. PR-04
|
||||
5. PR-05
|
||||
6. PR-06
|
||||
7. PR-07
|
||||
8. PR-08
|
||||
9. PR-09
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
- Each PR must remain independently revertible.
|
||||
- Avoid combining structural and behavioral changes in the same PR.
|
||||
- If regressions appear in PR-08 (largest), revert only PR-08 while retaining earlier cleanup PRs.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- All `CC-001` to `CC-010` addressed.
|
||||
- No API contract changes.
|
||||
- CI checks (`make check`) pass.
|
||||
- Refactor commits are small, reviewable, and scoped to one PR objective each.
|
||||
@@ -35,8 +35,14 @@ function App() {
|
||||
<Route path="/terms" element={<TermsPage />} />
|
||||
|
||||
{/* Backward-compatible auth aliases */}
|
||||
<Route path="/auth/login" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/auth/register" element={<Navigate to="/register" replace />} />
|
||||
<Route
|
||||
path="/auth/login"
|
||||
element={<Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/auth/register"
|
||||
element={<Navigate to="/register" replace />}
|
||||
/>
|
||||
|
||||
{/* Protected app routes */}
|
||||
<Route
|
||||
|
||||
@@ -58,14 +58,21 @@ export default function NotificationBell() {
|
||||
async function handleMarkRead(id: string) {
|
||||
await notificationsApi.markRead(id);
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, read_at: new Date().toISOString() } : n))
|
||||
prev.map((n) =>
|
||||
n.id === id ? { ...n, read_at: new Date().toISOString() } : n,
|
||||
),
|
||||
);
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
await notificationsApi.markAllRead();
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read_at: n.read_at ?? new Date().toISOString() })));
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => ({
|
||||
...n,
|
||||
read_at: n.read_at ?? new Date().toISOString(),
|
||||
})),
|
||||
);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,8 @@ export default function ProtectedRoute({
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const [onboardingLoading, setOnboardingLoading] = useState(true);
|
||||
const [onboardingCompleted, setOnboardingCompleted] = useState(true);
|
||||
const [onboardingStep, setOnboardingStep] = useState<OnboardingStepId>(
|
||||
"welcome",
|
||||
);
|
||||
const [onboardingStep, setOnboardingStep] =
|
||||
useState<OnboardingStepId>("welcome");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -82,13 +81,8 @@ export default function ProtectedRoute({
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (
|
||||
!onboardingCompleted &&
|
||||
!shouldBypassOnboarding(location.pathname)
|
||||
) {
|
||||
return (
|
||||
<Navigate to={`/onboarding?step=${onboardingStep}`} replace />
|
||||
);
|
||||
if (!onboardingCompleted && !shouldBypassOnboarding(location.pathname)) {
|
||||
return <Navigate to={`/onboarding?step=${onboardingStep}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
type StructuredData =
|
||||
| Record<string, unknown>
|
||||
| Record<string, unknown>[];
|
||||
type StructuredData = Record<string, unknown> | Record<string, unknown>[];
|
||||
|
||||
interface SeoMetaProps {
|
||||
title: string;
|
||||
@@ -15,7 +13,11 @@ interface SeoMetaProps {
|
||||
structuredData?: StructuredData;
|
||||
}
|
||||
|
||||
function upsertMeta(attribute: "name" | "property", key: string, content: string) {
|
||||
function upsertMeta(
|
||||
attribute: "name" | "property",
|
||||
key: string,
|
||||
content: string,
|
||||
) {
|
||||
let meta = document.head.querySelector(
|
||||
`meta[${attribute}="${key}"]`,
|
||||
) as HTMLMetaElement | null;
|
||||
|
||||
@@ -36,8 +36,11 @@ function usagePercent(used: number, limit: number): number {
|
||||
return Math.min(100, Math.round((used / limit) * 100));
|
||||
}
|
||||
|
||||
export default function SubscriptionManager({ checkoutState }: SubscriptionManagerProps) {
|
||||
const [subscription, setSubscription] = useState<BillingSubscriptionResponse | null>(null);
|
||||
export default function SubscriptionManager({
|
||||
checkoutState,
|
||||
}: SubscriptionManagerProps) {
|
||||
const [subscription, setSubscription] =
|
||||
useState<BillingSubscriptionResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [openingPortal, setOpeningPortal] = useState(false);
|
||||
@@ -92,7 +95,11 @@ export default function SubscriptionManager({ checkoutState }: SubscriptionManag
|
||||
}
|
||||
|
||||
async function handleCancelAtPeriodEnd() {
|
||||
if (!window.confirm("Cancel this subscription at the end of the current billing period?")) {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Cancel this subscription at the end of the current billing period?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,11 +136,20 @@ export default function SubscriptionManager({ checkoutState }: SubscriptionManag
|
||||
{currentTier[0].toUpperCase() + currentTier.slice(1)} plan
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Status: <span className="font-medium text-gray-700 dark:text-gray-200">{subscription.status}</span>
|
||||
Status:{" "}
|
||||
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||
{subscription.status}
|
||||
</span>
|
||||
{" · "}
|
||||
Cycle: <span className="font-medium text-gray-700 dark:text-gray-200">{cycle}</span>
|
||||
Cycle:{" "}
|
||||
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||
{cycle}
|
||||
</span>
|
||||
{" · "}
|
||||
Renewal: <span className="font-medium text-gray-700 dark:text-gray-200">{formatRenewalDate(subscription.renewal_date)}</span>
|
||||
Renewal:{" "}
|
||||
<span className="font-medium text-gray-700 dark:text-gray-200">
|
||||
{formatRenewalDate(subscription.renewal_date)}
|
||||
</span>
|
||||
</p>
|
||||
{subscription.cancel_at_period_end && (
|
||||
<p className="mt-2 text-xs font-medium text-amber-600 dark:text-amber-300">
|
||||
@@ -165,7 +181,9 @@ export default function SubscriptionManager({ checkoutState }: SubscriptionManag
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Usage</h4>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Usage
|
||||
</h4>
|
||||
<div className="mt-4 space-y-4">
|
||||
{[
|
||||
{
|
||||
@@ -198,7 +216,9 @@ export default function SubscriptionManager({ checkoutState }: SubscriptionManag
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Change plan</h4>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Change plan
|
||||
</h4>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
{recommendedPlans.map((plan) => {
|
||||
const isCurrent = plan.tier === currentTier;
|
||||
@@ -213,11 +233,17 @@ export default function SubscriptionManager({ checkoutState }: SubscriptionManag
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{plan.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{plan.description}</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{plan.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
${cycle === "monthly" ? plan.monthlyPrice : plan.annualPrice}/{cycle === "monthly" ? "mo" : "yr"}
|
||||
$
|
||||
{cycle === "monthly" ? plan.monthlyPrice : plan.annualPrice}
|
||||
/{cycle === "monthly" ? "mo" : "yr"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -225,7 +251,11 @@ export default function SubscriptionManager({ checkoutState }: SubscriptionManag
|
||||
onClick={() => startCheckout({ tier: plan.tier, cycle })}
|
||||
className="mt-3 w-full rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isCurrent ? "Current plan" : checkoutLoading ? "Redirecting..." : `Switch to ${plan.name}`}
|
||||
{isCurrent
|
||||
? "Current plan"
|
||||
: checkoutLoading
|
||||
? "Redirecting..."
|
||||
: `Switch to ${plan.name}`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,15 +41,18 @@ const features = [
|
||||
|
||||
export default function FeaturesSection() {
|
||||
return (
|
||||
<section id="features" className="bg-gray-50 px-6 py-16 dark:bg-gray-900 sm:px-10 lg:px-14 lg:py-24">
|
||||
<section
|
||||
id="features"
|
||||
className="bg-gray-50 px-6 py-16 dark:bg-gray-900 sm:px-10 lg:px-14 lg:py-24"
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="max-w-3xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl">
|
||||
Built for lean CS teams that still need enterprise-grade signal.
|
||||
</h2>
|
||||
<p className="mt-3 text-gray-600 dark:text-gray-300">
|
||||
Focus effort where it matters most, with proactive visibility instead
|
||||
of reactive firefighting.
|
||||
Focus effort where it matters most, with proactive visibility
|
||||
instead of reactive firefighting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,13 +56,18 @@ export default function FooterSection() {
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[1.1fr_1fr]">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100">PulseScore</h3>
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
PulseScore
|
||||
</h3>
|
||||
<p className="mt-3 max-w-md text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
Customer health scoring for B2B SaaS teams that need to move faster
|
||||
than churn.
|
||||
Customer health scoring for B2B SaaS teams that need to move
|
||||
faster than churn.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-5 flex max-w-md flex-col gap-2 sm:flex-row">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mt-5 flex max-w-md flex-col gap-2 sm:flex-row"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="you@company.com"
|
||||
@@ -101,11 +106,17 @@ export default function FooterSection() {
|
||||
return (
|
||||
<li key={link.label}>
|
||||
{isExternal ? (
|
||||
<a href={link.href} className="hover:text-indigo-600 dark:hover:text-indigo-300">
|
||||
<a
|
||||
href={link.href}
|
||||
className="hover:text-indigo-600 dark:hover:text-indigo-300"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
) : (
|
||||
<Link to={link.href} className="hover:text-indigo-600 dark:hover:text-indigo-300">
|
||||
<Link
|
||||
to={link.href}
|
||||
className="hover:text-indigo-600 dark:hover:text-indigo-300"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
)}
|
||||
@@ -121,13 +132,28 @@ export default function FooterSection() {
|
||||
<div className="mt-12 flex flex-col gap-4 border-t border-gray-200 pt-5 text-sm text-gray-500 dark:border-gray-800 dark:text-gray-400 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p>© {new Date().getFullYear()} PulseScore. All rights reserved.</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="https://x.com" target="_blank" rel="noreferrer" className="hover:text-indigo-600 dark:hover:text-indigo-300">
|
||||
<a
|
||||
href="https://x.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-indigo-600 dark:hover:text-indigo-300"
|
||||
>
|
||||
X
|
||||
</a>
|
||||
<a href="https://github.com" target="_blank" rel="noreferrer" className="hover:text-indigo-600 dark:hover:text-indigo-300">
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-indigo-600 dark:hover:text-indigo-300"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a href="https://www.linkedin.com" target="_blank" rel="noreferrer" className="hover:text-indigo-600 dark:hover:text-indigo-300">
|
||||
<a
|
||||
href="https://www.linkedin.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-indigo-600 dark:hover:text-indigo-300"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -69,26 +69,28 @@ export default function HeroSection() {
|
||||
{
|
||||
name: "Northwind SaaS",
|
||||
score: 89,
|
||||
tone:
|
||||
"from-emerald-400 to-emerald-600 dark:from-emerald-500 dark:to-emerald-400",
|
||||
tone: "from-emerald-400 to-emerald-600 dark:from-emerald-500 dark:to-emerald-400",
|
||||
},
|
||||
{
|
||||
name: "Orbit Analytics",
|
||||
score: 64,
|
||||
tone:
|
||||
"from-amber-400 to-amber-600 dark:from-amber-500 dark:to-amber-400",
|
||||
tone: "from-amber-400 to-amber-600 dark:from-amber-500 dark:to-amber-400",
|
||||
},
|
||||
{
|
||||
name: "Acme Ops",
|
||||
score: 37,
|
||||
tone:
|
||||
"from-rose-400 to-rose-600 dark:from-rose-500 dark:to-rose-400",
|
||||
tone: "from-rose-400 to-rose-600 dark:from-rose-500 dark:to-rose-400",
|
||||
},
|
||||
].map((customer) => (
|
||||
<div key={customer.name} className="rounded-lg bg-white p-3 dark:bg-gray-900">
|
||||
<div
|
||||
key={customer.name}
|
||||
className="rounded-lg bg-white p-3 dark:bg-gray-900"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{customer.name}</span>
|
||||
<span className="font-semibold">{customer.score}/100</span>
|
||||
<span className="font-semibold">
|
||||
{customer.score}/100
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-200 dark:bg-gray-800">
|
||||
<div
|
||||
|
||||
@@ -25,7 +25,9 @@ function normalizeTier(value?: string | null): PlanTier | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function PricingSection({ showStandaloneHeader = false }: PricingSectionProps) {
|
||||
export default function PricingSection({
|
||||
showStandaloneHeader = false,
|
||||
}: PricingSectionProps) {
|
||||
const [cycle, setCycle] = useState<BillingCycle>("monthly");
|
||||
const { isAuthenticated, organization } = useAuth();
|
||||
const { loading, startCheckout } = useCheckout();
|
||||
@@ -61,12 +63,16 @@ export default function PricingSection({ showStandaloneHeader = false }: Pricing
|
||||
}, [isAuthenticated, organization?.plan]);
|
||||
|
||||
const annualSavingsText = useMemo(() => {
|
||||
const growthPlan = billingPlans.find((plan) => plan.tier === "growth") ?? billingPlans[0];
|
||||
const growthPlan =
|
||||
billingPlans.find((plan) => plan.tier === "growth") ?? billingPlans[0];
|
||||
return savingsBadge(growthPlan);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="pricing" className="bg-white px-6 py-16 dark:bg-gray-950 sm:px-10 lg:px-14 lg:py-24">
|
||||
<section
|
||||
id="pricing"
|
||||
className="bg-white px-6 py-16 dark:bg-gray-950 sm:px-10 lg:px-14 lg:py-24"
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{showStandaloneHeader && (
|
||||
<div className="mb-8">
|
||||
@@ -74,7 +80,8 @@ export default function PricingSection({ showStandaloneHeader = false }: Pricing
|
||||
Choose the right PulseScore plan
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-300">
|
||||
Start on Free and upgrade when your customer health workflow scales.
|
||||
Start on Free and upgrade when your customer health workflow
|
||||
scales.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -123,7 +130,8 @@ export default function PricingSection({ showStandaloneHeader = false }: Pricing
|
||||
<div className="mt-10 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{billingPlans.map((plan) => {
|
||||
const isFree = plan.monthlyPrice === 0;
|
||||
const displayPrice = cycle === "monthly" ? plan.monthlyPrice : plan.annualPrice;
|
||||
const displayPrice =
|
||||
cycle === "monthly" ? plan.monthlyPrice : plan.annualPrice;
|
||||
const period = cycle === "monthly" ? "/mo" : "/yr";
|
||||
const isCurrentPlan = currentTier === plan.tier;
|
||||
|
||||
@@ -144,8 +152,8 @@ export default function PricingSection({ showStandaloneHeader = false }: Pricing
|
||||
isCurrentPlan
|
||||
? "border-emerald-400 bg-emerald-50/40 dark:border-emerald-700 dark:bg-emerald-950/10"
|
||||
: plan.featured
|
||||
? "border-indigo-300 bg-indigo-50/50 dark:border-indigo-700 dark:bg-indigo-950/20"
|
||||
: "border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
||||
? "border-indigo-300 bg-indigo-50/50 dark:border-indigo-700 dark:bg-indigo-950/20"
|
||||
: "border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
|
||||
}`}
|
||||
>
|
||||
{isCurrentPlan && (
|
||||
@@ -159,17 +167,25 @@ export default function PricingSection({ showStandaloneHeader = false }: Pricing
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">{plan.name}</h3>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">{plan.description}</p>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
{plan.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex items-baseline gap-1">
|
||||
<span className="text-4xl font-extrabold tracking-tight text-gray-900 dark:text-gray-100">
|
||||
${displayPrice}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{period}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{period}
|
||||
</span>
|
||||
</div>
|
||||
{isFree && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">No credit card required</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
No credit card required
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isAuthenticated && !isFree && !isCurrentPlan ? (
|
||||
@@ -186,7 +202,11 @@ export default function PricingSection({ showStandaloneHeader = false }: Pricing
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={isAuthenticated ? "/dashboard" : `/register?plan=${plan.tier}`}
|
||||
to={
|
||||
isAuthenticated
|
||||
? "/dashboard"
|
||||
: `/register?plan=${plan.tier}`
|
||||
}
|
||||
className={`mt-5 inline-flex w-full items-center justify-center rounded-xl px-4 py-2.5 text-sm font-semibold transition ${
|
||||
plan.featured
|
||||
? "bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
|
||||
@@ -44,7 +44,8 @@ export default function SocialProofSection() {
|
||||
<section className="bg-gray-50 px-6 py-16 dark:bg-gray-900 sm:px-10 lg:px-14 lg:py-24">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<p className="text-xs font-semibold tracking-[0.14em] text-gray-500 uppercase dark:text-gray-400">
|
||||
Placeholder logos & testimonials for MVP (replace with production brand assets)
|
||||
Placeholder logos & testimonials for MVP (replace with production
|
||||
brand assets)
|
||||
</p>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
@@ -64,8 +65,12 @@ export default function SocialProofSection() {
|
||||
key={metric.label}
|
||||
className="rounded-2xl border border-indigo-200 bg-white p-6 text-center dark:border-indigo-900 dark:bg-gray-950"
|
||||
>
|
||||
<p className="text-3xl font-extrabold text-indigo-600 dark:text-indigo-300">{metric.value}</p>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">{metric.label}</p>
|
||||
<p className="text-3xl font-extrabold text-indigo-600 dark:text-indigo-300">
|
||||
{metric.value}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{metric.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -81,8 +86,13 @@ export default function SocialProofSection() {
|
||||
“{testimonial.quote}”
|
||||
</blockquote>
|
||||
<figcaption className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-200">{testimonial.name}</span>
|
||||
<span> · {testimonial.role}, {testimonial.company}</span>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{testimonial.name}
|
||||
</span>
|
||||
<span>
|
||||
{" "}
|
||||
· {testimonial.role}, {testimonial.company}
|
||||
</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
|
||||
@@ -27,9 +27,13 @@ export default function HubSpotConnectStep({
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Status: {statusText}</p>
|
||||
<p className="text-sm font-medium text-gray-800">
|
||||
Status: {statusText}
|
||||
</p>
|
||||
{accountId && (
|
||||
<p className="mt-1 text-xs text-gray-500">Portal ID: {accountId}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Portal ID: {accountId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
|
||||
@@ -27,7 +27,9 @@ export default function IntercomConnectStep({
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Status: {statusText}</p>
|
||||
<p className="text-sm font-medium text-gray-800">
|
||||
Status: {statusText}
|
||||
</p>
|
||||
{accountId && (
|
||||
<p className="mt-1 text-xs text-gray-500">App ID: {accountId}</p>
|
||||
)}
|
||||
|
||||
@@ -33,8 +33,8 @@ export default function ScorePreviewStep({
|
||||
>
|
||||
{connectedProviders.length === 0 ? (
|
||||
<div className="rounded-lg border border-yellow-300 bg-yellow-50 p-4 text-sm text-yellow-800">
|
||||
No integrations connected yet. You can finish onboarding now and connect
|
||||
data sources later from Settings.
|
||||
No integrations connected yet. You can finish onboarding now and
|
||||
connect data sources later from Settings.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -42,9 +42,14 @@ export default function ScorePreviewStep({
|
||||
<h3 className="text-sm font-semibold text-gray-800">Sync status</h3>
|
||||
<ul className="mt-2 space-y-1 text-sm text-gray-700">
|
||||
{connectedProviders.map((provider) => (
|
||||
<li key={provider} className="flex items-center justify-between">
|
||||
<li
|
||||
key={provider}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="capitalize">{provider}</span>
|
||||
<span className="font-medium">{syncStatus[provider] ?? "pending"}</span>
|
||||
<span className="font-medium">
|
||||
{syncStatus[provider] ?? "pending"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -64,7 +69,9 @@ export default function ScorePreviewStep({
|
||||
<div className="mt-3 space-y-2">
|
||||
{distribution.map((bucket) => (
|
||||
<div key={bucket.range} className="flex items-center gap-3">
|
||||
<span className="w-16 text-xs text-gray-500">{bucket.range}</span>
|
||||
<span className="w-16 text-xs text-gray-500">
|
||||
{bucket.range}
|
||||
</span>
|
||||
<div className="h-2 flex-1 rounded bg-gray-100">
|
||||
<div
|
||||
className="h-2 rounded bg-indigo-500"
|
||||
@@ -89,9 +96,14 @@ export default function ScorePreviewStep({
|
||||
</h4>
|
||||
<ul className="mt-2 space-y-1 text-sm text-red-700">
|
||||
{atRiskCustomers.map((customer) => (
|
||||
<li key={customer.id} className="flex items-center justify-between">
|
||||
<li
|
||||
key={customer.id}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{customer.name}</span>
|
||||
<span className="font-semibold">{customer.health_score}</span>
|
||||
<span className="font-semibold">
|
||||
{customer.health_score}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -31,9 +31,13 @@ export default function StripeConnectStep({
|
||||
<div className="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Status: {statusText}</p>
|
||||
<p className="text-sm font-medium text-gray-800">
|
||||
Status: {statusText}
|
||||
</p>
|
||||
{accountId && (
|
||||
<p className="mt-1 text-xs text-gray-500">Account ID: {accountId}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Account ID: {accountId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
|
||||
@@ -22,13 +22,7 @@ const industries = [
|
||||
"Other",
|
||||
];
|
||||
|
||||
const companySizes = [
|
||||
"1-10",
|
||||
"11-50",
|
||||
"51-200",
|
||||
"201-500",
|
||||
"500+",
|
||||
];
|
||||
const companySizes = ["1-10", "11-50", "51-200", "201-500", "500+"];
|
||||
|
||||
export default function WelcomeStep({
|
||||
value,
|
||||
@@ -41,8 +35,8 @@ export default function WelcomeStep({
|
||||
description="Let’s configure your organization so we can personalize scoring and insights."
|
||||
>
|
||||
<div className="mb-5 rounded-lg border border-indigo-200 bg-indigo-50 p-4 text-sm text-indigo-700">
|
||||
You’re about to connect customer data and generate your first health score
|
||||
preview. It usually takes just a few minutes.
|
||||
You’re about to connect customer data and generate your first health
|
||||
score preview. It usually takes just a few minutes.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
|
||||
@@ -16,10 +16,16 @@ interface OnboardingContextValue {
|
||||
status: OnboardingStatus | null;
|
||||
currentStepIndex: number;
|
||||
setCurrentStepIndex: (index: number) => void;
|
||||
hydrateFromStatus: (status: OnboardingStatus, preferredStepIndex?: number) => void;
|
||||
hydrateFromStatus: (
|
||||
status: OnboardingStatus,
|
||||
preferredStepIndex?: number,
|
||||
) => void;
|
||||
markCompleted: (stepId: OnboardingStepId) => void;
|
||||
markSkipped: (stepId: OnboardingStepId) => void;
|
||||
setStepPayload: (stepId: OnboardingStepId, payload: Record<string, unknown>) => void;
|
||||
setStepPayload: (
|
||||
stepId: OnboardingStepId,
|
||||
payload: Record<string, unknown>,
|
||||
) => void;
|
||||
setCompletedAt: (completedAt: string | null) => void;
|
||||
}
|
||||
|
||||
@@ -82,7 +88,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
}
|
||||
|
||||
function setStepPayload(stepId: OnboardingStepId, payload: Record<string, unknown>) {
|
||||
function setStepPayload(
|
||||
stepId: OnboardingStepId,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
setStatus((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
@@ -117,7 +126,9 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>
|
||||
<OnboardingContext.Provider value={value}>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +100,7 @@ export const billingApi = {
|
||||
createPortalSession: () =>
|
||||
api.post<{ url: string }>("/billing/portal-session"),
|
||||
|
||||
cancelAtPeriodEnd: () =>
|
||||
api.post<{ status: string }>("/billing/cancel"),
|
||||
cancelAtPeriodEnd: () => api.post<{ status: string }>("/billing/cancel"),
|
||||
};
|
||||
|
||||
// Alert types and API
|
||||
@@ -160,26 +159,39 @@ export interface UpdateAlertRulePayload {
|
||||
export const alertsApi = {
|
||||
listRules: () => api.get<{ rules: AlertRule[] }>("/alerts/rules"),
|
||||
|
||||
getRule: (id: string) => api.get<{ rule: AlertRule }>(`/alerts/rules/${encodeURIComponent(id)}`),
|
||||
getRule: (id: string) =>
|
||||
api.get<{ rule: AlertRule }>(`/alerts/rules/${encodeURIComponent(id)}`),
|
||||
|
||||
createRule: (data: CreateAlertRulePayload) =>
|
||||
api.post<{ rule: AlertRule }>("/alerts/rules", data),
|
||||
|
||||
updateRule: (id: string, data: UpdateAlertRulePayload) =>
|
||||
api.patch<{ rule: AlertRule }>(`/alerts/rules/${encodeURIComponent(id)}`, data),
|
||||
api.patch<{ rule: AlertRule }>(
|
||||
`/alerts/rules/${encodeURIComponent(id)}`,
|
||||
data,
|
||||
),
|
||||
|
||||
deleteRule: (id: string) =>
|
||||
api.delete(`/alerts/rules/${encodeURIComponent(id)}`),
|
||||
|
||||
listHistory: (params?: { status?: string; limit?: number; offset?: number }) =>
|
||||
api.get<{ history: AlertHistory[]; total: number; limit: number; offset: number }>(
|
||||
"/alerts/history",
|
||||
{ params },
|
||||
),
|
||||
listHistory: (params?: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) =>
|
||||
api.get<{
|
||||
history: AlertHistory[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}>("/alerts/history", { params }),
|
||||
|
||||
getStats: () => api.get<Record<string, number>>("/alerts/stats"),
|
||||
|
||||
listRuleHistory: (ruleId: string, params?: { limit?: number; offset?: number }) =>
|
||||
listRuleHistory: (
|
||||
ruleId: string,
|
||||
params?: { limit?: number; offset?: number },
|
||||
) =>
|
||||
api.get<{ history: AlertHistory[] }>(
|
||||
`/alerts/rules/${encodeURIComponent(ruleId)}/history`,
|
||||
{ params },
|
||||
@@ -228,19 +240,19 @@ export interface AppNotification {
|
||||
|
||||
export const notificationsApi = {
|
||||
list: (params?: { limit?: number; offset?: number }) =>
|
||||
api.get<{ notifications: AppNotification[]; total: number; limit: number; offset: number }>(
|
||||
"/notifications",
|
||||
{ params },
|
||||
),
|
||||
api.get<{
|
||||
notifications: AppNotification[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}>("/notifications", { params }),
|
||||
|
||||
unreadCount: () =>
|
||||
api.get<{ count: number }>("/notifications/unread-count"),
|
||||
unreadCount: () => api.get<{ count: number }>("/notifications/unread-count"),
|
||||
|
||||
markRead: (id: string) =>
|
||||
api.post(`/notifications/${encodeURIComponent(id)}/read`),
|
||||
|
||||
markAllRead: () =>
|
||||
api.post("/notifications/read-all"),
|
||||
markAllRead: () => api.post("/notifications/read-all"),
|
||||
};
|
||||
|
||||
export type OnboardingStepId =
|
||||
|
||||
@@ -51,21 +51,33 @@ export default function LandingPage() {
|
||||
|
||||
<header className="sticky top-0 z-20 border-b border-gray-200/80 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-950/90">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3 sm:px-10 lg:px-14">
|
||||
<Link to="/" className="text-lg font-bold text-indigo-600 dark:text-indigo-300">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-lg font-bold text-indigo-600 dark:text-indigo-300"
|
||||
>
|
||||
PulseScore
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-6 text-sm text-gray-600 md:flex dark:text-gray-300">
|
||||
<a href="#features" className="hover:text-indigo-600 dark:hover:text-indigo-300">
|
||||
<a
|
||||
href="#features"
|
||||
className="hover:text-indigo-600 dark:hover:text-indigo-300"
|
||||
>
|
||||
Features
|
||||
</a>
|
||||
<a href="#pricing" className="hover:text-indigo-600 dark:hover:text-indigo-300">
|
||||
<a
|
||||
href="#pricing"
|
||||
className="hover:text-indigo-600 dark:hover:text-indigo-300"
|
||||
>
|
||||
Pricing
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/login" className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800">
|
||||
<Link
|
||||
to="/login"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
|
||||
@@ -4,7 +4,9 @@ import api, { onboardingApi, type OnboardingStepId } from "@/lib/api";
|
||||
import { stripeApi, type StripeStatus } from "@/lib/stripe";
|
||||
import { hubspotApi, type HubSpotStatus } from "@/lib/hubspot";
|
||||
import { intercomApi, type IntercomStatus } from "@/lib/intercom";
|
||||
import WizardShell, { type WizardShellStep } from "@/components/wizard/WizardShell";
|
||||
import WizardShell, {
|
||||
type WizardShellStep,
|
||||
} from "@/components/wizard/WizardShell";
|
||||
import WelcomeStep, {
|
||||
type WelcomeFormValue,
|
||||
} from "@/components/wizard/steps/WelcomeStep";
|
||||
@@ -15,7 +17,10 @@ import ScorePreviewStep, {
|
||||
type AtRiskCustomerPreview,
|
||||
type ScoreBucket,
|
||||
} from "@/components/wizard/steps/ScorePreviewStep";
|
||||
import { OnboardingProvider, useOnboarding } from "@/contexts/onboarding/OnboardingContext";
|
||||
import {
|
||||
OnboardingProvider,
|
||||
useOnboarding,
|
||||
} from "@/contexts/onboarding/OnboardingContext";
|
||||
import {
|
||||
ONBOARDING_RESUME_STEP_STORAGE_KEY,
|
||||
stepIdToIndex,
|
||||
@@ -77,8 +82,12 @@ function OnboardingContent() {
|
||||
});
|
||||
|
||||
const [stripeStatus, setStripeStatus] = useState<StripeStatus | null>(null);
|
||||
const [hubSpotStatus, setHubSpotStatus] = useState<HubSpotStatus | null>(null);
|
||||
const [intercomStatus, setIntercomStatus] = useState<IntercomStatus | null>(null);
|
||||
const [hubSpotStatus, setHubSpotStatus] = useState<HubSpotStatus | null>(
|
||||
null,
|
||||
);
|
||||
const [intercomStatus, setIntercomStatus] = useState<IntercomStatus | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [stripeBusy, setStripeBusy] = useState(false);
|
||||
const [hubSpotBusy, setHubSpotBusy] = useState(false);
|
||||
@@ -90,7 +99,9 @@ function OnboardingContent() {
|
||||
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [distribution, setDistribution] = useState<ScoreBucket[]>([]);
|
||||
const [atRiskCustomers, setAtRiskCustomers] = useState<AtRiskCustomerPreview[]>([]);
|
||||
const [atRiskCustomers, setAtRiskCustomers] = useState<
|
||||
AtRiskCustomerPreview[]
|
||||
>([]);
|
||||
|
||||
const startedAtRef = useRef<Record<string, number>>({});
|
||||
const startedEventRef = useRef<Set<string>>(new Set());
|
||||
@@ -142,7 +153,8 @@ function OnboardingContent() {
|
||||
}),
|
||||
]);
|
||||
|
||||
const buckets = scoreRes?.buckets ?? scoreRes?.distribution ?? scoreRes ?? [];
|
||||
const buckets =
|
||||
scoreRes?.buckets ?? scoreRes?.distribution ?? scoreRes ?? [];
|
||||
if (Array.isArray(buckets)) {
|
||||
setDistribution(
|
||||
buckets.map((bucket: Record<string, unknown>) => ({
|
||||
@@ -192,7 +204,8 @@ function OnboardingContent() {
|
||||
hydrateFromStatus(data, preferredStepIndex);
|
||||
|
||||
const welcomePayload =
|
||||
data.step_payloads?.welcome && typeof data.step_payloads.welcome === "object"
|
||||
data.step_payloads?.welcome &&
|
||||
typeof data.step_payloads.welcome === "object"
|
||||
? (data.step_payloads.welcome as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
@@ -200,7 +213,7 @@ function OnboardingContent() {
|
||||
name:
|
||||
typeof welcomePayload?.name === "string"
|
||||
? welcomePayload.name
|
||||
: organization?.name ?? "",
|
||||
: (organization?.name ?? ""),
|
||||
industry:
|
||||
typeof welcomePayload?.industry === "string"
|
||||
? welcomePayload.industry
|
||||
@@ -295,13 +308,17 @@ function OnboardingContent() {
|
||||
|
||||
const jobs: Promise<unknown>[] = [];
|
||||
if (isConnected(stripeStatus?.status)) jobs.push(stripeApi.triggerSync());
|
||||
if (isConnected(hubSpotStatus?.status)) jobs.push(hubspotApi.triggerSync());
|
||||
if (isConnected(intercomStatus?.status)) jobs.push(intercomApi.triggerSync());
|
||||
if (isConnected(hubSpotStatus?.status))
|
||||
jobs.push(hubspotApi.triggerSync());
|
||||
if (isConnected(intercomStatus?.status))
|
||||
jobs.push(intercomApi.triggerSync());
|
||||
|
||||
if (jobs.length > 0) {
|
||||
try {
|
||||
await Promise.allSettled(jobs);
|
||||
toast.info("Initial sync started. Preview will update as data arrives.");
|
||||
toast.info(
|
||||
"Initial sync started. Preview will update as data arrives.",
|
||||
);
|
||||
} catch {
|
||||
// Promise.allSettled shouldn't throw, but keep the wizard resilient.
|
||||
}
|
||||
@@ -340,7 +357,9 @@ function OnboardingContent() {
|
||||
payload?: Record<string, unknown>,
|
||||
) {
|
||||
const startedAt = startedAtRef.current[stepId];
|
||||
const duration = startedAt ? Math.max(0, Date.now() - startedAt) : undefined;
|
||||
const duration = startedAt
|
||||
? Math.max(0, Date.now() - startedAt)
|
||||
: undefined;
|
||||
|
||||
await onboardingApi.updateStatus({
|
||||
action,
|
||||
@@ -430,125 +449,125 @@ function OnboardingContent() {
|
||||
}
|
||||
|
||||
const steps: WizardShellStep[] = [
|
||||
{
|
||||
id: "welcome",
|
||||
label: "Welcome",
|
||||
canProceed: welcomeValue.name.trim().length > 0,
|
||||
content: (
|
||||
<WelcomeStep
|
||||
value={welcomeValue}
|
||||
organizationName={organization?.name ?? "Your organization"}
|
||||
onChange={setWelcomeValue}
|
||||
/>
|
||||
),
|
||||
onNext: handleWelcomeComplete,
|
||||
{
|
||||
id: "welcome",
|
||||
label: "Welcome",
|
||||
canProceed: welcomeValue.name.trim().length > 0,
|
||||
content: (
|
||||
<WelcomeStep
|
||||
value={welcomeValue}
|
||||
organizationName={organization?.name ?? "Your organization"}
|
||||
onChange={setWelcomeValue}
|
||||
/>
|
||||
),
|
||||
onNext: handleWelcomeComplete,
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
label: "Stripe",
|
||||
canProceed: isConnected(stripeStatus?.status),
|
||||
canSkip: true,
|
||||
content: (
|
||||
<StripeConnectStep
|
||||
connected={isConnected(stripeStatus?.status)}
|
||||
loading={stripeBusy}
|
||||
statusText={statusText(stripeStatus?.status)}
|
||||
accountId={stripeStatus?.account_id}
|
||||
error={stripeError}
|
||||
onConnect={connectStripe}
|
||||
onRefresh={fetchStripeStatus}
|
||||
/>
|
||||
),
|
||||
onNext: async () => {
|
||||
await recordStep("step_completed", "stripe", "hubspot", {
|
||||
connected: true,
|
||||
status: normalizeStatus(stripeStatus?.status),
|
||||
});
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
label: "Stripe",
|
||||
canProceed: isConnected(stripeStatus?.status),
|
||||
canSkip: true,
|
||||
content: (
|
||||
<StripeConnectStep
|
||||
connected={isConnected(stripeStatus?.status)}
|
||||
loading={stripeBusy}
|
||||
statusText={statusText(stripeStatus?.status)}
|
||||
accountId={stripeStatus?.account_id}
|
||||
error={stripeError}
|
||||
onConnect={connectStripe}
|
||||
onRefresh={fetchStripeStatus}
|
||||
/>
|
||||
),
|
||||
onNext: async () => {
|
||||
await recordStep("step_completed", "stripe", "hubspot", {
|
||||
connected: true,
|
||||
status: normalizeStatus(stripeStatus?.status),
|
||||
});
|
||||
},
|
||||
onSkip: async () => {
|
||||
await recordStep("step_skipped", "stripe", "hubspot", {
|
||||
connected: false,
|
||||
reason: "skipped",
|
||||
});
|
||||
},
|
||||
onSkip: async () => {
|
||||
await recordStep("step_skipped", "stripe", "hubspot", {
|
||||
connected: false,
|
||||
reason: "skipped",
|
||||
});
|
||||
},
|
||||
{
|
||||
id: "hubspot",
|
||||
label: "HubSpot",
|
||||
canProceed: isConnected(hubSpotStatus?.status),
|
||||
canSkip: true,
|
||||
content: (
|
||||
<HubSpotConnectStep
|
||||
connected={isConnected(hubSpotStatus?.status)}
|
||||
loading={hubSpotBusy}
|
||||
statusText={statusText(hubSpotStatus?.status)}
|
||||
accountId={hubSpotStatus?.external_account_id}
|
||||
error={hubSpotError}
|
||||
onConnect={connectHubSpot}
|
||||
onRefresh={fetchHubSpotStatus}
|
||||
/>
|
||||
),
|
||||
onNext: async () => {
|
||||
await recordStep("step_completed", "hubspot", "intercom", {
|
||||
connected: true,
|
||||
status: normalizeStatus(hubSpotStatus?.status),
|
||||
});
|
||||
},
|
||||
onSkip: async () => {
|
||||
await recordStep("step_skipped", "hubspot", "intercom", {
|
||||
connected: false,
|
||||
reason: "skipped",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "hubspot",
|
||||
label: "HubSpot",
|
||||
canProceed: isConnected(hubSpotStatus?.status),
|
||||
canSkip: true,
|
||||
content: (
|
||||
<HubSpotConnectStep
|
||||
connected={isConnected(hubSpotStatus?.status)}
|
||||
loading={hubSpotBusy}
|
||||
statusText={statusText(hubSpotStatus?.status)}
|
||||
accountId={hubSpotStatus?.external_account_id}
|
||||
error={hubSpotError}
|
||||
onConnect={connectHubSpot}
|
||||
onRefresh={fetchHubSpotStatus}
|
||||
/>
|
||||
),
|
||||
onNext: async () => {
|
||||
await recordStep("step_completed", "hubspot", "intercom", {
|
||||
connected: true,
|
||||
status: normalizeStatus(hubSpotStatus?.status),
|
||||
});
|
||||
},
|
||||
{
|
||||
id: "intercom",
|
||||
label: "Intercom",
|
||||
canProceed: isConnected(intercomStatus?.status),
|
||||
canSkip: true,
|
||||
content: (
|
||||
<IntercomConnectStep
|
||||
connected={isConnected(intercomStatus?.status)}
|
||||
loading={intercomBusy}
|
||||
statusText={statusText(intercomStatus?.status)}
|
||||
accountId={intercomStatus?.external_account_id}
|
||||
error={intercomError}
|
||||
onConnect={connectIntercom}
|
||||
onRefresh={fetchIntercomStatus}
|
||||
/>
|
||||
),
|
||||
onNext: async () => {
|
||||
await recordStep("step_completed", "intercom", "preview", {
|
||||
connected: true,
|
||||
status: normalizeStatus(intercomStatus?.status),
|
||||
});
|
||||
},
|
||||
onSkip: async () => {
|
||||
await recordStep("step_skipped", "intercom", "preview", {
|
||||
connected: false,
|
||||
reason: "skipped",
|
||||
});
|
||||
},
|
||||
onSkip: async () => {
|
||||
await recordStep("step_skipped", "hubspot", "intercom", {
|
||||
connected: false,
|
||||
reason: "skipped",
|
||||
});
|
||||
},
|
||||
{
|
||||
id: "preview",
|
||||
label: "Preview",
|
||||
content: (
|
||||
<ScorePreviewStep
|
||||
connectedProviders={connectedProviders}
|
||||
syncStatus={syncStatus}
|
||||
loading={previewLoading}
|
||||
distribution={distribution}
|
||||
atRiskCustomers={atRiskCustomers}
|
||||
/>
|
||||
),
|
||||
onNext: async () => {
|
||||
await recordStep("step_completed", "preview", "preview", {
|
||||
connected_providers: connectedProviders,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "intercom",
|
||||
label: "Intercom",
|
||||
canProceed: isConnected(intercomStatus?.status),
|
||||
canSkip: true,
|
||||
content: (
|
||||
<IntercomConnectStep
|
||||
connected={isConnected(intercomStatus?.status)}
|
||||
loading={intercomBusy}
|
||||
statusText={statusText(intercomStatus?.status)}
|
||||
accountId={intercomStatus?.external_account_id}
|
||||
error={intercomError}
|
||||
onConnect={connectIntercom}
|
||||
onRefresh={fetchIntercomStatus}
|
||||
/>
|
||||
),
|
||||
onNext: async () => {
|
||||
await recordStep("step_completed", "intercom", "preview", {
|
||||
connected: true,
|
||||
status: normalizeStatus(intercomStatus?.status),
|
||||
});
|
||||
},
|
||||
];
|
||||
onSkip: async () => {
|
||||
await recordStep("step_skipped", "intercom", "preview", {
|
||||
connected: false,
|
||||
reason: "skipped",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preview",
|
||||
label: "Preview",
|
||||
content: (
|
||||
<ScorePreviewStep
|
||||
connectedProviders={connectedProviders}
|
||||
syncStatus={syncStatus}
|
||||
loading={previewLoading}
|
||||
distribution={distribution}
|
||||
atRiskCustomers={atRiskCustomers}
|
||||
/>
|
||||
),
|
||||
onNext: async () => {
|
||||
await recordStep("step_completed", "preview", "preview", {
|
||||
connected_providers: connectedProviders,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function handleDone() {
|
||||
try {
|
||||
@@ -572,7 +591,9 @@ function OnboardingContent() {
|
||||
if (initialError || !status) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
||||
<h2 className="text-lg font-semibold text-red-800">Unable to load onboarding</h2>
|
||||
<h2 className="text-lg font-semibold text-red-800">
|
||||
Unable to load onboarding
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-red-700">
|
||||
{initialError || "Something went wrong while loading onboarding."}
|
||||
</p>
|
||||
|
||||
@@ -12,23 +12,25 @@ export default function PrivacyPage() {
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-3xl rounded-2xl border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Privacy Policy</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: February 24, 2026
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-5 text-sm leading-7 text-gray-700 dark:text-gray-300">
|
||||
<p>
|
||||
This is placeholder policy content for MVP use. Replace with legal-reviewed
|
||||
copy before production launch.
|
||||
This is placeholder policy content for MVP use. Replace with
|
||||
legal-reviewed copy before production launch.
|
||||
</p>
|
||||
<p>
|
||||
PulseScore collects account profile information and integration data only
|
||||
for product functionality, analytics, and support.
|
||||
PulseScore collects account profile information and integration data
|
||||
only for product functionality, analytics, and support.
|
||||
</p>
|
||||
<p id="cookies">
|
||||
We use essential cookies for authentication and optional analytics cookies
|
||||
for product improvement.
|
||||
We use essential cookies for authentication and optional analytics
|
||||
cookies for product improvement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,22 +12,26 @@ export default function TermsPage() {
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-3xl rounded-2xl border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Terms of Service</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Terms of Service
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Last updated: February 24, 2026
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-5 text-sm leading-7 text-gray-700 dark:text-gray-300">
|
||||
<p>
|
||||
This is placeholder terms content for MVP use. Replace with legal-reviewed
|
||||
terms before production launch.
|
||||
This is placeholder terms content for MVP use. Replace with
|
||||
legal-reviewed terms before production launch.
|
||||
</p>
|
||||
<p>
|
||||
By using PulseScore, you agree to provide accurate account information,
|
||||
comply with applicable laws, and avoid misuse of integrations.
|
||||
By using PulseScore, you agree to provide accurate account
|
||||
information, comply with applicable laws, and avoid misuse of
|
||||
integrations.
|
||||
</p>
|
||||
<p>
|
||||
Service availability targets and support terms may vary by subscription tier.
|
||||
Service availability targets and support terms may vary by
|
||||
subscription tier.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -207,10 +207,7 @@ export default function RegisterPage() {
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
<Link to="/login" className="text-indigo-600 hover:text-indigo-500">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { alertsApi, type AlertRule, type AlertHistory, type CreateAlertRulePayload } from "@/lib/api";
|
||||
import {
|
||||
alertsApi,
|
||||
type AlertRule,
|
||||
type AlertHistory,
|
||||
type CreateAlertRulePayload,
|
||||
} from "@/lib/api";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import { Bell, Loader2, Plus, Trash2, Power, PowerOff, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import {
|
||||
Bell,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
Power,
|
||||
PowerOff,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
|
||||
const TRIGGER_TYPES = [
|
||||
{ value: "score_below", label: "Score Below Threshold" },
|
||||
@@ -18,7 +32,8 @@ function statusBadge(status: string) {
|
||||
const colors: Record<string, string> = {
|
||||
sent: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
failed: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
|
||||
pending: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
pending:
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
@@ -40,8 +55,12 @@ interface RuleFormProps {
|
||||
function RuleForm({ onSave, onCancel, initial, saving }: RuleFormProps) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [triggerType, setTriggerType] = useState(initial?.trigger_type ?? "score_below");
|
||||
const [recipients, setRecipients] = useState(initial?.recipients?.join(", ") ?? "");
|
||||
const [triggerType, setTriggerType] = useState(
|
||||
initial?.trigger_type ?? "score_below",
|
||||
);
|
||||
const [recipients, setRecipients] = useState(
|
||||
initial?.recipients?.join(", ") ?? "",
|
||||
);
|
||||
const [threshold, setThreshold] = useState(
|
||||
String((initial?.conditions?.threshold as number) ?? 40),
|
||||
);
|
||||
@@ -87,20 +106,38 @@ function RuleForm({ onSave, onCancel, initial, saving }: RuleFormProps) {
|
||||
const labelCls = "block text-sm font-medium text-gray-700 dark:text-gray-300";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div>
|
||||
<label className={labelCls}>Name</label>
|
||||
<input className={inputCls} required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input
|
||||
className={inputCls}
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Description</label>
|
||||
<input className={inputCls} value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
<input
|
||||
className={inputCls}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Trigger Type</label>
|
||||
<select className={inputCls} value={triggerType} onChange={(e) => setTriggerType(e.target.value)}>
|
||||
<select
|
||||
className={inputCls}
|
||||
value={triggerType}
|
||||
onChange={(e) => setTriggerType(e.target.value)}
|
||||
>
|
||||
{TRIGGER_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -108,8 +145,18 @@ function RuleForm({ onSave, onCancel, initial, saving }: RuleFormProps) {
|
||||
{triggerType === "score_below" && (
|
||||
<div>
|
||||
<label className={labelCls}>Score Threshold</label>
|
||||
<input className={inputCls} type="number" min={1} max={100} required value={threshold} onChange={(e) => setThreshold(e.target.value)} />
|
||||
<p className="mt-1 text-xs text-gray-500">Alert when score drops below this value</p>
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
required
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Alert when score drops below this value
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -117,18 +164,38 @@ function RuleForm({ onSave, onCancel, initial, saving }: RuleFormProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>Points Drop</label>
|
||||
<input className={inputCls} type="number" min={1} required value={points} onChange={(e) => setPoints(e.target.value)} />
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
min={1}
|
||||
required
|
||||
value={points}
|
||||
onChange={(e) => setPoints(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Within Days</label>
|
||||
<input className={inputCls} type="number" min={1} required value={days} onChange={(e) => setDays(e.target.value)} />
|
||||
<input
|
||||
className={inputCls}
|
||||
type="number"
|
||||
min={1}
|
||||
required
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>Recipients (comma-separated emails)</label>
|
||||
<input className={inputCls} required value={recipients} onChange={(e) => setRecipients(e.target.value)} placeholder="admin@example.com, csm@example.com" />
|
||||
<input
|
||||
className={inputCls}
|
||||
required
|
||||
value={recipients}
|
||||
onChange={(e) => setRecipients(e.target.value)}
|
||||
placeholder="admin@example.com, csm@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
@@ -139,7 +206,11 @@ function RuleForm({ onSave, onCancel, initial, saving }: RuleFormProps) {
|
||||
>
|
||||
{saving ? "Saving…" : initial ? "Update Rule" : "Create Rule"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -247,9 +318,16 @@ export default function AlertsTab() {
|
||||
{ label: "Failed", value: stats.failed ?? 0 },
|
||||
{ label: "Pending", value: stats.pending ?? 0 },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">{s.label}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">{s.value}</p>
|
||||
<div
|
||||
key={s.label}
|
||||
className="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{s.label}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{s.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -257,7 +335,9 @@ export default function AlertsTab() {
|
||||
{/* Alert Rules */}
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Alert Rules</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Alert Rules
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
||||
@@ -266,40 +346,80 @@ export default function AlertsTab() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && <RuleForm onSave={handleCreate} onCancel={() => setShowForm(false)} saving={saving} />}
|
||||
{showForm && (
|
||||
<RuleForm
|
||||
onSave={handleCreate}
|
||||
onCancel={() => setShowForm(false)}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rules.length === 0 && !showForm ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 py-12 dark:border-gray-600">
|
||||
<Bell className="h-8 w-8 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">No alert rules yet</p>
|
||||
<button onClick={() => setShowForm(true)} className="mt-3 text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400">
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
No alert rules yet
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="mt-3 text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400"
|
||||
>
|
||||
Create your first rule
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<div
|
||||
key={rule.id}
|
||||
className="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100">{rule.name}</h4>
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${rule.is_active ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" : "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"}`}>
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{rule.name}
|
||||
</h4>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${rule.is_active ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" : "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"}`}
|
||||
>
|
||||
{rule.is_active ? "Active" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{triggerLabel(rule.trigger_type)} · {rule.recipients.length} recipient{rule.recipients.length !== 1 ? "s" : ""}
|
||||
{triggerLabel(rule.trigger_type)} ·{" "}
|
||||
{rule.recipients.length} recipient
|
||||
{rule.recipients.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => toggleRuleHistory(rule.id)} title="View history" className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-200">
|
||||
{expandedRule === rule.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
<button
|
||||
onClick={() => toggleRuleHistory(rule.id)}
|
||||
title="View history"
|
||||
className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
{expandedRule === rule.id ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => toggleActive(rule)} title={rule.is_active ? "Disable" : "Enable"} className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-200">
|
||||
{rule.is_active ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}
|
||||
<button
|
||||
onClick={() => toggleActive(rule)}
|
||||
title={rule.is_active ? "Disable" : "Enable"}
|
||||
className="rounded p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
{rule.is_active ? (
|
||||
<PowerOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Power className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => deleteRule(rule.id)} title="Delete" className="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400">
|
||||
<button
|
||||
onClick={() => deleteRule(rule.id)}
|
||||
title="Delete"
|
||||
className="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -307,11 +427,16 @@ export default function AlertsTab() {
|
||||
{expandedRule === rule.id && (
|
||||
<div className="border-t border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
{ruleHistory.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No alerts sent for this rule yet.</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
No alerts sent for this rule yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{ruleHistory.map((h) => (
|
||||
<div key={h.id} className="flex items-center justify-between text-sm">
|
||||
<div
|
||||
key={h.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{new Date(h.created_at).toLocaleString()}
|
||||
</span>
|
||||
@@ -330,9 +455,13 @@ export default function AlertsTab() {
|
||||
|
||||
{/* Recent Alert History */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">Recent Alerts</h3>
|
||||
<h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Recent Alerts
|
||||
</h3>
|
||||
{history.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">No alerts sent yet.</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
No alerts sent yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full text-left text-sm">
|
||||
@@ -345,11 +474,16 @@ export default function AlertsTab() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((h) => (
|
||||
<tr key={h.id} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<tr
|
||||
key={h.id}
|
||||
className="border-b border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||
{new Date(h.created_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{h.channel}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
{h.channel}
|
||||
</td>
|
||||
<td className="px-4 py-3">{statusBadge(h.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -31,7 +31,9 @@ export default function HubSpotCallbackPage() {
|
||||
hubspotApi
|
||||
.callback(code, state)
|
||||
.then(() => {
|
||||
const resumeStep = localStorage.getItem(ONBOARDING_RESUME_STEP_STORAGE_KEY);
|
||||
const resumeStep = localStorage.getItem(
|
||||
ONBOARDING_RESUME_STEP_STORAGE_KEY,
|
||||
);
|
||||
if (resumeStep) {
|
||||
localStorage.removeItem(ONBOARDING_RESUME_STEP_STORAGE_KEY);
|
||||
navigate(`/onboarding?step=${resumeStep}`, { replace: true });
|
||||
|
||||
@@ -31,7 +31,9 @@ export default function IntercomCallbackPage() {
|
||||
intercomApi
|
||||
.callback(code, state)
|
||||
.then(() => {
|
||||
const resumeStep = localStorage.getItem(ONBOARDING_RESUME_STEP_STORAGE_KEY);
|
||||
const resumeStep = localStorage.getItem(
|
||||
ONBOARDING_RESUME_STEP_STORAGE_KEY,
|
||||
);
|
||||
if (resumeStep) {
|
||||
localStorage.removeItem(ONBOARDING_RESUME_STEP_STORAGE_KEY);
|
||||
navigate(`/onboarding?step=${resumeStep}`, { replace: true });
|
||||
|
||||
@@ -33,7 +33,12 @@ export default function NotificationsTab() {
|
||||
load();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function toggleField(field: keyof Pick<NotificationPreference, "email_enabled" | "in_app_enabled" | "digest_enabled">) {
|
||||
async function toggleField(
|
||||
field: keyof Pick<
|
||||
NotificationPreference,
|
||||
"email_enabled" | "in_app_enabled" | "digest_enabled"
|
||||
>,
|
||||
) {
|
||||
if (!prefs) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -76,9 +81,7 @@ export default function NotificationsTab() {
|
||||
muted_rule_ids: newMuted,
|
||||
});
|
||||
setPrefs(data);
|
||||
toast.success(
|
||||
muted.includes(ruleId) ? "Rule unmuted" : "Rule muted"
|
||||
);
|
||||
toast.success(muted.includes(ruleId) ? "Rule unmuted" : "Rule muted");
|
||||
} catch {
|
||||
toast.error("Failed to update preferences");
|
||||
} finally {
|
||||
|
||||
@@ -85,9 +85,7 @@ export default function OrganizationTab() {
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-indigo-200 bg-indigo-50 p-4">
|
||||
<h3 className="text-sm font-semibold text-indigo-800">
|
||||
Onboarding
|
||||
</h3>
|
||||
<h3 className="text-sm font-semibold text-indigo-800">Onboarding</h3>
|
||||
<p className="mt-1 text-sm text-indigo-700">
|
||||
Need to revisit setup? You can restart the onboarding wizard anytime.
|
||||
</p>
|
||||
|
||||
@@ -31,7 +31,9 @@ export default function StripeCallbackPage() {
|
||||
stripeApi
|
||||
.callback(code, state)
|
||||
.then(() => {
|
||||
const resumeStep = localStorage.getItem(ONBOARDING_RESUME_STEP_STORAGE_KEY);
|
||||
const resumeStep = localStorage.getItem(
|
||||
ONBOARDING_RESUME_STEP_STORAGE_KEY,
|
||||
);
|
||||
if (resumeStep) {
|
||||
localStorage.removeItem(ONBOARDING_RESUME_STEP_STORAGE_KEY);
|
||||
navigate(`/onboarding?step=${resumeStep}`, { replace: true });
|
||||
|
||||
Reference in New Issue
Block a user