refactor: consolidate integration handlers and clean up UI flows

This commit is contained in:
2026-02-24 17:38:31 -06:00
parent 34ff6e0a46
commit 7c3f2fb803
45 changed files with 2131 additions and 1455 deletions

115
clean-code-review.json Normal file
View 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
View 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.

View File

@@ -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)

View File

@@ -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, &notFoundErr):
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) {

View File

@@ -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 {

View 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, &notFoundErr):
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"))
}
}

View 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})
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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"`
}

View 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
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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 != "" {

View File

@@ -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

View File

@@ -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 {

View 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.

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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}</>;

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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"

View File

@@ -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>
))}

View File

@@ -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

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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

View File

@@ -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="Lets 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">
Youre about to connect customer data and generate your first health score
preview. It usually takes just a few minutes.
Youre 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">

View File

@@ -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>
);
}

View File

@@ -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 =

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
))}

View File

@@ -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 });

View File

@@ -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 });

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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 });