From 78decdb0f5fb8103b1532179b2afdfb0f599cbf6 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:52:57 +0200 Subject: [PATCH 01/60] feat: document dashboard interaction rules --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1b70a8ae..d67a1570 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,8 @@ Everything in the dashboard must use our own theme, not browser/library defaults - Theme tokens: slate borders (`border-slate-200`), sky accents (`focus:border-sky-400 focus:ring-sky-100`, `bg-sky-50 text-sky-700`), `rounded-md`, `text-[12.5px]` base, `h-7` controls, `10px uppercase tracking-[0.14em]` section labels. - Multi-select tables: when rows are selected, show a floating bottom-center selection bar with the count + bulk actions (mirror `SelectionBar` in contacts `ContactsTable.tsx`). - Row actions must be reachable on touch: never hide the only affordance behind `opacity-0 group-hover` with no mobile fallback. Use `opacity-100 md:opacity-0 md:group-hover:opacity-100`, or surface actions in the detail drawer. +- Confirmations: never use the native `window.confirm` / `alert` / `prompt`. Use the in-app confirm: `const confirm = useConfirm()` (from `@/hooks/context/confirm`), then `confirm.show(text, onSubmit)`. `onSubmit` is awaited and the provider renders its own loading spinner, so pass an `async` callback (prefer `mutateAsync` over callback-style `mutate`). For the synchronous `if (!window.confirm(x)) return; act()` pattern, restructure to `confirm.show(x, act)`; for close-while-dirty guards, route every close path (Escape handler, backdrop `onMouseDown`, close button) through one `requestClose()` that calls `confirm.show(...)` when dirty. ConfirmProvider is mounted in `app/app/layout.tsx`, so `useConfirm()` works anywhere under `/app`. +- Row interactions: list rows behave like the campaigns list — clicking anywhere on a row opens that item's detail (drawer or page); right-side action buttons (3-dots / "More") open a relevant detail/tab (e.g. the mailbox 3-dots opens the Settings tab of `InboxDetails`). Inner interactive controls (checkbox, dropdown trigger, action buttons) must `e.stopPropagation()` so they don't also fire the row's open handler. - Prefer realtime over polling: subscribe to the socket and `queryClient.invalidateQueries(...)` on the relevant event instead of `refetchInterval` where an event exists (see `useRealtimeEvents` / `RealtimeManager`). ## System Shape From e1e02c5c18dde246647ee16e9db2dbe864cddec3 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:53:23 +0200 Subject: [PATCH 02/60] feat: add campaign send controls backend --- cmd/backend/main.go | 2 +- internal/api/handler/campaign.go | 71 +++ internal/api/routes.go | 7 + internal/app/campaign/handlers.go | 117 +++- internal/app/campaign/service.go | 8 + internal/config/constants.go | 10 + .../000013_campaign_send_controls.down.sql | 23 + .../000013_campaign_send_controls.up.sql | 71 +++ internal/models/campaign.go | 80 ++- internal/models/contact.go | 6 + internal/repository/pg_campaign.go | 588 +++++++++++++++++- internal/repository/pg_campaign_progress.go | 33 +- internal/repository/pg_contact.go | 21 +- internal/repository/pg_email.go | 73 +++ internal/scheduler/campaign_scheduler.go | 256 +++++++- internal/scheduler/errors.go | 9 + internal/scheduler/helpers.go | 142 ++++- internal/scheduler/service.go | 10 + internal/tasks/campaign_task.go | 63 +- internal/utils/validate/campaign.go | 95 +++ 20 files changed, 1606 insertions(+), 79 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000013_campaign_send_controls.down.sql create mode 100644 internal/infrastructure/db/migrations/000013_campaign_send_controls.up.sql diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 400de6dc..4f8ae9b6 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -785,7 +785,7 @@ func main() { // Template & email send services templateService = template.NewService(templateRepository) - schedulerService := scheduler.NewSchedulerService(taskRepository, warmupRepository, campaignProgressRepository, emailRepostory, campaignRepostory) + schedulerService := scheduler.NewSchedulerService(taskRepository, warmupRepository, campaignProgressRepository, emailRepostory, campaignRepostory, contactRepostory, campaignLogRepository) campaignService = campaign.NewService(campaignRepostory, taskRepository, emailRepostory, campaignLogRepository, featureGateService, dailyThrottleService, schedulerService, tasksClient, streamingPublisher) emailSendService = emailsend.NewService(taskRepository, emailRepostory, userRepostory, schedulerService, tasksClient, featureGateService, dailyThrottleService) // uniboxService is constructed here (rather than alongside the diff --git a/internal/api/handler/campaign.go b/internal/api/handler/campaign.go index c8b2cbb1..9e3cb41a 100644 --- a/internal/api/handler/campaign.go +++ b/internal/api/handler/campaign.go @@ -182,3 +182,74 @@ func (h *Handler) GetCampaignLogs(c *gin.Context) { c.JSON(http.StatusOK, result) } + +// ListCampaignSenders returns a campaign's explicit sender pool. +// GET /campaigns/:id/senders +func (h *Handler) ListCampaignSenders(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + senders, xerr := h.CampaignService.ListCampaignSenders(c.Request.Context(), *orgID, c.Param("id")) + if xerr != nil { + errx.JSON(c, xerr) + return + } + + c.JSON(http.StatusOK, gin.H{"data": senders}) +} + +// ReplaceCampaignSenders atomically replaces a campaign's explicit sender pool. +// PUT /campaigns/:id/senders +func (h *Handler) ReplaceCampaignSenders(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + var body struct { + Senders []models.CampaignSenderInput `json:"senders"` + } + if err := c.ShouldBindJSON(&body); err != nil { + errx.JSON(c, errx.ErrInvalid) + return + } + + senders, xerr := h.CampaignService.ReplaceCampaignSenders(c.Request.Context(), *orgID, c.Param("id"), body.Senders) + if xerr != nil { + errx.JSON(c, xerr) + return + } + + if campaignID, err := uuid.Parse(c.Param("id")); err == nil { + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityCampaign, &campaignID, nil, map[string]string{"scope": "senders"}) + } + + c.JSON(http.StatusOK, gin.H{"data": senders}) +} + +// VerifyCampaignTrackingDomain resolves the campaign-scoped tracking domain's +// CNAME and flips verified on success. +// POST /campaigns/:id/tracking-domain/verify +func (h *Handler) VerifyCampaignTrackingDomain(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + status, xerr := h.CampaignService.VerifyCampaignTrackingDomain(c.Request.Context(), *orgID, c.Param("id")) + if xerr != nil { + errx.JSON(c, xerr) + return + } + + if campaignID, err := uuid.Parse(c.Param("id")); err == nil { + h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityCampaign, &campaignID, nil, map[string]string{"scope": "tracking_domain_verify"}) + } + + c.JSON(http.StatusOK, status) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 81741925..c378d1b8 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -265,6 +265,13 @@ func Run( campaigns.POST("/:id/stop", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.StopCampaign) campaigns.GET("/:id/logs", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetCampaignLogs) + // Explicit sender pool (rotation/weighting). + campaigns.GET("/:id/senders", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignSenders) + campaigns.PUT("/:id/senders", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.ReplaceCampaignSenders) + + // Campaign-scoped tracking-domain verification. + campaigns.POST("/:id/tracking-domain/verify", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.VerifyCampaignTrackingDomain) + sequences := campaigns.Group("/:id/sequences") { sequences.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetSequences) diff --git a/internal/app/campaign/handlers.go b/internal/app/campaign/handlers.go index 809204d7..06af7962 100644 --- a/internal/app/campaign/handlers.go +++ b/internal/app/campaign/handlers.go @@ -3,6 +3,8 @@ package campaign import ( "context" "errors" + "net" + "strings" "time" "github.com/getsentry/sentry-go" @@ -159,13 +161,30 @@ func (s *campaignService) StartCampaign(ctx context.Context, orgID uuid.UUID, ca return errx.InternalError() } - // Validate active email accounts exist for the campaign's email tags - accounts, xerr := s.emailRepo.GetByTags(ctx, campaign.UserID, campaign.EmailTags) - if xerr != nil { - return xerr - } - if len(accounts) == 0 { - return errx.New(errx.BadRequest, "no active email accounts found for campaign's email tags") + // Validate active sending mailboxes exist. Explicit-strategy campaigns + // resolve through the campaign_senders pool; tag-strategy through tags. + if campaign.SenderStrategy == "explicit" { + senders, serr := s.campaignRepository.GetCampaignSenders(ctx, cID) + if serr != nil { + return errx.InternalError() + } + enabled := 0 + for _, snd := range senders { + if snd.Enabled { + enabled++ + } + } + if enabled == 0 { + return errx.New(errx.BadRequest, "no enabled senders configured for this campaign") + } + } else { + accounts, xerr := s.emailRepo.GetByTags(ctx, campaign.UserID, campaign.EmailTags) + if xerr != nil { + return xerr + } + if len(accounts) == 0 { + return errx.New(errx.BadRequest, "no active email accounts found for campaign's email tags") + } } activeCampaigns, err := s.campaignRepository.CountActiveForOrganization(ctx, orgID) @@ -216,7 +235,10 @@ func (s *campaignService) enqueueCampaignWakeup(ctx context.Context, campaignID } nextTime, _, accountID, err := s.scheduler.CalculateNextCampaignTime(ctx, campaignID) - if err != nil { + // A deferral still yields a usable first-send slot (nextTime) and a nominal + // pool mailbox (accountID), so fall through and schedule the first wakeup at + // the defer time rather than failing the campaign start. + if err != nil && !errors.Is(err, scheduler.ErrCampaignDeferred) { switch { case errors.Is(err, scheduler.ErrNoEmailAccounts): _ = s.campaignRepository.UpdateStatusWithLock(ctx, campaignID, "paused_no_accounts") @@ -366,3 +388,82 @@ func (s *campaignService) GetLogs(ctx context.Context, userID, campaignID string return result, nil } + +// campaignForOrg loads a campaign and verifies it belongs to the given org. +func (s *campaignService) campaignForOrg(ctx context.Context, orgID uuid.UUID, campaignID string) (*models.Campaign, uuid.UUID, *errx.Error) { + cID, parseErr := uuid.Parse(campaignID) + if parseErr != nil { + return nil, uuid.Nil, errx.ErrUuid + } + campaign, err := s.campaignRepository.GetByID(ctx, cID) + if err != nil { + if errors.Is(err, errx.ErrResourceNotFound) { + return nil, uuid.Nil, errx.ErrNotFound + } + return nil, uuid.Nil, errx.InternalError() + } + if campaign == nil || campaign.OrganizationID == nil || *campaign.OrganizationID != orgID { + return nil, uuid.Nil, errx.ErrNotFound + } + return campaign, cID, nil +} + +// ListCampaignSenders returns the campaign's explicit sender pool. +func (s *campaignService) ListCampaignSenders(ctx context.Context, orgID uuid.UUID, campaignID string) ([]models.CampaignSender, *errx.Error) { + _, cID, xerr := s.campaignForOrg(ctx, orgID, campaignID) + if xerr != nil { + return nil, xerr + } + senders, err := s.campaignRepository.GetCampaignSenders(ctx, cID) + if err != nil { + return nil, errx.InternalError() + } + return senders, nil +} + +// ReplaceCampaignSenders atomically replaces the explicit sender pool. +func (s *campaignService) ReplaceCampaignSenders(ctx context.Context, orgID uuid.UUID, campaignID string, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) { + _, cID, xerr := s.campaignForOrg(ctx, orgID, campaignID) + if xerr != nil { + return nil, xerr + } + return s.campaignRepository.ReplaceCampaignSenders(ctx, cID, in) +} + +// trackingDomainTarget is the shared host customers point their CNAME at. Kept +// in sync with the mailbox tracking-domain resolver and the TRACKING_DOMAIN +// default. +const trackingDomainTarget = "t.warmbly.com" + +// VerifyCampaignTrackingDomain resolves the campaign-scoped tracking domain's +// CNAME and flips verified on success. Only a verified override is honored at +// send time, so an unresolved record stays "pending" rather than erroring. +func (s *campaignService) VerifyCampaignTrackingDomain(ctx context.Context, orgID uuid.UUID, campaignID string) (*models.TrackingDomainStatus, *errx.Error) { + campaign, cID, xerr := s.campaignForOrg(ctx, orgID, campaignID) + if xerr != nil { + return nil, xerr + } + + status := &models.TrackingDomainStatus{TrackingDomain: campaign.TrackingDomain} + if campaign.TrackingDomain == "" { + // No override configured — nothing to verify; ensure verified is cleared. + if err := s.campaignRepository.SetCampaignTrackingDomainVerified(ctx, cID, false, nil); err != nil { + return nil, errx.InternalError() + } + return status, nil + } + + if cname, err := net.DefaultResolver.LookupCNAME(ctx, campaign.TrackingDomain); err == nil { + resolved := strings.TrimSuffix(strings.ToLower(cname), ".") + if strings.Contains(resolved, trackingDomainTarget) { + now := time.Now().UTC() + status.TrackingDomainVerified = true + status.TrackingDomainVerifiedAt = &now + } + } + + if err := s.campaignRepository.SetCampaignTrackingDomainVerified(ctx, cID, status.TrackingDomainVerified, status.TrackingDomainVerifiedAt); err != nil { + return nil, errx.InternalError() + } + return status, nil +} diff --git a/internal/app/campaign/service.go b/internal/app/campaign/service.go index 2f49225e..1f440f20 100644 --- a/internal/app/campaign/service.go +++ b/internal/app/campaign/service.go @@ -29,6 +29,14 @@ type CampaignService interface { // Logs GetLogs(ctx context.Context, userID, campaignID string, limit int, cursor *string) (*models.CampaignLogsResult, *errx.Error) + + // Explicit sender pool (feature 1). + ListCampaignSenders(ctx context.Context, orgID uuid.UUID, campaignID string) ([]models.CampaignSender, *errx.Error) + ReplaceCampaignSenders(ctx context.Context, orgID uuid.UUID, campaignID string, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) + + // Campaign-scoped tracking domain (feature 5). Resolves the override's CNAME + // and flips verified on success; an unresolved record stays "pending". + VerifyCampaignTrackingDomain(ctx context.Context, orgID uuid.UUID, campaignID string) (*models.TrackingDomainStatus, *errx.Error) } type campaignService struct { diff --git a/internal/config/constants.go b/internal/config/constants.go index 54e1a175..86d021a1 100644 --- a/internal/config/constants.go +++ b/internal/config/constants.go @@ -12,6 +12,16 @@ const ( WarmupMaxDefault = 40 WarmupIncreaseDefault = 1 + // Net-new campaign send controls. The ramp mirrors the warmup ramp shape + // (start, +increment/day, ceiling) but is applied only via min() against + // the per-mailbox cold cap, so it can only lower effective volume. + CampaignSenderWeightDefault = 1 + CampaignSenderWeightMax = 100 + CampaignRampStartDefault = 10 + CampaignRampIncrementDefault = 5 + CampaignRampCeilingDefault = 50 + CampaignMaxNewLeadsMax = 1000 + MaxContactSize = 10240 MaxEmailBodySize = 200 * 1024 // 200 KB MaxEmailFolders = 30 diff --git a/internal/infrastructure/db/migrations/000013_campaign_send_controls.down.sql b/internal/infrastructure/db/migrations/000013_campaign_send_controls.down.sql new file mode 100644 index 00000000..1a8fcd97 --- /dev/null +++ b/internal/infrastructure/db/migrations/000013_campaign_send_controls.down.sql @@ -0,0 +1,23 @@ +-- Reverse FK order: drop the child tables (which reference campaigns) before +-- dropping the campaign columns, then the independent contact columns. + +DROP INDEX IF EXISTS idx_campaign_senders_campaign; +DROP TABLE IF EXISTS campaign_daily_sends; +DROP TABLE IF EXISTS campaign_senders; + +ALTER TABLE contacts DROP COLUMN IF EXISTS esp_resolved_at; +ALTER TABLE contacts DROP COLUMN IF EXISTS esp_provider; + +ALTER TABLE campaigns DROP COLUMN IF EXISTS tracking_domain_verified_at; +ALTER TABLE campaigns DROP COLUMN IF EXISTS tracking_domain_verified; +ALTER TABLE campaigns DROP COLUMN IF EXISTS tracking_domain; +ALTER TABLE campaigns DROP COLUMN IF EXISTS prioritize_new_leads; +ALTER TABLE campaigns DROP COLUMN IF EXISTS max_new_leads_per_day; +ALTER TABLE campaigns DROP COLUMN IF EXISTS esp_match_mode; +ALTER TABLE campaigns DROP COLUMN IF EXISTS ramp_level_date; +ALTER TABLE campaigns DROP COLUMN IF EXISTS ramp_level; +ALTER TABLE campaigns DROP COLUMN IF EXISTS ramp_ceiling; +ALTER TABLE campaigns DROP COLUMN IF EXISTS ramp_increment; +ALTER TABLE campaigns DROP COLUMN IF EXISTS ramp_start; +ALTER TABLE campaigns DROP COLUMN IF EXISTS rotation_mode; +ALTER TABLE campaigns DROP COLUMN IF EXISTS sender_strategy; diff --git a/internal/infrastructure/db/migrations/000013_campaign_send_controls.up.sql b/internal/infrastructure/db/migrations/000013_campaign_send_controls.up.sql new file mode 100644 index 00000000..1552cc7e --- /dev/null +++ b/internal/infrastructure/db/migrations/000013_campaign_send_controls.up.sql @@ -0,0 +1,71 @@ +-- Net-new campaign send controls: sender rotation/weighting, per-campaign daily +-- ramp-up, ESP/provider matching, new-lead cap + priority, and a campaign-scoped +-- tracking-domain override. +-- +-- Every change is ADDITIVE and the defaults reproduce today's behavior exactly: +-- tag-based sender selection, flat daily_limit (ramp off), no ESP matching, +-- unlimited new leads, and the mailbox/default tracking domain. Existing +-- campaigns are therefore unaffected until a control is explicitly turned on. + +-- 1. Sending-account rotation / weighting -------------------------------- +ALTER TABLE campaigns ADD COLUMN sender_strategy text NOT NULL DEFAULT 'tags' + CHECK (sender_strategy IN ('tags', 'explicit')); +ALTER TABLE campaigns ADD COLUMN rotation_mode text NOT NULL DEFAULT 'weighted' + CHECK (rotation_mode IN ('weighted', 'round_robin', 'least_recently_used')); + +-- 2. Per-campaign daily ramp-up ------------------------------------------ +ALTER TABLE campaigns ADD COLUMN ramp_enabled boolean NOT NULL DEFAULT false; +ALTER TABLE campaigns ADD COLUMN ramp_start integer NOT NULL DEFAULT 10 + CHECK (ramp_start >= 1 AND ramp_start <= 100); +ALTER TABLE campaigns ADD COLUMN ramp_increment integer NOT NULL DEFAULT 5 + CHECK (ramp_increment >= 0 AND ramp_increment <= 100); +ALTER TABLE campaigns ADD COLUMN ramp_ceiling integer NOT NULL DEFAULT 50 + CHECK (ramp_ceiling >= 1 AND ramp_ceiling <= 100); +-- ramp_level is the persisted per-mailbox level (0 = not started); pause/resume +-- keeps progress. ramp_level_date day-gates the once-per-UTC-day advance. +ALTER TABLE campaigns ADD COLUMN ramp_level integer NOT NULL DEFAULT 0; +ALTER TABLE campaigns ADD COLUMN ramp_level_date date; + +-- 3. ESP / provider matching --------------------------------------------- +ALTER TABLE campaigns ADD COLUMN esp_match_mode text NOT NULL DEFAULT 'off' + CHECK (esp_match_mode IN ('off', 'prefer', 'strict')); + +-- 4. New-lead cap + priority --------------------------------------------- +ALTER TABLE campaigns ADD COLUMN max_new_leads_per_day integer NOT NULL DEFAULT 0 + CHECK (max_new_leads_per_day >= 0 AND max_new_leads_per_day <= 1000); -- 0 = unlimited +ALTER TABLE campaigns ADD COLUMN prioritize_new_leads boolean NOT NULL DEFAULT false; + +-- 5. Campaign-scoped tracking domain (honored only when verified) --------- +ALTER TABLE campaigns ADD COLUMN tracking_domain text NOT NULL DEFAULT ''; -- '' = mailbox/default fallback +ALTER TABLE campaigns ADD COLUMN tracking_domain_verified boolean NOT NULL DEFAULT false; +ALTER TABLE campaigns ADD COLUMN tracking_domain_verified_at timestamptz; + +-- Explicit per-campaign sender list (feature 1). Used only when +-- sender_strategy = 'explicit'; an empty set makes the scheduler fall back to +-- the existing tag-based selection, preserving backward compatibility. +CREATE TABLE campaign_senders ( + campaign_id uuid NOT NULL REFERENCES campaigns (id) ON DELETE CASCADE, + email_account_id uuid NOT NULL REFERENCES email_accounts (id) ON DELETE CASCADE, + weight integer NOT NULL DEFAULT 1 CHECK (weight >= 1 AND weight <= 100), + rotation_position integer NOT NULL DEFAULT 0, -- monotonic cursor for round_robin + last_sent_at timestamptz, -- drives least_recently_used + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT NOW(), + PRIMARY KEY (campaign_id, email_account_id) +); +CREATE INDEX idx_campaign_senders_campaign ON campaign_senders (campaign_id) WHERE enabled = true; + +-- Per-(campaign, day) counters; back the ramp advance and the new-lead cap +-- (features 2 & 4). A "new lead" is a contact receiving sequence position 1. +CREATE TABLE campaign_daily_sends ( + campaign_id uuid NOT NULL REFERENCES campaigns (id) ON DELETE CASCADE, + send_date date NOT NULL, + emails_sent integer NOT NULL DEFAULT 0, + new_leads_started integer NOT NULL DEFAULT 0, + PRIMARY KEY (campaign_id, send_date) +); + +-- Recipient ESP cache (feature 3). Derived from the recipient domain in the +-- control plane at selection time — never an MX dial on the send hot path. +ALTER TABLE contacts ADD COLUMN esp_provider text NOT NULL DEFAULT ''; -- '' | 'gmail' | 'outlook' | 'other' +ALTER TABLE contacts ADD COLUMN esp_resolved_at timestamptz; diff --git a/internal/models/campaign.go b/internal/models/campaign.go index 0a3d892b..feb55416 100644 --- a/internal/models/campaign.go +++ b/internal/models/campaign.go @@ -20,7 +20,7 @@ type Campaign struct { LinkTracking bool `json:"link_tracking"` TextOnly bool `json:"text_only"` DailyLimit int `json:"daily_limit"` - UnsubscribeHeader bool `json:"unscrubscribe_header"` + UnsubscribeHeader bool `json:"unsubscribe_header"` RiskyEmails bool `json:"risky_emails"` CC []string `json:"cc"` @@ -40,12 +40,57 @@ type Campaign struct { ContactOrderDir string `json:"contact_order_dir"` ContactOrderField *string `json:"contact_order_field,omitempty"` + // Sending-account selection. SenderStrategy is "tags" (default — accounts + // resolved from EmailTags) or "explicit" (the campaign_senders list). + // RotationMode picks how volume spreads across the chosen mailboxes. + SenderStrategy string `json:"sender_strategy"` + RotationMode string `json:"rotation_mode"` + Senders []CampaignSender `json:"senders,omitempty"` // loaded on demand, not in the base SELECT + + // Per-campaign daily ramp-up. Applied only via min() against the per-mailbox + // cap, so it can never raise volume above the cold cap. RampLevel/RampLevelDate + // are server-managed (persisted across pause/resume). + RampEnabled bool `json:"ramp_enabled"` + RampStart int `json:"ramp_start"` + RampIncrement int `json:"ramp_increment"` + RampCeiling int `json:"ramp_ceiling"` + RampLevel int `json:"ramp_level"` + RampLevelDate *time.Time `json:"ramp_level_date,omitempty"` + + // ESP/provider matching: off | prefer | strict. + ESPMatchMode string `json:"esp_match_mode"` + + // New-lead throttle. MaxNewLeadsPerDay 0 = unlimited (current behavior). + MaxNewLeadsPerDay int `json:"max_new_leads_per_day"` + PrioritizeNewLeads bool `json:"prioritize_new_leads"` + + // Campaign-scoped tracking-domain override. Honored only when verified; + // otherwise falls back to the mailbox/default domain. + TrackingDomain string `json:"tracking_domain"` + TrackingDomainVerified bool `json:"tracking_domain_verified"` + TrackingDomainVerifiedAt *time.Time `json:"tracking_domain_verified_at,omitempty"` + LastStatusChangeAt *time.Time `json:"last_status_change_at,omitempty"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } +// CampaignSender is one mailbox in an explicit-strategy campaign's sender pool. +type CampaignSender struct { + EmailAccountID uuid.UUID `json:"email_account_id"` + Weight int `json:"weight"` + LastSentAt *time.Time `json:"last_sent_at,omitempty"` + Enabled bool `json:"enabled"` +} + +// CampaignSenderInput is the write shape for PUT /campaigns/:id/senders. +type CampaignSenderInput struct { + EmailAccountID uuid.UUID `json:"email_account_id"` + Weight *int `json:"weight,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + type MiniCampaign struct { ID string `json:"id"` Name string `json:"name"` @@ -85,6 +130,21 @@ type UpdateCampaign struct { ContactOrderBy *string `json:"contact_order_by"` ContactOrderDir *string `json:"contact_order_dir"` ContactOrderField *string `json:"contact_order_field"` + + // Net-new send controls. The explicit sender LIST is edited via + // PUT /campaigns/:id/senders; only the strategy/mode toggles ride PATCH. + SenderStrategy *string `json:"sender_strategy,omitempty"` + RotationMode *string `json:"rotation_mode,omitempty"` + + RampEnabled *bool `json:"ramp_enabled,omitempty"` + RampStart *int `json:"ramp_start,omitempty"` + RampIncrement *int `json:"ramp_increment,omitempty"` + RampCeiling *int `json:"ramp_ceiling,omitempty"` + + ESPMatchMode *string `json:"esp_match_mode,omitempty"` + MaxNewLeadsPerDay *int `json:"max_new_leads_per_day,omitempty"` + PrioritizeNewLeads *bool `json:"prioritize_new_leads,omitempty"` + TrackingDomain *string `json:"tracking_domain,omitempty"` } // CreateCampaign is the payload accepted by POST /campaigns. Name is required; @@ -119,6 +179,24 @@ type CreateCampaign struct { EmailTagIDs []string `json:"email_tag_ids,omitempty"` FolderIDs []string `json:"folder_ids,omitempty"` + // Sending-account selection + rotation (net-new). When sender_strategy is + // "explicit", Senders is the mailbox pool; otherwise EmailTagIDs are used. + SenderStrategy *string `json:"sender_strategy,omitempty"` + RotationMode *string `json:"rotation_mode,omitempty"` + Senders []CampaignSenderInput `json:"senders,omitempty"` + + // Per-campaign daily ramp-up (net-new). ramp_level is server-owned. + RampEnabled *bool `json:"ramp_enabled,omitempty"` + RampStart *int `json:"ramp_start,omitempty"` + RampIncrement *int `json:"ramp_increment,omitempty"` + RampCeiling *int `json:"ramp_ceiling,omitempty"` + + // ESP/provider matching + new-lead throttle + tracking-domain override. + ESPMatchMode *string `json:"esp_match_mode,omitempty"` + MaxNewLeadsPerDay *int `json:"max_new_leads_per_day,omitempty"` + PrioritizeNewLeads *bool `json:"prioritize_new_leads,omitempty"` + TrackingDomain *string `json:"tracking_domain,omitempty"` + // Initial sequences (in order) — caller can also create them after. Sequences []CreateSequenceInput `json:"sequences,omitempty"` diff --git a/internal/models/contact.go b/internal/models/contact.go index 97337611..bee6bfe6 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -39,6 +39,12 @@ type Contact struct { IsCatchAll bool `json:"is_catch_all"` VerificationCheckedAt *time.Time `json:"verification_checked_at,omitempty"` + // Recipient ESP/provider, derived in the control plane from the recipient + // domain (never an MX dial on the send hot path). '' | 'gmail' | 'outlook' + // | 'other'. Used by the campaign ESP-matching feature. + ESPProvider string `json:"esp_provider"` + ESPResolvedAt *time.Time `json:"esp_resolved_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/repository/pg_campaign.go b/internal/repository/pg_campaign.go index 2364f92d..21f8c4c4 100644 --- a/internal/repository/pg_campaign.go +++ b/internal/repository/pg_campaign.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -39,10 +40,39 @@ type CampaignRepository interface { CountActiveForOrganization(ctx context.Context, orgID uuid.UUID) (int, error) AccountHasActiveCampaign(ctx context.Context, accountID uuid.UUID) (bool, error) // CountActiveCampaignsForAccount returns how many active campaigns send - // from the given mailbox (matched through the campaign's email tags). - // Used by the warmup scheduler to keep a low-volume health-check warmup - // running whenever a mailbox is in use by a live campaign. + // from the given mailbox (matched through the campaign's email tags OR an + // explicit campaign_senders row). Used by the warmup scheduler to keep a + // low-volume health-check warmup running whenever a mailbox is in use by a + // live campaign. CountActiveCampaignsForAccount(ctx context.Context, accountID uuid.UUID) (int, error) + + // ── Explicit sender pool (feature 1) ──────────────────────────────── + // GetCampaignSenders returns the campaign's explicit sender rows. + GetCampaignSenders(ctx context.Context, campaignID uuid.UUID) ([]models.CampaignSender, error) + // ReplaceCampaignSenders atomically replaces the explicit sender pool + // (delete-all + multi-insert in one tx). Every account must belong to the + // campaign owner; an empty list is rejected (use sender_strategy='tags'). + ReplaceCampaignSenders(ctx context.Context, campaignID uuid.UUID, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) + // AdvanceCampaignSender bumps the round-robin cursor and stamps last_sent_at + // for the chosen mailbox in a single atomic UPDATE. Fires only on a genuine + // send so the round-robin/LRU cursors stay coherent under concurrency. + AdvanceCampaignSender(ctx context.Context, campaignID, accountID uuid.UUID) error + + // ── Per-campaign ramp + daily counters (features 2 & 4) ───────────── + // AdvanceRampLevel advances the persisted ramp level once per UTC day. + // Idempotent and a no-op when ramp is disabled or already advanced today. + AdvanceRampLevel(ctx context.Context, campaignID uuid.UUID) error + // IncrementCampaignDailySend bumps today's send counters; newLead also + // increments new_leads_started (a position-1 send). + IncrementCampaignDailySend(ctx context.Context, campaignID uuid.UUID, newLead bool) error + // CountNewLeadsStartedToday returns new_leads_started for the current UTC + // day (0 when no row exists yet). + CountNewLeadsStartedToday(ctx context.Context, campaignID uuid.UUID) (int, error) + + // ── Campaign-scoped tracking domain (feature 5) ───────────────────── + // SetCampaignTrackingDomainVerified flips the verified flag / timestamp on + // the campaign-scoped tracking-domain override. + SetCampaignTrackingDomainVerified(ctx context.Context, campaignID uuid.UUID, verified bool, at *time.Time) error } type campaignRepository struct { @@ -55,13 +85,23 @@ func NewCampaignRepostory(db *db.DB) CampaignRepository { } } +// CAMPAIGN_SELECT and its scan dests (getCampaign), CAMPAIGN_SELECT_FULL + +// getCampaignFull, and the hand-written GetByID Scan MUST stay in lockstep: +// the new send-control columns are appended AFTER created_at in the base list +// (and BETWEEN created_at and the tag/folder aggregates in the _FULL list) so +// every scanner reads the same column order. A mismatch is a runtime scan error +// that compiles and lints cleanly — change all four together. const CAMPAIGN_SELECT = `id, name, description, status, stop_on_reply, open_tracking, link_tracking, text_only, daily_limit, unsubscribe_header, risky_emails, cc_addr, bcc_addr, start_date, end_date, timezone, days, start_time, end_time, contact_order_by, contact_order_dir, contact_order_field, - updated_at, created_at` + updated_at, created_at, + sender_strategy, rotation_mode, + ramp_enabled, ramp_start, ramp_increment, ramp_ceiling, ramp_level, ramp_level_date, + esp_match_mode, max_new_leads_per_day, prioritize_new_leads, + tracking_domain, tracking_domain_verified, tracking_domain_verified_at` func getCampaign(rows db.Scannable, campaign *models.Campaign, extra ...any) error { var dest []any = []any{ @@ -72,6 +112,10 @@ func getCampaign(rows db.Scannable, campaign *models.Campaign, extra ...any) err &campaign.StartTime, &campaign.EndTime, &campaign.ContactOrderBy, &campaign.ContactOrderDir, &campaign.ContactOrderField, &campaign.UpdatedAt, &campaign.CreatedAt, + &campaign.SenderStrategy, &campaign.RotationMode, + &campaign.RampEnabled, &campaign.RampStart, &campaign.RampIncrement, &campaign.RampCeiling, &campaign.RampLevel, &campaign.RampLevelDate, + &campaign.ESPMatchMode, &campaign.MaxNewLeadsPerDay, &campaign.PrioritizeNewLeads, + &campaign.TrackingDomain, &campaign.TrackingDomainVerified, &campaign.TrackingDomainVerifiedAt, } dest = append(dest, extra...) return rows.Scan( @@ -87,6 +131,10 @@ const CAMPAIGN_SELECT_FULL = ` c.start_time, c.end_time, c.contact_order_by, c.contact_order_dir, c.contact_order_field, c.updated_at, c.created_at, + c.sender_strategy, c.rotation_mode, + c.ramp_enabled, c.ramp_start, c.ramp_increment, c.ramp_ceiling, c.ramp_level, c.ramp_level_date, + c.esp_match_mode, c.max_new_leads_per_day, c.prioritize_new_leads, + c.tracking_domain, c.tracking_domain_verified, c.tracking_domain_verified_at, COALESCE(array_agg(cet.tag_id) FILTER (WHERE cet.tag_id IS NOT NULL), '{}') AS email_tag_ids, COALESCE(array_agg(cec.folder_id) FILTER (WHERE cec.folder_id IS NOT NULL), '{}') AS email_folder_ids ` @@ -214,6 +262,74 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u riskyEmails = *data.RiskyEmails } + // ── Net-new send controls. Defaults reproduce today's behavior exactly. ── + senderStrategy := "tags" + if data.SenderStrategy != nil { + if err := validate.CampaignSenderStrategy(*data.SenderStrategy); err != nil { + return nil, err + } + senderStrategy = *data.SenderStrategy + } + rotationMode := "weighted" + if data.RotationMode != nil { + if err := validate.CampaignRotationMode(*data.RotationMode); err != nil { + return nil, err + } + rotationMode = *data.RotationMode + } + rampEnabled := false + if data.RampEnabled != nil { + rampEnabled = *data.RampEnabled + } + rampStart := config.CampaignRampStartDefault + if data.RampStart != nil { + rampStart = *data.RampStart + } + rampIncrement := config.CampaignRampIncrementDefault + if data.RampIncrement != nil { + rampIncrement = *data.RampIncrement + } + rampCeiling := config.CampaignRampCeilingDefault + if data.RampCeiling != nil { + rampCeiling = *data.RampCeiling + } + if data.RampEnabled != nil || data.RampStart != nil || data.RampIncrement != nil || data.RampCeiling != nil { + if err := validate.CampaignRamp(rampStart, rampIncrement, rampCeiling); err != nil { + return nil, err + } + } + espMatchMode := "off" + if data.ESPMatchMode != nil { + if err := validate.CampaignESPMatchMode(*data.ESPMatchMode); err != nil { + return nil, err + } + espMatchMode = *data.ESPMatchMode + } + maxNewLeads := 0 + if data.MaxNewLeadsPerDay != nil { + if err := validate.CampaignMaxNewLeads(*data.MaxNewLeadsPerDay); err != nil { + return nil, err + } + maxNewLeads = *data.MaxNewLeadsPerDay + } + prioritizeNewLeads := false + if data.PrioritizeNewLeads != nil { + prioritizeNewLeads = *data.PrioritizeNewLeads + } + trackingDomain := "" + if data.TrackingDomain != nil { + if err := validate.CampaignTrackingDomain(*data.TrackingDomain); err != nil { + return nil, err + } + trackingDomain = strings.TrimSpace(strings.ToLower(*data.TrackingDomain)) + } + // An explicit-strategy campaign must ship at least one sender (the scheduler + // otherwise falls back to tags, but persisting an empty explicit pool is a + // configuration error the caller should fix up front). + if senderStrategy == "explicit" && len(data.Senders) == 0 { + return nil, errx.New(errx.BadRequest, "explicit sender strategy requires at least one sender") + } + tx, err := r.DB.Begin(ctx) if err != nil { db.CaptureError(err, "", nil, "begin") @@ -235,6 +351,10 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u daily_limit, unsubscribe_header, risky_emails, cc_addr, bcc_addr, start_date, end_date, timezone, days, start_time, end_time, + sender_strategy, rotation_mode, + ramp_enabled, ramp_start, ramp_increment, ramp_ceiling, + esp_match_mode, max_new_leads_per_day, prioritize_new_leads, + tracking_domain, created_at, updated_at ) VALUES ( gen_random_uuid(), $1, $2, $3, $4, @@ -242,31 +362,45 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, + $20, $21, + $22, $23, $24, $25, + $26, $27, $28, + $29, NOW(), NOW() ) RETURNING %s `, CAMPAIGN_SELECT) params := []any{ - data.Name, // $1 - data.Description, // $2 - userID, // $3 - orgID, // $4 (nullable) - stopOnReply, // $5 - openTracking, // $6 - linkTracking, // $7 - textOnly, // $8 - dailyLimit, // $9 - unsubHeader, // $10 - riskyEmails, // $11 - cc, // $12 - bcc, // $13 - data.StartDate, // $14 - data.EndDate, // $15 - timezone, // $16 - days, // $17 - startTime, // $18 - endTime, // $19 + data.Name, // $1 + data.Description, // $2 + userID, // $3 + orgID, // $4 (nullable) + stopOnReply, // $5 + openTracking, // $6 + linkTracking, // $7 + textOnly, // $8 + dailyLimit, // $9 + unsubHeader, // $10 + riskyEmails, // $11 + cc, // $12 + bcc, // $13 + data.StartDate, // $14 + data.EndDate, // $15 + timezone, // $16 + days, // $17 + startTime, // $18 + endTime, // $19 + senderStrategy, // $20 + rotationMode, // $21 + rampEnabled, // $22 + rampStart, // $23 + rampIncrement, // $24 + rampCeiling, // $25 + espMatchMode, // $26 + maxNewLeads, // $27 + prioritizeNewLeads, // $28 + trackingDomain, // $29 } row := tx.QueryRow(ctx, insertSQL, params...) @@ -298,6 +432,16 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u campaign.Folders = folders } + // Explicit sender pool (feature 1). Only persisted when the caller passed a + // list; validation/ownership checks live in syncCampaignSendersTx. + if len(data.Senders) > 0 { + senders, xerr := syncCampaignSendersTx(ctx, tx, campaign.ID, userID, data.Senders) + if xerr != nil { + return nil, xerr + } + campaign.Senders = senders + } + // Initial sequences. Position is the array index; wait_after defaults // to 0 for the first step and 3 days for any follow-ups so a default // wizard run still produces something usable. @@ -734,7 +878,89 @@ func (r *campaignRepository) Update(ctx context.Context, userID, campaignID stri argPos++ } - if argPos == 3 && data.EmailTags == nil { + // ── Net-new send controls ─────────────────────────────────────────── + if data.SenderStrategy != nil { + if err := validate.CampaignSenderStrategy(*data.SenderStrategy); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "sender_strategy", argPos)) + args = append(args, *data.SenderStrategy) + argPos++ + } + if data.RotationMode != nil { + if err := validate.CampaignRotationMode(*data.RotationMode); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "rotation_mode", argPos)) + args = append(args, *data.RotationMode) + argPos++ + } + if data.RampEnabled != nil { + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_enabled", argPos)) + args = append(args, *data.RampEnabled) + argPos++ + } + if data.RampStart != nil { + if *data.RampStart < 1 || *data.RampStart > 100 { + return nil, errx.New(errx.BadRequest, "ramp start must be between 1 and 100") + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_start", argPos)) + args = append(args, *data.RampStart) + argPos++ + } + if data.RampIncrement != nil { + if *data.RampIncrement < 0 || *data.RampIncrement > 100 { + return nil, errx.New(errx.BadRequest, "ramp increment must be between 0 and 100") + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_increment", argPos)) + args = append(args, *data.RampIncrement) + argPos++ + } + if data.RampCeiling != nil { + if *data.RampCeiling < 1 || *data.RampCeiling > 100 { + return nil, errx.New(errx.BadRequest, "ramp ceiling must be between 1 and 100") + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "ramp_ceiling", argPos)) + args = append(args, *data.RampCeiling) + argPos++ + } + if data.RampStart != nil && data.RampCeiling != nil && *data.RampStart > *data.RampCeiling { + return nil, errx.New(errx.BadRequest, "ramp start cannot exceed ramp ceiling") + } + if data.ESPMatchMode != nil { + if err := validate.CampaignESPMatchMode(*data.ESPMatchMode); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "esp_match_mode", argPos)) + args = append(args, *data.ESPMatchMode) + argPos++ + } + if data.MaxNewLeadsPerDay != nil { + if err := validate.CampaignMaxNewLeads(*data.MaxNewLeadsPerDay); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "max_new_leads_per_day", argPos)) + args = append(args, *data.MaxNewLeadsPerDay) + argPos++ + } + if data.PrioritizeNewLeads != nil { + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "prioritize_new_leads", argPos)) + args = append(args, *data.PrioritizeNewLeads) + argPos++ + } + if data.TrackingDomain != nil { + if err := validate.CampaignTrackingDomain(*data.TrackingDomain); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "tracking_domain", argPos)) + args = append(args, *data.TrackingDomain) + argPos++ + // Any change to the override invalidates a prior verification until + // the CNAME is re-resolved (only a verified override is honored). + setClauses = append(setClauses, "tracking_domain_verified = false", "tracking_domain_verified_at = NULL") + } + + if argPos == 3 && data.EmailTags == nil && data.Folders == nil { return nil, errx.ErrNotEnough } @@ -832,6 +1058,10 @@ func (r *campaignRepository) GetByID(ctx context.Context, campaignID uuid.UUID) &campaign.StartTime, &campaign.EndTime, &campaign.ContactOrderBy, &campaign.ContactOrderDir, &campaign.ContactOrderField, &campaign.UpdatedAt, &campaign.CreatedAt, + &campaign.SenderStrategy, &campaign.RotationMode, + &campaign.RampEnabled, &campaign.RampStart, &campaign.RampIncrement, &campaign.RampCeiling, &campaign.RampLevel, &campaign.RampLevelDate, + &campaign.ESPMatchMode, &campaign.MaxNewLeadsPerDay, &campaign.PrioritizeNewLeads, + &campaign.TrackingDomain, &campaign.TrackingDomainVerified, &campaign.TrackingDomainVerifiedAt, &campaign.EmailTags, &campaign.Folders, ) if err != nil { @@ -938,9 +1168,20 @@ func (r *campaignRepository) PauseAllByUserID(ctx context.Context, userID uuid.U return err } -// StartCampaign sets campaign status to active and updates last_status_change_at +// StartCampaign sets campaign status to active and updates last_status_change_at. +// When ramp is enabled and not yet started (ramp_level = 0), it also seeds the +// ramp at ramp_start for today so the first day sends at the ramp floor rather +// than ramp_start+increment. Pause/resume preserves ramp_level, so a resume +// continues from the persisted level (this only fires on a fresh start). func (r *campaignRepository) StartCampaign(ctx context.Context, campaignID uuid.UUID) error { - query := `UPDATE campaigns SET status = 'active', last_status_change_at = NOW(), updated_at = NOW() WHERE id = $1` + query := ` + UPDATE campaigns + SET status = 'active', + last_status_change_at = NOW(), + updated_at = NOW(), + ramp_level = CASE WHEN ramp_enabled AND ramp_level = 0 THEN ramp_start ELSE ramp_level END, + ramp_level_date = CASE WHEN ramp_enabled AND ramp_level = 0 THEN CURRENT_DATE ELSE ramp_level_date END + WHERE id = $1` _, err := r.DB.Exec(ctx, query, campaignID) return err } @@ -974,7 +1215,27 @@ func (r *campaignRepository) ValidateCampaignReady(ctx context.Context, campaign return errx.New(errx.BadRequest, "campaign must have at least one contact") } - // Check email tags + // Check sender pool. The required shape depends on the strategy: + // - 'tags' → at least one campaign_email_tag (legacy behavior) + // - 'explicit' → at least one ENABLED campaign_senders row + // An explicit-sender campaign legitimately has no tags, so the old + // tag-only check would wrongly reject it. + var senderStrategy string + if err := r.DB.QueryRow(ctx, `SELECT sender_strategy FROM campaigns WHERE id = $1`, campaignID).Scan(&senderStrategy); err != nil { + return err + } + if senderStrategy == "explicit" { + var senderCount int + err = r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM campaign_senders WHERE campaign_id = $1 AND enabled`, campaignID).Scan(&senderCount) + if err != nil { + return err + } + if senderCount == 0 { + return errx.New(errx.BadRequest, "campaign must have at least one enabled sender") + } + return nil + } + var tagCount int err = r.DB.QueryRow(ctx, `SELECT COUNT(*) FROM campaign_email_tags WHERE campaign_id = $1`, campaignID).Scan(&tagCount) if err != nil { @@ -1026,6 +1287,11 @@ func (r *campaignRepository) CountActiveForOrganization(ctx context.Context, org return count, err } +// AccountHasActiveCampaign reports whether the mailbox backs at least one active +// campaign, counting BOTH tag-based campaigns AND explicit-sender campaigns +// (campaign_senders). The explicit lane matters for the warmup health-check +// floor: an explicit-sender mailbox sends cold and must keep its in-campaign +// warmup heartbeat just like a tag-based one. func (r *campaignRepository) AccountHasActiveCampaign(ctx context.Context, accountID uuid.UUID) (bool, error) { query := ` SELECT EXISTS ( @@ -1037,6 +1303,17 @@ func (r *campaignRepository) AccountHasActiveCampaign(ctx context.Context, accou WHERE ea.id = $1 AND ea.status = 'active' AND c.status = 'active' + AND c.sender_strategy = 'tags' + UNION ALL + SELECT 1 + FROM campaign_senders cs + JOIN campaigns c ON c.id = cs.campaign_id + JOIN email_accounts ea ON ea.id = cs.email_account_id + WHERE cs.email_account_id = $1 + AND cs.enabled + AND ea.status = 'active' + AND c.status = 'active' + AND c.sender_strategy = 'explicit' ) ` var exists bool @@ -1044,18 +1321,265 @@ func (r *campaignRepository) AccountHasActiveCampaign(ctx context.Context, accou return exists, err } +// CountActiveCampaignsForAccount counts distinct active campaigns the mailbox +// backs, via the campaign's email tags (tag strategy) OR an enabled +// campaign_senders row (explicit strategy). func (r *campaignRepository) CountActiveCampaignsForAccount(ctx context.Context, accountID uuid.UUID) (int, error) { query := ` - SELECT COUNT(DISTINCT c.id) - FROM campaigns c - JOIN campaign_email_tags cet ON cet.campaign_id = c.id - JOIN email_tags et ON et.tag_id = cet.tag_id - WHERE et.email_id = $1 AND c.status = 'active'` + SELECT COUNT(*) FROM ( + SELECT c.id + FROM campaigns c + JOIN campaign_email_tags cet ON cet.campaign_id = c.id + JOIN email_tags et ON et.tag_id = cet.tag_id + WHERE et.email_id = $1 + AND c.status = 'active' + AND c.sender_strategy = 'tags' + UNION + SELECT c.id + FROM campaigns c + JOIN campaign_senders cs ON cs.campaign_id = c.id + WHERE cs.email_account_id = $1 + AND cs.enabled + AND c.status = 'active' + AND c.sender_strategy = 'explicit' + ) AS active_campaigns` var count int err := r.DB.QueryRow(ctx, query, accountID).Scan(&count) return count, err } +// syncCampaignSendersTx replaces the explicit sender pool inside an existing tx. +// It deletes the current rows and multi-inserts the new set, validating that +// every account belongs to the campaign owner (userID). rotation_position and +// last_sent_at are preserved for accounts that remain in the pool so a rewrite +// of weights/enabled flags does not reset the round-robin/LRU cursors. +func syncCampaignSendersTx(ctx context.Context, tx pgx.Tx, campaignID uuid.UUID, userID string, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) { + accountIDs := make([]uuid.UUID, 0, len(in)) + weights := make(map[uuid.UUID]int, len(in)) + enabledFor := make(map[uuid.UUID]bool, len(in)) + seen := make(map[uuid.UUID]struct{}, len(in)) + for _, s := range in { + if s.EmailAccountID == uuid.Nil { + return nil, errx.New(errx.BadRequest, "sender email_account_id is required") + } + if _, dup := seen[s.EmailAccountID]; dup { + return nil, errx.New(errx.BadRequest, "duplicate sender email_account_id") + } + seen[s.EmailAccountID] = struct{}{} + weight := config.CampaignSenderWeightDefault + if s.Weight != nil { + if err := validate.CampaignSenderWeight(*s.Weight); err != nil { + return nil, err + } + weight = *s.Weight + } + enabled := true + if s.Enabled != nil { + enabled = *s.Enabled + } + accountIDs = append(accountIDs, s.EmailAccountID) + weights[s.EmailAccountID] = weight + enabledFor[s.EmailAccountID] = enabled + } + + // Ownership check: every referenced mailbox must belong to the campaign owner. + var ownedCount int + if err := tx.QueryRow(ctx, + `SELECT COUNT(*) FROM email_accounts WHERE user_id = $1 AND id = ANY($2)`, + userID, accountIDs, + ).Scan(&ownedCount); err != nil { + db.CaptureError(err, "campaign_senders ownership", []any{userID}, "queryrow") + return nil, errx.InternalError() + } + if ownedCount != len(accountIDs) { + return nil, errx.New(errx.BadRequest, "one or more sender mailboxes do not belong to this account") + } + + // Delete rows that are no longer in the set (CASCADE-safe; preserves the + // surviving rows so their cursors carry over). + if _, err := tx.Exec(ctx, + `DELETE FROM campaign_senders WHERE campaign_id = $1 AND email_account_id <> ALL($2)`, + campaignID, accountIDs, + ); err != nil { + db.CaptureError(err, "campaign_senders delete", []any{campaignID}, "exec") + return nil, errx.InternalError() + } + + // Upsert the desired rows. ON CONFLICT updates weight/enabled but leaves + // rotation_position/last_sent_at untouched. + for _, id := range accountIDs { + if _, err := tx.Exec(ctx, ` + INSERT INTO campaign_senders (campaign_id, email_account_id, weight, enabled) + VALUES ($1, $2, $3, $4) + ON CONFLICT (campaign_id, email_account_id) + DO UPDATE SET weight = EXCLUDED.weight, enabled = EXCLUDED.enabled + `, campaignID, id, weights[id], enabledFor[id]); err != nil { + db.CaptureError(err, "campaign_senders upsert", []any{campaignID, id}, "exec") + return nil, errx.InternalError() + } + } + + return readCampaignSendersTx(ctx, tx, campaignID) +} + +// campaignSenderQuerier is satisfied by both *db.DB (via the embedded pool) and +// pgx.Tx, so readCampaignSendersTx works inside or outside a transaction. +type campaignSenderQuerier interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) +} + +// readCampaignSendersTx loads the explicit sender pool inside a tx. +func readCampaignSendersTx(ctx context.Context, q campaignSenderQuerier, campaignID uuid.UUID) ([]models.CampaignSender, *errx.Error) { + rows, err := q.Query(ctx, ` + SELECT email_account_id, weight, last_sent_at, enabled + FROM campaign_senders + WHERE campaign_id = $1 + ORDER BY created_at ASC, email_account_id ASC + `, campaignID) + if err != nil { + db.CaptureError(err, "campaign_senders select", []any{campaignID}, "query") + return nil, errx.InternalError() + } + defer rows.Close() + + senders := make([]models.CampaignSender, 0) + for rows.Next() { + var s models.CampaignSender + if err := rows.Scan(&s.EmailAccountID, &s.Weight, &s.LastSentAt, &s.Enabled); err != nil { + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + senders = append(senders, s) + } + if err := rows.Err(); err != nil { + db.CaptureError(err, "", nil, "rows") + return nil, errx.InternalError() + } + return senders, nil +} + +// GetCampaignSenders returns the explicit sender pool for a campaign. +func (r *campaignRepository) GetCampaignSenders(ctx context.Context, campaignID uuid.UUID) ([]models.CampaignSender, error) { + senders, xerr := readCampaignSendersTx(ctx, r.DB, campaignID) + if xerr != nil { + return nil, xerr + } + return senders, nil +} + +// ReplaceCampaignSenders atomically swaps the explicit sender pool. An empty +// list is rejected — clearing senders should be done by switching the campaign +// back to sender_strategy='tags'. +func (r *campaignRepository) ReplaceCampaignSenders(ctx context.Context, campaignID uuid.UUID, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) { + if len(in) == 0 { + return nil, errx.New(errx.BadRequest, "explicit sender strategy requires at least one sender") + } + + // Resolve the campaign owner so we can validate mailbox ownership. + var userID string + if err := r.DB.QueryRow(ctx, `SELECT user_id FROM campaigns WHERE id = $1`, campaignID).Scan(&userID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errx.ErrNotFound + } + db.CaptureError(err, "campaign owner lookup", []any{campaignID}, "queryrow") + return nil, errx.InternalError() + } + + tx, err := r.DB.Begin(ctx) + if err != nil { + db.CaptureError(err, "", nil, "begin") + return nil, errx.InternalError() + } + var committed bool + defer func() { + if !committed { + tx.Rollback(ctx) + } + }() + + senders, xerr := syncCampaignSendersTx(ctx, tx, campaignID, userID, in) + if xerr != nil { + return nil, xerr + } + + if err := tx.Commit(ctx); err != nil { + db.CaptureError(err, "", nil, "commit") + return nil, errx.InternalError() + } + committed = true + return senders, nil +} + +// AdvanceCampaignSender bumps the round-robin cursor and stamps last_sent_at in +// a single atomic UPDATE (no read-modify-write), keeping cursors coherent when +// multiple campaign tasks for the same campaign run concurrently. +func (r *campaignRepository) AdvanceCampaignSender(ctx context.Context, campaignID, accountID uuid.UUID) error { + _, err := r.DB.Exec(ctx, ` + UPDATE campaign_senders + SET rotation_position = rotation_position + 1, last_sent_at = NOW() + WHERE campaign_id = $1 AND email_account_id = $2 + `, campaignID, accountID) + return err +} + +// AdvanceRampLevel advances the persisted ramp level once per UTC day. It is a +// no-op when ramp is off or already advanced today. Applied via min() in the +// scheduler so it can only LOWER the effective per-mailbox cap. +func (r *campaignRepository) AdvanceRampLevel(ctx context.Context, campaignID uuid.UUID) error { + _, err := r.DB.Exec(ctx, ` + UPDATE campaigns + SET ramp_level = LEAST(ramp_ceiling, GREATEST(ramp_level, ramp_start) + ramp_increment), + ramp_level_date = CURRENT_DATE + WHERE id = $1 + AND ramp_enabled + AND (ramp_level_date IS NULL OR ramp_level_date < CURRENT_DATE) + `, campaignID) + return err +} + +// IncrementCampaignDailySend bumps today's per-campaign send counters. newLead +// also increments new_leads_started (a position-1 send) so the new-lead cap can +// read it back. +func (r *campaignRepository) IncrementCampaignDailySend(ctx context.Context, campaignID uuid.UUID, newLead bool) error { + newLeadInc := 0 + if newLead { + newLeadInc = 1 + } + _, err := r.DB.Exec(ctx, ` + INSERT INTO campaign_daily_sends (campaign_id, send_date, emails_sent, new_leads_started) + VALUES ($1, CURRENT_DATE, 1, $2) + ON CONFLICT (campaign_id, send_date) + DO UPDATE SET emails_sent = campaign_daily_sends.emails_sent + 1, + new_leads_started = campaign_daily_sends.new_leads_started + $2 + `, campaignID, newLeadInc) + return err +} + +// CountNewLeadsStartedToday returns new_leads_started for the current UTC day. +func (r *campaignRepository) CountNewLeadsStartedToday(ctx context.Context, campaignID uuid.UUID) (int, error) { + var n int + err := r.DB.QueryRow(ctx, ` + SELECT COALESCE(new_leads_started, 0) + FROM campaign_daily_sends + WHERE campaign_id = $1 AND send_date = CURRENT_DATE + `, campaignID).Scan(&n) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + return n, err +} + +// SetCampaignTrackingDomainVerified flips the verified flag/timestamp on the +// campaign-scoped tracking-domain override. +func (r *campaignRepository) SetCampaignTrackingDomainVerified(ctx context.Context, campaignID uuid.UUID, verified bool, at *time.Time) error { + _, err := r.DB.Exec(ctx, ` + UPDATE campaigns + SET tracking_domain_verified = $2, tracking_domain_verified_at = $3, updated_at = NOW() + WHERE id = $1 + `, campaignID, verified, at) + return err +} + // UpdateStatusWithLock updates campaign status using a PostgreSQL advisory lock to prevent concurrent updates. // The WHERE clause guards against races: only updates if the campaign is currently 'active'. func (r *campaignRepository) UpdateStatusWithLock(ctx context.Context, campaignID uuid.UUID, status string) error { diff --git a/internal/repository/pg_campaign_progress.go b/internal/repository/pg_campaign_progress.go index f3f7058e..00d0fe9e 100644 --- a/internal/repository/pg_campaign_progress.go +++ b/internal/repository/pg_campaign_progress.go @@ -48,6 +48,9 @@ type CampaignRollingRates struct { type ContactSequencePair struct { ContactID uuid.UUID SequenceID uuid.UUID + // IsNewLead is true when this pair is the contact's first step (sequence + // position 1). Drives the per-day new-lead counter and cap. + IsNewLead bool } type CampaignSequencePair struct { @@ -74,8 +77,10 @@ type CampaignProgressRepository interface { CountEmailsSentTodayByOrganization(ctx context.Context, organizationID uuid.UUID) (int, error) GetLatestCampaignSequenceForContact(ctx context.Context, contactID uuid.UUID) (*CampaignSequencePair, error) - // Find next email to send - FindNextContactSequence(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string) (*ContactSequencePair, error) + // Find next email to send. prioritizeNewLeads sorts position-1 (new lead) + // pairs first; excludeNewLeads drops position-1 pairs entirely (so the + // new-lead/day cap can be enforced while follow-ups keep flowing). + FindNextContactSequence(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool) (*ContactSequencePair, error) } type campaignProgressRepository struct { @@ -360,7 +365,7 @@ func (r *campaignProgressRepository) GetLatestCampaignSequenceForContact(ctx con // orderBy: "created_at", "email", "name", "custom_field", "manual" // orderDir: "asc", "desc" // orderField: custom field name (used when orderBy is "custom_field") -func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string) (*ContactSequencePair, error) { +func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool) (*ContactSequencePair, error) { // Build the ORDER BY clause based on ordering settings var contactOrder string switch orderBy { @@ -386,18 +391,34 @@ func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context dir = "DESC" } + // When prioritizing new leads, sort position-1 pairs first; otherwise the + // existing contact/position/created_at order is preserved exactly. + orderPrefix := "" + if prioritizeNewLeads { + orderPrefix = "(s.position = 1) DESC, " + } + + // When excluding new leads (the per-day cap is hit), drop position-1 pairs + // so only follow-ups remain eligible. + excludeClause := "" + if excludeNewLeads { + excludeClause = "AND s.position > 1" + } + query := ` WITH all_pairs AS ( -- Generate all possible contact-sequence combinations for this campaign SELECT cl.contact_id, s.id as sequence_id, - ROW_NUMBER() OVER (ORDER BY ` + contactOrder + ` ` + dir + `, s.position, s.created_at) as pair_order + (s.position = 1) as is_new_lead, + ROW_NUMBER() OVER (ORDER BY ` + orderPrefix + contactOrder + ` ` + dir + `, s.position, s.created_at) as pair_order FROM campaign_leads cl JOIN contacts c ON c.id = cl.contact_id CROSS JOIN sequences s WHERE cl.campaign_id = $1 AND s.campaign_id = $1 + ` + excludeClause + ` -- Skip contacts that bounced in ANY campaign AND NOT EXISTS ( SELECT 1 FROM campaign_contact_progress ccp2 @@ -420,7 +441,7 @@ func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context WHERE campaign_id = $1 AND sent_at IS NOT NULL ) - SELECT ap.contact_id, ap.sequence_id + SELECT ap.contact_id, ap.sequence_id, ap.is_new_lead FROM all_pairs ap LEFT JOIN sent_pairs sp ON ap.contact_id = sp.contact_id AND ap.sequence_id = sp.sequence_id WHERE sp.contact_id IS NULL -- Not yet sent @@ -429,7 +450,7 @@ func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context ` pair := &ContactSequencePair{} - err := r.db.QueryRow(ctx, query, campaignID).Scan(&pair.ContactID, &pair.SequenceID) + err := r.db.QueryRow(ctx, query, campaignID).Scan(&pair.ContactID, &pair.SequenceID, &pair.IsNewLead) if err == sql.ErrNoRows { return nil, nil // No more emails to send diff --git a/internal/repository/pg_contact.go b/internal/repository/pg_contact.go index 6a267e55..75a820bc 100644 --- a/internal/repository/pg_contact.go +++ b/internal/repository/pg_contact.go @@ -32,6 +32,10 @@ type ContactRepository interface { // the batch scheduler can work them off a cap per tick. UpdateContactVerification(ctx context.Context, contactID uuid.UUID, res emailverify.Result) *errx.Error ListUnverifiedContacts(ctx context.Context, limit int) ([]models.Contact, *errx.Error) + // SetContactESP caches the recipient ESP/provider resolved from the contact's + // domain (control-plane only, no MX dial). Best-effort: a failure should not + // block sending. + SetContactESP(ctx context.Context, contactID uuid.UUID, provider string) error GetByEmailsAndUser(ctx context.Context, userID uuid.UUID, emails []string) (map[string]models.Contact, *errx.Error) Search(ctx context.Context, userID string, category, cursor *string, filters models.SearchContacts, limit int32) (*models.ContactsResult, *errx.Error) ExportAll(ctx context.Context, userID string, filters *models.SearchContacts, contactIDs []string, max int) ([]models.Contact, *errx.Error) @@ -340,7 +344,8 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (* SELECT c.id, c.first_name, c.last_name, c.email, c.company, c.phone, c.custom_fields, c.subscribed, c.updated_at, c.created_at, - c.verification_status, c.verification_reason, c.is_catch_all, c.verification_checked_at + c.verification_status, c.verification_reason, c.is_catch_all, c.verification_checked_at, + c.esp_provider, c.esp_resolved_at FROM contacts c WHERE c.id = $1 ` @@ -351,6 +356,7 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (* &contact.Company, &contact.Phone, &contact.CustomFields, &contact.Subscribed, &contact.UpdatedAt, &contact.CreatedAt, &contact.VerificationStatus, &contact.VerificationReason, &contact.IsCatchAll, &contact.VerificationCheckedAt, + &contact.ESPProvider, &contact.ESPResolvedAt, ) if err != nil { if err == pgx.ErrNoRows { @@ -365,6 +371,19 @@ func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (* return &contact, nil } +// SetContactESP caches the recipient ESP/provider on the contact row. It is a +// single keyed UPDATE and intentionally tolerant: callers treat any error as a +// best-effort cache miss and fall back to deriving the provider on the fly. +func (r *contactRepository) SetContactESP(ctx context.Context, contactID uuid.UUID, provider string) error { + query := ` + UPDATE contacts + SET esp_provider = $2, esp_resolved_at = NOW() + WHERE id = $1 + ` + _, err := r.DB.Exec(ctx, query, contactID, provider) + return err +} + // UpdateContactVerification stores the outcome of a verification pass on the // contact. It is keyed only by contact id (the verifier runs in the control // plane, not in a user request) and is a no-op-safe single UPDATE. diff --git a/internal/repository/pg_email.go b/internal/repository/pg_email.go index 53542ec0..47d94d5f 100644 --- a/internal/repository/pg_email.go +++ b/internal/repository/pg_email.go @@ -43,6 +43,10 @@ type EmailRepository interface { Get(ctx context.Context, userID, emailAccountID string) (*models.Email, *errx.Error) GetByID(ctx context.Context, emailAccountID uuid.UUID) (*models.Email, *errx.Error) GetByTags(ctx context.Context, userID string, tags []string) ([]models.Email, *errx.Error) + // GetByCampaignSenders returns the active mailboxes in a campaign's explicit + // sender pool, carrying each sender's rotation metadata (weight, + // rotation_position, last_sent_at) for the scheduler's rotation modes. + GetByCampaignSenders(ctx context.Context, userID string, campaignID uuid.UUID) ([]CampaignSenderAccount, *errx.Error) GetSMTPCredentials(ctx context.Context, emailAccountID uuid.UUID) (*SMTPCredentials, *errx.Error) GetOAuthCredentials(ctx context.Context, emailAccountID uuid.UUID) (*OAuthCredentials, *errx.Error) GetWorkerID(ctx context.Context, emailAccountID uuid.UUID) (*uuid.UUID, *errx.Error) @@ -982,6 +986,75 @@ func (r *emailRepository) GetByTags(ctx context.Context, userID string, tags []s return emails, nil } +// CampaignSenderAccount pairs an active sender mailbox with its per-campaign +// rotation metadata, so the scheduler's rotation modes (weighted / round_robin +// / least_recently_used) can pick among them without a second query. +type CampaignSenderAccount struct { + Account models.Email + Weight int + RotationPosition int + LastSentAt *time.Time +} + +// GetByCampaignSenders mirrors GetByTags but resolves accounts through the +// explicit campaign_senders pool instead of email tags. Only enabled senders +// backing an active mailbox are returned; the per-sender weight/cursor/last-send +// ride along for rotation. +func (r *emailRepository) GetByCampaignSenders(ctx context.Context, userID string, campaignID uuid.UUID) ([]CampaignSenderAccount, *errx.Error) { + query := ` + SELECT + ea.id, ea.user_id, ea.email, ea.name, ea.signature_plain, ea.signature_html, ea.signature_sync, ea.signature_code, + ea.provider, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit, + ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.warmup, ea.warmup_paused_at, ea.warmup_base, + ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag, + ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.timezone, + ea.created_at, ea.updated_at, + cs.weight, cs.rotation_position, cs.last_sent_at + FROM email_accounts ea + JOIN campaign_senders cs ON cs.email_account_id = ea.id + WHERE cs.campaign_id = $2 + AND cs.enabled + AND ea.user_id = $1 + AND ea.status = 'active' + ORDER BY ea.id + ` + + rows, err := r.DB.Query(ctx, query, userID, campaignID) + if err != nil { + db.CaptureError(err, query, []any{userID, campaignID}, "query") + return nil, errx.InternalError() + } + defer rows.Close() + + var out []CampaignSenderAccount + for rows.Next() { + var i models.Email + var sender CampaignSenderAccount + err := rows.Scan( + &i.ID, &i.UserID, &i.Email, &i.Name, &i.SignaturePlain, &i.SignatureHTML, &i.SignatureSync, &i.SignatureCode, + &i.Provider, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit, + &i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase, + &i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag, + &i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.Timezone, + &i.CreatedAt, &i.UpdatedAt, + &sender.Weight, &sender.RotationPosition, &sender.LastSentAt, + ) + if err != nil { + db.CaptureError(err, "", nil, "scan") + return nil, errx.InternalError() + } + i.Tags = []string{} + sender.Account = i + out = append(out, sender) + } + if err := rows.Err(); err != nil { + db.CaptureError(err, "", nil, "rows") + return nil, errx.InternalError() + } + + return out, nil +} + // GetSMTPCredentials retrieves SMTP/IMAP credentials for an email account func (r *emailRepository) GetSMTPCredentials(ctx context.Context, emailAccountID uuid.UUID) (*SMTPCredentials, *errx.Error) { query := ` diff --git a/internal/scheduler/campaign_scheduler.go b/internal/scheduler/campaign_scheduler.go index 05cb24b1..43087493 100644 --- a/internal/scheduler/campaign_scheduler.go +++ b/internal/scheduler/campaign_scheduler.go @@ -22,36 +22,128 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai return time.Time{}, nil, uuid.Nil, ErrCampaignNotActive } - // STEP 2: Get all email accounts assigned to this campaign (via tags) - accounts, err := s.emailRepo.GetByTags(ctx, campaign.UserID, campaign.EmailTags) - if err != nil { - return time.Time{}, nil, uuid.Nil, err + // STEP 1.5: Advance the per-campaign daily ramp level (idempotent, once per + // UTC day; no-op when ramp is disabled). Re-load so campaign.RampLevel + // reflects today's level before any capacity math. Failing open here keeps + // scheduling running (the worst case is today's ramp not advancing). + if campaign.RampEnabled { + if aerr := s.campaignRepo.AdvanceRampLevel(ctx, campaignID); aerr == nil { + if reloaded, rerr := s.campaignRepo.GetByID(ctx, campaignID); rerr == nil && reloaded != nil { + campaign = reloaded + } + } + } + + // STEP 2: Resolve the campaign's sending mailboxes. Explicit strategy uses + // the campaign_senders pool (carrying per-sender rotation metadata); tags + // strategy keeps the existing tag-based resolution. An empty explicit pool + // falls back to tags so a misconfigured campaign still sends. + type senderMeta struct { + weight int + rotationPosition int + lastSentAt *time.Time + hasMeta bool + } + accounts := []models.Email{} + senderMetaByID := map[uuid.UUID]senderMeta{} + if campaign.SenderStrategy == "explicit" { + senders, serr := s.emailRepo.GetByCampaignSenders(ctx, campaign.UserID, campaignID) + if serr != nil { + return time.Time{}, nil, uuid.Nil, serr + } + for _, snd := range senders { + accounts = append(accounts, snd.Account) + senderMetaByID[snd.Account.ID] = senderMeta{ + weight: snd.Weight, + rotationPosition: snd.RotationPosition, + lastSentAt: snd.LastSentAt, + hasMeta: true, + } + } + } + if len(accounts) == 0 { + tagAccounts, terr := s.emailRepo.GetByTags(ctx, campaign.UserID, campaign.EmailTags) + if terr != nil { + return time.Time{}, nil, uuid.Nil, terr + } + accounts = tagAccounts + senderMetaByID = map[uuid.UUID]senderMeta{} } if len(accounts) == 0 { return time.Time{}, nil, uuid.Nil, ErrNoEmailAccounts } - // STEP 3: Get campaign progress - find next contact/sequence to send + // STEP 3: Get campaign progress - find next contact/sequence to send. + // Honor the new-lead-per-day cap and the prioritize-new-leads ordering. orderField := "" if campaign.ContactOrderField != nil { orderField = *campaign.ContactOrderField } + excludeNewLeads := false + if campaign.MaxNewLeadsPerDay > 0 { + newLeadsToday, nlerr := s.campaignRepo.CountNewLeadsStartedToday(ctx, campaignID) + if nlerr != nil { + return time.Time{}, nil, uuid.Nil, nlerr + } + if newLeadsToday >= campaign.MaxNewLeadsPerDay { + excludeNewLeads = true + } + } nextPair, err := s.campaignProgressRepo.FindNextContactSequence( ctx, campaignID, campaign.ContactOrderBy, campaign.ContactOrderDir, orderField, + campaign.PrioritizeNewLeads, + excludeNewLeads, ) if err != nil { return time.Time{}, nil, uuid.Nil, err } if nextPair == nil { + // When the new-lead cap is active and only new-lead pairs remain, + // FindNextContactSequence returns nil with exclude on but WOULD return a + // pair without it. In that case defer to the next day so follow-ups keep + // progressing and new leads resume tomorrow — do NOT complete. + if excludeNewLeads { + if again, aerr := s.campaignProgressRepo.FindNextContactSequence( + ctx, campaignID, campaign.ContactOrderBy, campaign.ContactOrderDir, orderField, + campaign.PrioritizeNewLeads, false, + ); aerr == nil && again != nil { + s.logCampaignDecision(ctx, campaignID, "new_lead_cap_reached", + "Daily new-lead cap reached; deferring remaining new leads to tomorrow", + map[string]interface{}{"max_new_leads_per_day": campaign.MaxNewLeadsPerDay}) + deferTime := s.deferToNextDay(campaign) + // Return a DEFERRAL, never a sendable pair: the caller only checks + // err for deferrals, so a nil-error here would send a new lead and + // blow past the cap. nil pair + sentinel = reschedule, don't send. + return deferTime, nil, accounts[0].ID, ErrCampaignDeferred + } + } return time.Time{}, nil, uuid.Nil, ErrCampaignCompleted } + // STEP 3.5: Resolve the recipient ESP/provider for ESP matching. Cheap: + // prefer the cached contact.esp_provider, else derive from the domain + // string. NEVER dial MX on the hot path. Empty => unknown => wildcard. + recipientProvider := "" + if campaign.ESPMatchMode != "off" && s.contactRepo != nil { + if contact, cerr := s.contactRepo.GetByID(ctx, nextPair.ContactID); cerr == nil && contact != nil { + if contact.ESPProvider != "" { + recipientProvider = contact.ESPProvider + } else { + recipientProvider = providerForEmailDomain(contact.Email) + // Opportunistically cache the derived provider (best-effort). + if recipientProvider != "" { + _ = s.contactRepo.SetContactESP(ctx, contact.ID, recipientProvider) + } + } + } + } + // STEP 4: Calculate base time from sequence wait_after baseTime := time.Now() @@ -94,6 +186,32 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai // STEP 7: Ensure within campaign time window (start_time to end_time) candidateTime = ensureTimeWindow(candidateTime, campaign.StartTime, campaign.EndTime, campaignTZ) + // effectiveCap is the per-mailbox cold cap for THIS campaign, after the ramp + // clamp. It is min(per-mailbox cold cap, campaign daily limit) further min()'d + // with the day's ramp ceiling. Applied via min() only — it can never RAISE a + // mailbox above its cold cap (the mailbox-first safety invariant). + effectiveCap := func(acct models.Email) int { + lim := min(acct.CampaignLimit, campaign.DailyLimit) + if campaign.RampEnabled { + lim = min(lim, campaignRampCeiling(true, campaign.RampStart, campaign.RampIncrement, campaign.RampCeiling, campaign.RampLevel)) + } + return lim + } + + // providerMatches reports whether a mailbox's provider satisfies the + // recipient ESP under the current match mode. smtp_imap is a wildcard + // (can carry any provider's recipients); an unknown recipient provider is + // also a wildcard so matching never blocks first contact. + providerMatches := func(acctProvider string) bool { + if campaign.ESPMatchMode == "off" || recipientProvider == "" { + return true + } + if acctProvider == "smtp_imap" { + return true + } + return acctProvider == recipientProvider + } + // STEP 8: Build weighted account candidates // Skip accounts whose local time falls outside business hours (8am-8pm) var candidates []AccountCandidate @@ -103,7 +221,7 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai return time.Time{}, nil, uuid.Nil, err } - acctLimit := min(acct.CampaignLimit, campaign.DailyLimit) + acctLimit := effectiveCap(acct) remaining := acctLimit - sentToday // Skip accounts that have reached their daily limit @@ -116,6 +234,8 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai // cold volume (the concentration risk the safety policy warns about): // - quarantined/blocked (still within blocked_until) → don't send at all // - throttled → halve today's budget (and the wider min-gap still applies) + // This gate runs FIRST, before any rotation/ESP logic, so a degraded + // mailbox is always dropped regardless of weighting. if state, blockedUntil, herr := s.warmupRepo.GetHealthState(ctx, acct.ID); herr == nil { switch state { case models.WarmupHealthQuarantined, models.WarmupHealthBlocked: @@ -145,37 +265,104 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai warmupAgeDays = int(time.Since(*acct.Warmup).Hours() / 24) } - candidates = append(candidates, AccountCandidate{ + cand := AccountCandidate{ Account: acct, RemainingToday: remaining, WarmupAgeDays: warmupAgeDays, Weight: computeWeight(remaining, warmupAgeDays), - }) + ProviderMatch: providerMatches(acct.Provider), + } + if meta, ok := senderMetaByID[acct.ID]; ok { + cand.HasSenderMetadata = true + cand.SenderWeight = meta.weight + cand.RotationPosition = meta.rotationPosition + cand.SenderLastSentAt = meta.lastSentAt + } + candidates = append(candidates, cand) } - // STEP 8.5: Select best account via weighted random selection - selected := selectAccountWeighted(candidates) + // STEP 8.25: Apply ESP matching to the under-budget candidate set. + // strict → only matching mailboxes are eligible; if none, DEFER (never + // send cross-provider). + // prefer → restrict to matching mailboxes when at least one has capacity, + // otherwise fall back to the full eligible set (never starves). + if campaign.ESPMatchMode != "off" && recipientProvider != "" { + matching := make([]AccountCandidate, 0, len(candidates)) + for _, c := range candidates { + if c.ProviderMatch { + matching = append(matching, c) + } + } + switch campaign.ESPMatchMode { + case "strict": + if len(matching) == 0 { + // No matching mailbox under budget today: defer to the next slot + // rather than complete or send cross-provider. + s.logCampaignDecision(ctx, campaignID, "provider_match_deferred", + "No same-provider mailbox available; deferring to next slot", + map[string]interface{}{"recipient_provider": recipientProvider}) + // Deferral, not a send: nil pair + sentinel so the caller reschedules + // instead of sending this contact from a cross-provider mailbox. + return s.deferToNextDay(campaign), nil, accounts[0].ID, ErrCampaignDeferred + } + candidates = matching + case "prefer": + if len(matching) > 0 { + candidates = matching + } + } + } + + // STEP 8.5: Select best account per the campaign's rotation mode. + selected := selectAccountByRotationMode(campaign.RotationMode, candidates) if selected == nil { - // ALL accounts at capacity today — push to next day and pick highest-weight account (all reset tomorrow) + // ALL accounts at capacity today — push to next day and recompute with + // tomorrow's full (ramp-clamped) capacity. The ramp clamp AND the ESP + // filter MUST be re-applied here, or tomorrow's recompute over-budgets a + // mailbox past its ramp ceiling / picks a cross-provider sender. candidateTime = candidateTime.Add(24 * time.Hour) candidateTime = findNextValidDay(candidateTime, uint8(campaign.Days), campaignTZ) candidateTime = ensureTimeWindow(candidateTime, campaign.StartTime, campaign.EndTime, campaignTZ) - // Recompute with full capacity for tomorrow - var bestCandidate *AccountCandidate + var tomorrow []AccountCandidate for i := range candidates { - warmupAgeDays := candidates[i].WarmupAgeDays - acctLimit := min(candidates[i].Account.CampaignLimit, campaign.DailyLimit) - candidates[i].RemainingToday = acctLimit - candidates[i].Weight = computeWeight(acctLimit, warmupAgeDays) - if bestCandidate == nil || candidates[i].Weight > bestCandidate.Weight { - bestCandidate = &candidates[i] + acct := candidates[i].Account + // ESP-strict: keep only matching mailboxes for tomorrow too. + if campaign.ESPMatchMode == "strict" && recipientProvider != "" && !candidates[i].ProviderMatch { + continue + } + acctLimit := effectiveCap(acct) // same ramp clamp as STEP 8 + c := candidates[i] + c.RemainingToday = acctLimit + c.Weight = computeWeight(acctLimit, candidates[i].WarmupAgeDays) + tomorrow = append(tomorrow, c) + } + // ESP-prefer: restrict tomorrow to matching mailboxes when any exist. + if campaign.ESPMatchMode == "prefer" && recipientProvider != "" { + var matchingTomorrow []AccountCandidate + for _, c := range tomorrow { + if c.ProviderMatch { + matchingTomorrow = append(matchingTomorrow, c) + } + } + if len(matchingTomorrow) > 0 { + tomorrow = matchingTomorrow } } - if bestCandidate == nil { + + selected = selectAccountByRotationMode(campaign.RotationMode, tomorrow) + if selected == nil { + // ESP-strict with no matching mailbox at all: defer rather than + // complete or send cross-provider. + if campaign.ESPMatchMode == "strict" && recipientProvider != "" { + s.logCampaignDecision(ctx, campaignID, "provider_match_deferred", + "No same-provider mailbox available tomorrow; deferring", + map[string]interface{}{"recipient_provider": recipientProvider}) + // Deferral, not a send (see above). + return s.deferToNextDay(campaign), nil, accounts[0].ID, ErrCampaignDeferred + } return time.Time{}, nil, uuid.Nil, ErrNoEmailAccounts } - selected = bestCandidate } account := &selected.Account @@ -241,3 +428,30 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai return candidateTime, nextPair, account.ID, nil } + +// deferToNextDay pushes a candidate time to the next valid campaign day within +// the campaign's send window. Used by the ESP-strict and new-lead-cap deferral +// paths so a campaign reschedules instead of completing or busy-looping. +func (s *schedulerService) deferToNextDay(campaign *models.Campaign) time.Time { + tz := loadLocation(campaign.Timezone) + t := time.Now().Add(24 * time.Hour) + t = findNextValidDay(t, uint8(campaign.Days), tz) + t = ensureTimeWindow(t, campaign.StartTime, campaign.EndTime, tz) + // Add a small jitter so deferred tasks don't all wake at the same instant. + return t.Add(time.Minute * time.Duration(randomJitter(0, 30))) +} + +// logCampaignDecision records a send-path decision (ESP defer, new-lead cap) to +// the campaign activity log. Best-effort and nil-safe — a logging miss never +// blocks scheduling. +func (s *schedulerService) logCampaignDecision(ctx context.Context, campaignID uuid.UUID, eventType, message string, metadata map[string]interface{}) { + if s.campaignLogRepo == nil { + return + } + _ = s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ + CampaignID: campaignID, + EventType: eventType, + Message: message, + Metadata: metadata, + }) +} diff --git a/internal/scheduler/errors.go b/internal/scheduler/errors.go index 44110c37..f26be723 100644 --- a/internal/scheduler/errors.go +++ b/internal/scheduler/errors.go @@ -20,4 +20,13 @@ var ( // ErrDailyLimitReached is returned when the daily limit has been reached ErrDailyLimitReached = errors.New("daily email limit reached") + + // ErrCampaignDeferred is returned when there IS a valid contact to send but + // no eligible mailbox right now — ESP-strict has no same-provider mailbox + // under budget, or the daily new-lead cap is reached. The caller must + // reschedule at the returned (defer) time WITHOUT sending. The returned pair + // is always nil on this path so it can never be mistaken for a sendable + // contact; the returned accountID is a nominal pool mailbox for the wakeup + // task only (the next invocation re-evaluates selection from scratch). + ErrCampaignDeferred = errors.New("campaign send deferred - no eligible mailbox for this contact right now") ) diff --git a/internal/scheduler/helpers.go b/internal/scheduler/helpers.go index 0e3e3ce0..df2dd3a0 100644 --- a/internal/scheduler/helpers.go +++ b/internal/scheduler/helpers.go @@ -4,6 +4,7 @@ import ( "math" "math/rand" "sort" + "strings" "time" "github.com/warmbly/warmbly/internal/models" @@ -192,6 +193,63 @@ type AccountCandidate struct { RemainingToday int WarmupAgeDays int Weight float64 + + // Per-sender rotation metadata (explicit sender strategy only). SenderWeight + // multiplies the base Weight in weighted mode; RotationPosition drives + // round_robin; SenderLastSentAt drives least_recently_used. Defaults + // (weight 1, nil last-sent, position 0) make tag-strategy candidates behave + // exactly as before. + SenderWeight int + RotationPosition int + SenderLastSentAt *time.Time + HasSenderMetadata bool + + // ProviderMatch reflects whether this mailbox's provider matches the + // recipient ESP under ESP matching. Always true when ESP matching is off or + // the recipient provider is unknown. + ProviderMatch bool +} + +// campaignRampCeiling returns the day's effective ramp ceiling. When ramp is +// disabled it returns the campaign ceiling unchanged (the caller still min()s +// against the per-mailbox cap). When enabled it returns the already-advanced +// level clamped into [start, ceiling]. The scheduler applies this ONLY via +// min() against the per-mailbox cold cap, so it can only LOWER volume. +func campaignRampCeiling(enabled bool, start, increment, ceiling, level int) int { + _ = increment // the increment is applied by AdvanceRampLevel; level is already advanced + if !enabled { + return ceiling + } + v := level + if v < start { + v = start + } + if v > ceiling { + v = ceiling + } + return v +} + +// providerForEmailDomain maps a recipient email address to a coarse ESP bucket +// for provider matching. Pure string work — never dials MX. Unknown/other +// domains return "" so matching never blocks the first contact. +func providerForEmailDomain(email string) string { + at := strings.LastIndex(email, "@") + if at < 0 || at == len(email)-1 { + return "" + } + domain := strings.ToLower(strings.TrimSpace(email[at+1:])) + switch domain { + case "gmail.com", "googlemail.com": + return "gmail" + case "outlook.com", "hotmail.com", "live.com", "msn.com", "office365.com", "microsoft.com": + return "outlook" + } + // Subdomain / suffix heuristics for hosted Google/Microsoft mail. + if strings.HasSuffix(domain, ".onmicrosoft.com") { + return "outlook" + } + return "" } // computeWeight calculates a scheduling weight for an account based on remaining capacity and warmup age. @@ -205,13 +263,18 @@ func computeWeight(remaining int, warmupAgeDays int) float64 { } // selectAccountWeighted picks an account using weighted random selection. -// Returns nil if all candidates have zero weight. +// Returns nil if all candidates have zero weight. The per-sender weight (1..100, +// default 1 for tag-strategy candidates) multiplies the base scheduling weight, +// so an operator can bias rotation toward specific mailboxes without ever +// raising any mailbox above its already-clamped per-mailbox cap. func selectAccountWeighted(candidates []AccountCandidate) *AccountCandidate { var totalWeight float64 var viable []AccountCandidate for _, c := range candidates { - if c.Weight > 0 { - totalWeight += c.Weight + w := effectiveWeight(c) + if w > 0 { + totalWeight += w + c.Weight = w viable = append(viable, c) } } @@ -232,3 +295,76 @@ func selectAccountWeighted(candidates []AccountCandidate) *AccountCandidate { // Fallback to last viable candidate return &viable[len(viable)-1] } + +// effectiveWeight folds the per-sender weight into the base scheduling weight. +// Tag-strategy candidates carry SenderWeight 0 (no metadata) and so use the +// base weight unchanged. +func effectiveWeight(c AccountCandidate) float64 { + if c.HasSenderMetadata && c.SenderWeight > 0 { + return c.Weight * float64(c.SenderWeight) + } + return c.Weight +} + +// selectAccountLeastRecentlyUsed picks the viable candidate (Weight>0) whose +// sender last_sent_at is oldest; a nil last_sent_at (never used) sorts first. +// Ties broken by account id for determinism. +func selectAccountLeastRecentlyUsed(candidates []AccountCandidate) *AccountCandidate { + var best *AccountCandidate + for i := range candidates { + if candidates[i].Weight <= 0 { + continue + } + if best == nil || lruLess(&candidates[i], best) { + best = &candidates[i] + } + } + return best +} + +func lruLess(a, b *AccountCandidate) bool { + switch { + case a.SenderLastSentAt == nil && b.SenderLastSentAt == nil: + return a.Account.ID.String() < b.Account.ID.String() + case a.SenderLastSentAt == nil: + return true + case b.SenderLastSentAt == nil: + return false + case a.SenderLastSentAt.Equal(*b.SenderLastSentAt): + return a.Account.ID.String() < b.Account.ID.String() + default: + return a.SenderLastSentAt.Before(*b.SenderLastSentAt) + } +} + +// selectAccountRoundRobin picks the viable candidate (Weight>0) with the lowest +// rotation_position; ties broken by account id for determinism. +func selectAccountRoundRobin(candidates []AccountCandidate) *AccountCandidate { + var best *AccountCandidate + for i := range candidates { + if candidates[i].Weight <= 0 { + continue + } + if best == nil || + candidates[i].RotationPosition < best.RotationPosition || + (candidates[i].RotationPosition == best.RotationPosition && + candidates[i].Account.ID.String() < best.Account.ID.String()) { + best = &candidates[i] + } + } + return best +} + +// selectAccountByRotationMode dispatches to the chosen rotation strategy. For +// explicit-strategy campaigns rotationMode is one of weighted / round_robin / +// least_recently_used; anything else falls back to weighted. +func selectAccountByRotationMode(rotationMode string, candidates []AccountCandidate) *AccountCandidate { + switch rotationMode { + case "round_robin": + return selectAccountRoundRobin(candidates) + case "least_recently_used": + return selectAccountLeastRecentlyUsed(candidates) + default: + return selectAccountWeighted(candidates) + } +} diff --git a/internal/scheduler/service.go b/internal/scheduler/service.go index 0927f74b..6391a43f 100644 --- a/internal/scheduler/service.go +++ b/internal/scheduler/service.go @@ -27,6 +27,12 @@ type schedulerService struct { campaignProgressRepo repository.CampaignProgressRepository emailRepo repository.EmailRepository campaignRepo repository.CampaignRepository + // contactRepo is used only to read/cache the recipient ESP/provider for + // ESP matching (no MX dial on the hot path). nil-safe. + contactRepo repository.ContactRepository + // campaignLogRepo records send-path decision logs (e.g. ESP defer, + // new-lead cap). Optional/nil-safe so the scheduler keeps working without it. + campaignLogRepo repository.CampaignLogRepository } // NewSchedulerService creates a new scheduler service @@ -36,6 +42,8 @@ func NewSchedulerService( campaignProgressRepo repository.CampaignProgressRepository, emailRepo repository.EmailRepository, campaignRepo repository.CampaignRepository, + contactRepo repository.ContactRepository, + campaignLogRepo repository.CampaignLogRepository, ) SchedulerService { return &schedulerService{ taskRepo: taskRepo, @@ -43,6 +51,8 @@ func NewSchedulerService( campaignProgressRepo: campaignProgressRepo, emailRepo: emailRepo, campaignRepo: campaignRepo, + contactRepo: contactRepo, + campaignLogRepo: campaignLogRepo, } } diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index 50eabb6b..00d70e01 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -156,7 +156,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // Reschedule to the next day to keep campaign progression alive. nextDay := time.Now().UTC().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(5 * time.Minute) _, _, nextAccountID, calcErr := s.scheduler.CalculateNextCampaignTime(ctx, *campaignTask.CampaignID) - if calcErr == nil { + if calcErr == nil || errors.Is(calcErr, scheduler.ErrCampaignDeferred) { if err := s.createCampaignTask(ctx, campaign.ID, nextAccountID, nextDay); err != nil { log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to create next campaign task after daily limit") } @@ -175,6 +175,22 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { executionStatus = "completed" return nil } + if errors.Is(err, scheduler.ErrCampaignDeferred) { + // A valid contact exists but no eligible mailbox right now (ESP-strict + // has no same-provider mailbox, or the daily new-lead cap is reached). + // Reschedule at the deferred slot WITHOUT sending and WITHOUT touching + // progress / daily counters / rotation — mirrors the daily-limit path. + scheduledNext := nextTime + if scheduledNext.IsZero() { + scheduledNext = time.Now().UTC().Add(1 * time.Hour) + } + if cerr := s.createCampaignTask(ctx, campaign.ID, accountID, scheduledNext); cerr != nil { + log.Warn().Err(cerr).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to schedule deferred campaign task") + } + s.taskRepo.UpdateTaskStatus(ctx, taskID, "completed") + executionStatus = "completed" + return nil + } if errors.Is(err, scheduler.ErrCampaignCompleted) { s.campaignRepo.UpdateStatus(ctx, campaign.ID, "completed") if s.campaignLogRepo != nil { @@ -292,13 +308,31 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { } } - // STEP 11: Add tracking + // STEP 11: Add tracking. Resolve the tracking host once: a VERIFIED + // campaign-scoped override wins, otherwise the mailbox/default domain. + // Only a verified override is honored (an unresolved/unverified host could + // point tracking at a hijackable target — SSRF-adjacent), matching the + // webhook-safety posture. + trackingDomain := account.TrackingDomain + if campaign.TrackingDomain != "" { + if campaign.TrackingDomainVerified { + trackingDomain = campaign.TrackingDomain + } else if s.campaignLogRepo != nil { + s.campaignLogRepo.CreateLog(ctx, &repository.CampaignLogEntry{ + CampaignID: campaign.ID, + EventType: "tracking_domain_unverified", + Message: "Campaign tracking domain not verified; using mailbox default", + Metadata: map[string]interface{}{"tracking_domain": campaign.TrackingDomain}, + }) + } + } + if campaign.OpenTracking && bodyHTML != "" { - bodyHTML = AddOpenTrackingPixel(bodyHTML, taskID, account.TrackingDomain) + bodyHTML = AddOpenTrackingPixel(bodyHTML, taskID, trackingDomain) } if campaign.LinkTracking && bodyHTML != "" { - bodyHTML = WrapLinksForTracking(bodyHTML, taskID, account.TrackingDomain) + bodyHTML = WrapLinksForTracking(bodyHTML, taskID, trackingDomain) } // STEP 12: Add signature @@ -339,13 +373,13 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { // STEP 14: Generate Message-ID messageID := generateMessageID(account.Email) - // STEP 15: Build tracking info + // STEP 15: Build tracking info (worker receives the already-resolved host). var tracking *models.TrackingInfo if campaign.OpenTracking || campaign.LinkTracking { tracking = &models.TrackingInfo{ OpenTracking: campaign.OpenTracking, LinkTracking: campaign.LinkTracking, - TrackingDomain: account.TrackingDomain, + TrackingDomain: trackingDomain, } } @@ -440,6 +474,13 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to record email sent") } + // Bump today's per-campaign counters. newLead counts ONLY a genuinely-sent + // position-1 (first-step) email, so the new-lead/day cap can never under-count + // and over-send. Skipped/suppressed/failed tasks never reach this point. + if err := s.campaignRepo.IncrementCampaignDailySend(ctx, campaign.ID, nextPair.IsNewLead); err != nil { + log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to increment campaign daily send counter") + } + // Publish campaign progress summary to Pub/Sub for real-time dashboard updates if s.streamingPublisher != nil { if progress, pErr := s.campaignProgressRepo.GetCampaignProgress(ctx, campaign.ID); pErr == nil && progress != nil { @@ -467,6 +508,16 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { return errx.InternalError() } + // STEP 18.5: Advance the explicit-sender rotation cursor on a GENUINE send + // only (single atomic UPDATE), so round_robin/least_recently_used cursors + // stay coherent and a send-failure/skip never bumps them. Tag-strategy + // campaigns have no sender rows and skip this. + if campaign.SenderStrategy == "explicit" { + if err := s.campaignRepo.AdvanceCampaignSender(ctx, campaign.ID, account.ID); err != nil { + log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to advance campaign sender cursor") + } + } + // Publish task completion to Pub/Sub if s.streamingPublisher != nil { s.streamingPublisher.PublishTaskStatus(ctx, campaign.UserID, taskID, pubsub.EventTaskCompleted, "Email sent successfully", map[string]string{ diff --git a/internal/utils/validate/campaign.go b/internal/utils/validate/campaign.go index dc328beb..c9f38401 100644 --- a/internal/utils/validate/campaign.go +++ b/internal/utils/validate/campaign.go @@ -1,6 +1,8 @@ package validate import ( + "net" + "strings" "time" "github.com/warmbly/warmbly/internal/bitmask" @@ -57,3 +59,96 @@ func CampaignTime(input string) *errx.Error { } return nil } + +// ── Net-new send-control validators ────────────────────────────────────── + +func CampaignSenderStrategy(s string) *errx.Error { + if s != "tags" && s != "explicit" { + return errx.ErrInvalid + } + return nil +} + +func CampaignRotationMode(s string) *errx.Error { + switch s { + case "weighted", "round_robin", "least_recently_used": + return nil + } + return errx.ErrInvalid +} + +func CampaignSenderWeight(w int) *errx.Error { + if w < 1 || w > 100 { + return errx.New(errx.BadRequest, "sender weight must be between 1 and 100") + } + return nil +} + +// CampaignRamp validates the ramp config. The ceiling<=daily_limit cross-check +// is intentionally NOT enforced here: the scheduler applies the ramp via +// min(daily_limit, ramp_ceiling, per-mailbox cap), so a ceiling above the +// daily limit can only be clamped down, never over-send. +func CampaignRamp(start, increment, ceiling int) *errx.Error { + if start < 1 || start > 100 { + return errx.New(errx.BadRequest, "ramp start must be between 1 and 100") + } + if increment < 0 || increment > 100 { + return errx.New(errx.BadRequest, "ramp increment must be between 0 and 100") + } + if ceiling < 1 || ceiling > 100 { + return errx.New(errx.BadRequest, "ramp ceiling must be between 1 and 100") + } + if start > ceiling { + return errx.New(errx.BadRequest, "ramp start cannot exceed ramp ceiling") + } + return nil +} + +func CampaignESPMatchMode(s string) *errx.Error { + switch s { + case "off", "prefer", "strict": + return nil + } + return errx.ErrInvalid +} + +func CampaignMaxNewLeads(v int) *errx.Error { + if v < 0 || v > 1000 { + return errx.New(errx.BadRequest, "max new leads per day must be between 0 and 1000") + } + return nil +} + +// CampaignTrackingDomain validates a campaign-scoped tracking-domain override. +// Empty means "fall back to the mailbox/default domain". Otherwise it must be a +// bare hostname: no scheme, no path, no raw IP literal, and no internal/metadata +// host — mirroring the mailbox tracking-domain rules and the webhook-SSRF posture. +func CampaignTrackingDomain(host string) *errx.Error { + if host == "" { + return nil + } + if len(host) > 253 || strings.Contains(host, "://") || strings.ContainsAny(host, " \t\r\n/\\?#@:") { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + if net.ParseIP(host) != nil { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + lower := strings.ToLower(host) + if lower == "localhost" || strings.HasSuffix(lower, ".localhost") || lower == "metadata.google.internal" { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + if !strings.Contains(host, ".") { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + for _, label := range strings.Split(host, ".") { + if label == "" || len(label) > 63 { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + for _, c := range label { + if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { + return errx.New(errx.BadRequest, "invalid tracking domain") + } + } + } + return nil +} From 2ad67762d4d80e85a6ba338ff6a45714b5155c96 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:53:42 +0200 Subject: [PATCH 03/60] feat: add dashboard analytics visuals --- web/package.json | 1 + web/pnpm-lock.yaml | 8 + web/src/app/app/analytics/page.tsx | 340 +++++++++++++----- .../app/analytics/AnalyticsShareButton.tsx | 43 +++ .../app/analytics/StatsShareCard.tsx | 84 +++++ web/src/components/ui/charts.tsx | 172 +++++++++ web/src/hooks/useExportCard.ts | 41 +++ .../client/app/analytics/compareCampaigns.ts | 22 +- .../app/analytics/getAccountStatuses.ts | 9 +- .../app/analytics/getCampaignDailyStats.ts | 14 +- .../app/analytics/getCampaignHourlyStats.ts | 13 +- .../api/client/app/analytics/getDashboard.ts | 6 +- .../app/analytics/useCampaignDailyStats.ts | 20 +- .../app/analytics/useCampaignHourlyStats.ts | 10 +- .../app/analytics/useCompareCampaigns.ts | 17 +- .../api/hooks/app/analytics/useDashboard.ts | 6 +- .../models/app/analytics/CampaignAnalytics.ts | 48 ++- .../app/analytics/CampaignComparison.ts | 21 ++ .../api/models/app/analytics/DailyStats.ts | 10 +- .../models/app/analytics/DashboardOverview.ts | 59 ++- .../api/models/app/analytics/HourlyStats.ts | 8 +- 21 files changed, 818 insertions(+), 134 deletions(-) create mode 100644 web/src/components/app/analytics/AnalyticsShareButton.tsx create mode 100644 web/src/components/app/analytics/StatsShareCard.tsx create mode 100644 web/src/components/ui/charts.tsx create mode 100644 web/src/hooks/useExportCard.ts create mode 100644 web/src/lib/api/models/app/analytics/CampaignComparison.ts diff --git a/web/package.json b/web/package.json index 9cdf4df5..93855ce2 100644 --- a/web/package.json +++ b/web/package.json @@ -78,6 +78,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.23.24", + "html-to-image": "^1.11.13", "immer": "^11.1.3", "input-otp": "^1.4.2", "lucide-react": "^0.563.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9af70661..0675a246 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: framer-motion: specifier: ^12.23.24 version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 immer: specifier: ^11.1.3 version: 11.1.3 @@ -2508,6 +2511,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -5577,6 +5583,8 @@ snapshots: html-escaper@2.0.2: {} + html-to-image@1.11.13: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 diff --git a/web/src/app/app/analytics/page.tsx b/web/src/app/app/analytics/page.tsx index 398a3ae2..99fa7649 100644 --- a/web/src/app/app/analytics/page.tsx +++ b/web/src/app/app/analytics/page.tsx @@ -1,4 +1,17 @@ -import { ArrowUpRightIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { + ActivityIcon, + AlertTriangleIcon, + ArrowUpRightIcon, + Loader2Icon, + MailCheckIcon, + MousePointerClickIcon, + RefreshCcwIcon, + ReplyIcon, + SendIcon, + TriangleAlertIcon, +} from "lucide-react"; import { EmptyBlock, Page, @@ -8,107 +21,251 @@ import { Stat, StatStrip, } from "@/components/layout/Page"; -import { useState } from "react"; +import { DailyBars, type ChartPoint } from "@/components/ui/charts"; +import AnalyticsShareButton from "@/components/app/analytics/AnalyticsShareButton"; +import useDashboard from "@/lib/api/hooks/app/analytics/useDashboard"; -const stats = [ - { label: "Total sent", value: "—", sub: "last 7 days" }, - { label: "Open rate", value: "—%", sub: "after delivery" }, - { label: "Reply rate", value: "—%", sub: "incl. positive" }, - { label: "Bounce rate", value: "—%", sub: "hard + soft" }, +type Range = "7d" | "30d" | "90d"; +type Metric = "sent" | "opens" | "clicks" | "replies"; + +const RANGE_LABEL: Record = { + "7d": "Last 7 days", + "30d": "Last 30 days", + "90d": "Last 90 days", +}; + +const METRICS: { key: Metric; label: string; bar: string }[] = [ + { key: "sent", label: "Sent", bar: "bg-sky-500" }, + { key: "opens", label: "Opens", bar: "bg-emerald-500" }, + { key: "clicks", label: "Clicks", bar: "bg-violet-500" }, + { key: "replies", label: "Replies", bar: "bg-amber-500" }, ]; -const breakdown = [ - { label: "Delivered", dot: "bg-emerald-500" }, - { label: "Opened", dot: "bg-sky-500" }, - { label: "Replied", dot: "bg-violet-500" }, - { label: "Bounced", dot: "bg-amber-500" }, - { label: "Spam", dot: "bg-red-500" }, -]; +function pct(v: number | undefined): string { + return v == null ? "—" : `${v.toFixed(1)}%`; +} +function num(v: number | undefined): string { + return (v ?? 0).toLocaleString(); +} export default function AnalyticsPage() { - const [range, setRange] = useState<"7d" | "30d" | "90d">("7d"); + const [range, setRange] = useState("7d"); + const [metric, setMetric] = useState("sent"); + const dash = useDashboard(range); + const d = dash.data; + const os = d?.overall_stats; + + const series: ChartPoint[] = useMemo( + () => (d?.daily_trend ?? []).map((p) => ({ label: p.date, value: p[metric] ?? 0 })), + [d?.daily_trend, metric], + ); + + const breakdown = [ + { label: "Sent", value: os?.total_emails_sent, icon: SendIcon, dot: "bg-slate-400" }, + { label: "Opens", value: os?.total_opens, icon: MailCheckIcon, dot: "bg-emerald-500" }, + { label: "Clicks", value: os?.total_clicks, icon: MousePointerClickIcon, dot: "bg-violet-500" }, + { label: "Replies", value: os?.total_replies, icon: ReplyIcon, dot: "bg-amber-500" }, + { label: "Bounces", value: os?.total_bounces, icon: TriangleAlertIcon, dot: "bg-rose-500" }, + ]; + + const shareData = { + title: "Workspace performance", + subtitle: RANGE_LABEL[range], + metrics: [ + { label: "Sent", value: num(os?.total_emails_sent), sub: "emails" }, + { label: "Open rate", value: pct(os?.open_rate) }, + { label: "Reply rate", value: pct(os?.reply_rate) }, + { label: "Bounce rate", value: pct(os?.bounce_rate) }, + ], + daily: (d?.daily_trend ?? []).map((p) => ({ label: p.date, value: p.sent })), + }; + return ( - + + - {stats.map((s, i) => ( - - ))} + 0} /> + + + -
-
- -
-
- {[...Array(28)].map((_, i) => ( -
-
+ dash.refetch()} isRefetching={dash.isFetching} /> + + ) : ( + <> +
+
+ +
+ {METRICS.map((m) => ( + + ))} +
+
+
+ {dash.isPending ? ( +
+ ) : ( + m.key === metric)?.bar} + emptyLabel="No sends in this window yet" /> + )} +
+
+ + +
+ + + + All campaigns + + + + {dash.isPending ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
))}
-
- 7d ago - today + ) : (d?.top_campaigns?.length ?? 0) === 0 ? ( + + ) : ( +
+ {d!.top_campaigns.map((c) => { + const dot = + c.status === "active" ? "bg-emerald-500" : c.status === "paused" ? "bg-amber-500" : "bg-slate-300"; + return ( + + + {c.name} + + {num(c.emails_sent)} sent + {pct(c.open_rate)} open + {pct(c.reply_rate)} reply + + + ); + })}
-
-
+ )} - -
- - - - All accounts - - - - - - + ) : (d?.recent_activity?.length ?? 0) === 0 ? ( + + ) : ( +
+ {d!.recent_activity.map((a, i) => ( + + ))} +
+ )} + + + )}
); } -function RangeTabs({ value, onChange }: { value: "7d" | "30d" | "90d"; onChange: (v: "7d" | "30d" | "90d") => void }) { - const opts: ("7d" | "30d" | "90d")[] = ["7d", "30d", "90d"]; +function HealthCell({ n, label, tone }: { n: number; label: string; tone: string }) { + return ( +
+
{n}
+
{label}
+
+ ); +} + +const ACTIVITY_TONE: Record = { + sent: { tone: "text-slate-500", verb: "Sent to" }, + opened: { tone: "text-emerald-600", verb: "Opened by" }, + clicked: { tone: "text-violet-600", verb: "Clicked by" }, + replied: { tone: "text-amber-600", verb: "Reply from" }, + bounced: { tone: "text-rose-600", verb: "Bounced" }, +}; + +function ActivityRow({ a }: { a: { type: string; campaign_name: string; contact_email: string; timestamp: string } }) { + const meta = ACTIVITY_TONE[a.type] ?? { tone: "text-slate-500", verb: a.type }; + return ( +
+ + + {meta.verb} {a.contact_email} + + {a.campaign_name} + + {new Date(a.timestamp).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })} + +
+ ); +} + +function RangeTabs({ value, onChange }: { value: Range; onChange: (v: Range) => void }) { + const opts: Range[] = ["7d", "30d", "90d"]; return (
{opts.map((o) => ( @@ -116,9 +273,7 @@ function RangeTabs({ value, onChange }: { value: "7d" | "30d" | "90d"; onChange: key={o} onClick={() => onChange(o)} className={`h-6 px-2 rounded text-[11px] font-medium tabular-nums transition-colors ${ - value === o - ? "bg-slate-900 text-white" - : "text-slate-500 hover:text-slate-900" + value === o ? "bg-slate-900 text-white" : "text-slate-500 hover:text-slate-900" }`} > {o} @@ -127,3 +282,26 @@ function RangeTabs({ value, onChange }: { value: "7d" | "30d" | "90d"; onChange:
); } + +function ErrorState({ onRetry, isRefetching }: { onRetry: () => void; isRefetching: boolean }) { + return ( +
+
+ +
+

Couldn't load analytics

+

+ The request failed. The backend may be down or returning an error. +

+ +
+ ); +} diff --git a/web/src/components/app/analytics/AnalyticsShareButton.tsx b/web/src/components/app/analytics/AnalyticsShareButton.tsx new file mode 100644 index 00000000..eab5299e --- /dev/null +++ b/web/src/components/app/analytics/AnalyticsShareButton.tsx @@ -0,0 +1,43 @@ +import { useRef } from "react"; +import { DownloadIcon, Loader2Icon } from "lucide-react"; +import useExportCard from "@/hooks/useExportCard"; +import StatsShareCard, { type ShareCardData } from "./StatsShareCard"; + +// Drops a "Share image" button next to any analytics surface. It keeps a +// branded mounted off-viewport (real layout, so the capture +// isn't blank) and rasterizes it to a downloadable PNG on click. +export default function AnalyticsShareButton({ + data, + filename, + label = "Share image", +}: { + data: ShareCardData; + filename: string; + label?: string; +}) { + const ref = useRef(null); + const { exporting, exportPng } = useExportCard(); + + return ( + <> + + + {/* Off-viewport but laid out, so html-to-image captures real pixels. */} +
+ +
+ + ); +} diff --git a/web/src/components/app/analytics/StatsShareCard.tsx b/web/src/components/app/analytics/StatsShareCard.tsx new file mode 100644 index 00000000..60037bed --- /dev/null +++ b/web/src/components/app/analytics/StatsShareCard.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { Logo } from "@/components/svg"; +import { DailyBars, type ChartPoint } from "@/components/ui/charts"; + +// A fixed-size, branded analytics card meant to be rasterized to a shareable +// PNG (see useExportCard). It mirrors the live dashboard's slate/sky language +// so the export looks like Warmbly, not a generic screenshot. Rendered +// off-viewport by AnalyticsShareButton. + +export interface ShareMetric { + label: string; + value: string; + sub?: string; +} + +export interface ShareCardData { + title: string; + subtitle?: string; + metrics: ShareMetric[]; // up to 4 + daily: ChartPoint[]; // primary "sent" series +} + +const StatsShareCard = React.forwardRef( + function StatsShareCard({ data }, ref) { + const metrics = data.metrics.slice(0, 4); + return ( +
+ {/* header */} +
+ + Warmbly + {data.subtitle && ( + + {data.subtitle} + + )} +
+ +

+ {data.title} +

+ + {/* metric grid */} +
+ {metrics.map((m) => ( +
+
+ {m.label} +
+
+ {m.value} +
+ {m.sub &&
{m.sub}
} +
+ ))} +
+ + {/* chart snapshot */} +
+
+ Sends over time +
+ +
+ + {/* footer */} +
+ warmbly.com + {todayLabel()} +
+
+ ); + }, +); + +function todayLabel(): string { + return new Date().toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); +} + +export default StatsShareCard; diff --git a/web/src/components/ui/charts.tsx b/web/src/components/ui/charts.tsx new file mode 100644 index 00000000..e695c8c3 --- /dev/null +++ b/web/src/components/ui/charts.tsx @@ -0,0 +1,172 @@ +// Library-free, on-theme charts shared across the analytics + campaign +// surfaces. The dashboard ships no charting dependency on purpose (keeps the +// bundle small and the visuals consistent with the hairline chrome), so these +// are hand-rolled SVG/flex primitives. +// +// The key idea: a chart ALWAYS occupies its footprint. When there's no data we +// render a dashed "empty frame" with a faint baseline + caption rather than +// hiding the graph — so the user sees an empty graph, never a blank gap. + +import React from "react"; +import { cn } from "@/lib/utils"; + +export interface ChartPoint { + /** x-tick source — typically an ISO date string. */ + label: string; + value: number; +} + +function shortDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } catch { + return iso; + } +} + +/** + * EmptyChart — preserves the chart's footprint and reads as an empty graph + * (dashed border + faint baseline + centered caption) instead of vanishing. + */ +export function EmptyChart({ + height = 200, + label = "No data in this window", + className, +}: { + height?: number; + label?: string; + className?: string; +}) { + return ( +
+ {/* faint baseline so the frame still reads as an axis */} +
+
+ {label} +
+
+ ); +} + +/** + * DailyBars — single-series vertical bar chart with date ticks + hover tooltip. + * Falls back to when there is no data (or the series is all zero), + * keeping the same height so the layout never jumps. + */ +export function DailyBars({ + points, + height = 200, + barClass = "bg-sky-500", + emptyLabel = "No activity yet", + formatValue = (v: number) => v.toLocaleString(), + className, +}: { + points: ChartPoint[]; + height?: number; + barClass?: string; + emptyLabel?: string; + formatValue?: (v: number) => string; + className?: string; +}) { + const [hover, setHover] = React.useState(null); + + const total = points.reduce((s, p) => s + (p.value || 0), 0); + const isEmpty = points.length === 0 || total === 0; + const max = Math.max(1, ...points.map((p) => p.value || 0)); + + if (isEmpty) return ; + + const tickH = 18; + const barAreaH = height - tickH - 4; + + return ( +
+
+ {points.map((p, i) => { + const h = p.value > 0 ? Math.max(2, (p.value / max) * barAreaH) : 0; + return ( +
setHover(i)} + onMouseLeave={() => setHover(null)} + > +
+ {hover === i && ( +
+ {formatValue(p.value)} · {shortDate(p.label)} +
+ )} +
+ ); + })} +
+
+
+ {shortDate(points[0].label)} + {shortDate(points[points.length - 1].label)} +
+
+ ); +} + +/** + * Sparkline — smooth single-series line + gradient fill for inline trends. + * On-theme (sky) and library-free; mirrors the api-keys Sparkline but lives + * here so analytics/campaign code shares one implementation. + */ +export function Sparkline({ + values, + width = 120, + height = 28, + stroke = "#0284c7", + className, +}: { + values: number[]; + width?: number; + height?: number; + stroke?: string; + className?: string; +}) { + if (!values || values.length === 0) { + return
; + } + + const max = Math.max(1, ...values); + const pad = 2; + const w = width - pad * 2; + const h = height - pad * 2; + const step = values.length > 1 ? w / (values.length - 1) : 0; + const points = values.map((v, i) => [pad + i * step, pad + h - (v / max) * h] as const); + const linePath = points + .map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`) + .join(" "); + const areaPath = `${linePath} L ${pad + (values.length - 1) * step} ${pad + h} L ${pad} ${pad + h} Z`; + const gid = `spark-${stroke.replace("#", "")}`; + + return ( + + + + + + + + + + + ); +} diff --git a/web/src/hooks/useExportCard.ts b/web/src/hooks/useExportCard.ts new file mode 100644 index 00000000..62ce7759 --- /dev/null +++ b/web/src/hooks/useExportCard.ts @@ -0,0 +1,41 @@ +import { useCallback, useState } from "react"; +import { toPng } from "html-to-image"; +import { downloadBlob } from "@/lib/api/client/app/contacts/exportContacts"; + +// Rasterize an offscreen DOM node to a crisp PNG and trigger a download. +// +// We use html-to-image (not html2canvas): the dashboard theme is Tailwind v4 +// with oklch() colors, which html2canvas can't parse. html-to-image renders the +// node through an SVG via the real browser engine, so oklch, +// CSS variables and gradients all rasterize correctly. +// +// Two passes: the first primes the embedded-font cache (a freshly-serialized +// foreignObject can render the first frame with fallback fonts); the second is +// the real capture. pixelRatio 3 keeps it sharp when shared/zoomed. +export default function useExportCard() { + const [exporting, setExporting] = useState(false); + + const exportPng = useCallback( + async (node: HTMLElement | null, filename: string) => { + if (!node || exporting) return; + setExporting(true); + try { + try { + await document.fonts?.ready; + } catch { + /* fonts.ready is best-effort */ + } + const opts = { pixelRatio: 3, backgroundColor: "#ffffff", cacheBust: true }; + await toPng(node, opts); // prime font/embed cache + const dataUrl = await toPng(node, opts); // real capture + const blob = await (await fetch(dataUrl)).blob(); + downloadBlob(blob, filename); + } finally { + setExporting(false); + } + }, + [exporting], + ); + + return { exporting, exportPng }; +} diff --git a/web/src/lib/api/client/app/analytics/compareCampaigns.ts b/web/src/lib/api/client/app/analytics/compareCampaigns.ts index 6e23d863..63bef85c 100644 --- a/web/src/lib/api/client/app/analytics/compareCampaigns.ts +++ b/web/src/lib/api/client/app/analytics/compareCampaigns.ts @@ -1,15 +1,21 @@ -import type CampaignAnalytics from "@/lib/api/models/app/analytics/CampaignAnalytics"; +import type CampaignComparison from "@/lib/api/models/app/analytics/CampaignComparison"; import Request from "../../Request"; -export default async function compareCampaigns(campaignIds: string[]): Promise { +// GET /analytics/campaigns/compare?ids=a,b,c&from&to. The backend reads `ids` +// (NOT campaign_ids) and REQUIRES from/to; returns a single { campaigns, period }. +export default async function compareCampaigns( + campaignIds: string[], + from: string, + to: string, +): Promise { const params = new URLSearchParams(); - if (campaignIds.length > 0) params.append("campaign_ids", campaignIds.join(",")); - const queryString = params.toString(); - const url = `/analytics/campaigns/compare${queryString ? `?${queryString}` : ""}`; + if (campaignIds.length > 0) params.append("ids", campaignIds.join(",")); + params.append("from", from); + params.append("to", to); - return await Request({ + return await Request({ method: "GET", - url, + url: `/analytics/campaigns/compare?${params.toString()}`, authorization: true, - }) + }); } diff --git a/web/src/lib/api/client/app/analytics/getAccountStatuses.ts b/web/src/lib/api/client/app/analytics/getAccountStatuses.ts index 68e76c04..a6099416 100644 --- a/web/src/lib/api/client/app/analytics/getAccountStatuses.ts +++ b/web/src/lib/api/client/app/analytics/getAccountStatuses.ts @@ -2,9 +2,16 @@ import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; import Request from "../../Request"; export default async function getAccountStatuses(): Promise { - return await Request({ + // The backend wraps list responses in a `{ data: [...] }` envelope, and the + // shared Request helper returns the raw body (it does not unwrap). Mirror the + // convention used by the other list clients (getMyInvitations, listTemplates) + // so callers always receive a real array — otherwise `for…of` over the + // envelope object throws "{} is not iterable" on the accounts page. + const res = await Request<{ data: AccountStatus[] | null } | AccountStatus[]>({ method: "GET", url: `/analytics/accounts`, authorization: true, }) + if (Array.isArray(res)) return res + return res?.data ?? [] } diff --git a/web/src/lib/api/client/app/analytics/getCampaignDailyStats.ts b/web/src/lib/api/client/app/analytics/getCampaignDailyStats.ts index 2ba0767c..5647cb4a 100644 --- a/web/src/lib/api/client/app/analytics/getCampaignDailyStats.ts +++ b/web/src/lib/api/client/app/analytics/getCampaignDailyStats.ts @@ -1,10 +1,16 @@ import type DailyStats from "@/lib/api/models/app/analytics/DailyStats"; import Request from "../../Request"; -export default async function getCampaignDailyStats(id: string): Promise { - return await Request({ +// GET /analytics/campaigns/:id/daily?from&to — the backend REQUIRES both date +// params (YYYY-MM-DD) and returns 400 without them. Returns a { data: [...] } +// envelope, which we unwrap to a real array. +export default async function getCampaignDailyStats(id: string, from: string, to: string): Promise { + const params = new URLSearchParams({ from, to }); + const res = await Request<{ data: DailyStats[] | null } | DailyStats[]>({ method: "GET", - url: `/analytics/campaigns/${id}/daily`, + url: `/analytics/campaigns/${id}/daily?${params.toString()}`, authorization: true, - }) + }); + if (Array.isArray(res)) return res; + return res?.data ?? []; } diff --git a/web/src/lib/api/client/app/analytics/getCampaignHourlyStats.ts b/web/src/lib/api/client/app/analytics/getCampaignHourlyStats.ts index 4e2ddcaf..4fe852fe 100644 --- a/web/src/lib/api/client/app/analytics/getCampaignHourlyStats.ts +++ b/web/src/lib/api/client/app/analytics/getCampaignHourlyStats.ts @@ -1,10 +1,15 @@ import type HourlyStats from "@/lib/api/models/app/analytics/HourlyStats"; import Request from "../../Request"; -export default async function getCampaignHourlyStats(id: string): Promise { - return await Request({ +// GET /analytics/campaigns/:id/hourly?date=YYYY-MM-DD — pass the day explicitly +// (the backend keys hourly buckets on a date). Returns a { data: [...] } envelope. +export default async function getCampaignHourlyStats(id: string, date: string): Promise { + const params = new URLSearchParams({ date }); + const res = await Request<{ data: HourlyStats[] | null } | HourlyStats[]>({ method: "GET", - url: `/analytics/campaigns/${id}/hourly`, + url: `/analytics/campaigns/${id}/hourly?${params.toString()}`, authorization: true, - }) + }); + if (Array.isArray(res)) return res; + return res?.data ?? []; } diff --git a/web/src/lib/api/client/app/analytics/getDashboard.ts b/web/src/lib/api/client/app/analytics/getDashboard.ts index 25097d79..fd12738c 100644 --- a/web/src/lib/api/client/app/analytics/getDashboard.ts +++ b/web/src/lib/api/client/app/analytics/getDashboard.ts @@ -1,10 +1,12 @@ import type DashboardOverview from "@/lib/api/models/app/analytics/DashboardOverview"; import Request from "../../Request"; -export default async function getDashboard(): Promise { +// Workspace dashboard analytics. Returned as a bare object (no {data} envelope), +// so no unwrap is needed here — but the period must be forwarded. +export default async function getDashboard(period: string = "7d"): Promise { return await Request({ method: "GET", - url: `/analytics/dashboard`, + url: `/analytics/dashboard?period=${encodeURIComponent(period)}`, authorization: true, }) } diff --git a/web/src/lib/api/hooks/app/analytics/useCampaignDailyStats.ts b/web/src/lib/api/hooks/app/analytics/useCampaignDailyStats.ts index ecc8a43b..2408598f 100644 --- a/web/src/lib/api/hooks/app/analytics/useCampaignDailyStats.ts +++ b/web/src/lib/api/hooks/app/analytics/useCampaignDailyStats.ts @@ -1,10 +1,22 @@ import { useQuery } from "@tanstack/react-query"; import getCampaignDailyStats from "@/lib/api/client/app/analytics/getCampaignDailyStats"; -export default function useCampaignDailyStats(id: string) { +function isoDay(d: Date): string { + return d.toISOString().slice(0, 10); +} + +// Defaults to the trailing `days`-day window; the backend requires from/to. +// The realtime invalidation key ["analytics","campaigns",id,"daily"] still +// matches this (prefix) so live events refresh the chart. +export default function useCampaignDailyStats(id: string, days: number = 30) { + const to = new Date(); + const from = new Date(); + from.setUTCDate(from.getUTCDate() - (days - 1)); + const fromStr = isoDay(from); + const toStr = isoDay(to); return useQuery({ - queryKey: ["analytics", "campaigns", id, "daily"], - queryFn: () => getCampaignDailyStats(id), + queryKey: ["analytics", "campaigns", id, "daily", fromStr, toStr], + queryFn: () => getCampaignDailyStats(id, fromStr, toStr), enabled: !!id, - }) + }); } diff --git a/web/src/lib/api/hooks/app/analytics/useCampaignHourlyStats.ts b/web/src/lib/api/hooks/app/analytics/useCampaignHourlyStats.ts index 8e2dc237..e48b9111 100644 --- a/web/src/lib/api/hooks/app/analytics/useCampaignHourlyStats.ts +++ b/web/src/lib/api/hooks/app/analytics/useCampaignHourlyStats.ts @@ -1,10 +1,12 @@ import { useQuery } from "@tanstack/react-query"; import getCampaignHourlyStats from "@/lib/api/client/app/analytics/getCampaignHourlyStats"; -export default function useCampaignHourlyStats(id: string) { +// Defaults to today (UTC). Pass a YYYY-MM-DD date to inspect another day. +export default function useCampaignHourlyStats(id: string, date?: string) { + const day = date ?? new Date().toISOString().slice(0, 10); return useQuery({ - queryKey: ["analytics", "campaigns", id, "hourly"], - queryFn: () => getCampaignHourlyStats(id), + queryKey: ["analytics", "campaigns", id, "hourly", day], + queryFn: () => getCampaignHourlyStats(id, day), enabled: !!id, - }) + }); } diff --git a/web/src/lib/api/hooks/app/analytics/useCompareCampaigns.ts b/web/src/lib/api/hooks/app/analytics/useCompareCampaigns.ts index 318ad35e..0159f548 100644 --- a/web/src/lib/api/hooks/app/analytics/useCompareCampaigns.ts +++ b/web/src/lib/api/hooks/app/analytics/useCompareCampaigns.ts @@ -1,10 +1,19 @@ import { useQuery } from "@tanstack/react-query"; import compareCampaigns from "@/lib/api/client/app/analytics/compareCampaigns"; -export default function useCompareCampaigns(campaignIds: string[]) { +function isoDay(d: Date): string { + return d.toISOString().slice(0, 10); +} + +export default function useCompareCampaigns(campaignIds: string[], days: number = 30) { + const to = new Date(); + const from = new Date(); + from.setUTCDate(from.getUTCDate() - (days - 1)); + const fromStr = isoDay(from); + const toStr = isoDay(to); return useQuery({ - queryKey: ["analytics", "campaigns", "compare", campaignIds], - queryFn: () => compareCampaigns(campaignIds), + queryKey: ["analytics", "campaigns", "compare", campaignIds, fromStr, toStr], + queryFn: () => compareCampaigns(campaignIds, fromStr, toStr), enabled: campaignIds.length > 0, - }) + }); } diff --git a/web/src/lib/api/hooks/app/analytics/useDashboard.ts b/web/src/lib/api/hooks/app/analytics/useDashboard.ts index 3a543960..066bbb31 100644 --- a/web/src/lib/api/hooks/app/analytics/useDashboard.ts +++ b/web/src/lib/api/hooks/app/analytics/useDashboard.ts @@ -1,9 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import getDashboard from "@/lib/api/client/app/analytics/getDashboard"; -export default function useDashboard() { +export default function useDashboard(period: string = "7d") { return useQuery({ - queryKey: ["analytics", "dashboard"], - queryFn: () => getDashboard(), + queryKey: ["analytics", "dashboard", period], + queryFn: () => getDashboard(period), }) } diff --git a/web/src/lib/api/models/app/analytics/CampaignAnalytics.ts b/web/src/lib/api/models/app/analytics/CampaignAnalytics.ts index cda14d60..801a06e4 100644 --- a/web/src/lib/api/models/app/analytics/CampaignAnalytics.ts +++ b/web/src/lib/api/models/app/analytics/CampaignAnalytics.ts @@ -1,12 +1,46 @@ -export default interface CampaignAnalytics { - campaign_id: string - total_sent: number - total_opened: number - total_clicked: number - total_replied: number - total_bounced: number +import type DailyStats from "./DailyStats" + +// GET /analytics/campaigns/:id — a single (un-enveloped) object that mirrors +// the backend models.CampaignAnalytics. The previous flat shape (total_sent…) +// never matched the wire body, so every field read came back undefined. + +export interface DateRange { + from: string + to: string +} + +export interface CampaignSummary { + total_contacts: number + emails_sent: number + emails_pending: number + unique_opens: number + unique_clicks: number + replies: number + bounces: number + unsubscribes: number open_rate: number click_rate: number reply_rate: number bounce_rate: number } + +export interface SequenceStats { + sequence_id: string + name: string + position: number + emails_sent: number + opens: number + clicks: number + replies: number + bounces: number +} + +export default interface CampaignAnalytics { + campaign_id: string + name: string + status: string + date_range: DateRange + summary: CampaignSummary + sequences: SequenceStats[] + daily_stats?: DailyStats[] +} diff --git a/web/src/lib/api/models/app/analytics/CampaignComparison.ts b/web/src/lib/api/models/app/analytics/CampaignComparison.ts new file mode 100644 index 00000000..91130c71 --- /dev/null +++ b/web/src/lib/api/models/app/analytics/CampaignComparison.ts @@ -0,0 +1,21 @@ +import type { DateRange } from "./CampaignAnalytics" + +// GET /analytics/campaigns/compare?ids=a,b,c — a single (un-enveloped) object +// mirroring backend models.CampaignComparison. Compare returns an object with +// a `campaigns` array, NOT a bare array. + +export interface CampaignComparisonItem { + campaign_id: string + name: string + status: string + emails_sent: number + open_rate: number + click_rate: number + reply_rate: number + bounce_rate: number +} + +export default interface CampaignComparison { + campaigns: CampaignComparisonItem[] + period: DateRange +} diff --git a/web/src/lib/api/models/app/analytics/DailyStats.ts b/web/src/lib/api/models/app/analytics/DailyStats.ts index 4eee3f5b..e5e681e5 100644 --- a/web/src/lib/api/models/app/analytics/DailyStats.ts +++ b/web/src/lib/api/models/app/analytics/DailyStats.ts @@ -1,8 +1,10 @@ +// Per-day campaign series. Mirrors the backend models.CampaignDailyStats +// (and DashboardDailyStats) JSON tags exactly — note the backend uses +// `opens`/`clicks`/`replies`, not `opened`/`clicked`/`replied`. export default interface DailyStats { date: string sent: number - opened: number - clicked: number - replied: number - bounced: number + opens: number + clicks: number + replies: number } diff --git a/web/src/lib/api/models/app/analytics/DashboardOverview.ts b/web/src/lib/api/models/app/analytics/DashboardOverview.ts index 97d89752..a1502560 100644 --- a/web/src/lib/api/models/app/analytics/DashboardOverview.ts +++ b/web/src/lib/api/models/app/analytics/DashboardOverview.ts @@ -1,10 +1,61 @@ -export default interface DashboardOverview { - total_campaigns: number - active_campaigns: number +// GET /analytics/dashboard?period=7d|30d|90d — a single (un-enveloped) object +// mirroring the backend models.DashboardAnalytics. The previous flat shape +// (total_campaigns/total_contacts…) did not match the wire body. + +export interface DashboardOverallStats { total_emails_sent: number - total_contacts: number + total_opens: number + total_clicks: number + total_replies: number + total_bounces: number open_rate: number click_rate: number reply_rate: number bounce_rate: number + active_campaigns: number + active_accounts: number +} + +export interface RecentActivityItem { + type: string // sent | opened | clicked | replied | bounced + campaign_id: string + campaign_name: string + contact_email: string + contact_id?: string + timestamp: string + link?: string +} + +export interface TopCampaignStats { + campaign_id: string + name: string + status: string + emails_sent: number + open_rate: number + click_rate: number + reply_rate: number +} + +export interface AccountHealthSummary { + total_accounts: number + healthy_accounts: number + warning_accounts: number + error_accounts: number +} + +export interface DashboardDailyStats { + date: string + sent: number + opens: number + clicks: number + replies: number +} + +export default interface DashboardOverview { + period: string + overall_stats: DashboardOverallStats + recent_activity: RecentActivityItem[] + top_campaigns: TopCampaignStats[] + account_health: AccountHealthSummary + daily_trend: DashboardDailyStats[] } diff --git a/web/src/lib/api/models/app/analytics/HourlyStats.ts b/web/src/lib/api/models/app/analytics/HourlyStats.ts index ba0b4358..7fb224e7 100644 --- a/web/src/lib/api/models/app/analytics/HourlyStats.ts +++ b/web/src/lib/api/models/app/analytics/HourlyStats.ts @@ -1,8 +1,8 @@ +// Per-hour campaign series (0-23). Mirrors backend models.CampaignHourlyStats. export default interface HourlyStats { hour: number sent: number - opened: number - clicked: number - replied: number - bounced: number + opens: number + clicks: number + replies: number } From ef58d649881ed8f0183727eff800e98f7a3bf5ae Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:53:56 +0200 Subject: [PATCH 04/60] feat: add campaign send control settings --- .../app/campaigns/[id]/preferences/page.tsx | 190 +++++++--- .../preferences/CampaignAppearance.tsx | 208 +++++++---- .../preferences/CampaignContactOrder.tsx | 275 +++++++------- .../campaigns/preferences/CampaignEmails.tsx | 348 ++++++++++++++---- .../components/CampaignPreferenceBoolBox.tsx | 185 +++++++++- .../client/app/campaigns/createCampaign.ts | 12 + .../app/campaigns/getCampaignSenders.ts | 14 + .../app/campaigns/replaceCampaignSenders.ts | 18 + .../campaigns/verifyCampaignTrackingDomain.ts | 19 + .../hooks/app/campaigns/useCampaignSenders.ts | 10 + .../campaigns/useReplaceCampaignSenders.ts | 13 + .../useVerifyCampaignTrackingDomain.ts | 13 + .../lib/api/models/app/campaigns/Campaign.ts | 41 +++ 13 files changed, 994 insertions(+), 352 deletions(-) create mode 100644 web/src/lib/api/client/app/campaigns/getCampaignSenders.ts create mode 100644 web/src/lib/api/client/app/campaigns/replaceCampaignSenders.ts create mode 100644 web/src/lib/api/client/app/campaigns/verifyCampaignTrackingDomain.ts create mode 100644 web/src/lib/api/hooks/app/campaigns/useCampaignSenders.ts create mode 100644 web/src/lib/api/hooks/app/campaigns/useReplaceCampaignSenders.ts create mode 100644 web/src/lib/api/hooks/app/campaigns/useVerifyCampaignTrackingDomain.ts diff --git a/web/src/app/app/campaigns/[id]/preferences/page.tsx b/web/src/app/app/campaigns/[id]/preferences/page.tsx index f1715c00..f9907ede 100644 --- a/web/src/app/app/campaigns/[id]/preferences/page.tsx +++ b/web/src/app/app/campaigns/[id]/preferences/page.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { ListOrderedIcon, SettingsIcon, SlidersHorizontalIcon } from "lucide-react"; import { Loading } from "@/components/loader"; import { useCampaign } from "@/hooks/context/campaign"; import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; @@ -10,117 +11,186 @@ import buildError from "@/lib/helper/buildError"; import CampaignEmails from "@/components/app/campaigns/preferences/CampaignEmails"; import CampaignContactOrder from "@/components/app/campaigns/preferences/CampaignContactOrder"; +const DAILY_MIN = 3; +const DAILY_MAX = 100; + +const TABS = [ + { key: "standard", title: "Standard", Icon: SettingsIcon }, + { key: "advanced", title: "Advanced", Icon: SlidersHorizontalIcon }, + { key: "order", title: "Contact order", Icon: ListOrderedIcon }, +] as const; + +type TabKey = (typeof TABS)[number]["key"]; + export default function CampaignPreferences() { - const campaign = useCampaign() + const campaign = useCampaign(); if (!campaign) { - throw new Error("CampaignPreferences cannot be rendered without a campaign") + throw new Error("CampaignPreferences cannot be rendered without a campaign"); } const updateCampaign = useUpdateCampaign(campaign.id); - const [loading, setLoading] = React.useState(false); - const [activeTab, setActiveTab] = React.useState("tab1"); + const [loading, setLoading] = React.useState(false); + const [activeTab, setActiveTab] = React.useState("standard"); const [newData, setNewData] = React.useState(campaign); React.useEffect(() => { if (!campaign) return; setNewData(campaign); - }, [campaign]) + }, [campaign]); - const tabData = { - ...(campaign && { - tab1: { - title: "Appearance", - content: , - }, - tab2: { - title: "Campaign Emails", - content: , - }, - tab3: { - title: "Contact Order", - content: , - }, - }), - }; - - const getChanges = () => { + const getChanges = (): Partial => { if (!newData) return {}; return { ...(newData.name !== campaign.name && { name: newData.name }), ...(newData.description !== campaign.description && { description: newData.description }), + // Standard — sending + deliverability + ...(newData.email_tags !== campaign.email_tags && { email_tags: newData.email_tags }), + ...(newData.daily_limit !== campaign.daily_limit && { daily_limit: newData.daily_limit }), + ...(newData.stop_on_reply !== campaign.stop_on_reply && { stop_on_reply: newData.stop_on_reply }), ...(newData.text_only !== campaign.text_only && { text_only: newData.text_only }), ...(newData.open_tracking !== campaign.open_tracking && { open_tracking: newData.open_tracking }), ...(newData.link_tracking !== campaign.link_tracking && { link_tracking: newData.link_tracking }), + ...(newData.unsubscribe_header !== campaign.unsubscribe_header && { + unsubscribe_header: newData.unsubscribe_header, + }), - ...(newData.email_tags !== campaign.email_tags && { email_tags: newData.email_tags }), - ...(newData.daily_limit !== campaign.daily_limit && { daily_limit: newData.daily_limit }), + // Advanced — sender selection + rotation + ...(newData.sender_strategy !== campaign.sender_strategy && { + sender_strategy: newData.sender_strategy, + }), + ...(newData.rotation_mode !== campaign.rotation_mode && { rotation_mode: newData.rotation_mode }), - ...(newData.unsubscribe_header !== campaign.unsubscribe_header && { unsubscribe_header: newData.unsubscribe_header }), + // Advanced — ramp-up + ...(newData.ramp_enabled !== campaign.ramp_enabled && { ramp_enabled: newData.ramp_enabled }), + ...(newData.ramp_start !== campaign.ramp_start && { ramp_start: newData.ramp_start }), + ...(newData.ramp_increment !== campaign.ramp_increment && { ramp_increment: newData.ramp_increment }), + ...(newData.ramp_ceiling !== campaign.ramp_ceiling && { ramp_ceiling: newData.ramp_ceiling }), + + // Advanced — ESP matching + new-lead throttle + ...(newData.esp_match_mode !== campaign.esp_match_mode && { esp_match_mode: newData.esp_match_mode }), + ...(newData.max_new_leads_per_day !== campaign.max_new_leads_per_day && { + max_new_leads_per_day: newData.max_new_leads_per_day, + }), + ...(newData.prioritize_new_leads !== campaign.prioritize_new_leads && { + prioritize_new_leads: newData.prioritize_new_leads, + }), ...(newData.risky_emails !== campaign.risky_emails && { risky_emails: newData.risky_emails }), - ...(newData.cc !== campaign.cc && { cc: newData.cc }), - ...(newData.bcc !== campaign.bcc && { cc: newData.bcc }), + // Advanced — tracking domain + ...(newData.tracking_domain !== campaign.tracking_domain && { + tracking_domain: newData.tracking_domain, + }), - ...(newData.contact_order_by !== campaign.contact_order_by && { contact_order_by: newData.contact_order_by }), - ...(newData.contact_order_dir !== campaign.contact_order_dir && { contact_order_dir: newData.contact_order_dir }), - ...(newData.contact_order_field !== campaign.contact_order_field && { contact_order_field: newData.contact_order_field }), + // Advanced — cc/bcc + ...(newData.cc !== campaign.cc && { cc: newData.cc }), + ...(newData.bcc !== campaign.bcc && { bcc: newData.bcc }), + + // Contact order + ...(newData.contact_order_by !== campaign.contact_order_by && { + contact_order_by: newData.contact_order_by, + }), + ...(newData.contact_order_dir !== campaign.contact_order_dir && { + contact_order_dir: newData.contact_order_dir, + }), + ...(newData.contact_order_field !== campaign.contact_order_field && { + contact_order_field: newData.contact_order_field, + }), + }; + }; + + const validationError = (): string | null => { + if (newData.daily_limit < DAILY_MIN || newData.daily_limit > DAILY_MAX) { + return `Daily limit must be between ${DAILY_MIN} and ${DAILY_MAX}.`; } - } + if (newData.ramp_enabled && newData.ramp_start > newData.ramp_ceiling) { + return "Ramp start must be less than or equal to the ramp ceiling."; + } + return null; + }; async function submit() { if (loading) return; + const err = validationError(); + if (err) { + toast.error(err); + return; + } try { - setLoading(true) + setLoading(true); const data = getChanges(); - toast.promise( - updateCampaign.mutateAsync(data), - { - loading: "Saving...", - success: "Campaign successfully updated.", - error: (err: AppError) => buildError(err), - } - ) + await toast.promise(updateCampaign.mutateAsync(data), { + loading: "Saving…", + success: "Campaign successfully updated.", + error: (e: AppError) => buildError(e), + }); } finally { setLoading(false); } } + const tabContent: Record = { + standard: ( + + ), + advanced: ( + + ), + order: ( + + ), + }; + const hasChanges = Object.keys(getChanges()).length > 0; + const blocked = validationError() !== null; return (
-
-
- {Object.keys(tabData).map((key) => ( - - ))} -
-
- {tabData[activeTab as keyof typeof tabData]?.content} +
+
+ {TABS.map(({ key, title, Icon }) => { + const active = activeTab === key; + return ( + + ); + })}
+
{tabContent[activeTab]}
-
+
+ {blocked && hasChanges && ( + {validationError()} + )}
- ) + ); } diff --git a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx index e6ce55be..c4e173aa 100644 --- a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx @@ -1,86 +1,152 @@ -import type Campaign from "@/lib/api/models/app/campaigns/Campaign" -import SubTitle from "../../text/SubTitle" -import Title from "../../text/Title" -import MiniInput from "../../popup/MiniInput" -import MiniTextArea from "../../popup/MiniTextArea" -import CampaignPreferenceBoolBox from "./components/CampaignPreferenceBoolBox" -import Switch from "../../Switch" +// Standard campaign settings — the everyday controls (mirrors Instantly's +// "Standard" tab): identity, sending accounts, per-mailbox daily limit, and +// the deliverability toggles. On-theme: slate/sky, rounded-md, 12.5px base. + +import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; +import { Label, NumberInput, TextInput } from "@/components/ui/field"; +import TagSelector from "../../popup/select/TagSelector"; +import { SettingRow, Toggle } from "./components/CampaignPreferenceBoolBox"; + +const DAILY_MIN = 3; +const DAILY_MAX = 100; export default function CampaignAppearance({ campaign, newCampaign, setNewCampaign, }: { - campaign: Campaign, - newCampaign: Campaign, - setNewCampaign: React.Dispatch>, + campaign: Campaign; + newCampaign: Campaign; + setNewCampaign: React.Dispatch>; }) { + const dailyInvalid = newCampaign.daily_limit < DAILY_MIN || newCampaign.daily_limit > DAILY_MAX; + return ( -
-
- Campaign Name - setNewCampaign(bef => ({ - ...bef, - name: e.target.value, - }))} - /> -
-
- Campaign Description - setNewCampaign(bef => ({ - ...bef, - description: e.target.value, - }))} - /> -
- +
+ {/* Identity */} +
- Plain Text Only - Send as simple text email for the best deliverability (disables tracking) + + setNewCampaign((bef) => ({ ...bef, name: v }))} + className="w-full max-w-[420px]" + />
- setNewCampaign(bef => ({ - ...bef, - text_only: e, - }))} - /> - - -
- Open Tracking - Track email opens, but may slightly reduce deliverability -
- setNewCampaign(bef => ({ - ...bef, - open_tracking: e, - }))} - /> -
-
- Link Tracking - Track clicks on links to measure engagement (click-through rate) + + setNewCampaign((bef) => ({ ...bef, description: v }))} + className="w-full max-w-[420px]" + />
- setNewCampaign(bef => ({ - ...bef, - link_tracking: e, - }))} +
+ +
+ + {/* Sending */} +
+
+ + + setNewCampaign((bef) => ({ ...bef, email_tags: [...bef.email_tags, t] })) + } + onRemove={(t) => + setNewCampaign((bef) => ({ + ...bef, + email_tags: bef.email_tags.filter((e) => e !== t), + })) + } + /> +

+ Volume is split across every mailbox in these tags to protect deliverability. +

+
+
+ + setNewCampaign((bef) => ({ ...bef, daily_limit: v }))} + suffix="emails / day" + className="w-48" + /> +

+ {dailyInvalid + ? `Must be between ${DAILY_MIN} and ${DAILY_MAX}.` + : `${DAILY_MIN}–${DAILY_MAX}. Default 50 — stay conservative until reputation is proven.`} +

+
+
+ +
+ + {/* Deliverability toggles */} +
+ setNewCampaign((bef) => ({ ...bef, stop_on_reply: v }))} + /> + } /> - + setNewCampaign((bef) => ({ ...bef, text_only: v }))} + /> + } + /> + setNewCampaign((bef) => ({ ...bef, open_tracking: v }))} + /> + } + /> + setNewCampaign((bef) => ({ ...bef, link_tracking: v }))} + /> + } + /> + setNewCampaign((bef) => ({ ...bef, unsubscribe_header: v }))} + /> + } + /> +
- ) + ); } diff --git a/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx b/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx index 545d1c76..2669dcb3 100644 --- a/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignContactOrder.tsx @@ -1,4 +1,7 @@ -import { useState, useCallback } from 'react'; +// Contact ordering settings — how the campaign picks who to send to next. +// On-theme: slate/sky, rounded-md, 12.5px base, house primitives. + +import { useState, useCallback } from "react"; import { DndContext, closestCenter, @@ -7,33 +10,33 @@ import { useSensor, useSensors, type DragEndEvent, -} from '@dnd-kit/core'; +} from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { RiDraggable } from '@remixicon/react'; -import type Campaign from '@/lib/api/models/app/campaigns/Campaign'; -import SubTitle from '../../text/SubTitle'; -import Title from '../../text/Title'; -import Selector from '../../popup/select/Selector'; -import SelectMenu from '../../popup/select/SelectMenu'; -import SelectOption from '../../popup/select/SelectOption'; -import MiniInput from '../../popup/MiniInput'; -import Switch from '../../Switch'; -import CampaignPreferenceBoolBox from './components/CampaignPreferenceBoolBox'; +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVerticalIcon, InfoIcon } from "lucide-react"; +import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; +import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; +import { Segmented, SettingRow } from "./components/CampaignPreferenceBoolBox"; -// Order by options const ORDER_OPTIONS = [ - { value: 'created_at', label: 'Creation Time', description: 'Order by when contacts were added' }, - { value: 'email', label: 'Email', description: 'Alphabetical by email address' }, - { value: 'name', label: 'Name', description: 'Alphabetical by first name, then last name' }, - { value: 'custom_field', label: 'Custom Field', description: 'Order by a custom contact field' }, - { value: 'manual', label: 'Manual', description: 'Drag and drop to set custom order' }, + { value: "created_at", label: "Creation time", description: "Order by when contacts were added" }, + { value: "email", label: "Email", description: "Alphabetical by email address" }, + { value: "name", label: "Name", description: "Alphabetical by first, then last name" }, + { value: "custom_field", label: "Custom field", description: "Order by a custom contact field" }, + { value: "manual", label: "Manual", description: "Drag and drop to set custom order" }, ] as const; interface ContactItem { @@ -44,20 +47,10 @@ interface ContactItem { position?: number; } -interface SortableContactProps { - contact: ContactItem; - index: number; -} - -function SortableContact({ contact, index }: SortableContactProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: contact.id }); +function SortableContact({ contact, index }: { contact: ContactItem; index: number }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: contact.id, + }); const style = { transform: CSS.Transform.toString(transform), @@ -69,25 +62,28 @@ function SortableContact({ contact, index }: SortableContactProps) {
- {index + 1}. + + {index + 1} +
-

+

{contact.firstName || contact.lastName ? `${contact.firstName} ${contact.lastName}`.trim() - : 'Unknown'} + : "Unknown"}

-

{contact.email}

+

{contact.email}

); @@ -102,130 +98,121 @@ interface CampaignContactOrderProps { } export default function CampaignContactOrder({ - campaign, newCampaign, setNewCampaign, contacts = [], onContactsReorder, }: CampaignContactOrderProps) { - const [showOrderMenu, setShowOrderMenu] = useState(false); const [orderedContacts, setOrderedContacts] = useState( - [...contacts].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + [...contacts].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)), ); const sensors = useSensors( useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); - const selectedOption = ORDER_OPTIONS.find(o => o.value === newCampaign.contact_order_by) || ORDER_OPTIONS[0]; + const selectedOption = + ORDER_OPTIONS.find((o) => o.value === newCampaign.contact_order_by) || ORDER_OPTIONS[0]; - const handleDragEnd = useCallback((event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - setOrderedContacts((items) => { - const oldIndex = items.findIndex((i) => i.id === active.id); - const newIndex = items.findIndex((i) => i.id === over.id); - - const newItems = arrayMove(items, oldIndex, newIndex); - - // Update positions - const updatedItems = newItems.map((item, index) => ({ - ...item, - position: index, - })); - - // Notify parent - onContactsReorder?.(updatedItems); - - return updatedItems; - }); - } - }, [onContactsReorder]); + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (over && active.id !== over.id) { + setOrderedContacts((items) => { + const oldIndex = items.findIndex((i) => i.id === active.id); + const newIndex = items.findIndex((i) => i.id === over.id); + const updatedItems = arrayMove(items, oldIndex, newIndex).map((item, index) => ({ + ...item, + position: index, + })); + onContactsReorder?.(updatedItems); + return updatedItems; + }); + } + }, + [onContactsReorder], + ); return (
- {/* Order By Selection */} + {/* Order By */}
- Order Contacts By -
- - {selectedOption.label} - - + + + + + + {ORDER_OPTIONS.map((option) => ( - { + onSelect={() => setNewCampaign((prev) => ({ ...prev, contact_order_by: option.value, - })); - setShowOrderMenu(false); - }} + })) + } > -
-

{option.label}

-

{option.description}

-
-
+ + + {option.label} + + {option.description} + + ))} -
-
+ +
- {/* Direction Toggle (not shown for manual) */} - {newCampaign.contact_order_by !== 'manual' && ( - -
- Descending Order - - {newCampaign.contact_order_dir === 'desc' - ? 'Contacts will be processed from Z to A / newest to oldest' - : 'Contacts will be processed from A to Z / oldest to newest'} - -
- - setNewCampaign((prev) => ({ - ...prev, - contact_order_dir: v ? 'desc' : 'asc', - })) - } - /> -
+ {/* Direction (not for manual) */} + {newCampaign.contact_order_by !== "manual" && ( + + setNewCampaign((prev) => ({ ...prev, contact_order_dir: v })) + } + options={[ + { value: "asc", label: "Ascending" }, + { value: "desc", label: "Descending" }, + ]} + /> + } + /> )} - {/* Custom Field Input */} - {newCampaign.contact_order_by === 'custom_field' && ( + {/* Custom field name */} + {newCampaign.contact_order_by === "custom_field" && (
- Custom Field Name - - setNewCampaign((prev) => ({ - ...prev, - contact_order_field: e.target.value, - })) + + + setNewCampaign((prev) => ({ ...prev, contact_order_field: v })) } + className="w-full max-w-[280px]" /> -

- Enter the name of a custom field from your contacts +

+ Enter the name of a custom field from your contacts.

)} - {/* Manual Drag-and-Drop */} - {newCampaign.contact_order_by === 'manual' && ( + {/* Manual drag-and-drop */} + {newCampaign.contact_order_by === "manual" && (
- Drag to Reorder Contacts + {orderedContacts.length > 0 ? ( c.id)} strategy={verticalListSortingStrategy} > -
+
{orderedContacts.map((contact, index) => ( - + ))}
) : ( -
-

No contacts in this campaign

-

Add contacts to enable manual ordering

+
+

+ No contacts in this campaign +

+

+ Add contacts to enable manual ordering. +

)}
)} - {/* Info Box */} -
-

- How contact ordering works: When processing emails, - contacts will be selected in the order you specify here. This determines - who receives emails first when your campaign is running. + {/* Info */} +

+ +

+ When processing sends, contacts are selected in this order — it determines who + receives emails first while the campaign is running.

diff --git a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx index dbe68e1c..f0743b75 100644 --- a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx @@ -1,101 +1,297 @@ -import type Campaign from "@/lib/api/models/app/campaigns/Campaign" -import SubTitle from "../../text/SubTitle" -import TagSelector from "../../popup/select/TagSelector" -import MiniNumberInput from "../../popup/MiniNumberInput" -import CampaignPreferenceBoolBox from "./components/CampaignPreferenceBoolBox" -import Title from "../../text/Title" -import Switch from "../../Switch" -import MultiInput from "../../MultiInput" +// Advanced campaign settings — the Instantly-level send controls: sender +// strategy + rotation, per-campaign ramp-up, ESP matching, new-lead throttle, +// bounce protection, a campaign tracking-domain override, and cc/bcc. +// On-theme: slate/sky, rounded-md, 12.5px base, NumberInput for every number. + +import { + CheckCircle2Icon, + InfoIcon, + AlertCircleIcon, + Loader2Icon, +} from "lucide-react"; +import toast from "react-hot-toast"; +import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; +import { Label, NumberInput, TextInput } from "@/components/ui/field"; +import { EmailListInput, Segmented, SettingRow, Toggle } from "./components/CampaignPreferenceBoolBox"; +import useVerifyCampaignTrackingDomain from "@/lib/api/hooks/app/campaigns/useVerifyCampaignTrackingDomain"; export default function CampaignEmails({ campaign, newCampaign, setNewCampaign, }: { - campaign: Campaign, - newCampaign: Campaign, - setNewCampaign: React.Dispatch>, + campaign: Campaign; + newCampaign: Campaign; + setNewCampaign: React.Dispatch>; }) { + const verify = useVerifyCampaignTrackingDomain(campaign.id); + // Verify resolves the CNAME for the SAVED domain, so it's only offered when + // the field has no unsaved edits. + const trackingDirty = newCampaign.tracking_domain !== campaign.tracking_domain; + const rampInvalid = + newCampaign.ramp_enabled && newCampaign.ramp_start > newCampaign.ramp_ceiling; + return ( -
-
- Email Accounts - setNewCampaign(bef => ({ - ...bef, - email_tags: [...bef.email_tags, t] - }))} - onRemove={(t) => setNewCampaign(bef => ({ - ...bef, - email_tags: bef.email_tags.filter((e) => e !== t) - }))} +
+ {/* Sender selection */} +
+ setNewCampaign((bef) => ({ ...bef, sender_strategy: v }))} + options={[ + { value: "tags", label: "Tags" }, + { value: "explicit", label: "Explicit" }, + ]} + /> + } /> -
-
- Daily Limit - setNewCampaign(bef => ({ - ...bef, - daily_limit: e.target.valueAsNumber - }))} + {newCampaign.sender_strategy === "explicit" && ( +
+ +

+ Manage the explicit mailbox pool from the senders API. The in-app picker + is coming soon — until then the campaign keeps its current pinned senders. +

+
+ )} + setNewCampaign((bef) => ({ ...bef, rotation_mode: v }))} + options={[ + { value: "weighted", label: "Weighted" }, + { value: "round_robin", label: "Round-robin" }, + { value: "least_recently_used", label: "Least-recently-used" }, + ]} + /> + } /> -
- + + +
+ + {/* Ramp-up */} +
+ setNewCampaign((bef) => ({ ...bef, ramp_enabled: v }))} + /> + } + /> + {newCampaign.ramp_enabled && ( +
+
+
+ + setNewCampaign((bef) => ({ ...bef, ramp_start: v }))} + suffix="/ day" + className="w-36" + /> +
+
+ + + setNewCampaign((bef) => ({ ...bef, ramp_increment: v })) + } + suffix="+ / day" + className="w-36" + /> +
+
+ + + setNewCampaign((bef) => ({ ...bef, ramp_ceiling: v })) + } + suffix="/ day" + className="w-36" + /> +
+ + Today's cap + {newCampaign.ramp_level} + +
+ {rampInvalid && ( +

+ + Start must be less than or equal to the ceiling. +

+ )} +
+ )} +
+ +
+ + {/* ESP matching + new-lead throttle */} +
+ setNewCampaign((bef) => ({ ...bef, esp_match_mode: v }))} + options={[ + { value: "off", label: "Off" }, + { value: "prefer", label: "Prefer same" }, + { value: "strict", label: "Strict same" }, + ]} + /> + } + /> + {newCampaign.esp_match_mode === "strict" && ( +

+ Strict matching can slow sending when no same-provider mailbox is free. +

+ )}
- Unsubscribe Header - Add an unsubscribe link in the email header for compliance and better deliverability + + + setNewCampaign((bef) => ({ ...bef, max_new_leads_per_day: v })) + } + suffix="leads / day" + className="w-48" + /> +

+ {newCampaign.max_new_leads_per_day === 0 + ? "0 = unlimited new leads per day." + : "Caps how many fresh leads enter the campaign each day."} +

- setNewCampaign(bef => ({ - ...bef, - unsubscribe_header: e, - }))} + + setNewCampaign((bef) => ({ ...bef, prioritize_new_leads: v })) + } + /> + } /> - - -
- Risky Emails - Attempt sending to risky addresses (may increase bounces) + setNewCampaign((bef) => ({ ...bef, risky_emails: v }))} + /> + } + /> +
+ +
+ + {/* Tracking domain */} +
+
+ + {newCampaign.tracking_domain ? ( + newCampaign.tracking_domain_verified ? ( + + + Verified + + ) : ( + + + Unverified + + ) + ) : null} + {campaign.tracking_domain && !campaign.tracking_domain_verified && !trackingDirty && ( + + )} + {campaign.tracking_domain && !campaign.tracking_domain_verified && trackingDirty && ( + Save to verify + )}
- setNewCampaign(bef => ({ - ...bef, - risky_emails: e, - }))} + setNewCampaign((bef) => ({ ...bef, tracking_domain: v }))} + className="w-full max-w-[420px]" /> - -
-
+

+ Add a CNAME from this host to our tracking endpoint, then verify. Tracking links + only use this domain once it's verified. +

+
+ +
+ + {/* CC / BCC */} +
- CC receipments - Send a copy of each email to additional recipients (visible to others) - CC recipients + setNewCampaign(bef => ({ - ...bef, - cc: v, - }))} + onChange={(v) => setNewCampaign((bef) => ({ ...bef, cc: v }))} /> +

+ A copy of each email goes to these addresses (visible to recipients). +

- BCC receipments - Send a blind copy of each email to hidden recipients (not visible to others) - BCC recipients + setNewCampaign(bef => ({ - ...bef, - bcc: v, - }))} + onChange={(v) => setNewCampaign((bef) => ({ ...bef, bcc: v }))} /> +

+ A blind copy goes to these addresses (hidden from recipients). +

-
+
- ) + ); } diff --git a/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx b/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx index d77c5620..f6a801b0 100644 --- a/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx +++ b/web/src/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox.tsx @@ -1,7 +1,190 @@ +// On-theme primitives shared across the campaign settings tabs. +// +// Replaces the legacy off-theme Switch / Title / SubTitle usage. Everything +// here is slate/sky, rounded-md, 12.5px base, h-7 controls — matching the +// rebuilt analytics + campaign-overview chrome. + +import React from "react"; + +/** + * SettingRow — a labelled setting line. Title + helper text on the left, + * an arbitrary control (Toggle, Segmented, NumberInput…) on the right. + * Used for the boolean toggles and inline selects across the tabs. + */ +export function SettingRow({ + title, + description, + control, + children, +}: { + title: string; + description?: React.ReactNode; + control?: React.ReactNode; + children?: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} + {children} +
+ {control &&
{control}
} +
+ ); +} + +/** + * Legacy default export kept so any stray importer still compiles. Plain + * justified flex row. + */ export default function CampaignPreferenceBoolBox({ children }: { children: React.ReactNode }) { return (
{children}
- ) + ); +} + +/** + * Toggle — our small on-theme switch. Sky-600 when on, slate when off. + * 28px wide track, no library defaults. + */ +export function Toggle({ + value, + onChange, + disabled, + id, +}: { + value: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; + id?: string; +}) { + return ( + + ); +} + +/** + * Segmented — a small on-theme pill group. Active option = bg-sky-600 white; + * the rest are muted slate. Generic over the option value. + */ +export function Segmented({ + value, + onChange, + options, + className, +}: { + value: T; + onChange: (v: T) => void; + options: { value: T; label: string }[]; + className?: string; +}) { + return ( +
+ {options.map((o) => { + const active = o.value === value; + return ( + + ); + })} +
+ ); +} + +/** + * EmailListInput — chip input for cc/bcc recipients, on-theme. Type an + * address and press Enter/Tab/comma to add; backspace on an empty field + * removes the last chip. + */ +export function EmailListInput({ + values, + onChange, + placeholder = "name@example.com — Enter to add", +}: { + values: string[]; + onChange: (values: string[]) => void; + placeholder?: string; +}) { + const [draft, setDraft] = React.useState(""); + + const commit = () => { + const v = draft.trim().replace(/,$/, "").trim(); + if (!v) return; + if (!values.includes(v)) onChange([...values, v]); + setDraft(""); + }; + + return ( +
+ {values.map((v, i) => ( + + {v} + + + ))} + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === "Tab" || e.key === ",") { + if (draft.trim()) { + e.preventDefault(); + commit(); + } + } else if (e.key === "Backspace" && draft === "" && values.length > 0) { + e.preventDefault(); + onChange(values.slice(0, -1)); + } + }} + onBlur={commit} + placeholder={values.length === 0 ? placeholder : ""} + className="flex-1 min-w-[120px] h-5 bg-transparent text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+ ); } diff --git a/web/src/lib/api/client/app/campaigns/createCampaign.ts b/web/src/lib/api/client/app/campaigns/createCampaign.ts index fff64f98..8f61e913 100644 --- a/web/src/lib/api/client/app/campaigns/createCampaign.ts +++ b/web/src/lib/api/client/app/campaigns/createCampaign.ts @@ -32,6 +32,18 @@ export interface CreateCampaignInput { email_tag_ids?: string[]; folder_ids?: string[]; + // Net-new send controls (all optional; backend defaults reproduce today's behavior) + sender_strategy?: 'tags' | 'explicit'; + rotation_mode?: 'weighted' | 'round_robin' | 'least_recently_used'; + ramp_enabled?: boolean; + ramp_start?: number; + ramp_increment?: number; + ramp_ceiling?: number; + esp_match_mode?: 'off' | 'prefer' | 'strict'; + max_new_leads_per_day?: number; + prioritize_new_leads?: boolean; + tracking_domain?: string; + // Initial sequences (ordered) sequences?: Array<{ name: string; diff --git a/web/src/lib/api/client/app/campaigns/getCampaignSenders.ts b/web/src/lib/api/client/app/campaigns/getCampaignSenders.ts new file mode 100644 index 00000000..292d590e --- /dev/null +++ b/web/src/lib/api/client/app/campaigns/getCampaignSenders.ts @@ -0,0 +1,14 @@ +import type { CampaignSender } from "@/lib/api/models/app/campaigns/Campaign"; +import Request from "../../Request"; + +// GET /campaigns/:id/senders -> { data: CampaignSender[] }. Unwrap the envelope +// (Request returns the raw body) so callers always get a real array. +export default async function getCampaignSenders(campaignId: string): Promise { + const res = await Request<{ data: CampaignSender[] | null } | CampaignSender[]>({ + method: "GET", + url: `/campaigns/${campaignId}/senders`, + authorization: true, + }); + if (Array.isArray(res)) return res; + return res?.data ?? []; +} diff --git a/web/src/lib/api/client/app/campaigns/replaceCampaignSenders.ts b/web/src/lib/api/client/app/campaigns/replaceCampaignSenders.ts new file mode 100644 index 00000000..40d29d30 --- /dev/null +++ b/web/src/lib/api/client/app/campaigns/replaceCampaignSenders.ts @@ -0,0 +1,18 @@ +import type { CampaignSender, CampaignSenderInput } from "@/lib/api/models/app/campaigns/Campaign"; +import Request from "../../Request"; + +// PUT /campaigns/:id/senders — full-replace of the explicit sender pool. +// Body is { senders: [...] }; response is the resolved { data: CampaignSender[] }. +export default async function replaceCampaignSenders( + campaignId: string, + senders: CampaignSenderInput[], +): Promise { + const res = await Request<{ data: CampaignSender[] | null } | CampaignSender[]>({ + method: "PUT", + url: `/campaigns/${campaignId}/senders`, + data: { senders }, + authorization: true, + }); + if (Array.isArray(res)) return res; + return res?.data ?? []; +} diff --git a/web/src/lib/api/client/app/campaigns/verifyCampaignTrackingDomain.ts b/web/src/lib/api/client/app/campaigns/verifyCampaignTrackingDomain.ts new file mode 100644 index 00000000..cf16a620 --- /dev/null +++ b/web/src/lib/api/client/app/campaigns/verifyCampaignTrackingDomain.ts @@ -0,0 +1,19 @@ +import Request from "../../Request"; + +// Mirrors backend models.TrackingDomainStatus (bare object response). +export interface TrackingDomainStatus { + tracking_domain: string; + tracking_domain_verified: boolean; + tracking_domain_verified_at?: string | null; +} + +// POST /campaigns/:id/tracking-domain/verify — resolves the CNAME and flips +// verified on success. Side-effectful but naturally idempotent (re-resolving +// the same domain is safe) and covered by the global Idempotency-Key middleware. +export default async function verifyCampaignTrackingDomain(campaignId: string): Promise { + return await Request({ + method: "POST", + url: `/campaigns/${campaignId}/tracking-domain/verify`, + authorization: true, + }); +} diff --git a/web/src/lib/api/hooks/app/campaigns/useCampaignSenders.ts b/web/src/lib/api/hooks/app/campaigns/useCampaignSenders.ts new file mode 100644 index 00000000..9f1c4439 --- /dev/null +++ b/web/src/lib/api/hooks/app/campaigns/useCampaignSenders.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import getCampaignSenders from "@/lib/api/client/app/campaigns/getCampaignSenders"; + +export default function useCampaignSenders(campaignId: string, enabled: boolean = true) { + return useQuery({ + queryKey: ["campaigns", campaignId, "senders"], + queryFn: () => getCampaignSenders(campaignId), + enabled: enabled && !!campaignId, + }); +} diff --git a/web/src/lib/api/hooks/app/campaigns/useReplaceCampaignSenders.ts b/web/src/lib/api/hooks/app/campaigns/useReplaceCampaignSenders.ts new file mode 100644 index 00000000..00ccadcd --- /dev/null +++ b/web/src/lib/api/hooks/app/campaigns/useReplaceCampaignSenders.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { CampaignSender, CampaignSenderInput } from "@/lib/api/models/app/campaigns/Campaign"; +import replaceCampaignSenders from "@/lib/api/client/app/campaigns/replaceCampaignSenders"; + +export default function useReplaceCampaignSenders(campaignId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (senders: CampaignSenderInput[]) => replaceCampaignSenders(campaignId, senders), + onSuccess: (data: CampaignSender[]) => { + queryClient.setQueryData(["campaigns", campaignId, "senders"], data); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/campaigns/useVerifyCampaignTrackingDomain.ts b/web/src/lib/api/hooks/app/campaigns/useVerifyCampaignTrackingDomain.ts new file mode 100644 index 00000000..b8f8ca37 --- /dev/null +++ b/web/src/lib/api/hooks/app/campaigns/useVerifyCampaignTrackingDomain.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import verifyCampaignTrackingDomain from "@/lib/api/client/app/campaigns/verifyCampaignTrackingDomain"; + +export default function useVerifyCampaignTrackingDomain(campaignId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => verifyCampaignTrackingDomain(campaignId), + onSuccess: () => { + // The campaign row carries the verified flags; refetch the detail. + queryClient.invalidateQueries({ queryKey: ["campaigns", campaignId] }); + }, + }); +} diff --git a/web/src/lib/api/models/app/campaigns/Campaign.ts b/web/src/lib/api/models/app/campaigns/Campaign.ts index 064bcb8a..5179a95e 100644 --- a/web/src/lib/api/models/app/campaigns/Campaign.ts +++ b/web/src/lib/api/models/app/campaigns/Campaign.ts @@ -31,6 +31,33 @@ export default interface Campaign { contact_order_dir: 'asc' | 'desc'; contact_order_field?: string; + // ── Net-new send controls ──────────────────────────────────────────── + // Sender selection: "tags" (default) resolves mailboxes from email_tags; + // "explicit" uses the campaign's sender pool (edited via the senders endpoint). + sender_strategy: 'tags' | 'explicit'; + rotation_mode: 'weighted' | 'round_robin' | 'least_recently_used'; + senders?: CampaignSender[]; + + // Per-campaign daily ramp-up. ramp_level/ramp_level_date are server-managed. + ramp_enabled: boolean; + ramp_start: number; + ramp_increment: number; + ramp_ceiling: number; + ramp_level: number; + ramp_level_date?: string | null; + + // Match the sending mailbox provider to the recipient's provider. + esp_match_mode: 'off' | 'prefer' | 'strict'; + + // New-lead throttle. max_new_leads_per_day 0 = unlimited. + max_new_leads_per_day: number; + prioritize_new_leads: boolean; + + // Campaign-scoped tracking-domain override (honored only when verified). + tracking_domain: string; + tracking_domain_verified: boolean; + tracking_domain_verified_at?: string | null; + updated_at: Date; created_at: Date; @@ -39,3 +66,17 @@ export default interface Campaign { sequences: Sequence[] | null; } +export interface CampaignSender { + email_account_id: string; + weight: number; + last_sent_at?: string | null; + enabled: boolean; +} + +// Write shape for PUT /campaigns/:id/senders (full-replace of the pool). +export interface CampaignSenderInput { + email_account_id: string; + weight?: number; + enabled?: boolean; +} + From 33ccb99e7b482b03d54108a25de1146ba197b092 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:54:17 +0200 Subject: [PATCH 05/60] feat: refresh campaign detail overview --- web/src/app/app/campaigns/[id]/layout.tsx | 150 ++++++-- web/src/app/app/campaigns/[id]/page.tsx | 256 +++++++++++-- .../components/app/campaigns/TaskPreview.tsx | 347 ++++++++++-------- web/src/hooks/useCampaignChannel.ts | 205 ++++++----- 4 files changed, 661 insertions(+), 297 deletions(-) diff --git a/web/src/app/app/campaigns/[id]/layout.tsx b/web/src/app/app/campaigns/[id]/layout.tsx index a2da319b..4f6bfcb4 100644 --- a/web/src/app/app/campaigns/[id]/layout.tsx +++ b/web/src/app/app/campaigns/[id]/layout.tsx @@ -1,27 +1,56 @@ import { Link, Outlet, useLocation, useParams } from "react-router-dom"; +import { motion } from "framer-motion"; +import { + BarChart3Icon, + CalendarIcon, + ListChecksIcon, + Loader2Icon, + PauseIcon, + PlayIcon, + Settings2Icon, + UsersIcon, +} from "lucide-react"; import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign"; +import useStartCampaign from "@/lib/api/hooks/app/campaigns/useStartCampaign"; +import useStopCampaign from "@/lib/api/hooks/app/campaigns/useStopCampaign"; import { CampaignContext } from "@/hooks/context/campaign"; +import { useConfirm } from "@/hooks/context/confirm"; + +const TABS = [ + { label: "Overview", path: "", Icon: BarChart3Icon }, + { label: "Leads", path: "/leads", Icon: UsersIcon }, + { label: "Sequences", path: "/sequences", Icon: ListChecksIcon }, + { label: "Schedule", path: "/schedule", Icon: CalendarIcon }, + { label: "Settings", path: "/preferences", Icon: Settings2Icon }, +] as const; + +const STATUS_PILL: Record = { + active: "bg-emerald-50 text-emerald-700 border-emerald-200", + paused: "bg-amber-50 text-amber-700 border-amber-200", + draft: "bg-slate-100 text-slate-600 border-slate-200", + completed: "bg-slate-100 text-slate-600 border-slate-200", +}; export default function CampaignLayout() { - const location = useLocation() - const { id } = useParams() - const { pathname } = location; - const campaignData = useCampaign(id ?? "") - - const tabData = { - "Analytics": "", - "Leads": "/leads", - "Sequences": "/sequences", - "Schedule": "/schedule", - "Preferences": "/preferences" - } + const { pathname } = useLocation(); + const { id } = useParams(); + const campaignData = useCampaign(id ?? ""); + const confirm = useConfirm(); + const startCampaign = useStartCampaign(); + const stopCampaign = useStopCampaign(); if (campaignData.isLoading) { return ( -
- {[...Array(5)].map((_, i) => ( -
- ))} +
+
+
+
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
); } @@ -43,38 +72,95 @@ export default function CampaignLayout() { ); } + const campaign = campaignData.data; + const status = campaign.status; + const pill = STATUS_PILL[status] ?? STATUS_PILL.draft; + + const isActive = status === "active"; + const canStart = status === "draft" || status === "paused"; + const canToggle = isActive || canStart; + const pending = isActive ? stopCampaign.isPending : startCampaign.isPending; + + const onToggle = () => { + const action = isActive ? "Pause" : "Start"; + confirm?.show(`${action} ${campaign.name}?`, () => { + if (isActive) { + stopCampaign.mutate(campaign.id); + } else { + startCampaign.mutate(campaign.id); + } + }); + }; + return ( - -
-
-

{campaignData.data.name}

-

{campaignData.data.id}

+ +
+
+
+
+

{campaign.name}

+ + {status} + +
+

{campaign.id}

+
+ + {canToggle && ( +
+ +
+ )}
-
- {Object.entries(tabData).map(([label, path]) => { + +
+ {TABS.map(({ label, path, Icon }) => { const fullPath = `/app/campaigns/${id}${path}`; - const isActive = pathname.replaceAll("/", "") === fullPath.replaceAll("/", ""); + const isTabActive = pathname.replace(/\/$/, "") === fullPath.replace(/\/$/, ""); return ( + {label} - {isActive && ( - + {isTabActive && ( + )} ); })}
+
- ) + ); } diff --git a/web/src/app/app/campaigns/[id]/page.tsx b/web/src/app/app/campaigns/[id]/page.tsx index 9f5335ce..af42c03f 100644 --- a/web/src/app/app/campaigns/[id]/page.tsx +++ b/web/src/app/app/campaigns/[id]/page.tsx @@ -1,37 +1,249 @@ +import { useMemo, useState } from "react"; +import { + MailCheckIcon, + MousePointerClickIcon, + ReplyIcon, + SendIcon, + TriangleAlertIcon, +} from "lucide-react"; import { useCampaign } from "@/hooks/context/campaign"; +import useCampaignAnalytics from "@/lib/api/hooks/app/analytics/useCampaignAnalytics"; +import useCampaignDailyStats from "@/lib/api/hooks/app/analytics/useCampaignDailyStats"; +import { SectionBar, Stat, StatStrip } from "@/components/layout/Page"; +import { DailyBars, type ChartPoint } from "@/components/ui/charts"; +import AnalyticsShareButton from "@/components/app/analytics/AnalyticsShareButton"; import TaskPreview from "@/components/app/campaigns/TaskPreview"; -import { BarChart3Icon } from "lucide-react"; -export default function CampaignPreview() { +type Metric = "sent" | "opens" | "clicks" | "replies"; + +const METRICS: { key: Metric; label: string; bar: string }[] = [ + { key: "sent", label: "Sent", bar: "bg-sky-500" }, + { key: "opens", label: "Opens", bar: "bg-emerald-500" }, + { key: "clicks", label: "Clicks", bar: "bg-violet-500" }, + { key: "replies", label: "Replies", bar: "bg-amber-500" }, +]; + +function pct(v: number | undefined): string { + return v == null ? "—" : `${v.toFixed(1)}%`; +} +function num(v: number | undefined): string { + return (v ?? 0).toLocaleString(); +} + +export default function CampaignOverview() { const campaign = useCampaign(); + const id = campaign?.id ?? ""; + + const analytics = useCampaignAnalytics(id); + const daily = useCampaignDailyStats(id); + + const [metric, setMetric] = useState("sent"); + + const summary = analytics.data?.summary; + const sequences = analytics.data?.sequences ?? []; + const dailyStats = daily.data ?? []; + + const series: ChartPoint[] = useMemo( + () => (daily.data ?? []).map((d) => ({ label: d.date, value: d[metric] ?? 0 })), + [daily.data, metric], + ); + + const loading = analytics.isPending || daily.isPending; + const hasSends = (summary?.emails_sent ?? 0) > 0; + + const shareData = { + title: campaign?.name ?? "Campaign", + subtitle: "Campaign", + metrics: [ + { label: "Sent", value: num(summary?.emails_sent), sub: "emails" }, + { label: "Open rate", value: pct(summary?.open_rate) }, + { label: "Reply rate", value: pct(summary?.reply_rate) }, + { label: "Bounce rate", value: pct(summary?.bounce_rate) }, + ], + daily: dailyStats.map((d) => ({ label: d.date, value: d.sent })), + }; if (!campaign) { return ( -
- {[...Array(3)].map((_, i) => ( -
- ))} +
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
); } - return ( -
-
-
-
- -
-

Campaign Analytics

-

Data will appear here once the campaign starts sending.

-
-
+ const breakdown = [ + { label: "Sent", value: summary?.emails_sent, icon: SendIcon, dot: "bg-slate-400" }, + { label: "Opens", value: summary?.unique_opens, icon: MailCheckIcon, dot: "bg-emerald-500" }, + { label: "Clicks", value: summary?.unique_clicks, icon: MousePointerClickIcon, dot: "bg-violet-500" }, + { label: "Replies", value: summary?.replies, icon: ReplyIcon, dot: "bg-amber-500" }, + { label: "Bounces", value: summary?.bounces, icon: TriangleAlertIcon, dot: "bg-rose-500" }, + ]; -
- + return ( +
+
+ {/* Main analytics column */} +
+
+ + + + + + + + + + +
+ + {analytics.isError ? ( +
+

Couldn't load analytics

+

The request failed — try refreshing.

+
+ ) : ( +
+ +
+ {METRICS.map((m) => ( + + ))} +
+
+
+ {loading ? ( +
+ ) : ( + m.key === metric)?.bar} + emptyLabel={ + hasSends + ? "No activity in this window yet" + : "No sends yet — start the campaign to see performance" + } + /> + )} +
+
+ )} + +
+ + {loading ? ( +
+ {[...Array(2)].map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : sequences.length === 0 ? ( +
+

No sequence data yet

+

+ Once steps start sending, per-step opens, clicks, and replies show up here. +

+
+ ) : ( +
+ {/* header row */} +
+ Step + Sent + Opens + Clicks + Replies + Bounces +
+ {sequences.map((s) => ( +
+ + + {s.position} + + {s.name} + + + {num(s.emails_sent)} + + + {num(s.opens)} + + + {num(s.clicks)} + + + {num(s.replies)} + + + {num(s.bounces)} + +
+ ))} +
+ )} +
+ + {/* quick breakdown strip below sequence table, mobile-friendly summary */} +
+ +
+ {breakdown.map((q) => ( +
+ + {q.label} + + {loading ? "—" : num(q.value)} + +
+ ))} +
+
+
+ + {/* Live panel */} +
); diff --git a/web/src/components/app/campaigns/TaskPreview.tsx b/web/src/components/app/campaigns/TaskPreview.tsx index fc26f47b..d6af914b 100644 --- a/web/src/components/app/campaigns/TaskPreview.tsx +++ b/web/src/components/app/campaigns/TaskPreview.tsx @@ -1,206 +1,247 @@ -import { useMemo } from 'react'; -import { useCampaignChannel, type ActivityItem } from '@/hooks/useCampaignChannel'; +import { useMemo } from "react"; +import { + MailOpenIcon, + MousePointerClickIcon, + ReplyIcon, + SendIcon, + TriangleAlertIcon, + XCircleIcon, + type LucideIcon, +} from "lucide-react"; +import { useCampaignChannel, type ActivityItem } from "@/hooks/useCampaignChannel"; +import useCampaignLogs from "@/lib/api/hooks/app/campaigns/useCampaignLogs"; interface TaskPreviewProps { campaignId: string; campaignStatus?: string; } -// Status badge colors -const statusColors: Record = { - active: 'bg-green-100 text-green-800', - paused: 'bg-yellow-100 text-yellow-800', - draft: 'bg-gray-100 text-gray-800', - completed: 'bg-blue-100 text-blue-800', +// ── Status pill tones ─────────────────────────────────────────────── +const STATUS_TONE: Record = { + active: "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200", + paused: "bg-amber-50 text-amber-700 ring-1 ring-amber-200", + completed: "bg-sky-50 text-sky-700 ring-1 ring-sky-200", + draft: "bg-slate-100 text-slate-600 ring-1 ring-slate-200", }; -// Activity type colors -const activityColors: Record = { - sent: 'text-blue-600', - opened: 'text-green-600', - clicked: 'text-purple-600', - replied: 'text-indigo-600', - bounced: 'text-orange-600', - failed: 'text-red-600', +// ── Activity-type icon + tone ─────────────────────────────────────── +const ACTIVITY_META: Record = { + sent: { icon: SendIcon, tone: "text-slate-500" }, + opened: { icon: MailOpenIcon, tone: "text-emerald-600" }, + clicked: { icon: MousePointerClickIcon, tone: "text-violet-600" }, + replied: { icon: ReplyIcon, tone: "text-amber-600" }, + bounced: { icon: TriangleAlertIcon, tone: "text-rose-600" }, + failed: { icon: XCircleIcon, tone: "text-rose-600" }, }; -// Activity type icons -const activityIcons: Record = { - sent: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', - opened: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z', - clicked: 'M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122', - replied: 'M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6', - bounced: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z', - failed: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z', -}; - -function formatTime(date: Date): string { - return date.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - }); +function statusLabel(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); } -function ActivityIcon({ type }: { type: string }) { - const path = activityIcons[type] || activityIcons.sent; - return ( - - - - ); +function initials(name?: string, email?: string): string { + const source = (name || email || "").trim(); + if (!source) return "?"; + const parts = source.split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return source[0].toUpperCase(); } -function ActivityFeedItem({ activity }: { activity: ActivityItem }) { +function relativeTime(date: Date): string { + const diff = Date.now() - date.getTime(); + const sec = Math.round(diff / 1000); + if (sec < 5) return "just now"; + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + return date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); +} + +function ActivityRow({ activity }: { activity: ActivityItem }) { + const meta = ACTIVITY_META[activity.type] ?? ACTIVITY_META.sent; + const Icon = meta.icon; return ( -
-
- -
-
-

{activity.message}

-

{formatTime(activity.timestamp)}

-
+
+ +

+ {activity.message} +

+ + {relativeTime(activity.timestamp)} +
); } export default function TaskPreview({ campaignId, campaignStatus: initialStatus }: TaskPreviewProps) { - const { - isConnected, - channelState, - campaignStatus: realtimeStatus, - taskProgress, - activities, - } = useCampaignChannel(campaignId); + const { isConnected, channelState, campaignStatus: realtimeStatus, taskProgress, activities } = + useCampaignChannel(campaignId); - // Use realtime status if available, otherwise fall back to initial - const currentStatus = realtimeStatus?.status || initialStatus || 'draft'; + // Durable log tail — best effort; never breaks the live panel if it errors. + const logs = useCampaignLogs(campaignId); - // Calculate estimated time remaining - const estimatedTimeRemaining = useMemo(() => { - if (!taskProgress || taskProgress.processed_count === 0) return null; + const currentStatus = realtimeStatus?.status || initialStatus || "draft"; + const isActive = currentStatus === "active"; - const remaining = taskProgress.total_contacts - taskProgress.processed_count; + const connectionLabel = isConnected + ? "Connected" + : channelState === "joining" + ? "Connecting…" + : "Disconnected"; + + const showNowSending = !!taskProgress && isActive && taskProgress.status === "active"; + + const progress = Math.min(100, Math.max(0, taskProgress?.progress ?? 0)); + const processed = taskProgress?.processed_count ?? 0; + const total = taskProgress?.total_contacts ?? 0; + + // Conservative estimate: remaining contacts paced roughly one per minute. + const remainingHint = useMemo(() => { + if (!taskProgress || total <= 0) return null; + const remaining = total - processed; if (remaining <= 0) return null; + if (remaining < 60) return `~${remaining} min left`; + const hours = Math.floor(remaining / 60); + const mins = remaining % 60; + return mins > 0 ? `~${hours}h ${mins}m left` : `~${hours}h left`; + }, [taskProgress, total, processed]); - // Estimate based on recent activity (assuming ~1 email per minute average) - const minutes = remaining; - if (minutes < 60) return `~${minutes} min`; - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - return `~${hours}h ${mins}m`; - }, [taskProgress]); + const recentLogs = useMemo(() => { + const all = logs.data?.logs ?? []; + return all.slice(-6).reverse(); + }, [logs.data]); + + const hasActivity = activities.length > 0; return ( -
- {/* Header */} -
-
-

Live Preview

- - {currentStatus.charAt(0).toUpperCase() + currentStatus.slice(1)} - -
-
- - - {isConnected ? 'Connected' : channelState === 'joining' ? 'Connecting...' : 'Disconnected'} +
+ {/* ── Header ─────────────────────────────────────────────── */} +
+ + Live activity + + + {statusLabel(currentStatus)} + +
+ + {isConnected && ( + + )} + + {connectionLabel}
- {/* Current Task Card */} - {taskProgress && currentStatus === 'active' && ( -
-
- {/* Contact Avatar */} -
- {taskProgress.contact_name?.[0]?.toUpperCase() || taskProgress.contact_email?.[0]?.toUpperCase() || '?'} + {/* ── Now sending ────────────────────────────────────────── */} + {showNowSending && ( +
+
+
+ {initials(taskProgress.contact_name, taskProgress.contact_email)}
-

- {taskProgress.contact_name || 'Unknown Contact'} +

+ {taskProgress.contact_name || taskProgress.contact_email || "Unknown contact"}

-

{taskProgress.contact_email}

+ {taskProgress.contact_name && ( +

+ {taskProgress.contact_email} +

+ )} {taskProgress.sequence_name && ( -

+

{taskProgress.sequence_name} - {taskProgress.sequence_index > 0 && ` (Step ${taskProgress.sequence_index})`} + {taskProgress.sequence_index > 0 && ` · Step ${taskProgress.sequence_index}`}

)}
-
- - {taskProgress.status === 'active' ? 'Sending...' : taskProgress.status} - -
+ + + Sending… +
)} - {/* Progress Section */} -
-
- Progress - - {taskProgress?.progress ?? 0}% - -
-
-
-
-
- - {taskProgress?.processed_count ?? 0} of {taskProgress?.total_contacts ?? 0} contacts - - {estimatedTimeRemaining && ( - - {estimatedTimeRemaining} remaining + {/* ── Progress ───────────────────────────────────────────── */} + {(showNowSending || (isActive && total > 0)) && ( +
+
+ + Progress - )} + {progress}% +
+
+
+
+
+ + {processed.toLocaleString()} of {total.toLocaleString()} contacts + + {remainingHint && ( + {remainingHint} + )} +
-
+ )} - {/* Activity Feed */} -
- {activities.length > 0 ? ( -
+ {/* ── Activity feed ──────────────────────────────────────── */} +
+ {hasActivity ? ( +
{activities.map((activity) => ( - + ))}
+ ) : recentLogs.length > 0 ? ( + <> +
+ Recent log +
+
+ {recentLogs.map((log, i) => ( +
+ +

+ {log.message} +

+ + {relativeTime(new Date(log.timestamp))} + +
+ ))} +
+ ) : ( -
- - - -

- {currentStatus === 'active' - ? 'Waiting for activity...' - : 'Start the campaign to see live updates'} +

+

+ {isActive ? "Waiting for the next send…" : "Nothing sending yet"} +

+

+ {isActive + ? "Opens, clicks, replies and bounces will stream in here live as your campaign sends." + : "Start the campaign to watch it send live."}

)} diff --git a/web/src/hooks/useCampaignChannel.ts b/web/src/hooks/useCampaignChannel.ts index 079a2d3c..74daf551 100644 --- a/web/src/hooks/useCampaignChannel.ts +++ b/web/src/hooks/useCampaignChannel.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { useChannel, useChannelEvent } from './context/socket'; // Task progress event payload @@ -47,6 +47,17 @@ export interface CampaignChannelState { const MAX_ACTIVITIES = 50; +// Normalize a realtime event name to a canonical UPPERCASE_SNAKE key. The Go +// publishers set `event_type` to uppercase constants (TASK_PROGRESS, +// EMAIL_OPENED, CAMPAIGN_STARTED, …) and the Elixir channel pushes that verbatim +// as the Phoenix event name. Normalizing here (the same way useRealtimeEvents +// does) means the feed works regardless of any future casing/separator drift. +function normalizeEvent(name: unknown): string { + return String(name ?? '') + .replace(/[.:\s-]+/g, '_') + .toUpperCase(); +} + export function useCampaignChannel(campaignId: string): CampaignChannelState { const topic = `campaign:${campaignId}`; const channel = useChannel(topic); @@ -55,6 +66,11 @@ export function useCampaignChannel(campaignId: string): CampaignChannelState { const [taskProgress, setTaskProgress] = useState(null); const [activities, setActivities] = useState([]); + // Monotonic id source so rapid same-millisecond events don't collide on + // React keys (Date.now() alone repeats under bursty sending). + const idRef = useRef(0); + const nextId = useCallback((prefix: string) => `${prefix}-${++idRef.current}`, []); + // Add activity to feed const addActivity = useCallback((activity: ActivityItem) => { setActivities((prev) => { @@ -68,99 +84,108 @@ export function useCampaignChannel(campaignId: string): CampaignChannelState { setActivities([]); }, []); - // Handle task progress events - useChannelEvent(topic, 'task_progress', (payload) => { - const data = payload as unknown as TaskProgressPayload; - setTaskProgress(data); + // Single wildcard subscription: the socket dispatcher passes every event on + // this channel here with `_event` set to the Phoenix event name. We + // normalize and route, instead of registering lowercase handlers that never + // matched the uppercase event names the backend actually emits. + useChannelEvent(topic, '*', (payload) => { + const raw = payload as { _event?: string; event_type?: string; type?: string }; + const name = normalizeEvent(raw._event ?? raw.event_type ?? raw.type); - // Add to activity feed based on status - if (data.status === 'completed') { - addActivity({ - id: `${data.task_id}-${Date.now()}`, - type: 'sent', - contactEmail: data.contact_email, - contactName: data.contact_name, - message: `Email sent to ${data.contact_email}`, - timestamp: new Date(data.timestamp || Date.now()), - }); - } else if (data.status === 'failed') { - addActivity({ - id: `${data.task_id}-${Date.now()}`, - type: 'failed', - contactEmail: data.contact_email, - contactName: data.contact_name, - message: `Failed to send to ${data.contact_email}`, - timestamp: new Date(data.timestamp || Date.now()), - }); + switch (name) { + case 'TASK_PROGRESS': { + const data = payload as unknown as TaskProgressPayload; + setTaskProgress(data); + if (data.status === 'completed') { + addActivity({ + id: nextId('sent'), + type: 'sent', + contactEmail: data.contact_email, + contactName: data.contact_name, + message: `Email sent to ${data.contact_email}`, + timestamp: new Date(data.timestamp || Date.now()), + }); + } else if (data.status === 'failed') { + addActivity({ + id: nextId('failed'), + type: 'failed', + contactEmail: data.contact_email, + contactName: data.contact_name, + message: `Failed to send to ${data.contact_email}`, + timestamp: new Date(data.timestamp || Date.now()), + }); + } + break; + } + case 'EMAIL_OPENED': { + const data = payload as { contact_email?: string }; + addActivity({ + id: nextId('open'), + type: 'opened', + contactEmail: data.contact_email || 'Unknown', + message: `Opened by ${data.contact_email || 'Unknown'}`, + timestamp: new Date(), + }); + break; + } + case 'EMAIL_CLICKED': { + const data = payload as { contact_email?: string; original_url?: string }; + addActivity({ + id: nextId('click'), + type: 'clicked', + contactEmail: data.contact_email || 'Unknown', + message: data.original_url + ? `Click from ${data.contact_email || 'Unknown'} → ${data.original_url}` + : `Click from ${data.contact_email || 'Unknown'}`, + timestamp: new Date(), + }); + break; + } + case 'EMAIL_REPLIED': { + const data = payload as { contact_email?: string }; + addActivity({ + id: nextId('reply'), + type: 'replied', + contactEmail: data.contact_email || 'Unknown', + message: `Reply from ${data.contact_email || 'Unknown'}`, + timestamp: new Date(), + }); + break; + } + case 'EMAIL_BOUNCED': { + const data = payload as { contact_email?: string }; + addActivity({ + id: nextId('bounce'), + type: 'bounced', + contactEmail: data.contact_email || 'Unknown', + message: `Bounced: ${data.contact_email || 'Unknown'}`, + timestamp: new Date(), + }); + break; + } + case 'CAMPAIGN_STARTED': { + const data = payload as unknown as CampaignStatusPayload; + setCampaignStatus({ ...data, status: 'active' }); + break; + } + case 'CAMPAIGN_PAUSED': { + const data = payload as unknown as CampaignStatusPayload; + setCampaignStatus({ ...data, status: 'paused' }); + break; + } + case 'CAMPAIGN_COMPLETED': { + const data = payload as unknown as CampaignStatusPayload; + setCampaignStatus({ ...data, status: 'completed' }); + break; + } + case 'CAMPAIGN_UPDATED': { + const data = payload as unknown as CampaignStatusPayload; + setCampaignStatus(data); + break; + } } }); - // Handle campaign status changes - useChannelEvent(topic, 'campaign_updated', (payload) => { - const data = payload as unknown as CampaignStatusPayload; - setCampaignStatus(data); - }); - - useChannelEvent(topic, 'campaign_started', (payload) => { - const data = payload as unknown as CampaignStatusPayload; - setCampaignStatus({ ...data, status: 'active' }); - }); - - useChannelEvent(topic, 'campaign_paused', (payload) => { - const data = payload as unknown as CampaignStatusPayload; - setCampaignStatus({ ...data, status: 'paused' }); - }); - - useChannelEvent(topic, 'campaign_completed', (payload) => { - const data = payload as unknown as CampaignStatusPayload; - setCampaignStatus({ ...data, status: 'completed' }); - }); - - // Handle tracking events - useChannelEvent(topic, 'email_opened', (payload) => { - const data = payload as { contact_email?: string; contact_id?: string }; - addActivity({ - id: `open-${data.contact_id}-${Date.now()}`, - type: 'opened', - contactEmail: data.contact_email || 'Unknown', - message: `Opened by ${data.contact_email || 'Unknown'}`, - timestamp: new Date(), - }); - }); - - useChannelEvent(topic, 'email_clicked', (payload) => { - const data = payload as { contact_email?: string; contact_id?: string; original_url?: string }; - addActivity({ - id: `click-${data.contact_id}-${Date.now()}`, - type: 'clicked', - contactEmail: data.contact_email || 'Unknown', - message: `Click from ${data.contact_email || 'Unknown'}`, - timestamp: new Date(), - }); - }); - - useChannelEvent(topic, 'email_replied', (payload) => { - const data = payload as { contact_email?: string; contact_id?: string }; - addActivity({ - id: `reply-${data.contact_id}-${Date.now()}`, - type: 'replied', - contactEmail: data.contact_email || 'Unknown', - message: `Reply from ${data.contact_email || 'Unknown'}`, - timestamp: new Date(), - }); - }); - - useChannelEvent(topic, 'email_bounced', (payload) => { - const data = payload as { contact_email?: string; contact_id?: string }; - addActivity({ - id: `bounce-${data.contact_id}-${Date.now()}`, - type: 'bounced', - contactEmail: data.contact_email || 'Unknown', - message: `Bounced: ${data.contact_email || 'Unknown'}`, - timestamp: new Date(), - }); - }); - // Reset state when campaign changes useEffect(() => { setCampaignStatus(null); From d630619a3082f31004cd830a948898c2ec126968 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:54:25 +0200 Subject: [PATCH 06/60] feat: redesign campaign sequence builder --- .../app/app/campaigns/[id]/sequences/page.tsx | 234 ++++++++-------- .../app/campaigns/sequences/SequenceBox.tsx | 98 +++++-- .../app/campaigns/sequences/SequenceView.tsx | 249 ++++++++++-------- .../app/campaigns/sequences/StepRail.tsx | 198 ++++++++++++++ 4 files changed, 524 insertions(+), 255 deletions(-) create mode 100644 web/src/components/app/campaigns/sequences/StepRail.tsx diff --git a/web/src/app/app/campaigns/[id]/sequences/page.tsx b/web/src/app/app/campaigns/[id]/sequences/page.tsx index 4299fac8..5a4398cb 100644 --- a/web/src/app/app/campaigns/[id]/sequences/page.tsx +++ b/web/src/app/app/campaigns/[id]/sequences/page.tsx @@ -1,147 +1,139 @@ import React from "react"; +import { LayersIcon, Loader2Icon, PlusIcon } from "lucide-react"; +import toast from "react-hot-toast"; import { useCampaign } from "@/hooks/context/campaign"; -import { Loading } from "@/components/loader"; -import SequenceBox from "@/components/app/campaigns/sequences/SequenceBox"; +import StepRail from "@/components/app/campaigns/sequences/StepRail"; import SequenceView from "@/components/app/campaigns/sequences/SequenceView"; import useSequences from "@/lib/api/hooks/app/campaigns/sequences/useSequences"; import useCreateSequence from "@/lib/api/hooks/app/campaigns/sequences/useCreateSequence"; -import toast from "react-hot-toast"; +import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; -import { FileTextIcon, PlusIcon } from "lucide-react"; export default function CampaignSequences() { const campaign = useCampaign(); if (!campaign) { - throw new Error("CampaignSequences cannot be rendered without a campaign") + throw new Error("CampaignSequences cannot be rendered without a campaign"); } - const [load, setLoad] = React.useState(false); - const [select, setSelect] = React.useState(""); - const [newSequences, setNewSequences] = React.useState() - const sequencesData = useSequences(campaign.id); + return ( + }> + + + ); +} - const createSequence = useCreateSequence(campaign.id); +function SequencesBuilder({ campaignId }: { campaignId: string }) { + const { data: sequences } = useSequences(campaignId); + const createSequence = useCreateSequence(campaignId); + const [creating, setCreating] = React.useState(false); + const [selectedId, setSelectedId] = React.useState(""); - async function CreateSequence() { - if (load) return; - setLoad(true); + // Keep a valid selection: default to the first step, and recover if the + // selected step is deleted. + React.useEffect(() => { + if (sequences.length === 0) { + if (selectedId !== "") setSelectedId(""); + return; + } + if (!sequences.some((s) => s.id === selectedId)) { + setSelectedId(sequences[0].id); + } + }, [sequences, selectedId]); + + async function create() { + if (creating) return; + setCreating(true); try { - const resp = await toast.promise( - createSequence.mutateAsync, - { - loading: "Creating sequence...", - success: "Sequence successfully created.", - error: (err: AppError) => buildError(err), - } - ) - setNewSequences(bef => bef ? [...bef, resp] : [resp]) + const created = await toast.promise(createSequence.mutateAsync(), { + loading: "Adding step…", + success: "Step added.", + error: (err: AppError) => buildError(err), + }); + setSelectedId((created as Sequence).id); } finally { - setLoad(false); + setCreating(false); } } - return !sequencesData.isLoading ? ( -
-
- {sequencesData.data.map((seq, i) => { - if (!campaign.sequences || !newSequences || campaign.sequences.length !== newSequences.length) return null; - return ( - setNewSequences(bef => bef ? bef.map((s) => s.id === seq.id ? { - ...s, - wait_after: v, - } : s) : null)} - onClick={() => setSelect(seq.id)} - >{seq.name} - ) - })} - {sequencesData.data.length === 0 && ( - { }} - onClick={() => { }} - >New Sequence - )} - {sequencesData.data.length < 5 && ( - - )} + if (sequences.length === 0) { + return ( +
+
+ +
+

Build your sequence

+

+ Add steps to automate the outreach flow. The first step sends immediately; + later steps wait and thread as follow-ups. +

+
-
- {(() => { - const seq = sequencesData.data.find((v) => v.id === select) - const seq2 = newSequences?.find((v) => v.id === select) - if (!seq || !seq2 || !campaign.sequences) { - return ( -
-
- -
-

Create your first sequence

-

- Add email sequences to automate your outreach flow. -

- -
- ) - } - return setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - name: v, - } : s) : null)} - setSubject={(v) => setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - subject: v, - } : s) : null)} - setBodyPlain={(v) => setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - body_plain: v, - } : s) : null)} - setBodyHTML={(v) => setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - body_html: v, - } : s) : null)} - setBodySync={(v) => setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - body_sync: v, - } : s) : null)} - setBodyCode={(v) => setNewSequences(bef => bef ? bef.map((s) => s.id === select ? { - ...s, - body_code: v, - } : s) : null)} - onUpdate={(s) => setNewSequences(bef => bef ? bef.map((seq) => seq.id === s.id ? s : seq) : null)} + ); + } + + const selected = sequences.find((s) => s.id === selectedId); + const selectedIndex = sequences.findIndex((s) => s.id === selectedId); + + return ( +
+
+
+ Steps +
+ +
+
+ {selected ? ( + - })()} + ) : ( +
+

Select a step

+

+ Pick a step from the list to edit its email. +

+
+ )}
- ) : ( -
- {[...Array(3)].map((_, i) => ( -
- ))} + ); +} + +function SequencesSkeleton() { + return ( +
+
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+
+
+
- ) + ); } diff --git a/web/src/components/app/campaigns/sequences/SequenceBox.tsx b/web/src/components/app/campaigns/sequences/SequenceBox.tsx index 2ea3a63c..ad458de4 100644 --- a/web/src/components/app/campaigns/sequences/SequenceBox.tsx +++ b/web/src/components/app/campaigns/sequences/SequenceBox.tsx @@ -1,37 +1,79 @@ -import MiniNumberInput from "@/components/app/popup/MiniNumberInput"; +import { Trash2Icon } from "lucide-react"; +import { cn } from "@/lib/utils"; +// One selectable step in the sequence stepper. Renders "Step N", the step's +// display name, and a subject preview. Active rows highlight sky; the delete +// affordance stays touch-reachable (visible on mobile, hover-revealed on md+). export default function SequenceBox({ - children, - next, + index, + name, + subject, active, - def_wait, - wait, - setWait, onClick, + onDelete, }: { - children: React.ReactNode, - next: boolean, - active: boolean, - def_wait: number, - wait: number, - setWait: (v: number) => void, - onClick: () => void, + index: number; + name: string; + subject: string; + active: boolean; + onClick: () => void; + onDelete: () => void; }) { return ( - <> -
- {children} +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + className={cn( + "group relative w-full select-none cursor-pointer rounded-md border px-3 py-2.5 transition-colors", + active + ? "border-sky-300 bg-sky-50 ring-2 ring-sky-100" + : "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50", + )} + > +
+ + {index + 1} + + + {name || `Step ${index + 1}`} + +
- {next && (
- Wait - setWait(e.target.valueAsNumber)} - /> - Day(s) -
-
)} - - ) +

+ {subject || "No subject yet"} +

+
+ ); } diff --git a/web/src/components/app/campaigns/sequences/SequenceView.tsx b/web/src/components/app/campaigns/sequences/SequenceView.tsx index ba59c212..5e1b5dbb 100644 --- a/web/src/components/app/campaigns/sequences/SequenceView.tsx +++ b/web/src/components/app/campaigns/sequences/SequenceView.tsx @@ -1,138 +1,175 @@ -import React, { useMemo } from "react"; +import React from "react"; +import { GitBranchIcon, Loader2Icon } from "lucide-react"; +import toast from "react-hot-toast"; import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; -import SubTitle from "../../text/SubTitle"; -import MiniInput from "../../popup/MiniInput"; import EmailEditor from "../../EmailEditor"; import ContentScore from "../ContentScore"; -import { Loading } from "@/components/loader"; +import { Label, TextInput } from "@/components/ui/field"; import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdateSequence"; -import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; +// Editable subset of a step. wait_after is edited from the stepper rail, so the +// editor pane only owns the content fields — but the save still diffs the same +// body_sync/body_html/body_plain/body_code semantics the original used. +type Draft = Pick< + Sequence, + "name" | "subject" | "body_plain" | "body_html" | "body_sync" | "body_code" +>; + +function toDraft(s: Sequence): Draft { + return { + name: s.name, + subject: s.subject, + body_plain: s.body_plain, + body_html: s.body_html, + body_sync: s.body_sync, + body_code: s.body_code, + }; +} + export default function SequenceView({ - campaign_id, - def_sequence, + campaignId, sequence, - - setName, - setSubject, - setBodyPlain, - setBodyHTML, - setBodySync, - setBodyCode, - onUpdate, + index, }: { - campaign_id: string, - def_sequence: Sequence, - sequence: Sequence, - - setName: (v: string) => void, - setSubject: (v: string) => void, - setBodyPlain: (v: string) => void, - setBodyHTML: (v: string) => void, - setBodySync: (v: boolean) => void, - setBodyCode: (v: boolean) => void, - onUpdate: (v: Sequence) => void, + campaignId: string; + sequence: Sequence; + index: number; }) { - const updateSequence = useUpdateSequence(campaign_id, sequence.id) + const updateSequence = useUpdateSequence(campaignId, sequence.id); + const [load, setLoad] = React.useState(false); - const [load, setLoad] = React.useState(false); + // Draft is seeded from the canonical sequence and reset whenever a different + // step is selected (or the underlying record changes). The committed + // baseline lives in `sequence`; `draft` is the local working copy. + const [draft, setDraft] = React.useState(() => toDraft(sequence)); + React.useEffect(() => { + setDraft(toDraft(sequence)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sequence.id]); - const savable = useMemo( - () => JSON.stringify(def_sequence) !== JSON.stringify(sequence), - [def_sequence, sequence] - ) + const baseline = toDraft(sequence); + const savable = React.useMemo( + () => JSON.stringify(baseline) !== JSON.stringify(draft), + [baseline, draft], + ); + + const patch = (p: Partial) => setDraft((d) => ({ ...d, ...p })); async function submit() { - if (load) return; + if (load || !savable) return; setLoad(true); try { - const data = { - ...(sequence.name !== def_sequence.name && { name: sequence.name }), - ...(sequence.subject !== def_sequence.subject && { subject: sequence.subject }), - - ...(sequence.body_plain !== def_sequence.body_plain && { body_plain: sequence.body_plain }), - ...(sequence.body_html !== def_sequence.body_html && { body_html: sequence.body_html }), - ...(sequence.body_sync !== def_sequence.body_sync && { body_sync: sequence.body_sync }), - ...(sequence.body_code !== def_sequence.body_code && { body_code: sequence.body_code }), - } - const resp = await toast.promise( - updateSequence.mutateAsync(data), - { - loading: "Updating sequence...", - success: "Sequence successfully updated.", - error: (err: AppError) => buildError(err), - } - ) - onUpdate(resp) + // Diff against the committed baseline so we only PATCH changed + // fields — identical semantics to the previous implementation. + const data: Partial = { + ...(draft.name !== baseline.name && { name: draft.name }), + ...(draft.subject !== baseline.subject && { subject: draft.subject }), + ...(draft.body_plain !== baseline.body_plain && { body_plain: draft.body_plain }), + ...(draft.body_html !== baseline.body_html && { body_html: draft.body_html }), + ...(draft.body_sync !== baseline.body_sync && { body_sync: draft.body_sync }), + ...(draft.body_code !== baseline.body_code && { body_code: draft.body_code }), + }; + await toast.promise(updateSequence.mutateAsync(data), { + loading: "Saving step…", + success: "Step saved.", + error: (err: AppError) => buildError(err), + }); + // The mutation hook writes the fresh record into the query cache; the + // `sequence` prop will update and the effect above re-seeds the draft. } finally { - setLoad(false) + setLoad(false); } } return ( -
-
- Display Name - setName(e.target.value)} - /> +
+
+
+
+ Step {index + 1} +
+

+ Edit the email this step sends. +

+
+
+ + +
-
- setSubject(e.target.value)} - type="text" - /> -
+
+
+ + patch({ name: v })} + placeholder={`Step ${index + 1}`} + /> +

+ Internal label only — recipients never see it. +

+
+ +
+ + patch({ subject: v })} + placeholder="Subject line" + /> +
+ +
+ patch({ body_html: v })} + setPlainText={(v) => patch({ body_plain: v })} + setSync={(v) => patch({ body_sync: v })} + setCode={(v) => patch({ body_code: v })} />
-
- -
- - -
+ + {index > 0 && ( +
+ +

+ Follow-ups thread on the previous step's subject. Change this + subject and the follow-up starts a new thread instead of replying in + the existing one. +

+
+ )} + +
- ) + ); } diff --git a/web/src/components/app/campaigns/sequences/StepRail.tsx b/web/src/components/app/campaigns/sequences/StepRail.tsx new file mode 100644 index 00000000..6ea772e0 --- /dev/null +++ b/web/src/components/app/campaigns/sequences/StepRail.tsx @@ -0,0 +1,198 @@ +import React from "react"; +import { ClockIcon, Loader2Icon, PlusIcon, SendIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import { NumberInput } from "@/components/ui/field"; +import SequenceBox from "./SequenceBox"; +import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; +import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdateSequence"; +import useDeleteSequence from "@/lib/api/hooks/app/campaigns/sequences/useDeleteSequence"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; + +const MAX_STEPS = 5; + +// WaitConnector — the vertical line between two steps. Step 1 has no preceding +// connector (it sends immediately); every later step is preceded by a "Wait N +// days" control whose NumberInput persists wait_after on the step it gates. +function WaitConnector({ + campaignId, + sequence, +}: { + campaignId: string; + sequence: Sequence; +}) { + const update = useUpdateSequence(campaignId, sequence.id); + const [draft, setDraft] = React.useState(sequence.wait_after); + + // Keep the local draft in lockstep with the canonical value when the cache + // updates (e.g. after a save elsewhere). + React.useEffect(() => { + setDraft(sequence.wait_after); + }, [sequence.wait_after]); + + const commit = (v: number) => { + const next = Math.max(0, Math.round(v)); + setDraft(next); + if (next === sequence.wait_after) return; + update.mutate( + { wait_after: next }, + { + onError: (err) => { + setDraft(sequence.wait_after); + toast.error(buildError(err as unknown as AppError)); + }, + }, + ); + }; + + return ( +
+
+ + + +
+
+
+ + Wait + + days + {update.isPending && ( + + )} +
+
+
+ ); +} + +export default function StepRail({ + campaignId, + sequences, + selectedId, + onSelect, + onCreate, + creating, +}: { + campaignId: string; + sequences: Sequence[]; + selectedId: string; + onSelect: (id: string) => void; + onCreate: () => void; + creating: boolean; +}) { + const [confirmId, setConfirmId] = React.useState(null); + const [deletingId, setDeletingId] = React.useState(null); + const deleteSequence = useDeleteSequence(campaignId, confirmId ?? ""); + + const confirmTarget = sequences.find((s) => s.id === confirmId) ?? null; + const confirmIndex = sequences.findIndex((s) => s.id === confirmId); + + async function runDelete() { + if (!confirmId) return; + const id = confirmId; + setDeletingId(id); + try { + await deleteSequence.mutateAsync(id); + toast.success("Step removed."); + if (selectedId === id) { + const remaining = sequences.filter((s) => s.id !== id); + onSelect(remaining[0]?.id ?? ""); + } + } catch (err) { + toast.error(buildError(err as AppError)); + } finally { + setDeletingId(null); + setConfirmId(null); + } + } + + return ( +
+ {sequences.map((seq, i) => ( + + {i === 0 ? ( +
+ + Sends immediately +
+ ) : ( + + )} + onSelect(seq.id)} + onDelete={() => setConfirmId(seq.id)} + /> +
+ ))} + + {sequences.length < MAX_STEPS && ( +
+ +

+ Up to {MAX_STEPS} steps. Follow-ups thread on the same subject line. +

+
+ )} + + {confirmTarget && ( +
+
+

+ Delete step {confirmIndex + 1}? +

+

+ {confirmTarget.name || `Step ${confirmIndex + 1}`} and its content will + be removed from this campaign. This can't be undone. +

+
+ + +
+
+
+ )} +
+ ); +} From ca310748fea2f1895b5d69714f51ede7308e0c64 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:54:33 +0200 Subject: [PATCH 07/60] feat: polish campaign schedule controls --- .../app/app/campaigns/[id]/schedule/page.tsx | 320 +++++++++--------- .../campaigns/schedule/ScheduleDateSelect.tsx | 66 ++-- .../app/campaigns/schedule/WeekdayBitmask.tsx | 61 ++-- 3 files changed, 232 insertions(+), 215 deletions(-) diff --git a/web/src/app/app/campaigns/[id]/schedule/page.tsx b/web/src/app/app/campaigns/[id]/schedule/page.tsx index b9df4a1b..a76764be 100644 --- a/web/src/app/app/campaigns/[id]/schedule/page.tsx +++ b/web/src/app/app/campaigns/[id]/schedule/page.tsx @@ -1,14 +1,16 @@ import React from "react"; +import { ClockIcon, GlobeIcon } from "lucide-react"; import DateSelect from "@/components/app/campaigns/schedule/ScheduleDateSelect"; import WeekdayBitmask from "@/components/app/campaigns/schedule/WeekdayBitmask"; import { Loading } from "@/components/loader"; -import { RiHistoryLine } from "@remixicon/react"; -import Selector from "@/components/app/popup/select/Selector"; -import SelectMenu from "@/components/app/popup/select/SelectMenu"; -import { twColors } from "tailwindv4-colors"; -import SelectOption from "@/components/app/popup/select/SelectOption"; -import TimeSelector from "@/components/app/popup/select/TimeSelector"; -import SubTitle from "@/components/app/text/SubTitle"; +import { Label } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; import { useCampaign } from "@/hooks/context/campaign"; import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; import useUpdateCampaign from "@/lib/api/hooks/app/campaigns/useUpdateCampaign"; @@ -16,25 +18,53 @@ import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import { useUserProfile } from "@/hooks/context/user"; +import { timeOptions, to12Hour } from "@/lib/core/time"; + +const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +function TimePicker({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( + + + } + label={to12Hour(value)} + className="w-full justify-between" + /> + + + {timeOptions.map((t) => ( + onChange(t.value)} + > + {t.name} + + ))} + + + ); +} export default function CampaignSchedule() { const campaign = useCampaign(); if (!campaign) { - throw new Error("CampaignSchedule cannot be rendered without a campaign") + throw new Error("CampaignSchedule cannot be rendered without a campaign"); } + const u = useUserProfile(); const updateCampaign = useUpdateCampaign(campaign.id); - const [loading, setLoading] = React.useState(false); - const [newData, setNewData] = React.useState(campaign) + const [loading, setLoading] = React.useState(false); + const [newData, setNewData] = React.useState(campaign); React.useEffect(() => { if (!campaign) return; - setNewData(campaign) - }, [campaign]) + setNewData(campaign); + }, [campaign]); - - const getChanges = () => { + const getChanges = (): Partial => { if (!newData || !campaign) return {}; return { ...(newData.start_date !== campaign.start_date && { start_date: newData.start_date }), @@ -42,166 +72,146 @@ export default function CampaignSchedule() { ...(newData.timezone !== campaign.timezone && { timezone: newData.timezone }), ...(newData.days !== campaign.days && { days: newData.days }), ...(newData.start_time !== campaign.start_time && { start_time: newData.start_time }), - ...(newData.end_time !== campaign.end_time && { end_time: newData.end_time }) - } - } + ...(newData.end_time !== campaign.end_time && { end_time: newData.end_time }), + }; + }; async function submit() { if (loading || !campaign) return; + if (newData.days === 0) { + toast.error("Pick at least one active day."); + return; + } try { setLoading(true); - const data = getChanges(); - await toast.promise( - updateCampaign.mutateAsync(data), - { - loading: "Saving...", - success: "Campaign successfully updated.", - error: (err: AppError) => buildError(err), - } - ) + await toast.promise(updateCampaign.mutateAsync(getChanges()), { + loading: "Saving…", + success: "Campaign successfully updated.", + error: (err: AppError) => buildError(err), + }); } finally { setLoading(false); } } - const u = useUserProfile(); - const tzRef = React.useRef(null); - const [tzDrop, setTzDrop] = React.useState(false); - - React.useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (tzRef.current && !tzRef.current.contains(event.target as Node)) { - setTzDrop(false); - } - }; - if (tzDrop) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [tzDrop]); - if (!campaign || !newData) return null; - const hasChanges = Object.keys(getChanges() || {}).length > 0; + const hasChanges = Object.keys(getChanges()).length > 0; + const tzLabel = + u.timezones.find((tz) => tz.name === newData.timezone)?.display_name ?? newData.timezone; - return (<> -
-
- setNewData(bef => bef ? ({ - ...bef, - startDate: v, - }) : null)} - /> - setNewData(bef => bef ? ({ - ...bef, - endDate: v, - }) : null)} - /> -
-
+ return ( +
+
+ {/* Timezone */}
- Timezone -
- {((() => { - const v = u.timezones.find((tz) => tz.name === newData.timezone) - if (v) { - return v.display_name - } else { - return newData.timezone - } - })())} - - {u.timezones ? (
- {u.timezones.map((tz) => ( - { - setNewData(bef => bef ? ({ - ...bef, - timezone: tz.name, - }) : null) - }} - selected={tz.name === newData.timezone} - > - - {tz.display_name} - - ))} -
) : ( -
- -
- )} -
+ + + + } + label={tzLabel} + className="w-full max-w-[340px] justify-between" + /> + + + {u.timezones.map((tz) => ( + + setNewData((bef) => ({ ...bef, timezone: tz.name })) + } + > + {tz.display_name} + + ))} + + +

+ Sends are scheduled in this zone. Worker IPs spread distribution naturally. +

+
+ +
+ + {/* Active days */} +
+ + setNewData((bef) => ({ ...bef, days: v }))} + /> +

+ Pick at least one day. Defaults to Monday–Friday. +

+
+ +
+ + {/* Sending window */} +
+ +
+
+ Start time + setNewData((bef) => ({ ...bef, start_time: v }))} + /> +
+
+ End time + setNewData((bef) => ({ ...bef, end_time: v }))} + /> +
-
-
- Active Days -
- setNewData(bef => bef ? ({ - ...bef, - days: v, - }) : null)} - /> -
-
-
-
- Start Time - setNewData(bef => bef ? ({ - ...bef, - start_time: v, - }) : null)} - /> -
-
- End Time - setNewData(bef => bef ? ({ - ...bef, - end_time: v, - }) : null)} - /> -
+ +
+ + {/* Run window */} +
+ +
+ setNewData((bef) => ({ ...bef, start_date: v }))} + /> + setNewData((bef) => ({ ...bef, end_date: v }))} + />
+

+ Optional bounds for when the campaign may send. Leave blank to run open-ended. +

-
-
- - + + +
- ); + ); } diff --git a/web/src/components/app/campaigns/schedule/ScheduleDateSelect.tsx b/web/src/components/app/campaigns/schedule/ScheduleDateSelect.tsx index 812614f4..7ede98c0 100644 --- a/web/src/components/app/campaigns/schedule/ScheduleDateSelect.tsx +++ b/web/src/components/app/campaigns/schedule/ScheduleDateSelect.tsx @@ -1,18 +1,19 @@ import React from "react"; -import { RiCloseLine, RiCalendarScheduleLine } from "@remixicon/react"; +import { CalendarIcon, XIcon } from "lucide-react"; import Calendar from "../../Calendar"; import { format } from "date-fns"; +import { Label } from "@/components/ui/field"; export default function DateSelect({ title, value, onChange, }: { - title: string, - value: Date | null, - onChange: (v: Date | null) => void, + title: string; + value: Date | null; + onChange: (v: Date | null) => void; }) { - const [open, setOpen] = React.useState(false); + const [open, setOpen] = React.useState(false); const modalRef = React.useRef(null); React.useEffect(() => { @@ -22,42 +23,47 @@ export default function DateSelect({ } }; if (open) { - document.addEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); } - return () => { - document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener("mousedown", handleClickOutside); }; }, [open]); return (
-

- {title} - onChange(null)} - > - - -

+
+ + {value && ( + + )} +
- setOpen(false)} - onSubmit={onChange} - /> + setOpen(false)} onSubmit={onChange} />
- ) + ); } diff --git a/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx b/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx index cd783a14..6cde3b2f 100644 --- a/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx +++ b/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx @@ -1,37 +1,38 @@ -import Checkbox from "../../Checkbox"; +// Active-days picker — a grid of 7 day pills backed by a uint8 day-of-week +// bitmask (bit i = weekday i). On-theme: sky-600 active, slate idle, h-7 +// rounded-md pills. Mirrors the create wizard's ScheduleStep. export default function WeekdayBitmask({ weekdays, value, setValue, }: { - weekdays: string[], - value: number, - setValue: (v: number) => void, + weekdays: string[]; + value: number; + setValue: (v: number) => void; }) { - const toggleDay = (index: number) => { - const mask = 1 << index; - setValue(value ^ mask); // XOR toggles the bit - }; - - return weekdays.map((day, index) => { - const mask = 1 << index; - const isChecked = (value & mask) !== 0; - - return ( - - ); - }) -}; + return ( +
+ {weekdays.map((day, index) => { + const mask = 1 << index; + const active = (value & mask) !== 0; + return ( + + ); + })} +
+ ); +} From 981ab01c8c3fbe98cf15603d0cd2d24733dc84d5 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:54:42 +0200 Subject: [PATCH 08/60] feat: add bulk mailbox warmup controls --- web/src/app/app/emails/page.tsx | 84 ++++--- .../app/emails/BulkWarmupDialog.tsx | 211 ++++++++++++++++++ .../components/app/emails/InboxDetails.tsx | 25 ++- web/src/components/ui/TimeSelect.tsx | 45 ++++ 4 files changed, 332 insertions(+), 33 deletions(-) create mode 100644 web/src/components/app/emails/BulkWarmupDialog.tsx create mode 100644 web/src/components/ui/TimeSelect.tsx diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index 449a2cb9..385d3244 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -9,7 +9,9 @@ import useFeatureStatus from "@/lib/api/hooks/app/subscription/useFeatureStatus" import warmupLifecycle from "@/lib/api/client/app/emails/warmupLifecycle"; import removeEmail from "@/lib/api/client/app/emails/removeEmail"; import { useUserProfile } from "@/hooks/context/user"; +import { useConfirm } from "@/hooks/context/confirm"; import InboxDetails from "@/components/app/emails/InboxDetails"; +import BulkWarmupDialog from "@/components/app/emails/BulkWarmupDialog"; import type Tag from "@/lib/api/models/app/Tag"; import type Inbox from "@/lib/api/models/app/emails/Inbox"; import type AccountStatus from "@/lib/api/models/app/analytics/AccountStatus"; @@ -68,6 +70,7 @@ function healthTone(status?: AccountStatus): { dot: string; text: string; label: export default function AddressesPage() { const p = useUserProfile(); + const confirm = useConfirm(); const [query, setQuery] = React.useState(""); const [tag, setTag] = React.useState(""); @@ -76,6 +79,7 @@ export default function AddressesPage() { const [view, setView] = React.useState(""); const [viewTab, setViewTab] = React.useState("overview"); const [removing, setRemoving] = React.useState(false); + const [bulkStart, setBulkStart] = React.useState(false); const queryClient = useQueryClient(); // Warmup is a paid/trial feature; gate the start controls when the org @@ -87,19 +91,25 @@ export default function AddressesPage() { // One query feeds live health for every row; the realtime layer already // invalidates ["analytics","accounts",…] on warmup/account events. const statuses = useAccountStatuses(); + // Coerce to an array defensively: a wrong-shape (non-array) response must + // never reach a `for…of`, which would throw "{} is not iterable". + const accountStatuses = useMemo( + () => (Array.isArray(statuses.data) ? statuses.data : []), + [statuses.data], + ); const statusById = useMemo(() => { const m = new Map(); - for (const s of statuses.data ?? []) m.set(s.id, s); + for (const s of accountStatuses) m.set(s.id, s); return m; - }, [statuses.data]); + }, [accountStatuses]); // Proactively notify the user when a mailbox's health drops. const prevHealth = useRef>(new Map()); useEffect(() => { - if (!statuses.data) return; + if (accountStatuses.length === 0) return; const prev = prevHealth.current; const next = new Map(); - for (const s of statuses.data) { + for (const s of accountStatuses) { const cur = s.health?.status ?? "healthy"; next.set(s.id, cur); const before = prev.get(s.id); @@ -109,20 +119,24 @@ export default function AddressesPage() { } } prevHealth.current = next; - }, [statuses.data]); + }, [accountStatuses]); - const removeSelected = async () => { + const removeSelected = () => { if (selected.length === 0 || removing) return; const n = selected.length; - if (!window.confirm(`Remove ${n} mailbox${n > 1 ? "es" : ""}? This disconnects ${n > 1 ? "them" : "it"} from Warmbly.`)) return; - setRemoving(true); - const results = await Promise.allSettled(selected.map((id) => removeEmail(id))); - const failed = results.filter((r) => r.status === "rejected").length; - await queryClient.invalidateQueries({ queryKey: ["emails"] }); - setSelected([]); - setRemoving(false); - if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be removed`); - else toast.success(`Removed ${n} mailbox${n > 1 ? "es" : ""}`); + confirm.show( + `Remove ${n} mailbox${n > 1 ? "es" : ""}? This disconnects ${n > 1 ? "them" : "it"} from Warmbly.`, + async () => { + setRemoving(true); + const results = await Promise.allSettled(selected.map((id) => removeEmail(id))); + const failed = results.filter((r) => r.status === "rejected").length; + await queryClient.invalidateQueries({ queryKey: ["emails"] }); + setSelected([]); + setRemoving(false); + if (failed > 0) toast.error(`${failed} mailbox${failed > 1 ? "es" : ""} couldn't be removed`); + else toast.success(`Removed ${n} mailbox${n > 1 ? "es" : ""}`); + }, + ); }; const bulkWarmup = async (action: "start" | "pause") => { @@ -319,7 +333,7 @@ export default function AddressesPage() { {canWarmup && ( diff --git a/web/src/components/app/emails/BulkWarmupDialog.tsx b/web/src/components/app/emails/BulkWarmupDialog.tsx new file mode 100644 index 00000000..a7b153f1 --- /dev/null +++ b/web/src/components/app/emails/BulkWarmupDialog.tsx @@ -0,0 +1,211 @@ +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { FlameIcon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { Label, NumberInput } from "@/components/ui/field"; +import TimeSelect from "@/components/ui/TimeSelect"; +import WeekdayBitmask from "@/components/app/campaigns/schedule/WeekdayBitmask"; +import { Loading } from "@/components/loader"; +import updateEmail from "@/lib/api/client/app/emails/updateEmail"; +import warmupLifecycle from "@/lib/api/client/app/emails/warmupLifecycle"; +import type Inbox from "@/lib/api/models/app/emails/Inbox"; + +const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +// Bulk warmup-start dialog. Lets the user optionally apply one set of warmup +// settings to every selected mailbox before starting — useful when onboarding a +// batch of new inboxes that should all ramp the same way. +export default function BulkWarmupDialog({ + open, + ids, + onClose, + onComplete, +}: { + open: boolean; + ids: string[]; + onClose: () => void; + onComplete: () => void; +}) { + const queryClient = useQueryClient(); + const [customize, setCustomize] = useState(true); + const [busy, setBusy] = useState(false); + + const [base, setBase] = useState(10); + const [increase, setIncrease] = useState(1); + const [max, setMax] = useState(40); + const [replyRate, setReplyRate] = useState(30); + const [startTime, setStartTime] = useState("08:00"); + const [endTime, setEndTime] = useState("20:00"); + const [days, setDays] = useState(0); + + const n = ids.length; + const baseOverMax = base > max; + + const start = async () => { + if (busy || n === 0) return; + if (customize && baseOverMax) { + toast.error("Starting volume can't exceed the maximum."); + return; + } + setBusy(true); + const patch: Partial = { + warmup_base: base, + warmup_increase: increase, + warmup_max: max, + warmup_reply_rate: replyRate, + warmup_start_time: startTime, + warmup_end_time: endTime, + warmup_days: days, + }; + // Apply settings (if customizing) then start, per mailbox. allSettled so + // one failure doesn't abort the rest. + const results = await Promise.allSettled( + ids.map(async (id) => { + if (customize) await updateEmail(id, patch); + await warmupLifecycle(id, "start"); + }), + ); + const failed = results.filter((r) => r.status === "rejected").length; + await queryClient.invalidateQueries({ queryKey: ["emails"] }); + await queryClient.invalidateQueries({ queryKey: ["analytics", "accounts"] }); + setBusy(false); + if (failed > 0) { + toast.error(`${failed} of ${n} mailbox${n > 1 ? "es" : ""} couldn't start`); + } else { + toast.success(`Warmup started for ${n} mailbox${n > 1 ? "es" : ""}`); + } + onComplete(); + onClose(); + }; + + return ( + + {open && ( + <> + +
+ + {/* Header */} +
+
+ +
+
+
Start warmup
+
+ {n} mailbox{n > 1 ? "es" : ""} selected +
+
+ +
+ + {/* Body */} +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ {baseOverMax && ( +

+ Starting volume can't exceed the maximum. +

+ )} +
+ + +
+
+ +
+ + +
+
+
+ + +

+ Leave all unselected to send every day. +

+
+
+
+ + {/* Footer */} +
+ + +
+
+
+ + )} +
+ ); +} + +function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} diff --git a/web/src/components/app/emails/InboxDetails.tsx b/web/src/components/app/emails/InboxDetails.tsx index 1d2f4f4f..6e5c169f 100644 --- a/web/src/components/app/emails/InboxDetails.tsx +++ b/web/src/components/app/emails/InboxDetails.tsx @@ -51,10 +51,11 @@ import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import EmailEditor from "../EmailEditor"; import TagSelector from "../popup/select/TagSelector"; -import TimeSelector from "../popup/select/TimeSelector"; +import TimeSelect from "@/components/ui/TimeSelect"; import WeekdayBitmask from "../campaigns/schedule/WeekdayBitmask"; import { Loading } from "@/components/loader"; import { NumberInput, TextInput } from "@/components/ui/field"; +import { useConfirm } from "@/hooks/context/confirm"; import { cn } from "@/lib/utils"; /* ── small themed primitives ─────────────────────── */ @@ -660,6 +661,7 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form: // read live state off the mailbox prop, which the lifecycle mutation patches // back into cache on success. const life = useWarmupLifecycle(mailbox.id); + const confirm = useConfirm(); const off = !mailbox.warmup; const paused = !!mailbox.warmup && !!mailbox.warmup_paused_at; const active = !!mailbox.warmup && !mailbox.warmup_paused_at; @@ -671,8 +673,17 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form: }); const stopReset = () => { - if (!window.confirm("Stop warmup and reset ramp progress? Restarting begins from the base volume. Use Pause to keep progress.")) return; - run("stop", "stopped"); + confirm.show( + "Stop warmup and reset ramp progress? Restarting begins from the base volume. Use Pause to keep progress.", + async () => { + try { + await life.mutateAsync("stop"); + toast.success("Warmup stopped"); + } catch (e) { + toast.error(buildError(e as unknown as AppError)); + } + }, + ); }; const baseOverMax = form.warmup_base > form.warmup_max; @@ -825,14 +836,14 @@ function WarmupTab({ form, update, status, mailbox, canWarmup = true }: { form: Sending window
- update({ warmup_start_time: v })} /> + update({ warmup_start_time: v })} /> - update({ warmup_end_time: v })} /> + update({ warmup_end_time: v })} />
- -
+ +
void; + className?: string; +}) { + return ( + + + } + label={to12Hour(value)} + className={cn("w-full justify-between", className)} + /> + + + {timeOptions.map((t) => ( + onChange(t.value)} + > + {t.name} + + ))} + + + ); +} From 276758e8b8f038a3a41b4686b47ad998783874ed Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:54:48 +0200 Subject: [PATCH 09/60] feat: improve contact drawer interactions --- .../components/app/contacts/ContactEdit.tsx | 30 +++++++++++------- .../app/contacts/ContactsEditBulk.tsx | 19 +++++++----- .../components/app/contacts/ContactsTable.tsx | 21 +++++++++---- .../app/contacts/contact-edit/NotesTab.tsx | 31 ++++++++++--------- 4 files changed, 62 insertions(+), 39 deletions(-) diff --git a/web/src/components/app/contacts/ContactEdit.tsx b/web/src/components/app/contacts/ContactEdit.tsx index afbd5866..147aa672 100644 --- a/web/src/components/app/contacts/ContactEdit.tsx +++ b/web/src/components/app/contacts/ContactEdit.tsx @@ -21,6 +21,7 @@ import { CheckIcon, CopyIcon, Loader2Icon, XIcon } from "lucide-react"; import toast from "react-hot-toast"; import useUpdateContact from "@/lib/api/hooks/app/contacts/useUpdateContact"; import useContact from "@/lib/api/hooks/app/contacts/useContact"; +import { useConfirm } from "@/hooks/context/confirm"; import type Contact from "@/lib/api/models/app/contacts/Contact"; import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign"; import type { AppError } from "@/lib/api/client/normalizeError"; @@ -38,10 +39,12 @@ export default function ContactEdit({ contacts, active, setActive, + initialTab, }: { contacts: Contact[]; active: string; setActive: React.Dispatch>; + initialTab?: ContactSlideTab; }) { const contact = React.useMemo( () => contacts.find((c) => c.id === active), @@ -55,6 +58,7 @@ export default function ContactEdit({ key={contact.id} contact={contact} onClose={() => setActive("")} + initialTab={initialTab} /> )} @@ -64,14 +68,17 @@ export default function ContactEdit({ function ContactEditPanel({ contact, onClose, + initialTab, }: { contact: Contact; onClose: () => void; + initialTab?: ContactSlideTab; }) { const update = useUpdateContact(contact.id); const detail = useContact(contact.id); + const confirm = useConfirm(); - const [tab, setTab] = React.useState("overview"); + const [tab, setTab] = React.useState(initialTab ?? "overview"); const [firstName, setFirstName] = React.useState(contact.first_name); const [lastName, setLastName] = React.useState(contact.last_name); @@ -162,16 +169,19 @@ function ContactEditPanel({ } } + // Close, guarding unsaved edits behind the in-app confirm (never window.confirm). + const requestClose = React.useCallback(() => { + if (dirty) confirm.show("Discard unsaved changes?", onClose); + else onClose(); + }, [dirty, onClose, confirm]); + React.useEffect(() => { function onKey(e: KeyboardEvent) { - if (e.key === "Escape") { - if (dirty && !window.confirm("Discard unsaved changes?")) return; - onClose(); - } + if (e.key === "Escape") requestClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [dirty, onClose]); + }, [requestClose]); const displayName = firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed contact"; @@ -185,7 +195,7 @@ function ContactEditPanel({ exit={{ opacity: 0 }} transition={{ duration: 0.15 }} className="fixed inset-0 z-[110] flex justify-end bg-slate-900/30 backdrop-blur-[2px]" - onMouseDown={onClose} + onMouseDown={requestClose} > - dirty && !window.confirm("Discard unsaved changes?") - ? undefined - : onClose() - } + onClose={requestClose} /> diff --git a/web/src/components/app/contacts/ContactsEditBulk.tsx b/web/src/components/app/contacts/ContactsEditBulk.tsx index ddc13da8..2b2c373f 100644 --- a/web/src/components/app/contacts/ContactsEditBulk.tsx +++ b/web/src/components/app/contacts/ContactsEditBulk.tsx @@ -25,6 +25,7 @@ import { Label, TextInput } from "@/components/ui/field"; import useUpdateContactsBulk from "@/lib/api/hooks/app/contacts/useUpdateContactsBulk"; import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; import useClickOutside from "@/hooks/useClickOutside"; +import { useConfirm } from "@/hooks/context/confirm"; import useFlipPlacement from "@/hooks/useFlipPlacement"; import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign"; import type { AppError } from "@/lib/api/client/normalizeError"; @@ -62,6 +63,7 @@ export default function ContactsEditBulk({ const [subscribeMode, setSubscribeMode] = React.useState<"unchanged" | "subscribe" | "unsubscribe">("unchanged"); const update = useUpdateContactsBulk(); + const confirm = useConfirm(); const dirty = campaignsAdd.length > 0 || @@ -71,6 +73,12 @@ export default function ContactsEditBulk({ fields.length > 0 || subscribeMode !== "unchanged"; + // Close, guarding unsaved edits behind the in-app confirm (never window.confirm). + const requestClose = React.useCallback(() => { + if (dirty) confirm.show("Discard bulk changes?", () => setActive(false)); + else setActive(false); + }, [dirty, confirm, setActive]); + function reset() { setCampaignsAdd([]); setCampaignsRemove([]); @@ -111,14 +119,11 @@ export default function ContactsEditBulk({ React.useEffect(() => { function onKey(e: KeyboardEvent) { - if (e.key === "Escape") { - if (dirty && !window.confirm("Discard bulk changes?")) return; - setActive(false); - } + if (e.key === "Escape") requestClose(); } if (active) window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [dirty, active, setActive]); + }, [requestClose, active]); return ( @@ -130,7 +135,7 @@ export default function ContactsEditBulk({ exit={{ opacity: 0 }} transition={{ duration: 0.15 }} className="fixed inset-0 z-[110] flex justify-end bg-slate-900/30 backdrop-blur-[2px]" - onMouseDown={() => (dirty && !window.confirm("Discard bulk changes?") ? undefined : setActive(false))} + onMouseDown={requestClose} > (dirty && !window.confirm("Discard bulk changes?") ? undefined : setActive(false))} + onClick={requestClose} aria-label="Close" className="ml-auto size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors" > diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 49229ca4..14be1602 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -37,6 +37,7 @@ import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import ContactFilters from "./ContactFilters"; import ContactEdit from "./ContactEdit"; +import type { ContactSlideTab } from "./contact-edit/tabs"; import type MiniCampaign from "@/lib/api/models/app/campaigns/MiniCampaign"; import ContactsEditBulk from "./ContactsEditBulk"; import { NewContactDialog } from "./NewContactDialog"; @@ -77,6 +78,13 @@ export default function ContactsTable({ const [del, setDelete] = React.useState(false); const [filtersOpen, setFiltersOpen] = React.useState(false); const [edit, setEdit] = React.useState(""); + // Which tab the drawer opens on. Row click → default (overview); the + // right-side 3-dots → "details", mirroring the mailbox 3-dots → settings. + const [editTab, setEditTab] = React.useState(undefined); + const openContact = React.useCallback((id: string, tab?: ContactSlideTab) => { + setEditTab(tab); + setEdit(id); + }, []); const [bulkEdit, setBulkEdit] = React.useState(false); const [subFilter, setSubFilter] = React.useState("all"); const [newOpen, setNewOpen] = React.useState(false); @@ -163,7 +171,7 @@ export default function ContactsTable({ } isSelectedAll={isSelectedAll} onToggleAll={toggleAll} - onRowClick={setEdit} + onRowClick={openContact} onDelete={(id) => confirm?.show(`Delete this contact?`, async () => { setSelected([id]); @@ -413,7 +421,7 @@ export default function ContactsTable({ activeCampaign={current_campaign} loading={contactsData.isLoading} /> - + setNewOpen(false)} /> void; isSelectedAll: boolean; onToggleAll: () => void; - onRowClick: (id: string) => void; + onRowClick: (id: string, tab?: ContactSlideTab) => void; onDelete: (id: string) => void; emptyTitle: string; emptyBody: string; @@ -650,7 +658,8 @@ function ContactsTableBody({ : "—"} e.stopPropagation()}> -
+ {/* Touch-safe: always visible on mobile, hover-reveal on desktop. */} +
-
- setInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === ",") { - e.preventDefault(); - add(input); - } - if (e.key === "Backspace" && input === "" && draft.length > 0) { - remove(draft[draft.length - 1]); - } - }} - placeholder="add a tag (eu-west, hetzner, warmup-only) and press enter" - className="w-full border rounded px-3 py-1.5 text-sm" - /> - {suggestions.length > 0 && ( -
- {suggestions.map((s) => ( - - ))} -
- )} -
+ { + if (!o) setDismissed(true); + }} + align="start" + > + + setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + add(input); + } + if (e.key === "Backspace" && input === "" && draft.length > 0) { + remove(draft[draft.length - 1]); + } + }} + placeholder="add a tag (eu-west, hetzner, warmup-only) and press enter" + className="w-full border rounded px-3 py-1.5 text-sm" + /> + + + {suggestions.map((s) => ( + + ))} + +
- - {open && ( - - - {options.map((o) => ( - - ))} - - )} - -
+ + + + + + onChange("")} selected={value === ""}> + All + + {options.map((o) => ( + onChange(o)} selected={value === o}> + {o} + + ))} + + ); } diff --git a/web/src/app/app/campaigns/page.tsx b/web/src/app/app/campaigns/page.tsx index df404c66..c25023d3 100644 --- a/web/src/app/app/campaigns/page.tsx +++ b/web/src/app/app/campaigns/page.tsx @@ -344,7 +344,7 @@ export default function CampaignsPage() { (cstatus === "active" && stopCampaign.isPending) || (cstatus !== "active" && startCampaign.isPending) } - className="size-6 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shrink-0 disabled:opacity-30" + className="size-6 rounded text-slate-400 hover:text-slate-900 hover:bg-slate-100 flex items-center justify-center opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity shrink-0 disabled:opacity-30" aria-label={ cstatus === "active" ? "Pause campaign" : "Start campaign" } diff --git a/web/src/app/app/crm/deals/page.tsx b/web/src/app/app/crm/deals/page.tsx index c24383bf..c81ee259 100644 --- a/web/src/app/app/crm/deals/page.tsx +++ b/web/src/app/app/crm/deals/page.tsx @@ -31,13 +31,18 @@ import { TopbarAction, } from "@/components/layout/Page"; import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuTrigger, + PopoverMenuContent, + PopoverMenuItem, +} from "@/components/ui/popover-menu"; import usePipelines from "@/lib/api/hooks/app/crm/pipelines/usePipelines"; import useDeals from "@/lib/api/hooks/app/crm/deals/useDeals"; import useCreateDeal from "@/lib/api/hooks/app/crm/deals/useCreateDeal"; import useUpdateDeal from "@/lib/api/hooks/app/crm/deals/useUpdateDeal"; import useDeleteDeal from "@/lib/api/hooks/app/crm/deals/useDeleteDeal"; import { useConfirm } from "@/hooks/context/confirm"; -import useClickOutside from "@/hooks/useClickOutside"; import type Deal from "@/lib/api/models/app/crm/Deal"; import type { Stage } from "@/lib/api/models/app/crm/Pipeline"; import type { AppError } from "@/lib/api/client/normalizeError"; @@ -316,50 +321,31 @@ function PipelinePicker({ onChange: (id: string) => void; }) { const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); const cur = pipelines.find((p) => p.id === currentId); return ( -
- - - {open && ( - + + + + + {pipelines.map((p) => ( + onChange(p.id)} + selected={p.id === currentId} > - {pipelines.map((p) => ( - - ))} - - )} - -
+ {p.name} + + ))} + + ); } @@ -680,64 +666,47 @@ function StagePill({ onChange: (id: string) => void; }) { const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); const cur = stages.find((s) => s.id === value); return ( -
- - - {open && ( - + + + + + {stages.map((s) => ( + onChange(s.id)} + selected={s.id === value} + icon={ + + } > - {stages.map((s) => ( - - ))} - - )} - -
+ {s.name} + + ))} + + ); } diff --git a/web/src/app/app/crm/pipelines/page.tsx b/web/src/app/app/crm/pipelines/page.tsx index 310a02c9..4d1c507d 100644 --- a/web/src/app/app/crm/pipelines/page.tsx +++ b/web/src/app/app/crm/pipelines/page.tsx @@ -32,6 +32,12 @@ import { TopbarAction, } from "@/components/layout/Page"; import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; import usePipelines from "@/lib/api/hooks/app/crm/pipelines/usePipelines"; import useCreatePipeline from "@/lib/api/hooks/app/crm/pipelines/useCreatePipeline"; import useDeletePipeline from "@/lib/api/hooks/app/crm/pipelines/useDeletePipeline"; @@ -44,7 +50,6 @@ import type Pipeline from "@/lib/api/models/app/crm/Pipeline"; import type { Stage } from "@/lib/api/models/app/crm/Pipeline"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -import useClickOutside from "@/hooks/useClickOutside"; const STAGE_COLORS = [ { id: "slate", bg: "bg-slate-400", hex: "#94a3b8" }, @@ -166,8 +171,6 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) { const [name, setName] = React.useState(pipeline.name); const [menuOpen, setMenuOpen] = React.useState(false); const [addStageOpen, setAddStageOpen] = React.useState(false); - const menuRef = React.useRef(null); - useClickOutside(menuRef, () => setMenuOpen(false)); React.useEffect(() => setName(pipeline.name), [pipeline.name]); @@ -268,50 +271,32 @@ function PipelineCard({ pipeline }: { pipeline: Pipeline }) { Stage -
- - - {menuOpen && ( - - - - - )} - -
+ + + + + + setRenaming(true)} + icon={} + > + Rename + + } + danger + > + Delete pipeline + + +
@@ -368,8 +353,6 @@ function StageCell({ stage, pipelineId: _pipelineId }: { stage: Stage; pipelineI const [editing, setEditing] = React.useState(false); const [name, setName] = React.useState(stage.name); const [colorOpen, setColorOpen] = React.useState(false); - const colorRef = React.useRef(null); - useClickOutside(colorRef, () => setColorOpen(false)); const color = colorForHex(stage.color); React.useEffect(() => setName(stage.name), [stage.name]); @@ -422,39 +405,32 @@ function StageCell({ stage, pipelineId: _pipelineId }: { stage: Stage; pipelineI return (
-
-
+ + +
+ + {editing ? ( @@ -782,44 +758,35 @@ function AddStageDialog({ function ColorDot({ hex, onChange }: { hex: string; onChange: (hex: string) => void }) { const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); const color = colorForHex(hex); return ( -
- - - {open && ( - - {STAGE_COLORS.map((c) => ( -
+ + + + + +
+ {STAGE_COLORS.map((c) => ( +
+
+
); } diff --git a/web/src/app/app/crm/tasks/page.tsx b/web/src/app/app/crm/tasks/page.tsx index 26faa86b..2a7d8dec 100644 --- a/web/src/app/app/crm/tasks/page.tsx +++ b/web/src/app/app/crm/tasks/page.tsx @@ -33,12 +33,17 @@ import { TopbarAction, } from "@/components/layout/Page"; import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; import useCRMTasks from "@/lib/api/hooks/app/crm/tasks/useCRMTasks"; import useCreateCRMTask from "@/lib/api/hooks/app/crm/tasks/useCreateCRMTask"; import useUpdateCRMTask from "@/lib/api/hooks/app/crm/tasks/useUpdateCRMTask"; import useDeleteCRMTask from "@/lib/api/hooks/app/crm/tasks/useDeleteCRMTask"; import { useConfirm } from "@/hooks/context/confirm"; -import useClickOutside from "@/hooks/useClickOutside"; import type CRMTask from "@/lib/api/models/app/crm/CRMTask"; import type { CRMTaskPriority, CRMTaskStatus } from "@/lib/api/models/app/crm/CRMTask"; import type { AppError } from "@/lib/api/client/normalizeError"; @@ -591,50 +596,33 @@ function PriorityPill({ onChange: (p: CRMTaskPriority) => void; }) { const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); const cur = PRIORITIES.find((p) => p.id === value)!; return ( -
- - - {open && ( - + + + + + {PRIORITIES.map((p) => ( + onChange(p.id)} + selected={p.id === value} + icon={} > - {PRIORITIES.map((p) => ( - - ))} - - )} - -
+ {p.label} + + ))} + + ); } diff --git a/web/src/app/app/integrations/_components/ConnectionDetail.tsx b/web/src/app/app/integrations/_components/ConnectionDetail.tsx index 0bacfb1e..f8d22a0b 100644 --- a/web/src/app/app/integrations/_components/ConnectionDetail.tsx +++ b/web/src/app/app/integrations/_components/ConnectionDetail.tsx @@ -27,6 +27,7 @@ import { import toast from "react-hot-toast"; import { Label, TextInput } from "@/components/ui/field"; +import { useConfirm } from "@/hooks/context/confirm"; import useConnectionDetail from "@/lib/api/hooks/app/integrations/useConnectionDetail"; import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration"; import { @@ -70,6 +71,7 @@ export default function ConnectionDetail({ const [adding, setAdding] = React.useState(false); const [busy, setBusy] = React.useState(false); + const confirm = useConfirm(); const conn = detail.data?.connection ?? connection; const events = detail.data?.events ?? []; @@ -94,15 +96,16 @@ export default function ConnectionDetail({ } } - async function handleDisconnect() { - if (!window.confirm(`Disconnect ${conn.label}? Automations using it will stop.`)) return; - try { - await disconnect.mutateAsync(conn.id); - toast.success("Disconnected"); - onClose(); - } catch { - toast.error("Disconnect failed"); - } + function handleDisconnect() { + confirm.show(`Disconnect ${conn.label}? Automations using it will stop.`, async () => { + try { + await disconnect.mutateAsync(conn.id); + toast.success("Disconnected"); + onClose(); + } catch { + toast.error("Disconnect failed"); + } + }); } async function addAutomation(eventType: string, config: Record) { diff --git a/web/src/app/app/settings/members/page.tsx b/web/src/app/app/settings/members/page.tsx index 6b78cc86..94a7c62a 100644 --- a/web/src/app/app/settings/members/page.tsx +++ b/web/src/app/app/settings/members/page.tsx @@ -19,10 +19,13 @@ import { XIcon, } from "lucide-react"; import toast from "react-hot-toast"; -import { AnimatePresence, motion } from "framer-motion"; -import { Label, TextInput } from "@/components/ui/field"; +import { Label } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; import { useConfirm } from "@/hooks/context/confirm"; -import useClickOutside from "@/hooks/useClickOutside"; import useFeatureAccess from "@/hooks/useFeatureAccess"; import useMembers from "@/lib/api/hooks/app/organizations/useMembers"; import usePendingInvitations from "@/lib/api/hooks/app/organizations/usePendingInvitations"; @@ -226,7 +229,7 @@ export default function MembersSettingsPage() { type="button" onClick={() => doRemove(m.user_id, email)} aria-label="Remove member" - className="size-6 rounded text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100" + className="size-6 rounded text-slate-400 hover:text-red-600 hover:bg-red-50 inline-flex items-center justify-center transition-colors opacity-100 md:opacity-0 md:group-hover:opacity-100" > @@ -284,7 +287,7 @@ export default function MembersSettingsPage() { {access.isOwner && ( -
+
- - {open && ( - - {assignable.map((r) => { - const selected = r.id === value; - return ( - - ); - })} - - )} - -
+ + + + + + {assignable.map((r) => { + const selected = r.id === value; + return ( + + ); + })} + + ); } diff --git a/web/src/app/app/templates/page.tsx b/web/src/app/app/templates/page.tsx index 55e9e038..dd1ebc5c 100644 --- a/web/src/app/app/templates/page.tsx +++ b/web/src/app/app/templates/page.tsx @@ -31,13 +31,19 @@ import { TopbarAction, } from "@/components/layout/Page"; import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuSeparator, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; import useTemplates from "@/lib/api/hooks/app/templates/useTemplates"; import useCreateTemplate from "@/lib/api/hooks/app/templates/useCreateTemplate"; import useUpdateTemplate from "@/lib/api/hooks/app/templates/useUpdateTemplate"; import useDeleteTemplate from "@/lib/api/hooks/app/templates/useDeleteTemplate"; import useDuplicateTemplate from "@/lib/api/hooks/app/templates/useDuplicateTemplate"; import useReorderTemplates from "@/lib/api/hooks/app/templates/useReorderTemplates"; -import useClickOutside from "@/hooks/useClickOutside"; import { useConfirm } from "@/hooks/context/confirm"; import type Template from "@/lib/api/models/app/templates/Template"; import type { AppError } from "@/lib/api/client/normalizeError"; @@ -271,13 +277,10 @@ function TemplateRow({ const del = useDeleteTemplate(); const confirm = useConfirm(); const [menuOpen, setMenuOpen] = React.useState(false); - const menuRef = React.useRef(null); - useClickOutside(menuRef, () => setMenuOpen(false)); const preview = previewText(template); async function doDuplicate() { - setMenuOpen(false); try { await toast.promise(duplicate.mutateAsync(template.id), { loading: "Duplicating…", @@ -290,7 +293,6 @@ function TemplateRow({ } function doDelete() { - setMenuOpen(false); confirm?.show(`Delete template "${template.name}"? This can't be undone.`, async () => { try { await toast.promise(del.mutateAsync(template.id), { @@ -356,57 +358,42 @@ function TemplateRow({ )} -
- - - {menuOpen && ( - + + + - -
- - - )} - + + + + + } + > + Edit + + } + > + Duplicate + + + } + > + Delete + + +
); @@ -458,6 +445,7 @@ function TemplateEditor({ }) { const create = useCreateTemplate(); const update = useUpdateTemplate(); + const confirm = useConfirm(); const [name, setName] = React.useState(""); const [subject, setSubject] = React.useState(""); @@ -486,19 +474,22 @@ function TemplateEditor({ }, [state]); function applyPreset(p: TemplatePreset) { - // Only fill empty fields if the user has already started typing, - // so picking a preset by accident doesn't trash their work. + const apply = () => { + setName(p.name); + setSubject(p.subject); + setBodyPlain(p.body_plain); + setBodyHTML(""); + setShowHTML(false); + setActivePresetId(p.id); + }; + // Only warn if the user has already started typing, so picking a preset + // by accident doesn't trash their work. const dirty = name.trim() !== "" || subject.trim() !== "" || bodyPlain.trim() !== "" || bodyHTML.trim() !== ""; if (dirty && activePresetId !== p.id) { - const ok = window.confirm("Replace what you have so far with this template?"); - if (!ok) return; + confirm.show("Replace what you have so far with this template?", apply); + return; } - setName(p.name); - setSubject(p.subject); - setBodyPlain(p.body_plain); - setBodyHTML(""); - setShowHTML(false); - setActivePresetId(p.id); + apply(); } function clearPreset() { @@ -722,44 +713,25 @@ function TemplateEditor({ function VariableMenu({ onPick }: { onPick: (token: string) => void }) { const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); - useClickOutside(ref, () => setOpen(false)); return ( -
- - - {open && ( - - {VARIABLE_HINTS.map((v) => ( - - ))} - - )} - -
+ + + + + + {VARIABLE_HINTS.map((v) => ( + onPick(v)}> + {v} + + ))} + + ); } diff --git a/web/src/components/app/Calendar.tsx b/web/src/components/app/Calendar.tsx index 76fde1ea..c26b4d3e 100644 --- a/web/src/components/app/Calendar.tsx +++ b/web/src/components/app/Calendar.tsx @@ -1,5 +1,6 @@ import { AnimatePresence, motion } from "framer-motion"; import React from "react"; +import { createPortal } from "react-dom"; import { format, addMonths, subMonths, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isSameDay, isToday } from 'date-fns'; import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react"; @@ -16,6 +17,15 @@ export default function Calendar({ }) { const [currentMonth, setCurrentMonth] = React.useState(date || new Date()); + // Zero-size anchor rendered at the calendar's natural DOM position (inside + // the caller's `relative` trigger wrapper). We measure its parent — the + // trigger box — to position the portaled panel, instead of relying on + // `absolute top-full`, which clips inside overflow-hidden/scroll ancestors + // (modals, scroll areas, cards) and never flips up when there's no room. + const anchorRef = React.useRef(null); + const panelRef = React.useRef(null); + const [pos, setPos] = React.useState<{ top: number; left: number } | null>(null); + const handleDateSelect = (date: Date) => { onSubmit(date); close(); @@ -33,83 +43,150 @@ export default function Calendar({ return days; }; + // Position the portaled panel fixed at the trigger rect, flipping above the + // trigger when there isn't room below in the viewport. + React.useLayoutEffect(() => { + if (!active) { + setPos(null); + return; + } + const compute = () => { + const trigger = anchorRef.current?.parentElement; + const panel = panelRef.current; + if (!trigger) return; + const r = trigger.getBoundingClientRect(); + const sideOffset = 8; + const ch = panel?.offsetHeight ?? 0; + const cw = panel?.offsetWidth ?? 320; // w-80 + let top = r.bottom + sideOffset; + if (ch && top + ch > window.innerHeight - 8) { + const above = r.top - ch - sideOffset; + if (above >= 8) top = above; + } + let left = r.left; + if (cw && left + cw > window.innerWidth - 8) left = window.innerWidth - 8 - cw; + if (left < 8) left = 8; + setPos({ top, left }); + }; + compute(); + window.addEventListener("resize", compute); + window.addEventListener("scroll", compute, true); + return () => { + window.removeEventListener("resize", compute); + window.removeEventListener("scroll", compute, true); + }; + }, [active, currentMonth]); + + // The panel is portaled to , outside the caller's wrapper ref. The + // callers dismiss on a document "mousedown" that checks ref.contains(target), + // so a mousedown inside the (portaled) panel would otherwise close the + // calendar on month-nav. Stop mousedown/touchstart at the panel via a NATIVE + // listener (reliable regardless of React's event delegation) — "click" is not + // stopped, so date selection and month navigation still fire. + React.useEffect(() => { + const el = panelRef.current; + if (!active || !el) return; + const stop = (e: Event) => e.stopPropagation(); + el.addEventListener("mousedown", stop); + el.addEventListener("touchstart", stop); + return () => { + el.removeEventListener("mousedown", stop); + el.removeEventListener("touchstart", stop); + }; + }, [active, pos]); + const calendarDays = generateCalendarDays(); return ( - - {active && ( - -
-
-
setCurrentMonth(subMonths(currentMonth, 1))} - className="p-1 ripple cursor-pointer hover:bg-gray-100 rounded-lg transition-colors" + <> +
- - - {format(currentMonth, 'yyyy. MMMM')} - - -
setCurrentMonth(addMonths(currentMonth, 1))} - className="p-1 ripple cursor-pointer hover:bg-gray-100 rounded-lg transition-colors" - > - -
-
- -
-
- {['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map(day => ( -
- {day} -
- ))} -
- -
- {calendarDays.map((day) => { - const isSelected = date && isSameDay(day, date); - const isCurrentDay = isToday(day); - const isCurrentMonth = isSameMonth(day, currentMonth); - - return ( +
+
{ - handleDateSelect(day) - if (!isCurrentMonth) { - setCurrentMonth(day) - } - }} - className={` - relative cursor-pointer ripple w-10 h-10 flex items-center justify-center text-sm rounded-lg transition-[background] - ${isSelected - ? 'bg-blue-500 text-white shadow-md' - : isCurrentDay - ? 'bg-blue-50 text-blue-600 font-semibold' - : isCurrentMonth - ? 'text-gray-700 hover:bg-gray-100' - : 'text-gray-200 hover:bg-gray-50' - } - `} + onClick={() => setCurrentMonth(subMonths(currentMonth, 1))} + className="p-1 ripple cursor-pointer hover:bg-gray-100 rounded-lg transition-colors" > - {format(day, 'd')} +
- ); - })} -
-
-
- - )} - + + + {format(currentMonth, 'yyyy. MMMM')} + + +
setCurrentMonth(addMonths(currentMonth, 1))} + className="p-1 ripple cursor-pointer hover:bg-gray-100 rounded-lg transition-colors" + > + +
+
+ +
+
+ {['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map(day => ( +
+ {day} +
+ ))} +
+ +
+ {calendarDays.map((day) => { + const isSelected = date && isSameDay(day, date); + const isCurrentDay = isToday(day); + const isCurrentMonth = isSameMonth(day, currentMonth); + + return ( +
{ + handleDateSelect(day) + if (!isCurrentMonth) { + setCurrentMonth(day) + } + }} + className={` + relative cursor-pointer ripple w-10 h-10 flex items-center justify-center text-sm rounded-lg transition-[background] + ${isSelected + ? 'bg-blue-500 text-white shadow-md' + : isCurrentDay + ? 'bg-blue-50 text-blue-600 font-semibold' + : isCurrentMonth + ? 'text-gray-700 hover:bg-gray-100' + : 'text-gray-200 hover:bg-gray-50' + } + `} + > + {format(day, 'd')} +
+ ); + })} +
+
+
+
+ )} +
, + document.body, + )} + ) } From 0c49d3c607d4374da9bb9f725e3fe40a0bc18c2c Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:55:26 +0200 Subject: [PATCH 11/60] feat: improve app routing and browser chrome --- web/src/app/app/not-found.tsx | 144 ++++++++++++++++++ web/src/app/auth/hooks/useLoginConfirmForm.ts | 13 ++ web/src/app/auth/login/page.tsx | 31 ++-- web/src/app/not-found.tsx | 70 ++++++++- web/src/components/layout/AppNav.tsx | 2 +- web/src/components/layout/OrgSwitcher.tsx | 25 +-- web/src/hooks/RealtimeManager.tsx | 12 ++ web/src/hooks/useDocumentTitle.ts | 36 ++++- web/src/lib/faviconBadge.ts | 113 ++++++++++++++ web/src/main.tsx | 19 +++ 10 files changed, 434 insertions(+), 31 deletions(-) create mode 100644 web/src/app/app/not-found.tsx create mode 100644 web/src/lib/faviconBadge.ts diff --git a/web/src/app/app/not-found.tsx b/web/src/app/app/not-found.tsx new file mode 100644 index 00000000..00af2263 --- /dev/null +++ b/web/src/app/app/not-found.tsx @@ -0,0 +1,144 @@ +import { Link, useNavigate, useLocation } from "react-router-dom"; +import { motion } from "framer-motion"; +import { + MailX, + ArrowLeft, + LayoutDashboard, + Search, + ArrowRight, + MailIcon, + MegaphoneIcon, + UsersIcon, + BarChart3Icon, +} from "lucide-react"; +import { useAppStore } from "@/stores"; +import { cn } from "@/lib/utils"; + +const dests = [ + { title: "Accounts", url: "/app/emails", icon: MailIcon, hint: "mailboxes & senders" }, + { title: "Campaigns", url: "/app/campaigns", icon: MegaphoneIcon, hint: "sequences & sends" }, + { title: "Contacts", url: "/app/contacts", icon: UsersIcon, hint: "people & lists" }, + { title: "Analytics", url: "/app/analytics", icon: BarChart3Icon, hint: "opens, clicks, replies" }, +]; + +export default function DashboardNotFound() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen); + + const goBack = () => { + if (window.history.state?.idx > 0) navigate(-1); + else navigate("/app"); + }; + + return ( +
+ + {/* Glyph: MailX in a slate ring + one sky stamp dot */} +
+
+ +
+ +
+ + {/* Eyebrow */} + + Undeliverable · 404 + + + {/* Headline */} +

+ This page bounced +

+ + {/* Subhead */} +

+ We couldn't find a page at that address. The link may be broken, or the page may have + moved. Nothing was lost — pick up where you left off below. +

+ + {/* Bounce-report chip */} +
+
+ + to + + {pathname} +
+
+ 404 + · + no_such_route +
+
+ + {/* Actions */} +
+ + + Dashboard + + +
+ + {/* Popular destinations */} +
+ + Popular destinations + +
+ {dests.map((d, i) => ( + + + + + + {d.title} + + {d.hint} + + + ))} +
+
+
+
+ ); +} diff --git a/web/src/app/auth/hooks/useLoginConfirmForm.ts b/web/src/app/auth/hooks/useLoginConfirmForm.ts index 79fa4ff1..b3edb673 100644 --- a/web/src/app/auth/hooks/useLoginConfirmForm.ts +++ b/web/src/app/auth/hooks/useLoginConfirmForm.ts @@ -1,8 +1,10 @@ import type React from "react"; import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import useLoginConfirm from "@/lib/api/hooks/auth/useLoginConfirm"; import { saveTokens } from "@/lib/auth"; +import getUser from "@/lib/api/client/auth/getUser"; import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -10,6 +12,7 @@ import buildError from "@/lib/helper/buildError"; export function useLoginConfirmForm() { const params = useParams(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const loginConfirm = useLoginConfirm(); const mail = params["to"] ?? ""; @@ -26,6 +29,16 @@ export function useLoginConfirmForm() { { loading: "Loading...", success: "Successfully authorized.", error: (err: AppError) => buildError(err) } ); saveTokens(Object.fromEntries(Object.entries(r).map(([k, v]) => [k, String(v)]))); + // Drop any logged-out cache, then prime the profile with the NEW token + // BEFORE navigating into the gated shell. Navigating immediately raced + // UserProvider's mount (useUser is refetchOnMount:false) and left the + // loader spinning until a manual reload / window refocus. + queryClient.clear(); + try { + await queryClient.fetchQuery({ queryKey: ["auth", "me"], queryFn: getUser }); + } catch { + // UserProvider re-attempts and redirects to login on a real failure. + } navigate("/app/emails"); } finally { setPending(false); } }; diff --git a/web/src/app/auth/login/page.tsx b/web/src/app/auth/login/page.tsx index b6ded89f..0523e450 100644 --- a/web/src/app/auth/login/page.tsx +++ b/web/src/app/auth/login/page.tsx @@ -19,6 +19,7 @@ import useLoginConfirm from "@/lib/api/hooks/auth/useLoginConfirm"; import useRegister from "@/lib/api/hooks/auth/useRegister"; import useRegisterConfirm from "@/lib/api/hooks/auth/useRegisterConfirm"; import { saveTokens } from "@/lib/auth"; +import getUser from "@/lib/api/client/auth/getUser"; import { WEBSITE_URL } from "@/lib/information"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -198,13 +199,24 @@ export default function LoginPage() { const explicitPasskeyChallengeRef = useRef(null); const explicitPasskeyChallengePendingRef = useRef(false); - const completeSession = useCallback((token: Token) => { + const completeSession = useCallback(async (token: Token) => { saveTokens(token as unknown as Record); // Drop any cache from the logged-out state. `useUser` is // `refetchOnMount: false`, so a stale/errored ["auth","me"] entry would - // otherwise survive into the app shell and leave every gated page empty - // until a manual reload or window-focus refetch. Mirrors useLogout(). + // otherwise survive into the app shell. Mirrors useLogout(). queryClient.clear(); + // Prime identity with the NEW token BEFORE entering the gated shell. + // Navigating the instant tokens are saved raced UserProvider's mount: + // with no cached profile and refetchOnMount:false, the loader could spin + // until a manual reload / window refocus (the reported infinite load). + // Fetching /auth/me here resolves it deterministically — the network and + // token are known-good (login just succeeded) — and a genuine auth + // failure surfaces as a redirect rather than a hang. + try { + await queryClient.fetchQuery({ queryKey: ["auth", "me"], queryFn: getUser }); + } catch { + // UserProvider re-attempts and redirects to login on a real failure. + } navigate("/app/emails"); }, [navigate, queryClient]); @@ -218,7 +230,7 @@ export default function LoginPage() { try { const token = await passkeyLogin({ conditional: true }); toast.success("Welcome back!"); - completeSession(token); + await completeSession(token); } catch (e) { // Cancel / no-passkey is expected here; report only real failures. if (!(e instanceof PasskeyCancelled)) Sentry.captureException(e); @@ -280,7 +292,7 @@ export default function LoginPage() { const token = await finishPasskeyLogin(challenge); signedIn = true; toast.success("Welcome back!"); - completeSession(token); + await completeSession(token); } catch (e) { if (e instanceof PasskeyCancelled) { // WebAuthn can't distinguish "no passkey on this device" from @@ -419,13 +431,12 @@ export default function LoginPage() { if (mode === "signin") { const res = await loginConfirmMutation.mutateAsync({ session, code, turnstile: token }); toast.success("Welcome back!"); - saveTokens(res as unknown as Record); - // See completeSession: clear stale logged-out cache so the - // app shell refetches identity with the new token. - queryClient.clear(); // Nudge passwordless enrollment once the dashboard loads. try { sessionStorage.setItem(SUGGEST_PASSKEY_FLAG, "1"); } catch { /* storage unavailable */ } - navigate("/app/emails"); + // completeSession saves tokens, clears stale cache, primes the + // profile with the new token, then navigates — so the gated + // shell never mounts without identity (no infinite loader). + await completeSession(res as unknown as Token); } else { await registerConfirmMutation.mutateAsync({ session, code, turnstile: token }); toast.success("Account created! Please sign in."); diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx index 7192b293..e47eebfb 100644 --- a/web/src/app/not-found.tsx +++ b/web/src/app/not-found.tsx @@ -1,13 +1,71 @@ import { Link } from "react-router-dom"; +import { motion } from "framer-motion"; +import { MailX, LayoutDashboard, LogIn } from "lucide-react"; +import { Logo } from "@/components/svg"; export default function NotFound() { return ( -
-
-

404 Error

-

Page not found.

- Go back +
+ + {/* Brand wordmark */} +
+ + + Warmbly +
+ + {/* Glyph: MailX in a slate ring + one sky stamp dot */} +
+
+ +
+ +
+ + {/* Eyebrow */} + + Undeliverable · 404 + + + {/* Headline */} +

+ This page bounced +

+ + {/* Subhead */} +

+ We couldn't find a page at that address. The link may be broken, or the page may have + moved. Head back to a safe place below. +

+ + {/* Actions */} +
+ + Back to dashboard + + + Sign in + +
+
); -} \ No newline at end of file +} diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index c3419512..555f1b82 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -154,7 +154,7 @@ function NavRow({ item }: { item: NavItem }) { ) : ( badge != null && badge > 0 && ( - + {badge > 99 ? "99+" : badge} ) diff --git a/web/src/components/layout/OrgSwitcher.tsx b/web/src/components/layout/OrgSwitcher.tsx index c60b6830..069c50ee 100644 --- a/web/src/components/layout/OrgSwitcher.tsx +++ b/web/src/components/layout/OrgSwitcher.tsx @@ -46,6 +46,7 @@ export function OrgSwitcher() { const [newOpen, setNewOpen] = React.useState(false); const name = currentOrganization?.name ?? "Workspace"; + const orgAvatar = currentOrganization?.avatar_url ?? currentOrganization?.avatar; // Picking an org from the sidebar used to call only the local // zustand action, which left the server session's @@ -70,17 +71,21 @@ export function OrgSwitcher() { @@ -108,15 +113,15 @@ export function OrgSwitcher() { disabled={switchOrgMutation.isPending} selected={org.id === currentOrganization?.id} icon={ - + {avatar ? ( ) : ( - + {initials(org.name)} )} diff --git a/web/src/hooks/RealtimeManager.tsx b/web/src/hooks/RealtimeManager.tsx index c5be506a..cb408256 100644 --- a/web/src/hooks/RealtimeManager.tsx +++ b/web/src/hooks/RealtimeManager.tsx @@ -3,6 +3,7 @@ import { useSocket } from './context/socket' import { useAppStore } from '@/stores' import { useUserProfile } from './context/user' import { useRealtimeEvents } from './useRealtimeEvents' +import getUnseenCount from '@/lib/api/client/app/unibox/getUnseenCount' export function RealtimeManager({ children }: { children: React.ReactNode }) { const { isConnected, reconnectAttempt, joinChannel, leaveChannel } = useSocket() @@ -13,6 +14,7 @@ export function RealtimeManager({ children }: { children: React.ReactNode }) { const setConnectionQuality = useAppStore((s) => s.setConnectionQuality) const addJoinedChannel = useAppStore((s) => s.addJoinedChannel) const removeJoinedChannel = useAppStore((s) => s.removeJoinedChannel) + const setUnseenCount = useAppStore((s) => s.setUnseenCount) const prevOrgIdRef = useRef(null) const heartbeatRef = useRef | null>(null) @@ -97,6 +99,16 @@ export function RealtimeManager({ children }: { children: React.ReactNode }) { } }, [isConnected, setConnectionQuality]) + // Seed the unread inbox count from the server once on load. The store value + // is otherwise session-only (realtime increments it but it starts at 0), so + // seeding makes the title + favicon badge reflect the real unread count + // immediately. Best-effort — a failure just leaves the count at 0. + useEffect(() => { + getUnseenCount() + .then((r) => { if (r && typeof r.count === 'number') setUnseenCount(r.count) }) + .catch(() => { /* endpoint unavailable — keep current count */ }) + }, [setUnseenCount]) + // Set up event-to-store routing useRealtimeEvents() diff --git a/web/src/hooks/useDocumentTitle.ts b/web/src/hooks/useDocumentTitle.ts index 93e1719e..2a44b202 100644 --- a/web/src/hooks/useDocumentTitle.ts +++ b/web/src/hooks/useDocumentTitle.ts @@ -1,5 +1,7 @@ import { useEffect } from "react"; import { useLocation } from "react-router-dom"; +import { useCurrentOrg, useUnseenCount } from "@/stores"; +import { setFaviconBadge } from "@/lib/faviconBadge"; /* * Dynamic document titles for the SPA. @@ -84,17 +86,43 @@ function titleForPath(pathname: string): string { for (const [pattern, paramLabel] of PARAM_ROUTES) { if (pattern.test(pathname)) return `${paramLabel} | ${BRAND}`; } - return BRAND; + // Unmatched pathname = a genuine 404; surface that in the tab title. + return `Page not found | ${BRAND}`; +} + +// Fold the current workspace in as context, before the brand: +// "Mailboxes | Warmbly" -> "Mailboxes · Acme | Warmbly" +// "Warmbly" -> "Acme | Warmbly" +function withOrg(base: string, org?: string): string { + if (!org) return base; + const suffix = ` | ${BRAND}`; + if (base === BRAND) return `${org}${suffix}`; + if (base.endsWith(suffix)) return `${base.slice(0, -suffix.length)} · ${org}${suffix}`; + return `${base} · ${org}`; } /** - * Sets document.title from the current route. Pass an explicit `override` to + * Sets document.title from the current route, folding in the current workspace + * and a leading unread-count prefix ("(3) …") on the dashboard, and mirrors the + * unread count onto the favicon as a red badge. Pass an explicit `override` to * title a page from loaded data (e.g. a campaign name) instead of the map. */ export function useDocumentTitle(override?: string) { const { pathname } = useLocation(); + const org = useCurrentOrg(); + const unread = useUnseenCount(); useEffect(() => { - document.title = override ? `${override} | ${BRAND}` : titleForPath(pathname); - }, [pathname, override]); + // Workspace context + the unread badge are dashboard-only; on auth / + // marketing routes (or with no selected workspace) use the plain title. + const onApp = pathname.startsWith("/app"); + const count = onApp ? unread : 0; + + const base = override ? `${override} | ${BRAND}` : titleForPath(pathname); + const titled = withOrg(base, onApp ? org?.name : undefined); + const prefix = count > 0 ? `(${count > 99 ? "99+" : count}) ` : ""; + document.title = `${prefix}${titled}`; + + setFaviconBadge(count); + }, [pathname, override, org?.name, unread]); } diff --git a/web/src/lib/faviconBadge.ts b/web/src/lib/faviconBadge.ts new file mode 100644 index 00000000..26ef6302 --- /dev/null +++ b/web/src/lib/faviconBadge.ts @@ -0,0 +1,113 @@ +// Live favicon notification badge — the red "unread" disc on the tab icon, +// like X / Gmail / Slack / Discord. +// +// Technique: draw the base favicon onto an offscreen canvas (clipped to a +// circle so the corner badge reads cleanly), overlay a red disc + white count, +// and point a dedicated at the canvas data URL. At count 0 we +// remove the managed link so the static favicon resumes. +// +// Notes: +// - The base PNG is same-origin (web/public), so the canvas is NOT tainted +// and toDataURL() is safe. +// - We render at devicePixelRatio so the small number stays crisp on retina. +// - Safari historically ignores JS favicon changes — the document.title count +// is the reliable cross-browser surface; the badge is progressive polish. + +const BASE_ICON = "/favicon-96x96.png"; +const LINK_ID = "favicon-badge"; +const BADGE_COLOR = "#ef4444"; // red-500 — universal "unread" affordance + +let baseReady: Promise | null = null; +let lastReq = 0; + +function loadBase(): Promise { + if (baseReady) return baseReady; + baseReady = new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = BASE_ICON; + }); + return baseReady; +} + +function clearBadge() { + document.getElementById(LINK_ID)?.remove(); +} + +/** + * Render (or clear) the unread badge on the favicon. Count <= 0 restores the + * plain favicon. Concurrent calls are guarded so a slower render can't clobber + * a newer count. + */ +export async function setFaviconBadge(count: number): Promise { + if (typeof document === "undefined") return; + const req = ++lastReq; + + if (!count || count <= 0) { + clearBadge(); + return; + } + + let img: HTMLImageElement; + try { + img = await loadBase(); + } catch { + return; // base icon failed to load — leave the static favicon alone + } + if (req !== lastReq) return; // a newer count superseded this render + + const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); + const size = 64; + const canvas = document.createElement("canvas"); + canvas.width = size * dpr; + canvas.height = size * dpr; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.scale(dpr, dpr); + + // Base, clipped to a circle so the corner badge sits cleanly (X/Slack look). + ctx.save(); + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(img, 0, 0, size, size); + ctx.restore(); + + // Badge geometry: top-right disc, ~30% radius, slight inset. + const label = count > 9 ? "9+" : String(count); + const r = size * 0.3; + const cx = size - r - 1; + const cy = r + 1; + + // White ring so the red disc separates from the icon. + ctx.beginPath(); + ctx.arc(cx, cy, r + 2, 0, Math.PI * 2); + ctx.fillStyle = "#ffffff"; + ctx.fill(); + + // Red disc. + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = BADGE_COLOR; + ctx.fill(); + + // Count. + ctx.fillStyle = "#ffffff"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const fontSize = label.length > 1 ? size * 0.36 : size * 0.46; + ctx.font = `bold ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif`; + ctx.fillText(label, cx, cy + size * 0.02); + + // Swap the managed link (remove + re-append forces a reliable repaint). + const url = canvas.toDataURL("image/png"); + clearBadge(); + const link = document.createElement("link"); + link.id = LINK_ID; + link.rel = "icon"; + link.type = "image/png"; + link.href = url; + document.head.appendChild(link); +} diff --git a/web/src/main.tsx b/web/src/main.tsx index f0982079..35500cad 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -38,6 +38,8 @@ import BillingSettingsPage from './app/app/settings/billing/page'; import LimitsSettingsPage from './app/app/settings/limits/page'; import RolesSettingsPage from './app/app/settings/roles/page'; import UniboxPage from './app/app/unibox/page'; +import DashboardNotFound from './app/app/not-found'; +import NotFound from './app/not-found'; import { Toaster } from '@/components/ui/toaster'; @@ -177,6 +179,10 @@ const router = createBrowserRouter([ path: "app", element: , children: [ + { + index: true, + element: , + }, { path: "emails", element: , @@ -227,6 +233,10 @@ const router = createBrowserRouter([ { path: "crm", children: [ + { + index: true, + element: , + }, { path: "pipelines", element: , @@ -261,6 +271,7 @@ const router = createBrowserRouter([ path: "settings", element: , children: [ + { index: true, element: }, { path: "profile", element: }, { path: "notifications", element: }, { path: "security", element: }, @@ -298,7 +309,15 @@ const router = createBrowserRouter([ { path: "audit", element: }, ], }, + { + path: "*", + element: , + }, ] + }, + { + path: "*", + element: , } ], }, From a1c9a898b5333a9af171cececb0681b5526812eb Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 08:55:31 +0200 Subject: [PATCH 12/60] feat: refresh app favicon assets --- site/public/apple-touch-icon.png | Bin 7398 -> 26603 bytes site/public/favicon-16x16.png | Bin 656 -> 806 bytes site/public/favicon-32x32.png | Bin 1163 -> 1934 bytes site/public/favicon-48x48.png | Bin 1785 -> 3379 bytes site/public/favicon-96x96.png | Bin 3953 -> 10170 bytes site/public/favicon.ico | Bin 15086 -> 15086 bytes site/public/web-app-manifest-192x192.png | Bin 7648 -> 30047 bytes site/public/web-app-manifest-512x512.png | Bin 35522 -> 153883 bytes web/public/apple-icon.png | Bin 6797 -> 0 bytes web/public/apple-touch-icon.png | Bin 7398 -> 26603 bytes web/public/favicon-16x16.png | Bin 656 -> 806 bytes web/public/favicon-32x32.png | Bin 1163 -> 1934 bytes web/public/favicon-48x48.png | Bin 1785 -> 3379 bytes web/public/favicon-96x96.png | Bin 3953 -> 10170 bytes web/public/favicon.ico | Bin 15086 -> 15086 bytes web/public/icon0.svg | 22 ---------------------- web/public/icon1.png | Bin 4248 -> 0 bytes web/public/manifest.json | 21 --------------------- web/public/web-app-manifest-192x192.png | Bin 7648 -> 30047 bytes web/public/web-app-manifest-512x512.png | Bin 35522 -> 153883 bytes 20 files changed, 43 deletions(-) delete mode 100644 web/public/apple-icon.png delete mode 100644 web/public/icon0.svg delete mode 100644 web/public/icon1.png delete mode 100644 web/public/manifest.json diff --git a/site/public/apple-touch-icon.png b/site/public/apple-touch-icon.png index 492364a84b99972d217dbe9aa83557e10302b69c..3ab55a4e27f567b0833db467728ae93dfafd8bc6 100644 GIT binary patch literal 26603 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 Y{}b@|nlX?(QUCw|07*qoM6N<$g7Zm*%K!iX delta 622 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 A$^ZZW diff --git a/site/public/favicon-48x48.png b/site/public/favicon-48x48.png index b9325ffa8ad072a494b04a03a124b146e1efcd0f..2701e19eb8446b65a4b593916c2cb59a6210f381 100644 GIT binary patch literal 3379 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 delta 3945 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 literal 15086 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 R7tVLF5)q3)?dU__{{fTj7@hzC diff --git a/site/public/web-app-manifest-192x192.png b/site/public/web-app-manifest-192x192.png index 8f84d0e32e22ec040776d06f93f0ef3fe4b37d24..2c41b2f3c29bdcfff53a6a5fc9bcf568b4a4bcb8 100644 GIT binary patch literal 30047 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 Z{|^a288lZDVuAnw002ovPDHLkV1jQ$opt~K literal 7648 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

{Jh#ov->uY4|&e$e=h1;Sc!{%>XB3 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 diff --git a/web/public/apple-icon.png b/web/public/apple-icon.png deleted file mode 100644 index b8ee964a55eb19207dfacb00511dd04747538e4e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6797 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 Y{}b@|nlX?(QUCw|07*qoM6N<$g7Zm*%K!iX delta 622 zcmV-!0+Icu29O1i8Gi!+001a04^sdD0X0xeR7Fa?`75&NPN48)sQuXL{Xo+FK+^q8 zX>l&R?>WczM4ancr1xZ~{g2l2LDKy|(fmNt{qXny_4@y^)$`8c^!fb%|Ns9)oa;K7 z<~hgqK+^pfjM7e^@FbAh5sJ`bsQp=__c6WiEtKFKp4}Lg*MBRs>L#ho=|Ad&=#e&+NhH`11MvX3q0c$MT)r_51qzN}ljlqW5OA;V`x8U8w98 ziPxCQ@-LO($!+NMg~#YQx9g41>?D)ZEtu8S;_)A@=cUo|JHYU4t^B{=^}gTodBEjs zyXM2<^9`5T=<4l1oaXuP>_eXCy4d}q%<>zN+9AzXE&u=k7IachQylWC@gnl90ITls z?5XkcC$H}D@~^5UQ-hm=w<#@v$%Ax~~Omt%}Uh%+9sr?U@mQNgx4Ju{y< z=iKk_ch1@0-n~0l5F(Z$XHtrsT`6*Iq{xv{s(w9-SvQdu74iLQ=i7E2&;0sX-#-$KyZH zm)D00=7~|-@_(Qf*&n@Td4se=NT_u-CDb~(hijbp(RyO7ljT>PtZi_yp;08IUS!>M zk+&K}Uaxnt@~Vr($exg|aWx9dF4@FoRW?Ss^bo3Syjo@Bl?n8O%Qn_ubMRi1i`_Dj zRHd80w@V@aC%MG0Zi)QthKqG|j?mgA7p=T}(IyIuD}OCKibdxvEIeywX{CjwmHngF zmyGyx7FJy9=aVZfbhnRQF2M^+}x7yUAC%*&~jZYpD_~mrIDo-Cbrbsh%GY`TV`NMg(;-PN3BghZ|0L5cGB8J3c5rZ`Xt&c5(VM%*zmG<<50%SLy}`{Ls}Et}=^h?M?5Q4_%mZBM^O0EI$MeN{;=btN#V_uy z3FbdIXXL{=3xB?8qfqJOtlG(Wjgu;!i(3{i;@}W&{}3LJ7pvWl)8Rl^P^@DjVo&Or zbwW#}&P~uvo&djY=x0WOmN|u5UMSH=t=AJ@s(&Y`tcUgIdU^L^A3wihujPVpXYz zP32lXsMPV>+CGFCSqkoFZjOT0C)I5Is+*6idf9!YH#GT($t^xwExysbQZ4doK^M;) zxXY|;Wr&B>eLhRU^dkzU9#-(&5e2i4Dt{2B9h4IyBjOLrnS10es|&l>TA}09>K^`h zwU^79@CghJhntpnIJs%?j1JN739$QeFW*UT<-7Y^nUUGX^pV<;`oTP!sTp#F$*C>e z&y=(l=4HxBKCa}aWomwLRzrSE|Cj;1fnj!C?BVO)@XhIu2p3c?mgcnctv$DRHh-;! zDd`W^AK}^57KHEZy~S8$AtvvYF>T*%7G$e;tDn z!5aLfT+s9Mo|`=Vx0_7e7q0hUuUiPy_uXMUrtNFtxzrY(*(2la5*5)FhzwEOY9%2@ zfl2a@p3rbat5%C7<|_F1UuEO=5PxQ+wJ|GA9-%GDhnbz;#?-y7ym>-3+BD)uPvUn~ zeXPyz!s!c)p2Wy$*u4RMU)4)Y`q;W72=n$Un27lY6wFL*XHA|8Uu1%j0Y(Pu6aBfw3LwQVU2eAk4)r_t$EXq_f5sNd`#2r-g zT9yX06h7jS<~O>%tjg6gKdp-;htw0Yc%1sevcozi;+4ZXUOuEFAxn?SIyib0o|cO& zJ)|T4pqAIN^b^*Y2qE!E4}VEnkCoZsI7E(t^Bo>OtFp7`U@vQO4Xn;Fuxdj3 z9;pk58%1)OME+6Z9N;!si=66h)j)7Y>I5yWN2#Nw_^;8dN@Zo{@MbUwjfY z3h8yeofp~%(b|Tvdx!B4!Qg-&r^|)aYQuBnG6Da^w%?;r#TK4gLn`U7~pgIJwz6y0{dR)0AuleziBxdA>X8Q}e* z$42&kk?*O{`yntBkd67fiitXpx}D2{oONRx`}@Q}9sfLDc^~&-*^l`#Q?^H^lTc!16GHhNg|M>g=zS{QO==D3w`2YX^JIeSrm*gyy-hVNb;Xcp%HNf&W#PmFx z=Rut5LDc_8o$V))+fkwOOrP*#r~EOy?h=jC{Qm!3rTJE)_92hi9F5f$i_$H&>u0I| z7LwE{u;?bM?y z^Y_2o`QPyT_xJg2x#ZvM_bI30&gb?NnA$m*=rx(%Jg44nu=pI9**~D#Gn&{myzV)& z=2^$_LdEk)vgEGW^SQ<%j=1zy1r5)^Nq_!`<;-qK-xY001F$QchCQ z&k<0AMj@+!5+&}gu&Eo;J86_8^dxkoBQ;?6=Q?-SjgyV~e0k5j=gizs7XUzAQ(gC@ zLS1!@8bl5C5C5UQf$SR}{6`~zx@qLGM3EyTo7B+!=r88%b9Vb2nVO-cM>Z{|eb!?# zf0>G&S^IQP3*3Aq`|>+KcuamSms{lgMUTA}46@T%gW;46svB$}%uOEKj;^ zHXqWskV0h6_4Ggk&35~#-k~hRO|J6cq>_5`4mT_(UBF9LTlXRT9p>i zFLODOT%1fXNF(~DTv82vvi=|~Uf8l$q$sr7q#Al!00BN!C=to)QL6de`LKA z0kR);>UxJnQVks?6Xfym+E7Os=q%YtATD%vY;=}^_CFlBv;MhV0rt)2P?uIauGMOf zw$`;3;P7x~Yje|VHitr?@vcgs3WuXnl}4r6-81h$t#AYGClX8~VzF2vf@<&UbERu= zFO{%TF$>T07E3t&OsNXmG^19UP~v&7*ZVD+eyMOp4RAX^(KHo{oaJ}7yI&|=BA@eC z3=z?)*-m#WTov~KZFldqwno3dE_eBQ75V}F- A$^ZZW diff --git a/web/public/favicon-48x48.png b/web/public/favicon-48x48.png index b9325ffa8ad072a494b04a03a124b146e1efcd0f..2701e19eb8446b65a4b593916c2cb59a6210f381 100644 GIT binary patch literal 3379 zcmV-34b1Y1P)Q^Hj0B=BZdbw>FkLPlaZl3VEI? z#yk}rC~~4axAtneyUt&wFDr8=Y>544o{GDK{-mU;$bzJ*NK;Z(gugQyld2-G_G$Q> ziqJpIsR*r}QxW3toK)NMR~zv7iALoJM#YWx7VN9 zKmAwY#y?sj-Sy}>sqxWs61RgHH>jU^K0#+)cdqKwtk^D0EA}m(R_x<;Gvlm}$4f-! zoD-RMUSw8@$jq}MPn7tWe?jD#ixN+smzY)JW9C^Oj~4rwe(gAmghx(!_C9jTL$93d zL;frDA3Nh=&RGvh#U7qN@8$JUkrh=k%POR}{M!nNrDY zWVSW>*`}5GS|#y^YLO4hMBXm-@zOaD|5of~`Uw|PiW1{ZD{^Us$;TYh~56V zR4?=Sk0Q%Ty}VN5=CL9tGYTC%ecCAr6Th+1$)s-bjZkz)9kw#=h?R#5>^JN?sldj} z<8~IEb@9gvAM5KR((5I%8YK!15~UW2GONU8i$sN0qTDJ`YL)rkqB!Gce~X{J&3+1u z0a8>VU)T7^)XE4C9=31=GuM_o>();9jIna?V{%q_GtJm18mBNiqcvGl30@JNA` z7f(9)@RFM^t9`7k^^vU=DQT6cv`W<4CF&d!YP*EWE~9Y;X!HcBb+>cT)<&}!!r2z4 z#xA3E1+e?u5ytLsV~v*_tw^EH#~Fi<5~E0&MdW;|#3hR#vmC-3jN)jE5C}!^2imZ^ zJh;3fK2bs#ou}t!8K19b(4H1@v=T~+#-Kp&XWAhngZH#BHs8S5Uiyv6(=#s5z~lo) zk_ybcc-+FfXKZ|0>foz#2Va*t`KsK>no1{WDmMq3BwEA}p-7BqBtkIQhTHAIZnI;v z*|6HI2oL1wxLL;L>KK^W%vO~*?m)ZZ0bmbA`1rh?A-kFxv8RP`x%xiF(07e9DPPaj z{RW;mV&Itq6Td!YX2}UNOHNw&;-arti};Afu5{S&4qVjXzQue|J-y z`*taLV*JT_TbP=sWBySiUtV@`s7a*M>__XAX>oX|HCU-?G1J^?MHsO|%k{V~Q_JYx zjf~md$b);Dn3CJf+=F@^-=`zF%t>dm`6D#A+q=rjX8WYX#=!J?hNjmua%W?&HAF*tIH3D$>hL%W2~0sde1Dy&=wZ=iJ^w zdIKZU>*F@um(jqO%myCL(lR}}k@@?Zc>P;F%ZgeNhHur-7r)wC$NlLVrtH!3>q9zT zD=@I+TLUXjn^M4EGcRhQq}au@KhCJ((8CUTg!sOExh}kf#01l@_C7g zPm4`tR5`jrCShs}sXfZ(at9_U)a69y-Y9AjnXVA978TQW*7IC`6K@>V@lm0HFV33y(^(Vg751+1NvuRdZg2eTbJMfl`oD?eXV%`aA0Gi+mRpY!(z;qEon^u>TR)eK(uBlmBrWqPKDXLGf@ zcBq*TkL&q;k%2EuOa#KwZYywQKV<#r;<>$z$f0Ogm?>?YPM&HJc_>}Y9bZ*2aBVe% z*7j}3^$`9wxq=%pAf=LDt*zq0%{9!(Q1g6FJul?cvv6Ny*HiAc5JWmQ#aoA)N!q1} zmt(O6iUXbPzF>s^EHd)5ugdw^S7i)bRmm^=VNZ92yOS%pdu0Xjyf2jYl@$y~u4KsS zD(+oZ9cSF8T8zH-9viv4Gi~806H{wgkf-gcSDff{GTI>*>qMreskq~RF30B^m~tIE zu9Ag8Db?JNRSH!MO6g8D1CpyyJKC;sp!+J++S~Z&FDv-qTSG!mS&eRcCfuPXwnzBG z$yWa5&sF^5>q>^L?uT6|)d<5@|H#cUyn}(s)m$>lJvMM}PGncR5I!s8)03ty-B>rm z$|mp7J+GH|JVV1j{cj~hSKZhaVZ<60eN)?K+kIE6?onZL0u(w_03$o-)xD~?&>x?|RB8NWfxgpK`U zd_U|ECT?uvR+yaH#62lmR+e-e=sFyTbu-@R3$bu-EBB-{#PuiL;#?E2gD^R@g>L2Tq*0|1;)K=cymw2yx0#SZf z;9$afV?X-;?GRqcvh#YLoj3Cxypd;TVUCR#_gHy)hlRNr7G|een6)jD&)#NcPMRgo z6X_OaY&NkZ-%hMgzK+?>9UT}oT*WB|FYL1MY^IGDciVX;)5cR7R+7?f%t^EH+)i7(Zst}CPwlXg zwB5>++pY1uQ-1O)xi^w-QD68*C2_Py=7dJ(n_8Kya*6a(k<^PK8_s#za>2_VkGuK2 z(8bzfS3Ljlu#;s694yXp@O-A7`5AWR@38Y!#_dNa)BC0GoBdQ8WGW4Q%Jcy)>-}8P z1t^b~WiB?$oYRWb7=4uMB#P@rPHQ}5e(&PHzIE~LJ{JqJTs*tO$uqYXpP;eG^;)Z+ zMw_2zyNu2m!0ZWP^#-wd+i;0($Z{J#(T`U_3gGa{n4A*TdLJiL9jD zQE^NT(&F$_sFwNigve_-US8hi<)zz0mQYX|_{|ATfNEm^tu2Vo6-I845e`QQg~9}b zK_p4S)g>*iou}>w1pC98|7#dKjTDW zgu+q$!3bWt9iv^Mvf0ZSjYv_gMA~^jD~<$Nniu4)9EJ9unBdB5zSIME04aU_nU}ax zfM5vfZDI1t+DSPb=D!Qt`Tfxl@8yShCpUaMNPZ_beC@v)baUhtK-=TOFqR0XKs#7` zG0F@P)|`p((ZL8y_l8-zH^O^)(VrUadwJ2GVbfQ#iQMUHV^J^?%QpL{YjPqvWT>^k z(V7@LOJjWYU5t8)ujVws@$mQlGQICHy6w{D`^w?>x7YRO@%rEF z`bVAYQlaxspztoX>SU<>RipJe#q&d)>ORo>W~u)_(EK>Y^nd^V|Nj5~`uzT+&GhJIHmf7@MuHwAd^JvZTalPf{=jg%X z^U3G+>gwy-?f3htLH{owKYADY=? zs`fmc+kZc=;$EulXRhpfz2DwWe7tmlls{3nytC7RbZ!t04#J;PE!DS z*tZe<-U$91@y%iMu=(*CVXqO=@?fvm?_S$>b`j0-@n7)Ec*)~>*z?!uQ#5*!As2rH zCrLy>RCt_~*5_N>Rul)|OIXg{qkHc|shd&>S_n?lgiw>DC5sa3Auqi5zzZ-K8&5EM z7|h-dVKl4#+xlE1yC^}FzK=iLb98=>u0Ke+2xSx;J5hA}XI96HP8=&h|0>S=;QRmd zQC@L&{_xT74&>-zdcNq}11icSir;_x=Dl<)72m1PO zd8z=N`H@{uPmi-jrNX}!=Rgl`ok3@-*!8riR2vFxJfLcEI!CIi&Z4)kvKnbqjgKqb zQfW*pjky&YD(A?^7wF{g3@>h8&8p>Zf<{Wq9$6RqoX4@6AA-N7y!KLTaAgGej<^;S3^r`SLe`> z88y-p9if>qzuyHU=nO!CG+3ERHSUt?2nI*}4s?r_3Xb{xYBdfJu}Pr7rmn0^Wv^zX zQmB&lchKi|49^b%5a4lffFeQKx?K4hbdUCD>u9rMczB2eAi<#a;)H)qy}W$y73d)i zCwva|um}hsp>xy&BuRp{)%cJJHDuM*JZ~Kx^?88+prCo0A_$TKkJ@NV*X4#98c^LX z>6)6qI6&gRA$slug24OIwi@`G$s?+!U%|+SX+P zKtZQ*9!P>9C$3x>o=IQ^iVoPO1-|ACU$bN7 z20ei8Gw=kRI{}ge7w^6xr#?G@cnl>1S0+G$s8_(_mE`cUPzeK9UBY^Ak3kh!UCPavabMnx9VHVUeFX z44nRq5|IQ+Q65vbM5(kaPW{T_5PcLl{b}YoBnVN&{BlKc@%nEpvhxTPe^LlQP@)OR z%Lmul9C;^qI6(-VyLjmwaP)H}1A<*|H_eP43uoS9MqK)!iRNzcD#A07EfmTg&< z;f7?d^{#K`o0)IVe&3nd`|NWx(r9{cjIUzo9aX${M^)?UJE~es$5-JQUqy6$6$so> z)!Js`NcJbnI@W8A`>I8GqU`wk|BK>_YxUew)mk#X3cG5+ zS}4DJYoW|PA%7nnywidF;rYr>Z^>7jx8%#*k}nar0!oriq?5~%%ad(a=|uj2b9}i& z`&$#XAmI}>Io(zJZuQ@FOTO|S?E>7KE4@EFSK=QHx8%vHy)`euh`a!|=C%3Td?Gy} zPhw<#fKmA}BLwMr9owD(-a95hPN3@MoB;nQ7@q6r)?7a$bK}I1$_+4D*!%z^a{b(r z6X4cd0l!*4HZQ;(g%acPCB_#9xV=DPY`(-uA(R?R#0)Sz$A3Egs8q=xw#PSg*ghY_ z_WI~xyGoy`tgG@9+3zF%K0i0@^|j;XeSU`T69oGhmFs6*8@YED1h}tIK;n-40HbsL zjLPvdCO5!sd49$h1h}^_!2N{*esw@n?L7qn#^+0n%?+p+DTK(0WB9)Kxo+C$SNjl0 zm)CTS=B7Q~_ix(crAzGganl}OC%>=FFUtML(jD4(879!V?E8+t$II|NK1S^GGa|>& zEqlG(ve(Dh93OY)`?)vY&wT}ce!XAfw?`yiE0THXh|Hr0;$%NmDDm{608by1cunF*!cQ2+`E@__$e!e!^>R@BcfRVY|Gmhwbt)Y?qgg z*2l1&Nom#JIWOuYmvvEoLVK3tx5pIL!|**`Zp-m;SDugYIdR;V=i}l1ejeK&;JHJ7 zCLERcuuSH&YK4y~WZo;4m{1g8!qEU9mdkupq40j0%$r3LFCCV6?qGnY5BhleV1P#p zg#bS8%=2+?o=-j3EqgqS*ek>rBzxlM^4h(bn(MROWjE~baDBF$WZP9bxlZSFvahRn z-&gz_c6hjPr{dAMnpn-RM`jNj+u?i??V7W#PlfS)H1_<3r-pO+8$d9Os`(+Zhy zY80l|1esK=FtJ)@vOtZ@j9P`Mb&8t*x=QB5(g1%b4)9J#jE;sk>b@NPtmp702 z`KTno#43p|DgyjXC0}B8ox+kvg%#Q$YxN4-j53=I3Yj{YwK|awvdgTn!=$jWNnuTs zq>8bmL1A`nT#(7t3V$h=_)|%M*AM%6>7b7%^S#`?&%?Og9&X;@;`;4QhHZCpbGDmX zvt8V^*B#bex7EpYTU@BNbGkDUWhbk1vUVNPj9C(jl!%NnYnc`^Xp_5)hnbm%B*XW*0D6t)``wix1q?6b%eSS9i; z3VTd4*+!Y~3<_&>3afQ8X^oN^Yfi1q*A)`)ANBL@kpQnA@bXl?mk0K_8N1WPn4M0> z?{;(79ygEYdC*+5*})Hi>oyDGoM$KYRtGn2b1-Iyi+guFc{<% ziP<$Wi|S-j8zhx{LE?6UOuj{?&?c60B?zIL;X8#db+yWPRVd)>TH;O3pfK0Yb-GpQoLx78B!#K^CcSXwWU z*(kBYC`Px;5u2pqs7Zb=6WGwve^rvN zb9o|;@O70=jx%_JjmL9bjNjql(lr)_Y_u|@*Wz0lvcblUo9v9*=HQpRT>K`-%`1f- z-aqW+^U?rQD+7F6B{8o?CbeE-y-sGYDL&$h>@vkF*)o+5i7El7e9Sr}DjhPFt{~Me zg=%+*dT*F=cbI%@h~4HO`yC-FyiqE>QA|oRMk&fcdyxHBg#%WF244iL9HrV5Ml(3e z!d30Cs&;UeIgWMa4t}+4U7O7^cWIALcZ`2^mW6(6OiV2E;|jO%`hE}n*IKx8t(j{! zSh?y9<0sNXH(0oNi*$ZYVlT^}IF zB$ghDVw*&%EkK1`rb3+sg7g5DPD%AuIuvT0GIfG{UkHN~#jM1r@ZQ0t50 z3dIP7W4MD6wSKwHN4-g)N{iLnSte!&ZrdmiSDmaffIMMAQ-P3bo)2 zHKUaxnB`_Xp=LtS7L;%VzY@gl^J8^+v3dhI0y2I{!5;{q8L-C0F&LB5-SM|!^(o4e z;tX1A;^LJ?Ue0x?0z_I`RjZd{{C>ZiJ}Zq}vC7DxH6{l3ApU^1c2^0p*O<9xotYcf zn;5yt!b3akJhjKc%Xu!|Dsc1HqaG%fc$rq_V{V0y#npZ?>wIK2c-f}&vsvq7eS?o3 zdOx{FKZWK1IfejRbYjMcn{g1GKY};Xf*frn7;8n2HWP_86K!cB8fzvj1PDlYJRUqA zH$I;aNs{sV1NeMiG*_-N@O@(7Y6BOpXyS=o_VyXjrUA{Zt*qB6jM!-A;&eR&R~Z3=qgPFld#5A*+qtu+Ge=O&0FlYEjSq#MH27K57$DoAQ0I#fh_n!jwGwHDSaU1Qu^7=< zlyD@Bq9}O1UYt&+3Wvjq%jHtj!fFPr)bo8}P^O+smN)TmwpAs)wGBcD0dZKBX5J}q zad~p3>w zFSPT?K?fi0xAVtB2mf8@;KKt>)&Aw6gD;M_m|5l_wZ_jTolKE4j75p5Ga%C3O02ns zSS(5;93c=0s3TtxZMWO8+a1_!Hmp`F7K=r-nk&=w91lYmzNRjGCqYKMe*cU_KAs+s zq34n`J@;+3AjewTMM&7hY!FA5R$|l!BNr^yF(9K!ZJUUDau|-CGrdX0m2GWBoIxw~ zT$O2H=o%w8t}$_ImYJ~|O^jS`0yt0@7U5LQ|t=k3N^)m*WniL;6R z%ahV8bPPyu;*w=L#%(ZR4>qerCzGDYCwx{V#vckCT$ZNe(li|dg{WuTFCAB=$NMrM zU8IGmI#rk(R_htMO3%>MdPZlNxNW_O@taIM{GFL6wp(~?yM;%#S=3oGvB=AMt*m|! ztMx{y@kXg|g)sO+SiCZJPXLG8i{0VGYO`T9TQHc+?c@uq>9<@*cW`+(>$M|I#}&)9 z^j{Ijl`FIiT%lu7x{khST1Kul(CCkJ5I~r4b4!v4$CTNulNpm`;1^38xMI18eVYC0 z0#~Hz=)YX6+C*AF^u2$Y=xY;$GMX5Y(ZujIdPc1^Fm|1uaT^TWwb96ndu-~xBemMk zR=vzFqe7usp~@v5Qze?*5;}(uoy|p~*+z}tOr61k&Sb@8v0*Y>(OkZ?k;|7Qpru28 zskS}ccWFaXy+nC(JMA&w(nk8FHPSz=fq~1l3|g+^suenhX6P9z*5&0|Zdu(#xhs6! z3=ku}eP(o;4Q4sU9}67xS=z{Dsf}E*OsnEpU%KD_zHM##Ez`EA+p#R}7wNujzW!-i z2Bc{jlCI^NbR9RWY~uDT1F!C}@yUJ{v&+0>*8AD6lQ?QssBkD$iO&o+Kb00YMS2H^ zv^MtEnkj6sQf;(Tt+$}*v!tFgMc>p0u2>dFKXFk@8@X~>16QvQoz-$(x|SPO>KL`g zz_@jKMy}B_cCCR!wov<%P~s4SzQ7X1rgU7MgiTC{Y`v_G#Pb%$&)qMz;f$}L+t(^) z;L=70rfIoxrH(tY4E%DFk#}F!AMnD_`%o@Oi$G z$@@)AKWJjw0Sn(8Fq2y5B&F1e=8}|pdV)(A)zc@Xj=qa)xnfBJ1D7^1WLYCOuFx_f zQ^&2DT5evcWz;GicV{*6t4&6p+-l;5Y%|YpH}R`Y2KE`1_UHfh8PH~HJz>5rawA3C z?g=M&Qp#?U8Mn5HbLZ94XHh+u_3(O52bZPP(PvQ|eNy5aT)w1^K}#FBGPQxLmo+dd zQ_I-ZTJB%p#9eE(+`CrGb6cBuaa$Ab>^Ac5UK1bXS@|U2issS*eDFNERVm zCo!|gO>-OL9cqcs4Q;BpgU$Ro-^RrYYB+Ch4Sg5Yap}UIU)O1)?Oc5XAB$?~x44eJ zi^PDaqhCrLSEbf--LiVFU*5o-YqUI&)x-nqn|OMQ9?d25Yv?I1oi8r7mVPNUT)m{8 z;pvTx6Uwh^;;~Hz9^0(v(T#eZ{?5p&*(QF!+sp@hEqt)g!i(FC?9fSZ0Xjd2CXMnX z^PI&b9JMu9erajeMu|cR2%+iHKF+go!JI12on6HxYG0GCC;9r4ukpBRJuw#-&#h@gJgxeY+6aFK zJMUQCi#yfTTsWtip5UT6Ra`c&nkyF8a8*hzH!iDZTxKH=uW#b1O?sZ+V&J#m8F*#8 zfwy-W`CyNkk8>^jCD+X7`Bwh8$IR9S{|QM=7QrURcw>i&O{b`zEW{qY%$SS@&Y4lE z_E(6|(`!9@s6q3KS(RKct4c*8E$lJ>F<)}M3ujev(VR;9&9CN~#Wmcryq<9x4cxy* z#}gZxcz&~im$n*sBipEw{c(<&&+;sMmT%>Yd_GMbi^9{J^z3X~ zwocSWEJ=wOC0>$!oNMLc*;Sk~tz5Mi%ohFcc#WN{xaQ-1-RI|mZpDvV%`aw@b1IzQ zh3|ry6KlD>1Q7__jOVX3u@Twc%kj0PTEr{kGTdS2YD=k@JIKHOvC(;PFO=UMqY z&%)P*R{ol6BCFPOQu5nLZL`bEJv_2b%N~8;xX%yCN9QM{Jxvnh((C!R$z^KaFPvF% zTG!eWe!5#%bKdkadJ1tNm(8l=in*0sy`YK_OKZ6!y@6k@YT$`19Zzr2@zUldexI%9 zgIy***=OQ!xn{o3H}mN}GwJ1SqFp`VbU~;%#4FzqcA2?tc^$d>_)LhMXgz9cn7F}v z!_9n}YvueIWt=m)gbP$MdVcM_1)k(;Lwf&VsfIT5m)Z z;HJej;;+0;_bpWc&eKb<-!Z>#zFlu~+*63(4c z!uiw6xM+Ggmwj8t)$=R4F{PSuX?2WWQOARs4LqHt<%NwpCT!L7);0t0?J)5A79D@h zG2`v@X}3EftgG^J!L%~2T~JNFAtA!4+a~I`LoxoAXW{&5rTqKEV$Pdds^ZMJ=ovIW zn^a71;QT2iTsW#J@pM)L&#Y_U*{lX0Syj)= zn{?QuXtxDuO{(|bJ5Btb&yO->UM0m=<b3dILZErik+<6?5*SB7QNsgv)1?a{0_s2Fxns`UMq?NU3IgS`FitRxvhJpqhJE z)Y9ZV{*CCV2o#5yId`SiqWS9~hAyb2%GvR~(#d1Aw%;4X68L4Fl?$g9^OMgHtNrgS zPPzll&n6bpTlje!&i$sC^CuN^!Q>Jyo?1%Z8D;dFS;pYmDsz%# z_KV4-9nu09O)cYJKQAJ$sqKAGcWwe5d24U;`)x+f`KpMYepSSSD;x2LkA17w-6Q^_ z6mM|UqHupkJ^%KFAgP24rj+&yKgrxAtR_Lo1yjn}$>}QJ8C{hNU&4P}d8d3rfX#I$ zf4y+BBhkjGG&62#9Y6o3gr9y{%u{Q01lwL?pN_7gZX%`>X<^2GJAJ2>bIw=ATsWmn zof*B&kLJS3We8l1vAup8wUCHYSk@6svd{QDQhtY~}F*%JYh+Z{41^qpSLMUzW8 z=Zg}4w^>iP?T&n!ldJ8k(0HQ~YmkRmHu9feixFGOrBi#CAI-&6%K0I1+0=6W;722Z{1E6nt&(#mmh(St1fA*8 z{(XQPUZ z(+fOkE}v1u4}pF&Yq)4iHP2-k&uIDSAVBh~ifjuPOs=B;%o_U5s8Q4BPpUj)oGb86 zB^OVr>J=U|{ioN^e@1ObboM3k$^Nd=!X}`Wbv4unEM=I_Hno7xB)VpIS7cg9AF@%2U`x zx~uXlPg1XQ{L81+aOYB8w_c*1ihOa1Unl={ubn zda{S1M48ST<(V~BuAHvr`Z-OfMgIRrXl|I@#0|6c9B=hpKPR5QKFQa)PCT95HaXu_ z+>Y^w&T3+P)rr4YPku#w&=}&*R1;Ut&@pUI_u`-J{-0@l&5d&noCw3_bhN#VVRH>! zH(O6yNucY$6-)B1t&kZ$&%iaa^xQPBoAJ+f|9c_6=H|IZZkl62*qhs|$S0us+mP(* zTz;y$ovoUlV_@hk1M?0Z|D{YKEq;tOCEv@?Z}kkDW8{{3qJ8(`pY8siN&JLQGkl)7 zC=;rkAGgEjwn6w4>A50pI_B$8b}DpT_m+7^hJI^cQeOO*&xy}t?Z4j-j9y@7>>>+e7MKuzfl&+0+&bTs1alA2b-R%Z z%v?X)#9wyCzZDZ7tP)Q;wazF{X4<%RmQk(OllZ-dvlm}8W`Tt<3$2V;wO^->Z|*XFd*4L93t#v9fA->Q#xJ&U-%=Y7EVJ>@avP7N z+j(?_gC{ecJi5ZpLuqy%SZe2tHu`fkF`raN3y^jeew9o zzT`M@Up)2?9bfb0N+-{)b~0hTi{Gtx@!EPfuV%S;b)AdnSG#z6l|wCmN(ho}=e|@M z_bjn-Z>midVO)w;B}xb(Y$C1Z7umRNk?`5nbaFjm$1SpQ(;N%0t#_$AjeIFKJQbsU zftYc};`Ic{{W%-xt@xThZS(R`wpYFQulD)*Y`33}wtIPJvzs?IxR{XT;#}QAD zoNwi^3e5E(9GK(V8H>2)Wb3c;l(vBUS8``g?c^9&2KWqlI7r+sSX}r;pDkY7f+-+d0?r7 zU!^&?FV)Uni-i~t?o4rThrl8ScM2gE+8DjS&h062r~(O_Kyp5jP6&CT@~+xek+`PBh#T!{p)hCv-$5*&`c$te|Epu}J5(oD# zc5>em2lu3O66PNWnq9RDTPkI?Rw;;dUV}njqs-1)ne1ALtyMDX$`w_hjAEJ8BQj}6 zWtJZekapNl#!)|MNBm6Q>*I@EJ|^$=vT(nbv_n494th!1@8R1#FMr$V;gf6+f6aFD z>2^17ZFKS6N+(aPaH@hlnCj$#R0nr2c5+vWi@Q>s{}9j|(km2-T?6YC_SFZ~bdf=! z*dTLI7o_$s?!`Y|XiAK7q|_)=Y*09AP!Q<~vqGg=Qbi~+1*xzIgeWsBl$(Q8T0&G= zL)6=YR9QsbAUcOagFQ%%MWVtKpw=wWpi@%%A*SL&@wmoB}Dk~BCM$}$*yXvOsz$s&ZZ8EhsnHqZ# ztuu(;6-Mt0sW5rMSUe$Y-XIQt2&*@Q#~;Ech42Ox+yMorPr>Dv@kl{zzH*AkUH||G zU`a$lR3Ju|jKL|ZqSTuG6gTeUhD(QYHgrtO!C5Pv|%7f_JoAc_)1mP2aZ>yHa#a!EAWBuexFj_Ujz)cV*} z?PFb$hi`K{e7)PphgK=R)#}JsM}1rX zy;H{E3S#yIv3SEO`HnyYpAtn5M)4~V+<`D&DNH~P5ey2V6_spBk_ZH30s#eCmg9a| zY4^*r5GaUW3geN&IQ=1<{xH=RnZr#1zAF!~_<+RJy%KM)_wh`IkH?n#_|;Osdci*~ zcr!JnM&&)R=2w{FYq?m*n;bG)N3ealvHGHTl^B6glwi1-P^1|t7*P=nh6#m2Z2~Cj zPJse~@B~C&?3XDvu@iYAj1VoTwvD2MVmJd~Or8*>#vmKZf-F5K^U1aVFJuOIVuhcF zmjzT2eq{K)W?w__y4|%w_S7mI(1lbH8tox;jt~vbFpZ7~MsG9zPz&K`E76!h3sJE_ zX(UP{5>W*ZgbHE>$q6I`5NWY}qucFPZRhfUtPoHlxB?NHToDSiVKRz>e7RfZt@Sc5 zt&(_rd4NZkNxgzcmiAt(GmJk({ZC3Ojs(lI%0gt<1j%U#QDO{JYYWrhjG}e5VDh!# z69UANe91)`*npF{+iHRLzqftVkpt{U>y}ou*1rnqu_RUN{>>leB`)S%8IK@t~ zZnwIXuQw1zYY$Osim;_3%x61-ys<9Gvnv%sRg^-ic4&X?tj{kQ)j0 z8Y^ak*tXYdwPCf{F`LDHnHCjRn**!Ejo#{^+8m%rAEdBRVO>dx*#%KP+7{-`tProR z3GsA#kf)Z1cxrjDBUT6;@Bd8~z7yf3ba)L3{Tv|U@rh+AzJ0F{#4gCTIMA9L zG?^V3EKUqo549#Qd36ESl_+E$2{JD~%%|BAe!n5iZ&!wRJ|oD}>0$Lkegru3n-!t| z+vWch&eyeEc~~FwH@d`4SB!8}!b8#cOc3_~F$2UOLAU=6Rm<^$gu+pja15UujX&@E z6&!9q7N?JTqnk3FhoVLwhZ?;c(FQnB?`Kn~#EgAG{`_5-KW&NdW|km7%ySuGo=*?+ z?20hYq(^wRn>br_&oI99Y{9E4fpBX~tn=b4T0yR`;tO-(6Sg(!_ag}b zgc$0Ns6<{B0_0E&a;O=v9K-AmQDc@l+~{XtwfMlLu(nu?c!l&s3Jddte6=&o8|xyx zx+=^onGs$P5&tbovdC@+x0Mh?D>r7FE!mcXyw_~*49|!&o#UA zOXau#t?dv#A%b{xmBV23$2i&)W=na9^(A4_4n>%@FUl7?!hE(p%$GYO{Q0{mudj*l za%PklGh!;~KN7@VNRK|#E%Mt%fCt33YbRy@WWpqg;8j|%_*yukkFnxNl(~6PChczK z&s&;#cYTbv)G4cgn6Bz5U(5jXKxtbtaV+$|h6wy@%GGoNjX@&3jbZ?28; zyVcFSn%T@NnK52gZHtPRe;hdSa%Rhb9+96U!cTyA#CFYHEk^A(;n1J3Tq%Fy{oqt?$KJs7Mf>7~viToD%A%WhsmbL4KJT3bd{4t#4sNSu1IWTbZ(_ zg%3Bi@czaY-dx+tYnhx9zgyKk-^u*nQ~dJRGI{s4OgetZ>g~g=FhmOTjuL5IW${y7 zVrq3C(i5q+L6Zjz9?-f3@vUSWg|x%4Xg^Hf2cLe|${*IX^7dMIeKkz@`$Xim)wm|C org*|?GAFF&oe8VCx+i4*e|~|%gg*aLK>z>%07*qoM6N<$g5(rfp#T5? delta 3945 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 literal 15086 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 R7tVLF5)q3)?dU__{{fTj7@hzC diff --git a/web/public/icon0.svg b/web/public/icon0.svg deleted file mode 100644 index 469be09d..00000000 --- a/web/public/icon0.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/web/public/icon1.png b/web/public/icon1.png deleted file mode 100644 index d2ea5664e10c5b8e128e03cd156aa39b4aa7f66b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4248 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 Z{|^a288lZDVuAnw002ovPDHLkV1jQ$opt~K literal 7648 zcmV<69UtO}P)8J5>Awd*;@^(&L! zF}v?Qn&&IB>KmEaAfet*q4FfD;}wzA8=Tt{kJ2fx=RC{#7nIf^qu()=;Teq6H^uZQ zt>#jp^AU{EDv{hFkk~cB@?E9*OrP&Xoa-=@;Q9OgVyFBrbNUYOw8Art2r4+eW9{*695_q1trE>n4@dna24$y6kMO`OD({ zd9?f9rC!d#k!SU@89Z2Zu;^;=ftS{}y}k-ya;}zdty{&p$ZC&p$ZC&p$ZC z&%gX)EdTPeRzyY?=)91 zd3^a&PyapkH(!dEFJHd=i@=$e@&`G!NiqeeH*ZqKW0bSuOa)A9 zoMi_2>GL-~&;*05TAd!^>eZ`{A74d!l@E`Rl`lT38TC-0J%cjmr>D=KpPrt+IXyki zM4{n>hMPC1&rhG9Hhw0#eF!)MNDnZW>@-4LFAooME#a}$kfNpZ0xByhO|Cr|driQH z>FE!{1m>#tJt`6uOv3>wt|}^3T&+w4a7%Iixb`^xxbiCZ{|6#F3P{4xGCDjwI!Y2) zhE!1bn3_&(Ul))QYAOr0zgkH>`nZzW4}il8I!kg`6QroVnNHnjc?viQ zfI0O(Mb7Bx={nJMTt{3wH9*FG zeLICsb z@9#&7OkW@6NrM_ssvq|ED<%6&H8uOwrz~I}0_4!;{vCh=di#lWUq7L*ue+PGq(O$0 zlmG>?txM<(@%vF+y7c>{6m@(oz@7!D4nXJ(fIb`m+TDFbyAS-kyZZo^(EDC@1Kkz& z)xOxD9!@1dy`FwdMf(``ngE(Pd9e0U0D-?on{t{4xQE;@AQcP`e<1Sg%nSlf8kc^T z5ybJs0(xcmtE{Dho}Qi_g9siFJfsmy2&8G6UPr|(w0#Tdty{N{p}bn;bI2kui^sJ8 z{g4E#K)@0HeccAu$l269MDTd%9iSO^0IQBsw1S=xx3WS~${A_xfB*e(e}8&qcs0QR z{gOZEgEqs49M`cQNCSp@Uw{958V@99AO`^_hC>KgSs7hbhd{4%fZF}wZ2*_GSOWF_ zJ{V$Xe;nxT@9yjC6?gC6x^?UB-CK0Piz>KI1)Y_zW80(haDRVzcyx7kc1I54UO51` z&lBhqcbhH3zJL?Dag0P9HWYe$`?`BBAN0l0P8k*EFP{>sYm>MHp_#!x>Tp!I&I z3R`C<$`M;660uPjb@xPUJ+_F$l8ha(aJUEM}vvqA|ceTex z$UTFxSXX;Y6VO3PNAB!Fs9UrM1y0D(URAKN3Jo83cH~M3^JwxkLOx`&6K8jKH@e;Fw71ypU=MV4wZD4@0i@)e_yg#`8;tPQp%tjQgUk%}_Zxdf zix5Ilgk$vz&S$n)Mcg$g7#Q$ELR_v$`;E&{3ap6B3G&2WApz}g-vMvmk|$We@z7EK zo&*T1B1HdTqfR)}hdMg$A0AF>3U)9M;sf#C-Z;JEy;%0hhXW|Uz?Ge^82B>^6)=Dh z5}FgP6sl zRXajR0qlgG#>SjJ2uHZxhj>B5>Z+<@XJ==XUQnbdP(w(KAIUvYK&mTeVuN$_t1Y?! zOh-z3Cme0-k3Txb-R?=Zdvb+UtgiZy$f2>iy5k*y0*qstLg^Ne#2*40bsEMY!p-AiWyQDRL$bQ+B^Njx zu81w-8i~bX1s(ReJdl=?}RF2Jfgp}zh~?k!zmA>-rYQU;Igl@&N-C(bv6NKa3M zRu4P@{qxVaZ~uHNNB}vh-t9;hfRP~jCU585(uOXiz7aCUF)$=nR(5E$O6!V9q_cBy zWaQg7&<9vRy%CM|S-{t79KV8}#`?yHD!^*jl&}-3Qnl51wAfhRNC!>`q=8ketnPT@ z@m^Z4VwpWMG6?Di0Eb=o3>gPon7L|>Ia0;=j$z))GGl{P4P&^Y0Ck9I2-w(f*`f^(<7YU5~ADcY|} zd48jDym5To4Ik`yI8TH!oq?d8Bvu3>gy@tzL8-{sPEQ1poo6GAg80-pm$u< z*VkVeAD^6D@%dK0SR~QPqPu74U2tfG6eu0C`7w=*h`JvOI(*ZOTG}6Nb$IujxF@Z1sb`na^*Kw z&`V~j)Sjo2h29Sa1A#y=IN58s6Dy)85J^~CuoEQt6z$)ZC)6rwD*2%2(0NrGwBT?D zgF#;?8d?f1MS0-|9bR!eGpI?e^NIf^IzQm^`2vB-xbZ%$h~9wV&c+hDC+n&(XC0LI~TpD%!GFD)&Fq6n&N0_v*j$N}K*-gIxe-P?$sKmh!ge1Tws03k%k=S*H5 z*}WTeRdsd8pkG&4CmC#z3`~7)_qcnL2rNNGC?v=Z?ejG#CqK?-B!8NeCnw#T6h8Fv zC11dsKwlBgKqz5>O4Cty@LuZnZ{rg3bz6TK_)wmTL_>C_d)w`X2=EYf*+3tK18Cgp z{6nFoC7(B8Er+{7(3_|Lm3D94y}A=XmDZtE8}3aY7|1SQg9NzUunx%ZC+9&yk^rHI zzF_=_0!8#}HrzNF0ac`;u8wELZMV2_<3`<$y1E;6Hz?KesHZxz9S48*8u3>JgpveU zg@|Kd98usTCnUFDr(TpQaH1|XA2pG^z#k@V5ReJXe{P%k`xugcD6|x!ZzU2GI70!C z_NZ(a3O&Jwd${rmLuBP&w+mNs|NDFam47rEjfO%YXOaL6gC!q*;g#%!7H{31y3-c0 z3;G*#5Wvx+#!ocrje4Wes4FR2Xc+i>3rDIUsV8{no}ux3_fqH6A*H>zmkIE}E+U8p zB=8SKz22zTag?c0;y#~ok)%to$Lkt!@e_o|a{k7R#38Wcqxe^E=H7(0gUZtx^7#@L zc6uDyf_EBDUO+>Ge5(aR?#3=000pWGsQ=OXNcATgqk;t(3 z;s}xoQbmfe3dI`;1X6hiPEg{z=93d}=gysm1|)YVX}}8!ipbvB-6a>$eVZ3iJctR= zg;XT*Y~TxS1|z9YTH%B{Nf*7-Ktvic^Eqk{0hs_F?C$RB3gkQ#WeI5n2t{;nZf*x1 z>CW2YyAPOCp~xlx3MyCuO+`V20^G{RQd1O)h|g`$1xL~-P(&nPJ`o8d-~nYH+3nDApVn@uLBaE*LZgWAyQ;ux3!8cy`U- zTY!{7k<`pykm;O1g8-q3B@*vF@`$FxstAYg_)f@G00$(3sTru)-NmTh-Q8WtEWo}^ zF&oclWfdMd5{@CjlpB~mOJGNEKv_JQs9|MI!@!);Oi{$h5{BtR&_f1v_>dchRMU(05N za7HUEISIHxkO#_CROm&mE-|ASJTedEBtR%)*~A|)dDA~07=Z5}E3)$u31nJ&#pFW|z3NA${!N2bi{X=9H%<|@$MqYD*tIbyr) z_yQi$4=0%fSTa7VBr5QiQEQJL{AKI3m1I3g^xX2QT9C` z3T@y*Fo&fOe&O+b_?Hzd;L&C-3j^C`#l^Dk0&$U1cCm~PNGRouS_@lQn`mD)*OGm0 zworWG;W694n8jWdaBP$n+p~p#OT4V?;l=L+k+Wqw3e`vd+8~j?eX%UL!LxU9JPdqNc6MB4Q<{zT~F!|%(#T2vPo^Cc24WGu+@dNI-dVftScyFQh{ zys-AO*o)#q@o+iauj9AY%AOa0Z-|0hxadvuS1JC|i%rGf9pd@(V)_A!i;Gc21}{1? z)C?OK?VB#9`hFU}1Fo)aN@1UG`_Hr33mls8ZsMa%o8*B`Jx_wszUktpqXMw_yr_S! zkoKQvu@_Bw;rWr8R~P4x{)wV+%vYze&$s=|c-|ylfyAp9>Fo1uzbUi5D0=nkRZ&sVt5?XpLdKAz%qz_V`DFW|>h;7g3vu@< zDo$&kZ~L#Z*o&ei?Kb5&oH`Ovb63 zDk{L91(cSSmzS33ePynz4NC!vRg7tNIdw!)F70Qw7eu?fytbsIwzjsmw6r#}j#QBA zX+_v4GI#&?fG967F9-FRnVGhknUa#)lG^gx+VWb|t6JclavUg6Z$1T!@}?;gpa{pO zCjLko`*OA~O-f3Q=6Tx-kgu(+ZJU{ynOR#~TZ532681-VE~tHu@23?-d|59_{fXMr zoEkcwy(lRup}q(C^>tOkOj}89EksCHpjk>wYv}?wNR(8M(FP?8C9<;ih&>%rh$}VJ z$!xy%+_ldh-hnTn_)-rkNg;3Y85JLpDzo=+Ok~AC#8kaQY#uhM8#UY zMm?XSy?TlAasl29IH5YEM-gt{a4nHs1q#9qwL&wu)~_(?)) znO|R*ZYU`!QAGe49T*_^QLBog!ZBZDFgjI;#ZR9W7x8D%i;E-!dN`EUwP2F8NlZye zTN_umfp@F`J`D$in=Gn7n zswd7CV$2ALh)y7yIH2a|KOqAtYisNCtcB*OXQbg-TU(Ao@)g^btG)1e#ypHEkB2oN zoF*m~7o&@dpB9l*$A;=0o}}0h?25J$?M34Rsa{J3wG!u9Gi3_37YmX-{+&JYaKs)* z6uD!0d6_(dp}x${fgD8B`Z^pTWwf=CkW+`i4gp~A_xowH01`yu#7dBxz$`VPjTIg$ zpB6vOuhY$YX6A+F3_j=?twI(O$_5(1r&Oi~l^!h%Bd%X)jdX;UR{fi+;$Mf?u%t0gZ4E{50fO8(8nW4JLqlO`7#a$P!~QT8;2UBM`Ujl{TC6QV0tPzWHP+YHo}P^Wn@#q; z?Z9?`gH3(&rh!94oC4S(5H>?Yw7-VOGloEtQ`6eo(-)`j1u-<#Y3n>tx5>2Gh$L@0 zIXE~tID*|+MhGYlj!*^RV94($FYuU#8`fUDc=6&tIjeY)C)rf?Lf_$21ra5#Kn!mQ z&axKv1-w8r6P6+H!qXQop1wFu2q|C?yg|NGqfYc0?d|P2yj64Tkxt%Z#)AP(g8}3D z_3PKKH3?4>)NnqNzNh)y3uB)Tj%fX^qdgXD$NqGn-rkO^>W#rp3~AbH#zRvp$I;)f zpXP)YDYN<7lK@SF?045l%;|K-T(a}qyRbnTL(j!xh@^vq&Q6LXT1U~0^!Mu=6`Tkl z0KvokFZst}F%Xp=0DX;iJN8P`B)}1|k-<(3=dtCzWfJfPQACmS)OdLPlwY!>*SyvY zj$_Xu#41<-c0+SIi8MKY*n>6i@JXA!$vl~Kb;SlT&*E+e7hHbx_y7Kf_9=O7%<)mL zf34;Vuovn^!}{FgV*YkJcRlkb+7j6B5CwVG-W9`m@OwOCdlQTEm*2ek-zh@Ko0xGJ zxSUC9ooyCl;HPm!5=#-p`y^|O@qg7)^<;2;V33I{;Ip^b$uJe0+7Pf+)bJvhMk6P`VMn@ZtBK+8|) z#hE0eRQTk0_F^iG*!jW^pow3hz4A!`|KlBPIJelb&~A^tdp839^raDfOC+bC&#k{c zIc05k;=7lff=2SCf6OINF!;f4SC{_>2kO_53v%{pJsSaAh#YN$e-&0MHhf?Y z7=np?;~E*E=ir5@DfP>28ojNptxrh7ucrt>D<2s#l5ygnj~M|UwAk%2hb@eG$1}Fa zzf0iwZ#_-oDuhFwG_A?h4S#|q|CUD8;B;+;=?iR(-LHhtpFf{F8v!mk<)q|(L<)tG z)9Jw4e{T;HCkKi8TMel?_p*^4cgA^r_W z`bQ0lj379`-lM8D*q6d9Gl4@x{SQ=M)-}I$u5_kOS!H>+9Fg*U;j* z*4A?*<18S;TNuXLtv3UGJKv-opdSv8kpr%s4Qm6$mn|%zX)c8yWc(O6f|vJUERt}; z+xqh5OL&SS=wF~fU^b%15=norGT@BphqzhL0?DEf7&F!nc%f4x_t`+1v;qr zr{pc*?Ac$tc1_?>CQOS_3L`|hV~fDgmoAK0thfnbBEN@67x4cVNcevUu5_<8ye^}^@ z5P5HBeoXS>o64yy_FmT^w`-nwejjJ+i#!V<{l?(RG6-v29I7#wplUf*dI=mhqC zl)pmfJWpM&ARQ@ z&ZObHn?weC`ag1>HyzX$SHF*%hQ4dv3D~-ZxlN^51x~(gJU?7n&CK_Z#>c&nD@1fE zByYUym=3zU+-;zrt}?dgwSLbqBZwC!25YE6xYZjp{NYESIsLS@`<7EYn{l36mE#(PN>-^A3{}?e@ig3q;qcC{nP8af#(0N zpC8N-49cXry$yDjm83Q-IRhJ6OunyO*~+lZgb+FNApS*N=XE@Z*3#&Xio(grJo!$* zyvBJneG*h`OzMLLJS(}R>byOefa@|t#rrk*6~OH56))vOpp_q!4gSFUbh9_7>@-Nk zyTbyuw(X6j53q1{$8?1~UVm%j$HjPfG+UAooCh0cah=5G#ulhLZj!1hk39_Uq3Dkc zE|(2F#b5FnHDSaMrvKOG$@eOxx4k%5W5O?1gB3A}?}mo{gtT|BPNqD4JF(ez6hF9_ zH^>T~`bZ-G1^Cwu|F*KB#Y13OTVUwz>zC!;BoV5nYRvX?GrZ=pcA>-ypIiQyhQLOH zbXjrjIy#3V(OBBtq^V64n3F@1+xcRvA5{$PrO4S&u60 z+roqYmOAp!QZ)_UD;pG=jl-kvA#ueOUp!Phksw*A?e|moYIKjrI&ZXK=XAkKeFX7~ zoH7lkv-?Cn&hK&Ck+u5R&mVsaaZe^r`^6P(*F`I zWf3ayhM_H>uuOV!r<^)pJXN!1mxZhEZsfDO_A&x*Jz_e1LH?C_1gT*S zeXmnGjG22ff^_NG8C4uY$w46Fqw_KpvAU32%ER!l;lccXF@&3AypXm^^Dm7(-lGG_ z|2g2@5chQ$^GHtUGZnBol&8ih=yi}r#87>EB@+=Tyv8Q8eIDNH-EOEcEykF#`G1Zh zt;T=bgBYxbNK|K#KDyOa3+9^&tjj+sy7e}Q{Er0w=9~tLNCJo{=iM}q9&7U@uEbz$ z^dV{)G~8c*F?YYdB&FdW3w?=g= z*GQNJX^Uy{Ap2JDpXJuTNjd@C`7`@uy$GV8Um6DQ$cbaqR|F z-?(clgZKTz*=1!QS|EQF*W2FmG#u~J+hw#Tgq){_E86DUWlY-zPeohMJ1A%5 zK5lB+YsobHQ}*2@dCeySo1NepvF!YR565^+N(V?sXleb-0)SZzIpBQSGEy5Yqyx*v zCk~ErYDXv`PNlltrPu59W-?YHq5cDEq?-Nh!wyCk6hEdkl53m@jRFGDpNeqVce+vj~vf2t`8~TbKU1~Sum$Zbl+`ubdTx6 zo#(e5s^gmYtiEykoQkkST>S@%g*H~;jlABw=7`X~mW+_Z+wtoNBQH|r``(hqN=BaR z2S{w8N`z$xEv8LelE(ZeDoWZGc~o&0PDd4#Z6n->2dg6z&{ZbV`?KAPY{Ugo-qX>1 zkDnfy4*Q5#eEv0Gz8dpw4;|H25L?9#vf-RDud?8`2V)ZNN*fjCj+Hg@L83)V-9q4x zJ%WeL0-{B}Xn!(0#AQPM+hKO})1Y+RbF|)nNmAX%{|XKrgmA&^H@UDY!eV4kX{h7! zSIg!Ujp`zgMIvadZ@^dKbhf`uXZ8O#Ki}J4O4BRmPF04E{My8gMTIU~iT|3tSV#zl zg04Qp@(9xua3;8}UYRo4=E+KDw3F;2(kfZ{5RunDnlW)r_1l!nkXT@DW}i6uJPHP{iRL+dGMBCN7>L(gX`q_*9{c*ndqmgzgAi+$n^i1 zRZsnqCW~Q*cATx|^z|Nw@&vA}S;9XtJ6dQ=)%bMAEqMOrCgtG|@z4=_{_{?M;tmZT zj&-^N>&vTzPE4Yj7!<(buW7nw(!*bgrsa56On2twPj*jvh3W2?C)X`hKam6I91f_y zjV~M(O5W}|+6Caa-F<>|t2ai#Qi$-KD1d#p@DBKoq-V+#M7;Z%-ak3MG)?*nW)An zL9E3|{AM@Hp2F#3vIdwLcYC8Uh+{y=XE`QOAlQC{nfi-H2`NoFm8O!Aek4+ah?CThKOl2+xFt!4RQ87&; z+}&T&t5$t+MlfUfUpnxHmkOUqn{@C-+`#K8$B5dL>sa~Z3trOu|5d-DMZCZrk-hXr zg&kB@_SIBgu@TilfHkgkCf%4*o-2`Qby6PB?yTVu{84@sn+o?`Ukhxr1<{A$_`<>0&NsMh>u5Syxv0$;V4bZQt>4Y$h))_#UiWW4S5y zfxf-SLVsP(VM5*?rN+X_t?Gq6(sbnNd@c7Ch|G`wCC8^hkM5ifd-zOjZ;qfpP+$&- z<`^kS7}VQVnK5oS9?!#)D+#$-=O!KVq@8b;C*Skk-4dk##^C-hJh6Ln1c5Ny1}@xm zu_(BM;c-1uP&iJ$n*CLR?E1?xM8DblYq#x@t$0SQTTD1dKD7S-D?;*B|D<7g~?#^C!NiNbh5)!Vn((#|AsWLgCR?7Zk zXwyZAz#HCV!VPPjipj4>IYPE>#zbcMpWbryI3XoDY z$URpUZJ`k%_Mh=+4{fL9emx#r{C|9cyWaAcboe|mO>1Vh*gs9U?+Rp!VE43_ueJ;r z|H`PaUtwC=XsDe*tSf6djGBe)o+fU}5qzx@-J*sDJ8FZC&F21#A1k5p{p zxlPf0s6PGff7nH{kOWrYL9`UI_8?Mz8fAJ}WDMz8)TkRfrLcG`oA^(Ns&itz=`%qN z#VKh+t!H((w(c7K`A+GX4OQ?@HgpCp2FB}rJ?%VN*+HWPNC$K6=}O!9Zce8t!7%C+G4?a=nNYUGT>T`NWQQK z?ktP7+mU;%<9+l&f;gYnN|WW~4>;eA_MQ(BeNS@?I`0z14QW&+BJc1c=4P}^_+!k~ zsr_NBh{9Ox7Vk_l3m+1r26{eYhH_{Tg3X3hMGWF%7lyukXp4`B3_VXC;QBoFR#Ct0 z6;&n>#l&KVbAbZVsCp2OW|?77<%h@#n8aED8o5m!Zsox=(r9S7VG+axD+62}xw1Vc z5HgKgU=#ASOZZc%TOFXMyBcsIb|V}K8I5|K2FBW2U%4LC5_#$afovLx=a-D^`F|+j z*1fH_866CVNsXNoU*l9PK=BTnl^dYP|Y#)=X;M`X!rg$d{W9<4{bg^AX zsb&cp@5Yc!+2<;MXYc`Ye$mGV%m_@<&@5wxBG*21c96(mTI#0HxgBYx71z6|RpnK} zb@?`lAS}B7qc`4%s{+khMs?pksYXICQ3*yP5lG&!3~gSA8R7Ab#HpAIBGz)^@8h4- zQ-&DG5A@d<5SZBQKAENG*XhVK9*hYQ&j{{L4fL?XvTKX*o1=njd92CGl9YQq>hH3u z+@3qmU=DyABJ1N`8vh4oMs=VWecM8y$&mh&&d>Ua)6Kp_mH0cSSVA=p3wZYFOyaP* z3of+428EAY4kjbvBN1eQz6;JBsK%o^{?S8LpjD!*WIY{IuKOvbsrUImtuUSD%yK16}HHl87 zgwTA~gXs-dSF~BgfjysCP#~0I2X@KbCqUoxi+9V(Z%ay;gaYa%=4b&$+unzMFuc!L z+FvCyTV&X#myg71Vsvs6_U*+5M!xKn6=C>iymXgFsSBC$6rU!M&fSDa-~Zrb!=)ZO zD#;mk{D%#V&hMP;AqEL>;2kRi%~tsrI&*)-p5|103R65~hfXsX?4532{jQ607G-jo zSbr|G_4Cv}Y^XGs;_Ku2K`i)ca{OpU3lL3n$Xn`~t}UzJ2h28DrIWI*W-QtIhUVM< zB{&;hpPu4wXb)BwS_GnMR&Lxt>56r2)W-nqo9psAeW9*2AjRP~Li3pZJ7m1Ke?S=Sn1#J2jqL1to?0{M<6A(e%R!4^u3akAyYc_5ZDy+=1ehjqD6EAvyO zqo~ScA*io`7K0*Tp-J(kl=UGKr4E1egjx)c>_KActS;{%Y=^r+kMdj%E`ffS(wIk) ze~N9J{?Icf1?ZejmD4d93_LzQBDnR2EMl(6oHyauMi4N};{Q-h=gjHmLSCy|m;z&# z%l1+RuPKB)L=ZayId4z^`O|LLUJb7ESW%_M(V5a-aN=;@v+NC?lQM8kpu_Sc3lm_d zM^W9$T9E?X_&rgJAUrv=aR>MZXEoGIa|rD2hUn+M*u-vKZ0zpq3`vXjmtOy5_fcmn zK@1>blD?%~?INLvmMVO-pYVo>KKUl1{$iEf141M>hx37L6z`SV9VXSld=sVxT-;Df z^}=uhRicq`(+WgxIm794Rih!{aS?mx1aonC{l)&))0My*)+XT1$n!l%Xy}RU`>DvA z11w6|7Z)OtACIDs1^z0cU$#yS=(Qq~0Nv!0s2a7;D2;N;QoThc5Wi%pf*t( z4s?n*{KI~KCB@AYy+l41bV`1%E|EsevBm;*m?vA8!JdgC%xh0I3o?V07IBC@GCiX4 zCl_R6Sr?$F5zfqrM1>$SJ(8V8hp{shBxLy?+W;nHx-`mAWu=b`nS%kr@_9;=2pR{8 zQr4$g;>F<8xBAs%6ZF*|zT4kL;z(KxfZteL6z9#bP4qtGkadl-5M5YRt%H41Q2svPnQc|L|{WO;iD)o+OwAU ziWZLb%{dYdGZGw625J#wf*uoNBXJNFLLHwY3VDtL_2SijDp5kV(?sW{A1kBKwfK^W zXZna-AI zS(JOo(J(Y(UPCWoS8$^E%VbmFIpH9_00^L;dyi4eio`~`mK#$$kgsKg+GccPT7UV> zCI;jGLo8Q>YA9DwsXj5(ay$i>O+D`~A_bJ|JW}L)k``9%t%kE7ZQ4vQIF?#iR z*z{#O5m;aMC;BhT=$JTZju_R9iGS)U(W%Jb$&RrkuN^wzq0S*+RFYk0yKB53(!W>d z0LGYY%^|+SnbK7w(0nUa3b_SWa8Em$_j#2PRKw4guM}01 z6om``qhp=z%b`0kNdA;&$4*hrQP2#fBOe-uQX_#zA*00VyA9LfCH{`Oh6U`@J^IA~ zH6~Rnr-k~syse!5TfH1NCu_oNnM$wG9@p(5$+sdFT&PDbq}7ro=H}(9YMWpU_m3!( z(HDH8!h8&7TcpLlK}lp6Ynqber2J)sp`w^f%MSbsr<|i8%Y;_T4k*$kp}Iw`i_X<@ z#C5HDT4x_=1}XHlxCR;F6zrFOYeCqe2$kKzfX&|09yCx} zjrn=So8!j^Wl)4Ver)9Elno9ny@z|o<4g?ro%^ls-MKdm*aK;+5G*ez%7mgqLt8T4 zN*5v6Wh5uMEtl>3I4U|elE}}dDDJF}9-2EQ(yI}4)qpH3&$|)19r;zI2I=9t^$Pvz zXrZ5sGZd23T~p;J5ylZ3B)p9#`Ndm#Qtt>>ZYMuS{&=*Fn<+zTtI|mp-XkCxsA_eU z27-jN{j3OR#I`2sQ&jM#lAQgO*7YjSu``f&4C=bF3Fm!eK>iGEU&qdp#{NbdE8qoiR8j8OnGr83H+p-CO408|>rz8>Q{#cj?AJpK|m?5xC@6_gYoRcnEuF z02z(S!Qqx1^q~&Ec!Yc?Lghypga!Wl4b2Vu`pTF)Fnd zo^#wNEYt_7IHG5WQZjOpt?U9OTs2X07}mZ$U`=Mh`(8~?M5eiZItJ{Y$*3|nvlV?p zxUqPhBawCB4uCV_W-n$LJmed@HaA4C=itJupl1RNWzfTPSl{?F<>hK7M_8 z>3xb${j&&y{X*D5qbZCyJ(AolC`3BVy%*&;Yd`&}E{#ZW2}TZzlZp~Ys8XaE4Gwph@2=&i}|!aWa8f-3ZjzX6g@-l#w366Qf=8VVvqpIAnUUu zf1k-N$8mI1jSshcd{ay?{K1C{M zm`oCujN8=64~H|K{m>W=VruuLbbAsWy369s8g?juX9;%rNRr?bbN?InJa*!>b~>N* zU;C=$ulJqX=T43gsT}52CQ#sW9hlFs-|1l@(F(;yMMB$GU;VI4u{(P>9=r7A>y#1c zfgqM{p6=_yTz0-fMYP_$Y+TL5!8z|e{mHzWU>ZNybXP=B~#nT{Al5yfnM zM8^7_ldMR7p0cq71|(z*3L3xyOQZ`|gA|*M#Lt6&djTB86n}1-y8`(4o=7L?)ak#c zF;rPglFK?M1;JqD3c@f8vhLuJAtuwpSRj6TBG0@!%6yHAG+35}(r?UsoCN+Xj{RWA8S)x!jt z|C8O!4=$USOchLTTB275B-56<2Dx3qh88)4xzJ~%ul#iRn4LHN7k`MP( zum(q!;uw%)U~ET{Po@8gqT|vaQ5m6+-oek?k#5Dqf$hIh#Qm8ILQZ_Y)`=I{{$q*( z2wZq{XHGc{6YouNm#c{vCRHGm;gZca_MKtIQp zW4Sv0u@mG@Y$88P*{3*{3T?hE2O_pyInGJeV9ujWYMPdBt);AxxtcuQRH6VuCetQfrOU0si^pkjHo%k%?)4`0fy-`zdy@H9n*&jX2` z2y0NtSw*5CPcS)7-yC(8Sq6FeP9nAo_Do*^iU)S>#EGQ_lrjjf0p=q-H4G3PRTjAm zP#b5o`j7xt{8iEq z4R-BBe&|G36Hm|CkuIu0N5~%m>7yqy(kX==_CKYO;VCzD8{5%BIIA467YlPCLbfoR ziZ}`KE5*@nBWZk6-$g{k>%E`X!7Q80Omt|s*1L{(yvBxR%{JE;y3X^rw$Gkxfi+AY zx!*n;xf+Lsab7>Ek7u{j!VK{^QRj46<_x0ulQdlBYi}4PP!@MraI5X(R`)fDRT`Kl z3=zkleCwooAv^`9E9*W1A;W^hHS&}hY?BoTgSdbr+hmuDLxyv7xY5W7!_aDC5@oqT zX(0^BDoKc)?9s#fyJQYJ6n0vQNy;G(G3U>cI$hJ92-bh%r{0^i zn!;+{s!-1-JfF{uy^`!t%^y7K9EeX0h*yZuR~#Hx$}BxB^bH*MtZ@31!&F6?rU8$Z zBq-f~6&QC<+AJk8PW9%M98QY0lIiEBWa$p9wH94+Y30Ar%R+iB;x}vUR@Ko|fsiv; zpX<_^kon27({NR-kd6lDer&bXVYQVa6^y(__9zn^T>piA&LWiJWThudeX&S_iiAy8 z6A{XlE{b(jLLN$i!0V_`rRSFQ5f4e;Bs+BT9Q=z-l;A26E#i2wzM}oDa;r*BoNIGg~`UeGUh}LKo*eR$@^-vfo;fph*)cPq}Z1p3R z1v>5LeL0B7ms#->MRVP~<(_tJ%TSPAy|ZdM=8r>}pa~>0Xg`qU*TK=uhIu7`7AE-e z*!aOyoOj1!Am z3w#iM6S&l>3Qvkq?hpB3^p_upF@>)_{GD7BfEzXlHqC&MRp=DCJxdDoY1uW8Cqdf$F(jzd!z98#yv3i$&Gj+Vab@PE z(D$6R`XM$?b)oq;*C(31YT?=%&Z*GcYa`m0;vK=?9R01(R+lS9BylfK&Al2aQJ2<= z9qc94j+$^BgtFW%lsFQ-*z#E8aWay*TymcVa2yl}+sh?Z7xLB@yPG^}eEA!E=_=i= z1Xfq#L1uP@tHJI`p1$QxMFy)gu*-8J+}|r`Apwle3sL3enThiGX&`vAV&)inh0HDq z#tgcq$a2QaJ!}^X8$|=jf$cir;l@f&2|o*@o>D>5iJbG*_OAA`93O`*?)09cj35rkrzA5JtFBqo{^XQExIOG2iQeG6}J(SsdG6%-L!KVc~zdv;kQ}y^TCqZHm7hUOL z3<03Y(&=+43U7{7cakC?ho-T2+#@hWZ6PP|AzReVPFo~IZTv@Lc(mXJ{YM8o2~I`O zHf_+Z=EwRa9sIZ!`AJhY8;#eOL=JGImYWJ@`h-J|LVf;(U7|eO%2;XZ3A>q5s&+3m zEVL|n#Dnecj2VtG^j2#d=Q=)r8AxVp$t2KAB~VW#&{OCcZL;c$I1u;5{ELlevNQt1 z{-+(1rv#iQ?W$xXyQxHPrJ*u(ca^{4 z{O3x%V^jHu&#WG6v$qYP!OHh_zn<@Yz1*z}olTS6tMIIDlsaC{T~IE6b`AUmt3@Ug z;8xy5^0U0T5v1MJv`xXTKrH$i5ozF=c*YqjgNNo-57)lz8A5V#Dp@YDf{zY}4E3_x z3nt@5-tx{)Qw-)c_D<@^N0{p5uBxEP{(`TaDxD&wihZpjsUsSMZb*dKJ(oluXE}m( z9i=xbapT@G0WC}hnaKF6)~ZZ@J=`x`S|Y{(y`vEJMPRB^&>{RpJr?sJ(d$1g|DE zYC3X5bCEzPRsToocO?u$`Z&`H@zqGUt!RcOul%=o zW`A0XB+t8=L7!zzp`$=A($#g^o){V5r3sST@QL;%Fd5+d!t*Yk+X3k=N)mG|a9;}@ zrQ~$yL^qUc3zQO`ON0pxQbeZ!d=R7#mga%m5n4=-{a)bEI9lSjkP%J>XV;(j$yO0X z%QZFZ_$-U^DRHhaMQ8HI%h*ZDKuj$BeYLAL4N0P$&BU>4WdbMCNKWHocc93n61mDy z)7xp*>E?yhax=gw%?3;fiKp6^$j3mW7$-z`i&x5MV}Czxxj8W5YC9%|F%vpD$d~_H z`Evs0B;&5+P<$O1i!Y2>#+n@G&+ao0Y9R-sdZmT>_c)vPkXyMHCxJRB%*Ysz(rb?^ zINCWVEJQ-yFS6LeypKt*Ggu;G%A)D8of{Zrs?N##N- zc29f`x`Y!@wbE|B=iPoY6FQ4BZ2E;p-p>Jk=J0=3|8Tu9cvu*GE$n_H+uVeiAEHWE z)6#6Px=^*TT;=Gf$rId_dvKrr*|?{x}RT;xmaV zSjwsqL=0da<4%m zvBBB2qv+qH-)AoFn)qE3t!>f<>GRhWjV+4@)&a zt>aXNENGQ*R%Xo$C?3UIF_oYtVntA_3Zjds!ORy7jHpKOM+$P$NyLPzpF0PzsP(W5 z7Hh(ekTsXkFS?UW%Ir=}pY$SMybhRmyqrqqiW9!O+@f>2aJlMwSGd z`)9lv^*(lgQ7i)`oPV~zx__beY`;!1l2a-P;rOD0cFXd27&{IcKY1z#S(WBxpFThg z@L}$!o9Dx@y)YAtkE;EN6{!xGKS9g8ix4LT3h?+4TsWFP5NTAj`jZ=z5p+l+Mmt(H z*H?-+psKUZ&w_jZ6vUh#^&M7`#8JM?`kyTe8w(9zAwl)BpdPW1&KOaker;0rElzOp zZd3Mc(_Wn|7g~<#ssFt)>)-V}*w$#U4!RK%T1oo76>Igf?DTwxi<(!yi44*R;K=R# zDjOKJ7Y1?v3h=GUc|ah}rlR=@*+X(^xdjDG@ic1pQO@+S%B*YDY_HdBFOXWDiQV02 zqGSBn2Z9&R!Bd3+P58sw2TOK!Fvo4IL;(bVcYo_w#Hxg4y=Fb%AXQ|}`F78Gq8jSf++kVlnG-;-vFoNC$y{#*| z#tdT|`o$mB_T;|Z-kr65PXQ=y1AdxC#I~eL(`$G1s>bhU*f%{L)J!sQ-A5#Qx5S)( z@ZRLgn=}aqA>Kh0*Z5cW}83bu^Od5$Ov#Ay< z7cCVN8I>-!x)u=b6Z8U9$Jg{fkcB_`%{!p+du5KT{&_ah^ zf}dk<<$4T>rVI&;S&(y^ap3^BEb6rX+(UvF@C3wlf5U(^Wx@L?sK9xp!Qxwt`!fXF zr;*k-k$7dgNEj5G=<1c}>ecS!MJU(qqn+vFRo+%HhquTQW$3eZm zEl)>hv9Ka`gi%(je=p@L8lq*Eva<*+-FA|>p{ya6G+Cn{154bH0#J4MFS_qA2P7hV zS8CanoCS)jPKd@do#yk^S!C-ze)nAL$A+dK*>@=m6g3zBvKC>@FF7OCNo05Q>NIoI z1B=v>Wi{MIY4Pyb>FI6}Y|a-8PnDjZ&5-QkKaqgh9v6oW1=k19WXJchhpreSZWm$i z7GakeL(Z^6A(7Xn;D!z&-!Wu^f3tTP5*t?&&lr-LJCRsAk$jvOR}+~Ai=ZA?6Ps3} zom7(scldk>v`GBTKV&ErD%|f31w`b+`;~~CXhf+gUH! z=Kwqx+;oqb@*<oVm6Mlx`<&-mndAK?+R|L@k~;qA zw59|)s5w#d>dl7IK_H{2X!nWYo$j8{eC=kSRs_ARfj5_ngf_;%23vTc{oY?j^2+u- zsz#~fL9JgzL+j**p9{DH8b5QPu7v;%~RK-&{i* zMCR3)eQsJ`UuxbaOu#?Izz)cHox;3|6#&mX{^!h)XFVz>@g>5LvqlMVLwsYF`+6%n zu7+}j_32XKwLIaqBH@`*;gzC~adt98a<1m(S%&6ShStFN(9DSDzV(V90cQ+@?S$D# z#=EpuVV@5)fr^EdsEng8H)9VNBTCYiR2|qBOKPKlHDg8{Me4nQj7GMMDAc*NE+#Wg zDkD%&A}A{TYFMfhG`IG#7w1iGLjw=ERUtQpDTRgx?dAmQPKk>7W8^e`AA%vKf-F%I zMcr{DLX^W?u9O<0cA&SU1yMo&r2Xr9mUHXyw?4`KF@OtpgnJ~i;G-*u?JVaYkowFN zT~x`{&jIpJpg~zKvktUe%G@vcC@2Ph`a;x?+2b(Yv!qqnImjjvZvt*2HT56iF?I_9|xuI`+J<8V7TIg;F!bhN51Rwn-Gwn{Quj@P!C|^sABZ zFC>JnY5!0Uwd_nE^{E;awNrR#gTJEF|4ZO$&1byIPO;o~ewoAGuKh){+C?-QCsEK@ zHH!5|T5a$b{uUbAAThrrvAh(!-6UDqBrq@T;27EvV1MmK2ZJe}of3{NO_NPdFms%y zS}s2>5Ev5{n3I$e5kG(!T5Qu5$M=HhNu-qxtuS|GK&lJDrC>g1%_v~~N1+*F09Z(mQ! zk1T}H^lOBz4pszQ%r}Q5+|d0ea&PA8F3v#P4ALVJAwXXaai7h&Um2?a=$DSvg-DeC zN+Ls*0?xuOL>p=V`1CtWBfa=LG%bL+%fH8t0?Mz*x@-H9fBoAY6I_y+EwtVhjm`ev zQV0UDYf>t};AhJP|Lgbu`u9j00y9~D2zUZs_(@cBQHQczZp|;LpDGk^=4lj8z8Brm z^TUGPdHWM8Ag4Cb1_QZ^&<`M$$P{{RR%pD{kyuZeiCigKUAzb<#4l~sFNzrfTsIEi z&f+PTe=}FvN0w*mC0ACS2s!Wy{O&XmD=Sp@t&u1MdCtDPO->2CvD~6{3IbB!nOtW! z1q5HSK?aH4s}T{~X_#ysO4e`O-q%i0x8#?|wwEZjm!u0Kyko+G75>hWftTyN$h?m5$JXf|kB=aId1Hyb|fnTw- zTl~I;f`359EGFX47@kte|8MO6Dq*KT+%V;Duo5k_SNSEf&)NZq;{r zA$YHSvds9F;XzamVl&GCE>35oNc1Rvgsrx-s~7@KuYDtJT2~ELr~N9=;d;E<$b&Gw@r~kLh`1KRVyLh(!$eKDf|EdKuDT#rNe6vGAE<~`)j$JB86MP z@2=o-vzc&Sm1AbnRv%@7uG2rB4gzW$D#|B0))0DYtHt)0 zxqk5TfhFqe(+K8k>y<+D3y*7>d zdLa4{mF>TjK9bTt4`gh{VQtJbG2`Oc5j?T2ExnhbYT+%ZBkr8Qv1e@S$jU`3GbpGf zn#-CR+&UcK>{o=AKu8NA321;#grR`!LZAMs%d~f_7b((2!&Vg@4E4*5tvEc8A15#9 zg?nuOb7eU+05v1QsXVDR1Tl? zF#JF7Y=9Rl)8M|e4Dwn8yBf4wD72mj+|@ta2)ES=KNhG*Pm(YRzw_QXBo}`umzN|C zs9`=a8$%UcEZE!X=n*@!n`auTisq&gXWi8l-_^O773YH|721uKkM8V(wdSUfsq+m_ z54-ZVBAwO(e0tOg{D&sShek#;&k{5*Ykki|^;c;0E;mU)H8R=!-vyKVi(Junw4kBz z2PHhFsaN=ytCBZ#uS1B36it*#`V+jjMQqUdHz@cvsQY#xRhR56BKM&v zlhKCvO~-#`JzaNTU0=|$P~iCLu1>_(9ZnMp)4VeUQ<|r#>MaHBq{l=<@P+6{YUqBE ziMKcO*l#+jaT%)A418H7JH^kAl5N$&YJt>=<}OU8-N9{=Vh-$`+(Ep#;qbWwSbLrW zH)lY0K?z`wf8k5*Qn)=5nhiyb6w0CJ!nFPV1gF0D=+-$Y2Ee`>misfuX=Ixr!q=OVf$|{I$h-uv|8Fk$T z^xx0Y-iFz7pJ=gwuWxB@Pc9#Kf)fVsM1D@a`>4HV>^afib>^7AdwmDrLBI66+{5t0 z$vG1|;&Fm_Lk8*e>1d`5j~5N|YPM1W0zehNmlD#2XGiaEt(EU;etmnH$g<|Q?(3#$xtK`QujytQZ)mPB6#7hG&vW(Xqcg*;-f~m z^e5JGNr(mlL6HJTsKI6P&yp?#sQwm0>yR&vNqCvVu~4Ji~=n5@1ZCm-tf5;zFb}c zZbc$*!|ttVSLgpfn!Y+J%J2Jn3Wn~kp(K=)&KVk{MM9*JmQJY|xg5%AV>&G zH==}eH`3ih%sbzAt>0RA-FeoU|DNaGbM`)WpM7z*m&dy=ed@T$pfxckeFCxxROkV0_v7!T%lTZ+$21Bp_pI_if6^`|dnbSM00`W0M$Hv4F_-E_Psn}+ z$6`G4?sp)!zwz${x!I5rj+OqgbYF;9Dv+yI07?ni+1Q&z_(;^ z*f^?d&u4o0m+>&fdwJl59ryF&ZI?6@(u!Mmpu~ADL@0RyiMNpa8mN;?L49%lHB2{W z!YTn^_Ir`9`4Z75{ZfJ`(f7RI zs8YIBF`kvX0xS8+V1-x0`y!^ zbH-Bh1n^kJ;5__9&k|O31}gwOV?VR&+caipBF%cHj5a@r+sSNJ`}=imBEu1PpDnKI zbT2zfkl2`&t-khv{qu3fyv*^`U9#Wn4u9V-DJA&#&RL;W7mR}s?SAUWTQZOIv|BV( zEgBxaJ|ZtCtp)n{aL#kPzkTe`4_vThvb6-FXO|-@h95}3{=6LGJ;+U;!{jUT96$2x zaM*^thjAj4v7x!Py8HkZrB;oMd)o3)t-FhXE=*q)*h6*pCLPXqMOr$(jO^a(xGd+r zwk)7~`0ZJLzuVMzDO?oDDna+XTm(lMs)VlfyZkTA7(3U#eNcbnDcTR|RJVZx& zJ}B)#?o&t*L-6qyCBx>Sr}!~8Y2N4`5PB_P0+{}sOs97vxfU?L@jq;ZO}8J_hprG+ zNOhBZux@a;n5(P2zT&ug#9{kCJw_+4OBi@rqtbJ-)6W$_=ZKKjiP!`}3ny(suyY|eC!MUVbv9k`t7a`Iv0=1B*xrhL7#is-m1`jSkEc17k^w`V$eUN{q)g! zX7dgmp!&T2Ns149OkJ5;P^ocHTUILo(Vt>pP)2LDofL5K%k3gg05L-uU}+Kb1^VKo z<@)BUN022mqA#!g=4^@lvh(jMDXGE37GCWW^&#(Tt{W_vrB*vmM`M6{z*$+9x~q(~ zql`A}jH&lq5uMlUITm}1_E7}faQrG*-RJPRp8sVXaE}UQHqW=Y)g|1nhF3`B*%3Ru zC$%%Hlpng}z#Dx~<%Yz{#>zefC~hr2oJihM<}2NuV6jyX41Kh-Sd_9OdJ7I5KVMY1 z9A>+mV!P}^T{3_`2MwVa%d$PJu@qQl+mSHxx3fc}^&G7yVevSmSTl&oNdod*rUSAY zBFUC|1R@?N4CA^nigY=RZfYsgXsgorA;wi5UuvZM(T?pcz9g%dAnFYDKIxYe6;#Vv z-EgXmOMJx~b^7zQ2ESS4iCCQQF=x|p-J?N}wZ1tW_$dIESi!=O!m$jg5>S_CmeiJ% z4h3J<8 z_pRErGp0PSboQjS;wFk*;_gvLzrDBwItCKqBJq4foVP1ZkMFQ2bXW^L_gXO{)lI|PzF>Nw%9E_kp9FDyxr3b))u zNQe#jAKWm?tADxsFp8;I@3zG6hb>)AZ`>k-a+oE%1By%0_1`g~^Zusu{yB!}-4@=s z{_Rpx-LFC=@3f#wROO@yY!Kbf#)>dDGi!N$jnHgE5z%u6JujTr@US@JdqUuvsM zsE^5gWuEwow*^&%)2+K${RY!6wzJ4p0QYCEEy*g zbp%pl@mBr=E7_8o(Ycc$6p}m!Ra57Z3 zPEgE~3_(r!8ZB~6vKk@sTP&R<$f%v0j2P8l&6d>5G zSDM>8QP!&UHpLiy1G68sgpDqwSNT=hzQISJ+d6yhNplz6`?$>bWkr0M>z#$BiO zz6>zv>+n)8*ssw0!l?=^I_OJw&q{Fx8RZ6|k*af0xx36_=*JWtHa8U6TPDeFlC+~E zco}rKZbeGYM~5`eHgpuQ21pE-L><6CF-v}8F4S8Gm;kko{T-v^vCE7$rcJ)+epB-H z%rhRB$K&Rd?pAJSkK-8ueIcp*3f$%TpitFeWq$~GV5h8pZP=L*Vg-6us}QR_DHn@B z!5xe5u|4*6VfxF$ET6lp(f7VgnYB~qq0|4Gd}OvzwVH^ z`i!hCwv~+Njg9D@jc8>m{N|O$7Oz7gRpnv>DgNm8yv6SSC=eayp5hcbi3-ZiT^5MN%*>l<1i`k}h0If>J zQ#Dgqxsc zMb-nZ46*V6m=vw@ReUnjBUS-7l*NVeNY12)W_%}21S!NKPCA!wnl-4{@7|B;-36c| zQ6WMD*jgJhlG2?yPVmkmEX0FuYt=pVuf@T%1s6#GkeoU>*0MvFAeA+cW;F{f8fu5j zfKV+roSk~SyjZy~S-F7!uCXt3-t$>vsvGsAa2lLXKOQ*A0m-k+!1?Lk4Da#_CbRPt zzO7J=0`jhkbCg3H*fz9Me-|(z0Sl;ocBX_PoABFW(@4<5BoEuUU_~!O?ow*r0qr5i z3`qg;ID0q}al>W=;>DJf=K14h_ob`*p-PA z%JvEdEz5swb&+hxvhh&Nz%exD$C02HS3%qzsl@^2%yY1%pfG*OAY)hIr>cu2p$``c z-M`TznS|=Ly28jD5%r^BC5ILy{4)mjTvU>-ii^%G`OE` zLY>6_c1%RSFqC8OUCV*^Gsmw?bG$R@NPn1A!kMHE5cpOsU>H{O^rfN`3;};jN4ZB~ zz(7$KYyR)X;0y=o(X!MZ#;BR+ad(@0MG{Ahl4BK5Aq(rGO(Y1e$fg#cGsX)Z;^HA% zRMuACj{ke6-B%V;ZKL)s&|Zwkepj_X;^5X|rA_Fk=Gfn! zEq}6wBPqW75VCYRZu`Y`nCf{2e4idgL;^pX_N$)uin1JQ}gzX zy(&|9K92Q72>E>TFj1@Ltj*aV|JXh8S8mS6d@hQW^g9+#mZ$hWZf;I}TliWu7~>Em zs)#({ma^QN9>ZU02t61AUe_JvAnU?vVRuz@p-~vN7*dk;{jA#uL?6eR`G_FfTtPBN zCguYFb?D^>8|ZfV_lLwKkLa6WAvo(R7tR$o+23~#7kza(7``*$s4o*9BhMZG06=kv z!E9?gMT_L-!skviKW{u77aBRdw|d4GY(c0mjkoWm3f9e=6e5eB+T}s5+#d%NqeaJ* z2l)Q(oHUlD`lHdDq0T7p9ozj1+YDOiuOU$o@5s> zj`3{k@(M7{JW_W*rZtbTt^Ms0j*9_(SEJdSNizS?75B1!U)jq}@t-dXOu~@%IZ->d z_H+|c%rd2B0EmZh=5j7s`G%2~&qtf06mzUc6kPN6mCFaTPfG-Z>pJw$!%;so9Rt#e zr=6hyYhLW>t8YA=OQ`xX*x7?yrONitSvE1(lc)lYnk!K;O>Z+;_wWA;=|uNT!_R6 z+lMnAgTCmmd6U8H;=;QdF5}v{~B7B&08%lgfbW?q9TQ8WhF31oP zY|>p_`0Ht*#vpA70tv*grXY?bVtiOtfCUv{d9qqat>)WGgSSo0{o+^O*A^Z+{p{45 zf7Y4zQnN?6@(-G00R>l_^3n|FGL2HQqn%`fEQH(2lkBUsWJgwZk2P}|r-bDwVn^Y_ zs)m-U`k(WYLN;OQc3utb&(ODj9My+UAN|8kO8|ZT)HyLmCZC1#Nw(67Wc1G9S`5_b z`8RAXW3Q2;IwWYn*Md4f+f@n}%NAWrKuS0!SZY|qB;fh?J2&&tfyh(;CIwPjwy)m= z=;SC5JYyz`%9uHxYWI}eo)lmML)rcKb{Hip77*Hs>EgeQ={sx}a>GR7L&=J04GDFt zAaQd=#SygeZ2ztI`5&3V{dQ{gytI|)=prft)R+Wm-CXop-Wd4#^62_ijGUK`TpUl! zHDs{{q;EaicyvsIlm;S{Y4DK6H?0b+S!~z&)6kfA$KT9f!>~2nYaYx=*0l?11 zAV%vDv#bkJa?#<~O78hh|E_#>4PJKR`j!#ohVAT&t_#El|I2!dC!_RQfa<)|pVHd9 z2*6L6Iq(qws$u4B)sBq%ehbZ`xI70l#b}bG3{mP_q9a&Yj7! zCfz&u^Gly{pmlddsrt&xj;ks(uD`BMvDbMn-EBW*&aqTAeU>+CP>KAN99 z^Mn#1E`pXeTZ(N%RUd$qHa@poc8S~D;%e?cj--?JXEA*>a4Bi_+xDZbYK^{q7zn*S z;Z}a!5mn59K=MW%KN3?id*%}J*qPGMH}2O(92Oqz_iY5jTk1B8CU$G;*>dKnB5VVU z&6cK$Wg!s#OCR2m3)aOuP;zh_VB5V>0evxz##u|i=JKEt-5}=b#ySb;u`$^avogy% z)=#Do)7^be8@hc|8xrr2;k`g=-GUz>kG+>E_vh0q(Nq<1VjnJ~Wz$k=WNIb!bqsLy zr^gI$ND9v?-&wvbv4clEA$-x}PJ03FVagH1%*zV9Rb{gX3Uv}YNeiuAq z+SZ>r^H$8uqMK*S7xVIb1gW|rBeSo@64$N(u^~I+%~y~vN7qN&Zj5u@(c)fHhkvIP z%wDThY*C4aF;-Zzve*>Bx(kZe+heZxd2X}!FIkpY6-AGS3okF(UPpc?vt8wuUOk0R zl*-N(9qv3Y*wgn!EmX)YmxT3VDfG`c?zv9NtM7yU6wo()-dS#2Km`+1mNXjZ^Mv~V zF)Mx6hK_=~15NC<)>7$ma5N3E(Xq7&dcmEAoBliEm)Pn= zba@MJwA)dlrhnh2EXacioM-Z9R7*K!Tr+qlzx9k{VJc02eI?= zABmIvoi|;L6?9kg=b#IxZ&7s9KQQ@&^Yd#{R}x3}EFy}XVe+L#W0T+gHz{($NBgDJ zusMMHcKHpTlfFQx zbb{MnoO4Tzi`J(3*%PI)xf-8WT60Y4jXgESN3QSuv2~|zQ*@)-&HlF|z_R{o8HahqIH*N@&=%Cv*Ud&(&YuPknCA{4 z(ZFoKo$|3r@ZOH8nL3K94}mYBTbEAd5)!fjJ>APRIGv*kJ}Z@YY94P;60u|i$3=TR zq(i9#U*Hi2n-P7hz~suEo@{Rb=GzJEgq?5Wi8Sraz3`>XQwB*u3{%BViJqFoM1)NY zVo|rrS{cso@Xt7*uVZTL?|xAiO*{^Nc*QaHue_c&x{D#SoJ^-GMZHmFn~qmtd7f{1 z`7wND6v69& z{lLo7+Tf9+NJNk~0()HQ=uYYwFakxc#yqr)uzJ9d83#g4CJ(gINQZ$kwPS}ld3e0R za&>b05i;@qLNXZHQl|_3?&|ImF&56?V|h~K%@$t@r}95;KMNc*a)h-9=!yzWBMf9DN7StVw#ioZ z73sN`Uv{!fiU4d^jW|H$WQmwOHzuRKHWBsbla$^Qc{HAYdnt~)A^cETM7ud%sU>q= zb@HC&D6HsqAhuXuo(O$M2Yrm?K64e2!x6uVAnD1){BVH9q3s=zjcfgg_F~U{75|G* zXrbKXC;6ph$lqbVj`I-y!6~kr@8$LKX+QZNyC!Nhs(fl#oru@-ddt(mo?*(U;g)lRP3iV??LUIc=o`df62hct5 z>iB8?RH%YG^!$t!P>=`62`Je5JA$dZVp;N|a%khhbiHzKeSfm>KBynJh>uKaE`qoZ z5&>k4=-gn^8pb2j-(XVKA>iIBPxl^{Dqxo0YUzHrgl^kCT)c&7K;G^rT%xCp@(*`+ zcgMaiA88tzC!loj3Zum1@W&FZ<5UH|FyV2m6M!pqZKyNpKC$lMd%S#)FOwEs$!;=e zWjFG&dCSUfp;4qs^D-mqs6)6tn)x|f@aeCQH=Runmv}t4<^No&iIr{^g|FLZNUUGi zuvo=BTCh3tHuF8Xp=wIAx|of97%RNo2MBe7&36yJinA^+@-CAE7n)6jio*AR*ycXW z6z}qKmHa}L?B5F4opF{ueb}K9Y}bh8K>x%3`1kYAx|ff2vGY~ME9k@)E-Qm)51QRA zgsr)4)UcvGJ-EGsL`4Sr#yaG0%J}5O0SG|F)N!Hmn`s$&MUsNS;H)2InS&5gQ@#+- z{;V4A3@`4?pUJF>fcDCb;jf^hgQG7|gw8@|Wfnh+61GWVj8kD>73S^iN00Kej&O18e}4k)v|UN>v3bZ3z#Qtl1~{yYQBJm*>N+x$(Sl*#705D&=`L1G{cuJf9+QVixPyo30c1vse(wHV{1=#`za zI-U%Mn0EjRR~mmce*fj~KXBzAR(1Ru#(Mjm?Y_ZUNIq8lxUc3CEtJhHNa`d=@W?cfgH?*4oi5*|jw(p9H4k2w z2d{W8Sr!i`&4+JIeL51L*KsBi zwN1(>wnr}#n4Its$jf&XC5s-c(Pxe}G`=WQ2A?MvM9uU_tV67xSUnfg#}NZ;@*6-{-f;Zr-gCe<#CM#^lA5E;lFkvyzeVP zwtd``HBL`bub4PJL&AQeg=`Hmc;b?+ zbbCnGf=-6s2Tup7HE!V{ zfzuXxeBAU|vV5+a95L_5FmWj`ajx$$9g(`&zkAQB3M;$%7AUyYB@w?htM+|c^%O5M z*t`r0cOmjng;~(WLdog?#GrcIIXVwvg{Ks@z7%V0vOVt?d3xNoUk~}w{0XPQji9tB z@3_M2OtUxHcr&SIL>+5RE~d#27_Vt2f1QxiY+cQEN+9wih=uBgS!Akl+=`M_7=@tU z+lbmvD>qk>5lb}C>}Pmc_AW{6+cz44sh(bE`m(3av{46(b!3L2{#3H+^U?JZdeulf zIwnco%W&J{PPffYfz3FJ%_s#uoF(RI_lfH496PTQyMDm6>x`n*N;?U)5Nn&`w z0f^7R#AGC_G#!i_7MI+E%_Vctb-HWjh>q%thi0s~)ixaaTy#?o1bx&`yx9~# z`Oe)5dJ8Z6^peApfWij}n` zIkaGDsNiYN@fzeU6Sc`w&8v+ypa>i85TH$8P5^{v;IG488efOtt-#{N+a%blZwvO8 zR!`tQ>+RoC=S>FOdNw{0tDKjz+k2DK0o4KfymnR)Rv-$EB<~9q{7gx9|LF;1PX%L? z3#&WUF4VBC#JsISsjXQF{i-b3GV4yS@y@XtEg5+|QFT8@7_s|Z;eeWmzfB$_Z+Q$# za&#x>uzCUJ0N^2@N9|>|NLlOPr$tEdA}|z1UKk?$`S$c8BU*qppk*xh@CCHg*pq44yLaL<)kEv%o_l`%VySiMAv{7v5z(PEd=!d>|~&*QuwMD}Ypy=m`z^c3&TBRGsbEeQlNG{_9G%*P?HjpTO%A5k`WYq^LiA ziaaPBy&e|ho)35sfJNc>V3Xy4l!U1ECZi@uS9@2%UhflD0IS&|kJ-F39$@U+%{~3_ zP1cdK@u6GRp^D4Os@vu_md(%d7e7KTdKK`tTctn#^y?7fiFQwRcIN=1Fwm~{0pR-2 zvWL9_t?hzhR%p|xZX&9hyCL78?~opL-w>a=cgZGmfUw{S2n9UXFe3fwC^{k%@quZr zo~=w486r`3Q4{-5`7Kmy8ux8T>^QDeNU8*s{A882g4k;#Lw>*s%r^%10^H$G%3c)I zfGVy;Hds>$Sk5UTg+PB-?UO-I&QDO+5#eGP6y6A-PAXO%?qfnAy-%$@w}YRj5!9v8 z+vtgl*qD938lZ>pg~luyJxT5(>k7#?)X|mUsfJA7pOh4~OFaE*To#uk#(HfuP7-ia zQkW_6?VAqU6n8J+iX0u2+0&Gk{ep7cx!5>SqL{XvljyeWU-v(i4&eZqCjEbWj%Zji z<58j`EYYbzd-qzv?pU5W=HN8euzNB6fbJ@xlUvk+%%h zVe%lz01PCTfc>W=2iiM(PmKL&>^axr$-Fkn{AQ`H)2mmX=I1^(qriy60}rW{IF<`1^&12U-Gl}Gb4%v1 zeBNc&9T*40$IxU2$hFb{{I9$+Xhz=t4NR9D#h7JHc45tO9FjU6 z9pD-{uJ+b8V_Yp2j4MSv0H`3QAU_AP{OSHDnkH<7SJ;m4Ou*q{ z1G(~O(h?&4DU=oIv0uB@C7}_FG?a~9u4S?p2oxPRlCx8yL)E%CmElTud`&cM+RHO! z`2LI?0e)nycY_U&VV-kMCNV2MaXO6vvcwg_9zCUbVm>R+6gtMV^%7?rEu8IwQHpU> z8zWsUrxT#?RG+kOeI2wCN@&@D%Wte&^`+dL{2QT?{Xafs?1`a}d_F4waqZvh^2C{o z#KkTt=HQlBvAzj91$3|s$_OtTMEq5JhjdbL>_= zB4k&5IZ=(O_5PiBqsvekz#}UtL~6x*;=cXESlkwr=6-nl?Fw3A(Bp@@;mp}x2fH8w zWB<&(`%j)v5A1NMZ)&0vtu^NP6 z4P{G^Ob869NN;!tsFa@jT6Tat_qF7JVeWHrB>}>rLeeHPl2xM~6xNXQJuZCtNgrCj z|0o@hJbx1)!sudeTg4P`cl+@=J1u)GZQRt$NcYc9+*=NE@$@rQ8?CqtJe?afOGu>x8?1=i=ti7qdV^mH^JN0?^T6(#`4#CTzW3fOI#aHB+s61lsMQ z1oY?-R{7(p@&~{7D;oKXSE%!|-NO6Lh3Y3iystcHjlwwaBnx$v)l!eMa8JIdo>V8T zi6yQ14vlH^j}29AlRHx%y-7dZ%Yf}?zz#ED2SzMV%hXe?c64xitt)mRHjp0-ksnl9 zAA?%uirYmhTj9}bPl2z2in0K+J_dZ;V5y=IrRQ*srLDvd@QO}n!9Q2kB#ge~CBinM z-O_@@Q&-PdbV&vJFYp1ru2nIUfl^gjlcP5!S2Ya(?mnKMD@>S}@>^aJ+c0n(^R3AAg z*T+DD^lG4N>Q-0yeiH7D-~1?TBaCA_8JzmpvGL;>8!q6(`*=J~JVOJIFG=2kQ8R^9 zu(ZzBh&lfjLhUf|^Itv*!-Z5>BRY}tixI}S)c3L38U%Jax`_kdSUL|^_U-$=mhCfW zA)E{O@zjOv4SgWa6p zxjA98c>3WEfMZ306>t&9;;UJG=Tm)Z)(!%$vfW=;A0)Be4_OlzcXkgzx*wZ@86u<9 zT5E)IKm}OS`y?03CJ11=ATADr<1NkkFtSH&-TJt4I$HP)+JdR+RDuR#T92#u?oOB# z+lpEc53tE8F_>?k{h<*LGzQ@lgeZ6BR`S3#cYR~+(VuPX{g za32||UmvR)Vj0jwJ7nE?Otwr%{)=20`70+QPowukMwRd{7V>=SPL|AlwqGn{2NnRE zip+1hbY=Kbg}|rbD8MC336~a%!rquhnqyLy?vMU7Q$qnWwCR5K29bQFdE)kWha%67 zg)f3lDr^^-$Me7@M=qYGkHA3edO1{spL^n}G&6>i*;5jqY~c<%TeF%sM2O8}gW&f8 zn?^1Io}&DQKpck`ex;c+c?_g`C*?p2VG4Z?anP@!fIEqeam+jFka7c~H9}IK$Cykd z=1fgjK9Bxg8NC%OTF=%WEHUGUVw5DPMso>vhNAxbV@F@JUvj2^bp)(+c6Hkd>S5m$s>s zoj*n*{^?}+PdBEM-1jhNC?d-XlZq2P9@D6cxn@lFLu`r2VNp!}zinBFI~uJLL5E=7 zMErl{j>-6}9FY_OF4>KJ$}Z2pmr@6*`j%1!DSq#_b5Qi_d+ng?g{uNh0#sx+W1z`M`U3Fsp%R*@^lG18m;KQp+4tem#J?*|jW2*3ak{Ul=7 zMTv~KPYy6K9Y#7K5iQ0EzQ`v(xFfb zkLg+vH5%N9XsfOVS^cW0u7!Rpm830p=Q0!R z+eEra_+t_UmOn*Dlk|!FQ4q2#?6&6b1EBkrp9su(E@=pfi?T$C+m6SJ0Nn&!^lDN( zkb6(m36T0NAge}XFoyr5z||JDRQSNQHrh z!8}wH8qlbc4S7w0+8oS4SP;39dQ!9|(L@mo=Hl(dNZn^m=j)!XHk=JW-!u7{&v(W~ z=jBY!9DzUYFFjL9Q!rO7$Us*CeUMp@5169k8#aZ2fuoKC;_`V z+Txp=a+>Q^n<|a&Z1b@}=dg}; zpkz=6aWya-nD&X{gSVr+8CrJbD$^fXT{J-|dA~@gg#ls(c{cB?2PpVoU|N+!0lccZ zHCCYZh0+7ygS!Q5vql@%x%{GT_$4H6VyMuck+}Ckev=<$zv)+z;WF`DiWgCpE$-i{pQ*GN8JMbn!wi&EKc7-T`3luS zQ2&MRlcv@GXe0tM9b34GwKv|j{;hRoxob@YFElf~4!NR<@wq!I=kP9W=XJ>6SPHql zd~|gshIr_Ud2~FQ?6MQPE7)zSV2EVQ>3L<^GynG{0>Zj9%_cv}3m?PM zkVo((4)|onn*&2jIsoW+a}#*5$#y|)#(SyQeyLc1ehwe)sEF9zi`ZpU8l3Om4eH*7 zmdsrWub_1M8bBmPnRE&^zr4%7hX0}GsLUnJsil_H zhO6M_F*0!_g8aY?5RSr&ASTvbN>=%EY+3OY0+1$_3`BX(z6XIk6r~GaD?{&y_fE(N zQo|T>pWCc^1)IcQ#C;Q3aG_G11^)T=_%e~_M1xR%^B1|Tw>c}c!icj8|BN7(b_o$+PqvG?u;gl)ur^``ic!?qjby>;@ zLu#I5a$MiX*U^T#e_cCkRL04xvS0VwQT>X?H%b(<)gOv0*Ll%U@|XgF*uFZ48ygbt+m z{?he%LIycBut2Dd65bD<-4mFABRU|0=SsoXKjb%iLw)A~Gr(%Bkp$5fM~LG9a5tu8 zx8HWRY~2bHELRjF^Vwd@MM}i9v(iGgEc2Z@VxXL4=NRpqA-#bzim$xTz6lRM0rYp#}Bs=jK2nJx;=Fi5I{@%g|!3!M3bhh&6 zn%IjfiI^2A)1=sDS;=H@K-p>wDF}$zDkZ`s=CtKQ_9TUY^P3mI*X~<+xP4zsnAL9_ z*nZAGk-oR7)b$C(hvMm^tPrVx6PA)M>E9wkV9MWF{o!?(wNj3&@rpE zO`}fV&vxv`5mDX2CNP%OBzYQxH%<+V$~&bQ7SHIdGOUf5H|vEhask`WTl;5EyW@Ku z#oYl=ylDQH!m~avo|Mjb{_=v1TNB3!zcFf*T9og)f4QYRy#cW2NB35rtja(+J(%{k z_*7tx%$1~e*lV;Q=E^hG1zGE||;Eg}p+!3w+e!`KXH>e8qUsJTv8j=R~3& z_W0Q#2Owq5meEsM0Q5L(`ovh`SV9~KTv&R%xCCN4ZWKRxAUS#P!_M@Mgq`~S%#};4(EJ_tiMhQGcM{rMOy3;M^qb5sWY2>S8 zK~A!rFK57+-W(PiT=xyz0&aQL^t!^Qw^tV@j=pX_z63+}K{&7cf@<+vjDqS{nal7? z%cw5vv3GBO9ZstBOGzbZdY!5mX%{yH)x+?HUw}hoHr5L?ne!(_3P?>)-d9ULp2Vugs6 zFp`~jIyK-AyZRpvfi5dM7H^*jg|##o&yI1xryj{oJ#ydqukmtFFq}jEt*iRmAV1ar z(*nHp)_d!q9^!BE*4^YSOcHVHqjm3Pc1w^5@ZHMD5&N_`RDL7kyjmIUhcY6^B>{v@ zu(pah1$xwj z=*Ac){S$g|3Io$OM8+>J@UR){(ad7_BGkn#41lYwg~{r8DazU#%^3^(<7lNZs;XE| z`GxSF&_1{`o;%4zv2MNa<169G#3!Y{A>litIZ}f32I1nkMP9zy`Z4r&+3#W;owDDl zN%=k><-!ShK3XQ!CH#oNJB|LGwy7B2|AG<++Qm<+j%l)54j9a)c@OVukt; z0y);diYjRY=`*jLT)}?m#`3cRx`0*$E1fjM@@3)ovxj)BS-%mTG~xHMvJX%?jR1W92`^$~R=oS4GN|MBW(k-sTbgM07a37BQZ@WR%K9$n#3?Y!MT0hf?qUVOF3YQ=c@86 zY_mwBG-Rd5ubea>8XpbD0;WPBC{`tj?&C(T#^AFtqckP!%GZ$E9pE_h5#r9Gb0^AN zMPVGeO`A`Szikcre>7cXR1M87R92I%_5OmD8ShKbB?xjhJ?NYnz)ApykAIkSsd54n^MsWd6EDE*bfy_?S!33iF z-^YTaPP&YUTb3B*rzhb)jL44QI0`HWAKMCBQ_o_;p&Q1x6t$HF72x_&azQe@iA!$i z1ltDyMR=@OW$6`XrtX;o)64oRrrSp+<(`G z2kdjpjP5r*vv>>QxDw(xEngE_S`vc3CIrE3V67juT068lE49LlEyG*MT-LvM-ky5) zKJeWoaDaSA;N2?EIH144> z4%Dv6)i)b&dL>#+G486|7`jWxOT>>E1FOZ~4Y0+=R`le0?rz}cikOy`Z{nvZfByvB zw5nmBQH3?{kSsy;V7W7boA(~Nw*>Soeo_URDvELewt=hS=4^R%RgUd1td@hSQZai% z7q3?v1 z30K`>Wb9v2r}fT>KwP0dCaV=g1FcBz1LX)z>5{=rmGC}o-o->V?9v|3sQJ2_6sMtt zRtKAB4W5ZLecaFZTy0+zP-UeRkDs3La!z=kiE`?kkG(Gb3BYpEXS)0366=_$hp!?Q z{4>j6Lx}fhlmCFQrnv-xmW1KYG($@x(RzS)MrAKMeayS)Vsqfs@({KG{x^wc2sc=W zC)l>8FNPUIxU3ULg}ZXr)}!y`ygNbjtOtxoFLT`(&^p6bOItmhjk$!)QoPW8js1PV zr{L8PC<- z3=CLGNl7--m?looWa@t4xtUC}HZZ)qNOOpFWaXxPUFuiET=k(mx5jg)y?TEX_^M>L zy>vIJRJ|cI_L?=&XDptZafq_I>AUR?Fy;Q)W$7?ZgFx7ApIRvN!58;scH2sZHvbwt z`ulh2@gE40fAVbahnrqb@ts3vMpS^*EZjj!*y;5H86Xc7mbpVDOcqvj_!eGB{Q#v- z4`5hCQ_x~);0hQpQWDbNoZx!G?lNMs@rsnbv|b@%(qe7iVzh~_&cv(MPu`hHa{Yr1 zBII%&=XZ`Y?z8B`l3X8q_E;Byyd>y!g2n#2YWBKm*3V0A)EJsDp>Y4y7m2p7ewk5s

{Jh#ov->uY4|&e$e=h1;Sc!{%>XB3 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 From 379e056a240d4fa6d962e4952e7a162e9c5398e2 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 09:30:36 +0200 Subject: [PATCH 13/60] feat: fix organization sync query handling --- web/src/hooks/OrgGate.tsx | 10 ++-- web/src/hooks/RealtimeManager.tsx | 26 +++++++---- .../organizations/useSwitchOrganization.ts | 46 +++++++++++++++---- .../api/hooks/app/unibox/useUnseenCount.ts | 13 +++++- 4 files changed, 72 insertions(+), 23 deletions(-) diff --git a/web/src/hooks/OrgGate.tsx b/web/src/hooks/OrgGate.tsx index 057cd3a8..6260095d 100644 --- a/web/src/hooks/OrgGate.tsx +++ b/web/src/hooks/OrgGate.tsx @@ -49,7 +49,11 @@ export function OrgGate() { // to fire on every render → repeated navigate() calls into // /select-org → "Too many calls to Location or History APIs" and a // setState loop from setOrganizations re-firing each time. - const { mutate: switchOrgMutate } = useSwitchOrganization(); + // "sync" mode: this is session reconciliation, NOT a workspace change, so + // it must INVALIDATE (refetch in place) rather than removeQueries — the + // latter orphans the dashboard's in-flight bootstrap queries and hangs the + // first paint after login. See useSwitchOrganization. + const { mutate: switchOrgMutate } = useSwitchOrganization({ mode: "sync" }); // Guard against the single-org auto-pick re-firing while the // mutation is in flight (effect re-runs on data changes). @@ -63,8 +67,8 @@ export function OrgGate() { const lastRedirectKey = useRef(null); // The org id we've already re-synced to the server session this // mount. Without this the stillMember branch would re-POST the - // switch on every effect run (e.g. when orgs.data settles), and - // useSwitchOrganization wipes the query cache on success → refetch + // switch on every effect run (e.g. when orgs.data settles), and the + // "sync" invalidate on success would re-fire each time → refetch // storm. One sync per (mount, org) is enough. const syncedOrgId = useRef(null); diff --git a/web/src/hooks/RealtimeManager.tsx b/web/src/hooks/RealtimeManager.tsx index cb408256..d5ea8d52 100644 --- a/web/src/hooks/RealtimeManager.tsx +++ b/web/src/hooks/RealtimeManager.tsx @@ -3,7 +3,7 @@ import { useSocket } from './context/socket' import { useAppStore } from '@/stores' import { useUserProfile } from './context/user' import { useRealtimeEvents } from './useRealtimeEvents' -import getUnseenCount from '@/lib/api/client/app/unibox/getUnseenCount' +import useUnseenCount from '@/lib/api/hooks/app/unibox/useUnseenCount' export function RealtimeManager({ children }: { children: React.ReactNode }) { const { isConnected, reconnectAttempt, joinChannel, leaveChannel } = useSocket() @@ -99,15 +99,23 @@ export function RealtimeManager({ children }: { children: React.ReactNode }) { } }, [isConnected, setConnectionQuality]) - // Seed the unread inbox count from the server once on load. The store value - // is otherwise session-only (realtime increments it but it starts at 0), so - // seeding makes the title + favicon badge reflect the real unread count - // immediately. Best-effort — a failure just leaves the count at 0. + // Seed the unread inbox count from the server. The store value is otherwise + // session-only (realtime increments it but it starts at 0), so seeding makes + // the title + favicon badge reflect the real unread count. + // + // This is a react-query (not a one-shot fetch) on purpose: /unibox/count is + // org-scoped, and on a fresh login the bootstrap fires before OrgGate has + // re-synced the server session — so the first read returns the wrong/empty + // count. OrgGate's "sync" switch invalidates org-scoped queries on success + // (root "unibox" included), which refetches this against the correct session + // and corrects the badge without a reload. Gated on a selected workspace so a + // multi-org login (which redirects to /select-org before any sync) doesn't + // fire a NULL-org read. Best-effort: a failure leaves the current count as-is. + const unseenQuery = useUnseenCount({ enabled: !!currentOrg }) useEffect(() => { - getUnseenCount() - .then((r) => { if (r && typeof r.count === 'number') setUnseenCount(r.count) }) - .catch(() => { /* endpoint unavailable — keep current count */ }) - }, [setUnseenCount]) + const c = unseenQuery.data?.count + if (typeof c === 'number') setUnseenCount(c) + }, [unseenQuery.data, setUnseenCount]) // Set up event-to-store routing useRealtimeEvents() diff --git a/web/src/lib/api/hooks/app/organizations/useSwitchOrganization.ts b/web/src/lib/api/hooks/app/organizations/useSwitchOrganization.ts index 1dbdc03e..5bc4844b 100644 --- a/web/src/lib/api/hooks/app/organizations/useSwitchOrganization.ts +++ b/web/src/lib/api/hooks/app/organizations/useSwitchOrganization.ts @@ -1,20 +1,48 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import switchOrganization from "@/lib/api/client/app/organizations/switchOrganization"; -// Switching org changes the entire data context — campaigns, contacts, -// emails, suppression, mailboxes, members, audits all become org-scoped -// data for a *different* org. Invalidating one or two queries leaves -// stale rows on screen for everything else. Cheaper-to-the-user fix: -// drop every cached query so each surface refetches on mount. -// -// `auth/me` is preserved because identity didn't change, just the -// active workspace. -export default function useSwitchOrganization() { +interface UseSwitchOrganizationOptions { + // How to reconcile the query cache after the server session switch lands: + // + // - "reset" (default): the user is switching to a *different* workspace + // from the UI (OrgSwitcher / select-org / new workspace). Campaigns, + // contacts, emails, suppression, mailboxes, members, audits all become + // a different org's data, so drop every cached query and let each + // surface refetch fresh on mount — no flash of the previous org's rows. + // + // - "sync": OrgGate is only reconciling the *server session* to the + // workspace the UI already shows (the DB session row is NULL right after + // a fresh login — see OrgGate). The org is NOT changing. removeQueries + // here is actively harmful: the dashboard's bootstrap queries (the + // emails list, the unread count, …) fire on mount and are still + // in-flight when this POST resolves, so removing them mid-fetch orphans + // their observers and the page hangs on a loading spinner until it is + // remounted (tab switch) or reloaded. Instead we INVALIDATE: in-flight + // fetches resolve normally (never orphaned), and any org-scoped query + // that fired before the session sync refetches against the now-correct + // session. "organizations" is left alone (the membership list didn't + // change) to avoid re-churning OrgGate's own effect. + // + // `auth/*` is preserved in both modes — identity didn't change, just the + // active workspace. + mode?: "reset" | "sync"; +} + +export default function useSwitchOrganization({ mode = "reset" }: UseSwitchOrganizationOptions = {}) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: string) => switchOrganization(id), onSuccess: () => { + if (mode === "sync") { + queryClient.invalidateQueries({ + predicate: (q) => { + const root = q.queryKey[0]; + return root !== "auth" && root !== "organizations"; + }, + }); + return; + } queryClient.removeQueries({ predicate: (q) => { const root = q.queryKey[0]; diff --git a/web/src/lib/api/hooks/app/unibox/useUnseenCount.ts b/web/src/lib/api/hooks/app/unibox/useUnseenCount.ts index 8e667546..015408c3 100644 --- a/web/src/lib/api/hooks/app/unibox/useUnseenCount.ts +++ b/web/src/lib/api/hooks/app/unibox/useUnseenCount.ts @@ -1,9 +1,18 @@ import { useQuery } from "@tanstack/react-query"; import getUnseenCount from "@/lib/api/client/app/unibox/getUnseenCount"; -export default function useUnseenCount() { +// Unread inbox count, used to seed the title + favicon badge (RealtimeManager). +// +// /unibox/count is org-scoped (resolved from the server session), so callers +// should gate on a selected workspace via `enabled` to avoid a NULL-org read +// before OrgGate has reconciled the session on a fresh login. The query key's +// root "unibox" is matched by OrgGate's post-login "sync" invalidate, so the +// count refetches against the correct session after login without a reload. +export default function useUnseenCount({ enabled = true }: { enabled?: boolean } = {}) { return useQuery({ queryKey: ["unibox", "unseen-count"], queryFn: () => getUnseenCount(), - }) + staleTime: 60_000, + enabled, + }); } From b2c885dda29a6bcd4892503f0740feb17a51c8de Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 11:49:49 +0200 Subject: [PATCH 14/60] feat: hide sheets automation integration --- internal/app/integration/actions.go | 12 ---- internal/app/integration/catalog.go | 17 ++--- internal/app/integration/dispatch.go | 11 ---- internal/app/integration/service.go | 98 +++++++++++++++++++++++++++- internal/models/integration.go | 1 - 5 files changed, 102 insertions(+), 37 deletions(-) diff --git a/internal/app/integration/actions.go b/internal/app/integration/actions.go index 448a96ea..e7e177a3 100644 --- a/internal/app/integration/actions.go +++ b/internal/app/integration/actions.go @@ -241,18 +241,6 @@ func pipedriveJSON(ctx context.Context, method, url, token string, body []byte, return nil } -// sheetsAppendRow appends an event row to a connected Google Sheet using the -// existing Sheets client wrapper. -func sheetsAppendRow(ctx context.Context, token, sheetID string, data map[string]any) error { - client := NewSheetsClient(token) - row := []string{ - time.Now().UTC().Format(time.RFC3339), - stringFromMap(data, "contact_email", "invitee_email", "email"), - stringFromMap(data, "subject", "event_name", "campaign_name"), - } - return client.AppendValues(ctx, sheetID, "A1", [][]string{row}) -} - func shortURL(u string) string { if i := len("https://api.hubapi.com"); len(u) > i { return u[i:] diff --git a/internal/app/integration/catalog.go b/internal/app/integration/catalog.go index a0a12481..6c5aa355 100644 --- a/internal/app/integration/catalog.go +++ b/internal/app/integration/catalog.go @@ -154,17 +154,10 @@ func Catalog() []models.IntegrationCatalogEntry { Highlights: []string{"We mint an inbound URL for you", "Booked meetings credit the originating campaign"}, }, - // Data -------------------------------------------------------------- - { - Provider: models.IntegrationGoogleSheets, - Name: "Google Sheets", - Tagline: "Pull leads from a sheet; push reply / bounce / booked rows back.", - Category: models.IntegrationCategoryData, - AuthMethod: string(models.IntegrationAuthOAuth), - DocsURL: "https://developers.google.com/sheets/api", - BetaFlag: true, - Highlights: []string{"One-click Google OAuth", "Append a row on reply / bounce"}, - Events: crmEvents, - }, + // NOTE: Google Sheets is intentionally NOT a catalog integration. The + // google_sheets OAuth connection still exists (it powers the on-demand + // Lead Sync feature under Contacts), but it is no longer surfaced as an + // integration tile and has no event-driven append-row automation. See + // internal/app/leadsync. } } diff --git a/internal/app/integration/dispatch.go b/internal/app/integration/dispatch.go index 583ecbba..745df5bb 100644 --- a/internal/app/integration/dispatch.go +++ b/internal/app/integration/dispatch.go @@ -124,17 +124,6 @@ func (s *service) execAction(ctx context.Context, target repository.DispatchTarg } return pipedriveUpsertPerson(ctx, token, data) - case models.IntegrationActionSheetsAppend: - token, terr := s.accessTokenFor(ctx, &target.Secrets) - if terr != nil { - return errReauthRequired - } - sheetID := configString(sub.Config, "sheet_id") - if sheetID == "" { - return errors.New("no sheet_id configured") - } - return sheetsAppendRow(ctx, token, sheetID, data) - default: return fmt.Errorf("unknown action: %s", sub.Action) } diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go index 76f93ef2..cbb08ad7 100644 --- a/internal/app/integration/service.go +++ b/internal/app/integration/service.go @@ -67,6 +67,22 @@ type Service interface { // MarkSynced records a successful/failed round-trip against a connection. MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error + // --- Google Sheets read helpers (used by the lead-sync feature) --------- + // These expose the existing google_sheets OAuth token + Sheets client to + // the leadsync package without leaking secret handling out of this service. + + // GoogleConnection returns the org's google_sheets OAuth connection, or nil + // if the org has not connected Google. Even though google_sheets is hidden + // from the integrations catalog, the underlying OAuth connection still lives + // in integration_connections — this is how lead-sync finds it. + GoogleConnection(ctx context.Context, orgID uuid.UUID) (*models.IntegrationConnection, error) + // SpreadsheetMeta returns the sheet's title + tabs using the connection's + // (refreshed) Google token. + SpreadsheetMeta(ctx context.Context, orgID, connID uuid.UUID, sheetID string) (*SheetMeta, error) + // SpreadsheetValues reads an A1 range from the sheet using the connection's + // (refreshed) Google token. + SpreadsheetValues(ctx context.Context, orgID, connID uuid.UUID, sheetID, a1Range string) ([][]string, error) + // Dispatch fans a platform event out to every matching event subscription, // executing each provider action. Best-effort: action failures are recorded // on the connection's health but never block the caller. @@ -116,7 +132,23 @@ func (s *service) Catalog() []models.IntegrationCatalogEntry { } func (s *service) ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) { - return s.repo.ListConnections(ctx, orgID) + conns, err := s.repo.ListConnections(ctx, orgID) + if err != nil { + return nil, err + } + // Google Sheets is no longer a catalog integration — its OAuth connection + // exists only to power the on-demand Lead Sync feature (see + // internal/app/leadsync). Hide it from the Integrations page so it doesn't + // render as an integration tile. GoogleConnection() still reaches it via the + // repository directly. + out := conns[:0] + for _, c := range conns { + if c.Provider == models.IntegrationGoogleSheets { + continue + } + out = append(out, c) + } + return out, nil } func (s *service) GetConnection(ctx context.Context, orgID, id uuid.UUID) (*models.IntegrationConnection, error) { @@ -395,6 +427,70 @@ func (s *service) MarkSynced(ctx context.Context, id uuid.UUID, status models.In return s.repo.MarkConnectionSynced(ctx, id, status, df, errMsg) } +// --- Google Sheets read helpers --------------------------------------------- + +// GoogleConnection returns the org's google_sheets OAuth connection (the most +// recent one), or nil when none exists. Lead-sync uses this connection's token +// to read sheets even though google_sheets is no longer surfaced as a catalog +// integration. +func (s *service) GoogleConnection(ctx context.Context, orgID uuid.UUID) (*models.IntegrationConnection, error) { + conns, err := s.repo.ListConnections(ctx, orgID) + if err != nil { + return nil, err + } + for i := range conns { + if conns[i].Provider == models.IntegrationGoogleSheets { + c := conns[i] + return &c, nil + } + } + return nil, nil +} + +// googleSheetsClient resolves the (refreshed) Google access token for a +// connection and returns a ready Sheets client. The connection must belong to +// orgID and be the google_sheets provider. +func (s *service) googleSheetsClient(ctx context.Context, orgID, connID uuid.UUID) (*SheetsClient, error) { + conn, err := s.repo.GetConnectionByID(ctx, orgID, connID) + if err != nil { + return nil, err + } + if conn == nil { + return nil, errors.New("connection not found") + } + if conn.Provider != models.IntegrationGoogleSheets { + return nil, errors.New("connection is not a google_sheets connection") + } + sec, err := s.repo.GetConnectionSecrets(ctx, connID) + if err != nil { + return nil, err + } + if sec == nil { + return nil, errors.New("connection secrets not found") + } + token, err := s.accessTokenFor(ctx, sec) + if err != nil { + return nil, err + } + return NewSheetsClient(token), nil +} + +func (s *service) SpreadsheetMeta(ctx context.Context, orgID, connID uuid.UUID, sheetID string) (*SheetMeta, error) { + client, err := s.googleSheetsClient(ctx, orgID, connID) + if err != nil { + return nil, err + } + return client.GetSpreadsheet(ctx, sheetID) +} + +func (s *service) SpreadsheetValues(ctx context.Context, orgID, connID uuid.UUID, sheetID, a1Range string) ([][]string, error) { + client, err := s.googleSheetsClient(ctx, orgID, connID) + if err != nil { + return nil, err + } + return client.ReadValues(ctx, sheetID, a1Range) +} + // --- encryption helpers ----------------------------------------------------- func (s *service) seal(ctx context.Context, userID uuid.UUID, plaintext string) (string, error) { diff --git a/internal/models/integration.go b/internal/models/integration.go index 30614a75..0f6d6e63 100644 --- a/internal/models/integration.go +++ b/internal/models/integration.go @@ -228,7 +228,6 @@ const ( IntegrationActionDiscordNotify IntegrationAction = "discord.notify" IntegrationActionHubSpotUpsert IntegrationAction = "hubspot.upsert_contact" IntegrationActionPipedriveUpsert IntegrationAction = "pipedrive.upsert_person" - IntegrationActionSheetsAppend IntegrationAction = "google_sheets.append_row" IntegrationActionGenericWebhookPing IntegrationAction = "webhook.ping" ) From d6f055da19324aca3164c284c3c106cccdc8518f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 11:49:55 +0200 Subject: [PATCH 15/60] feat: add lead sync backend --- cmd/backend/main.go | 12 + internal/api/handler/handler.go | 5 + internal/api/handler/lead_sync.go | 261 +++++++++++ internal/api/routes.go | 21 + internal/app/leadsync/service.go | 439 ++++++++++++++++++ .../000014_lead_sync_sources.down.sql | 3 + .../000014_lead_sync_sources.up.sql | 40 ++ internal/models/lead_sync.go | 104 +++++ internal/repository/pg_lead_sync.go | 199 ++++++++ 9 files changed, 1084 insertions(+) create mode 100644 internal/api/handler/lead_sync.go create mode 100644 internal/app/leadsync/service.go create mode 100644 internal/infrastructure/db/migrations/000014_lead_sync_sources.down.sql create mode 100644 internal/infrastructure/db/migrations/000014_lead_sync_sources.up.sql create mode 100644 internal/models/lead_sync.go create mode 100644 internal/repository/pg_lead_sync.go diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 4f8ae9b6..5520617a 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -40,6 +40,7 @@ import ( "github.com/warmbly/warmbly/internal/app/group" idempotencyapp "github.com/warmbly/warmbly/internal/app/idempotency" "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/app/leadsync" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/passkey" "github.com/warmbly/warmbly/internal/app/placement" @@ -184,6 +185,7 @@ func main() { var webhookServiceForHandler webhook.Service var integrationServiceForHandler integration.Service var contactRepoForHandler repository.ContactRepository + var leadSyncServiceForHandler leadsync.Service ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -766,6 +768,13 @@ func main() { rateLimitService = ratelimit.NewService(cache, rateLimitRepository) sequenceService = sequence.NewService(sequenceRepostory) contactService = contact.NewService(contactRepostory, subscriptionRepository, planRepository, streamingPublisher) + + // On-demand Google Sheets -> leads sync (backend-only / control plane). + // Reuses the integration service for the Google token + sheet reads and + // the contact service for the upsert. No worker / scheduler involvement. + leadSyncRepository := repository.NewLeadSyncRepository(primaryDB.Pool) + leadSyncServiceForHandler = leadsync.NewService(leadSyncRepository, integrationServiceForHandler, contactService) + apiKeyService = apikey.NewService(cache, apiKeyRepository) crmService = crm.NewService(crmRepository) socketService = socket.NewService(cache, tokenService) @@ -999,6 +1008,9 @@ func main() { IntegrationService: integrationServiceForHandler, ContactRepo: contactRepoForHandler, + // On-demand Google Sheets -> leads sync + LeadSyncService: leadSyncServiceForHandler, + WebsocketURI: websocketURI, // Object storage + direct repository handles for handlers diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index 2461d08d..6a35f820 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -19,6 +19,7 @@ import ( "github.com/warmbly/warmbly/internal/app/feature" "github.com/warmbly/warmbly/internal/app/group" "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/app/leadsync" "github.com/warmbly/warmbly/internal/app/organization" "github.com/warmbly/warmbly/internal/app/passkey" "github.com/warmbly/warmbly/internal/app/placement" @@ -134,6 +135,10 @@ type Handler struct { IntegrationService integration.Service ContactRepo repository.ContactRepository + // On-demand Google Sheets -> leads sync. Reuses the google_sheets OAuth + // connection's token to read sheets and the contact import path to upsert. + LeadSyncService leadsync.Service + // Public websocket URL used by frontend clients WebsocketURI string diff --git a/internal/api/handler/lead_sync.go b/internal/api/handler/lead_sync.go new file mode 100644 index 00000000..d8f98e9b --- /dev/null +++ b/internal/api/handler/lead_sync.go @@ -0,0 +1,261 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +// requireLeadSyncActor resolves the org + user for a lead-sync request the same +// way the contact and integration handlers do. +func (h *Handler) requireLeadSyncActor(c *gin.Context) (orgID, userID uuid.UUID, ok bool) { + orgID, ok = requireOrgID(c) + if !ok { + return uuid.Nil, uuid.Nil, false + } + uid, err := uuid.Parse(middleware.GetUserID(c)) + if err != nil { + errx.JSON(c, errx.New(errx.Unauthorized, "invalid user")) + return uuid.Nil, uuid.Nil, false + } + return orgID, uid, true +} + +// leadSyncConnectionResponse is the minimal connection shape the lead-sync UI +// needs to decide whether to show "Connect Google" or the sheet picker. +type leadSyncConnectionResponse struct { + Connected bool `json:"connected"` + Connection *leadSyncConnectionSummary `json:"connection"` +} + +type leadSyncConnectionSummary struct { + ID uuid.UUID `json:"id"` + ExternalAccountName string `json:"external_account_name"` + Status string `json:"status"` +} + +// GetLeadSyncGoogleConnection reports whether the org has a connected Google +// account usable for lead-sync (the hidden google_sheets OAuth connection). +func (h *Handler) GetLeadSyncGoogleConnection(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + conn, err := h.LeadSyncService.Connection(c.Request.Context(), orgID) + if err != nil { + errx.JSON(c, errx.New(errx.Internal, "failed to resolve google connection")) + return + } + if conn == nil { + c.JSON(http.StatusOK, leadSyncConnectionResponse{Connected: false, Connection: nil}) + return + } + c.JSON(http.StatusOK, leadSyncConnectionResponse{ + Connected: true, + Connection: &leadSyncConnectionSummary{ + ID: conn.ID, + ExternalAccountName: conn.ExternalAccountName, + Status: string(conn.Status), + }, + }) +} + +type leadSyncSpreadsheetPayload struct { + ConnectionID string `json:"connection_id"` + SheetID string `json:"sheet_id"` +} + +// GetLeadSyncSpreadsheet returns a sheet's title + tabs so the UI can render a +// tab picker before mapping columns. +func (h *Handler) GetLeadSyncSpreadsheet(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + var p leadSyncSpreadsheetPayload + if err := c.ShouldBindJSON(&p); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + connID, err := uuid.Parse(strings.TrimSpace(p.ConnectionID)) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid connection_id")) + return + } + if strings.TrimSpace(p.SheetID) == "" { + errx.JSON(c, errx.New(errx.BadRequest, "sheet_id is required")) + return + } + meta, merr := h.LeadSyncService.SpreadsheetMeta(c.Request.Context(), orgID, connID, strings.TrimSpace(p.SheetID)) + if merr != nil { + errx.JSON(c, errx.New(errx.BadRequest, "failed to read spreadsheet: "+merr.Error())) + return + } + c.JSON(http.StatusOK, meta) +} + +type leadSyncPreviewPayload struct { + ConnectionID string `json:"connection_id"` + SheetID string `json:"sheet_id"` + TabTitle string `json:"tab_title"` +} + +// PreviewLeadSync returns an ImportPreview-shaped payload (columns, sample_rows, +// total_rows, has_header, suggested_mapping) so the frontend reuses its +// contact-import column mapper verbatim. +func (h *Handler) PreviewLeadSync(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + var p leadSyncPreviewPayload + if err := c.ShouldBindJSON(&p); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + connID, err := uuid.Parse(strings.TrimSpace(p.ConnectionID)) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid connection_id")) + return + } + preview, xerr := h.LeadSyncService.Preview(c.Request.Context(), orgID, connID, strings.TrimSpace(p.SheetID), strings.TrimSpace(p.TabTitle)) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, preview) +} + +// ListLeadSyncSources lists this org's saved sources, optionally filtered to a +// campaign via ?campaign_id=. +func (h *Handler) ListLeadSyncSources(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + var campaignID *uuid.UUID + if raw := strings.TrimSpace(c.Query("campaign_id")); raw != "" { + id, err := uuid.Parse(raw) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid campaign_id")) + return + } + campaignID = &id + } + sources, err := h.LeadSyncService.List(c.Request.Context(), orgID, campaignID) + if err != nil { + errx.JSON(c, errx.New(errx.Internal, "failed to list sync sources")) + return + } + if sources == nil { + sources = []models.LeadSyncSource{} + } + c.JSON(http.StatusOK, gin.H{"data": sources}) +} + +// CreateLeadSyncSource saves a new on-demand sync source. +func (h *Handler) CreateLeadSyncSource(c *gin.Context) { + orgID, userID, ok := h.requireLeadSyncActor(c) + if !ok { + return + } + var in models.CreateLeadSyncSource + if err := c.ShouldBindJSON(&in); err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + src, xerr := h.LeadSyncService.Create(c.Request.Context(), orgID, userID, &in) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusCreated, src) +} + +// GetLeadSyncSource returns one saved source. +func (h *Handler) GetLeadSyncSource(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + src, xerr := h.LeadSyncService.Get(c.Request.Context(), orgID, id) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, src) +} + +// UpdateLeadSyncSource edits a saved source. +func (h *Handler) UpdateLeadSyncSource(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + var in models.UpdateLeadSyncSource + if berr := c.ShouldBindJSON(&in); berr != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid payload")) + return + } + src, xerr := h.LeadSyncService.Update(c.Request.Context(), orgID, id, &in) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, src) +} + +// DeleteLeadSyncSource removes a saved source. +func (h *Handler) DeleteLeadSyncSource(c *gin.Context) { + orgID, ok := requireOrgID(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + if xerr := h.LeadSyncService.Delete(c.Request.Context(), orgID, id); xerr != nil { + errx.JSON(c, xerr) + return + } + c.Status(http.StatusNoContent) +} + +// SyncLeadSyncSourceNow runs the source on-demand: read the sheet -> upsert +// contacts via ImportCommit. Naturally idempotent (email upsert), so retries +// are safe without an Idempotency-Key. +func (h *Handler) SyncLeadSyncSourceNow(c *gin.Context) { + orgID, userID, ok := h.requireLeadSyncActor(c) + if !ok { + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "invalid id")) + return + } + result, xerr := h.LeadSyncService.SyncNow(c.Request.Context(), userID, orgID, id) + if xerr != nil { + errx.JSON(c, xerr) + return + } + c.JSON(http.StatusOK, result) +} diff --git a/internal/api/routes.go b/internal/api/routes.go index c378d1b8..3d097887 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -450,6 +450,27 @@ func Run( integrations.GET("/bookings", h.ListMeetingBookings) } + // On-demand Google Sheets -> leads sync (org-scoped). A saved "sync + // source" the user re-runs with "Sync now"; new rows create contacts and + // existing rows (matched by email) update. Gated under the contacts + // write permissions because it ultimately upserts contacts. The Google + // account itself is connected via the existing /integrations/oauth flow + // with provider "google_sheets". + leadSync := protected.Group("/lead-sync") + leadSync.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageContacts, models.APIPermWriteContacts), m.RateLimitMiddleware(models.RateLimitWrite)) + { + leadSync.GET("/google/connection", h.GetLeadSyncGoogleConnection) + leadSync.POST("/google/spreadsheet", h.GetLeadSyncSpreadsheet) + leadSync.POST("/google/preview", h.PreviewLeadSync) + + leadSync.GET("/sources", h.ListLeadSyncSources) + leadSync.POST("/sources", h.CreateLeadSyncSource) + leadSync.GET("/sources/:id", h.GetLeadSyncSource) + leadSync.PATCH("/sources/:id", h.UpdateLeadSyncSource) + leadSync.DELETE("/sources/:id", h.DeleteLeadSyncSource) + leadSync.POST("/sources/:id/sync", h.SyncLeadSyncSourceNow) + } + // Warmup routing rules (org-scoped). Lets customers define // preferences for premium-pool partner selection — e.g. send // to Gmail recipients only from Google-classified senders. diff --git a/internal/app/leadsync/service.go b/internal/app/leadsync/service.go new file mode 100644 index 00000000..db2d1f96 --- /dev/null +++ b/internal/app/leadsync/service.go @@ -0,0 +1,439 @@ +// Package leadsync implements the on-demand Google Sheets -> leads sync. +// +// A "lead sync source" is a saved binding between a Google Sheet and Warmbly's +// contact importer that the user re-runs with a "Sync now" button. There is no +// background scheduler and no worker involvement: this is pure control-plane +// work. SyncNow reads the sheet, encodes the rows as CSV in memory, and hands +// them to the existing contact ImportCommit path so contact creation/dedupe is +// never reimplemented here. +package leadsync + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/app/contact" + "github.com/warmbly/warmbly/internal/app/integration" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +// previewRows is how many sheet rows the preview reads (header + sample). It +// mirrors the contacts import preview cap so the dashboard's column mapper sees +// the same amount of data either way. +const previewRows = models.MaxContactImportPreviewRows + 1 + +// fullColumnSpan is the widest A1 column range we read. Google Sheets caps at +// far more, but contact imports are narrow; A:Z is plenty for typical lead +// sheets and keeps the read cheap. +const fullColumnSpan = "A:Z" + +// Service is the on-demand lead-sync control-plane surface. +type Service interface { + // Connection returns the org's google_sheets OAuth connection (or nil). + Connection(ctx context.Context, orgID uuid.UUID) (*models.IntegrationConnection, error) + // SpreadsheetMeta returns the sheet title + tabs for the connect step. + SpreadsheetMeta(ctx context.Context, orgID, connID uuid.UUID, sheetID string) (*integration.SheetMeta, error) + // Preview reads the top rows of a tab and returns an ImportPreview-shaped + // payload so the frontend reuses its contact-import column mapper verbatim. + Preview(ctx context.Context, orgID, connID uuid.UUID, sheetID, tabTitle string) (*models.ContactImportPreview, *errx.Error) + + // Source CRUD. + List(ctx context.Context, orgID uuid.UUID, campaignID *uuid.UUID) ([]models.LeadSyncSource, error) + Get(ctx context.Context, orgID, id uuid.UUID) (*models.LeadSyncSource, *errx.Error) + Create(ctx context.Context, orgID, userID uuid.UUID, in *models.CreateLeadSyncSource) (*models.LeadSyncSource, *errx.Error) + Update(ctx context.Context, orgID, id uuid.UUID, in *models.UpdateLeadSyncSource) (*models.LeadSyncSource, *errx.Error) + Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error + + // SyncNow reads the source's sheet and upserts contacts via ImportCommit. + // triggeringUserID scopes the contact upsert (contacts are per-user). + SyncNow(ctx context.Context, triggeringUserID, orgID, sourceID uuid.UUID) (*models.LeadSyncResult, *errx.Error) +} + +type service struct { + repo repository.LeadSyncRepository + integration integration.Service + contacts contact.ContactService +} + +// NewService wires the lead-sync service to the integration service (Google +// token + sheet reads), the contact service (ImportCommit), and the lead-sync +// repository. +func NewService(repo repository.LeadSyncRepository, integrationSvc integration.Service, contactSvc contact.ContactService) Service { + return &service{repo: repo, integration: integrationSvc, contacts: contactSvc} +} + +func (s *service) Connection(ctx context.Context, orgID uuid.UUID) (*models.IntegrationConnection, error) { + return s.integration.GoogleConnection(ctx, orgID) +} + +func (s *service) SpreadsheetMeta(ctx context.Context, orgID, connID uuid.UUID, sheetID string) (*integration.SheetMeta, error) { + return s.integration.SpreadsheetMeta(ctx, orgID, connID, sheetID) +} + +func (s *service) Preview(ctx context.Context, orgID, connID uuid.UUID, sheetID, tabTitle string) (*models.ContactImportPreview, *errx.Error) { + sheetID = strings.TrimSpace(sheetID) + if sheetID == "" { + return nil, errx.New(errx.BadRequest, "sheet_id is required") + } + a1 := buildA1Range(tabTitle, previewRows) + values, err := s.integration.SpreadsheetValues(ctx, orgID, connID, sheetID, a1) + if err != nil { + return nil, errx.New(errx.BadRequest, "failed to read sheet: "+err.Error()) + } + if len(values) == 0 { + return nil, errx.New(errx.BadRequest, "the selected sheet/tab is empty") + } + + // The first row is treated as the header (sheets almost always have one); + // the user can flip has_header in the UI, exactly like a CSV import. + headers := normalizeHeaders(values[0]) + width := len(headers) + + sample := make([][]string, 0, len(values)-1) + for i := 1; i < len(values); i++ { + sample = append(sample, padRow(values[i], width)) + } + + return &models.ContactImportPreview{ + Filename: "google-sheets-sync.csv", + Format: "csv", + TotalRows: len(values) - 1, + Columns: headers, + HasHeader: true, + SampleRows: sample, + SuggestedMapping: suggestMapping(headers), + }, nil +} + +func (s *service) List(ctx context.Context, orgID uuid.UUID, campaignID *uuid.UUID) ([]models.LeadSyncSource, error) { + return s.repo.List(ctx, orgID, campaignID) +} + +func (s *service) Get(ctx context.Context, orgID, id uuid.UUID) (*models.LeadSyncSource, *errx.Error) { + src, err := s.repo.Get(ctx, orgID, id) + if err != nil { + return nil, errx.New(errx.Internal, "failed to load sync source") + } + if src == nil { + return nil, errx.New(errx.NotFound, "sync source not found") + } + return src, nil +} + +func (s *service) Create(ctx context.Context, orgID, userID uuid.UUID, in *models.CreateLeadSyncSource) (*models.LeadSyncSource, *errx.Error) { + if in == nil { + return nil, errx.New(errx.BadRequest, "missing payload") + } + if in.ConnectionID == uuid.Nil { + return nil, errx.New(errx.BadRequest, "connection_id is required") + } + if strings.TrimSpace(in.SheetID) == "" { + return nil, errx.New(errx.BadRequest, "sheet_id is required") + } + if len(in.ColumnMapping) == 0 { + return nil, errx.New(errx.BadRequest, "column_mapping is required") + } + if xerr := validateDedup(in.Dedup); xerr != nil { + return nil, xerr + } + if xerr := validateMappingHasEmail(in.ColumnMapping); xerr != nil { + return nil, xerr + } + + // The connection must be this org's google_sheets OAuth connection. + conn, err := s.integration.GoogleConnection(ctx, orgID) + if err != nil { + return nil, errx.New(errx.Internal, "failed to resolve google connection") + } + if conn == nil || conn.ID != in.ConnectionID { + return nil, errx.New(errx.BadRequest, "connection_id is not this organization's connected Google account") + } + + subscribed := true + if in.SubscribedDefault != nil { + subscribed = *in.SubscribedDefault + } + cats := in.CategoryIDs + if cats == nil { + cats = []string{} + } + + src := &models.LeadSyncSource{ + OrganizationID: orgID, + CreatedByUserID: userID, + Provider: string(models.IntegrationGoogleSheets), + ConnectionID: in.ConnectionID, + SheetID: strings.TrimSpace(in.SheetID), + SheetTitle: strings.TrimSpace(in.SheetTitle), + TabTitle: strings.TrimSpace(in.TabTitle), + A1Range: buildA1Range(in.TabTitle, 0), + HasHeader: in.HasHeader, + ColumnMapping: in.ColumnMapping, + Dedup: in.Dedup, + TargetCampaignID: in.TargetCampaignID, + CategoryIDs: cats, + SubscribedDefault: subscribed, + Label: strings.TrimSpace(in.Label), + Status: models.LeadSyncStatusIdle, + } + if err := s.repo.Create(ctx, src); err != nil { + return nil, errx.New(errx.Internal, "failed to create sync source") + } + return src, nil +} + +func (s *service) Update(ctx context.Context, orgID, id uuid.UUID, in *models.UpdateLeadSyncSource) (*models.LeadSyncSource, *errx.Error) { + if in == nil { + return nil, errx.New(errx.BadRequest, "missing payload") + } + src, xerr := s.Get(ctx, orgID, id) + if xerr != nil { + return nil, xerr + } + + if in.SheetID != nil { + v := strings.TrimSpace(*in.SheetID) + if v == "" { + return nil, errx.New(errx.BadRequest, "sheet_id cannot be empty") + } + src.SheetID = v + } + if in.SheetTitle != nil { + src.SheetTitle = strings.TrimSpace(*in.SheetTitle) + } + if in.TabTitle != nil { + src.TabTitle = strings.TrimSpace(*in.TabTitle) + src.A1Range = buildA1Range(src.TabTitle, 0) + } + if in.HasHeader != nil { + src.HasHeader = *in.HasHeader + } + if in.ColumnMapping != nil { + if len(*in.ColumnMapping) == 0 { + return nil, errx.New(errx.BadRequest, "column_mapping cannot be empty") + } + if xerr := validateMappingHasEmail(*in.ColumnMapping); xerr != nil { + return nil, xerr + } + src.ColumnMapping = *in.ColumnMapping + } + if in.Dedup != nil { + if xerr := validateDedup(*in.Dedup); xerr != nil { + return nil, xerr + } + src.Dedup = *in.Dedup + } + if in.ClearCampaign { + src.TargetCampaignID = nil + } else if in.TargetCampaignID != nil { + src.TargetCampaignID = in.TargetCampaignID + } + if in.CategoryIDs != nil { + src.CategoryIDs = *in.CategoryIDs + } + if in.SubscribedDefault != nil { + src.SubscribedDefault = *in.SubscribedDefault + } + if in.Label != nil { + src.Label = strings.TrimSpace(*in.Label) + } + + if err := s.repo.Update(ctx, src); err != nil { + return nil, errx.New(errx.Internal, "failed to update sync source") + } + return src, nil +} + +func (s *service) Delete(ctx context.Context, orgID, id uuid.UUID) *errx.Error { + if _, xerr := s.Get(ctx, orgID, id); xerr != nil { + return xerr + } + if err := s.repo.Delete(ctx, orgID, id); err != nil { + return errx.New(errx.Internal, "failed to delete sync source") + } + return nil +} + +// SyncNow reads the source's sheet and upserts contacts through the existing +// contact ImportCommit path. +// +// Idempotency: SyncNow is naturally idempotent. ImportCommit dedupes/upserts by +// (user, email), so re-running the same source against the same sheet converges +// to the same contact set — no Idempotency-Key header is required for safe +// retries (re-runs at worst re-update unchanged rows). +func (s *service) SyncNow(ctx context.Context, triggeringUserID, orgID, sourceID uuid.UUID) (*models.LeadSyncResult, *errx.Error) { + src, xerr := s.Get(ctx, orgID, sourceID) + if xerr != nil { + return nil, xerr + } + + // Read the full tab. We deliberately re-read the whole range each run so + // new rows (appended at the bottom) are picked up. + a1 := buildA1Range(src.TabTitle, 0) + values, err := s.integration.SpreadsheetValues(ctx, orgID, src.ConnectionID, src.SheetID, a1) + if err != nil { + s.recordResult(ctx, src.ID, models.LeadSyncStatusError, nil, nil, err.Error()) + return nil, errx.New(errx.BadRequest, "failed to read sheet: "+err.Error()) + } + + csvBytes, cerr := encodeCSV(values) + if cerr != nil { + s.recordResult(ctx, src.ID, models.LeadSyncStatusError, nil, nil, cerr.Error()) + return nil, errx.New(errx.Internal, "failed to encode sheet rows") + } + + var campaignIDs []string + if src.TargetCampaignID != nil { + campaignIDs = []string{src.TargetCampaignID.String()} + } + subscribed := src.SubscribedDefault + opts := &models.ContactImportCommit{ + Mapping: src.ColumnMapping, + Dedup: src.Dedup, + HasHeader: src.HasHeader, + CategoryIDs: src.CategoryIDs, + CampaignIDs: campaignIDs, + SubscribedDefault: &subscribed, + } + + result, ierr := s.contacts.ImportCommit(ctx, triggeringUserID.String(), bytes.NewReader(csvBytes), "google-sheets-sync.csv", opts) + if ierr != nil { + s.recordResult(ctx, src.ID, models.LeadSyncStatusError, nil, nil, ierr.Message) + return nil, ierr + } + + now := time.Now().UTC() + resJSON, _ := json.Marshal(result) + s.recordResult(ctx, src.ID, models.LeadSyncStatusIdle, &now, resJSON, "") + + return &models.LeadSyncResult{SourceID: src.ID, Result: result}, nil +} + +// recordResult is a best-effort persistence of a sync outcome. Failures to +// persist the bookkeeping never fail the sync the user already paid for. +func (s *service) recordResult(ctx context.Context, id uuid.UUID, status models.LeadSyncStatus, syncedAt *time.Time, resJSON []byte, errMsg string) { + _ = s.repo.SetResult(ctx, id, status, syncedAt, resJSON, errMsg) +} + +// --- helpers ---------------------------------------------------------------- + +// buildA1Range builds an A1 range for a tab. With maxRows == 0 it returns the +// full-column span for the tab (read everything); with maxRows > 0 it bounds +// the read to the first maxRows rows (preview). +// +// Tab titles are quoted with single quotes and any embedded single quote is +// doubled, per the Sheets A1 grammar. +func buildA1Range(tabTitle string, maxRows int) string { + span := fullColumnSpan + if maxRows > 0 { + span = "A1:Z" + strconv.Itoa(maxRows) + } + tab := strings.TrimSpace(tabTitle) + if tab == "" { + return span + } + quoted := "'" + strings.ReplaceAll(tab, "'", "''") + "'" + return quoted + "!" + span +} + +// encodeCSV writes the sheet's 2-D values to an in-memory CSV buffer. The +// contact importer re-parses this with the stdlib CSV reader, so the round-trip +// stays inside one well-understood format. +func encodeCSV(values [][]string) ([]byte, error) { + var buf bytes.Buffer + w := csv.NewWriter(&buf) + for _, row := range values { + if err := w.Write(row); err != nil { + return nil, err + } + } + w.Flush() + if err := w.Error(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func validateDedup(d models.ContactImportDedupStrategy) *errx.Error { + switch d { + case models.ContactImportDedupSkip, models.ContactImportDedupUpdate, models.ContactImportDedupCreateDuplicate: + return nil + } + return errx.New(errx.BadRequest, "unknown dedup strategy: "+string(d)) +} + +// validateMappingHasEmail enforces that at least one column maps to the email +// target — without it ImportCommit would reject every row as "missing email". +func validateMappingHasEmail(mapping []models.ContactImportColumnMapping) *errx.Error { + for _, m := range mapping { + if m.Target == models.ContactImportTargetEmail { + return nil + } + } + return errx.New(errx.BadRequest, "column_mapping must map one column to 'email'") +} + +// normalizeHeaders trims the header row and synthesises a name for any blank +// cell so every column is mappable, mirroring the CSV importer's behaviour. +func normalizeHeaders(first []string) []string { + out := make([]string, len(first)) + for i, c := range first { + out[i] = strings.TrimSpace(c) + if out[i] == "" { + out[i] = "Column " + strconv.Itoa(i+1) + } + } + return out +} + +// padRow returns a copy of row padded (or truncated) to n columns. Sheets +// returns ragged rows when trailing cells are empty. +func padRow(row []string, n int) []string { + if len(row) >= n { + return row[:n] + } + out := make([]string, n) + copy(out, row) + return out +} + +// suggestMapping applies the same fuzzy header heuristics the contact importer +// uses so the dashboard's preview arrives with sensible defaults. +func suggestMapping(headers []string) []models.ContactImportColumnMapping { + out := make([]models.ContactImportColumnMapping, len(headers)) + for i, h := range headers { + out[i] = guessTarget(i, h) + } + return out +} + +func guessTarget(idx int, header string) models.ContactImportColumnMapping { + key := strings.ToLower(header) + key = strings.NewReplacer(" ", "", "_", "", "-", "", ".", "").Replace(key) + switch key { + case "email", "emailaddress", "mail", "primaryemail": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetEmail} + case "firstname", "givenname", "fname", "first": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetFirstName} + case "lastname", "familyname", "surname", "lname", "last": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetLastName} + case "company", "companyname", "organization", "organisation", "employer", "account", "accountname": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetCompany} + case "phone", "phonenumber", "mobile", "cell": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetPhone} + case "subscribed", "optin", "optedin", "subscribe": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetSubscribed} + case "categories", "category", "tags", "tag", "labels", "label": + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetCategories} + } + return models.ContactImportColumnMapping{Index: idx, Target: models.ContactImportTargetIgnore} +} diff --git a/internal/infrastructure/db/migrations/000014_lead_sync_sources.down.sql b/internal/infrastructure/db/migrations/000014_lead_sync_sources.down.sql new file mode 100644 index 00000000..60fb2605 --- /dev/null +++ b/internal/infrastructure/db/migrations/000014_lead_sync_sources.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_lead_sync_sources_org_campaign; +DROP INDEX IF EXISTS idx_lead_sync_sources_org; +DROP TABLE IF EXISTS lead_sync_sources; diff --git a/internal/infrastructure/db/migrations/000014_lead_sync_sources.up.sql b/internal/infrastructure/db/migrations/000014_lead_sync_sources.up.sql new file mode 100644 index 00000000..849cb50e --- /dev/null +++ b/internal/infrastructure/db/migrations/000014_lead_sync_sources.up.sql @@ -0,0 +1,40 @@ +-- On-demand Google Sheets -> leads sync sources. +-- +-- A "lead sync source" is a saved, re-runnable binding between a Google Sheet +-- (reached through an existing google_sheets OAuth connection) and Warmbly's +-- contact importer. It is ON-DEMAND only: the user presses "Sync now" and the +-- control plane reads the sheet and upserts contacts by (user, email). There is +-- no background scheduler — nothing here drives the worker. +-- +-- column_mapping / category_ids are JSONB so they reuse the exact contact +-- import column-mapping shape ([]ContactImportColumnMapping) and category id +-- list the /contacts/import/commit path already understands. + +CREATE TABLE lead_sync_sources ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + created_by_user_id uuid NOT NULL, + provider text NOT NULL DEFAULT 'google_sheets', + connection_id uuid NOT NULL, -- integration_connections row used for Google OAuth + sheet_id text NOT NULL, + sheet_title text, + tab_title text, + a1_range text, + has_header boolean NOT NULL DEFAULT true, + column_mapping jsonb NOT NULL DEFAULT '[]', + dedup text NOT NULL DEFAULT 'update' + CHECK (dedup IN ('skip', 'update', 'create_duplicate')), + target_campaign_id uuid, + category_ids jsonb NOT NULL DEFAULT '[]', + subscribed_default boolean NOT NULL DEFAULT true, + label text, + status text NOT NULL DEFAULT 'idle', + last_synced_at timestamptz, + last_result jsonb, + last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_lead_sync_sources_org ON lead_sync_sources (organization_id); +CREATE INDEX idx_lead_sync_sources_org_campaign ON lead_sync_sources (organization_id, target_campaign_id); diff --git a/internal/models/lead_sync.go b/internal/models/lead_sync.go new file mode 100644 index 00000000..ce92c7d2 --- /dev/null +++ b/internal/models/lead_sync.go @@ -0,0 +1,104 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// LeadSyncStatus is the lifecycle state of a saved lead-sync source. It is +// not a queue state — syncs are on-demand — only a record of how the last +// "Sync now" went. +type LeadSyncStatus string + +const ( + // LeadSyncStatusIdle — never synced, or the last sync succeeded. + LeadSyncStatusIdle LeadSyncStatus = "idle" + // LeadSyncStatusSyncing — a sync is in flight (set for the duration of + // the synchronous SyncNow call). + LeadSyncStatusSyncing LeadSyncStatus = "syncing" + // LeadSyncStatusError — the last sync failed; LastError carries why. + LeadSyncStatusError LeadSyncStatus = "error" +) + +// LeadSyncSource is a saved, re-runnable binding between a Google Sheet (read +// through an existing google_sheets OAuth connection) and Warmbly's contact +// importer. New rows create contacts; rows matching an existing contact by +// email are updated. A source optionally enrols new/updated leads into a +// campaign and/or tags them with categories. +// +// Secrets never live here — the sheet is read using the linked connection's +// envelope-encrypted Google token, resolved at sync time. +type LeadSyncSource struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + CreatedByUserID uuid.UUID `json:"created_by_user_id"` + Provider string `json:"provider"` + ConnectionID uuid.UUID `json:"connection_id"` + SheetID string `json:"sheet_id"` + SheetTitle string `json:"sheet_title,omitempty"` + TabTitle string `json:"tab_title,omitempty"` + A1Range string `json:"a1_range,omitempty"` + HasHeader bool `json:"has_header"` + + // ColumnMapping reuses the exact contact-import mapping shape so the sheet + // rows flow through the same /contacts/import/commit code path. + ColumnMapping []ContactImportColumnMapping `json:"column_mapping"` + Dedup ContactImportDedupStrategy `json:"dedup"` + + // TargetCampaignID, when set, enrols every new/updated lead into that + // campaign on each sync. + TargetCampaignID *uuid.UUID `json:"target_campaign_id,omitempty"` + CategoryIDs []string `json:"category_ids"` + SubscribedDefault bool `json:"subscribed_default"` + + Label string `json:"label,omitempty"` + Status LeadSyncStatus `json:"status"` + + LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` + LastResult *ContactImportResult `json:"last_result,omitempty"` + LastError string `json:"last_error,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreateLeadSyncSource is the input for creating a saved source. The connection +// must be the org's google_sheets OAuth connection. +type CreateLeadSyncSource struct { + ConnectionID uuid.UUID `json:"connection_id"` + SheetID string `json:"sheet_id"` + SheetTitle string `json:"sheet_title"` + TabTitle string `json:"tab_title"` + HasHeader bool `json:"has_header"` + ColumnMapping []ContactImportColumnMapping `json:"column_mapping"` + Dedup ContactImportDedupStrategy `json:"dedup"` + TargetCampaignID *uuid.UUID `json:"target_campaign_id"` + CategoryIDs []string `json:"category_ids"` + SubscribedDefault *bool `json:"subscribed_default"` + Label string `json:"label"` +} + +// UpdateLeadSyncSource is the input for editing a saved source. All fields are +// optional pointers so a PATCH can touch a single setting; nil leaves the +// stored value untouched. +type UpdateLeadSyncSource struct { + SheetID *string `json:"sheet_id"` + SheetTitle *string `json:"sheet_title"` + TabTitle *string `json:"tab_title"` + HasHeader *bool `json:"has_header"` + ColumnMapping *[]ContactImportColumnMapping `json:"column_mapping"` + Dedup *ContactImportDedupStrategy `json:"dedup"` + TargetCampaignID *uuid.UUID `json:"target_campaign_id"` + ClearCampaign bool `json:"clear_campaign"` + CategoryIDs *[]string `json:"category_ids"` + SubscribedDefault *bool `json:"subscribed_default"` + Label *string `json:"label"` +} + +// LeadSyncResult is what "Sync now" returns: the underlying contact-import +// counts plus the source id so the dashboard can attribute the run. +type LeadSyncResult struct { + SourceID uuid.UUID `json:"source_id"` + Result *ContactImportResult `json:"result"` +} diff --git a/internal/repository/pg_lead_sync.go b/internal/repository/pg_lead_sync.go new file mode 100644 index 00000000..69db1343 --- /dev/null +++ b/internal/repository/pg_lead_sync.go @@ -0,0 +1,199 @@ +package repository + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/warmbly/warmbly/internal/models" +) + +// LeadSyncRepository owns persistence for on-demand Google Sheets -> leads +// sync sources. Every query is organization-scoped; a source is only ever +// reachable by the org that created it. +type LeadSyncRepository interface { + Create(ctx context.Context, src *models.LeadSyncSource) error + List(ctx context.Context, orgID uuid.UUID, campaignID *uuid.UUID) ([]models.LeadSyncSource, error) + Get(ctx context.Context, orgID, id uuid.UUID) (*models.LeadSyncSource, error) + Update(ctx context.Context, src *models.LeadSyncSource) error + Delete(ctx context.Context, orgID, id uuid.UUID) error + // SetResult records the outcome of a "Sync now" run. resultJSON is the + // marshalled *models.ContactImportResult (nil-safe). + SetResult(ctx context.Context, id uuid.UUID, status models.LeadSyncStatus, lastSyncedAt *time.Time, resultJSON []byte, lastErr string) error +} + +type leadSyncRepository struct { + db *pgxpool.Pool +} + +func NewLeadSyncRepository(db *pgxpool.Pool) LeadSyncRepository { + return &leadSyncRepository{db: db} +} + +const leadSyncCols = ` + id, organization_id, created_by_user_id, provider, connection_id, + sheet_id, COALESCE(sheet_title, ''), COALESCE(tab_title, ''), COALESCE(a1_range, ''), + has_header, column_mapping, dedup, target_campaign_id, category_ids, + subscribed_default, COALESCE(label, ''), status, last_synced_at, last_result, + COALESCE(last_error, ''), created_at, updated_at` + +func (r *leadSyncRepository) Create(ctx context.Context, src *models.LeadSyncSource) error { + if src.ID == uuid.Nil { + src.ID = uuid.New() + } + now := time.Now().UTC() + src.CreatedAt = now + src.UpdatedAt = now + if src.Provider == "" { + src.Provider = string(models.IntegrationGoogleSheets) + } + if src.Status == "" { + src.Status = models.LeadSyncStatusIdle + } + + mapping := marshalJSONDefault(src.ColumnMapping, "[]") + cats := marshalJSONDefault(src.CategoryIDs, "[]") + + _, err := r.db.Exec(ctx, ` + INSERT INTO lead_sync_sources ( + id, organization_id, created_by_user_id, provider, connection_id, + sheet_id, sheet_title, tab_title, a1_range, has_header, + column_mapping, dedup, target_campaign_id, category_ids, + subscribed_default, label, status, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, $10, + $11, $12, $13, $14, + $15, $16, $17, $18, $18 + )`, + src.ID, src.OrganizationID, src.CreatedByUserID, src.Provider, src.ConnectionID, + src.SheetID, nullIfEmptyStr(src.SheetTitle), nullIfEmptyStr(src.TabTitle), nullIfEmptyStr(src.A1Range), src.HasHeader, + mapping, string(src.Dedup), src.TargetCampaignID, cats, + src.SubscribedDefault, nullIfEmptyStr(src.Label), string(src.Status), now, + ) + return err +} + +func (r *leadSyncRepository) List(ctx context.Context, orgID uuid.UUID, campaignID *uuid.UUID) ([]models.LeadSyncSource, error) { + rows, err := r.db.Query(ctx, `SELECT `+leadSyncCols+` + FROM lead_sync_sources + WHERE organization_id = $1 AND ($2::uuid IS NULL OR target_campaign_id = $2) + ORDER BY created_at DESC`, orgID, campaignID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []models.LeadSyncSource{} + for rows.Next() { + var s models.LeadSyncSource + if err := scanLeadSyncInto(rows, &s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +func (r *leadSyncRepository) Get(ctx context.Context, orgID, id uuid.UUID) (*models.LeadSyncSource, error) { + row := r.db.QueryRow(ctx, `SELECT `+leadSyncCols+` + FROM lead_sync_sources WHERE organization_id = $1 AND id = $2`, orgID, id) + var s models.LeadSyncSource + if err := scanLeadSyncInto(row, &s); err != nil { + if isNoRows(err) { + return nil, nil + } + return nil, err + } + return &s, nil +} + +func (r *leadSyncRepository) Update(ctx context.Context, src *models.LeadSyncSource) error { + now := time.Now().UTC() + src.UpdatedAt = now + mapping := marshalJSONDefault(src.ColumnMapping, "[]") + cats := marshalJSONDefault(src.CategoryIDs, "[]") + + _, err := r.db.Exec(ctx, ` + UPDATE lead_sync_sources SET + sheet_id = $1, sheet_title = $2, tab_title = $3, a1_range = $4, + has_header = $5, column_mapping = $6, dedup = $7, target_campaign_id = $8, + category_ids = $9, subscribed_default = $10, label = $11, updated_at = $12 + WHERE organization_id = $13 AND id = $14`, + src.SheetID, nullIfEmptyStr(src.SheetTitle), nullIfEmptyStr(src.TabTitle), nullIfEmptyStr(src.A1Range), + src.HasHeader, mapping, string(src.Dedup), src.TargetCampaignID, + cats, src.SubscribedDefault, nullIfEmptyStr(src.Label), now, + src.OrganizationID, src.ID, + ) + return err +} + +func (r *leadSyncRepository) Delete(ctx context.Context, orgID, id uuid.UUID) error { + _, err := r.db.Exec(ctx, + `DELETE FROM lead_sync_sources WHERE organization_id = $1 AND id = $2`, orgID, id) + return err +} + +func (r *leadSyncRepository) SetResult(ctx context.Context, id uuid.UUID, status models.LeadSyncStatus, lastSyncedAt *time.Time, resultJSON []byte, lastErr string) error { + now := time.Now().UTC() + _, err := r.db.Exec(ctx, ` + UPDATE lead_sync_sources SET + status = $1, last_synced_at = $2, last_result = $3, last_error = $4, updated_at = $5 + WHERE id = $6`, + string(status), lastSyncedAt, nullIfEmptyBytes(resultJSON), nullIfEmptyStr(lastErr), now, id) + return err +} + +// marshalJSONDefault marshals v to JSON bytes, falling back to a literal +// default (e.g. "[]") if v is nil or marshalling fails — JSONB columns are +// NOT NULL with a '[]' default, so we never write a SQL NULL there. +func marshalJSONDefault(v any, def string) []byte { + if v == nil { + return []byte(def) + } + b, err := json.Marshal(v) + if err != nil || len(b) == 0 || string(b) == "null" { + return []byte(def) + } + return b +} + +func scanLeadSyncInto(row scanner, s *models.LeadSyncSource) error { + var ( + mapping []byte + cats []byte + lastResult []byte + status string + dedup string + ) + if err := row.Scan( + &s.ID, &s.OrganizationID, &s.CreatedByUserID, &s.Provider, &s.ConnectionID, + &s.SheetID, &s.SheetTitle, &s.TabTitle, &s.A1Range, + &s.HasHeader, &mapping, &dedup, &s.TargetCampaignID, &cats, + &s.SubscribedDefault, &s.Label, &status, &s.LastSyncedAt, &lastResult, + &s.LastError, &s.CreatedAt, &s.UpdatedAt, + ); err != nil { + return err + } + s.Status = models.LeadSyncStatus(status) + s.Dedup = models.ContactImportDedupStrategy(dedup) + + s.ColumnMapping = []models.ContactImportColumnMapping{} + if len(mapping) > 0 { + _ = json.Unmarshal(mapping, &s.ColumnMapping) + } + s.CategoryIDs = []string{} + if len(cats) > 0 { + _ = json.Unmarshal(cats, &s.CategoryIDs) + } + if len(lastResult) > 0 { + var res models.ContactImportResult + if err := json.Unmarshal(lastResult, &res); err == nil { + s.LastResult = &res + } + } + return nil +} From e598b3bd5cf3d469b93b2af447c9bbe405693547 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 11:50:00 +0200 Subject: [PATCH 16/60] feat: remove sheets from integrations UI --- .../integrations/_components/ConnectDrawer.tsx | 10 +--------- .../_components/ConnectionDetail.tsx | 16 ++++++---------- .../integrations/_components/ProviderGlyph.tsx | 1 - .../api/models/app/integrations/Integration.ts | 6 +----- 4 files changed, 8 insertions(+), 25 deletions(-) diff --git a/web/src/app/app/integrations/_components/ConnectDrawer.tsx b/web/src/app/app/integrations/_components/ConnectDrawer.tsx index 0bf395c4..25e93d70 100644 --- a/web/src/app/app/integrations/_components/ConnectDrawer.tsx +++ b/web/src/app/app/integrations/_components/ConnectDrawer.tsx @@ -1,6 +1,6 @@ // Multi-step connect drawer. The connect path depends on the provider's // auth method: -// - oauth (HubSpot, Slack, Google Sheets, Pipedrive, Salesforce): one-click +// - oauth (HubSpot, Slack, Pipedrive, Salesforce): one-click // "Connect with X" → provider popup → encrypted tokens stored server-side. // No credentials are ever pasted. Providers without server credentials // render as "coming soon". @@ -102,14 +102,6 @@ const FIELDS_BY_PROVIDER: Record = { helper: "Edit Channel → Integrations → Webhooks → New Webhook → Copy URL.", }, ], - google_sheets: [ - { - key: "sheet_id", - label: "Sheet ID", - placeholder: "1AbC…XyZ", - helper: "The long ID in the sheet URL between /d/ and /edit. Optional — set per automation later.", - }, - ], }; export default function ConnectDrawer({ diff --git a/web/src/app/app/integrations/_components/ConnectionDetail.tsx b/web/src/app/app/integrations/_components/ConnectionDetail.tsx index f8d22a0b..5a7cf5df 100644 --- a/web/src/app/app/integrations/_components/ConnectionDetail.tsx +++ b/web/src/app/app/integrations/_components/ConnectionDetail.tsx @@ -287,7 +287,7 @@ function AutomationRow({ sub, onDelete }: { sub: IntegrationEventSubscription; o const intents = Array.isArray(cfg.intents) ? (cfg.intents as string[]) : []; const minConf = typeof cfg.min_confidence === "number" ? cfg.min_confidence : undefined; const dest = - (cfg.channel as string) || (cfg.sheet_id as string) || (cfg.url as string) || (cfg.webhook_url as string) || ""; + (cfg.channel as string) || (cfg.url as string) || (cfg.webhook_url as string) || ""; const tmpl = (cfg.message_template as string) || ""; const filters: string[] = []; @@ -348,8 +348,7 @@ function AddAutomation({ const [template, setTemplate] = React.useState(""); const needsChannel = provider === "slack"; - const needsSheet = provider === "google_sheets"; - const needsURL = provider !== "slack" && provider !== "google_sheets" && provider !== "discord" && + const needsURL = provider !== "slack" && provider !== "discord" && provider !== "hubspot" && provider !== "pipedrive"; const isReplyTrigger = eventType === REPLY_EVENT; const destRequired = needsChannel || needsURL; @@ -361,7 +360,6 @@ function AddAutomation({ function buildConfig(): Record { const cfg: Record = {}; if (needsChannel && dest.trim()) cfg.channel = dest.trim(); - if (needsSheet && dest.trim()) cfg.sheet_id = dest.trim(); if (needsURL && dest.trim()) cfg.url = dest.trim(); if (isReplyTrigger && intents.length) cfg.intents = intents; if (isReplyTrigger && minConf > 0) cfg.min_confidence = minConf; @@ -369,8 +367,8 @@ function AddAutomation({ return cfg; } - const destLabel = needsChannel ? "Channel" : needsSheet ? "Sheet ID (optional)" : "Destination URL"; - const destPlaceholder = needsChannel ? "#sales" : needsSheet ? "1AbC…XyZ" : "https://…"; + const destLabel = needsChannel ? "Channel" : "Destination URL"; + const destPlaceholder = needsChannel ? "#sales" : "https://…"; const canSubmit = !busy && !(destRequired && !dest.trim()); return ( @@ -429,14 +427,14 @@ function AddAutomation({

)} - {(needsChannel || needsSheet || needsURL) && ( + {(needsChannel || needsURL) && (
)} @@ -503,8 +501,6 @@ function actionForProvider(provider: string): string { return "hubspot.upsert_contact"; case "pipedrive": return "pipedrive.upsert_person"; - case "google_sheets": - return "google_sheets.append_row"; default: return "webhook.ping"; } diff --git a/web/src/app/app/integrations/_components/ProviderGlyph.tsx b/web/src/app/app/integrations/_components/ProviderGlyph.tsx index 5157ad6b..8e78b4b4 100644 --- a/web/src/app/app/integrations/_components/ProviderGlyph.tsx +++ b/web/src/app/app/integrations/_components/ProviderGlyph.tsx @@ -16,7 +16,6 @@ const BRAND: Record = { discord: { bg: "bg-indigo-50", ring: "ring-indigo-200", text: "text-indigo-600" }, calendly: { bg: "bg-sky-50", ring: "ring-sky-200", text: "text-sky-600" }, cal_com: { bg: "bg-slate-100", ring: "ring-slate-300", text: "text-slate-800" }, - google_sheets: { bg: "bg-emerald-50", ring: "ring-emerald-200", text: "text-emerald-600" }, }; export default function ProviderGlyph({ diff --git a/web/src/lib/api/models/app/integrations/Integration.ts b/web/src/lib/api/models/app/integrations/Integration.ts index 9402a784..7811bc22 100644 --- a/web/src/lib/api/models/app/integrations/Integration.ts +++ b/web/src/lib/api/models/app/integrations/Integration.ts @@ -13,8 +13,7 @@ export type IntegrationProvider = | "slack" | "discord" | "calendly" - | "cal_com" - | "google_sheets"; + | "cal_com"; export type IntegrationAuthMethod = "oauth" | "api_key" | "webhook"; @@ -83,7 +82,6 @@ export type IntegrationAction = | "discord.notify" | "hubspot.upsert_contact" | "pipedrive.upsert_person" - | "google_sheets.append_row" | "webhook.ping"; export interface IntegrationEventSubscription { @@ -183,8 +181,6 @@ export function defaultActionForProvider(provider: IntegrationProvider): Integra return "hubspot.upsert_contact"; case "pipedrive": return "pipedrive.upsert_person"; - case "google_sheets": - return "google_sheets.append_row"; default: return "webhook.ping"; } From 6a4c370152a2b752304a5958843211ea5d18cc22 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 11:50:04 +0200 Subject: [PATCH 17/60] feat: share contact import mapping utilities --- .../components/app/contacts/ImportWizard.tsx | 43 ++----------- .../components/app/contacts/importShared.ts | 63 +++++++++++++++++++ 2 files changed, 67 insertions(+), 39 deletions(-) create mode 100644 web/src/components/app/contacts/importShared.ts diff --git a/web/src/components/app/contacts/ImportWizard.tsx b/web/src/components/app/contacts/ImportWizard.tsx index 9d692054..61fdb856 100644 --- a/web/src/components/app/contacts/ImportWizard.tsx +++ b/web/src/components/app/contacts/ImportWizard.tsx @@ -53,50 +53,15 @@ import { import { Label, TextInput } from "@/components/ui/field"; import CategoryPicker from "./CategoryPicker"; import { downloadBlob } from "@/lib/api/client/app/contacts/exportContacts"; +import { DEDUP_OPTIONS, STANDARD_TARGETS, describeError } from "./importShared"; interface Props { open: boolean; onClose: () => void; } -const STANDARD_TARGETS: { id: string; label: string }[] = [ - { id: "ignore", label: "Ignore" }, - { id: "email", label: "Email" }, - { id: "first_name", label: "First name" }, - { id: "last_name", label: "Last name" }, - { id: "company", label: "Company" }, - { id: "phone", label: "Phone" }, - { id: "subscribed", label: "Subscribed" }, - { id: "categories", label: "Categories" }, -]; - -const DEDUP_OPTIONS: { id: ImportDedupStrategy; label: string; hint: string }[] = [ - { id: "skip", label: "Skip existing", hint: "If a contact with this email exists, leave it alone." }, - { id: "update", label: "Update existing", hint: "Merge new values onto the existing contact." }, - { id: "create_duplicate", label: "Create duplicates", hint: "Force a new contact. Falls back to update if blocked by uniqueness." }, -]; - type Step = "upload" | "map" | "options" | "result"; -// Extract a human-readable message from whatever the API client throws. -// Client.ts rethrows AppError (a plain object), not an Error instance — -// so `err instanceof Error` silently fails and you lose the real reason. -function describeError(err: unknown, fallback: string): string { - if (err && typeof err === "object") { - const e = err as { message?: unknown; error?: unknown; status?: unknown }; - const msg = typeof e.message === "string" ? e.message.trim() : ""; - const title = typeof e.error === "string" ? e.error.trim() : ""; - const status = typeof e.status === "number" ? e.status : undefined; - if (msg && title && msg !== title) { - return status ? `${status} ${title}: ${msg}` : `${title}: ${msg}`; - } - if (msg) return status ? `${status}: ${msg}` : msg; - if (title) return status ? `${status} ${title}` : title; - } - if (err instanceof Error && err.message) return err.message; - return fallback; -} - export default function ImportWizard({ open, onClose }: Props) { const [step, setStep] = React.useState("upload"); const [file, setFile] = React.useState(null); @@ -456,7 +421,7 @@ function UploadStep({ // ----- Map step -------------------------------------------------- -function MapStep({ +export function MapStep({ preview, mapping, setMapping, @@ -538,7 +503,7 @@ function MapStep({ ); } -function TargetPicker({ +export function TargetPicker({ value, onChange, }: { @@ -683,7 +648,7 @@ function OptionsStep({ // ----- Result step ---------------------------------------------- -function ResultStep({ result, filename }: { result: ImportResult; filename: string }) { +export function ResultStep({ result, filename }: { result: ImportResult; filename: string }) { function downloadErrors() { if (!result.errors || result.errors.length === 0) return; const rows = [["line", "email", "reason"]]; diff --git a/web/src/components/app/contacts/importShared.ts b/web/src/components/app/contacts/importShared.ts new file mode 100644 index 00000000..c48a8341 --- /dev/null +++ b/web/src/components/app/contacts/importShared.ts @@ -0,0 +1,63 @@ +// Shared, non-component pieces of the contact-import flow. Kept in their own +// module (not exported from ImportWizard.tsx) so both the CSV ImportWizard and +// the Google-Sheet SheetSyncWizard can reuse the exact same column targets, +// dedup options, and error formatter without tripping react-refresh's +// "only export components" rule. + +import toast from "react-hot-toast"; +import type { + ImportDedupStrategy, + ImportResult, +} from "@/lib/api/client/app/contacts/importContacts"; + +export const STANDARD_TARGETS: { id: string; label: string }[] = [ + { id: "ignore", label: "Ignore" }, + { id: "email", label: "Email" }, + { id: "first_name", label: "First name" }, + { id: "last_name", label: "Last name" }, + { id: "company", label: "Company" }, + { id: "phone", label: "Phone" }, + { id: "subscribed", label: "Subscribed" }, + { id: "categories", label: "Categories" }, +]; + +export const DEDUP_OPTIONS: { id: ImportDedupStrategy; label: string; hint: string }[] = [ + { id: "skip", label: "Skip existing", hint: "If a contact with this email exists, leave it alone." }, + { id: "update", label: "Update existing", hint: "Merge new values onto the existing contact." }, + { + id: "create_duplicate", + label: "Create duplicates", + hint: "Force a new contact. Falls back to update if blocked by uniqueness.", + }, +]; + +// Extract a human-readable message from whatever the API client throws. +// Client.ts rethrows AppError (a plain object), not an Error instance — +// so `err instanceof Error` silently fails and you lose the real reason. +export function describeError(err: unknown, fallback: string): string { + if (err && typeof err === "object") { + const e = err as { message?: unknown; error?: unknown; status?: unknown }; + const msg = typeof e.message === "string" ? e.message.trim() : ""; + const title = typeof e.error === "string" ? e.error.trim() : ""; + const status = typeof e.status === "number" ? e.status : undefined; + if (msg && title && msg !== title) { + return status ? `${status} ${title}: ${msg}` : `${title}: ${msg}`; + } + if (msg) return status ? `${status}: ${msg}` : msg; + if (title) return status ? `${status} ${title}` : title; + } + if (err instanceof Error && err.message) return err.message; + return fallback; +} + +// announceResult toasts a contact-import / sheet-sync result with the standard +// imported/updated/skipped summary (or a warning when rows failed). +export function announceResult(res: ImportResult) { + if (res.failed === 0) { + toast.success( + `Imported ${res.imported} · updated ${res.updated} · skipped ${res.skipped}`, + ); + } else { + toast(`Synced with ${res.failed} errors`, { icon: "⚠️" }); + } +} From 7fe63a98eb9a06706c53e19c47851bbef8d6859c Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 11:50:09 +0200 Subject: [PATCH 18/60] feat: add lead sync frontend clients --- .../api/client/app/leadsync/createSource.ts | 19 +++ .../api/client/app/leadsync/deleteSource.ts | 10 ++ .../app/leadsync/getGoogleConnection.ts | 12 ++ .../lib/api/client/app/leadsync/getSource.ts | 11 ++ .../api/client/app/leadsync/getSpreadsheet.ts | 16 +++ .../api/client/app/leadsync/listSources.ts | 14 +++ .../api/client/app/leadsync/previewSheet.ts | 18 +++ .../lib/api/client/app/leadsync/syncSource.ts | 13 ++ .../api/client/app/leadsync/updateSource.ts | 19 +++ .../app/leadsync/useCreateLeadSyncSource.ts | 13 ++ .../app/leadsync/useDeleteLeadSyncSource.ts | 13 ++ .../hooks/app/leadsync/useGoogleConnection.ts | 12 ++ .../hooks/app/leadsync/useLeadSyncSources.ts | 12 ++ .../api/hooks/app/leadsync/useSheetMeta.ts | 13 ++ .../app/leadsync/useSyncLeadSyncSource.ts | 15 +++ .../app/leadsync/useUpdateLeadSyncSource.ts | 15 +++ .../lib/api/models/app/leadsync/LeadSync.ts | 116 ++++++++++++++++++ 17 files changed, 341 insertions(+) create mode 100644 web/src/lib/api/client/app/leadsync/createSource.ts create mode 100644 web/src/lib/api/client/app/leadsync/deleteSource.ts create mode 100644 web/src/lib/api/client/app/leadsync/getGoogleConnection.ts create mode 100644 web/src/lib/api/client/app/leadsync/getSource.ts create mode 100644 web/src/lib/api/client/app/leadsync/getSpreadsheet.ts create mode 100644 web/src/lib/api/client/app/leadsync/listSources.ts create mode 100644 web/src/lib/api/client/app/leadsync/previewSheet.ts create mode 100644 web/src/lib/api/client/app/leadsync/syncSource.ts create mode 100644 web/src/lib/api/client/app/leadsync/updateSource.ts create mode 100644 web/src/lib/api/hooks/app/leadsync/useCreateLeadSyncSource.ts create mode 100644 web/src/lib/api/hooks/app/leadsync/useDeleteLeadSyncSource.ts create mode 100644 web/src/lib/api/hooks/app/leadsync/useGoogleConnection.ts create mode 100644 web/src/lib/api/hooks/app/leadsync/useLeadSyncSources.ts create mode 100644 web/src/lib/api/hooks/app/leadsync/useSheetMeta.ts create mode 100644 web/src/lib/api/hooks/app/leadsync/useSyncLeadSyncSource.ts create mode 100644 web/src/lib/api/hooks/app/leadsync/useUpdateLeadSyncSource.ts create mode 100644 web/src/lib/api/models/app/leadsync/LeadSync.ts diff --git a/web/src/lib/api/client/app/leadsync/createSource.ts b/web/src/lib/api/client/app/leadsync/createSource.ts new file mode 100644 index 00000000..9e1b274f --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/createSource.ts @@ -0,0 +1,19 @@ +import type { + CreateLeadSyncSource, + LeadSyncSource, +} from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Saves a new on-demand source. The backend validates the connection is the +// org's google_sheets connection, dedup is valid, and column_mapping maps a +// column to 'email'. +export default async function createSource( + input: CreateLeadSyncSource, +): Promise { + return await Request({ + method: "POST", + url: "/lead-sync/sources", + data: input, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/deleteSource.ts b/web/src/lib/api/client/app/leadsync/deleteSource.ts new file mode 100644 index 00000000..d4e57683 --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/deleteSource.ts @@ -0,0 +1,10 @@ +import Request from "../../Request"; + +// Deletes a saved source (org-scoped). Backend returns 204 No Content. +export default async function deleteSource(id: string): Promise { + await Request({ + method: "DELETE", + url: `/lead-sync/sources/${id}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/getGoogleConnection.ts b/web/src/lib/api/client/app/leadsync/getGoogleConnection.ts new file mode 100644 index 00000000..609af68d --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/getGoogleConnection.ts @@ -0,0 +1,12 @@ +import type { LeadSyncConnection } from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Reports whether the org has a connected (hidden) google_sheets OAuth +// connection usable for lead-sync. Drives "Connect Google" vs the sheet picker. +export default async function getGoogleConnection(): Promise { + return await Request({ + method: "GET", + url: "/lead-sync/google/connection", + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/getSource.ts b/web/src/lib/api/client/app/leadsync/getSource.ts new file mode 100644 index 00000000..3006ffb4 --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/getSource.ts @@ -0,0 +1,11 @@ +import type { LeadSyncSource } from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Returns one saved source (org-scoped). +export default async function getSource(id: string): Promise { + return await Request({ + method: "GET", + url: `/lead-sync/sources/${id}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/getSpreadsheet.ts b/web/src/lib/api/client/app/leadsync/getSpreadsheet.ts new file mode 100644 index 00000000..b2d3fdab --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/getSpreadsheet.ts @@ -0,0 +1,16 @@ +import type { SheetMeta } from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Returns a spreadsheet's title + tabs (read via the connection's refreshed +// Google token) so the UI can render a tab picker. +export default async function getSpreadsheet(input: { + connection_id: string; + sheet_id: string; +}): Promise { + return await Request({ + method: "POST", + url: "/lead-sync/google/spreadsheet", + data: input, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/listSources.ts b/web/src/lib/api/client/app/leadsync/listSources.ts new file mode 100644 index 00000000..58ce00f6 --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/listSources.ts @@ -0,0 +1,14 @@ +import type { LeadSyncSource } from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Lists this org's saved sync sources, optionally filtered to one campaign +// (powers both the global Contacts > Sync sources area and the per-campaign +// "Connect a Google Sheet" list). +export default async function listSources(campaignId?: string): Promise<{ data: LeadSyncSource[] }> { + return await Request<{ data: LeadSyncSource[] }>({ + method: "GET", + url: "/lead-sync/sources", + params: campaignId ? { campaign_id: campaignId } : undefined, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/previewSheet.ts b/web/src/lib/api/client/app/leadsync/previewSheet.ts new file mode 100644 index 00000000..3e11ff92 --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/previewSheet.ts @@ -0,0 +1,18 @@ +import type { ImportPreview } from "@/lib/api/client/app/contacts/importContacts"; +import Request from "../../Request"; + +// Reads the first ~21 rows of a tab and returns the exact contacts +// ImportPreview JSON shape, so the frontend reuses its contact-import column +// mapper verbatim. +export default async function previewSheet(input: { + connection_id: string; + sheet_id: string; + tab_title: string; +}): Promise { + return await Request({ + method: "POST", + url: "/lead-sync/google/preview", + data: input, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/syncSource.ts b/web/src/lib/api/client/app/leadsync/syncSource.ts new file mode 100644 index 00000000..9788571c --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/syncSource.ts @@ -0,0 +1,13 @@ +import type { LeadSyncResult } from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// "Sync now": reads the full sheet tab, encodes rows as CSV in memory, and runs +// the existing contact ImportCommit (upsert by email, attach categories + +// target campaign). Naturally idempotent (email upsert), so no Idempotency-Key. +export default async function syncSource(id: string): Promise { + return await Request({ + method: "POST", + url: `/lead-sync/sources/${id}/sync`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/leadsync/updateSource.ts b/web/src/lib/api/client/app/leadsync/updateSource.ts new file mode 100644 index 00000000..3e1e42e9 --- /dev/null +++ b/web/src/lib/api/client/app/leadsync/updateSource.ts @@ -0,0 +1,19 @@ +import type { + LeadSyncSource, + UpdateLeadSyncSource, +} from "@/lib/api/models/app/leadsync/LeadSync"; +import Request from "../../Request"; + +// Edits a saved source; omitted fields are left unchanged. clear_campaign:true +// detaches the target campaign. +export default async function updateSource( + id: string, + input: UpdateLeadSyncSource, +): Promise { + return await Request({ + method: "PATCH", + url: `/lead-sync/sources/${id}`, + data: input, + authorization: true, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useCreateLeadSyncSource.ts b/web/src/lib/api/hooks/app/leadsync/useCreateLeadSyncSource.ts new file mode 100644 index 00000000..2be73c8f --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useCreateLeadSyncSource.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import createSource from "@/lib/api/client/app/leadsync/createSource"; + +// Saves a new sync source and refreshes every source list. +export default function useCreateLeadSyncSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: createSource, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["lead-sync", "sources"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useDeleteLeadSyncSource.ts b/web/src/lib/api/hooks/app/leadsync/useDeleteLeadSyncSource.ts new file mode 100644 index 00000000..a181e982 --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useDeleteLeadSyncSource.ts @@ -0,0 +1,13 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import deleteSource from "@/lib/api/client/app/leadsync/deleteSource"; + +// Deletes a saved source and refreshes every source list. +export default function useDeleteLeadSyncSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: deleteSource, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["lead-sync", "sources"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useGoogleConnection.ts b/web/src/lib/api/hooks/app/leadsync/useGoogleConnection.ts new file mode 100644 index 00000000..ea969a6a --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useGoogleConnection.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import getGoogleConnection from "@/lib/api/client/app/leadsync/getGoogleConnection"; + +// Reports whether the org has a connected google_sheets OAuth connection +// usable for lead-sync. Drives the "Connect Google Sheets" vs sheet-picker UI. +export default function useGoogleConnection() { + return useQuery({ + queryKey: ["lead-sync", "google", "connection"], + queryFn: getGoogleConnection, + staleTime: 10_000, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useLeadSyncSources.ts b/web/src/lib/api/hooks/app/leadsync/useLeadSyncSources.ts new file mode 100644 index 00000000..9f003713 --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useLeadSyncSources.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import listSources from "@/lib/api/client/app/leadsync/listSources"; + +// Lists saved sync sources, optionally filtered to one campaign. Used by both +// the global Contacts > Sync sources area and the per-campaign list. +export default function useLeadSyncSources(campaignId?: string) { + return useQuery({ + queryKey: ["lead-sync", "sources", campaignId ?? null], + queryFn: () => listSources(campaignId), + staleTime: 10_000, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useSheetMeta.ts b/web/src/lib/api/hooks/app/leadsync/useSheetMeta.ts new file mode 100644 index 00000000..682956d8 --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useSheetMeta.ts @@ -0,0 +1,13 @@ +import { useMutation } from "@tanstack/react-query"; +import getSpreadsheet from "@/lib/api/client/app/leadsync/getSpreadsheet"; +import previewSheet from "@/lib/api/client/app/leadsync/previewSheet"; + +// Fetching spreadsheet metadata and previewing a tab are user-triggered +// actions inside the wizard (not background queries), so they're mutations. +export function useGetSpreadsheet() { + return useMutation({ mutationFn: getSpreadsheet }); +} + +export function usePreviewSheet() { + return useMutation({ mutationFn: previewSheet }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useSyncLeadSyncSource.ts b/web/src/lib/api/hooks/app/leadsync/useSyncLeadSyncSource.ts new file mode 100644 index 00000000..6b663ad0 --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useSyncLeadSyncSource.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import syncSource from "@/lib/api/client/app/leadsync/syncSource"; + +// "Sync now". On success, refreshes the source list (so last_synced_at / +// last_result / status update) and contacts (new/updated rows landed). +export default function useSyncLeadSyncSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: syncSource, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["lead-sync", "sources"] }); + qc.invalidateQueries({ queryKey: ["contacts"] }); + }, + }); +} diff --git a/web/src/lib/api/hooks/app/leadsync/useUpdateLeadSyncSource.ts b/web/src/lib/api/hooks/app/leadsync/useUpdateLeadSyncSource.ts new file mode 100644 index 00000000..7ac3a673 --- /dev/null +++ b/web/src/lib/api/hooks/app/leadsync/useUpdateLeadSyncSource.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import updateSource from "@/lib/api/client/app/leadsync/updateSource"; +import type { UpdateLeadSyncSource } from "@/lib/api/models/app/leadsync/LeadSync"; + +// Edits a saved source and refreshes every source list. +export default function useUpdateLeadSyncSource() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: { id: string; body: UpdateLeadSyncSource }) => + updateSource(input.id, input.body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["lead-sync", "sources"] }); + }, + }); +} diff --git a/web/src/lib/api/models/app/leadsync/LeadSync.ts b/web/src/lib/api/models/app/leadsync/LeadSync.ts new file mode 100644 index 00000000..a7372248 --- /dev/null +++ b/web/src/lib/api/models/app/leadsync/LeadSync.ts @@ -0,0 +1,116 @@ +// Mirror of the backend's models/lead_sync.go shapes. Field names match the +// JSON contract exactly. Lead-sync is an on-demand Google Sheet → contacts +// importer: a saved "sync source" the user re-runs via a "Sync now" button. +// +// We deliberately REUSE the contact-import column-mapping + result types from +// the import wizard rather than redeclaring them, so the same TargetPicker / +// MapStep mapper and result rendering work verbatim against /google/preview +// and /sources/:id/sync responses. + +import type { + ImportColumnMapping, + ImportDedupStrategy, + ImportPreview, + ImportResult, +} from "@/lib/api/client/app/contacts/importContacts"; + +// Re-export the reused contact-import types under lead-sync names so callers +// can import everything lead-sync from one module. +export type { + ImportColumnMapping, + ImportDedupStrategy, + ImportPreview, + ImportResult, +}; + +// LeadSyncStatus mirrors models.LeadSyncStatus. The synchronous SyncNow only +// ever lands on idle/error; "syncing" exists for completeness/future async use. +export type LeadSyncStatus = "idle" | "syncing" | "error"; + +// A saved on-demand Google-Sheet → contacts source. +export interface LeadSyncSource { + id: string; + organization_id: string; + created_by_user_id: string; + provider: string; + connection_id: string; + sheet_id: string; + sheet_title?: string; + tab_title?: string; + a1_range?: string; + has_header: boolean; + column_mapping: ImportColumnMapping[]; + dedup: ImportDedupStrategy; + target_campaign_id?: string; + category_ids: string[]; + subscribed_default: boolean; + label?: string; + status: LeadSyncStatus; + last_synced_at?: string; + last_result?: ImportResult; + last_error?: string; + created_at: string; + updated_at: string; +} + +// Result of POST /sources/:id/sync. +export interface LeadSyncResult { + source_id: string; + result: ImportResult; +} + +// Reports whether the org has a connected (hidden) google_sheets OAuth +// connection usable for lead-sync. +export interface LeadSyncConnection { + connected: boolean; + connection: { + id: string; + external_account_name: string; + status: string; + } | null; +} + +// One tab of a spreadsheet, from /google/spreadsheet. +export interface SheetTab { + title: string; + index: number; +} + +// Spreadsheet metadata returned by /google/spreadsheet — drives the tab picker. +export interface SheetMeta { + sheet_id: string; + title: string; + tabs: SheetTab[]; +} + +// Request body for creating a new source. +export interface CreateLeadSyncSource { + connection_id: string; + sheet_id: string; + sheet_title: string; + tab_title: string; + has_header: boolean; + column_mapping: ImportColumnMapping[]; + dedup: ImportDedupStrategy; + target_campaign_id?: string; + category_ids: string[]; + subscribed_default?: boolean; + label: string; +} + +// Request body for PATCH — all fields optional; omitted ⇒ unchanged. +// `clear_campaign: true` detaches the target campaign (a nil pointer alone +// can't express "clear"). +export interface UpdateLeadSyncSource { + sheet_id?: string; + sheet_title?: string; + tab_title?: string; + has_header?: boolean; + column_mapping?: ImportColumnMapping[]; + dedup?: ImportDedupStrategy; + target_campaign_id?: string; + clear_campaign?: boolean; + category_ids?: string[]; + subscribed_default?: boolean; + label?: string; +} From 203a3e047189b5ef4cb79e97b510abb03e685f5f Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 11:50:14 +0200 Subject: [PATCH 19/60] feat: add contacts sheet sync UI --- .../components/app/contacts/ContactsTable.tsx | 23 + .../app/contacts/SheetSyncWizard.tsx | 726 ++++++++++++++++++ .../app/contacts/SyncSourceEditDrawer.tsx | 260 +++++++ .../app/contacts/SyncSourcesPanel.tsx | 358 +++++++++ 4 files changed, 1367 insertions(+) create mode 100644 web/src/components/app/contacts/SheetSyncWizard.tsx create mode 100644 web/src/components/app/contacts/SyncSourceEditDrawer.tsx create mode 100644 web/src/components/app/contacts/SyncSourcesPanel.tsx diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx index 14be1602..0cc7a3b1 100644 --- a/web/src/components/app/contacts/ContactsTable.tsx +++ b/web/src/components/app/contacts/ContactsTable.tsx @@ -23,6 +23,7 @@ import { PlusIcon, RefreshCcwIcon, Settings2Icon, + SheetIcon, TrashIcon, UploadIcon, UserPlusIcon, @@ -43,6 +44,7 @@ import ContactsEditBulk from "./ContactsEditBulk"; import { NewContactDialog } from "./NewContactDialog"; import ExportDialog from "./ExportDialog"; import ImportWizard from "./ImportWizard"; +import SyncSourcesPanel from "./SyncSourcesPanel"; import { CategoryChip } from "./CategoryPicker"; import { @@ -90,6 +92,7 @@ export default function ContactsTable({ const [newOpen, setNewOpen] = React.useState(false); const [exportOpen, setExportOpen] = React.useState(false); const [importOpen, setImportOpen] = React.useState(false); + const [syncOpen, setSyncOpen] = React.useState(false); const [searchProps, setSearchProps] = React.useState({ query: "", @@ -227,6 +230,13 @@ export default function ContactsTable({ > Filters + } + onClick={() => setSyncOpen(true)} + > + Sheet sync + } onClick={() => setNewOpen(true)} @@ -264,6 +274,11 @@ export default function ContactsTable({ /> setNewOpen(false)} /> + setSyncOpen(false)} + campaign={current_campaign} + /> ); } @@ -287,6 +302,13 @@ export default function ContactsTable({ > Import + } + onClick={() => setSyncOpen(true)} + > + Sheet sync + } @@ -435,6 +457,7 @@ export default function ContactsTable({ open={importOpen} onClose={() => setImportOpen(false)} /> + setSyncOpen(false)} /> ); } diff --git a/web/src/components/app/contacts/SheetSyncWizard.tsx b/web/src/components/app/contacts/SheetSyncWizard.tsx new file mode 100644 index 00000000..54ad56c8 --- /dev/null +++ b/web/src/components/app/contacts/SheetSyncWizard.tsx @@ -0,0 +1,726 @@ +// SheetSyncWizard — multi-step modal for creating (or editing) an on-demand +// Google-Sheet → contacts "sync source", and optionally running the first +// "Sync now". Mirrors ImportWizard's dialog shell + house theme, and REUSES +// its column-mapper verbatim (TargetPicker + MapStep + DEDUP_OPTIONS) so the +// /lead-sync/google/preview ImportPreview is mapped with the exact same UI as +// a CSV import. +// +// Steps: +// 1. connect — if no hidden google_sheets OAuth connection exists, run the +// EXISTING integration OAuth popup (provider "google_sheets"). +// 2. sheet — paste a Sheet ID, fetch its tabs, pick a tab. +// 3. map — preview first rows + map columns (reused MapStep). +// 4. options — dedup strategy, optional target campaign, optional categories. +// 5. save — POST /lead-sync/sources, then optionally Sync now → result. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { + AlertTriangleIcon, + ArrowLeftIcon, + ArrowRightIcon, + CheckIcon, + Loader2Icon, + PlugZapIcon, + RefreshCwIcon, + SaveIcon, + SheetIcon, + XIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { MapStep, ResultStep } from "./ImportWizard"; +import { DEDUP_OPTIONS, announceResult, describeError } from "./importShared"; +import CategoryPicker from "./CategoryPicker"; +import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuLabel, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; +import { + useFinishIntegrationOAuth, + useStartIntegrationOAuth, +} from "@/lib/api/hooks/app/integrations/useIntegrationOAuth"; +import { openOAuthPopup } from "@/lib/integrations/oauthPopup"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import useGoogleConnection from "@/lib/api/hooks/app/leadsync/useGoogleConnection"; +import { + useGetSpreadsheet, + usePreviewSheet, +} from "@/lib/api/hooks/app/leadsync/useSheetMeta"; +import useCreateLeadSyncSource from "@/lib/api/hooks/app/leadsync/useCreateLeadSyncSource"; +import useSyncLeadSyncSource from "@/lib/api/hooks/app/leadsync/useSyncLeadSyncSource"; +import { useQueryClient } from "@tanstack/react-query"; +import type { + ImportColumnMapping, + ImportDedupStrategy, + ImportPreview, + ImportResult, + LeadSyncSource, + SheetMeta, +} from "@/lib/api/models/app/leadsync/LeadSync"; + +type Step = "connect" | "sheet" | "map" | "options" | "result"; + +interface Props { + open: boolean; + onClose: () => void; + // When set, the source is pre-targeted to this campaign and the campaign + // picker is hidden — used by the per-campaign "Connect a Google Sheet". + lockedCampaign?: { id: string; name: string }; + // Notified after a source is saved so callers can refresh their list. + onSaved?: (source: LeadSyncSource) => void; +} + +const STEP_ORDER: Step[] = ["connect", "sheet", "map", "options", "result"]; + +export default function SheetSyncWizard({ open, onClose, lockedCampaign, onSaved }: Props) { + const connection = useGoogleConnection(); + const connectionId = connection.data?.connection?.id ?? null; + const connected = !!connection.data?.connected && !!connectionId; + + const [step, setStep] = React.useState("connect"); + const [sheetId, setSheetId] = React.useState(""); + const [meta, setMeta] = React.useState(null); + const [tabTitle, setTabTitle] = React.useState(""); + const [preview, setPreview] = React.useState(null); + const [mapping, setMapping] = React.useState([]); + const [hasHeader, setHasHeader] = React.useState(true); + const [dedup, setDedup] = React.useState("update"); + const [campaignId, setCampaignId] = React.useState(lockedCampaign?.id ?? null); + const [campaignName, setCampaignName] = React.useState(lockedCampaign?.name ?? ""); + const [categoryIds, setCategoryIds] = React.useState([]); + const [label, setLabel] = React.useState(""); + const [result, setResult] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + const startOAuth = useStartIntegrationOAuth(); + const finishOAuth = useFinishIntegrationOAuth(); + const getSpreadsheet = useGetSpreadsheet(); + const previewSheet = usePreviewSheet(); + const createSource = useCreateLeadSyncSource(); + const syncSource = useSyncLeadSyncSource(); + const queryClient = useQueryClient(); + + const reset = React.useCallback(() => { + setStep("connect"); + setSheetId(""); + setMeta(null); + setTabTitle(""); + setPreview(null); + setMapping([]); + setHasHeader(true); + setDedup("update"); + setCampaignId(lockedCampaign?.id ?? null); + setCampaignName(lockedCampaign?.name ?? ""); + setCategoryIds([]); + setLabel(""); + setResult(null); + setBusy(false); + }, [lockedCampaign]); + + React.useEffect(() => { + if (!open) reset(); + }, [open, reset]); + + // Skip straight to the sheet step once we know a connection exists. + React.useEffect(() => { + if (open && step === "connect" && connected) setStep("sheet"); + }, [open, step, connected]); + + async function runConnect() { + setBusy(true); + try { + const { url } = await startOAuth.mutateAsync({ + provider: "google_sheets", + label: "Google Sheets", + }); + const { code, state } = await openOAuthPopup(url); + await finishOAuth.mutateAsync({ code, state }); + await connection.refetch(); + await queryClient.invalidateQueries({ queryKey: ["lead-sync", "google", "connection"] }); + toast.success("Connected to Google Sheets"); + setStep("sheet"); + } catch (err) { + toast.error(describeError(err, "Connection failed.")); + } finally { + setBusy(false); + } + } + + async function loadTabs() { + if (!connectionId) return; + const id = sheetId.trim(); + if (!id) { + toast.error("Paste a Sheet ID first."); + return; + } + setBusy(true); + try { + const m = await getSpreadsheet.mutateAsync({ connection_id: connectionId, sheet_id: id }); + setMeta(m); + // Auto-select the first tab so the picker is never empty. + const first = m.tabs[0]?.title ?? ""; + setTabTitle(first); + if (!label.trim()) setLabel(m.title); + } catch (err) { + toast.error(describeError(err, "Couldn't read that spreadsheet.")); + setMeta(null); + } finally { + setBusy(false); + } + } + + async function loadPreview() { + if (!connectionId || !meta) return; + if (!tabTitle) { + toast.error("Pick a tab first."); + return; + } + setBusy(true); + try { + const p = await previewSheet.mutateAsync({ + connection_id: connectionId, + sheet_id: meta.sheet_id, + tab_title: tabTitle, + }); + setPreview(p); + setMapping(p.suggested_mapping); + setHasHeader(p.has_header); + setStep("map"); + } catch (err) { + toast.error(describeError(err, "Couldn't read that tab.")); + } finally { + setBusy(false); + } + } + + const emailMapped = mapping.some((m) => m.target === "email"); + + async function save(runSync: boolean) { + if (!connectionId || !meta) return; + setBusy(true); + try { + const source = await createSource.mutateAsync({ + connection_id: connectionId, + sheet_id: meta.sheet_id, + sheet_title: meta.title, + tab_title: tabTitle, + has_header: hasHeader, + column_mapping: mapping, + dedup, + target_campaign_id: campaignId ?? undefined, + category_ids: categoryIds, + subscribed_default: true, + label: label.trim() || meta.title, + }); + onSaved?.(source); + if (runSync) { + const res = await syncSource.mutateAsync(source.id); + setResult(res.result); + setStep("result"); + announceResult(res.result); + } else { + toast.success("Sync source saved"); + onClose(); + } + } catch (err) { + toast.error(describeError(err, "Couldn't save the sync source.")); + } finally { + setBusy(false); + } + } + + function stepIndex(): number { + // Hide the connect dot once connected — the visible flow is 4 steps. + const visible = connected ? STEP_ORDER.filter((s) => s !== "connect") : STEP_ORDER; + return Math.max(0, visible.indexOf(step)); + } + const visibleSteps = connected ? STEP_ORDER.filter((s) => s !== "connect") : STEP_ORDER; + + return ( + + {open && ( + + e.stopPropagation()} + className="w-full max-w-[760px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[90vh]" + > +
+
+ +
+ + Sync source + +
+ Google Sheet +
+ {visibleSteps.map((_, idx) => ( + + ))} +
+ +
+ +
+ {step === "connect" && ( + + )} + {step === "sheet" && ( + + )} + {step === "map" && preview && ( + + )} + {step === "options" && ( + { + setCampaignId(id); + setCampaignName(name); + }} + lockedCampaign={lockedCampaign} + categoryIds={categoryIds} + setCategoryIds={setCategoryIds} + /> + )} + {step === "result" && result && ( + + )} +
+ +
+ {step === "connect" && ( + + We use your existing Google Sheets authorization. + + )} + {step === "sheet" && ( + <> + {connected && !lockedCampaign && ( + + Connected to Google. + + )} + + + )} + {step === "map" && ( + <> + + {!emailMapped && ( + + + Map a column to Email + + )} + + + )} + {step === "options" && ( + <> + + + + + )} + {step === "result" && ( + + )} +
+
+
+ )} +
+ ); +} + +// ----- Connect step ---------------------------------------------- + +function ConnectStep({ busy, onConnect }: { busy: boolean; onConnect: () => void }) { + return ( +
+
+
+ +
+

Connect Google Sheets

+

+ Authorize Warmbly to read your spreadsheets. We only read the rows of the tab + you choose — nothing is written back. +

+ +
+
+
    +
  • One row per contact — first row should be the column headers.
  • +
  • At minimum a column with email addresses.
  • +
  • This is on-demand: nothing syncs until you press Sync now.
  • +
+
+
+ ); +} + +// ----- Sheet + tab step ------------------------------------------ + +function SheetStep({ + sheetId, + setSheetId, + meta, + tabTitle, + setTabTitle, + onLoadTabs, + busy, +}: { + sheetId: string; + setSheetId: (v: string) => void; + meta: SheetMeta | null; + tabTitle: string; + setTabTitle: (v: string) => void; + onLoadTabs: () => void; + busy: boolean; +}) { + return ( +
+
+ +
+ + +
+

+ The long ID in the sheet URL between /d/ and{" "} + /edit. +

+
+ + {meta && ( +
+
+ + {meta.title} + {meta.tabs.length} tabs +
+ + + + + + + Tabs + {meta.tabs.map((t) => ( + setTabTitle(t.title)} + > + {t.title} + + ))} + + +
+ )} +
+ ); +} + +// ----- Options step ---------------------------------------------- + +function OptionsStep({ + dedup, + setDedup, + label, + setLabel, + campaignId, + campaignName, + onCampaign, + lockedCampaign, + categoryIds, + setCategoryIds, +}: { + dedup: ImportDedupStrategy; + setDedup: (v: ImportDedupStrategy) => void; + label: string; + setLabel: (v: string) => void; + campaignId: string | null; + campaignName: string; + onCampaign: (id: string | null, name: string) => void; + lockedCampaign?: { id: string; name: string }; + categoryIds: string[]; + setCategoryIds: (v: string[]) => void; +}) { + return ( +
+
+ + +

+ Shown in your Sync sources list. Defaults to the spreadsheet title. +

+
+ +
+

+ Duplicate handling +

+

+ We dedupe on lowercased email each time you sync. Decide what happens when a row + matches a contact you already have. +

+
+ {DEDUP_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+

+ Enroll in campaign +

+ {lockedCampaign ? ( +
+ + + New & updated leads join{" "} + {lockedCampaign.name}. + +
+ ) : ( + <> +

+ Optionally enroll every synced contact into a campaign. +

+ + + )} +
+ +
+

+ Apply categories +

+

+ Every synced contact gets these categories. Skip to leave them untagged. +

+ +
+
+ ); +} + +// CampaignPicker — house-theme PopoverMenu campaign selector backed by the +// existing campaigns list query. Single-select with an explicit "None". +function CampaignPicker({ + campaignId, + campaignName, + onChange, +}: { + campaignId: string | null; + campaignName: string; + onChange: (id: string | null, name: string) => void; +}) { + const [query, setQuery] = React.useState(""); + const campaigns = useCampaigns({ query, folder: "" }); + const label = campaignId ? campaignName || "Selected campaign" : "No campaign"; + + return ( + + + + + +
+ setQuery(e.target.value)} + placeholder="Search campaigns…" + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+ onChange(null, "")}> + No campaign + + {campaigns.campaigns.map((c) => ( + onChange(c.id, c.name)} + > + {c.name} + + ))} + {campaigns.campaigns.length === 0 && ( +
+ {campaigns.isPending ? "Loading…" : "No campaigns found."} +
+ )} +
+
+ ); +} diff --git a/web/src/components/app/contacts/SyncSourceEditDrawer.tsx b/web/src/components/app/contacts/SyncSourceEditDrawer.tsx new file mode 100644 index 00000000..63393788 --- /dev/null +++ b/web/src/components/app/contacts/SyncSourceEditDrawer.tsx @@ -0,0 +1,260 @@ +// SyncSourceEditDrawer — edit a saved sync source's options without re-running +// the column mapper. Editing the sheet/tab/mapping is a "make a new source" +// operation conceptually, so here we only expose the safe, common edits: +// label, dedup, target campaign (with detach), categories, and the header flag. +// Sheet/tab/mapping are shown read-only for context. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, Loader2Icon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; + +import { DEDUP_OPTIONS, describeError } from "./importShared"; +import CategoryPicker from "./CategoryPicker"; +import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; +import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns"; +import useUpdateLeadSyncSource from "@/lib/api/hooks/app/leadsync/useUpdateLeadSyncSource"; +import type { + ImportDedupStrategy, + LeadSyncSource, + UpdateLeadSyncSource, +} from "@/lib/api/models/app/leadsync/LeadSync"; + +export default function SyncSourceEditDrawer({ + source, + onClose, +}: { + source: LeadSyncSource; + onClose: () => void; +}) { + const update = useUpdateLeadSyncSource(); + const [query, setQuery] = React.useState(""); + const campaigns = useCampaigns({ query, folder: "" }); + + const [label, setLabel] = React.useState(source.label ?? ""); + const [dedup, setDedup] = React.useState(source.dedup); + const [hasHeader, setHasHeader] = React.useState(source.has_header); + const [campaignId, setCampaignId] = React.useState( + source.target_campaign_id ?? null, + ); + const [categoryIds, setCategoryIds] = React.useState(source.category_ids ?? []); + const [busy, setBusy] = React.useState(false); + + const campaignName = + campaigns.campaigns.find((c) => c.id === campaignId)?.name ?? + (campaignId ? "Selected campaign" : "No campaign"); + + async function save() { + setBusy(true); + try { + const body: UpdateLeadSyncSource = { + label: label.trim() || source.sheet_title || "Sync source", + dedup, + has_header: hasHeader, + category_ids: categoryIds, + }; + // A nil pointer can't express "clear", so detach explicitly. + if (campaignId) { + body.target_campaign_id = campaignId; + } else if (source.target_campaign_id) { + body.clear_campaign = true; + } + await update.mutateAsync({ id: source.id, body }); + toast.success("Sync source updated"); + onClose(); + } catch (err) { + toast.error(describeError(err, "Couldn't update the sync source.")); + } finally { + setBusy(false); + } + } + + return ( + + + +
+ +
+
+ + {source.tab_title && } + m.target !== "ignore").length} fields`} + /> +
+ +
+ + +
+ +
+ +
+ +
+

+ Duplicate handling +

+
+ {DEDUP_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+

+ Enroll in campaign +

+ + + + + +
+ setQuery(e.target.value)} + placeholder="Search campaigns…" + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+ setCampaignId(null)} + > + No campaign + + {campaigns.campaigns.map((c) => ( + setCampaignId(c.id)} + > + {c.name} + + ))} +
+
+
+ +
+

+ Apply categories +

+ +
+
+ +
+ + +
+ + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/web/src/components/app/contacts/SyncSourcesPanel.tsx b/web/src/components/app/contacts/SyncSourcesPanel.tsx new file mode 100644 index 00000000..346cf1dd --- /dev/null +++ b/web/src/components/app/contacts/SyncSourcesPanel.tsx @@ -0,0 +1,358 @@ +// SyncSourcesPanel — the management surface for on-demand Google-Sheet → leads +// "sync sources". Lists saved sources with their last-sync result + status, and +// offers per-row Sync now / Edit / Delete plus a "New sync" entry that opens the +// SheetSyncWizard. Works in two placements: +// - global Contacts page (no campaignId): lists every source. +// - per-campaign leads view (campaignId set): lists that campaign's sources +// and pre-targets the wizard to it. +// +// Rendered as a centered modal mirroring ImportWizard's dialog shell + theme. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { + AlertTriangleIcon, + CheckCircle2Icon, + Loader2Icon, + PencilIcon, + PlusIcon, + RefreshCwIcon, + SheetIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { announceResult, describeError } from "./importShared"; +import SheetSyncWizard from "./SheetSyncWizard"; +import SyncSourceEditDrawer from "./SyncSourceEditDrawer"; +import { useConfirm } from "@/hooks/context/confirm"; +import useLeadSyncSources from "@/lib/api/hooks/app/leadsync/useLeadSyncSources"; +import useDeleteLeadSyncSource from "@/lib/api/hooks/app/leadsync/useDeleteLeadSyncSource"; +import useSyncLeadSyncSource from "@/lib/api/hooks/app/leadsync/useSyncLeadSyncSource"; +import type { LeadSyncSource } from "@/lib/api/models/app/leadsync/LeadSync"; + +export default function SyncSourcesPanel({ + open, + onClose, + campaign, +}: { + open: boolean; + onClose: () => void; + // When set, scopes the list + pre-targets new sources to this campaign. + campaign?: { id: string; name: string }; +}) { + const sources = useLeadSyncSources(campaign?.id); + const deleteSource = useDeleteLeadSyncSource(); + const syncSource = useSyncLeadSyncSource(); + const confirm = useConfirm(); + + const [wizardOpen, setWizardOpen] = React.useState(false); + const [editing, setEditing] = React.useState(null); + const [syncingId, setSyncingId] = React.useState(null); + + const list = sources.data?.data ?? []; + + async function runSync(src: LeadSyncSource) { + setSyncingId(src.id); + try { + const res = await syncSource.mutateAsync(src.id); + announceResult(res.result); + } catch (err) { + toast.error(describeError(err, "Sync failed.")); + } finally { + setSyncingId(null); + } + } + + function confirmDelete(src: LeadSyncSource) { + confirm.show( + `Delete sync source "${src.label || src.sheet_title || "this sheet"}"? Contacts already imported stay.`, + async () => { + try { + await deleteSource.mutateAsync(src.id); + toast.success("Sync source deleted"); + } catch (err) { + toast.error(describeError(err, "Delete failed.")); + } + }, + ); + } + + return ( + <> + + {open && ( + + e.stopPropagation()} + className="w-full max-w-[720px] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden flex flex-col max-h-[85vh]" + > +
+
+ +
+ + Sync sources + + {campaign && ( + <> +
+ + {campaign.name} + + + )} + + +
+ +
+ {sources.isPending ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) : sources.isError ? ( +
+
+ +
+

+ Couldn't load sync sources +

+ +
+ ) : list.length === 0 ? ( +
+
+ +
+

+ No sync sources yet +

+

+ Connect a Google Sheet and re-run it on demand to pull new and + updated leads into Warmbly{campaign ? ` and into ${campaign.name}` : ""}. +

+ +
+ ) : ( +
+ {list.map((src) => ( + runSync(src)} + onEdit={() => setEditing(src)} + onDelete={() => confirmDelete(src)} + /> + ))} +
+ )} +
+ + + )} + + + setWizardOpen(false)} + lockedCampaign={campaign} + onSaved={() => sources.refetch()} + /> + {editing && ( + setEditing(null)} /> + )} + + ); +} + +function SourceRow({ + source, + syncing, + onSync, + onEdit, + onDelete, +}: { + source: LeadSyncSource; + syncing: boolean; + onSync: () => void; + onEdit: () => void; + onDelete: () => void; +}) { + const r = source.last_result; + return ( +
+
+ +
+
+
+ + {source.label || source.sheet_title || "Untitled sheet"} + + +
+
+ {source.sheet_title && ( + {source.sheet_title} + )} + {source.tab_title && ( + <> + · + {source.tab_title} + + )} + · + + {source.last_synced_at + ? `synced ${new Date(source.last_synced_at).toLocaleString()}` + : "never synced"} + +
+ {r && ( +
+ + + + {r.failed > 0 && } +
+ )} + {source.last_error && ( +

{source.last_error}

+ )} +
+ +
+ + + +
+
+ ); +} + +function StatusBadge({ status, hasError }: { status: string; hasError: boolean }) { + if (status === "syncing") { + return ( + + + syncing + + ); + } + if (status === "error" || hasError) { + return ( + + + error + + ); + } + return ( + + + idle + + ); +} + +function Count({ + label, + value, + tone, +}: { + label: string; + value: number; + tone: "emerald" | "sky" | "slate" | "red"; +}) { + const cls = { + emerald: "bg-emerald-50 text-emerald-700", + sky: "bg-sky-50 text-sky-700", + slate: "bg-slate-100 text-slate-600", + red: "bg-red-50 text-red-700", + }[tone]; + return ( + + {value.toLocaleString()} + {label} + + ); +} From 5b9029d2a858ce9c02773a013e9c2dab14e8a36c Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 18:56:43 +0200 Subject: [PATCH 20/60] feat: add campaign schedule windows backend Store per-day schedule windows on campaigns and validate updates before persistence. Use the schedule windows in campaign scheduling, including legacy day/time derivation for existing campaigns. --- internal/app/advanced/service.go | 4 +- .../000015_campaign_schedule_windows.down.sql | 1 + .../000015_campaign_schedule_windows.up.sql | 13 +++ internal/models/campaign.go | 87 +++++++++++++++++++ internal/repository/pg_campaign.go | 58 ++++++++++--- internal/scheduler/campaign_scheduler.go | 61 ++++++------- internal/scheduler/helpers.go | 62 +++++++++++++ internal/utils/validate/campaign.go | 22 +++++ 8 files changed, 264 insertions(+), 44 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000015_campaign_schedule_windows.down.sql create mode 100644 internal/infrastructure/db/migrations/000015_campaign_schedule_windows.up.sql diff --git a/internal/app/advanced/service.go b/internal/app/advanced/service.go index 79021bca..aa571b72 100644 --- a/internal/app/advanced/service.go +++ b/internal/app/advanced/service.go @@ -1022,7 +1022,9 @@ func (s *service) RunPreflight(ctx context.Context, organizationID, campaignID u } if settings.Preflight.CheckScheduleWindow { - pass := campaign.StartTime < campaign.EndTime + // Per-day windows (when set) define the schedule; otherwise fall back to + // the legacy start_time/end_time check. + pass := !campaign.ScheduleWindows.IsEmpty() || campaign.StartTime < campaign.EndTime check := models.PreflightCheckResult{ Key: "schedule_window", Passed: pass, diff --git a/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.down.sql b/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.down.sql new file mode 100644 index 00000000..49f185b3 --- /dev/null +++ b/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.down.sql @@ -0,0 +1 @@ +ALTER TABLE campaigns DROP COLUMN IF EXISTS schedule_windows; diff --git a/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.up.sql b/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.up.sql new file mode 100644 index 00000000..fee8e80a --- /dev/null +++ b/internal/infrastructure/db/migrations/000015_campaign_schedule_windows.up.sql @@ -0,0 +1,13 @@ +-- Per-day, multi-interval campaign sending windows. +-- +-- Supersedes the single (days bitmask + start_time + end_time) window: when +-- this column is non-null it is the AUTHORITATIVE sending schedule. NULL means +-- "derive from the legacy days/start_time/end_time columns" (back-compat for +-- campaigns created before this migration). +-- +-- Shape: a jsonb array of exactly 7 elements indexed by weekday +-- (0 = Sunday .. 6 = Saturday, matching Go's time.Weekday). Each element is an +-- array of {"start": , "end": } intervals, e.g. +-- [[], [{"start":480,"end":600},{"start":720,"end":960}], [], [], [], [], []] +-- = Monday 08:00–10:00 and 12:00–16:00, no other day. +ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS schedule_windows jsonb; diff --git a/internal/models/campaign.go b/internal/models/campaign.go index feb55416..39bffd18 100644 --- a/internal/models/campaign.go +++ b/internal/models/campaign.go @@ -1,11 +1,91 @@ package models import ( + "database/sql/driver" + "encoding/json" + "fmt" "time" "github.com/google/uuid" ) +// TimeInterval is a sending window within a single day, expressed in minutes +// since local midnight. End is exclusive-ish and must be > Start, <= 1440. +type TimeInterval struct { + Start int `json:"start"` + End int `json:"end"` +} + +// ScheduleWindows is a campaign's per-day sending schedule, indexed by +// time.Weekday (0=Sunday .. 6=Saturday). A nil/empty day means "no sending that +// day". When non-empty it is the authoritative schedule and supersedes the +// legacy Days/StartTime/EndTime fields. Persisted as a jsonb array-of-7. +type ScheduleWindows [7][]TimeInterval + +// IsEmpty reports whether no day carries any interval (treated as "unset"). +func (w ScheduleWindows) IsEmpty() bool { + for _, day := range w { + if len(day) > 0 { + return false + } + } + return true +} + +// DaySpan returns the earliest start and latest end across a weekday's +// intervals (ok=false when that day has none). Used for even send distribution. +func (w ScheduleWindows) DaySpan(weekday int) (start, end int, ok bool) { + if weekday < 0 || weekday > 6 || len(w[weekday]) == 0 { + return 0, 0, false + } + start, end = w[weekday][0].Start, w[weekday][0].End + for _, iv := range w[weekday][1:] { + if iv.Start < start { + start = iv.Start + } + if iv.End > end { + end = iv.End + } + } + return start, end, true +} + +// Value implements driver.Valuer — marshals to jsonb, or NULL when empty (so an +// empty schedule reverts to the legacy day/time derivation). +func (w ScheduleWindows) Value() (driver.Value, error) { + if w.IsEmpty() { + return nil, nil + } + return json.Marshal(w) +} + +// Scan implements sql.Scanner — reads the jsonb column (NULL → empty). +func (w *ScheduleWindows) Scan(src any) error { + if src == nil { + *w = ScheduleWindows{} + return nil + } + var b []byte + switch v := src.(type) { + case []byte: + b = v + case string: + b = []byte(v) + default: + return fmt.Errorf("ScheduleWindows: unsupported scan type %T", src) + } + if len(b) == 0 { + *w = ScheduleWindows{} + return nil + } + var parsed ScheduleWindows + if err := json.Unmarshal(b, &parsed); err != nil { + return err + } + *w = parsed + return nil +} + type Campaign struct { ID uuid.UUID `json:"id"` UserID string `json:"user_id"` @@ -33,6 +113,10 @@ type Campaign struct { StartTime string `json:"start_time"` EndTime string `json:"end_time"` + // ScheduleWindows, when non-empty, is the authoritative per-day sending + // schedule (supersedes Days/StartTime/EndTime). Indexed by time.Weekday. + ScheduleWindows ScheduleWindows `json:"schedule_windows"` + EmailTags []string `json:"email_tags"` Folders []string `json:"folders"` @@ -124,6 +208,9 @@ type UpdateCampaign struct { StartTime *string `json:"start_time"` EndTime *string `json:"end_time"` + // Authoritative per-day schedule. When sent, supersedes Days/StartTime/EndTime. + ScheduleWindows *ScheduleWindows `json:"schedule_windows,omitempty"` + EmailTags []string `json:"email_tags"` Folders []string `json:"folders"` diff --git a/internal/repository/pg_campaign.go b/internal/repository/pg_campaign.go index 21f8c4c4..e0895c04 100644 --- a/internal/repository/pg_campaign.go +++ b/internal/repository/pg_campaign.go @@ -101,7 +101,8 @@ const CAMPAIGN_SELECT = `id, name, description, status, sender_strategy, rotation_mode, ramp_enabled, ramp_start, ramp_increment, ramp_ceiling, ramp_level, ramp_level_date, esp_match_mode, max_new_leads_per_day, prioritize_new_leads, - tracking_domain, tracking_domain_verified, tracking_domain_verified_at` + tracking_domain, tracking_domain_verified, tracking_domain_verified_at, + schedule_windows` func getCampaign(rows db.Scannable, campaign *models.Campaign, extra ...any) error { var dest []any = []any{ @@ -116,6 +117,7 @@ func getCampaign(rows db.Scannable, campaign *models.Campaign, extra ...any) err &campaign.RampEnabled, &campaign.RampStart, &campaign.RampIncrement, &campaign.RampCeiling, &campaign.RampLevel, &campaign.RampLevelDate, &campaign.ESPMatchMode, &campaign.MaxNewLeadsPerDay, &campaign.PrioritizeNewLeads, &campaign.TrackingDomain, &campaign.TrackingDomainVerified, &campaign.TrackingDomainVerifiedAt, + &campaign.ScheduleWindows, } dest = append(dest, extra...) return rows.Scan( @@ -135,6 +137,7 @@ const CAMPAIGN_SELECT_FULL = ` c.ramp_enabled, c.ramp_start, c.ramp_increment, c.ramp_ceiling, c.ramp_level, c.ramp_level_date, c.esp_match_mode, c.max_new_leads_per_day, c.prioritize_new_leads, c.tracking_domain, c.tracking_domain_verified, c.tracking_domain_verified_at, + c.schedule_windows, COALESCE(array_agg(cet.tag_id) FILTER (WHERE cet.tag_id IS NOT NULL), '{}') AS email_tag_ids, COALESCE(array_agg(cec.folder_id) FILTER (WHERE cec.folder_id IS NOT NULL), '{}') AS email_folder_ids ` @@ -435,7 +438,11 @@ func (r *campaignRepository) Create(ctx context.Context, userID string, orgID *u // Explicit sender pool (feature 1). Only persisted when the caller passed a // list; validation/ownership checks live in syncCampaignSendersTx. if len(data.Senders) > 0 { - senders, xerr := syncCampaignSendersTx(ctx, tx, campaign.ID, userID, data.Senders) + orgStr := "" + if orgID != nil { + orgStr = orgID.String() + } + senders, xerr := syncCampaignSendersTx(ctx, tx, campaign.ID, userID, orgStr, data.Senders) if xerr != nil { return nil, xerr } @@ -852,6 +859,14 @@ func (r *campaignRepository) Update(ctx context.Context, userID, campaignID stri args = append(args, *data.EndTime) argPos++ } + if data.ScheduleWindows != nil { + if err := validate.CampaignScheduleWindows(data.ScheduleWindows); err != nil { + return nil, err + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "schedule_windows", argPos)) + args = append(args, *data.ScheduleWindows) + argPos++ + } if data.ContactOrderBy != nil { validOrderBy := map[string]bool{ "created_at": true, "email": true, "name": true, @@ -1062,6 +1077,7 @@ func (r *campaignRepository) GetByID(ctx context.Context, campaignID uuid.UUID) &campaign.RampEnabled, &campaign.RampStart, &campaign.RampIncrement, &campaign.RampCeiling, &campaign.RampLevel, &campaign.RampLevelDate, &campaign.ESPMatchMode, &campaign.MaxNewLeadsPerDay, &campaign.PrioritizeNewLeads, &campaign.TrackingDomain, &campaign.TrackingDomainVerified, &campaign.TrackingDomainVerifiedAt, + &campaign.ScheduleWindows, &campaign.EmailTags, &campaign.Folders, ) if err != nil { @@ -1350,10 +1366,12 @@ func (r *campaignRepository) CountActiveCampaignsForAccount(ctx context.Context, // syncCampaignSendersTx replaces the explicit sender pool inside an existing tx. // It deletes the current rows and multi-inserts the new set, validating that -// every account belongs to the campaign owner (userID). rotation_position and -// last_sent_at are preserved for accounts that remain in the pool so a rewrite -// of weights/enabled flags does not reset the round-robin/LRU cursors. -func syncCampaignSendersTx(ctx context.Context, tx pgx.Tx, campaignID uuid.UUID, userID string, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) { +// every account is reachable in the campaign's context — by the campaign's +// organization (orgID) for org-scoped campaigns, or by the owner (userID) for a +// personal campaign with no org. rotation_position and last_sent_at are +// preserved for accounts that remain in the pool so a rewrite of weights/enabled +// flags does not reset the round-robin/LRU cursors. +func syncCampaignSendersTx(ctx context.Context, tx pgx.Tx, campaignID uuid.UUID, userID, orgID string, in []models.CampaignSenderInput) ([]models.CampaignSender, *errx.Error) { accountIDs := make([]uuid.UUID, 0, len(in)) weights := make(map[uuid.UUID]int, len(in)) enabledFor := make(map[uuid.UUID]bool, len(in)) @@ -1382,13 +1400,21 @@ func syncCampaignSendersTx(ctx context.Context, tx pgx.Tx, campaignID uuid.UUID, enabledFor[s.EmailAccountID] = enabled } - // Ownership check: every referenced mailbox must belong to the campaign owner. + // Ownership check: every referenced mailbox must be reachable in the + // campaign's context. Org-scoped campaigns (the org-gated senders route) + // validate against the organization, so any member with PermManageCampaigns + // can pick the org's mailboxes; a personal campaign with no org falls back to + // the owner's user_id. (ownerCol is a fixed literal, never user input.) + ownerCol, ownerVal := "user_id", userID + if orgID != "" { + ownerCol, ownerVal = "organization_id", orgID + } var ownedCount int if err := tx.QueryRow(ctx, - `SELECT COUNT(*) FROM email_accounts WHERE user_id = $1 AND id = ANY($2)`, - userID, accountIDs, + `SELECT COUNT(*) FROM email_accounts WHERE `+ownerCol+` = $1 AND id = ANY($2)`, + ownerVal, accountIDs, ).Scan(&ownedCount); err != nil { - db.CaptureError(err, "campaign_senders ownership", []any{userID}, "queryrow") + db.CaptureError(err, "campaign_senders ownership", []any{ownerVal}, "queryrow") return nil, errx.InternalError() } if ownedCount != len(accountIDs) { @@ -1475,15 +1501,21 @@ func (r *campaignRepository) ReplaceCampaignSenders(ctx context.Context, campaig return nil, errx.New(errx.BadRequest, "explicit sender strategy requires at least one sender") } - // Resolve the campaign owner so we can validate mailbox ownership. + // Resolve the campaign owner + organization so we can validate mailbox + // ownership against the org (the senders route is org-scoped). var userID string - if err := r.DB.QueryRow(ctx, `SELECT user_id FROM campaigns WHERE id = $1`, campaignID).Scan(&userID); err != nil { + var orgID *uuid.UUID + if err := r.DB.QueryRow(ctx, `SELECT user_id, organization_id FROM campaigns WHERE id = $1`, campaignID).Scan(&userID, &orgID); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errx.ErrNotFound } db.CaptureError(err, "campaign owner lookup", []any{campaignID}, "queryrow") return nil, errx.InternalError() } + orgStr := "" + if orgID != nil { + orgStr = orgID.String() + } tx, err := r.DB.Begin(ctx) if err != nil { @@ -1497,7 +1529,7 @@ func (r *campaignRepository) ReplaceCampaignSenders(ctx context.Context, campaig } }() - senders, xerr := syncCampaignSendersTx(ctx, tx, campaignID, userID, in) + senders, xerr := syncCampaignSendersTx(ctx, tx, campaignID, userID, orgStr, in) if xerr != nil { return nil, xerr } diff --git a/internal/scheduler/campaign_scheduler.go b/internal/scheduler/campaign_scheduler.go index 43087493..382d2c40 100644 --- a/internal/scheduler/campaign_scheduler.go +++ b/internal/scheduler/campaign_scheduler.go @@ -169,6 +169,9 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai // Fall back to UTC if campaign has no timezone set (account timezone checked later) campaignTZName := campaign.Timezone campaignTZ := loadLocation(campaignTZName) + // Authoritative per-day sending windows (or derived from the legacy + // days/start/end fields). Drives every day-of-week + time-window gate below. + windows := effectiveWindows(campaign) candidateTime := baseTime // Check campaign date range @@ -180,11 +183,10 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai return time.Time{}, nil, uuid.Nil, ErrCampaignEnded } - // STEP 6: Find next valid day of week (campaign.Days is bitmask) - candidateTime = findNextValidDay(candidateTime, uint8(campaign.Days), campaignTZ) - - // STEP 7: Ensure within campaign time window (start_time to end_time) - candidateTime = ensureTimeWindow(candidateTime, campaign.StartTime, campaign.EndTime, campaignTZ) + // STEP 6+7: Snap to the next allowed per-day sending window (handles both + // the day-of-week gate and the time-of-day window, including multiple + // intervals per day). + candidateTime = nextScheduleSlot(candidateTime, windows, campaignTZ) // effectiveCap is the per-mailbox cold cap for THIS campaign, after the ramp // clamp. It is min(per-mailbox cold cap, campaign daily limit) further min()'d @@ -321,8 +323,7 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai // filter MUST be re-applied here, or tomorrow's recompute over-budgets a // mailbox past its ramp ceiling / picks a cross-provider sender. candidateTime = candidateTime.Add(24 * time.Hour) - candidateTime = findNextValidDay(candidateTime, uint8(campaign.Days), campaignTZ) - candidateTime = ensureTimeWindow(candidateTime, campaign.StartTime, campaign.EndTime, campaignTZ) + candidateTime = nextScheduleSlot(candidateTime, windows, campaignTZ) var tomorrow []AccountCandidate for i := range candidates { @@ -367,24 +368,25 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai account := &selected.Account - // STEP 9: Even distribution across today's time window - endMinutes := parseTimeOfDay(campaign.EndTime) - nowLocal := time.Now().In(campaignTZ) - currentMinutes := nowLocal.Hour()*60 + nowLocal.Minute() - + // STEP 9: Even distribution across the candidate day's sending window. Uses + // the span (earliest start → latest end) of that weekday's intervals. remainingEmails := selected.RemainingToday if remainingEmails > 0 { - startMinutes := parseTimeOfDay(campaign.StartTime) - remainingMinutes := endMinutes - max(currentMinutes, startMinutes) - if remainingMinutes > 0 { - idealInterval := time.Minute * time.Duration(remainingMinutes/remainingEmails) - minInterval := time.Second * time.Duration(account.MinWaitTime) - if idealInterval < minInterval { - idealInterval = minInterval - } - distributedTime := time.Now().Add(idealInterval) - if distributedTime.After(candidateTime) { - candidateTime = distributedTime + wd := int(candidateTime.In(campaignTZ).Weekday()) + if dayStart, dayEnd, ok := windows.DaySpan(wd); ok { + nowLocal := time.Now().In(campaignTZ) + currentMinutes := nowLocal.Hour()*60 + nowLocal.Minute() + remainingMinutes := dayEnd - max(currentMinutes, dayStart) + if remainingMinutes > 0 { + idealInterval := time.Minute * time.Duration(remainingMinutes/remainingEmails) + minInterval := time.Second * time.Duration(account.MinWaitTime) + if idealInterval < minInterval { + idealInterval = minInterval + } + distributedTime := time.Now().Add(idealInterval) + if distributedTime.After(candidateTime) { + candidateTime = distributedTime + } } } } @@ -401,8 +403,8 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai if candidateTime.Before(earliestNext) { candidateTime = earliestNext - // Re-apply time window after adjusting for min wait - candidateTime = ensureTimeWindow(candidateTime, campaign.StartTime, campaign.EndTime, campaignTZ) + // Re-snap into a sending window after adjusting for min wait. + candidateTime = nextScheduleSlot(candidateTime, windows, campaignTZ) } } @@ -423,8 +425,9 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai // STEP 13: Apply human-like distribution (favor morning/afternoon peaks) candidateTime = applyDistributionCurve(candidateTime, campaignTZ) - // STEP 14: Ensure still within campaign window after all adjustments - candidateTime = ensureTimeWindow(candidateTime, campaign.StartTime, campaign.EndTime, campaignTZ) + // STEP 14: Ensure still within a sending window after all adjustments + // (jitter/conflict/distribution can push into a gap between intervals). + candidateTime = nextScheduleSlot(candidateTime, windows, campaignTZ) return candidateTime, nextPair, account.ID, nil } @@ -434,9 +437,7 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai // paths so a campaign reschedules instead of completing or busy-looping. func (s *schedulerService) deferToNextDay(campaign *models.Campaign) time.Time { tz := loadLocation(campaign.Timezone) - t := time.Now().Add(24 * time.Hour) - t = findNextValidDay(t, uint8(campaign.Days), tz) - t = ensureTimeWindow(t, campaign.StartTime, campaign.EndTime, tz) + t := nextScheduleSlot(time.Now().Add(24*time.Hour), effectiveWindows(campaign), tz) // Add a small jitter so deferred tasks don't all wake at the same instant. return t.Add(time.Minute * time.Duration(randomJitter(0, 30))) } diff --git a/internal/scheduler/helpers.go b/internal/scheduler/helpers.go index df2dd3a0..d16641f4 100644 --- a/internal/scheduler/helpers.go +++ b/internal/scheduler/helpers.go @@ -63,6 +63,68 @@ func ensureTimeWindow(t time.Time, startTime, endTime string, tz *time.Location) return t } +// effectiveWindows returns the campaign's authoritative per-day sending +// schedule. When ScheduleWindows is set it is used as-is; otherwise it is +// DERIVED from the legacy days bitmask + start/end time (one interval per active +// day) so campaigns created before the multi-window feature still schedule +// correctly. The stored days bitmask is Monday-indexed (bit 0 = Monday, as the +// bitmask package and dashboard write it), so it is mapped to time.Weekday +// (Sun=0) via (bit+1)%7 — which also corrects the historical off-by-one in the +// legacy day check. +func effectiveWindows(c *models.Campaign) models.ScheduleWindows { + if !c.ScheduleWindows.IsEmpty() { + return c.ScheduleWindows + } + start := parseTimeOfDay(c.StartTime) + end := parseTimeOfDay(c.EndTime) + if end <= start { + // No usable legacy window → unconstrained (any time allowed). + return models.ScheduleWindows{} + } + var sw models.ScheduleWindows + for bit := 0; bit < 7; bit++ { + if c.Days == 0 || c.Days&(1<= from that falls inside one of +// the campaign's per-day sending windows, searching up to 8 days ahead. An +// empty schedule means "unconstrained" and returns from unchanged. When from is +// already inside a window its exact instant is preserved (so jitter/min-wait +// adjustments survive); otherwise it advances to the next interval's start. +func nextScheduleSlot(from time.Time, sw models.ScheduleWindows, tz *time.Location) time.Time { + if sw.IsEmpty() { + return from + } + cur := from.In(tz) + for i := 0; i < 8; i++ { + y, m, d := cur.Date() + nowMin := cur.Hour()*60 + cur.Minute() + + ivs := append([]models.TimeInterval(nil), sw[int(cur.Weekday())]...) + sort.Slice(ivs, func(a, b int) bool { return ivs[a].Start < ivs[b].Start }) + + for _, iv := range ivs { + if nowMin < iv.Start { + return time.Date(y, m, d, iv.Start/60, iv.Start%60, 0, 0, tz) + } + if nowMin < iv.End { + if i == 0 { + return from // already inside a window — keep the exact instant + } + return cur + } + } + // No interval left today — jump to the start of the next day. + cur = time.Date(y, m, d, 0, 0, 0, 0, tz).Add(24 * time.Hour) + } + return from +} + // ensureBusinessHours ensures time is within business hours (8am-8pm) func ensureBusinessHours(t time.Time, timezone string) time.Time { loc := loadLocation(timezone) diff --git a/internal/utils/validate/campaign.go b/internal/utils/validate/campaign.go index c9f38401..179eb691 100644 --- a/internal/utils/validate/campaign.go +++ b/internal/utils/validate/campaign.go @@ -7,6 +7,7 @@ import ( "github.com/warmbly/warmbly/internal/bitmask" "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" ) func CampaignName(name string) *errx.Error { @@ -60,6 +61,27 @@ func CampaignTime(input string) *errx.Error { return nil } +// CampaignScheduleWindows validates a per-day sending schedule: each interval +// must sit within the day (0..1440 minutes) with start < end, and no day may +// carry an unreasonable number of windows. Overlaps are allowed (the scheduler +// resolves them); ordering is not required. +func CampaignScheduleWindows(w *models.ScheduleWindows) *errx.Error { + if w == nil { + return nil + } + for _, day := range w { + if len(day) > 8 { + return errx.New(errx.BadRequest, "a day may have at most 8 sending windows") + } + for _, iv := range day { + if iv.Start < 0 || iv.End > 1440 || iv.Start >= iv.End { + return errx.New(errx.BadRequest, "invalid sending window: 0 <= start < end <= 1440") + } + } + } + return nil +} + // ── Net-new send-control validators ────────────────────────────────────── func CampaignSenderStrategy(s string) *errx.Error { From 40413db071e6a00f3b870ce8002ffe693d5406c4 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 18:56:57 +0200 Subject: [PATCH 21/60] feat: add weekly campaign schedule editor Add the dashboard editor for per-day campaign sending windows and wire campaign models to the new schedule_windows API shape. Refresh the shared calendar/date controls used by the campaign schedule screen. --- .../app/app/campaigns/[id]/schedule/page.tsx | 325 +++++++++------- web/src/components/app/Calendar.tsx | 179 +++++---- .../campaigns/schedule/WeekScheduleGrid.tsx | 346 ++++++++++++++++++ .../app/campaigns/schedule/WeekdayBitmask.tsx | 21 +- .../lib/api/models/app/campaigns/Campaign.ts | 11 + 5 files changed, 677 insertions(+), 205 deletions(-) create mode 100644 web/src/components/app/campaigns/schedule/WeekScheduleGrid.tsx diff --git a/web/src/app/app/campaigns/[id]/schedule/page.tsx b/web/src/app/app/campaigns/[id]/schedule/page.tsx index a76764be..3d342fbd 100644 --- a/web/src/app/app/campaigns/[id]/schedule/page.tsx +++ b/web/src/app/app/campaigns/[id]/schedule/page.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { ClockIcon, GlobeIcon } from "lucide-react"; +import { ArrowRightIcon, CalendarClockIcon, CalendarRangeIcon, GlobeIcon } from "lucide-react"; +import { differenceInCalendarDays, format } from "date-fns"; import DateSelect from "@/components/app/campaigns/schedule/ScheduleDateSelect"; -import WeekdayBitmask from "@/components/app/campaigns/schedule/WeekdayBitmask"; +import WeekScheduleGrid, { type Interval } from "@/components/app/campaigns/schedule/WeekScheduleGrid"; import { Loading } from "@/components/loader"; -import { Label } from "@/components/ui/field"; import { PopoverMenu, PopoverMenuContent, @@ -13,40 +13,62 @@ import { } from "@/components/ui/popover-menu"; import { useCampaign } from "@/hooks/context/campaign"; import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; +import type { ScheduleInterval } from "@/lib/api/models/app/campaigns/Campaign"; import useUpdateCampaign from "@/lib/api/hooks/app/campaigns/useUpdateCampaign"; import toast from "react-hot-toast"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import { useUserProfile } from "@/hooks/context/user"; -import { timeOptions, to12Hour } from "@/lib/core/time"; -const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; +// ── Wire ↔ display conversion ──────────────────────────────────────────── +// Wire (schedule_windows) is a 7-array indexed by weekday 0=Sun..6=Sat (the +// backend's time.Weekday). The grid is Monday-first (column 0 = Mon), so +// display index i maps to wire weekday (i+1)%7. +const parseHHMM = (t: string): number => { + const [h, m] = (t || "0:0").split(":").map(Number); + return (h || 0) * 60 + (m || 0); +}; -function TimePicker({ value, onChange }: { value: string; onChange: (v: string) => void }) { - return ( - - - } - label={to12Hour(value)} - className="w-full justify-between" - /> - - - {timeOptions.map((t) => ( - onChange(t.value)} - > - {t.name} - - ))} - - - ); +function wireToDisplay(wire: ScheduleInterval[][] | null | undefined): Interval[][] { + return Array.from({ length: 7 }, (_, i) => { + const day = wire?.[(i + 1) % 7] ?? []; + return day.map((iv) => ({ start: iv.start, end: iv.end })); + }); } +function displayToWire(display: Interval[][]): ScheduleInterval[][] { + const wire: ScheduleInterval[][] = Array.from({ length: 7 }, () => []); + for (let i = 0; i < 7; i++) wire[(i + 1) % 7] = display[i].map((iv) => ({ start: iv.start, end: iv.end })); + return wire; +} + +const hasAnyWindow = (w: ScheduleInterval[][] | null | undefined): boolean => + !!w && w.some((d) => (d?.length ?? 0) > 0); + +// Seed the editor: prefer schedule_windows; otherwise derive one window per +// active legacy day (days bitmask is Monday-indexed, matching the grid). +function seedWindows(c: Campaign): Interval[][] { + if (hasAnyWindow(c.schedule_windows)) return wireToDisplay(c.schedule_windows); + const start = parseHHMM(c.start_time); + const end = parseHHMM(c.end_time); + return Array.from({ length: 7 }, (_, i) => { + const active = (c.days & (1 << i)) !== 0; + return active && end > start ? [{ start, end }] : []; + }); +} + +const PRESETS: { label: string; build: () => Interval[][] }[] = [ + { + label: "Mon–Fri 9–5", + build: () => Array.from({ length: 7 }, (_, i) => (i < 5 ? [{ start: 540, end: 1020 }] : [])), + }, + { + label: "Every day 9–5", + build: () => Array.from({ length: 7 }, () => [{ start: 540, end: 1020 }]), + }, + { label: "Clear", build: () => Array.from({ length: 7 }, () => []) }, +]; + export default function CampaignSchedule() { const campaign = useCampaign(); if (!campaign) { @@ -57,36 +79,47 @@ export default function CampaignSchedule() { const updateCampaign = useUpdateCampaign(campaign.id); const [loading, setLoading] = React.useState(false); - const [newData, setNewData] = React.useState(campaign); + const [newData, setNewData] = React.useState(campaign); // timezone + dates draft + const [windows, setWindows] = React.useState(() => seedWindows(campaign)); + + const baseline = React.useMemo(() => seedWindows(campaign), [campaign]); React.useEffect(() => { - if (!campaign) return; setNewData(campaign); + setWindows(seedWindows(campaign)); }, [campaign]); - const getChanges = (): Partial => { - if (!newData || !campaign) return {}; - return { - ...(newData.start_date !== campaign.start_date && { start_date: newData.start_date }), - ...(newData.end_date !== campaign.end_date && { end_date: newData.end_date }), - ...(newData.timezone !== campaign.timezone && { timezone: newData.timezone }), - ...(newData.days !== campaign.days && { days: newData.days }), - ...(newData.start_time !== campaign.start_time && { start_time: newData.start_time }), - ...(newData.end_time !== campaign.end_time && { end_time: newData.end_time }), - }; - }; + const windowsChanged = React.useMemo( + () => JSON.stringify(windows) !== JSON.stringify(baseline), + [windows, baseline], + ); + + const fieldChanges = (): Partial => ({ + ...(newData.start_date !== campaign.start_date && { start_date: newData.start_date }), + ...(newData.end_date !== campaign.end_date && { end_date: newData.end_date }), + ...(newData.timezone !== campaign.timezone && { timezone: newData.timezone }), + }); + + const hasChanges = windowsChanged || Object.keys(fieldChanges()).length > 0; + + const totalWindows = windows.reduce((s, d) => s + d.length, 0); + const activeDays = windows.filter((d) => d.length > 0).length; async function submit() { - if (loading || !campaign) return; - if (newData.days === 0) { - toast.error("Pick at least one active day."); + if (loading) return; + if (totalWindows === 0) { + toast.error("Add at least one sending window."); return; } + const patch: Partial = { + ...fieldChanges(), + ...(windowsChanged && { schedule_windows: displayToWire(windows) }), + }; try { setLoading(true); - await toast.promise(updateCampaign.mutateAsync(getChanges()), { + await toast.promise(updateCampaign.mutateAsync(patch), { loading: "Saving…", - success: "Campaign successfully updated.", + success: "Schedule updated.", error: (err: AppError) => buildError(err), }); } finally { @@ -94,114 +127,152 @@ export default function CampaignSchedule() { } } - if (!campaign || !newData) return null; + const tzLabel = u.timezones.find((tz) => tz.name === newData.timezone)?.display_name ?? newData.timezone; - const hasChanges = Object.keys(getChanges()).length > 0; - const tzLabel = - u.timezones.find((tz) => tz.name === newData.timezone)?.display_name ?? newData.timezone; + const startDate = newData.start_date instanceof Date ? newData.start_date : null; + const endDate = newData.end_date instanceof Date ? newData.end_date : null; + const durationLabel = + startDate && endDate ? `${differenceInCalendarDays(endDate, startDate)} days` : "Open-ended"; + const datesHint = startDate + ? endDate + ? `${format(startDate, "MMM d")} – ${format(endDate, "MMM d, yyyy")}` + : `from ${format(startDate, "MMM d, yyyy")} onward` + : endDate + ? `until ${format(endDate, "MMM d, yyyy")}` + : "Runs continuously while active"; return ( -
-
- {/* Timezone */} -
- - - - } - label={tzLabel} - className="w-full max-w-[340px] justify-between" - /> - - - {u.timezones.map((tz) => ( - - setNewData((bef) => ({ ...bef, timezone: tz.name })) - } - > - {tz.display_name} - - ))} - - -

- Sends are scheduled in this zone. Worker IPs spread distribution naturally. -

-
- -
- - {/* Active days */} -
- - setNewData((bef) => ({ ...bef, days: v }))} - /> -

- Pick at least one day. Defaults to Monday–Friday. -

-
- -
- - {/* Sending window */} -
- -
-
- Start time - setNewData((bef) => ({ ...bef, start_time: v }))} - /> +
+ {/* Hero scheduler card */} +
+
+
+ + + +
+
+ Weekly sending windows
-
- End time - setNewData((bef) => ({ ...bef, end_time: v }))} - /> + {totalWindows === 0 ? ( +

+ No windows yet — drag on a day to add one. +

+ ) : ( +

+ {totalWindows} window + {totalWindows === 1 ? "" : "s"} across{" "} + {activeDays} day + {activeDays === 1 ? "" : "s"} +

+ )}
+
+ + + } + label={tzLabel} + className="w-full justify-between sm:w-auto sm:max-w-[230px]" + /> + + + {u.timezones.map((tz) => ( + setNewData((bef) => ({ ...bef, timezone: tz.name }))} + > + {tz.display_name} + + ))} + + +
-
+ {/* presets */} +
+ + Presets + + {PRESETS.map((p) => ( + + ))} + + Drag to add · drag a block to move · edges to resize · ⧉ copies a day to all + +
- {/* Run window */} -
- -
+ {/* the visual week grid (scrolls horizontally on narrow screens + so the 7 day columns stay tappable) */} +
+
+ +
+

+ Each day is independent — set different windows per day, or several windows in one day. Sends + are scheduled in {tzLabel}; worker IPs spread distribution naturally. +

+
+
+ + {/* Run dates */} +
+
+ + + Campaign dates + + + {datesHint} + + {durationLabel} + + +
+
+
setNewData((bef) => ({ ...bef, start_date: v }))} + onChange={(v) => setNewData((b) => ({ ...b, start_date: v }))} /> +
+ +
setNewData((bef) => ({ ...bef, end_date: v }))} + onChange={(v) => setNewData((b) => ({ ...b, end_date: v }))} />
-

- Optional bounds for when the campaign may send. Leave blank to run open-ended. +

+ Optional bounds for when the campaign may send. Leave both blank to run open-ended.

-
+ + {/* Save / Reset */}
diff --git a/web/src/components/app/Calendar.tsx b/web/src/components/app/Calendar.tsx index c26b4d3e..f6d5ad57 100644 --- a/web/src/components/app/Calendar.tsx +++ b/web/src/components/app/Calendar.tsx @@ -1,8 +1,20 @@ import { AnimatePresence, motion } from "framer-motion"; import React from "react"; import { createPortal } from "react-dom"; -import { format, addMonths, subMonths, startOfMonth, endOfMonth, startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, isSameDay, isToday } from 'date-fns'; -import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; export default function Calendar({ date, @@ -26,8 +38,8 @@ export default function Calendar({ const panelRef = React.useRef(null); const [pos, setPos] = React.useState<{ top: number; left: number } | null>(null); - const handleDateSelect = (date: Date) => { - onSubmit(date); + const handleDateSelect = (d: Date) => { + onSubmit(d); close(); }; @@ -38,9 +50,7 @@ export default function Calendar({ const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); - const days = eachDayOfInterval({ start: calendarStart, end: calendarEnd }); - - return days; + return eachDayOfInterval({ start: calendarStart, end: calendarEnd }); }; // Position the portaled panel fixed at the trigger rect, flipping above the @@ -57,7 +67,7 @@ export default function Calendar({ const r = trigger.getBoundingClientRect(); const sideOffset = 8; const ch = panel?.offsetHeight ?? 0; - const cw = panel?.offsetWidth ?? 320; // w-80 + const cw = panel?.offsetWidth ?? 264; let top = r.bottom + sideOffset; if (ch && top + ch > window.innerHeight - 8) { const above = r.top - ch - sideOffset; @@ -105,10 +115,10 @@ export default function Calendar({ {active && ( -
-
-
setCurrentMonth(subMonths(currentMonth, 1))} - className="p-1 ripple cursor-pointer hover:bg-gray-100 rounded-lg transition-colors" - > - -
+ {/* month header */} +
+ + + {format(currentMonth, "MMMM yyyy")} + + +
- - {format(currentMonth, 'yyyy. MMMM')} - - -
setCurrentMonth(addMonths(currentMonth, 1))} - className="p-1 ripple cursor-pointer hover:bg-gray-100 rounded-lg transition-colors" - > - -
+
+ {/* weekday labels */} +
+ {["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].map((day) => ( +
+ {day} +
+ ))}
-
-
- {['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map(day => ( -
- {day} -
- ))} -
+ {/* day grid */} +
+ {calendarDays.map((day) => { + const isSelected = date && isSameDay(day, date); + const isCurrentDay = isToday(day); + const isCurrentMonth = isSameMonth(day, currentMonth); -
- {calendarDays.map((day) => { - const isSelected = date && isSameDay(day, date); - const isCurrentDay = isToday(day); - const isCurrentMonth = isSameMonth(day, currentMonth); - - return ( -
{ - handleDateSelect(day) - if (!isCurrentMonth) { - setCurrentMonth(day) - } - }} - className={` - relative cursor-pointer ripple w-10 h-10 flex items-center justify-center text-sm rounded-lg transition-[background] - ${isSelected - ? 'bg-blue-500 text-white shadow-md' - : isCurrentDay - ? 'bg-blue-50 text-blue-600 font-semibold' - : isCurrentMonth - ? 'text-gray-700 hover:bg-gray-100' - : 'text-gray-200 hover:bg-gray-50' - } - `} - > - {format(day, 'd')} -
- ); - })} -
+ return ( + + ); + })}
+ + {/* footer: clear + today */} +
+ + +
)} , document.body, )} - ) + ); } diff --git a/web/src/components/app/campaigns/schedule/WeekScheduleGrid.tsx b/web/src/components/app/campaigns/schedule/WeekScheduleGrid.tsx new file mode 100644 index 00000000..501132af --- /dev/null +++ b/web/src/components/app/campaigns/schedule/WeekScheduleGrid.tsx @@ -0,0 +1,346 @@ +// Calendar-style weekly sending editor with FULL per-day flexibility. Each +// Mon–Sun column owns an independent list of sending windows: +// • drag on empty space in a column to draw a new window +// • drag a window's body to move it, or its top/bottom edge to resize +// • × removes a window; the ⧉ in a day header copies that day to every day +// Everything snaps to 30-min steps. Windows here are in DISPLAY order (index +// 0 = Monday); the page converts to/from the Sun=0 wire format. + +import React from "react"; +import { CopyIcon, XIcon } from "lucide-react"; + +export interface Interval { + start: number; // minutes since midnight [0,1440) + end: number; // minutes since midnight (start,1440] +} + +const ABBR = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +const HOURS = [0, 3, 6, 9, 12, 15, 18, 21, 24]; +const BODY_H = 420; +const GUTTER = 46; +const SNAP = 30; +const MIN_DRAW = 60; +const DAY = 1440; + +const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); +const snap = (n: number) => Math.round(n / SNAP) * SNAP; +const pct = (min: number) => `${(min / DAY) * 100}%`; +const hourLabel = (h: number) => { + const hh = h % 24; + const ampm = hh < 12 ? "a" : "p"; + const h12 = hh % 12 === 0 ? 12 : hh % 12; + return `${h12}${ampm}`; +}; +const fmt = (min: number) => { + const h = Math.floor(min / 60); + const m = min % 60; + const ampm = h < 12 ? "am" : "pm"; + const h12 = h % 12 === 0 ? 12 : h % 12; + return m === 0 ? `${h12}${ampm}` : `${h12}:${String(m).padStart(2, "0")}${ampm}`; +}; + +function mergeIntervals(ivs: Interval[]): Interval[] { + const sorted = ivs.filter((iv) => iv.end > iv.start).sort((a, b) => a.start - b.start); + const out: Interval[] = []; + for (const iv of sorted) { + const last = out[out.length - 1]; + if (last && iv.start <= last.end) last.end = Math.max(last.end, iv.end); + else out.push({ ...iv }); + } + return out; +} + +type DragMode = "move" | "start" | "end" | "draw"; +interface DragState { + day: number; + idx: number; + mode: DragMode; + startY: number; + anchorMin: number; // draw: the fixed edge + origStart: number; + origEnd: number; + rectTop: number; + rectH: number; +} + +export default function WeekScheduleGrid({ + windows, + onChange, +}: { + windows: Interval[][]; // length 7, display order (Mon=0) + onChange: (next: Interval[][]) => void; +}) { + const todayIdx = (new Date().getDay() + 6) % 7; // Mon=0..Sun=6 + const [drag, setDrag] = React.useState(null); + + // Refs keep the move/up listeners reading the freshest state without + // re-subscribing mid-gesture. + const winRef = React.useRef(windows); + winRef.current = windows; + const changeRef = React.useRef(onChange); + changeRef.current = onChange; + + const setDay = (day: number, next: Interval[]) => + changeRef.current(winRef.current.map((d, i) => (i === day ? next : d))); + const setInterval = (day: number, idx: number, iv: Interval) => + setDay( + day, + winRef.current[day].map((x, i) => (i === idx ? iv : x)), + ); + + React.useEffect(() => { + if (!drag) return; + const yToMin = (clientY: number) => clamp(((clientY - drag.rectTop) / drag.rectH) * DAY, 0, DAY); + const move = (e: PointerEvent) => { + if (drag.mode === "draw") { + const cur = snap(yToMin(e.clientY)); + const s = clamp(Math.min(drag.anchorMin, cur), 0, DAY - SNAP); + let en = clamp(Math.max(drag.anchorMin, cur), s + SNAP, DAY); + if (en - s < SNAP) en = s + SNAP; + setInterval(drag.day, drag.idx, { start: s, end: en }); + return; + } + const deltaMin = ((e.clientY - drag.startY) / drag.rectH) * DAY; + const dur = drag.origEnd - drag.origStart; + if (drag.mode === "move") { + const s = clamp(snap(drag.origStart + deltaMin), 0, DAY - dur); + setInterval(drag.day, drag.idx, { start: s, end: s + dur }); + } else if (drag.mode === "start") { + const s = clamp(snap(drag.origStart + deltaMin), 0, drag.origEnd - SNAP); + setInterval(drag.day, drag.idx, { start: s, end: drag.origEnd }); + } else { + const en = clamp(snap(drag.origEnd + deltaMin), drag.origStart + SNAP, DAY); + setInterval(drag.day, drag.idx, { start: drag.origStart, end: en }); + } + }; + const up = () => { + let dayIvs = winRef.current[drag.day]; + if (drag.mode === "draw") { + const iv = dayIvs[drag.idx]; + if (iv && iv.end - iv.start < MIN_DRAW) { + let s = iv.start; + const en = Math.min(iv.start + MIN_DRAW, DAY); + if (en - s < MIN_DRAW) s = en - MIN_DRAW; + dayIvs = dayIvs.map((x, i) => (i === drag.idx ? { start: s, end: en } : x)); + } + } + setDay(drag.day, mergeIntervals(dayIvs)); + setDrag(null); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + const prevCursor = document.body.style.cursor; + document.body.style.cursor = drag.mode === "move" ? "grabbing" : "ns-resize"; + document.body.style.userSelect = "none"; + return () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + document.body.style.cursor = prevCursor; + document.body.style.userSelect = ""; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [drag]); + + const beginBlockDrag = (e: React.PointerEvent, day: number, idx: number, mode: DragMode) => { + e.preventDefault(); + e.stopPropagation(); + const col = e.currentTarget.closest("[data-col-body]") as HTMLElement | null; + const rect = (col ?? (e.currentTarget as HTMLElement)).getBoundingClientRect(); + const iv = windows[day][idx]; + setDrag({ + day, + idx, + mode, + startY: e.clientY, + anchorMin: 0, + origStart: iv.start, + origEnd: iv.end, + rectTop: rect.top, + rectH: rect.height, + }); + }; + + const beginDraw = (e: React.PointerEvent, day: number) => { + // Only the empty column surface starts a draw (blocks stopPropagation). + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const anchor = clamp(snap(((e.clientY - rect.top) / rect.height) * DAY), 0, DAY - SNAP); + const newIv: Interval = { start: anchor, end: anchor + SNAP }; + const idx = winRef.current[day].length; + setDay(day, [...winRef.current[day], newIv]); + setDrag({ + day, + idx, + mode: "draw", + startY: e.clientY, + anchorMin: anchor, + origStart: anchor, + origEnd: anchor + SNAP, + rectTop: rect.top, + rectH: rect.height, + }); + }; + + const removeInterval = (day: number, idx: number) => + setDay(day, windows[day].filter((_, i) => i !== idx)); + const copyToAll = (day: number) => { + const src = windows[day].map((iv) => ({ ...iv })); + onChange(windows.map(() => src.map((iv) => ({ ...iv })))); + }; + + return ( +
+ {/* day headers */} +
+
+
+ {ABBR.map((d, i) => { + const active = windows[i].length > 0; + return ( +
+ {d} + {i === todayIdx && ( + + )} + {active && ( + + )} +
+ ); + })} +
+
+ + {/* grid body */} +
+ {HOURS.map((h) => ( + + {hourLabel(h)} + + ))} + {HOURS.map((h) => ( +
+ ))} + +
+ {ABBR.map((d, i) => { + const active = windows[i].length > 0; + const isToday = i === todayIdx; + return ( +
beginDraw(e, i)} + className={`relative flex-1 cursor-crosshair touch-pan-x ${ + active ? "bg-sky-50/30" : "bg-slate-50/40 hover:bg-slate-100/50" + } ${isToday ? "ring-1 ring-inset ring-sky-100" : ""}`} + > + {windows[i].length === 0 && ( + + drag to add + + )} + {windows[i].map((iv, idx) => ( + beginBlockDrag(e, i, idx, "move")} + onTop={(e) => beginBlockDrag(e, i, idx, "start")} + onBottom={(e) => beginBlockDrag(e, i, idx, "end")} + onRemove={() => removeInterval(i, idx)} + /> + ))} +
+ ); + })} +
+
+
+ ); +} + +function Block({ + iv, + dragging, + onBody, + onTop, + onBottom, + onRemove, +}: { + iv: Interval; + dragging: boolean; + onBody: (e: React.PointerEvent) => void; + onTop: (e: React.PointerEvent) => void; + onBottom: (e: React.PointerEvent) => void; + onRemove: () => void; +}) { + const tall = iv.end - iv.start >= 90; + return ( +
+
{ + e.stopPropagation(); + onTop(e); + }} + className="absolute -top-1 inset-x-0 h-3 cursor-ns-resize touch-pan-x" + > +
+
+
{ + e.stopPropagation(); + onBottom(e); + }} + className="absolute -bottom-1 inset-x-0 h-3 cursor-ns-resize touch-pan-x" + > +
+
+ + + {fmt(iv.start)} + {tall ?
: "–"} + {fmt(iv.end)} +
+
+ ); +} diff --git a/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx b/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx index 6cde3b2f..dea4849a 100644 --- a/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx +++ b/web/src/components/app/campaigns/schedule/WeekdayBitmask.tsx @@ -1,6 +1,8 @@ -// Active-days picker — a grid of 7 day pills backed by a uint8 day-of-week -// bitmask (bit i = weekday i). On-theme: sky-600 active, slate idle, h-7 -// rounded-md pills. Mirrors the create wizard's ScheduleStep. +// Active-days picker — a roomier grid of 7 day cells backed by a uint8 +// day-of-week bitmask (bit i = weekday i, 0=Mon..6=Sun). On-theme: sky +// active, slate idle, h-12 rounded-md cells with a 3-letter label over a +// status dot. Logic is identical to the bitmask contract — only the cell +// presentation changed. export default function WeekdayBitmask({ weekdays, @@ -23,13 +25,18 @@ export default function WeekdayBitmask({ aria-pressed={active} title={day} onClick={() => setValue(value ^ mask)} - className={`h-7 rounded-md text-[11.5px] font-medium transition-colors ${ + className={`h-12 rounded-md border flex flex-col items-center justify-center transition-colors ${ active - ? "bg-sky-600 text-white" - : "bg-slate-50 text-slate-500 hover:bg-slate-100 hover:text-slate-700" + ? "border-sky-500 bg-sky-50 text-sky-700" + : "border-slate-200 bg-white text-slate-500 hover:border-slate-300" }`} > - {day.slice(0, 3)} + {day.slice(0, 3)} + ); })} diff --git a/web/src/lib/api/models/app/campaigns/Campaign.ts b/web/src/lib/api/models/app/campaigns/Campaign.ts index 5179a95e..9df27c4b 100644 --- a/web/src/lib/api/models/app/campaigns/Campaign.ts +++ b/web/src/lib/api/models/app/campaigns/Campaign.ts @@ -25,6 +25,11 @@ export default interface Campaign { start_time: string; end_time: string; + // Per-day sending windows. 7 elements indexed by weekday (0=Sunday..6=Saturday, + // matching the backend's time.Weekday); each day holds zero or more + // minute-of-day intervals. When present it supersedes days/start_time/end_time. + schedule_windows?: ScheduleInterval[][] | null; + email_tags: string[]; contact_order_by: 'created_at' | 'email' | 'name' | 'custom_field' | 'manual'; @@ -66,6 +71,12 @@ export default interface Campaign { sequences: Sequence[] | null; } +// One sending window within a day, in minutes since local midnight (end > start). +export interface ScheduleInterval { + start: number; + end: number; +} + export interface CampaignSender { email_account_id: string; weight: number; From f72952803e423a5da47fb7e1c661d23b655610b6 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 18:57:02 +0200 Subject: [PATCH 22/60] feat: add explicit campaign sender picker Load campaign senders for explicit sender strategy and persist the selected mailbox pool through the campaign sender endpoint. Add a reusable account selector in campaign preferences. --- .../app/campaigns/[id]/preferences/page.tsx | 76 +++++++- .../campaigns/preferences/AccountSelector.tsx | 181 ++++++++++++++++++ .../preferences/CampaignAppearance.tsx | 64 +++++-- .../campaigns/preferences/CampaignEmails.tsx | 27 +-- 4 files changed, 298 insertions(+), 50 deletions(-) create mode 100644 web/src/components/app/campaigns/preferences/AccountSelector.tsx diff --git a/web/src/app/app/campaigns/[id]/preferences/page.tsx b/web/src/app/app/campaigns/[id]/preferences/page.tsx index f9907ede..b97daae9 100644 --- a/web/src/app/app/campaigns/[id]/preferences/page.tsx +++ b/web/src/app/app/campaigns/[id]/preferences/page.tsx @@ -10,6 +10,8 @@ import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import CampaignEmails from "@/components/app/campaigns/preferences/CampaignEmails"; import CampaignContactOrder from "@/components/app/campaigns/preferences/CampaignContactOrder"; +import useCampaignSenders from "@/lib/api/hooks/app/campaigns/useCampaignSenders"; +import useReplaceCampaignSenders from "@/lib/api/hooks/app/campaigns/useReplaceCampaignSenders"; const DAILY_MIN = 3; const DAILY_MAX = 100; @@ -29,16 +31,36 @@ export default function CampaignPreferences() { } const updateCampaign = useUpdateCampaign(campaign.id); + const replaceSenders = useReplaceCampaignSenders(campaign.id); + + // The explicit sender pool is managed by the senders endpoint (not PATCH). + // Only fetch it when the campaign is actually on the explicit strategy. + const { data: senders } = useCampaignSenders( + campaign.id, + campaign.sender_strategy === "explicit", + ); const [loading, setLoading] = React.useState(false); const [activeTab, setActiveTab] = React.useState("standard"); const [newData, setNewData] = React.useState(campaign); + // Selected mailbox ids for the "Specific accounts" mode + the saved baseline + // we diff against. Seeded from the campaign's current senders. + const [explicitAccounts, setExplicitAccounts] = React.useState([]); + const [savedAccounts, setSavedAccounts] = React.useState([]); + React.useEffect(() => { if (!campaign) return; setNewData(campaign); }, [campaign]); + React.useEffect(() => { + if (!senders) return; + const ids = senders.map((s) => s.email_account_id); + setExplicitAccounts(ids); + setSavedAccounts(ids); + }, [senders]); + const getChanges = (): Partial => { if (!newData) return {}; return { @@ -100,6 +122,15 @@ export default function CampaignPreferences() { }; }; + // Whether the explicit sender pool changed. Only meaningful while the + // campaign is (or is being switched to) the explicit strategy. + const accountsDirty = React.useMemo(() => { + if (newData.sender_strategy !== "explicit") return false; + if (explicitAccounts.length !== savedAccounts.length) return true; + const a = new Set(savedAccounts); + return explicitAccounts.some((id) => !a.has(id)); + }, [newData.sender_strategy, explicitAccounts, savedAccounts]); + const validationError = (): string | null => { if (newData.daily_limit < DAILY_MIN || newData.daily_limit > DAILY_MAX) { return `Daily limit must be between ${DAILY_MIN} and ${DAILY_MAX}.`; @@ -107,6 +138,9 @@ export default function CampaignPreferences() { if (newData.ramp_enabled && newData.ramp_start > newData.ramp_ceiling) { return "Ramp start must be less than or equal to the ramp ceiling."; } + if (newData.sender_strategy === "explicit" && explicitAccounts.length === 0) { + return "Pick at least one sending account, or switch back to selecting by tag."; + } return null; }; @@ -120,11 +154,28 @@ export default function CampaignPreferences() { try { setLoading(true); const data = getChanges(); - await toast.promise(updateCampaign.mutateAsync(data), { - loading: "Saving…", - success: "Campaign successfully updated.", - error: (e: AppError) => buildError(e), - }); + // Persist the explicit sender pool through its own endpoint (it is + // not part of the campaign PATCH body). Map each picked mailbox to a + // CampaignSender with weight 1 so volume splits evenly. + const writeSenders = newData.sender_strategy === "explicit" && accountsDirty; + await toast.promise( + (async () => { + if (Object.keys(data).length > 0) { + await updateCampaign.mutateAsync(data); + } + if (writeSenders) { + await replaceSenders.mutateAsync( + explicitAccounts.map((id) => ({ email_account_id: id, weight: 1 })), + ); + setSavedAccounts(explicitAccounts); + } + })(), + { + loading: "Saving…", + success: "Campaign successfully updated.", + error: (e: AppError) => buildError(e), + }, + ); } finally { setLoading(false); } @@ -132,7 +183,13 @@ export default function CampaignPreferences() { const tabContent: Record = { standard: ( - + ), advanced: ( @@ -142,7 +199,7 @@ export default function CampaignPreferences() { ), }; - const hasChanges = Object.keys(getChanges()).length > 0; + const hasChanges = Object.keys(getChanges()).length > 0 || accountsDirty; const blocked = validationError() !== null; return ( @@ -179,7 +236,10 @@ export default function CampaignPreferences() { )} diff --git a/web/src/components/app/campaigns/preferences/AccountSelector.tsx b/web/src/components/app/campaigns/preferences/AccountSelector.tsx new file mode 100644 index 00000000..21d296e2 --- /dev/null +++ b/web/src/components/app/campaigns/preferences/AccountSelector.tsx @@ -0,0 +1,181 @@ +// AccountSelector — specific-mailbox multi-select for the explicit sender +// strategy. Mirrors the contacts CategoryPicker / mailbox TagSelector visual +// language (bordered chip box + framer-motion dropdown with a search header + +// checkbox-square rows), so account, tag and category pickers look identical +// across the app. +// +// value is a string[] of email_account_ids; onChange returns the next list. +// Mailboxes are fed by useEmails with an empty query/tag and a large limit so +// the whole pool is selectable from one dropdown. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, PlusIcon, XIcon, MailIcon } from "lucide-react"; +import useClickOutside from "@/hooks/useClickOutside"; +import useFlipPlacement from "@/hooks/useFlipPlacement"; +import useEmails from "@/lib/api/hooks/app/emails/useEmails"; + +// A health dot color from the mailbox status, mirroring InboxDetails.statusTone. +function healthDot(status: string): string { + const s = status?.toLowerCase(); + if (s === "active" || s === "healthy") return "bg-emerald-500"; + if (s === "warming" || s === "warning") return "bg-amber-500"; + if (s === "revoked" || s === "error" || s === "inactive") return "bg-rose-500"; + return "bg-slate-300"; +} + +export default function AccountSelector({ + value, + onChange, +}: { + value: string[]; + onChange: (next: string[]) => void; +}) { + // Pull the whole pool in one shot (empty query/tag, large limit). + const { emails, isLoading } = useEmails({ query: "", tag: "", limit: 200 }); + + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const ref = React.useRef(null); + const triggerRef = React.useRef(null); + useClickOutside(ref, () => setOpen(false)); + const placement = useFlipPlacement(triggerRef, open, 270); + + const byId = React.useMemo(() => { + const m = new Map(); + for (const e of emails) m.set(e.id, e); + return m; + }, [emails]); + + // Selected chips, even if the mailbox isn't in the current page yet keep the + // id so a stale/unknown selection still renders and can be removed. + const selectedChips = value.map((id) => ({ id, inbox: byId.get(id) })); + + const filtered = React.useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return emails; + return emails.filter( + (e) => + e.email.toLowerCase().includes(q) || + (e.name ?? "").toLowerCase().includes(q) || + (e.provider ?? "").toLowerCase().includes(q), + ); + }, [emails, query]); + + function toggle(id: string) { + if (value.includes(id)) onChange(value.filter((v) => v !== id)); + else onChange([...value, id]); + } + + return ( +
+
+ {selectedChips.length === 0 ? ( +
setOpen((o) => !o)} + className="px-3 py-2 text-[11.5px] text-slate-400 cursor-pointer hover:text-slate-600" + > + Click to add specific accounts… +
+ ) : ( +
+ {selectedChips.map(({ id, inbox }) => ( + + + {inbox?.email ?? id} + + + ))} + +
+ )} +
+ + + {open && ( + +
+ setQuery(e.target.value)} + placeholder="Search accounts…" + autoFocus + className="w-full h-5 bg-transparent text-[12px] text-slate-900 placeholder:text-slate-400 outline-none" + /> +
+
+ {isLoading && ( +
Loading…
+ )} + {!isLoading && filtered.length === 0 && ( +
+ {emails.length === 0 ? "No mailboxes connected yet." : "No matches."} +
+ )} + {filtered.map((e) => { + const checked = value.includes(e.id); + return ( + + ); + })} +
+
+ + {value.length} selected +
+
+ )} +
+
+ ); +} diff --git a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx index c4e173aa..24f8e6ad 100644 --- a/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignAppearance.tsx @@ -5,7 +5,8 @@ import type Campaign from "@/lib/api/models/app/campaigns/Campaign"; import { Label, NumberInput, TextInput } from "@/components/ui/field"; import TagSelector from "../../popup/select/TagSelector"; -import { SettingRow, Toggle } from "./components/CampaignPreferenceBoolBox"; +import AccountSelector from "./AccountSelector"; +import { Segmented, SettingRow, Toggle } from "./components/CampaignPreferenceBoolBox"; const DAILY_MIN = 3; const DAILY_MAX = 100; @@ -14,10 +15,16 @@ export default function CampaignAppearance({ campaign, newCampaign, setNewCampaign, + explicitAccounts, + setExplicitAccounts, }: { campaign: Campaign; newCampaign: Campaign; setNewCampaign: React.Dispatch>; + // Explicit-strategy sender selection. Lives on the page (persisted via the + // senders endpoint, not PATCH) and is seeded from the campaign's current pool. + explicitAccounts: string[]; + setExplicitAccounts: React.Dispatch>; }) { const dailyInvalid = newCampaign.daily_limit < DAILY_MIN || newCampaign.daily_limit > DAILY_MAX; @@ -50,22 +57,45 @@ export default function CampaignAppearance({ {/* Sending */}
- - - setNewCampaign((bef) => ({ ...bef, email_tags: [...bef.email_tags, t] })) - } - onRemove={(t) => - setNewCampaign((bef) => ({ - ...bef, - email_tags: bef.email_tags.filter((e) => e !== t), - })) - } - /> -

- Volume is split across every mailbox in these tags to protect deliverability. -

+
+ + + setNewCampaign((bef) => ({ ...bef, sender_strategy: v })) + } + options={[ + { value: "tags", label: "By tag" }, + { value: "explicit", label: "Specific accounts" }, + ]} + /> +
+ {newCampaign.sender_strategy === "tags" ? ( + <> + + setNewCampaign((bef) => ({ ...bef, email_tags: [...bef.email_tags, t] })) + } + onRemove={(t) => + setNewCampaign((bef) => ({ + ...bef, + email_tags: bef.email_tags.filter((e) => e !== t), + })) + } + /> +

+ Volume is split across every mailbox in these tags to protect deliverability. +

+ + ) : ( + <> + +

+ Send only from the mailboxes you pick here. Volume is split evenly across them. +

+ + )}
diff --git a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx index f0743b75..eda7f83a 100644 --- a/web/src/components/app/campaigns/preferences/CampaignEmails.tsx +++ b/web/src/components/app/campaigns/preferences/CampaignEmails.tsx @@ -5,7 +5,6 @@ import { CheckCircle2Icon, - InfoIcon, AlertCircleIcon, Loader2Icon, } from "lucide-react"; @@ -33,31 +32,9 @@ export default function CampaignEmails({ return (
- {/* Sender selection */} + {/* Rotation — sender selection (tags vs specific accounts) now lives in + the Standard tab next to the sending-accounts picker. */}
- setNewCampaign((bef) => ({ ...bef, sender_strategy: v }))} - options={[ - { value: "tags", label: "Tags" }, - { value: "explicit", label: "Explicit" }, - ]} - /> - } - /> - {newCampaign.sender_strategy === "explicit" && ( -
- -

- Manage the explicit mailbox pool from the senders API. The in-app picker - is coming soon — until then the campaign keeps its current pinned senders. -

-
- )} Date: Thu, 4 Jun 2026 18:57:08 +0200 Subject: [PATCH 23/60] feat: add analytics share preview Add preview sizing, copy-to-clipboard, and improved PNG rendering for analytics share cards. Refresh the exported share card visuals and keep chart bars constrained for compact layouts. --- .../app/analytics/AnalyticsShareButton.tsx | 218 ++++++++++++- .../app/analytics/StatsShareCard.tsx | 300 +++++++++++++++--- web/src/components/ui/charts.tsx | 7 +- web/src/hooks/useExportCard.ts | 47 ++- 4 files changed, 499 insertions(+), 73 deletions(-) diff --git a/web/src/components/app/analytics/AnalyticsShareButton.tsx b/web/src/components/app/analytics/AnalyticsShareButton.tsx index eab5299e..e7d2402c 100644 --- a/web/src/components/app/analytics/AnalyticsShareButton.tsx +++ b/web/src/components/app/analytics/AnalyticsShareButton.tsx @@ -1,11 +1,30 @@ -import { useRef } from "react"; -import { DownloadIcon, Loader2Icon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { CheckIcon, CopyIcon, DownloadIcon, ImageIcon, Loader2Icon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; import useExportCard from "@/hooks/useExportCard"; -import StatsShareCard, { type ShareCardData } from "./StatsShareCard"; +import StatsShareCard, { type ShareAspect, type ShareCardData } from "./StatsShareCard"; // Drops a "Share image" button next to any analytics surface. It keeps a // branded mounted off-viewport (real layout, so the capture -// isn't blank) and rasterizes it to a downloadable PNG on click. +// isn't blank) and, on click, opens a preview modal that rasterizes the card to +// a PNG. The user picks an aspect preset (1:1 / 3:2 / 16:9) and previews it +// before choosing to download or copy. + +const ASPECTS: { value: ShareAspect; label: string; ratio: string; suffix: string }[] = [ + { value: "1:1", label: "1:1", ratio: "1 / 1", suffix: "1x1" }, + { value: "3:2", label: "3:2", ratio: "3 / 2", suffix: "3x2" }, + { value: "16:9", label: "16:9", ratio: "16 / 9", suffix: "16x9" }, +]; + +// Apply a preset suffix before the ".png" extension (e.g. "warmbly-x.png" -> +// "warmbly-x-16x9.png"). Falls back to appending if there's no .png. +function withPresetSuffix(filename: string, suffix: string): string { + const i = filename.toLowerCase().lastIndexOf(".png"); + if (i === -1) return `${filename}-${suffix}.png`; + return `${filename.slice(0, i)}-${suffix}${filename.slice(i)}`; +} + export default function AnalyticsShareButton({ data, filename, @@ -16,27 +35,196 @@ export default function AnalyticsShareButton({ label?: string; }) { const ref = useRef(null); - const { exporting, exportPng } = useExportCard(); + const { renderPng, downloadPng } = useExportCard(); + const [open, setOpen] = useState(false); + const [aspect, setAspect] = useState("1:1"); + const [url, setUrl] = useState(null); + const [copied, setCopied] = useState(false); + + // Render the PNG when the preview opens AND whenever the aspect changes; + // reset when it closes. The capture is heavy, so defer a frame to let the + // off-viewport card relayout for the new aspect first. Square stays crisp at + // pixelRatio 3; wider presets drop to 2 so the (much larger) capture is + // snappy. + useEffect(() => { + if (!open) { + setUrl(null); + setCopied(false); + return; + } + let alive = true; + setUrl(null); // show the spinner while re-capturing for the new aspect + // Double rAF: the off-viewport card re-lays-out for the new aspect on + // the next frame; capturing in a second frame guarantees we rasterize + // the committed-and-painted new size, not the previous one. + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(async () => { + const out = await renderPng(ref.current, { pixelRatio: aspect === "1:1" ? 3 : 2 }); + if (alive) setUrl(out); + }); + }); + return () => { + alive = false; + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + }, [open, aspect, renderPng]); + + // Esc closes the preview. + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open]); + + async function copy() { + if (!url) return; + try { + // Pass a Promise to ClipboardItem rather than awaiting the blob + // first: browsers (especially Safari) require clipboard.write() to run + // within the click's user-gesture, and an `await fetch(...)` beforehand + // forfeits it → NotAllowedError. The Promise form keeps the gesture. + const item = new ClipboardItem({ "image/png": fetch(url).then((r) => r.blob()) }); + await navigator.clipboard.write([item]); + setCopied(true); + toast.success("Image copied to clipboard"); + setTimeout(() => setCopied(false), 1500); + } catch { + toast.error("Couldn't copy — download instead"); + } + } + + const current = ASPECTS.find((a) => a.value === aspect) ?? ASPECTS[0]; return ( <> + + {open && ( + setOpen(false)} + > + e.stopPropagation()} + className="w-full max-w-[min(94vw,860px)] rounded-lg bg-white border border-slate-200 shadow-[0_24px_48px_-12px_rgba(15,23,42,0.18),0_8px_16px_-8px_rgba(15,23,42,0.1)] overflow-hidden" + > +
+ + Share image + +
+ + Preview before download + + +
+ + {/* aspect preset selector (house theme segmented control) */} +
+ Aspect +
+ {ASPECTS.map((a) => { + const active = a.value === aspect; + return ( + + ); + })} +
+
+ +
+
+ {url ? ( + Analytics share preview + ) : ( +
+ + Rendering… +
+ )} +
+
+ +
+ + +
+ + + )} + + {/* Off-viewport but laid out, so html-to-image captures real pixels. */}
- +
); diff --git a/web/src/components/app/analytics/StatsShareCard.tsx b/web/src/components/app/analytics/StatsShareCard.tsx index 60037bed..1c9957c0 100644 --- a/web/src/components/app/analytics/StatsShareCard.tsx +++ b/web/src/components/app/analytics/StatsShareCard.tsx @@ -1,11 +1,20 @@ import React from "react"; import { Logo } from "@/components/svg"; -import { DailyBars, type ChartPoint } from "@/components/ui/charts"; +import { type ChartPoint } from "@/components/ui/charts"; -// A fixed-size, branded analytics card meant to be rasterized to a shareable -// PNG (see useExportCard). It mirrors the live dashboard's slate/sky language -// so the export looks like Warmbly, not a generic screenshot. Rendered -// off-viewport by AnalyticsShareButton. +// A branded analytics card rasterized to a shareable PNG (see useExportCard). +// Design: the marketing-site hero sky (deep-blue .sky-base + the real cloud +// WebP images), the Warmbly logo white on the sky, and ONE clean white panel +// floating on it holding the title, a divided metric row, and a long +// Instantly-style area chart. One soft shadow — no scattered heavy-shadow +// boxes. +// +// Capture-safe for html-to-image (SVG foreignObject): +// - sky / glow / haze are pure CSS gradients set inline. +// - clouds are SAME-ORIGIN WebP images (web/public/backdrops, from the site) +// so html-to-image inlines them without tainting the canvas. +// - NO CSS filter: blur() (unreliable in capture) and NO mix-blend-mode. +// - the chart is an inline SVG with an internal . export interface ShareMetric { label: string; @@ -20,63 +29,260 @@ export interface ShareCardData { daily: ChartPoint[]; // primary "sent" series } -const StatsShareCard = React.forwardRef( - function StatsShareCard({ data }, ref) { +export type ShareAspect = "1:1" | "3:2" | "16:9"; + +const DIMENSIONS: Record = { + "1:1": { width: 1080, height: 1080 }, + "3:2": { width: 1620, height: 1080 }, + "16:9": { width: 1920, height: 1080 }, +}; + +// Marketing-site hero sky palette, but SYMMETRIC for a standalone card: a soft +// top-center radial in the bright sky-300 → sky-400 band (never the hero's +// sky-800 navy). Centered at 50%, so neither side is darker than the other — +// no "dark on the right". Bright at top, gently deeper toward the bottom. +const SKY_BASE = + "radial-gradient(ellipse 125% 105% at 50% -12%," + + " #9fe0fb 0%, #74cef7 30%, #4ec1f6 58%, #2fb6f2 82%, #18abed 100%)"; + +// Additive light wash (the .sky-breathe layer, baked static) — centered to match. +const SKY_BREATHE = + "radial-gradient(ellipse 125% 105% at 50% -12%," + + " rgba(224,242,254,0.85) 0%, rgba(125,211,252,0.38) 24%, rgba(56,189,248,0.14) 46%, transparent 64%)"; + +// Lifts the exposed bottom band toward light sky so it stays airy. +const HAZE_BOTTOM = + "linear-gradient(to top, rgba(186,230,253,0.40) 0%, rgba(125,211,252,0.16) 42%, transparent 100%)"; + +// NO dark shadow — any dark-toned shadow on the blue sky pools into a visible +// "shadow band" along the card edge. Instead the panel lifts with a faint WHITE +// halo (lighter than the sky), so it separates without darkening anything. +const PANEL: React.CSSProperties = { + background: "#ffffff", + borderRadius: 26, + border: "1px solid rgba(255,255,255,0.85)", + boxShadow: "0 16px 50px -18px rgba(255,255,255,0.55), 0 2px 12px -6px rgba(255,255,255,0.45)", +}; + +const LOGO_LIFT: React.CSSProperties = { + filter: "drop-shadow(0 3px 10px rgba(8,47,73,0.40)) drop-shadow(0 1px 2px rgba(8,47,73,0.30))", +}; +const onSky: React.CSSProperties = { textShadow: "0 1px 4px rgba(8,47,73,0.45)" }; + +// Real WebP clouds in the hero's arrangement (big top-left + top-right framing +// the logo, softer puffs drifting through the lower sky band). Same-origin so +// html-to-image inlines them without tainting the canvas. +const CLOUDS: { v: number; style: React.CSSProperties }[] = [ + { v: 5, style: { top: "-5%", left: "-5%", width: "44%", opacity: 0.95 } }, + { v: 2, style: { top: "2%", right: "-4%", width: "40%", opacity: 0.9 } }, + { v: 3, style: { bottom: "-3%", left: "12%", width: "28%", opacity: 0.62 } }, + { v: 4, style: { bottom: "3%", right: "8%", width: "26%", opacity: 0.58 } }, +]; + +function SkyBackdrop() { + return ( +
+ {/* additive light wash */} +
+ {/* lift the bottom band */} +
+ {CLOUDS.map((c, i) => ( + + ))} +
+ ); +} + +function Stat({ metric, valueSize }: { metric: ShareMetric; valueSize: number }) { + return ( +
+
{metric.label}
+
+ {metric.value} +
+ {metric.sub &&
{metric.sub}
} +
+ ); +} + +// Long area chart — smooth sky line + gradient fill, ALWAYS with a bottom +// baseline (so it reads as a chart even with no data). Fills its flex parent. +function ShareAreaChart({ points }: { points: ChartPoint[] }) { + const vals = points.map((p) => p.value || 0); + const hasData = points.length > 0 && vals.reduce((a, b) => a + b, 0) > 0; + + const W = 1000; + const H = 320; + const padX = 4; + const padTop = 14; + const baseY = H - 6; + const max = Math.max(1, ...vals); + const w = W - padX * 2; + const h = baseY - padTop; + const step = vals.length > 1 ? w / (vals.length - 1) : 0; + const pts = vals.map((v, i) => [padX + i * step, baseY - (v / max) * h] as const); + const line = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`).join(" "); + const lastX = padX + Math.max(0, vals.length - 1) * step; + const area = `${line} L ${lastX.toFixed(1)} ${baseY} L ${padX} ${baseY} Z`; + + return ( +
+
+ + + + + + + + {hasData && } + {hasData && ( + + )} + {/* baseline — always present */} + + + {!hasData && ( +
+ No sends in this window yet +
+ )} +
+
+ {hasData ? shortDate(points[0].label) : ""} + {hasData ? shortDate(points[points.length - 1].label) : ""} +
+
+ ); +} + +const StatsShareCard = React.forwardRef( + function StatsShareCard({ data, aspect = "1:1" }, ref) { + const { width, height } = DIMENSIONS[aspect]; const metrics = data.metrics.slice(0, 4); + const landscape = aspect !== "1:1"; + + const pad = aspect === "16:9" ? 64 : aspect === "3:2" ? 56 : 48; + const logoClass = landscape ? "w-[60px] h-[60px]" : "w-[54px] h-[54px]"; + const wordmarkSize = landscape ? 40 : 36; + const titleSize = aspect === "16:9" ? 50 : aspect === "3:2" ? 46 : 40; + const valueSize = landscape ? 50 : 46; + return (
- {/* header */} -
- - Warmbly - {data.subtitle && ( - - {data.subtitle} - - )} -
+ -

- {data.title} -

- - {/* metric grid */} -
- {metrics.map((m) => ( -
-
- {m.label} -
-
- {m.value} -
- {m.sub &&
{m.sub}
} +
+ {/* logo on the sky */} +
+
+ + + Warmbly +
- ))} -
- - {/* chart snapshot */} -
-
- Sends over time + + {todayLabel()} +
- -
- {/* footer */} -
- warmbly.com - {todayLabel()} + {/* single white panel */} +
+
+ {/* title */} + {data.subtitle && ( +
+ {data.subtitle} +
+ )} +

+ {data.title} +

+ + {/* divided metric row */} +
+ {metrics.map((m) => ( + + ))} +
+ +
+ + {/* long area chart */} +
+ + Sends over time + +
+
+ +
+
+
+ + {/* footer on the sky */} +
+ + warmbly.com + + + Cold email, warmed up. + +
); }, ); +function shortDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + } catch { + return iso; + } +} + function todayLabel(): string { return new Date().toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); } diff --git a/web/src/components/ui/charts.tsx b/web/src/components/ui/charts.tsx index e695c8c3..c19ec2e2 100644 --- a/web/src/components/ui/charts.tsx +++ b/web/src/components/ui/charts.tsx @@ -93,7 +93,7 @@ export function DailyBars({ return (
setHover(i)} onMouseLeave={() => setHover(null)} > @@ -103,7 +103,10 @@ export function DailyBars({ barClass, hover === null || hover === i ? "opacity-100" : "opacity-50", )} - style={{ height: h }} + // Cap + center each bar so a sparse series (1–2 points) renders + // as a normal bar, not a full-width block. Dense series have + // cells narrower than the cap, so this is a no-op for them. + style={{ height: h, maxWidth: 64 }} /> {hover === i && (
diff --git a/web/src/hooks/useExportCard.ts b/web/src/hooks/useExportCard.ts index 62ce7759..d4d13c29 100644 --- a/web/src/hooks/useExportCard.ts +++ b/web/src/hooks/useExportCard.ts @@ -15,9 +15,19 @@ import { downloadBlob } from "@/lib/api/client/app/contacts/exportContacts"; export default function useExportCard() { const [exporting, setExporting] = useState(false); - const exportPng = useCallback( - async (node: HTMLElement | null, filename: string) => { - if (!node || exporting) return; + // Rasterize an offscreen node to a PNG *data URL* (two-pass: prime the + // font/embed cache, then capture). Returns null on failure. Does NOT + // download — callers can preview the result first, then downloadPng it. + // + // pixelRatio defaults to 3 (crisp for the square card). Wide presets (16:9 + // at 1920px) get huge at 3x, so callers can pass a lower ratio (e.g. 2) to + // keep the capture snappy. + const renderPng = useCallback( + async ( + node: HTMLElement | null, + options?: { pixelRatio?: number }, + ): Promise => { + if (!node) return null; setExporting(true); try { try { @@ -25,17 +35,36 @@ export default function useExportCard() { } catch { /* fonts.ready is best-effort */ } - const opts = { pixelRatio: 3, backgroundColor: "#ffffff", cacheBust: true }; + const opts = { + pixelRatio: options?.pixelRatio ?? 3, + backgroundColor: "#ffffff", + cacheBust: true, + }; await toPng(node, opts); // prime font/embed cache - const dataUrl = await toPng(node, opts); // real capture - const blob = await (await fetch(dataUrl)).blob(); - downloadBlob(blob, filename); + return await toPng(node, opts); // real capture + } catch { + return null; } finally { setExporting(false); } }, - [exporting], + [], ); - return { exporting, exportPng }; + // Trigger a browser download of a PNG data URL (produced by renderPng). + const downloadPng = useCallback(async (dataUrl: string, filename: string) => { + const blob = await (await fetch(dataUrl)).blob(); + downloadBlob(blob, filename); + }, []); + + // Convenience: render + download in one shot (no preview step). + const exportPng = useCallback( + async (node: HTMLElement | null, filename: string, options?: { pixelRatio?: number }) => { + const url = await renderPng(node, options); + if (url) await downloadPng(url, filename); + }, + [renderPng, downloadPng], + ); + + return { exporting, renderPng, downloadPng, exportPng }; } From 25cf6d044f46384df50fb13cc0143c6728e54bbb Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 18:57:24 +0200 Subject: [PATCH 24/60] feat: polish campaign step controls Persist wait-day edits only at commit points and use the shared in-app confirm flow for deleting campaign steps. Update sequence deletion cache handling so one mutation instance can remove any step. --- web/src/app/app/campaigns/[id]/layout.tsx | 2 +- .../app/campaigns/sequences/StepRail.tsx | 109 ++++++++---------- web/src/components/ui/field.tsx | 24 +++- .../campaigns/sequences/useDeleteSequence.ts | 19 +-- 4 files changed, 81 insertions(+), 73 deletions(-) diff --git a/web/src/app/app/campaigns/[id]/layout.tsx b/web/src/app/app/campaigns/[id]/layout.tsx index 4f6bfcb4..8f9db073 100644 --- a/web/src/app/app/campaigns/[id]/layout.tsx +++ b/web/src/app/app/campaigns/[id]/layout.tsx @@ -19,7 +19,7 @@ import { useConfirm } from "@/hooks/context/confirm"; const TABS = [ { label: "Overview", path: "", Icon: BarChart3Icon }, { label: "Leads", path: "/leads", Icon: UsersIcon }, - { label: "Sequences", path: "/sequences", Icon: ListChecksIcon }, + { label: "Steps", path: "/sequences", Icon: ListChecksIcon }, { label: "Schedule", path: "/schedule", Icon: CalendarIcon }, { label: "Settings", path: "/preferences", Icon: Settings2Icon }, ] as const; diff --git a/web/src/components/app/campaigns/sequences/StepRail.tsx b/web/src/components/app/campaigns/sequences/StepRail.tsx index 6ea772e0..bf70151a 100644 --- a/web/src/components/app/campaigns/sequences/StepRail.tsx +++ b/web/src/components/app/campaigns/sequences/StepRail.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { ClockIcon, Loader2Icon, PlusIcon, SendIcon } from "lucide-react"; +import { CheckIcon, ClockIcon, Loader2Icon, PlusIcon, SendIcon } from "lucide-react"; import toast from "react-hot-toast"; import { NumberInput } from "@/components/ui/field"; import SequenceBox from "./SequenceBox"; @@ -8,6 +8,7 @@ import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdate import useDeleteSequence from "@/lib/api/hooks/app/campaigns/sequences/useDeleteSequence"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; +import { useConfirm } from "@/hooks/context/confirm"; const MAX_STEPS = 5; @@ -22,7 +23,11 @@ function WaitConnector({ sequence: Sequence; }) { const update = useUpdateSequence(campaignId, sequence.id); + // `draft` is the live edited value; it only persists on a commit point + // (blur / Enter / stepper) — never on every keystroke — so an in-flight + // save can't snap the field back mid-typing. const [draft, setDraft] = React.useState(sequence.wait_after); + const [savedAt, setSavedAt] = React.useState(0); // Keep the local draft in lockstep with the canonical value when the cache // updates (e.g. after a save elsewhere). @@ -33,10 +38,11 @@ function WaitConnector({ const commit = (v: number) => { const next = Math.max(0, Math.round(v)); setDraft(next); - if (next === sequence.wait_after) return; + if (next === sequence.wait_after) return; // no change → nothing to save update.mutate( { wait_after: next }, { + onSuccess: () => setSavedAt((n) => n + 1), onError: (err) => { setDraft(sequence.wait_after); toast.error(buildError(err as unknown as AppError)); @@ -45,6 +51,15 @@ function WaitConnector({ ); }; + // Briefly flash a "saved" tick after a successful commit. + const [showSaved, setShowSaved] = React.useState(false); + React.useEffect(() => { + if (savedAt === 0) return; + setShowSaved(true); + const t = setTimeout(() => setShowSaved(false), 1400); + return () => clearTimeout(t); + }, [savedAt]); + return (
@@ -58,16 +73,22 @@ function WaitConnector({ Wait days - {update.isPending && ( + {update.isPending ? ( - )} + ) : showSaved ? ( + + + Saved + + ) : null}
@@ -89,30 +110,26 @@ export default function StepRail({ onCreate: () => void; creating: boolean; }) { - const [confirmId, setConfirmId] = React.useState(null); - const [deletingId, setDeletingId] = React.useState(null); - const deleteSequence = useDeleteSequence(campaignId, confirmId ?? ""); + const confirm = useConfirm(); + const deleteSequence = useDeleteSequence(campaignId); - const confirmTarget = sequences.find((s) => s.id === confirmId) ?? null; - const confirmIndex = sequences.findIndex((s) => s.id === confirmId); - - async function runDelete() { - if (!confirmId) return; - const id = confirmId; - setDeletingId(id); - try { - await deleteSequence.mutateAsync(id); - toast.success("Step removed."); - if (selectedId === id) { - const remaining = sequences.filter((s) => s.id !== id); - onSelect(remaining[0]?.id ?? ""); - } - } catch (err) { - toast.error(buildError(err as AppError)); - } finally { - setDeletingId(null); - setConfirmId(null); - } + function requestDelete(seq: Sequence, index: number) { + const name = seq.name || `Step ${index + 1}`; + confirm.show( + `Delete step ${index + 1}? "${name}" and its content will be removed from this campaign. This can't be undone.`, + async () => { + try { + await deleteSequence.mutateAsync(seq.id); + toast.success("Step removed."); + if (selectedId === seq.id) { + const remaining = sequences.filter((s) => s.id !== seq.id); + onSelect(remaining[0]?.id ?? ""); + } + } catch (err) { + toast.error(buildError(err as AppError)); + } + }, + ); } return ( @@ -133,7 +150,7 @@ export default function StepRail({ subject={seq.subject} active={seq.id === selectedId} onClick={() => onSelect(seq.id)} - onDelete={() => setConfirmId(seq.id)} + onDelete={() => requestDelete(seq, i)} /> ))} @@ -159,40 +176,6 @@ export default function StepRail({
)} - {confirmTarget && ( -
-
-

- Delete step {confirmIndex + 1}? -

-

- {confirmTarget.name || `Step ${confirmIndex + 1}`} and its content will - be removed from this campaign. This can't be undone. -

-
- - -
-
-
- )}
); } diff --git a/web/src/components/ui/field.tsx b/web/src/components/ui/field.tsx index 02ec9ac0..e75679dd 100644 --- a/web/src/components/ui/field.tsx +++ b/web/src/components/ui/field.tsx @@ -124,6 +124,7 @@ export function FieldRow({ children, className }: { children: React.ReactNode; c export function NumberInput({ value, onChange, + onCommit, min, max, step = 1, @@ -135,6 +136,12 @@ export function NumberInput({ }: { value: number; onChange: (value: number) => void; + // Optional "commit point" distinct from the live onChange: fires on blur, + // on Enter, and on each stepper click — but NOT on every keystroke. Use it + // when the consumer wants to persist (e.g. a network save) only once the + // user settles on a value, instead of mid-typing. Omitting it preserves the + // original onChange-only behavior for every existing call site. + onCommit?: (value: number) => void; min?: number; max?: number; step?: number; @@ -150,9 +157,12 @@ export function NumberInput({ if (max !== undefined && n > max) return max; return n; }; + const commitValue = () => onCommit?.(clamp(Number.isFinite(value) ? value : min ?? 0)); const bump = (dir: 1 | -1) => { if (disabled) return; - onChange(clamp((Number.isFinite(value) ? value : 0) + dir * step)); + const next = clamp((Number.isFinite(value) ? value : 0) + dir * step); + onChange(next); + onCommit?.(next); }; const atMax = max !== undefined && value >= max; const atMin = min !== undefined && value <= min; @@ -174,6 +184,18 @@ export function NumberInput({ const raw = e.target.value; onChange(raw === "" ? min ?? 0 : clamp(Number(raw))); }} + onBlur={onCommit ? commitValue : undefined} + onKeyDown={ + onCommit + ? (e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitValue(); + (e.target as HTMLInputElement).blur(); + } + } + : undefined + } className={cn( "w-full min-w-0 h-full bg-transparent outline-none px-2.5 text-[12.5px] text-slate-900 tabular-nums disabled:text-slate-400", "[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", diff --git a/web/src/lib/api/hooks/app/campaigns/sequences/useDeleteSequence.ts b/web/src/lib/api/hooks/app/campaigns/sequences/useDeleteSequence.ts index 115b8aed..1c967bc4 100644 --- a/web/src/lib/api/hooks/app/campaigns/sequences/useDeleteSequence.ts +++ b/web/src/lib/api/hooks/app/campaigns/sequences/useDeleteSequence.ts @@ -2,20 +2,23 @@ import deleteSequence from "@/lib/api/client/app/campaigns/sequences/deleteSeque import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -export default function useDeleteSequence(campaign_id: string, sequence_id: string) { +// Delete a campaign sequence step. The id to delete is the mutate() argument +// (so one mutation instance can delete any step — important for the per-row +// useConfirm flow in StepRail). onSuccess prunes the deleted id from the cache +// using the variable passed to mutate, not a render-time-bound id. +export default function useDeleteSequence(campaign_id: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: (sequence_id: string) => deleteSequence(campaign_id, sequence_id), - onSuccess: () => { + onSuccess: (_data, sequence_id) => { queryClient.setQueryData( ["campaigns", campaign_id, "sequences"], (oldData) => { if (!oldData) return oldData; - - return oldData.filter((s) => s.id !== sequence_id) - } - ) - } - }) + return oldData.filter((s) => s.id !== sequence_id); + }, + ); + }, + }); } From 2730de2bff7bed2c2780a4887fea0a49bf76d943 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 18:57:30 +0200 Subject: [PATCH 25/60] feat: add campaign rich text editor Add a TipTap-based campaign step editor with formatting controls, link editing, personalization variables, and spintax insertion. --- .../campaigns/sequences/RichTextEditor.tsx | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 web/src/components/app/campaigns/sequences/RichTextEditor.tsx diff --git a/web/src/components/app/campaigns/sequences/RichTextEditor.tsx b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx new file mode 100644 index 00000000..39f0640d --- /dev/null +++ b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx @@ -0,0 +1,284 @@ +// Rich email-body editor for campaign Steps, built on TipTap (no deprecated +// execCommand). Controlled by an HTML string; emits HTML on change. Ships a +// house-theme toolbar (headings, bold/italic/underline/strike, lists, link), a +// one-click {{variable}} inserter, and a spintax `{a|b}` helper. Personalization +// tokens are just text, so they survive serialization untouched. + +import React from "react"; +import { useEditor, EditorContent, type Editor } from "@tiptap/react"; +import Document from "@tiptap/extension-document"; +import Paragraph from "@tiptap/extension-paragraph"; +import Text from "@tiptap/extension-text"; +import Bold from "@tiptap/extension-bold"; +import Italic from "@tiptap/extension-italic"; +import Underline from "@tiptap/extension-underline"; +import Strike from "@tiptap/extension-strike"; +import Heading from "@tiptap/extension-heading"; +import Link from "@tiptap/extension-link"; +import { BulletList, OrderedList, ListItem } from "@tiptap/extension-list"; +import { + BoldIcon, + ItalicIcon, + UnderlineIcon, + StrikethroughIcon, + Heading2Icon, + ListIcon, + ListOrderedIcon, + Link2Icon, + BracesIcon, + ShuffleIcon, + CheckIcon, + XIcon, + ChevronDownIcon, +} from "lucide-react"; +import { AnimatePresence, motion } from "framer-motion"; +import useClickOutside from "@/hooks/useClickOutside"; + +export default function RichTextEditor({ + html, + onChange, + variables, + placeholder, +}: { + html: string; + onChange: (html: string) => void; + variables: string[]; + placeholder?: string; +}) { + const editor = useEditor({ + extensions: [ + Document, + Paragraph, + Text, + Bold, + Italic, + Underline, + Strike, + Heading.configure({ levels: [2, 3] }), + BulletList, + OrderedList, + ListItem, + Link.configure({ openOnClick: false, autolink: true }), + ], + content: html || "", + editorProps: { + attributes: { + class: "tiptap-body min-h-[260px] px-3 py-2.5 text-[13px] leading-relaxed text-slate-800 focus:outline-none", + }, + }, + onUpdate: ({ editor }) => onChange(editor.getHTML()), + }); + + // Keep the editor in sync when the value changes from outside (template + // applied, step switched, reset) without clobbering the user's caret on + // their own edits. + React.useEffect(() => { + if (!editor) return; + const current = editor.getHTML(); + if (html !== current) { + editor.commands.setContent(html || "", { emitUpdate: false }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [html, editor]); + + if (!editor) return null; + + return ( +
+ + + {placeholder && editor.isEmpty && ( +

+ {placeholder} +

+ )} +
+ ); +} + +function Toolbar({ editor, variables }: { editor: Editor; variables: string[] }) { + const [linkOpen, setLinkOpen] = React.useState(false); + const [linkUrl, setLinkUrl] = React.useState(""); + + const applyLink = () => { + const url = linkUrl.trim(); + if (url) { + editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); + } else { + editor.chain().focus().unsetLink().run(); + } + setLinkOpen(false); + setLinkUrl(""); + }; + + return ( +
+ editor.chain().focus().toggleBold().run()} title="Bold"> + + + editor.chain().focus().toggleItalic().run()} title="Italic"> + + + editor.chain().focus().toggleUnderline().run()} title="Underline"> + + + editor.chain().focus().toggleStrike().run()} title="Strikethrough"> + + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + title="Heading" + > + + + editor.chain().focus().toggleBulletList().run()} title="Bullet list"> + + + editor.chain().focus().toggleOrderedList().run()} title="Numbered list"> + + + { + setLinkUrl(editor.getAttributes("link").href ?? ""); + setLinkOpen((o) => !o); + }} + title="Link" + > + + + + editor.chain().focus().insertContent(v).run()} variables={variables} /> + editor.chain().focus().insertContent("{option one|option two}").run()} + title="Insert spintax — randomly picks one option per send" + > + + + + + {linkOpen && ( + + setLinkUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + applyLink(); + } else if (e.key === "Escape") { + setLinkOpen(false); + } + }} + placeholder="https://…" + className="h-7 w-56 rounded border border-slate-200 px-2 text-[12px] text-slate-800 outline-none focus:border-sky-400" + /> + + + + )} + +
+ ); +} + +function Btn({ + active, + onClick, + title, + children, +}: { + active?: boolean; + onClick: () => void; + title: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function Divider() { + return ; +} + +// Shared variable inserter — a compact menu of personalization tokens. +export function VariableMenu({ onPick, variables }: { onPick: (token: string) => void; variables: string[] }) { + const [open, setOpen] = React.useState(false); + const ref = React.useRef(null); + useClickOutside(ref, () => setOpen(false)); + return ( +
+ + + {open && ( + + {variables.map((v) => ( + + ))} + + )} + +
+ ); +} From 7c518a57227b5adf09398a49f989517526eae9e0 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 4 Jun 2026 18:58:05 +0200 Subject: [PATCH 26/60] feat: clean up campaign rich text editor Remove the stale lint suppression from the TipTap editor now that the hook dependencies are valid. --- web/src/components/app/campaigns/sequences/RichTextEditor.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/components/app/campaigns/sequences/RichTextEditor.tsx b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx index 39f0640d..ffc589f2 100644 --- a/web/src/components/app/campaigns/sequences/RichTextEditor.tsx +++ b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx @@ -78,7 +78,6 @@ export default function RichTextEditor({ if (html !== current) { editor.commands.setContent(html || "", { emitUpdate: false }); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [html, editor]); if (!editor) return null; From 2a2e248ad99b873229d390a51f805313a7254778 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 5 Jun 2026 06:02:55 +0200 Subject: [PATCH 27/60] feat: add campaign sequence branching backend Store per-step branching conditions, validate branch targets, and select campaign contact steps by following the flow graph. Preserve linear progression for steps without branch conditions. --- internal/app/sequence/handler.go | 122 +++++++ internal/errx/common.go | 8 +- .../000019_sequence_branching.down.sql | 1 + .../000019_sequence_branching.up.sql | 6 + internal/models/sequence.go | 53 +++ internal/repository/branch_conditions.go | 180 ++++++++++ internal/repository/pg_campaign_progress.go | 334 +++++++++++++++++- internal/repository/pg_sequence.go | 20 +- internal/scheduler/campaign_scheduler.go | 12 +- 9 files changed, 729 insertions(+), 7 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000019_sequence_branching.down.sql create mode 100644 internal/infrastructure/db/migrations/000019_sequence_branching.up.sql create mode 100644 internal/repository/branch_conditions.go diff --git a/internal/app/sequence/handler.go b/internal/app/sequence/handler.go index 7a597946..c493f47f 100644 --- a/internal/app/sequence/handler.go +++ b/internal/app/sequence/handler.go @@ -2,7 +2,9 @@ package sequence import ( "context" + "encoding/json" + "github.com/google/uuid" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" ) @@ -16,9 +18,129 @@ func (s *sequenceService) Get(ctx context.Context, userID, campaignID string) ([ } func (s *sequenceService) Update(ctx context.Context, userID, campaignID, sequenceID string, data *models.UpdateSequence) (*models.Sequence, *errx.Error) { + // Cross-step branching validation. Shape (fields/operators/values) is checked + // in the repository; here we need the full sequence set to verify branch + // targets point at OTHER steps in the SAME campaign and that the resulting + // graph contains no cycles. + if data.Conditions != nil { + if verr := s.validateBranchTargets(ctx, userID, campaignID, sequenceID, data.Conditions); verr != nil { + return nil, verr + } + } return s.sequenceRepository.Update(ctx, userID, campaignID, sequenceID, data) } +// validateBranchTargets ensures every branch target is a real step in the same +// campaign and that applying this update introduces no branching cycle. The +// graph edges are: each step's branches → their target steps. The step being +// updated uses its NEW conditions; every other step uses its persisted ones. +func (s *sequenceService) validateBranchTargets(ctx context.Context, userID, campaignID, sequenceID string, bc *models.BranchConditions) *errx.Error { + if bc == nil || len(bc.Branches) == 0 { + return nil + } + + updatedID, perr := uuid.Parse(sequenceID) + if perr != nil { + return errx.ErrSequenceBranch + } + + steps, gerr := s.sequenceRepository.Get(ctx, userID, campaignID) + if gerr != nil { + return gerr + } + + valid := make(map[uuid.UUID]bool, len(steps)) + for _, st := range steps { + valid[st.ID] = true + } + // The updated step must itself belong to the campaign. + if !valid[updatedID] { + return errx.ErrNotFound + } + + // Every target must be a real step in the campaign, and may not be the step + // itself (a self-branch is a trivial cycle). + for _, b := range bc.Branches { + if b.TargetSequenceID == nil { + continue // nil = stop, always valid + } + if *b.TargetSequenceID == updatedID { + return errx.ErrSequenceBranchTo + } + if !valid[*b.TargetSequenceID] { + return errx.ErrSequenceBranchTo + } + } + + // Build the branch graph from persisted conditions, then overlay the new + // conditions for the step being updated, and check for cycles. + graph := make(map[uuid.UUID][]uuid.UUID, len(steps)) + for _, st := range steps { + if st.ID == updatedID { + continue // overlaid below with the new conditions + } + if len(st.Conditions) == 0 { + continue + } + var existing models.BranchConditions + if json.Unmarshal(st.Conditions, &existing) != nil { + continue + } + for _, b := range existing.Branches { + if b.TargetSequenceID != nil { + graph[st.ID] = append(graph[st.ID], *b.TargetSequenceID) + } + } + } + for _, b := range bc.Branches { + if b.TargetSequenceID != nil { + graph[updatedID] = append(graph[updatedID], *b.TargetSequenceID) + } + } + + if hasCycle(graph) { + return errx.ErrSequenceBranchTo + } + return nil +} + +// hasCycle reports whether the directed branch graph contains a cycle, via DFS +// with a three-colour (white/grey/black) marking. +func hasCycle(graph map[uuid.UUID][]uuid.UUID) bool { + const ( + white = 0 + grey = 1 + black = 2 + ) + state := make(map[uuid.UUID]int) + + var visit func(n uuid.UUID) bool + visit = func(n uuid.UUID) bool { + state[n] = grey + for _, m := range graph[n] { + switch state[m] { + case grey: + return true // back-edge → cycle + case white: + if visit(m) { + return true + } + } + } + state[n] = black + return false + } + + for n := range graph { + if state[n] == white { + if visit(n) { + return true + } + } + } + return false +} + func (s *sequenceService) Delete(ctx context.Context, userID, campaignID, sequenceID string) *errx.Error { return s.sequenceRepository.Delete(ctx, userID, campaignID, sequenceID) } diff --git a/internal/errx/common.go b/internal/errx/common.go index da652e63..4ae53bc0 100644 --- a/internal/errx/common.go +++ b/internal/errx/common.go @@ -99,9 +99,11 @@ var ( ErrCampaignLimit = New(BadRequest, "You reached your limit for campaigns, please try again later.") // Sequence - ErrSequenceName = New(BadRequest, "Sequence name cannot be longer than 50 characters.") - ErrSequenceSubject = New(BadRequest, "Sequence subject cannot be longer than 100 characters.") - ErrSequenceBody = New(BadRequest, fmt.Sprintf("Sequence body cannot be longer than %d characters.", config.SequenceBodyLimit)) + ErrSequenceName = New(BadRequest, "Sequence name cannot be longer than 50 characters.") + ErrSequenceSubject = New(BadRequest, "Sequence subject cannot be longer than 100 characters.") + ErrSequenceBody = New(BadRequest, fmt.Sprintf("Sequence body cannot be longer than %d characters.", config.SequenceBodyLimit)) + ErrSequenceBranch = New(BadRequest, "Invalid branching conditions.") + ErrSequenceBranchTo = New(BadRequest, "Branch target must be another step in the same campaign and cannot create a cycle.") // Contact ErrContactSerialize = New(BadRequest, "Failed to serialize contact.") diff --git a/internal/infrastructure/db/migrations/000019_sequence_branching.down.sql b/internal/infrastructure/db/migrations/000019_sequence_branching.down.sql new file mode 100644 index 00000000..1fa60b14 --- /dev/null +++ b/internal/infrastructure/db/migrations/000019_sequence_branching.down.sql @@ -0,0 +1 @@ +ALTER TABLE sequences DROP COLUMN IF EXISTS conditions; diff --git a/internal/infrastructure/db/migrations/000019_sequence_branching.up.sql b/internal/infrastructure/db/migrations/000019_sequence_branching.up.sql new file mode 100644 index 00000000..72074a19 --- /dev/null +++ b/internal/infrastructure/db/migrations/000019_sequence_branching.up.sql @@ -0,0 +1,6 @@ +-- Per-step conditional branching. A sequence step can carry a branching tree +-- that routes a contact to a target step (or stops them) based on whether they +-- opened/clicked/replied within N days. Kept as a single jsonb tree on the +-- step itself — no extra tables. An empty object ('{}') / empty branches list +-- means the step keeps the default linear progression. +ALTER TABLE sequences ADD COLUMN IF NOT EXISTS conditions jsonb NOT NULL DEFAULT '{}'; diff --git a/internal/models/sequence.go b/internal/models/sequence.go index 4e91ea4a..d409050d 100644 --- a/internal/models/sequence.go +++ b/internal/models/sequence.go @@ -1,6 +1,7 @@ package models import ( + "encoding/json" "time" "github.com/google/uuid" @@ -19,6 +20,13 @@ type Sequence struct { WaitAfter int `json:"wait_after"` Position int `json:"position"` + // Conditions is the per-step branching tree. When empty (`{}` / no + // branches), the step keeps the default linear behaviour (advance to the + // next position). When populated, the scheduler evaluates the contact's + // engagement against these branches at schedule time to decide which step + // (or stop) comes next. Stored as a single jsonb column on `sequences`. + Conditions json.RawMessage `json:"conditions,omitempty"` + UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } @@ -33,4 +41,49 @@ type UpdateSequence struct { BodyCode *bool `json:"body_code"` WaitAfter *int `json:"wait_after"` + + // Conditions, when non-nil, replaces the step's branching tree. Send `{}` + // (or an object with an empty `branches` array) to clear branching and fall + // back to linear progression. + Conditions *BranchConditions `json:"conditions"` +} + +// BranchConditions is the typed branching tree persisted in the sequence +// `conditions` jsonb column. Branches are evaluated in declared order; the +// first branch whose conditions ALL match wins. A winning branch's +// TargetSequenceID selects the next step, or nil ("stop") ends the contact's +// journey. When no branch matches (or Branches is empty) the scheduler keeps +// the default linear progression. +type BranchConditions struct { + Branches []Branch `json:"branches,omitempty"` +} + +// Branch is a single conditional route out of a step. +type Branch struct { + // BranchID is a stable client-supplied identifier (for editor diffing / + // logging). Kept as a free-form string: the editor uses crypto.randomUUID() + // when available but falls back to a non-UUID token, so this must NOT be a + // strict uuid.UUID or unmarshalling the PATCH body would fail. + BranchID string `json:"branch_id"` + // TargetSequenceID is the step to jump to when this branch matches. nil + // means "stop" (do not send the contact any further step). + TargetSequenceID *uuid.UUID `json:"target_sequence_id"` + // Conditions are ANDed together — every condition must hold for the branch + // to match. An empty list is an unconditional/catch-all branch. + Conditions []BranchCondition `json:"conditions,omitempty"` +} + +// BranchCondition is a single engagement predicate evaluated against the +// contact's campaign_contact_progress row for the current step. +type BranchCondition struct { + // Field is the engagement signal: "opened" | "clicked" | "replied" and + // their negations "not_opened" | "not_clicked" | "not_replied". + Field string `json:"field"` + // Operator is the comparison. Currently "within_days" (the signal occurred + // in the last Value days) and "ever" (the signal occurred at all). For the + // not_* fields the meaning inverts (did NOT happen within / ever). + Operator string `json:"operator"` + // Value is the day window for "within_days". nil for operators that take no + // argument (e.g. "ever"). + Value *int `json:"value"` } diff --git a/internal/repository/branch_conditions.go b/internal/repository/branch_conditions.go new file mode 100644 index 00000000..7e8944c7 --- /dev/null +++ b/internal/repository/branch_conditions.go @@ -0,0 +1,180 @@ +package repository + +import ( + "hash/fnv" + "time" + + "github.com/google/uuid" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +// Branching condition vocabulary. Kept in one place so the shape validator +// (write path) and the resolver (schedule path) agree exactly. +var branchConditionFields = map[string]bool{ + "opened": true, + "clicked": true, + "replied": true, + "not_opened": true, + "not_clicked": true, + "not_replied": true, + // "random" routes a deterministic percentage of contacts down this branch + // (a random split / split-test). Pairs with operator "chance", Value = %. + "random": true, +} + +var branchConditionOperators = map[string]bool{ + "within_days": true, // signal occurred (or not, for not_*) in the last Value days + "ever": true, // signal occurred (or not) at all; Value ignored + // "always" is the frontend editor's label for "ever (any time)" — accepted as + // an exact alias of "ever" so the shipped UI contract stays valid. + "always": true, + // "chance" pairs with field "random": Value is the percent (1-99) of + // contacts that take the branch, chosen deterministically per contact. + "chance": true, +} + +// maxBranchesPerStep / maxConditionsPerBranch bound the tree so a single step +// cannot carry an unreasonable amount of branching logic. +const ( + maxBranchesPerStep = 20 + maxConditionsPerBranch = 20 + maxBranchWithinDays = 365 +) + +// validateBranchConditions checks the per-step shape of a branching tree: +// known fields/operators, sane within_days windows, and bounded fan-out. It +// does NOT check cross-step concerns (target exists / same campaign / no +// cycles) — those need the full sequence set and live in the service layer. +func validateBranchConditions(bc *models.BranchConditions) *errx.Error { + if bc == nil { + return nil + } + if len(bc.Branches) > maxBranchesPerStep { + return errx.ErrSequenceBranch + } + for _, b := range bc.Branches { + if len(b.Conditions) > maxConditionsPerBranch { + return errx.ErrSequenceBranch + } + for _, cond := range b.Conditions { + if !branchConditionFields[cond.Field] { + return errx.ErrSequenceBranch + } + if !branchConditionOperators[cond.Operator] { + return errx.ErrSequenceBranch + } + if cond.Operator == "within_days" { + if cond.Value == nil || *cond.Value < 1 || *cond.Value > maxBranchWithinDays { + return errx.ErrSequenceBranch + } + } + if cond.Field == "random" { + if cond.Operator != "chance" || cond.Value == nil || *cond.Value < 1 || *cond.Value > 99 { + return errx.ErrSequenceBranch + } + } + } + } + return nil +} + +// evaluateBranchConditions runs a step's branching tree against a contact's +// engagement timestamps and returns the matched branch (in declared order, AND +// semantics within a branch). Returns (nil, false) when no branch matches so +// the caller can fall back to linear progression. +// +// `now` is passed in so evaluation is deterministic for a single resolve call. +func evaluateBranchConditions(bc *models.BranchConditions, prog *CampaignContactProgress, now time.Time) (*models.Branch, bool) { + if bc == nil || len(bc.Branches) == 0 { + return nil, false + } + for i := range bc.Branches { + b := &bc.Branches[i] + if branchMatches(b, prog, now) { + return b, true + } + } + return nil, false +} + +// branchMatches reports whether EVERY condition in the branch holds. An empty +// condition list is an unconditional catch-all (always matches). +func branchMatches(b *models.Branch, prog *CampaignContactProgress, now time.Time) bool { + for _, cond := range b.Conditions { + if cond.Field == "random" { + if !randomHolds(cond, prog.ContactID, b.BranchID) { + return false + } + continue + } + if !conditionHolds(cond, prog, now) { + return false + } + } + return true +} + +// randomHolds deterministically routes Value% of contacts down a random-split +// branch. Stable per (contact, branch): the same contact always takes the same +// path for this branch, so re-evaluation at each schedule pass is consistent. +func randomHolds(cond models.BranchCondition, contactID uuid.UUID, branchID string) bool { + pct := 0 + if cond.Value != nil { + pct = *cond.Value + } + if pct <= 0 { + return false + } + if pct >= 100 { + return true + } + h := fnv.New32a() + _, _ = h.Write([]byte(contactID.String() + ":" + branchID)) + return int(h.Sum32()%100) < pct +} + +// conditionHolds evaluates a single engagement predicate. The not_* fields +// invert the positive signal of the same name. +func conditionHolds(cond models.BranchCondition, prog *CampaignContactProgress, now time.Time) bool { + var ts *time.Time + negate := false + + switch cond.Field { + case "opened": + ts = prog.OpenedAt + case "clicked": + ts = prog.ClickedAt + case "replied": + ts = prog.RepliedAt + case "not_opened": + ts, negate = prog.OpenedAt, true + case "not_clicked": + ts, negate = prog.ClickedAt, true + case "not_replied": + ts, negate = prog.RepliedAt, true + default: + // Unknown field never matches (validation should have caught it). + return false + } + + var positive bool + switch cond.Operator { + case "ever", "always": + positive = ts != nil + case "within_days": + if ts == nil || cond.Value == nil { + positive = false + } else { + cutoff := now.Add(-time.Hour * 24 * time.Duration(*cond.Value)) + positive = ts.After(cutoff) + } + default: + return false + } + + if negate { + return !positive + } + return positive +} diff --git a/internal/repository/pg_campaign_progress.go b/internal/repository/pg_campaign_progress.go index 00d0fe9e..ec6ac0b8 100644 --- a/internal/repository/pg_campaign_progress.go +++ b/internal/repository/pg_campaign_progress.go @@ -3,12 +3,22 @@ package repository import ( "context" "database/sql" + "encoding/json" + "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" + "github.com/warmbly/warmbly/internal/models" ) +// ErrUseLinearProgression is returned by ResolveSequenceBranch when the current +// step has no branching configured, or no branch matched. The caller should +// keep the default linear next-step behaviour. It is NOT an error condition — +// it is the explicit "no override" signal, kept distinct from a real error and +// from a nil ("stop") target. +var ErrUseLinearProgression = errors.New("use linear sequence progression") + // CampaignContactProgress represents the progress of a contact in a campaign type CampaignContactProgress struct { CampaignID uuid.UUID @@ -81,6 +91,42 @@ type CampaignProgressRepository interface { // pairs first; excludeNewLeads drops position-1 pairs entirely (so the // new-lead/day cap can be enforced while follow-ups keep flowing). FindNextContactSequence(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool) (*ContactSequencePair, error) + + // FindNextRoutedPair selects the next (contact, step) by following the flow + // graph (branch routing) rather than linear position order. A step is sent + // only if the route reaches it. Supersedes FindNextContactSequence + the + // scheduler's separate branch-override step. + FindNextRoutedPair(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool) (*ContactSequencePair, error) + + // ResolveSequenceBranch evaluates the branching tree on currentSequenceID + // against the contact's recorded engagement and decides what comes next: + // - (*uuid.UUID, nil) → jump to that target step + // - (nil, nil) → STOP (a matched branch routes to "stop") + // - (nil, ErrUseLinearProgression) → no branching / no branch matched; + // keep the default linear next step + // - (nil, err) → a real lookup error + // Conditions are evaluated at schedule time against the contact's + // campaign_contact_progress row for currentSequenceID (see the race note in + // the scheduler). + ResolveSequenceBranch(ctx context.Context, campaignID, contactID, currentSequenceID uuid.UUID) (*uuid.UUID, error) + + // GetCurrentSequenceForContact returns the contact's CURRENT step in a + // campaign (the most recently sent sequence). Returns (nil, nil) when the + // contact has not been sent anything yet (a brand-new lead, for which there + // is no prior step to branch from). Used by the scheduler to know which + // step's branching tree to evaluate. + GetCurrentSequenceForContact(ctx context.Context, campaignID, contactID uuid.UUID) (*uuid.UUID, error) + + // HasSentSequence reports whether a specific step has already been sent to a + // contact. The scheduler uses it so a branch override never re-sends a step + // the contact already received. + HasSentSequence(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) (bool, error) + + // FindNextContactSequenceExcluding behaves exactly like + // FindNextContactSequence but additionally skips the given contacts. The + // scheduler uses it to step past contacts whose branching resolved to STOP + // for this pass without re-sending them. + FindNextContactSequenceExcluding(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool, excludeContacts []uuid.UUID) (*ContactSequencePair, error) } type campaignProgressRepository struct { @@ -366,6 +412,12 @@ func (r *campaignProgressRepository) GetLatestCampaignSequenceForContact(ctx con // orderDir: "asc", "desc" // orderField: custom field name (used when orderBy is "custom_field") func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool) (*ContactSequencePair, error) { + return r.FindNextContactSequenceExcluding(ctx, campaignID, orderBy, orderDir, orderField, prioritizeNewLeads, excludeNewLeads, nil) +} + +// FindNextContactSequenceExcluding is FindNextContactSequence plus an optional +// set of contacts to skip (used by the branching STOP path). +func (r *campaignProgressRepository) FindNextContactSequenceExcluding(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool, excludeContacts []uuid.UUID) (*ContactSequencePair, error) { // Build the ORDER BY clause based on ordering settings var contactOrder string switch orderBy { @@ -405,6 +457,13 @@ func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context excludeClause = "AND s.position > 1" } + // Optional per-contact exclusion (branching STOP path). Bound as $2 so the + // list is parameterised; empty list means "exclude nothing". + contactExcludeClause := "AND NOT (cl.contact_id = ANY($2::uuid[]))" + if len(excludeContacts) == 0 { + excludeContacts = []uuid.UUID{} + } + query := ` WITH all_pairs AS ( -- Generate all possible contact-sequence combinations for this campaign @@ -419,6 +478,7 @@ func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context WHERE cl.campaign_id = $1 AND s.campaign_id = $1 ` + excludeClause + ` + ` + contactExcludeClause + ` -- Skip contacts that bounced in ANY campaign AND NOT EXISTS ( SELECT 1 FROM campaign_contact_progress ccp2 @@ -450,7 +510,7 @@ func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context ` pair := &ContactSequencePair{} - err := r.db.QueryRow(ctx, query, campaignID).Scan(&pair.ContactID, &pair.SequenceID, &pair.IsNewLead) + err := r.db.QueryRow(ctx, query, campaignID, excludeContacts).Scan(&pair.ContactID, &pair.SequenceID, &pair.IsNewLead) if err == sql.ErrNoRows { return nil, nil // No more emails to send @@ -458,3 +518,275 @@ func (r *campaignProgressRepository) FindNextContactSequence(ctx context.Context return pair, err } + +// FindNextRoutedPair selects the next (contact, step) to send by FOLLOWING THE +// FLOW graph instead of linear position order. For each contact, the next step +// is the route out of their last-sent step: +// 1. conditional branches (first match wins, evaluated against engagement), +// 2. then the explicit "else" catch-all branch (empty conditions; target nil = STOP), +// 3. then linear position+1 — but ONLY when the step defines no branches at all +// (so plain linear campaigns keep working unchanged). +// +// A contact who has never been sent starts at the entry step (position 1). A +// step is sent only if the route reaches it, so branch-only steps are never sent +// linearly, and a routed step that was already sent (a loop) stops the contact. +// Returns nil when no contact has a due next step. +func (r *campaignProgressRepository) FindNextRoutedPair(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool) (*ContactSequencePair, error) { + // 1. Load steps (position + branch tree) once, ordered by position. + type stepInfo struct { + id uuid.UUID + bc models.BranchConditions + } + srows, err := r.db.Query(ctx, `SELECT id, conditions FROM sequences WHERE campaign_id = $1 ORDER BY position ASC, created_at ASC`, campaignID) + if err != nil { + return nil, err + } + var steps []stepInfo + idxByID := map[uuid.UUID]int{} + for srows.Next() { + var si stepInfo + var raw []byte + if serr := srows.Scan(&si.id, &raw); serr != nil { + srows.Close() + return nil, serr + } + if len(raw) > 0 { + _ = json.Unmarshal(raw, &si.bc) + } + idxByID[si.id] = len(steps) + steps = append(steps, si) + } + srows.Close() + if serr := srows.Err(); serr != nil { + return nil, serr + } + if len(steps) == 0 { + return nil, nil + } + entry := steps[0].id + now := time.Now() + + // routeNext returns the next step out of fromID, or nil for STOP/end. + routeNext := func(fromID uuid.UUID, prog *CampaignContactProgress) *uuid.UUID { + idx, ok := idxByID[fromID] + if !ok { + return nil + } + bc := steps[idx].bc + linearNext := func() *uuid.UUID { + if idx+1 < len(steps) { + return &steps[idx+1].id + } + return nil + } + if len(bc.Branches) == 0 { + return linearNext() // pure linear step + } + branch, matched := evaluateBranchConditions(&bc, prog, now) + if !matched { + return linearNext() // branches exist but none matched (no catch-all) + } + return branch.TargetSequenceID // uuid target, or nil = explicit STOP + } + + // 2. Ordered candidate contacts + their last-sent step (with engagement) + sent set. + var contactOrder string + switch orderBy { + case "email": + contactOrder = "c.email" + case "name": + contactOrder = "c.first_name, c.last_name" + case "custom_field": + if orderField != "" { + contactOrder = "c.custom_fields->>'" + orderField + "'" + } else { + contactOrder = "c.created_at" + } + case "manual": + contactOrder = "cl.position NULLS LAST, c.created_at" + default: + contactOrder = "c.created_at" + } + dir := "ASC" + if orderDir == "desc" { + dir = "DESC" + } + orderPrefix := "" + if prioritizeNewLeads { + // New leads (no last-sent step) first. + orderPrefix = "(lp.sequence_id IS NULL) DESC, " + } + + query := ` + SELECT cl.contact_id, + lp.sequence_id, lp.sent_at, lp.opened_at, lp.clicked_at, lp.replied_at, + COALESCE(ss.ids, '{}') AS sent_ids + FROM campaign_leads cl + JOIN contacts c ON c.id = cl.contact_id + LEFT JOIN LATERAL ( + SELECT sequence_id, sent_at, opened_at, clicked_at, replied_at + FROM campaign_contact_progress p + WHERE p.campaign_id = $1 AND p.contact_id = cl.contact_id AND p.sent_at IS NOT NULL + ORDER BY p.sent_at DESC LIMIT 1 + ) lp ON true + LEFT JOIN LATERAL ( + SELECT array_agg(sequence_id) AS ids + FROM campaign_contact_progress p2 + WHERE p2.campaign_id = $1 AND p2.contact_id = cl.contact_id AND p2.sent_at IS NOT NULL + ) ss ON true + WHERE cl.campaign_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM campaign_contact_progress b + WHERE b.contact_id = cl.contact_id AND b.bounced_at IS NOT NULL + ) + AND NOT EXISTS ( + SELECT 1 FROM suppressed_recipients sr + JOIN campaigns camp ON camp.organization_id = sr.organization_id + WHERE camp.id = $1 + AND LOWER(sr.email) = LOWER(c.email) + AND (sr.expires_at IS NULL OR sr.expires_at > NOW()) + ) + ORDER BY ` + orderPrefix + contactOrder + ` ` + dir + ` + ` + + rows, err := r.db.Query(ctx, query, campaignID) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var contactID uuid.UUID + var lastSeq *uuid.UUID + var sentAt, openedAt, clickedAt, repliedAt *time.Time + var sentIDs []uuid.UUID + if serr := rows.Scan(&contactID, &lastSeq, &sentAt, &openedAt, &clickedAt, &repliedAt, &sentIDs); serr != nil { + return nil, serr + } + + isNew := lastSeq == nil + var routed *uuid.UUID + if isNew { + if excludeNewLeads { + continue + } + e := entry + routed = &e + } else { + prog := &CampaignContactProgress{ + CampaignID: campaignID, ContactID: contactID, SequenceID: *lastSeq, + SentAt: sentAt, OpenedAt: openedAt, ClickedAt: clickedAt, RepliedAt: repliedAt, + } + routed = routeNext(*lastSeq, prog) + } + if routed == nil { + continue // contact has reached the end / a STOP + } + // Loop guard: never re-send a step the contact already received. + already := false + for _, sid := range sentIDs { + if sid == *routed { + already = true + break + } + } + if already { + continue + } + return &ContactSequencePair{ContactID: contactID, SequenceID: *routed, IsNewLead: isNew}, nil + } + return nil, rows.Err() +} + +// GetCurrentSequenceForContact returns the contact's most recently sent step in +// a campaign (its current step). nil when nothing has been sent yet. +func (r *campaignProgressRepository) GetCurrentSequenceForContact(ctx context.Context, campaignID, contactID uuid.UUID) (*uuid.UUID, error) { + query := ` + SELECT sequence_id + FROM campaign_contact_progress + WHERE campaign_id = $1 + AND contact_id = $2 + AND sent_at IS NOT NULL + ORDER BY sent_at DESC + LIMIT 1 + ` + var seqID uuid.UUID + if err := r.db.QueryRow(ctx, query, campaignID, contactID).Scan(&seqID); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &seqID, nil +} + +// HasSentSequence reports whether a step has already been sent to a contact. +func (r *campaignProgressRepository) HasSentSequence(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) (bool, error) { + query := ` + SELECT EXISTS( + SELECT 1 FROM campaign_contact_progress + WHERE campaign_id = $1 + AND contact_id = $2 + AND sequence_id = $3 + AND sent_at IS NOT NULL + ) + ` + var exists bool + err := r.db.QueryRow(ctx, query, campaignID, contactID, sequenceID).Scan(&exists) + return exists, err +} + +// ResolveSequenceBranch reads the branching tree on currentSequenceID and the +// contact's engagement for that step, then returns the branch decision. See the +// interface doc for the (target, error) contract. Keeps existing linear +// behaviour by returning ErrUseLinearProgression whenever conditions are empty +// or nothing matches. +func (r *campaignProgressRepository) ResolveSequenceBranch(ctx context.Context, campaignID, contactID, currentSequenceID uuid.UUID) (*uuid.UUID, error) { + // Load the current step's conditions jsonb. The step must belong to this + // campaign (defence-in-depth: a stale/foreign sequence id can never branch). + var raw []byte + err := r.db.QueryRow(ctx, + `SELECT conditions FROM sequences WHERE id = $1 AND campaign_id = $2`, + currentSequenceID, campaignID, + ).Scan(&raw) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrUseLinearProgression + } + return nil, err + } + + if len(raw) == 0 { + return nil, ErrUseLinearProgression + } + var bc models.BranchConditions + if uerr := json.Unmarshal(raw, &bc); uerr != nil { + // Malformed stored tree: fail safe to linear rather than stopping or + // erroring the whole schedule pass. + return nil, ErrUseLinearProgression + } + if len(bc.Branches) == 0 { + return nil, ErrUseLinearProgression + } + + // Load engagement for this contact on the CURRENT step. Absence of a row + // means "no engagement yet" — every signal is treated as not-happened. + prog := &CampaignContactProgress{CampaignID: campaignID, ContactID: contactID, SequenceID: currentSequenceID} + scanErr := r.db.QueryRow(ctx, + `SELECT sent_at, opened_at, clicked_at, replied_at, bounced_at, complained_at + FROM campaign_contact_progress + WHERE campaign_id = $1 AND contact_id = $2 AND sequence_id = $3`, + campaignID, contactID, currentSequenceID, + ).Scan(&prog.SentAt, &prog.OpenedAt, &prog.ClickedAt, &prog.RepliedAt, &prog.BouncedAt, &prog.ComplainedAt) + if scanErr != nil && scanErr != sql.ErrNoRows { + return nil, scanErr + } + + branch, ok := evaluateBranchConditions(&bc, prog, time.Now()) + if !ok { + // No branch matched → keep linear progression. + return nil, ErrUseLinearProgression + } + + // Matched. nil target = explicit STOP; otherwise jump to the target step. + return branch.TargetSequenceID, nil +} diff --git a/internal/repository/pg_sequence.go b/internal/repository/pg_sequence.go index 9c32b462..a29d0d30 100644 --- a/internal/repository/pg_sequence.go +++ b/internal/repository/pg_sequence.go @@ -2,6 +2,7 @@ package repository import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -42,6 +43,7 @@ var SequenceSelections []string = []string{ "body_code", "wait_after", "position", + "conditions", "updated_at", "created_at", } @@ -64,7 +66,7 @@ var ( func GetSequence(row db.Scannable, seq *models.Sequence) error { return row.Scan( &seq.ID, &seq.Name, &seq.Subject, &seq.BodyPlain, &seq.BodyHTML, &seq.BodySync, - &seq.BodyCode, &seq.WaitAfter, &seq.Position, &seq.UpdatedAt, &seq.CreatedAt, + &seq.BodyCode, &seq.WaitAfter, &seq.Position, &seq.Conditions, &seq.UpdatedAt, &seq.CreatedAt, ) } @@ -237,6 +239,22 @@ func (r *sequenceRepository) Update(ctx context.Context, userID, campaignID, seq args = append(args, *data.WaitAfter) argPos++ } + if data.Conditions != nil { + // Validate shape (operators/fields/values) before persisting. Cross-step + // validation (targets-in-campaign + no cycles) happens in the service + // layer where the full sequence set is available. + if verr := validateBranchConditions(data.Conditions); verr != nil { + return nil, verr + } + raw, merr := json.Marshal(data.Conditions) + if merr != nil { + db.CaptureError(merr, "", nil, "marshal_conditions") + return nil, errx.InternalError() + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "conditions", argPos)) + args = append(args, raw) + argPos++ + } if argPos == 4 { return nil, errx.ErrNotEnough diff --git a/internal/scheduler/campaign_scheduler.go b/internal/scheduler/campaign_scheduler.go index 382d2c40..c13557fd 100644 --- a/internal/scheduler/campaign_scheduler.go +++ b/internal/scheduler/campaign_scheduler.go @@ -90,7 +90,7 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai excludeNewLeads = true } } - nextPair, err := s.campaignProgressRepo.FindNextContactSequence( + nextPair, err := s.campaignProgressRepo.FindNextRoutedPair( ctx, campaignID, campaign.ContactOrderBy, @@ -109,7 +109,7 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai // pair without it. In that case defer to the next day so follow-ups keep // progressing and new leads resume tomorrow — do NOT complete. if excludeNewLeads { - if again, aerr := s.campaignProgressRepo.FindNextContactSequence( + if again, aerr := s.campaignProgressRepo.FindNextRoutedPair( ctx, campaignID, campaign.ContactOrderBy, campaign.ContactOrderDir, orderField, campaign.PrioritizeNewLeads, false, ); aerr == nil && again != nil { @@ -126,6 +126,14 @@ func (s *schedulerService) CalculateNextCampaignTime(ctx context.Context, campai return time.Time{}, nil, uuid.Nil, ErrCampaignCompleted } + // Branch routing is resolved inside FindNextRoutedPair: the chosen step is the + // route out of the contact's last-sent step — conditional branches first + // (first match wins, evaluated against opened/clicked/replied), then the + // explicit "else" catch-all, then linear position+1 only when a step defines + // no branches. A step is sent only if the flow reaches it; STOP/end and + // already-sent loops drop the contact in the finder. Conditions are evaluated + // at schedule time (a known, accepted race vs. last-moment engagement). + // STEP 3.5: Resolve the recipient ESP/provider for ESP matching. Cheap: // prefer the cached contact.esp_provider, else derive from the domain // string. NEVER dial MX on the hot path. Empty => unknown => wildcard. From 630e69037ae536f74ae6988f3f1eab83cce9d10d Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 5 Jun 2026 06:03:00 +0200 Subject: [PATCH 28/60] feat: add per-step campaign ab variants Scope campaign A/B variants to individual sequence steps and select step variants deterministically per contact and step. Keep campaign-level variants on the existing assignment flow. --- internal/app/advanced/service.go | 115 +++++++++++++----- .../000016_per_step_ab_variants.down.sql | 3 + .../000016_per_step_ab_variants.up.sql | 13 ++ internal/models/advanced_outreach.go | 24 ++-- internal/repository/pg_advanced_outreach.go | 14 ++- internal/tasks/campaign_task.go | 21 +++- 6 files changed, 140 insertions(+), 50 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000016_per_step_ab_variants.down.sql create mode 100644 internal/infrastructure/db/migrations/000016_per_step_ab_variants.up.sql diff --git a/internal/app/advanced/service.go b/internal/app/advanced/service.go index aa571b72..a7a0eb3d 100644 --- a/internal/app/advanced/service.go +++ b/internal/app/advanced/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "hash/fnv" "math/rand" "net/mail" "strings" @@ -40,7 +41,7 @@ type Service interface { // (one-click POST or the manual link). Always suppresses — it's an explicit // recipient request, independent of the auto-suppress settings. Unsubscribe(ctx context.Context, campaignID, contactID uuid.UUID) *errx.Error - SelectVariant(ctx context.Context, organizationID, campaignID, contactID uuid.UUID, subject, bodyHTML, bodyPlain string) (*models.VariantSelection, *errx.Error) + SelectVariant(ctx context.Context, organizationID, campaignID, contactID, sequenceID uuid.UUID, subject, bodyHTML, bodyPlain string) (*models.VariantSelection, *errx.Error) OptimizeSendTime(ctx context.Context, organizationID uuid.UUID, contact *models.Contact, base time.Time) (time.Time, *errx.Error) StartTaskExecution(ctx context.Context, taskID uuid.UUID, executionKey string, metadata map[string]interface{}) (bool, *errx.Error) @@ -305,7 +306,56 @@ func (s *service) Unsubscribe(ctx context.Context, campaignID, contactID uuid.UU return nil } -func (s *service) SelectVariant(ctx context.Context, organizationID, campaignID, contactID uuid.UUID, subject, bodyHTML, bodyPlain string) (*models.VariantSelection, *errx.Error) { +// pickVariantWeightedRandom does a weighted random draw over active variants. +func pickVariantWeightedRandom(variants []models.CampaignABVariant) *models.CampaignABVariant { + total := 0 + for i := range variants { + if variants[i].Weight <= 0 { + variants[i].Weight = 100 + } + total += variants[i].Weight + } + if total <= 0 { + return nil + } + pick := rand.Intn(total) + running := 0 + for i := range variants { + running += variants[i].Weight + if pick < running { + return &variants[i] + } + } + return &variants[len(variants)-1] +} + +// pickVariantDeterministic does a weighted draw seeded by a stable string, so +// the same seed always picks the same variant (used for per-step assignment). +func pickVariantDeterministic(variants []models.CampaignABVariant, seed string) *models.CampaignABVariant { + total := 0 + for i := range variants { + if variants[i].Weight <= 0 { + variants[i].Weight = 100 + } + total += variants[i].Weight + } + if total <= 0 { + return nil + } + h := fnv.New32a() + _, _ = h.Write([]byte(seed)) + pick := int(h.Sum32() % uint32(total)) + running := 0 + for i := range variants { + running += variants[i].Weight + if pick < running { + return &variants[i] + } + } + return &variants[len(variants)-1] +} + +func (s *service) SelectVariant(ctx context.Context, organizationID, campaignID, contactID, sequenceID uuid.UUID, subject, bodyHTML, bodyPlain string) (*models.VariantSelection, *errx.Error) { settings, xerr := s.effectiveSettings(ctx, organizationID, campaignID) if xerr != nil { return nil, xerr @@ -314,45 +364,44 @@ func (s *service) SelectVariant(ctx context.Context, organizationID, campaignID, return &models.VariantSelection{Subject: subject, BodyHTML: bodyHTML, BodyPlain: bodyPlain}, nil } - assigned, err := s.repo.GetAssignedVariant(ctx, campaignID, contactID) + variants, err := s.repo.ListABVariants(ctx, campaignID) if err != nil { return nil, toErrx(err) } - var selected *models.CampaignABVariant - if assigned != nil && assigned.IsActive { - selected = assigned - } else { - variants, err := s.repo.ListABVariants(ctx, campaignID) - if err != nil { - return nil, toErrx(err) + // Partition active variants into step-scoped (this step) and campaign-level. + var stepVariants, campaignVariants []models.CampaignABVariant + for _, v := range variants { + if !v.IsActive { + continue } - active := make([]models.CampaignABVariant, 0, len(variants)) - totalWeight := 0 - for _, v := range variants { - if !v.IsActive { - continue + if v.SequenceID != nil { + if *v.SequenceID == sequenceID { + stepVariants = append(stepVariants, v) } - if v.Weight <= 0 { - v.Weight = 100 - } - totalWeight += v.Weight - active = append(active, v) - } - if len(active) == 0 || totalWeight <= 0 { - return &models.VariantSelection{Subject: subject, BodyHTML: bodyHTML, BodyPlain: bodyPlain}, nil + } else { + campaignVariants = append(campaignVariants, v) } + } - pick := rand.Intn(totalWeight) - running := 0 - for i := range active { - running += active[i].Weight - if pick < running { - selected = &active[i] - break - } + var selected *models.CampaignABVariant + if len(stepVariants) > 0 { + // Step-scoped: deterministic per (contact, step), so the same contact + // always gets the same variant for this step without an assignment row. + selected = pickVariantDeterministic(stepVariants, contactID.String()+":"+sequenceID.String()) + } else if len(campaignVariants) > 0 { + // Campaign-level (legacy): keep the assignment-based selection so a + // contact stays on one variant across the whole campaign. + assigned, aerr := s.repo.GetAssignedVariant(ctx, campaignID, contactID) + if aerr != nil { + return nil, toErrx(aerr) } - if selected != nil { - _ = s.repo.AssignVariant(ctx, campaignID, contactID, selected.ID) + if assigned != nil && assigned.IsActive && assigned.SequenceID == nil { + selected = assigned + } else { + selected = pickVariantWeightedRandom(campaignVariants) + if selected != nil { + _ = s.repo.AssignVariant(ctx, campaignID, contactID, selected.ID) + } } } diff --git a/internal/infrastructure/db/migrations/000016_per_step_ab_variants.down.sql b/internal/infrastructure/db/migrations/000016_per_step_ab_variants.down.sql new file mode 100644 index 00000000..50ba5f7a --- /dev/null +++ b/internal/infrastructure/db/migrations/000016_per_step_ab_variants.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS campaign_ab_variants_unique_name; +DROP INDEX IF EXISTS idx_campaign_ab_variants_sequence; +ALTER TABLE campaign_ab_variants DROP COLUMN IF EXISTS sequence_id; diff --git a/internal/infrastructure/db/migrations/000016_per_step_ab_variants.up.sql b/internal/infrastructure/db/migrations/000016_per_step_ab_variants.up.sql new file mode 100644 index 00000000..a0cd21d4 --- /dev/null +++ b/internal/infrastructure/db/migrations/000016_per_step_ab_variants.up.sql @@ -0,0 +1,13 @@ +-- Per-step A/B variants. A variant may belong to a specific sequence step +-- (sequence_id). NULL = campaign-level (legacy: applies regardless of step). +-- Step-scoped variants are selected deterministically per (contact, step), so +-- they need no assignment-table changes; campaign-level keeps its assignment. +ALTER TABLE campaign_ab_variants ADD COLUMN IF NOT EXISTS sequence_id uuid REFERENCES sequences(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS idx_campaign_ab_variants_sequence ON campaign_ab_variants (sequence_id); + +-- Variant names are unique per (campaign, step). COALESCE the NULL step to a +-- sentinel so campaign-level names also stay unique (NULLs would otherwise be +-- distinct in a plain unique index). +ALTER TABLE campaign_ab_variants DROP CONSTRAINT IF EXISTS campaign_variant_unique_name; +CREATE UNIQUE INDEX IF NOT EXISTS campaign_ab_variants_unique_name + ON campaign_ab_variants (campaign_id, COALESCE(sequence_id, '00000000-0000-0000-0000-000000000000'::uuid), name); diff --git a/internal/models/advanced_outreach.go b/internal/models/advanced_outreach.go index 1ae79ee1..f243c23e 100644 --- a/internal/models/advanced_outreach.go +++ b/internal/models/advanced_outreach.go @@ -138,8 +138,11 @@ type SuppressedRecipient struct { } type CampaignABVariant struct { - ID uuid.UUID `json:"id"` - CampaignID uuid.UUID `json:"campaign_id"` + ID uuid.UUID `json:"id"` + CampaignID uuid.UUID `json:"campaign_id"` + // SequenceID scopes the variant to one step. nil = campaign-level (applies + // to every step, legacy behavior). + SequenceID *uuid.UUID `json:"sequence_id,omitempty"` Name string `json:"name"` Weight int `json:"weight"` Subject string `json:"subject"` @@ -153,14 +156,15 @@ type CampaignABVariant struct { } type CreateCampaignABVariantRequest struct { - Name string `json:"name" binding:"required"` - Weight int `json:"weight"` - Subject string `json:"subject,omitempty"` - BodyHTML string `json:"body_html,omitempty"` - BodyPlain string `json:"body_plain,omitempty"` - IsControl bool `json:"is_control"` - IsActive *bool `json:"is_active,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + Name string `json:"name" binding:"required"` + SequenceID *uuid.UUID `json:"sequence_id,omitempty"` + Weight int `json:"weight"` + Subject string `json:"subject,omitempty"` + BodyHTML string `json:"body_html,omitempty"` + BodyPlain string `json:"body_plain,omitempty"` + IsControl bool `json:"is_control"` + IsActive *bool `json:"is_active,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type UpdateCampaignABVariantRequest struct { diff --git a/internal/repository/pg_advanced_outreach.go b/internal/repository/pg_advanced_outreach.go index d1f84dbe..201387c5 100644 --- a/internal/repository/pg_advanced_outreach.go +++ b/internal/repository/pg_advanced_outreach.go @@ -148,6 +148,7 @@ func scanABVariant(rows pgx.Row, v *models.CampaignABVariant) error { if err := rows.Scan( &v.ID, &v.CampaignID, + &v.SequenceID, &v.Name, &v.Weight, &v.Subject, @@ -174,7 +175,7 @@ func scanABVariant(rows pgx.Row, v *models.CampaignABVariant) error { func (r *advancedOutreachRepository) ListABVariants(ctx context.Context, campaignID uuid.UUID) ([]models.CampaignABVariant, error) { query := ` - SELECT id, campaign_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at + SELECT id, campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at FROM campaign_ab_variants WHERE campaign_id = $1 ORDER BY is_control DESC, created_at ASC @@ -211,14 +212,15 @@ func (r *advancedOutreachRepository) CreateABVariant(ctx context.Context, campai } query := ` INSERT INTO campaign_ab_variants ( - campaign_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at + campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW()) - RETURNING id, campaign_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) + RETURNING id, campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at ` var out models.CampaignABVariant if err := scanABVariant(r.db.QueryRow(ctx, query, campaignID, + req.SequenceID, req.Name, weight, req.Subject, @@ -292,7 +294,7 @@ func (r *advancedOutreachRepository) UpdateABVariant(ctx context.Context, campai UPDATE campaign_ab_variants SET %s WHERE campaign_id = $1 AND id = $2 - RETURNING id, campaign_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at + RETURNING id, campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active, metadata, created_at, updated_at `, strings.Join(sets, ", ")) var out models.CampaignABVariant @@ -316,7 +318,7 @@ func (r *advancedOutreachRepository) DeleteABVariant(ctx context.Context, campai func (r *advancedOutreachRepository) GetAssignedVariant(ctx context.Context, campaignID, contactID uuid.UUID) (*models.CampaignABVariant, error) { query := ` - SELECT v.id, v.campaign_id, v.name, v.weight, v.subject, v.body_html, v.body_plain, v.is_control, v.is_active, v.metadata, v.created_at, v.updated_at + SELECT v.id, v.campaign_id, v.sequence_id, v.name, v.weight, v.subject, v.body_html, v.body_plain, v.is_control, v.is_active, v.metadata, v.created_at, v.updated_at FROM campaign_ab_assignments a JOIN campaign_ab_variants v ON v.id = a.variant_id WHERE a.campaign_id = $1 AND a.contact_id = $2 diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index 00d70e01..1639aa94 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -259,6 +259,24 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { return errx.InternalError() } + // Load campaign attachments (campaign-wide; metadata only — the worker + // fetches the bytes from object storage by S3 key at send time). + var attachmentRefs []models.AttachmentRef + if s.attachmentRepo != nil { + atts, attErr := s.attachmentRepo.ListByCampaign(ctx, campaign.ID) + if attErr != nil { + log.Warn().Err(attErr).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()).Msg("Failed to load campaign attachments") + } else { + for _, a := range atts { + attachmentRefs = append(attachmentRefs, models.AttachmentRef{ + S3Key: a.S3Key, + Filename: a.Filename, + MimeType: a.MimeType, + }) + } + } + } + // STEP 7.5: Update campaign task with contact_id and sequence_id for tracking // This allows the tracking consumer to find the correct contact/sequence when // processing open/click events from the tracking pixel service @@ -297,7 +315,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { } if s.advanced != nil && campaign.OrganizationID != nil { - selection, sxerr := s.advanced.SelectVariant(ctx, *campaign.OrganizationID, campaign.ID, contact.ID, subject, bodyHTML, bodyPlain) + selection, sxerr := s.advanced.SelectVariant(ctx, *campaign.OrganizationID, campaign.ID, contact.ID, sequence.ID, subject, bodyHTML, bodyPlain) if sxerr != nil { return sxerr } @@ -404,6 +422,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) *errx.Error { Tracking: tracking, UserID: userUUID, UnsubscribeURL: unsubscribeURL, + Attachments: attachmentRefs, } if err := s.emailSender.Send(ctx, taskID, emailMsg, *account); err != nil { From 3c440a9f0cbaf0e7b714b4b87b7ef43e7a95421e Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 5 Jun 2026 06:03:13 +0200 Subject: [PATCH 29/60] feat: add campaign attachment endpoints Persist campaign attachment metadata, enforce attachment storage quotas, and expose list, upload, and delete routes backed by object storage. --- cmd/backend/main.go | 24 ++ internal/api/handler/attachment.go | 234 ++++++++++++++++++ internal/api/handler/handler.go | 8 + internal/api/routes.go | 9 + internal/app/feature/gate.go | 41 +++ .../000017_campaign_attachments.down.sql | 1 + .../000017_campaign_attachments.up.sql | 16 ++ internal/models/attachment.go | 29 +++ internal/repository/pg_attachment.go | 95 +++++++ internal/tasks/service.go | 3 + 10 files changed, 460 insertions(+) create mode 100644 internal/api/handler/attachment.go create mode 100644 internal/infrastructure/db/migrations/000017_campaign_attachments.down.sql create mode 100644 internal/infrastructure/db/migrations/000017_campaign_attachments.up.sql create mode 100644 internal/models/attachment.go create mode 100644 internal/repository/pg_attachment.go diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 5520617a..78d3959d 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -28,6 +28,7 @@ import ( "github.com/warmbly/warmbly/internal/app/campaign" "github.com/warmbly/warmbly/internal/app/cipher" "github.com/warmbly/warmbly/internal/app/contact" + "github.com/warmbly/warmbly/internal/app/credits" "github.com/warmbly/warmbly/internal/app/crm" "github.com/warmbly/warmbly/internal/app/dailythrottle" "github.com/warmbly/warmbly/internal/app/dangerzone" @@ -124,6 +125,9 @@ func main() { var advancedService advanced.Service var warmupContentRepo repository.WarmupContentRepository var warmupContentService warmupcontent.Service + var creditRepository repository.CreditRepository + var creditService credits.CreditService + var writingGenerator generation.WritingGenerator var emailVerifyService emailverifyapp.Service var placementRepository repository.PlacementRepository var placementService placement.Service @@ -185,6 +189,7 @@ func main() { var webhookServiceForHandler webhook.Service var integrationServiceForHandler integration.Service var contactRepoForHandler repository.ContactRepository + var attachmentRepoForHandler repository.AttachmentRepository var leadSyncServiceForHandler leadsync.Service ctx, cancel := context.WithCancel(context.Background()) @@ -447,6 +452,7 @@ func main() { campaignRepostory := repository.NewCampaignRepostory(primaryDB) sequenceRepostory := repository.NewSequenceRepostory(primaryDB) contactRepostory := repository.NewContactRepostory(primaryDB) + attachmentRepoForHandler = repository.NewAttachmentRepository(primaryDB) uniboxRepository := repository.NewUniboxRepository(primaryDB) encryptedKeys, err = encryptedkeys.FromEnv( encryptedkeys.Deps{DB: primaryDB}, @@ -497,6 +503,18 @@ func main() { generationClient = generation.NewClient(openaiKey) } warmupContentService = warmupcontent.NewService(warmupContentRepo, generationClient) + + // AI writing assistant: prefer Anthropic (claude-haiku free / sonnet paid); + // fall back to the existing OpenAI client when ANTHROPIC_API_KEY is unset. + // If neither is configured, writingGenerator stays nil and the endpoint + // returns 503 "not configured". + if anthropicKey := cfg.GetSecretOptional(ctx, "ANTHROPIC_API_KEY", "anthropic_api_key", ""); anthropicKey != "" { + writingGenerator = generation.NewAnthropicClient(anthropicKey) + } else if generationClient != nil { + writingGenerator = generationClient + } + creditRepository = repository.NewCreditRepository(primaryDB) + creditService = credits.NewService(creditRepository, cache) webhookRepository := repository.NewWebhookRepository(primaryDB.Pool) webhookService := webhook.NewService(webhookRepository) webhookServiceForHandler = webhookService @@ -839,6 +857,7 @@ func main() { contactRepostory, campaignLogRepository, advancedService, + attachmentRepoForHandler, ) // Admin outreach composer — sends from the platform mailer @@ -997,6 +1016,10 @@ func main() { WarmupContentRepo: warmupContentRepo, WarmupContentService: warmupContentService, + // AI writing assistant + credit ledger + CreditService: creditService, + WritingGenerator: writingGenerator, + // Pre-send email verification EmailVerifyService: emailVerifyService, @@ -1020,6 +1043,7 @@ func main() { EmailMessageMap: emailMessageMapForHandler, UserRepo: userRepoForHandler, OrgRepo: organizationRepoForHandler, + AttachmentRepo: attachmentRepoForHandler, StorageBackendRepo: storageBackendRepo, CloudCredentialRepo: cloudCredentialRepo, ProvisioningTemplateRepo: provisioningTemplateRepo, diff --git a/internal/api/handler/attachment.go b/internal/api/handler/attachment.go new file mode 100644 index 00000000..fea85484 --- /dev/null +++ b/internal/api/handler/attachment.go @@ -0,0 +1,234 @@ +// Campaign email attachment handlers — upload, list, delete. Binary lives in +// object storage (private; surfaced to the browser via short-lived presigned +// URLs and fetched by the worker at send time). Overall storage is capped per +// organization by the plan-based quota (feature gate). + +package handler + +import ( + "bytes" + "fmt" + "io" + "net/http" + "path" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/models" +) + +const ( + // Per-file cap. Kept under provider message ceilings (Gmail/Outlook ~25 MB + // total, and base64 inflates ~37%), so a single attachment always fits. + attachmentMaxBytes int64 = 15 * 1024 * 1024 + attachmentURLTTL = 15 * time.Minute +) + +// Executable / script types that must never ride an outbound email. Everything +// else (images, PDF, office docs, CSV, archives, …) is allowed. +var blockedAttachmentExt = map[string]bool{ + ".exe": true, ".bat": true, ".cmd": true, ".com": true, ".msi": true, + ".scr": true, ".js": true, ".jse": true, ".vbs": true, ".vbe": true, + ".ps1": true, ".sh": true, ".jar": true, ".app": true, ".dll": true, + ".cpl": true, ".hta": true, ".wsf": true, ".pif": true, +} + +func sanitizeFilename(name string) string { + name = path.Base(strings.ReplaceAll(name, "\\", "/")) + name = strings.TrimSpace(name) + if name == "" || name == "." || name == ".." { + return "file" + } + if len(name) > 200 { + name = name[len(name)-200:] + } + return name +} + +func mb(b int64) int64 { return b / (1024 * 1024) } + +// UploadCampaignAttachment — POST /campaigns/:id/attachments (multipart "file") +func (h *Handler) UploadCampaignAttachment(c *gin.Context) { + campaignID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + userID, err := uuid.Parse(middleware.GetUserID(c)) + if err != nil { + errx.JSON(c, errx.ErrAuth) + return + } + if h.Storage == nil { + errx.JSON(c, errx.New(errx.ServiceUnavailable, "object storage not configured")) + return + } + + // Optional sequence_id form field scopes the attachment to one step. + var seqID *uuid.UUID + if s := strings.TrimSpace(c.PostForm("sequence_id")); s != "" { + if id, perr := uuid.Parse(s); perr == nil { + seqID = &id + } + } + + // Cap the body before parsing so a huge upload can't pin a worker. + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, attachmentMaxBytes+(1<<20)) + fh, err := c.FormFile("file") + if err != nil { + errx.JSON(c, errx.New(errx.BadRequest, "file is required")) + return + } + if fh.Size <= 0 || fh.Size > attachmentMaxBytes { + errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf("file must be between 1 byte and %d MB", mb(attachmentMaxBytes)))) + return + } + filename := sanitizeFilename(fh.Filename) + if blockedAttachmentExt[strings.ToLower(path.Ext(filename))] { + errx.JSON(c, errx.New(errx.BadRequest, "that file type can't be attached to email")) + return + } + + // Plan-based overall storage quota (org-wide). + limit, xerr := h.FeatureGateService.GetStorageLimitBytes(c.Request.Context(), *orgID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + used, err := h.AttachmentRepo.SumStorageUsedByOrg(c.Request.Context(), *orgID) + if err != nil { + errx.JSON(c, errx.InternalError()) + return + } + if used+fh.Size > limit { + errx.JSON(c, errx.New(errx.BadRequest, fmt.Sprintf( + "storage limit reached (%d MB of %d MB used) — remove attachments or upgrade your plan", mb(used), mb(limit)))) + return + } + + src, err := fh.Open() + if err != nil { + errx.JSON(c, errx.InternalError()) + return + } + defer src.Close() + buf := &bytes.Buffer{} + if _, err := io.Copy(buf, src); err != nil { + errx.JSON(c, errx.InternalError()) + return + } + body := buf.Bytes() + mimeType := fh.Header.Get("Content-Type") + if mimeType == "" { + mimeType = http.DetectContentType(body) + } + + key := fmt.Sprintf("attachments/%s/%s-%s", campaignID.String(), uuid.NewString(), filename) + if err := h.Storage.Put(c.Request.Context(), key, bytes.NewReader(body), mimeType); err != nil { + errx.JSON(c, errx.InternalError()) + return + } + + att := &models.CampaignAttachment{ + CampaignID: campaignID, + SequenceID: seqID, + UserID: userID, + Filename: filename, + Size: fh.Size, + MimeType: mimeType, + S3Key: key, + } + if err := h.AttachmentRepo.Create(c.Request.Context(), att); err != nil { + _ = h.Storage.Delete(c.Request.Context(), key) // best-effort cleanup + errx.JSON(c, errx.InternalError()) + return + } + + h.auditOrg(c, models.AuditActionCreate, models.AuditEntityCampaign, &att.ID, nil, map[string]string{ + "scope": "attachment", "campaign_id": campaignID.String(), "filename": filename, + }) + + c.JSON(http.StatusCreated, h.attachmentResponse(c, att)) +} + +// ListCampaignAttachments — GET /campaigns/:id/attachments +func (h *Handler) ListCampaignAttachments(c *gin.Context) { + campaignID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + atts, err := h.AttachmentRepo.ListByCampaign(c.Request.Context(), campaignID) + if err != nil { + errx.JSON(c, errx.InternalError()) + return + } + out := make([]gin.H, 0, len(atts)) + for i := range atts { + out = append(out, h.attachmentResponse(c, &atts[i])) + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +// DeleteCampaignAttachment — DELETE /campaigns/:id/attachments/:attachmentId +func (h *Handler) DeleteCampaignAttachment(c *gin.Context) { + campaignID, err := uuid.Parse(c.Param("id")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + attID, err := uuid.Parse(c.Param("attachmentId")) + if err != nil { + errx.JSON(c, errx.ErrUuid) + return + } + att, err := h.AttachmentRepo.GetByID(c.Request.Context(), attID) + if err != nil { + errx.JSON(c, errx.InternalError()) + return + } + if att == nil || att.CampaignID != campaignID { + errx.JSON(c, errx.ErrNotFound) + return + } + if err := h.AttachmentRepo.Delete(c.Request.Context(), attID); err != nil { + errx.JSON(c, errx.InternalError()) + return + } + if h.Storage != nil { + _ = h.Storage.Delete(c.Request.Context(), att.S3Key) + } + h.auditOrg(c, models.AuditActionDelete, models.AuditEntityCampaign, &attID, nil, map[string]string{ + "scope": "attachment", "campaign_id": campaignID.String(), + }) + c.Status(http.StatusNoContent) +} + +func (h *Handler) attachmentResponse(c *gin.Context, att *models.CampaignAttachment) gin.H { + url := "" + if h.Storage != nil { + if u, err := h.Storage.PresignedGetURL(c.Request.Context(), att.S3Key, attachmentURLTTL); err == nil { + url = u + } + } + return gin.H{ + "id": att.ID, + "campaign_id": att.CampaignID, + "sequence_id": att.SequenceID, + "filename": att.Filename, + "size": att.Size, + "mime_type": att.MimeType, + "url": url, + "created_at": att.CreatedAt, + } +} diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go index 6a35f820..863cca2d 100644 --- a/internal/api/handler/handler.go +++ b/internal/api/handler/handler.go @@ -10,6 +10,7 @@ import ( "github.com/warmbly/warmbly/internal/app/auth" "github.com/warmbly/warmbly/internal/app/campaign" "github.com/warmbly/warmbly/internal/app/contact" + "github.com/warmbly/warmbly/internal/app/credits" "github.com/warmbly/warmbly/internal/app/crm" "github.com/warmbly/warmbly/internal/app/dangerzone" "github.com/warmbly/warmbly/internal/app/discount" @@ -40,6 +41,8 @@ import ( "github.com/warmbly/warmbly/internal/app/webhook" "github.com/warmbly/warmbly/internal/app/worker" "github.com/warmbly/warmbly/internal/app/worker_orchestrator" + "github.com/warmbly/warmbly/internal/pkg/generation" + "github.com/warmbly/warmbly/internal/infrastructure/encryptedkeys" "github.com/warmbly/warmbly/internal/infrastructure/storage" "github.com/warmbly/warmbly/internal/notify" @@ -123,6 +126,10 @@ type Handler struct { WarmupContentRepo repository.WarmupContentRepository WarmupContentService warmupcontent.Service + // AI writing assistant + credit ledger. + CreditService credits.CreditService + WritingGenerator generation.WritingGenerator + // Seed inbox-placement testing. PlacementRepo repository.PlacementRepository PlacementService placement.Service @@ -161,6 +168,7 @@ type Handler struct { // only when business logic accumulates. UserRepo repository.UserRepository OrgRepo repository.OrganizationRepository + AttachmentRepo repository.AttachmentRepository StorageBackendRepo repository.StorageBackendRepository CloudCredentialRepo repository.CloudCredentialRepository ProvisioningTemplateRepo repository.ProvisioningTemplateRepository diff --git a/internal/api/routes.go b/internal/api/routes.go index 3d097887..c4ba7c85 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -256,6 +256,9 @@ func Run( campaigns.POST("/:id/ab-variants", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.CreateCampaignABVariant) campaigns.PATCH("/:id/ab-variants/:variantId", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.UpdateCampaignABVariant) campaigns.DELETE("/:id/ab-variants/:variantId", m.RequireOrganization(), m.RequireAccess(models.PermManageSettings, models.APIPermWriteCampaigns), h.DeleteCampaignABVariant) + campaigns.GET("/:id/attachments", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.ListCampaignAttachments) + campaigns.POST("/:id/attachments", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.UploadCampaignAttachment) + campaigns.DELETE("/:id/attachments/:attachmentId", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.DeleteCampaignAttachment) campaigns.POST("/:id/preflight", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.RunCampaignPreflight) campaigns.GET("/:id/ab-analysis", m.RequireOrganization(), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetCampaignABAnalysis) campaigns.POST("/:id/test-email", m.RequireOrganization(), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.SendTestEmail) @@ -281,6 +284,12 @@ func Run( } } + generation := protected.Group("/generation") + generation.Use(m.RateLimitMiddleware(models.RateLimitWrite)) + { + generation.POST("/write", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.GenerateWriting) + } + contacts := protected.Group("/contacts") contacts.Use(m.RateLimitMiddleware(models.RateLimitWrite)) { diff --git a/internal/app/feature/gate.go b/internal/app/feature/gate.go index f6a208a0..79e4c446 100644 --- a/internal/app/feature/gate.go +++ b/internal/app/feature/gate.go @@ -15,6 +15,12 @@ const ( // UnlimitedEmails indicates unlimited daily emails UnlimitedEmails = -1 + + // Attachment storage quotas (overall bytes per organization). Generous by + // design; paid orgs get a much larger pool. Tunable per-plan later via a + // plans column without changing callers. + FreeTierStorageBytes int64 = 2 << 30 // 2 GiB for free / trial orgs + PaidStorageBytes int64 = 50 << 30 // 50 GiB for paid orgs ) type FeatureGateService interface { @@ -43,6 +49,14 @@ type FeatureGateService interface { // IsPaidOrganization checks if the organization has an active paid subscription IsPaidOrganization(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) + + // GetStorageLimitBytes returns the org's total attachment storage quota in + // bytes (generous; larger for paid orgs). + GetStorageLimitBytes(ctx context.Context, orgID uuid.UUID) (int64, *errx.Error) + + // CanUseWritingAssistant reports whether the org may use the AI writing + // assistant (paid or in free trial; expired/no-subscription is blocked). + CanUseWritingAssistant(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) } // SubscriptionStatus contains status info for feature gating @@ -215,6 +229,33 @@ func (s *featureGateService) GetSubscriptionStatus(ctx context.Context, orgID uu return status, nil } +// GetStorageLimitBytes returns the org's attachment storage quota. Paid orgs +// get the larger pool; everyone else (trial or no subscription) gets the free +// allowance so they can still attach files. +func (s *featureGateService) GetStorageLimitBytes(ctx context.Context, orgID uuid.UUID) (int64, *errx.Error) { + sub, err := s.subRepo.GetByOrganizationID(ctx, orgID) + if err != nil { + return 0, errx.New(errx.Internal, "failed to get subscription") + } + if sub != nil && sub.HasPaidSubscription() { + return PaidStorageBytes, nil + } + return FreeTierStorageBytes, nil +} + +// CanUseWritingAssistant — same trial allowance as warmup/unibox: paid +// subscribers and orgs inside their free-trial window may use the AI assistant. +func (s *featureGateService) CanUseWritingAssistant(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) { + sub, err := s.subRepo.GetByOrganizationID(ctx, orgID) + if err != nil { + return false, errx.New(errx.Internal, "failed to get subscription") + } + if sub == nil { + return false, nil + } + return sub.HasPaidSubscription() || sub.IsInFreeTrial(), nil +} + // IsPaidOrganization checks if the organization has an active paid subscription func (s *featureGateService) IsPaidOrganization(ctx context.Context, orgID uuid.UUID) (bool, *errx.Error) { sub, err := s.subRepo.GetByOrganizationID(ctx, orgID) diff --git a/internal/infrastructure/db/migrations/000017_campaign_attachments.down.sql b/internal/infrastructure/db/migrations/000017_campaign_attachments.down.sql new file mode 100644 index 00000000..4598c295 --- /dev/null +++ b/internal/infrastructure/db/migrations/000017_campaign_attachments.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS campaign_attachments; diff --git a/internal/infrastructure/db/migrations/000017_campaign_attachments.up.sql b/internal/infrastructure/db/migrations/000017_campaign_attachments.up.sql new file mode 100644 index 00000000..9254afb3 --- /dev/null +++ b/internal/infrastructure/db/migrations/000017_campaign_attachments.up.sql @@ -0,0 +1,16 @@ +-- Campaign email attachments. Binary lives in S3 (s3_key); this row is the +-- metadata + ownership record. sequence_id is nullable: NULL = available to +-- every step of the campaign; set = scoped to one step. +CREATE TABLE IF NOT EXISTS campaign_attachments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id uuid NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + sequence_id uuid REFERENCES sequences(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + filename text NOT NULL, + size bigint NOT NULL, + mime_type text NOT NULL, + s3_key text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_campaign_attachments_campaign ON campaign_attachments (campaign_id); +CREATE INDEX IF NOT EXISTS idx_campaign_attachments_sequence ON campaign_attachments (sequence_id); diff --git a/internal/models/attachment.go b/internal/models/attachment.go new file mode 100644 index 00000000..337d8979 --- /dev/null +++ b/internal/models/attachment.go @@ -0,0 +1,29 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// CampaignAttachment is the metadata + ownership record for an email +// attachment. The binary itself lives in object storage at S3Key. +type CampaignAttachment struct { + ID uuid.UUID `json:"id"` + CampaignID uuid.UUID `json:"campaign_id"` + SequenceID *uuid.UUID `json:"sequence_id,omitempty"` + UserID uuid.UUID `json:"user_id"` + Filename string `json:"filename"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + S3Key string `json:"s3_key"` + CreatedAt time.Time `json:"created_at"` +} + +// AttachmentRef is the lightweight reference carried through the send pipeline +// (backend → Kafka → worker). The worker fetches the bytes from S3 by key. +type AttachmentRef struct { + S3Key string `json:"s3_key"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` +} diff --git a/internal/repository/pg_attachment.go b/internal/repository/pg_attachment.go new file mode 100644 index 00000000..764de7e7 --- /dev/null +++ b/internal/repository/pg_attachment.go @@ -0,0 +1,95 @@ +package repository + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/warmbly/warmbly/internal/infrastructure/db" + "github.com/warmbly/warmbly/internal/models" +) + +// AttachmentRepository persists campaign attachment metadata. Binary content +// lives in object storage; these rows track ownership, size (for quota), and +// the S3 key so the worker can fetch the bytes at send time. +type AttachmentRepository interface { + Create(ctx context.Context, att *models.CampaignAttachment) error + GetByID(ctx context.Context, id uuid.UUID) (*models.CampaignAttachment, error) + ListByCampaign(ctx context.Context, campaignID uuid.UUID) ([]models.CampaignAttachment, error) + Delete(ctx context.Context, id uuid.UUID) error + // SumStorageUsedByOrg totals the bytes of every attachment owned by the org + // (joined through campaigns) — the basis for the per-plan storage quota. + SumStorageUsedByOrg(ctx context.Context, orgID uuid.UUID) (int64, error) +} + +type attachmentRepository struct { + DB *db.DB +} + +func NewAttachmentRepository(database *db.DB) AttachmentRepository { + return &attachmentRepository{DB: database} +} + +const attachmentCols = `id, campaign_id, sequence_id, user_id, filename, size, mime_type, s3_key, created_at` + +func scanAttachment(row pgx.Row, a *models.CampaignAttachment) error { + return row.Scan( + &a.ID, &a.CampaignID, &a.SequenceID, &a.UserID, + &a.Filename, &a.Size, &a.MimeType, &a.S3Key, &a.CreatedAt, + ) +} + +func (r *attachmentRepository) Create(ctx context.Context, att *models.CampaignAttachment) error { + return scanAttachment(r.DB.QueryRow(ctx, ` + INSERT INTO campaign_attachments (campaign_id, sequence_id, user_id, filename, size, mime_type, s3_key) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING `+attachmentCols, + att.CampaignID, att.SequenceID, att.UserID, att.Filename, att.Size, att.MimeType, att.S3Key, + ), att) +} + +func (r *attachmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.CampaignAttachment, error) { + a := &models.CampaignAttachment{} + err := scanAttachment(r.DB.QueryRow(ctx, `SELECT `+attachmentCols+` FROM campaign_attachments WHERE id = $1`, id), a) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return a, nil +} + +func (r *attachmentRepository) ListByCampaign(ctx context.Context, campaignID uuid.UUID) ([]models.CampaignAttachment, error) { + rows, err := r.DB.Query(ctx, `SELECT `+attachmentCols+` FROM campaign_attachments WHERE campaign_id = $1 ORDER BY created_at ASC`, campaignID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.CampaignAttachment, 0) + for rows.Next() { + var a models.CampaignAttachment + if err := scanAttachment(rows, &a); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (r *attachmentRepository) Delete(ctx context.Context, id uuid.UUID) error { + _, err := r.DB.Exec(ctx, `DELETE FROM campaign_attachments WHERE id = $1`, id) + return err +} + +func (r *attachmentRepository) SumStorageUsedByOrg(ctx context.Context, orgID uuid.UUID) (int64, error) { + var total int64 + err := r.DB.QueryRow(ctx, ` + SELECT COALESCE(SUM(ca.size), 0) + FROM campaign_attachments ca + JOIN campaigns c ON c.id = ca.campaign_id + WHERE c.organization_id = $1 + `, orgID).Scan(&total) + return total, err +} diff --git a/internal/tasks/service.go b/internal/tasks/service.go index f0e50d77..ae46490e 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -78,6 +78,7 @@ type tasksService struct { campaignRepo repository.CampaignRepository contactRepo repository.ContactRepository campaignLogRepo repository.CampaignLogRepository + attachmentRepo repository.AttachmentRepository // warmupSettings caches the warmup generation settings in-process so the // per-send AI-vs-static decision doesn't hit Postgres on every warmup. @@ -112,6 +113,7 @@ func NewService( contactRepo repository.ContactRepository, campaignLogRepo repository.CampaignLogRepository, advanced advanced.Service, + attachmentRepo repository.AttachmentRepository, ) TasksService { return &tasksService{ tasksClient: tasksClient, @@ -134,6 +136,7 @@ func NewService( campaignRepo: campaignRepo, contactRepo: contactRepo, campaignLogRepo: campaignLogRepo, + attachmentRepo: attachmentRepo, warmupSettings: &warmupSettingsCache{}, } } From b701e4efab1a0186d83b02ca556660e352d04360 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 5 Jun 2026 06:03:21 +0200 Subject: [PATCH 30/60] feat: send campaign email attachments Carry attachment references inside the stored email body blob, resolve bytes on workers, and encode attachments for Gmail API and SMTP sends without changing the Kafka send-email contract. --- internal/app/worker/event_send_email.go | 65 +++++++-- internal/app/worker/wmail/send.go | 92 ++++++++---- internal/client/goog/send.go | 184 ++++++++++++++++++++++++ internal/client/smtpimap/smtp/client.go | 111 +++++++++++--- internal/events/publisher.go | 31 +++- internal/pkg/emsg/emsg.go | 85 ++++++++++- internal/pkg/emsg/emsg_test.go | 32 +++++ internal/tasks/email_sender.go | 5 + 8 files changed, 543 insertions(+), 62 deletions(-) diff --git a/internal/app/worker/event_send_email.go b/internal/app/worker/event_send_email.go index be0fa937..16292aff 100644 --- a/internal/app/worker/event_send_email.go +++ b/internal/app/worker/event_send_email.go @@ -52,14 +52,23 @@ func (w *WorkerService) HandleSendEmail(ctx context.Context, body any) error { } } - // Fetch email body from S3 - bodyPlain, bodyHTML, err := w.fetchEmailBody(ctx, sendEmail.UserID, sendEmail.BodyS3Key) + // Fetch email body from S3 (attachment refs ride inside the emsg blob). + bodyPlain, bodyHTML, attachmentRefs, err := w.fetchEmailBody(ctx, sendEmail.UserID, sendEmail.BodyS3Key) if err != nil { log.Error().Err(err).Str("s3_key", sendEmail.BodyS3Key).Msg("Failed to fetch email body from S3") w.sendEmailFailure(sendEmail.TaskID, sendEmail.EmailID, mail, fmt.Sprintf("failed to fetch email body: %v", err)) return err } + // Fetch each attachment's bytes from object storage by key. A fetch failure + // fails the send rather than silently dropping a file the user expects. + attachments, err := w.fetchAttachments(ctx, attachmentRefs) + if err != nil { + log.Error().Err(err).Str("task_id", sendEmail.TaskID.String()).Msg("Failed to fetch attachment bytes from S3") + w.sendEmailFailure(sendEmail.TaskID, sendEmail.EmailID, mail, fmt.Sprintf("failed to fetch attachment: %v", err)) + return err + } + // Use unified Send method w.recordSendAttempt() sendStart := time.Now() @@ -77,6 +86,7 @@ func (w *WorkerService) HandleSendEmail(ctx context.Context, body any) error { IsWarmup: sendEmail.IsWarmup, WarmupToken: sendEmail.WarmupToken, UnsubscribeURL: sendEmail.UnsubscribeURL, + Attachments: attachments, }) w.recordSendLatency(time.Since(sendStart)) w.recordSendOutcome(result) @@ -118,29 +128,30 @@ func (w *WorkerService) deleteTransportEmailBody(ctx context.Context, taskID uui } } -// fetchEmailBody fetches and decodes email body from S3 -func (w *WorkerService) fetchEmailBody(ctx context.Context, userID uuid.UUID, s3Key string) (string, string, error) { +// fetchEmailBody fetches and decodes the email body from S3, returning the +// decrypted plain/HTML bodies and the attachment refs carried inside the blob. +func (w *WorkerService) fetchEmailBody(ctx context.Context, userID uuid.UUID, s3Key string) (string, string, []emsg.Attachment, error) { if w.Storage == nil { - return "", "", fmt.Errorf("storage client not configured") + return "", "", nil, fmt.Errorf("storage client not configured") } // Get object from storage body, err := w.Storage.Get(ctx, s3Key) if err != nil { - return "", "", fmt.Errorf("failed to get S3 object: %w", err) + return "", "", nil, fmt.Errorf("failed to get S3 object: %w", err) } defer body.Close() // Read the body data, err := io.ReadAll(body) if err != nil { - return "", "", fmt.Errorf("failed to read S3 object: %w", err) + return "", "", nil, fmt.Errorf("failed to read S3 object: %w", err) } // Decode using emsg blob, err := emsg.DecodeBinary(bytes.NewReader(data)) if err != nil { - return "", "", fmt.Errorf("failed to decode emsg blob: %w", err) + return "", "", nil, fmt.Errorf("failed to decode emsg blob: %w", err) } bodyPlain := string(blob.PlainText) @@ -161,7 +172,43 @@ func (w *WorkerService) fetchEmailBody(ctx context.Context, userID uuid.UUID, s3 } } - return bodyPlain, bodyHTML, nil + return bodyPlain, bodyHTML, blob.Attachments, nil +} + +// fetchAttachments downloads each attachment's bytes from object storage by +// key, returning wmail attachments ready to be MIME-encoded. The bytes are +// stored as-is (not user-encrypted), so no cipher pass is needed here. +func (w *WorkerService) fetchAttachments(ctx context.Context, refs []emsg.Attachment) ([]wmail.Attachment, error) { + if len(refs) == 0 { + return nil, nil + } + if w.Storage == nil { + return nil, fmt.Errorf("storage client not configured") + } + + out := make([]wmail.Attachment, 0, len(refs)) + for _, ref := range refs { + rc, err := w.Storage.Get(ctx, ref.S3Key) + if err != nil { + return nil, fmt.Errorf("get attachment %s: %w", ref.Filename, err) + } + data, readErr := io.ReadAll(rc) + rc.Close() + if readErr != nil { + return nil, fmt.Errorf("read attachment %s: %w", ref.Filename, readErr) + } + + mimeType := ref.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + out = append(out, wmail.Attachment{ + Filename: ref.Filename, + MimeType: mimeType, + Data: data, + }) + } + return out, nil } // sendEmailSuccess sends a success result back to the jobs service diff --git a/internal/app/worker/wmail/send.go b/internal/app/worker/wmail/send.go index 4e736bd0..5bea86cf 100644 --- a/internal/app/worker/wmail/send.go +++ b/internal/app/worker/wmail/send.go @@ -7,11 +7,20 @@ import ( "github.com/google/uuid" "github.com/warmbly/warmbly/internal/client/goog" + "github.com/warmbly/warmbly/internal/client/smtpimap/smtp" "github.com/warmbly/warmbly/internal/config" "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/models" ) +// Attachment is a fully-resolved attachment ready to be MIME-encoded: the +// worker has already fetched Data from object storage. +type Attachment struct { + Filename string + MimeType string + Data []byte +} + // SendRequest contains all parameters needed to send an email type SendRequest struct { TaskID uuid.UUID @@ -29,6 +38,9 @@ type SendRequest struct { // UnsubscribeURL, when set (campaign sends with the unsubscribe header // enabled), produces RFC 8058 one-click unsubscribe headers. UnsubscribeURL string + // Attachments, when present, are encoded as multipart/mixed parts after the + // multipart/alternative text body. Warmup sends never carry attachments. + Attachments []Attachment } // buildSendHeaders assembles the outbound custom headers: the warmup @@ -145,6 +157,10 @@ func (w *WMail) sendViaGmail(ctx context.Context, req *SendRequest, bodyHTML str // Build custom headers (warmup token + RFC 8058 one-click unsubscribe). customHeaders := buildSendHeaders(req) + // Convert resolved attachments to the goog transport shape (warmup sends + // carry none, but threading req.Attachments is harmless when empty). + attachments := toGoogAttachments(req.Attachments) + // Send via Gmail API gmailMsg, err := w.GoogleData.Client.SendMessage( ctx, @@ -156,6 +172,7 @@ func (w *WMail) sendViaGmail(ctx context.Context, req *SendRequest, bodyHTML str req.BodyPlain, bodyHTML, parent, + attachments, customHeaders, ) if err != nil { @@ -200,32 +217,23 @@ func (w *WMail) sendViaSMTP(ctx context.Context, req *SendRequest, bodyHTML stri // Build custom headers (warmup token + RFC 8058 one-click unsubscribe). smtpCustomHeaders := buildSendHeaders(req) - // Send via SMTP - var merr *errx.MailError - if smtpCustomHeaders != nil { - merr = w.SmtpImapData.SmtpClient.Send( - ctx, - req.To, - req.Cc, - req.Bcc, - req.Subject, - req.BodyPlain, - bodyHTML, - req.InReplyTo, - smtpCustomHeaders, - ) - } else { - merr = w.SmtpImapData.SmtpClient.Send( - ctx, - req.To, - req.Cc, - req.Bcc, - req.Subject, - req.BodyPlain, - bodyHTML, - req.InReplyTo, - ) - } + // Convert resolved attachments to the SMTP transport shape. + smtpAttachments := toSMTPAttachments(req.Attachments) + + // Send via SMTP. Attachments are passed explicitly (not variadic) so an + // empty list still selects the same code path. + merr := w.SmtpImapData.SmtpClient.Send( + ctx, + req.To, + req.Cc, + req.Bcc, + req.Subject, + req.BodyPlain, + bodyHTML, + req.InReplyTo, + smtpAttachments, + smtpCustomHeaders, + ) if merr != nil { result.Error = merr return result @@ -237,6 +245,38 @@ func (w *WMail) sendViaSMTP(ctx context.Context, req *SendRequest, bodyHTML stri return result } +// toGoogAttachments maps wmail attachments to the Gmail transport shape. +func toGoogAttachments(in []Attachment) []goog.Attachment { + if len(in) == 0 { + return nil + } + out := make([]goog.Attachment, 0, len(in)) + for _, a := range in { + out = append(out, goog.Attachment{ + Filename: a.Filename, + MimeType: a.MimeType, + Data: a.Data, + }) + } + return out +} + +// toSMTPAttachments maps wmail attachments to the SMTP transport shape. +func toSMTPAttachments(in []Attachment) []smtp.Attachment { + if len(in) == 0 { + return nil + } + out := make([]smtp.Attachment, 0, len(in)) + for _, a := range in { + out = append(out, smtp.Attachment{ + Filename: a.Filename, + MimeType: a.MimeType, + Data: a.Data, + }) + } + return out +} + // DetermineErrorEventType maps a MailError to the appropriate JobEventType func DetermineErrorEventType(err *errx.MailError) models.JobEventType { if err == nil { diff --git a/internal/client/goog/send.go b/internal/client/goog/send.go index 9f891f11..a926dea0 100644 --- a/internal/client/goog/send.go +++ b/internal/client/goog/send.go @@ -1,23 +1,47 @@ package goog import ( + "bytes" "context" "encoding/base64" "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/textproto" "strings" "github.com/warmbly/warmbly/internal/models" "google.golang.org/api/gmail/v1" ) +// Attachment is a fully-resolved file ready to be MIME-encoded into an outbound +// message. Data is the raw bytes; MimeType drives the Content-Type. +type Attachment struct { + Filename string + MimeType string + Data []byte +} + func (c *Client) SendMessage( ctx context.Context, to, cc, bcc []string, messageID, subject, bodyPlain, bodyHTML string, parent *models.EmailMessageData, + attachments []Attachment, customHeaders ...map[string]string, ) (*gmail.Message, error) { + // Attachments require a multipart/mixed MIME tree, which the structured + // gmail.MessagePart API does not express well (no per-part raw bytes with + // Content-Disposition). Build a raw RFC 5322 message and submit it as + // base64url Raw. The no-attachment path keeps the existing structured form + // so threading/back-compat behavior is unchanged. + if len(attachments) > 0 { + return c.sendRawWithAttachments(to, cc, bcc, messageID, subject, bodyPlain, bodyHTML, parent, attachments, customHeaders...) + } + // Compose headers headers := []*gmail.MessagePartHeader{ {Name: "From", Value: c.GetAddress()}, @@ -100,3 +124,163 @@ func (c *Client) SendMessage( return sent, nil } + +// sendRawWithAttachments builds a multipart/mixed RFC 5322 message +// (multipart/alternative for text+html, then one application/* part per +// attachment) and submits it via the Gmail API as base64url-encoded Raw. +func (c *Client) sendRawWithAttachments( + to, cc, bcc []string, + messageID, + subject, bodyPlain, bodyHTML string, + parent *models.EmailMessageData, + attachments []Attachment, + customHeaders ...map[string]string, +) (*gmail.Message, error) { + var hdrs []header + hdrs = append(hdrs, + header{"From", c.GetAddress()}, + header{"To", strings.Join(to, ", ")}, + header{"Subject", subject}, + header{"Message-ID", messageID}, + header{"MIME-Version", "1.0"}, + ) + if len(cc) > 0 { + hdrs = append(hdrs, header{"Cc", strings.Join(cc, ", ")}) + } + if len(bcc) > 0 { + hdrs = append(hdrs, header{"Bcc", strings.Join(bcc, ", ")}) + } + if parent != nil && parent.MessageID != "" { + mid := "<" + strings.Trim(parent.MessageID, "<>") + ">" + hdrs = append(hdrs, header{"In-Reply-To", mid}, header{"References", mid}) + } + if len(customHeaders) > 0 { + for k, v := range customHeaders[0] { + hdrs = append(hdrs, header{k, v}) + } + } + + raw, err := buildMixedMIME(hdrs, bodyPlain, bodyHTML, attachments) + if err != nil { + return nil, fmt.Errorf("build mime: %w", err) + } + + msg := &gmail.Message{ + Raw: base64.URLEncoding.EncodeToString(raw), + } + if parent != nil && parent.ThreadID != "" { + msg.ThreadId = parent.ThreadID + } + + sent, err := c.srv.Users.Messages.Send("me", msg).Do() + if err != nil { + return nil, fmt.Errorf("send message failed: %w", err) + } + return sent, nil +} + +type header struct{ name, value string } + +// buildMixedMIME assembles a multipart/mixed message: a multipart/alternative +// (text/plain + optional text/html) followed by one attachment part each. Text +// parts use quoted-printable; attachment parts use base64 with a +// Content-Disposition: attachment header. Shared by the Gmail raw path. +func buildMixedMIME(hdrs []header, bodyPlain, bodyHTML string, attachments []Attachment) ([]byte, error) { + var buf bytes.Buffer + + mixed := multipart.NewWriter(&buf) + + // Top-level headers + the multipart/mixed Content-Type. These must precede + // the first boundary, so write them before any part is created. + for _, h := range hdrs { + fmt.Fprintf(&buf, "%s: %s\r\n", h.name, h.value) + } + fmt.Fprintf(&buf, "Content-Type: multipart/mixed; boundary=%s\r\n\r\n", mixed.Boundary()) + + // --- multipart/alternative sub-tree for the text bodies --- + var altBuf bytes.Buffer + alt := multipart.NewWriter(&altBuf) + + if err := writeTextPart(alt, "text/plain; charset=UTF-8", bodyPlain); err != nil { + return nil, err + } + if bodyHTML != "" { + if err := writeTextPart(alt, "text/html; charset=UTF-8", bodyHTML); err != nil { + return nil, err + } + } + if err := alt.Close(); err != nil { + return nil, err + } + + altPart, err := mixed.CreatePart(textproto.MIMEHeader{ + "Content-Type": {fmt.Sprintf("multipart/alternative; boundary=%s", alt.Boundary())}, + }) + if err != nil { + return nil, err + } + if _, err := altPart.Write(altBuf.Bytes()); err != nil { + return nil, err + } + + // --- one attachment part per file --- + for _, a := range attachments { + mimeType := a.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + // RFC 2047-encode the filename for non-ASCII safety. + fn := mime.QEncoding.Encode("utf-8", a.Filename) + part, perr := mixed.CreatePart(textproto.MIMEHeader{ + "Content-Type": {fmt.Sprintf("%s; name=%q", mimeType, fn)}, + "Content-Transfer-Encoding": {"base64"}, + "Content-Disposition": {fmt.Sprintf("attachment; filename=%q", fn)}, + }) + if perr != nil { + return nil, perr + } + if werr := writeBase64Wrapped(part, a.Data); werr != nil { + return nil, werr + } + } + + if err := mixed.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// writeTextPart writes a quoted-printable text body part with the given +// Content-Type into the multipart writer. +func writeTextPart(w *multipart.Writer, contentType, body string) error { + part, err := w.CreatePart(textproto.MIMEHeader{ + "Content-Type": {contentType}, + "Content-Transfer-Encoding": {"quoted-printable"}, + }) + if err != nil { + return err + } + qp := quotedprintable.NewWriter(part) + if _, err := qp.Write([]byte(body)); err != nil { + return err + } + return qp.Close() +} + +// writeBase64Wrapped writes data as base64, hard-wrapped at 76 columns per +// RFC 2045 so strict MTAs accept the message. +func writeBase64Wrapped(w io.Writer, data []byte) error { + encoded := base64.StdEncoding.EncodeToString(data) + const lineLen = 76 + for i := 0; i < len(encoded); i += lineLen { + end := i + lineLen + if end > len(encoded) { + end = len(encoded) + } + if _, err := w.Write([]byte(encoded[i:end] + "\r\n")); err != nil { + return err + } + } + return nil +} diff --git a/internal/client/smtpimap/smtp/client.go b/internal/client/smtpimap/smtp/client.go index 1567fed4..ae4304c0 100644 --- a/internal/client/smtpimap/smtp/client.go +++ b/internal/client/smtpimap/smtp/client.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "crypto/tls" + "encoding/base64" "errors" "fmt" + "io" + "mime" "mime/multipart" "mime/quotedprintable" "net" "net/mail" "net/smtp" + "net/textproto" "strings" "time" @@ -35,11 +39,20 @@ type Client struct { BindIP *net.TCPAddr } +// Attachment is a fully-resolved file to encode into an outbound message. Data +// is the raw bytes; MimeType drives the Content-Type. +type Attachment struct { + Filename string + MimeType string + Data []byte +} + func (c *Client) Send( ctx context.Context, to, cc, bcc []string, subject, bodyPlain, bodyHTML, inReplyTo string, + attachments []Attachment, customHeaders ...map[string]string, ) *errx.MailError { from := mail.Address{Address: c.Email, Name: fmt.Sprintf("%s %s", c.FirstName, c.LastName)} @@ -71,20 +84,80 @@ func (c *Client) Send( } } - // ----- Multipart body ----- var msg bytes.Buffer - writer := multipart.NewWriter(&msg) - boundary := writer.Boundary() - headers["Content-Type"] = fmt.Sprintf("multipart/alternative; boundary=%s", boundary) + if len(attachments) > 0 { + c.writeMixedBody(&msg, headers, bodyPlain, bodyHTML, attachments) + } else { + c.writeAlternativeBody(&msg, headers, bodyPlain, bodyHTML) + } + + recipients := append(append([]string{}, to...), cc...) + recipients = append(recipients, bcc...) + + return c.sendRaw(ctx, from.Address, recipients, msg.Bytes()) +} + +// writeAlternativeBody writes a multipart/alternative message (text/plain + +// optional text/html) including the top-level headers. +func (c *Client) writeAlternativeBody(msg *bytes.Buffer, headers map[string]string, bodyPlain, bodyHTML string) { + writer := multipart.NewWriter(msg) + headers["Content-Type"] = fmt.Sprintf("multipart/alternative; boundary=%s", writer.Boundary()) for k, v := range headers { - fmt.Fprintf(&msg, "%s: %s\r\n", k, v) + fmt.Fprintf(msg, "%s: %s\r\n", k, v) } - fmt.Fprint(&msg, "\r\n") + fmt.Fprint(msg, "\r\n") - // text/plain + writeTextParts(writer, bodyPlain, bodyHTML) + writer.Close() +} + +// writeMixedBody writes a multipart/mixed message: a multipart/alternative +// sub-tree for the text bodies, then one application/* part per attachment with +// a Content-Disposition: attachment header. +func (c *Client) writeMixedBody(msg *bytes.Buffer, headers map[string]string, bodyPlain, bodyHTML string, attachments []Attachment) { + mixed := multipart.NewWriter(msg) + headers["Content-Type"] = fmt.Sprintf("multipart/mixed; boundary=%s", mixed.Boundary()) + + for k, v := range headers { + fmt.Fprintf(msg, "%s: %s\r\n", k, v) + } + fmt.Fprint(msg, "\r\n") + + // multipart/alternative sub-tree for the text bodies. + var altBuf bytes.Buffer + alt := multipart.NewWriter(&altBuf) + writeTextParts(alt, bodyPlain, bodyHTML) + alt.Close() + + altPart, _ := mixed.CreatePart(textproto.MIMEHeader{ + "Content-Type": {fmt.Sprintf("multipart/alternative; boundary=%s", alt.Boundary())}, + }) + altPart.Write(altBuf.Bytes()) + + // One attachment part per file. + for _, a := range attachments { + mimeType := a.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + fn := mime.QEncoding.Encode("utf-8", a.Filename) + part, _ := mixed.CreatePart(textproto.MIMEHeader{ + "Content-Type": {fmt.Sprintf("%s; name=%q", mimeType, fn)}, + "Content-Transfer-Encoding": {"base64"}, + "Content-Disposition": {fmt.Sprintf("attachment; filename=%q", fn)}, + }) + writeBase64Wrapped(part, a.Data) + } + + mixed.Close() +} + +// writeTextParts writes the text/plain and optional text/html quoted-printable +// parts into the given multipart writer. +func writeTextParts(writer *multipart.Writer, bodyPlain, bodyHTML string) { if bodyPlain != "" { - part, _ := writer.CreatePart(map[string][]string{ + part, _ := writer.CreatePart(textproto.MIMEHeader{ "Content-Type": {"text/plain; charset=UTF-8"}, "Content-Transfer-Encoding": {"quoted-printable"}, }) @@ -92,10 +165,8 @@ func (c *Client) Send( qp.Write([]byte(bodyPlain)) qp.Close() } - - // text/html if bodyHTML != "" { - part, _ := writer.CreatePart(map[string][]string{ + part, _ := writer.CreatePart(textproto.MIMEHeader{ "Content-Type": {"text/html; charset=UTF-8"}, "Content-Transfer-Encoding": {"quoted-printable"}, }) @@ -103,12 +174,20 @@ func (c *Client) Send( qp.Write([]byte(bodyHTML)) qp.Close() } - writer.Close() +} - recipients := append(append([]string{}, to...), cc...) - recipients = append(recipients, bcc...) - - return c.sendRaw(ctx, from.Address, recipients, msg.Bytes()) +// writeBase64Wrapped writes data as base64, hard-wrapped at 76 columns per +// RFC 2045 so strict MTAs accept the message. +func writeBase64Wrapped(w io.Writer, data []byte) { + encoded := base64.StdEncoding.EncodeToString(data) + const lineLen = 76 + for i := 0; i < len(encoded); i += lineLen { + end := i + lineLen + if end > len(encoded) { + end = len(encoded) + } + w.Write([]byte(encoded[i:end] + "\r\n")) + } } // ---------- Internal helpers ---------- diff --git a/internal/events/publisher.go b/internal/events/publisher.go index b74e55c6..d675dcf7 100644 --- a/internal/events/publisher.go +++ b/internal/events/publisher.go @@ -55,6 +55,10 @@ type SendEmailParams struct { TrackingInfo *models.TrackingInfo WarmupToken string UnsubscribeURL string + // Attachments are file refs put into the emsg EmailBlob inside the S3 body + // object (reached by the worker via BodyS3Key). They are deliberately NOT + // added to models.SendEmail / the Avro event — the Kafka contract is fixed. + Attachments []models.AttachmentRef } type publisher struct { @@ -78,8 +82,10 @@ func NewPublisher(bus eventbus.EventBus, storageClient storage.Store, c codec.Co // PublishSendEmail stores email body in S3 and publishes a send email event to the worker func (p *publisher) PublishSendEmail(ctx context.Context, workerID uuid.UUID, params *SendEmailParams) error { - // Store email body in S3 - s3Key, err := p.StoreEmailBody(ctx, params.TaskID, params.UserID, params.BodyPlain, params.BodyHTML) + // Store email body (and attachment refs) in S3. The attachment refs ride + // inside the emsg blob so the worker receives them via BodyS3Key without any + // change to the Avro event contract. + s3Key, err := p.storeEmailBody(ctx, params.TaskID, params.UserID, params.BodyPlain, params.BodyHTML, params.Attachments) if err != nil { return fmt.Errorf("failed to store email body: %w", err) } @@ -126,8 +132,17 @@ func (p *publisher) PublishSendEmail(ctx context.Context, workerID uuid.UUID, pa return p.publish(workerTopic, params.TaskID.String(), workerEvent) } -// StoreEmailBody stores email body in S3 and returns the S3 key +// StoreEmailBody stores email body in S3 and returns the S3 key. It is the +// interface method; the attachment-aware path goes through storeEmailBody. func (p *publisher) StoreEmailBody(ctx context.Context, taskID, userID uuid.UUID, plainText, htmlBody string) (string, error) { + return p.storeEmailBody(ctx, taskID, userID, plainText, htmlBody, nil) +} + +// storeEmailBody encodes the email body plus attachment refs into the emsg blob +// and uploads it to object storage, returning the S3 key. Bodies are encrypted +// per-user before encoding; attachment refs are plaintext metadata (the bytes +// they point to are stored separately and the worker fetches them by key). +func (p *publisher) storeEmailBody(ctx context.Context, taskID, userID uuid.UUID, plainText, htmlBody string, attachments []models.AttachmentRef) (string, error) { if p.storageClient == nil { return "", nil } @@ -153,11 +168,19 @@ func (p *publisher) StoreEmailBody(ctx context.Context, taskID, userID uuid.UUID } } - // Create email blob + // Create email blob. Attachment refs are carried inside the blob so the + // worker can fetch each file's bytes from object storage at send time. blob := &emsg.EmailBlob{ PlainText: []byte(encPlainText), HTMLBody: []byte(encHTMLBody), } + for _, a := range attachments { + blob.Attachments = append(blob.Attachments, emsg.Attachment{ + S3Key: a.S3Key, + Filename: a.Filename, + MimeType: a.MimeType, + }) + } data, err := blob.EncodeBinary() if err != nil { diff --git a/internal/pkg/emsg/emsg.go b/internal/pkg/emsg/emsg.go index 5a5d4eca..8f0cdb57 100644 --- a/internal/pkg/emsg/emsg.go +++ b/internal/pkg/emsg/emsg.go @@ -13,20 +13,40 @@ const Version = 1 // Bitmask flags for sections const ( - FlagPlainText uint32 = 1 << 0 - FlagHTMLBody uint32 = 1 << 1 + FlagPlainText uint32 = 1 << 0 + FlagHTMLBody uint32 = 1 << 1 + FlagAttachments uint32 = 1 << 2 ) +// Attachment is a single attachment reference carried inside the S3 body blob. +// Only the metadata travels here; the worker fetches the bytes from object +// storage by S3Key. Keeping these refs in the blob (not the Avro Kafka event) +// preserves the published worker event contract. +type Attachment struct { + S3Key string + Filename string + MimeType string +} + // EmailBlob represents a binary-encoded email body and metadata. type EmailBlob struct { - PlainText []byte - HTMLBody []byte + PlainText []byte + HTMLBody []byte + Attachments []Attachment } -// EncodeBinary serializes the blob into binary format. +// EncodeBinary serializes the blob into binary format. Layout: +// +// "EMSG" | version(1) | flags(4) | [plain] | [html] | [attachments] +// +// where each body section is uint32-length-prefixed and only present when its +// flag bit is set. The attachments section, when present, is a uint32 count +// followed by that many (s3key, filename, mimetype) triples of length-prefixed +// strings. Attachment metadata travels here (inside the S3 body blob), not in +// the Avro Kafka event, so the published worker event contract is unchanged. func (b *EmailBlob) EncodeBinary() ([]byte, error) { var flags uint32 - parts := make([][]byte, 0, 5) + parts := make([][]byte, 0, 2) if len(b.PlainText) > 0 { flags |= FlagPlainText @@ -36,6 +56,9 @@ func (b *EmailBlob) EncodeBinary() ([]byte, error) { flags |= FlagHTMLBody parts = append(parts, b.HTMLBody) } + if len(b.Attachments) > 0 { + flags |= FlagAttachments + } buf := new(bytes.Buffer) @@ -44,12 +67,26 @@ func (b *EmailBlob) EncodeBinary() ([]byte, error) { buf.WriteByte(Version) // 1 byte binary.Write(buf, binary.BigEndian, flags) // 4 bytes - // Write each section [len][data] + // Write each variable-length body section [len][data] in flag order. for _, p := range parts { binary.Write(buf, binary.BigEndian, uint32(len(p))) buf.Write(p) } + // Attachments section: [count] then [len][str] x3 per attachment. + if flags&FlagAttachments != 0 { + binary.Write(buf, binary.BigEndian, uint32(len(b.Attachments))) + writeStr := func(s string) { + binary.Write(buf, binary.BigEndian, uint32(len(s))) + buf.WriteString(s) + } + for _, a := range b.Attachments { + writeStr(a.S3Key) + writeStr(a.Filename) + writeStr(a.MimeType) + } + } + return buf.Bytes(), nil } @@ -91,5 +128,39 @@ func DecodeBinary(r io.Reader) (*EmailBlob, error) { b.HTMLBody, _ = readSection() } + if flags&FlagAttachments != 0 { + var count uint32 + if err := binary.Read(r, binary.BigEndian, &count); err != nil { + return nil, err + } + readStr := func() (string, error) { + data, err := readSection() + if err != nil { + return "", err + } + return string(data), nil + } + b.Attachments = make([]Attachment, 0, count) + for i := uint32(0); i < count; i++ { + s3Key, err := readStr() + if err != nil { + return nil, err + } + filename, err := readStr() + if err != nil { + return nil, err + } + mimeType, err := readStr() + if err != nil { + return nil, err + } + b.Attachments = append(b.Attachments, Attachment{ + S3Key: s3Key, + Filename: filename, + MimeType: mimeType, + }) + } + } + return b, nil } diff --git a/internal/pkg/emsg/emsg_test.go b/internal/pkg/emsg/emsg_test.go index 09ee75c5..8f27f89a 100644 --- a/internal/pkg/emsg/emsg_test.go +++ b/internal/pkg/emsg/emsg_test.go @@ -71,3 +71,35 @@ func TestEmailBlob_EncodeDecode(t *testing.T) { }) } } + +func TestEmailBlob_Attachments(t *testing.T) { + in := &EmailBlob{ + PlainText: []byte("hi"), + HTMLBody: []byte("hi"), + Attachments: []Attachment{ + {S3Key: "attachments/a/1.pdf", Filename: "report.pdf", MimeType: "application/pdf"}, + {S3Key: "attachments/a/2.png", Filename: "logo.png", MimeType: "image/png"}, + }, + } + + got := roundTrip(t, in) + + if len(got.Attachments) != len(in.Attachments) { + t.Fatalf("attachment count mismatch: got=%d want=%d", len(got.Attachments), len(in.Attachments)) + } + for i, want := range in.Attachments { + if got.Attachments[i] != want { + t.Errorf("attachment[%d] mismatch:\n got=%+v\nwant=%+v", i, got.Attachments[i], want) + } + } + if !bytes.Equal(got.PlainText, in.PlainText) || !bytes.Equal(got.HTMLBody, in.HTMLBody) { + t.Errorf("body sections corrupted when attachments present") + } +} + +func TestEmailBlob_NoAttachments(t *testing.T) { + got := roundTrip(t, &EmailBlob{PlainText: []byte("x")}) + if len(got.Attachments) != 0 { + t.Errorf("expected no attachments, got %d", len(got.Attachments)) + } +} diff --git a/internal/tasks/email_sender.go b/internal/tasks/email_sender.go index 5da997c2..989ad278 100644 --- a/internal/tasks/email_sender.go +++ b/internal/tasks/email_sender.go @@ -27,6 +27,10 @@ type EmailMessage struct { WarmupToken string UserID uuid.UUID UnsubscribeURL string + // Attachments are file refs (S3 key + metadata). The worker fetches the + // bytes from object storage at send time. Refs travel inside the S3 body + // blob, never the Avro Kafka event. + Attachments []models.AttachmentRef } // EmailSender interface for sending emails via workers @@ -78,6 +82,7 @@ func (s *emailSender) Send(ctx context.Context, taskID uuid.UUID, msg EmailMessa TrackingInfo: msg.Tracking, WarmupToken: msg.WarmupToken, UnsubscribeURL: msg.UnsubscribeURL, + Attachments: msg.Attachments, } // Publish send email event to worker From e92ec7c661933ceee23fa68fda1649a1b347ed74 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 5 Jun 2026 06:03:32 +0200 Subject: [PATCH 31/60] feat: add ai writing credit backend Add an organization credit ledger with idempotent consumption, plan credit grants, Anthropic/OpenAI writing providers, and a gated generation endpoint for campaign writing assistance. --- internal/api/handler/generation.go | 143 +++++++++++ internal/app/credits/service.go | 203 +++++++++++++++ .../migrations/000018_credit_ledger.down.sql | 6 + .../db/migrations/000018_credit_ledger.up.sql | 44 ++++ internal/models/credits.go | 38 +++ internal/models/subscriptions.go | 3 + internal/pkg/generation/anthropic.go | 242 ++++++++++++++++++ internal/pkg/generation/writing.go | 55 ++++ internal/repository/pg_credit.go | 226 ++++++++++++++++ internal/seed/plans.go | 13 +- 10 files changed, 971 insertions(+), 2 deletions(-) create mode 100644 internal/api/handler/generation.go create mode 100644 internal/app/credits/service.go create mode 100644 internal/infrastructure/db/migrations/000018_credit_ledger.down.sql create mode 100644 internal/infrastructure/db/migrations/000018_credit_ledger.up.sql create mode 100644 internal/models/credits.go create mode 100644 internal/pkg/generation/anthropic.go create mode 100644 internal/pkg/generation/writing.go create mode 100644 internal/repository/pg_credit.go diff --git a/internal/api/handler/generation.go b/internal/api/handler/generation.go new file mode 100644 index 00000000..b7ca44e6 --- /dev/null +++ b/internal/api/handler/generation.go @@ -0,0 +1,143 @@ +// AI writing-assistant generation endpoint. Flow: +// 1. feature-gate the org (paid or in free trial) via CanUseWritingAssistant +// 2. atomically consume one credit (DB-enforced: no negative balance, no +// double-charge on Idempotency-Key replay) +// 3. call the configured provider (Anthropic, falling back to OpenAI) +// 4. return {text, credits_remaining, model} +// +// On insufficient credits the consume step short-circuits with 402 BEFORE any +// provider call, so a depleted org never burns a paid completion. Because the +// debit happens before the provider call, a provider failure refunds the +// credit so the customer is not charged for a generation they never received. + +package handler + +import ( + "errors" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/warmbly/warmbly/internal/api/middleware" + "github.com/warmbly/warmbly/internal/app/credits" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/pkg/generation" +) + +// creditsPerWrite is the credit cost of one writing-assistant call. Kept as a +// constant so pricing is in one place; tokens consumed are recorded separately +// on the ledger transaction for later cost analysis. +const creditsPerWrite = 1 + +// writeMaxPromptLen bounds the inbound prompt so a single request can't be used +// to drive a very large (and expensive) completion. +const writeMaxPromptLen = 8000 + +type generationWriteRequest struct { + Prompt string `json:"prompt"` + Tone string `json:"tone"` +} + +// paymentRequiredJSON emits the standard error envelope with a 402 status. +// errx has no PaymentRequired code, so this endpoint writes the 402 directly +// while keeping the same {error, message, code, request_id} shape. +func paymentRequiredJSON(c *gin.Context, message string) { + c.JSON(http.StatusPaymentRequired, gin.H{ + "error": "Payment Required", + "message": message, + "code": "insufficient_credits", + "request_id": c.GetString("request_id"), + }) +} + +// GenerateWriting — POST /generation/write +func (h *Handler) GenerateWriting(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.JSON(c, errx.New(errx.BadRequest, "no organization selected")) + return + } + + var req generationWriteRequest + if err := c.ShouldBindJSON(&req); err != nil { + errx.JSON(c, errx.ErrInvalid) + return + } + req.Prompt = strings.TrimSpace(req.Prompt) + if req.Prompt == "" { + errx.JSON(c, errx.New(errx.BadRequest, "prompt is required")) + return + } + if len(req.Prompt) > writeMaxPromptLen { + errx.JSON(c, errx.New(errx.BadRequest, "prompt is too long")) + return + } + + // Feature gate: paid orgs and free-trial orgs may use the assistant. + allowed, xerr := h.FeatureGateService.CanUseWritingAssistant(c.Request.Context(), *orgID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + if !allowed { + errx.JSON(c, errx.New(errx.Forbidden, "The AI writing assistant requires an active plan or trial.")) + return + } + + // Provider must be configured. + if h.WritingGenerator == nil { + errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI writing assistant is not configured.")) + return + } + + // Model routing by tier. Paid orgs get the stronger model; the active + // provider (Anthropic or OpenAI fallback) decides the concrete model ID. + paid, xerr := h.FeatureGateService.IsPaidOrganization(c.Request.Context(), *orgID) + if xerr != nil { + errx.JSON(c, xerr) + return + } + model := h.WritingGenerator.ModelForTier(paid) + + // Consume one credit up front. The DB enforces the no-negative / no-replay + // invariants; on a depleted balance this returns 402 with no provider call. + idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key")) + remaining, err := h.CreditService.Consume( + c.Request.Context(), *orgID, creditsPerWrite, + "writing_assistant", model, 0, idemKey, + ) + if err != nil { + switch { + case errors.Is(err, credits.ErrInsufficientCredits): + paymentRequiredJSON(c, "You're out of AI credits. Upgrade or purchase more to keep using the writing assistant.") + case errors.Is(err, credits.ErrCapExceeded): + errx.JSON(c, errx.New(errx.TooManyRequests, "AI writing assistant usage limit reached, please try again later.")) + default: + errx.JSON(c, errx.InternalError()) + } + return + } + + // Generate. On provider failure, refund the credit so the customer is not + // charged for a completion they never received. The refund is best-effort; + // a failed refund is logged via the audit trail rather than surfaced. + result, gerr := h.WritingGenerator.GenerateWriting(c.Request.Context(), model, req.Prompt, req.Tone) + if gerr != nil { + if bal, rerr := h.CreditService.Grant(c.Request.Context(), *orgID, creditsPerWrite, "writing_assistant_refund"); rerr == nil { + remaining = bal + } + if errors.Is(gerr, generation.ErrNotConfigured) { + errx.JSON(c, errx.New(errx.ServiceUnavailable, "AI writing assistant is not configured.")) + return + } + errx.JSON(c, errx.New(errx.ServiceUnavailable, "The writing assistant is temporarily unavailable. Your credit was not charged.")) + return + } + + c.JSON(http.StatusOK, gin.H{ + "text": result.Text, + "credits_remaining": remaining, + "model": result.Model, + }) +} diff --git a/internal/app/credits/service.go b/internal/app/credits/service.go new file mode 100644 index 00000000..280bd37a --- /dev/null +++ b/internal/app/credits/service.go @@ -0,0 +1,203 @@ +// Package credits implements the AI writing-assistant credit ledger: balance +// reads, atomic consumption with idempotency, and grants. Hard billing +// correctness (no negative balances, no double-charge on retry) lives in the +// repository's atomic SQL; this service layer adds the abuse caps (a short +// rolling window plus a daily window) on top. +package credits + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/infrastructure/cache" + "github.com/warmbly/warmbly/internal/models" + "github.com/warmbly/warmbly/internal/repository" +) + +const ( + // Rolling-window cap: max writing-assistant generations per org in a 5-hour + // window. Catches burst abuse independent of credit balance. + WindowShort = 5 * time.Hour + DefaultShortLimit = 60 + + // Daily cap: max writing-assistant generations per org per UTC day. + WindowDaily = 24 * time.Hour + DefaultDailyLimit = 300 + + keyPrefixShort = "credits:cap:5h:" + keyPrefixDaily = "credits:cap:day:" +) + +// ErrInsufficientCredits signals the org has fewer credits than requested. The +// handler maps this to HTTP 402. Distinct, exported sentinels are used (rather +// than *errx.Error) because errx has no 402 code; the handler emits the 402 +// envelope itself. +var ErrInsufficientCredits = errors.New("insufficient credits") + +// ErrCapExceeded signals the org hit a rolling/daily generation cap. The +// handler maps this to HTTP 429. +var ErrCapExceeded = errors.New("generation rate cap exceeded") + +// CreditService is the application-facing API for AI credits. +type CreditService interface { + // GetBalance returns the org's current credit balance (0 if no ledger yet). + GetBalance(ctx context.Context, orgID uuid.UUID) (int, *errx.Error) + + // Consume enforces the abuse caps, then atomically debits `amount` credits. + // On success it returns the resulting balance. idempotencyKey may be empty; + // when set, a retry with the same key does not double-charge and does not + // re-count against the caps. + // + // Returns ErrInsufficientCredits (→402) or ErrCapExceeded (→429) as + // sentinel errors the handler maps; any other error is an internal failure. + Consume(ctx context.Context, orgID uuid.UUID, amount int, reason, model string, tokens int, idempotencyKey string) (int, error) + + // Grant credits to an org (monthly plan grant or purchase). Returns the + // resulting balance. + Grant(ctx context.Context, orgID uuid.UUID, amount int, reason string) (int, *errx.Error) + + // ListTransactions returns recent ledger transactions, newest first. + ListTransactions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.CreditTransaction, *errx.Error) +} + +type creditService struct { + repo repository.CreditRepository + cache *cache.Cache + shortLimit int + dailyLimit int +} + +func NewService(repo repository.CreditRepository, c *cache.Cache) CreditService { + return &creditService{ + repo: repo, + cache: c, + shortLimit: DefaultShortLimit, + dailyLimit: DefaultDailyLimit, + } +} + +func (s *creditService) GetBalance(ctx context.Context, orgID uuid.UUID) (int, *errx.Error) { + ledger, err := s.repo.GetBalance(ctx, orgID) + if err != nil { + return 0, errx.New(errx.Internal, "failed to read credit balance") + } + if ledger == nil { + return 0, nil + } + return ledger.Balance, nil +} + +func (s *creditService) Consume(ctx context.Context, orgID uuid.UUID, amount int, reason, model string, tokens int, idempotencyKey string) (int, error) { + if amount <= 0 { + return 0, errors.New("credit amount must be positive") + } + + // Enforce the abuse caps first. The repo's Consume then handles the atomic + // debit and idempotent replay; we only count a cap hit against a *fresh* + // debit (replayed == false) so a legitimate client retry with the same + // Idempotency-Key is never penalized against the 5h/daily window. + if err := s.checkCaps(ctx, orgID, idempotencyKey); err != nil { + return 0, err + } + + bal, _, replayed, err := s.repo.Consume(ctx, orgID, amount, reason, model, tokens, idempotencyKey) + if errors.Is(err, repository.ErrInsufficientCredits) { + return 0, ErrInsufficientCredits + } + if err != nil { + return 0, err + } + + // Persist the cap increment only for fresh debits. checkCaps used a + // reserve/peek so a replay does not advance the window. + if !replayed { + s.commitCaps(ctx, orgID) + } + return bal, nil +} + +func (s *creditService) Grant(ctx context.Context, orgID uuid.UUID, amount int, reason string) (int, *errx.Error) { + if amount <= 0 { + return 0, errx.New(errx.BadRequest, "grant amount must be positive") + } + bal, _, err := s.repo.Grant(ctx, orgID, amount, reason) + if err != nil { + return 0, errx.New(errx.Internal, "failed to grant credits") + } + return bal, nil +} + +func (s *creditService) ListTransactions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.CreditTransaction, *errx.Error) { + txns, err := s.repo.ListTransactions(ctx, orgID, limit) + if err != nil { + return nil, errx.New(errx.Internal, "failed to list credit transactions") + } + return txns, nil +} + +// shortKey / dailyKey are the per-org Redis keys for the two abuse windows. +func (s *creditService) shortKey(orgID uuid.UUID) string { + return keyPrefixShort + orgID.String() +} + +func (s *creditService) dailyKey(orgID uuid.UUID) string { + day := time.Now().UTC().Format("2006-01-02") + return fmt.Sprintf("%s%s:%s", keyPrefixDaily, orgID.String(), day) +} + +// checkCaps rejects when the org has already reached the rolling 5h or daily +// generation cap. It only *reads* the counters (commitCaps does the increment +// after a successful fresh debit), so an idempotent replay never advances the +// window. On Redis errors it fails open so a cache outage never blocks +// legitimate generation. It is a thin wrapper over Redis (the same key shape the +// rate-limit service uses), not a reimplementation of that service. +// +// idempotencyKey is reserved for future per-key suppression; today a replay is +// distinguished after the fact via the repo's replayed flag. +func (s *creditService) checkCaps(ctx context.Context, orgID uuid.UUID, _ string) error { + if s.cache == nil { + return nil + } + if s.atOrOverLimit(ctx, s.shortKey(orgID), s.shortLimit) { + return ErrCapExceeded + } + if s.atOrOverLimit(ctx, s.dailyKey(orgID), s.dailyLimit) { + return ErrCapExceeded + } + return nil +} + +func (s *creditService) atOrOverLimit(ctx context.Context, key string, limit int) bool { + n, err := s.cache.Get(ctx, key).Int() + if err == redis.Nil { + return false + } + if err != nil { + // Fail open on cache errors. + return false + } + return n >= limit +} + +// commitCaps increments both windows after a successful fresh debit, setting the +// TTLs on first write. Best-effort: a Redis failure here never fails the +// already-completed generation. +func (s *creditService) commitCaps(ctx context.Context, orgID uuid.UUID) { + if s.cache == nil { + return + } + s.bump(ctx, s.shortKey(orgID), WindowShort) + s.bump(ctx, s.dailyKey(orgID), WindowDaily) +} + +func (s *creditService) bump(ctx context.Context, key string, window time.Duration) { + pipe := s.cache.Pipeline() + pipe.Incr(ctx, key) + pipe.Expire(ctx, key, window) + _, _ = pipe.Exec(ctx) +} diff --git a/internal/infrastructure/db/migrations/000018_credit_ledger.down.sql b/internal/infrastructure/db/migrations/000018_credit_ledger.down.sql new file mode 100644 index 00000000..4c387674 --- /dev/null +++ b/internal/infrastructure/db/migrations/000018_credit_ledger.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE plans DROP COLUMN IF EXISTS monthly_credits; + +DROP INDEX IF EXISTS uq_credit_txns_idempotency; +DROP INDEX IF EXISTS idx_credit_txns_org; +DROP TABLE IF EXISTS credit_ledger_transactions; +DROP TABLE IF EXISTS credit_ledger; diff --git a/internal/infrastructure/db/migrations/000018_credit_ledger.up.sql b/internal/infrastructure/db/migrations/000018_credit_ledger.up.sql new file mode 100644 index 00000000..d4921916 --- /dev/null +++ b/internal/infrastructure/db/migrations/000018_credit_ledger.up.sql @@ -0,0 +1,44 @@ +-- AI writing-assistant credit system. Two tables plus a per-plan monthly grant. +-- +-- credit_ledger is the authoritative per-organization balance (one row per org). +-- Consumption is a single atomic conditional UPDATE +-- (balance = balance - $n WHERE balance >= $n RETURNING ...) so concurrent +-- generation requests can never drive the balance negative — billing +-- correctness lives in the database, not in application-level read-modify-write. +-- +-- credit_ledger_transactions is the append-only audit trail. idempotency_key is +-- nullable+unique so a retried POST /generation/write (same Idempotency-Key) +-- can be detected and not double-charged. + +CREATE TABLE IF NOT EXISTS credit_ledger ( + org_id uuid PRIMARY KEY REFERENCES organizations (id) ON DELETE CASCADE, + balance integer NOT NULL DEFAULT 0 CHECK (balance >= 0), + month_reset_at timestamptz NOT NULL DEFAULT now(), + total_purchased integer NOT NULL DEFAULT 0 CHECK (total_purchased >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS credit_ledger_transactions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE, + amount integer NOT NULL, -- negative = consumption, positive = grant/purchase + reason text NOT NULL, + model_used text NOT NULL DEFAULT '', + tokens_used integer NOT NULL DEFAULT 0, + balance_after integer NOT NULL, + idempotency_key text, -- nullable; unique when present + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_credit_txns_org ON credit_ledger_transactions (org_id, created_at DESC); + +-- Partial unique index: at most one transaction per idempotency_key, but NULL +-- keys (grants, resets, non-idempotent calls) are unconstrained. +CREATE UNIQUE INDEX IF NOT EXISTS uq_credit_txns_idempotency + ON credit_ledger_transactions (idempotency_key) + WHERE idempotency_key IS NOT NULL; + +-- Per-plan monthly credit grant. Additive with a safe default so existing +-- plans (and the free trial) start at 0 until seeded. +ALTER TABLE plans ADD COLUMN IF NOT EXISTS monthly_credits integer NOT NULL DEFAULT 0; diff --git a/internal/models/credits.go b/internal/models/credits.go new file mode 100644 index 00000000..4a7f8028 --- /dev/null +++ b/internal/models/credits.go @@ -0,0 +1,38 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// CreditLedger is the authoritative per-organization AI-credit balance. There +// is exactly one row per organization (org_id is the primary key). Balance is +// mutated only through atomic conditional UPDATEs in the repository so it can +// never go negative under concurrent generation requests. +type CreditLedger struct { + OrgID uuid.UUID `json:"org_id"` + Balance int `json:"balance"` + MonthResetAt time.Time `json:"month_reset_at"` + TotalPurchased int `json:"total_purchased"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CreditTransaction is one append-only row in the credit audit trail. Amount is +// negative for consumption and positive for grants/purchases. BalanceAfter is +// the resulting ledger balance captured atomically with the mutation. +// IdempotencyKey is nil for non-idempotent operations (grants, monthly resets) +// and set to the caller's Idempotency-Key for consumption so retries don't +// double-charge. +type CreditTransaction struct { + ID uuid.UUID `json:"id"` + OrgID uuid.UUID `json:"org_id"` + Amount int `json:"amount"` + Reason string `json:"reason"` + ModelUsed string `json:"model_used"` + TokensUsed int `json:"tokens_used"` + BalanceAfter int `json:"balance_after"` + IdempotencyKey *string `json:"idempotency_key,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/models/subscriptions.go b/internal/models/subscriptions.go index 2575b61b..74c1c8fc 100644 --- a/internal/models/subscriptions.go +++ b/internal/models/subscriptions.go @@ -59,6 +59,9 @@ type Plan struct { MaxTeamMembers *int `json:"max_team_members,omitempty"` MaxEmailAccounts *int `json:"max_email_accounts,omitempty"` + // AI writing-assistant monthly credit grant for this plan. + MonthlyCredits int `json:"monthly_credits"` + UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/pkg/generation/anthropic.go b/internal/pkg/generation/anthropic.go new file mode 100644 index 00000000..fbd8be7b --- /dev/null +++ b/internal/pkg/generation/anthropic.go @@ -0,0 +1,242 @@ +package generation + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// ErrNotConfigured is returned by WritingGenerator implementations when no +// provider API key is available. Handlers map this to a clear "AI writing +// assistant is not configured" response instead of a generic 500. +var ErrNotConfigured = errors.New("writing assistant not configured: no provider API key") + +// Model tiers for the writing assistant. Free orgs are routed to the cheaper, +// faster Haiku model; paid orgs to Sonnet. The strings are Anthropic model IDs; +// callers should use ModelForTier rather than hardcoding. +const ( + ModelWritingFree = "claude-3-5-haiku-latest" + ModelWritingPaid = "claude-sonnet-4-5" + + // OpenAI fallback models, used only when ANTHROPIC_API_KEY is unset but + // OPENAI_API_KEY is present. + ModelWritingFreeOpenAI = "gpt-4o-mini" + ModelWritingPaidOpenAI = "gpt-4o" + + anthropicMessagesURL = "https://api.anthropic.com/v1/messages" + anthropicVersion = "2023-06-01" + + // writingMaxTokens caps a single writing-assistant completion. Generous for + // an email draft but bounded so a runaway prompt can't burn credits/tokens. + writingMaxTokens = 1024 +) + +// WritingResult is the normalized output of a writing-assistant generation, +// regardless of which provider produced it. +type WritingResult struct { + Text string + Model string + TokensUsed int +} + +// WritingGenerator is the provider-agnostic interface the handler depends on. +// Keeping it behind an interface means a missing API key yields a clear +// ErrNotConfigured at call time, and the provider (Anthropic vs OpenAI +// fallback) can be swapped without touching the handler. +type WritingGenerator interface { + // GenerateWriting produces assistant text for the given model, prompt, and + // optional tone. model is a provider model ID (see ModelForTier). + GenerateWriting(ctx context.Context, model, prompt, tone string) (*WritingResult, error) + + // ModelForTier returns the model ID this provider should use for the org's + // tier (paid → stronger model). The handler calls this rather than knowing + // which concrete provider is active. + ModelForTier(paid bool) string +} + +// ModelForTier returns the Anthropic writing model ID for the org's tier. +func (c *AnthropicClient) ModelForTier(paid bool) string { + if paid { + return ModelWritingPaid + } + return ModelWritingFree +} + +// humanWritingSystemPrompt encodes concrete, researched human-writing rules so +// output reads like a real person typed it fast — NOT the vague "be human". +// Bans em dashes + AI-tell vocabulary, forces sentence-length variation, one +// low-friction ask, <=80 words, and preserves {{.Merge}} variables. +const humanWritingSystemPrompt = `You are Warmbly's cold-outreach email writer. You write very short, first-touch cold emails that sound like a real, busy person typed them in 30 seconds. Your only goal is replies. Sounding human and getting replies are the same goal; do not try to "beat detectors." + +OUTPUT +- Output only the email body (and a subject line if one is requested). No preamble, no explanation, no "Sure, here is". +- Keep it under 80 words and 4 to 6 sentences. Shorter is better. +- If a subject line is requested, make it lowercase, specific, and under 6 words (e.g. "quick q on your onboarding"). Never use Re:/Fwd: fakes, ALL-CAPS, emoji, or "!". + +MERGE VARIABLES +- Preserve any merge variables exactly as written, including dotted Go-template form like {{.FirstName}}, {{.Company}}, {{.Role}}. Never rename, reformat, or invent them. +- Place merge variables inline and naturally. A merge variable is NOT personalization by itself. Given a real signal about the recipient (a recent hire, funding round, launch, pricing change, post), build the first line on that signal, not the merge tag. + +STRUCTURE +1. Open with one specific, earned observation about the recipient or their problem. No "I hope this email finds you well," no "I wanted to reach out," no "my name is." With only a merge tag and no signal, lead with the problem their kind of team feels now. +2. Name a pain they actually feel before mentioning what Warmbly does. Buyer-first, never product- or credentials-first. +3. Make one concrete claim, ideally with a real number, product, or observable fact. +4. End with exactly ONE low-friction, interest-based ask that gives an easy out (e.g. "want the 2-line version of how?", "worth a look, or not a priority right now?"). Never stack asks. Never ask "do you have 30 minutes?". +5. Optional: one casual P.S., one line, a genuine human aside. + +VOICE AND RHYTHM +- Casual founder register. Lowercase openers are fine. Fragments are fine ("makes sense?"). A quick note between meetings, not a press release. +- Use contractions always: it's, don't, you're, we'll, won't, that's. +- Active voice with a concrete subject doing the action. +- Vary sentence length hard. Put a 3-4 word line next to a 20+ word one. Never write three or four sentences of similar length in a row. Let one short line land alone. +- One idea per sentence. Keep most sentences under 20 words. Aim for an 8th-grade reading level. +- Take a position. Make a direct claim and own it. Warm but blunt. Offer an easy "no." + +HARD BANS (never produce these) +- Em dashes. Use a period, comma, or parentheses instead. +- AI vocabulary: delve, leverage, utilize, robust, elevate, seamless(ly), tapestry, underscore, realm, harness, pivotal, comprehensive, foster, showcase, testament, multifaceted, cutting-edge, best-in-class, end-to-end, unlock, empower, streamline, actionable insights, drive value, move the needle, synergy, circle back, low-hanging fruit, touch base. +- Formulaic openers: "I hope this email finds you well," "In today's fast-paced world," "I came across your profile," "Hope you're having a great week," "I wanted to reach out," "just touching base," "love what you're building." +- Hedging filler: "it's important to note," "it's worth mentioning," "generally speaking," "in many cases." +- Rule-of-three triads and neat parallel triplets. +- Summary/inspirational closers: "In conclusion," "At the end of the day," "Looking forward to hearing from you." +- Over-politeness: "Thank you so much for your time," "at your earliest convenience," "Have a wonderful day!" and exclamation-point friendliness. +- Negative parallelism: "It's not just X, it's Y," "not only... but also." Say it plainly. +- Transition scaffolding: "Furthermore," "Moreover," "Additionally," "That said." Cut it or use "so," "but here's the catch." +- Trailing -ing filler: "helping you save time," "underscoring the value." +- Vague claims: "many companies," "leading brands," "significant results," "industry-leading," "studies show." Be specific or cut it. +- Passive voice that hides who acted. Spam triggers: ALL-CAPS, "!!!", "FREE," "GUARANTEED," "risk-free," "ACT NOW," and 2+ links. + +SELF-CHECK before returning: under 80 words? one ask with an easy out? sentence lengths actually vary? zero em dashes? zero banned phrases? merge variables intact? reads like a person typed it fast, not a template? If any answer is no, rewrite.` + +// writingSystemPrompt builds the instruction shared by both providers, folding +// in the optional caller tone on top of the human-writing rules. +func writingSystemPrompt(tone string) string { + base := humanWritingSystemPrompt + tone = strings.TrimSpace(tone) + if tone != "" { + base += fmt.Sprintf("\n\nTONE: match this tone where it doesn't conflict with the rules above: %s.", tone) + } + return base +} + +// AnthropicClient calls the Anthropic Messages API over plain HTTP. We use the +// stdlib rather than adding an SDK dependency so the build stays lean; the +// request shape is the documented v1/messages contract. +type AnthropicClient struct { + apiKey string + http *http.Client +} + +// NewAnthropicClient returns a client, or nil if apiKey is empty so callers can +// fall back to another provider. +func NewAnthropicClient(apiKey string) *AnthropicClient { + if strings.TrimSpace(apiKey) == "" { + return nil + } + return &AnthropicClient{ + apiKey: apiKey, + http: &http.Client{Timeout: 60 * time.Second}, + } +} + +type anthropicMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type anthropicRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system,omitempty"` + Messages []anthropicMessage `json:"messages"` +} + +type anthropicResponse struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` +} + +// GenerateWriting implements WritingGenerator against the Anthropic API. +func (c *AnthropicClient) GenerateWriting(ctx context.Context, model, prompt, tone string) (*WritingResult, error) { + if c == nil { + return nil, ErrNotConfigured + } + if model == "" { + model = ModelWritingFree + } + + body, err := json.Marshal(anthropicRequest{ + Model: model, + MaxTokens: writingMaxTokens, + System: writingSystemPrompt(tone), + Messages: []anthropicMessage{ + {Role: "user", Content: prompt}, + }, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, anthropicMessagesURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", c.apiKey) + req.Header.Set("anthropic-version", anthropicVersion) + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + + var parsed anthropicResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("anthropic: decode response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if parsed.Error != nil { + return nil, fmt.Errorf("anthropic: %s: %s", parsed.Error.Type, parsed.Error.Message) + } + return nil, fmt.Errorf("anthropic: unexpected status %d", resp.StatusCode) + } + + var sb strings.Builder + for _, block := range parsed.Content { + if block.Type == "text" { + sb.WriteString(block.Text) + } + } + text := strings.TrimSpace(sb.String()) + if text == "" { + return nil, errors.New("anthropic: empty completion") + } + + return &WritingResult{ + Text: text, + Model: model, + TokensUsed: parsed.Usage.InputTokens + parsed.Usage.OutputTokens, + }, nil +} diff --git a/internal/pkg/generation/writing.go b/internal/pkg/generation/writing.go new file mode 100644 index 00000000..3ed36cb0 --- /dev/null +++ b/internal/pkg/generation/writing.go @@ -0,0 +1,55 @@ +package generation + +import ( + "context" + "errors" + "strings" + + "github.com/openai/openai-go/v2" +) + +// ModelForTier returns the OpenAI fallback writing model ID for the org's tier. +func (c *GenerationClient) ModelForTier(paid bool) string { + if paid { + return ModelWritingPaidOpenAI + } + return ModelWritingFreeOpenAI +} + +// GenerateWriting implements WritingGenerator using the existing OpenAI client. +// It is the fallback provider used only when ANTHROPIC_API_KEY is unset but +// OPENAI_API_KEY is present. The handler depends on the WritingGenerator +// interface, so it never needs to know which provider is active. +func (c *GenerationClient) GenerateWriting(ctx context.Context, model, prompt, tone string) (*WritingResult, error) { + if c == nil { + return nil, ErrNotConfigured + } + chatModel := openai.ChatModel(ModelWritingFreeOpenAI) + if model != "" { + chatModel = openai.ChatModel(model) + } + + resp, err := c.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ + Model: chatModel, + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(writingSystemPrompt(tone)), + openai.UserMessage(prompt), + }, + }) + if err != nil { + return nil, err + } + if len(resp.Choices) == 0 { + return nil, errors.New("openai: empty completion") + } + text := strings.TrimSpace(resp.Choices[0].Message.Content) + if text == "" { + return nil, errors.New("openai: empty completion") + } + + return &WritingResult{ + Text: text, + Model: string(chatModel), + TokensUsed: int(resp.Usage.TotalTokens), + }, nil +} diff --git a/internal/repository/pg_credit.go b/internal/repository/pg_credit.go new file mode 100644 index 00000000..2698be3a --- /dev/null +++ b/internal/repository/pg_credit.go @@ -0,0 +1,226 @@ +package repository + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/warmbly/warmbly/internal/infrastructure/db" + "github.com/warmbly/warmbly/internal/models" +) + +// ErrInsufficientCredits is returned by Consume when the org's balance is lower +// than the requested amount. Callers map this to a 402. +var ErrInsufficientCredits = errors.New("insufficient credits") + +// CreditRepository persists the AI-credit ledger and its append-only +// transaction log. All balance mutations are performed with atomic, conditional +// SQL so the balance can never go negative under concurrency — this is the +// billing-correctness boundary and must not be reimplemented as an +// application-level read-modify-write. +type CreditRepository interface { + // GetBalance returns the ledger for an org, or nil if it has none yet. + GetBalance(ctx context.Context, orgID uuid.UUID) (*models.CreditLedger, error) + + // EnsureLedger creates the org's ledger row if absent (idempotent), then + // returns it. + EnsureLedger(ctx context.Context, orgID uuid.UUID) (*models.CreditLedger, error) + + // Consume atomically debits `amount` credits from the org, writing a + // negative transaction row in the same transaction. Returns the resulting + // balance, the recorded transaction, and whether this was an idempotent + // replay (true = no new debit happened, the prior transaction was returned). + // + // If idempotencyKey is non-empty and a transaction already exists for it, + // Consume is a no-op debit and returns that prior transaction with + // replayed=true (so retries are safe). If the balance is too low, it returns + // ErrInsufficientCredits and debits nothing. + Consume(ctx context.Context, orgID uuid.UUID, amount int, reason, model string, tokens int, idempotencyKey string) (balance int, txn *models.CreditTransaction, replayed bool, err error) + + // Grant atomically credits `amount` to the org (creating the ledger if + // needed) and writes a positive transaction row. Used for monthly plan + // grants and credit purchases. + Grant(ctx context.Context, orgID uuid.UUID, amount int, reason string) (int, *models.CreditTransaction, error) + + // ListTransactions returns the org's transaction history, newest first. + ListTransactions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.CreditTransaction, error) +} + +type creditRepository struct { + DB *db.DB +} + +func NewCreditRepository(database *db.DB) CreditRepository { + return &creditRepository{DB: database} +} + +const creditLedgerCols = `org_id, balance, month_reset_at, total_purchased, created_at, updated_at` + +func scanLedger(row pgx.Row, l *models.CreditLedger) error { + return row.Scan(&l.OrgID, &l.Balance, &l.MonthResetAt, &l.TotalPurchased, &l.CreatedAt, &l.UpdatedAt) +} + +const creditTxnCols = `id, org_id, amount, reason, model_used, tokens_used, balance_after, idempotency_key, created_at` + +func scanTxn(row pgx.Row, t *models.CreditTransaction) error { + return row.Scan( + &t.ID, &t.OrgID, &t.Amount, &t.Reason, &t.ModelUsed, + &t.TokensUsed, &t.BalanceAfter, &t.IdempotencyKey, &t.CreatedAt, + ) +} + +func (r *creditRepository) GetBalance(ctx context.Context, orgID uuid.UUID) (*models.CreditLedger, error) { + l := &models.CreditLedger{} + err := scanLedger(r.DB.QueryRow(ctx, `SELECT `+creditLedgerCols+` FROM credit_ledger WHERE org_id = $1`, orgID), l) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return l, nil +} + +func (r *creditRepository) EnsureLedger(ctx context.Context, orgID uuid.UUID) (*models.CreditLedger, error) { + l := &models.CreditLedger{} + err := scanLedger(r.DB.QueryRow(ctx, ` + INSERT INTO credit_ledger (org_id) VALUES ($1) + ON CONFLICT (org_id) DO UPDATE SET org_id = EXCLUDED.org_id + RETURNING `+creditLedgerCols, orgID), l) + if err != nil { + return nil, err + } + return l, nil +} + +func (r *creditRepository) Consume(ctx context.Context, orgID uuid.UUID, amount int, reason, model string, tokens int, idempotencyKey string) (int, *models.CreditTransaction, bool, error) { + if amount <= 0 { + return 0, nil, false, errors.New("consume amount must be positive") + } + + tx, err := r.DB.Begin(ctx) + if err != nil { + return 0, nil, false, err + } + defer tx.Rollback(ctx) + + // Idempotency short-circuit: if this key already produced a transaction, + // return it unchanged rather than debiting again. + if idempotencyKey != "" { + existing := &models.CreditTransaction{} + err := scanTxn(tx.QueryRow(ctx, + `SELECT `+creditTxnCols+` FROM credit_ledger_transactions WHERE idempotency_key = $1`, idempotencyKey), existing) + if err == nil { + if cerr := tx.Commit(ctx); cerr != nil { + return 0, nil, false, cerr + } + return existing.BalanceAfter, existing, true, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return 0, nil, false, err + } + } + + // Atomic conditional debit. The WHERE balance >= amount guard means a + // concurrent request can never drive the balance below zero, and a missing + // ledger row yields no rows (treated as insufficient). + var newBalance int + err = tx.QueryRow(ctx, ` + UPDATE credit_ledger + SET balance = balance - $2, updated_at = now() + WHERE org_id = $1 AND balance >= $2 + RETURNING balance + `, orgID, amount).Scan(&newBalance) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil, false, ErrInsufficientCredits + } + if err != nil { + return 0, nil, false, err + } + + var keyArg *string + if idempotencyKey != "" { + keyArg = &idempotencyKey + } + txn := &models.CreditTransaction{} + err = scanTxn(tx.QueryRow(ctx, ` + INSERT INTO credit_ledger_transactions + (org_id, amount, reason, model_used, tokens_used, balance_after, idempotency_key) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING `+creditTxnCols, + orgID, -amount, reason, model, tokens, newBalance, keyArg), txn) + if err != nil { + return 0, nil, false, err + } + + if err := tx.Commit(ctx); err != nil { + return 0, nil, false, err + } + return newBalance, txn, false, nil +} + +func (r *creditRepository) Grant(ctx context.Context, orgID uuid.UUID, amount int, reason string) (int, *models.CreditTransaction, error) { + if amount <= 0 { + return 0, nil, errors.New("grant amount must be positive") + } + + tx, err := r.DB.Begin(ctx) + if err != nil { + return 0, nil, err + } + defer tx.Rollback(ctx) + + var newBalance int + err = tx.QueryRow(ctx, ` + INSERT INTO credit_ledger (org_id, balance) VALUES ($1, $2) + ON CONFLICT (org_id) DO UPDATE + SET balance = credit_ledger.balance + EXCLUDED.balance, updated_at = now() + RETURNING balance + `, orgID, amount).Scan(&newBalance) + if err != nil { + return 0, nil, err + } + + txn := &models.CreditTransaction{} + err = scanTxn(tx.QueryRow(ctx, ` + INSERT INTO credit_ledger_transactions + (org_id, amount, reason, model_used, tokens_used, balance_after, idempotency_key) + VALUES ($1, $2, $3, '', 0, $4, NULL) + RETURNING `+creditTxnCols, + orgID, amount, reason, newBalance), txn) + if err != nil { + return 0, nil, err + } + + if err := tx.Commit(ctx); err != nil { + return 0, nil, err + } + return newBalance, txn, nil +} + +func (r *creditRepository) ListTransactions(ctx context.Context, orgID uuid.UUID, limit int) ([]models.CreditTransaction, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := r.DB.Query(ctx, ` + SELECT `+creditTxnCols+` + FROM credit_ledger_transactions + WHERE org_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `, orgID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.CreditTransaction, 0) + for rows.Next() { + var t models.CreditTransaction + if err := scanTxn(rows, &t); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} diff --git a/internal/seed/plans.go b/internal/seed/plans.go index f80ca997..e29ec7d3 100644 --- a/internal/seed/plans.go +++ b/internal/seed/plans.go @@ -52,6 +52,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { maxActiveCampaigns *int maxTeamMembers *int maxEmailAccounts *int + monthlyCredits int } plans := []plan{ @@ -62,6 +63,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { dedicatedWorkers: 0, dailyCampaignLimit: intPtr(20), maxCampaigns: intPtr(2), maxActiveCampaigns: intPtr(1), maxTeamMembers: intPtr(1), maxEmailAccounts: intPtr(2), + monthlyCredits: 50, }, { id: PlanStarterID, name: "Starter", maxContacts: 1_000, dailyEmails: 100, @@ -70,6 +72,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { dedicatedWorkers: 0, dailyCampaignLimit: intPtr(100), maxCampaigns: intPtr(5), maxActiveCampaigns: intPtr(2), maxTeamMembers: intPtr(2), maxEmailAccounts: intPtr(3), + monthlyCredits: 250, }, { id: PlanProMonthlyID, name: "Pro", maxContacts: 25_000, dailyEmails: 1_000, @@ -78,6 +81,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { dedicatedWorkers: 1, dailyCampaignLimit: intPtr(1_000), maxCampaigns: intPtr(50), maxActiveCampaigns: intPtr(20), maxTeamMembers: intPtr(10), maxEmailAccounts: intPtr(20), + monthlyCredits: 2_000, }, { id: PlanProYearlyID, name: "Pro (Annual)", maxContacts: 25_000, dailyEmails: 1_000, @@ -86,6 +90,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { dedicatedWorkers: 1, dailyCampaignLimit: intPtr(1_000), maxCampaigns: intPtr(50), maxActiveCampaigns: intPtr(20), maxTeamMembers: intPtr(10), maxEmailAccounts: intPtr(20), + monthlyCredits: 2_000, }, { id: PlanEnterpriseID, name: "Enterprise", maxContacts: 1_000_000, dailyEmails: 10_000, @@ -94,6 +99,7 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { dedicatedWorkers: 3, dailyCampaignLimit: intPtr(10_000), maxCampaigns: nil, maxActiveCampaigns: nil, maxTeamMembers: nil, maxEmailAccounts: nil, + monthlyCredits: 25_000, }, } @@ -103,8 +109,9 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { id, name, max_contacts, daily_emails, ai_generation, account_limit, price, discounted_price, duration_id, savings, public, dedicated_workers, daily_campaign_limit, - max_campaigns, max_active_campaigns, max_team_members, max_email_accounts - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + max_campaigns, max_active_campaigns, max_team_members, max_email_accounts, + monthly_credits + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, max_contacts = EXCLUDED.max_contacts, @@ -122,12 +129,14 @@ func seedPlans(ctx context.Context, pool *pgxpool.Pool, r *Result) error { max_active_campaigns = EXCLUDED.max_active_campaigns, max_team_members = EXCLUDED.max_team_members, max_email_accounts = EXCLUDED.max_email_accounts, + monthly_credits = EXCLUDED.monthly_credits, updated_at = NOW() `, p.id, p.name, p.maxContacts, p.dailyEmails, p.ai, p.accountLimit, p.price, p.discounted, p.duration, p.savings, p.public, p.dedicatedWorkers, p.dailyCampaignLimit, p.maxCampaigns, p.maxActiveCampaigns, p.maxTeamMembers, p.maxEmailAccounts, + p.monthlyCredits, ) if err != nil { return err From 161d668a8fc19a4f5209d4eda55c96aeb2484319 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 5 Jun 2026 06:04:00 +0200 Subject: [PATCH 32/60] feat: add campaign sequence api clients Add frontend models, clients, and React Query hooks for step A/B variants, attachments, branching conditions, and AI writing generation. --- .../api/client/app/campaigns/abVariants.ts | 42 +++++++++++++++++ .../api/client/app/campaigns/attachments.ts | 40 ++++++++++++++++ .../lib/api/client/app/generation/write.ts | 14 ++++++ .../app/campaigns/useCampaignABVariants.ts | 46 +++++++++++++++++++ .../app/campaigns/useCampaignAttachments.ts | 34 ++++++++++++++ .../hooks/app/generation/useGenerateWrite.ts | 12 +++++ .../lib/api/models/app/campaigns/ABVariant.ts | 37 +++++++++++++++ .../api/models/app/campaigns/Attachment.ts | 17 +++++++ .../app/campaigns/sequences/Branching.ts | 39 ++++++++++++++++ .../app/campaigns/sequences/Sequence.ts | 6 +++ .../lib/api/models/app/generation/Write.ts | 24 ++++++++++ web/src/lib/helper/formatBytes.ts | 11 +++++ 12 files changed, 322 insertions(+) create mode 100644 web/src/lib/api/client/app/campaigns/abVariants.ts create mode 100644 web/src/lib/api/client/app/campaigns/attachments.ts create mode 100644 web/src/lib/api/client/app/generation/write.ts create mode 100644 web/src/lib/api/hooks/app/campaigns/useCampaignABVariants.ts create mode 100644 web/src/lib/api/hooks/app/campaigns/useCampaignAttachments.ts create mode 100644 web/src/lib/api/hooks/app/generation/useGenerateWrite.ts create mode 100644 web/src/lib/api/models/app/campaigns/ABVariant.ts create mode 100644 web/src/lib/api/models/app/campaigns/Attachment.ts create mode 100644 web/src/lib/api/models/app/campaigns/sequences/Branching.ts create mode 100644 web/src/lib/api/models/app/generation/Write.ts create mode 100644 web/src/lib/helper/formatBytes.ts diff --git a/web/src/lib/api/client/app/campaigns/abVariants.ts b/web/src/lib/api/client/app/campaigns/abVariants.ts new file mode 100644 index 00000000..1c1b8ab2 --- /dev/null +++ b/web/src/lib/api/client/app/campaigns/abVariants.ts @@ -0,0 +1,42 @@ +import type ABVariant from "@/lib/api/models/app/campaigns/ABVariant"; +import type { CreateABVariantInput, UpdateABVariantInput } from "@/lib/api/models/app/campaigns/ABVariant"; +import Request from "../../Request"; + +export async function listABVariants(campaignId: string): Promise { + const res = await Request<{ data: ABVariant[] }>({ + method: "GET", + url: `/campaigns/${campaignId}/ab-variants`, + authorization: true, + }); + return res.data ?? []; +} + +export async function createABVariant(campaignId: string, input: CreateABVariantInput): Promise { + return await Request({ + method: "POST", + url: `/campaigns/${campaignId}/ab-variants`, + data: input, + authorization: true, + }); +} + +export async function updateABVariant( + campaignId: string, + variantId: string, + input: UpdateABVariantInput, +): Promise { + return await Request({ + method: "PATCH", + url: `/campaigns/${campaignId}/ab-variants/${variantId}`, + data: input, + authorization: true, + }); +} + +export async function deleteABVariant(campaignId: string, variantId: string): Promise { + await Request({ + method: "DELETE", + url: `/campaigns/${campaignId}/ab-variants/${variantId}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/campaigns/attachments.ts b/web/src/lib/api/client/app/campaigns/attachments.ts new file mode 100644 index 00000000..9cfc5c0c --- /dev/null +++ b/web/src/lib/api/client/app/campaigns/attachments.ts @@ -0,0 +1,40 @@ +import type Attachment from "@/lib/api/models/app/campaigns/Attachment"; +import type { UploadAttachmentOptions } from "@/lib/api/models/app/campaigns/Attachment"; +import Request from "../../Request"; + +// Campaign attachments. The upload sidesteps the JSON body shape because the +// payload is multipart/form-data (the file rides the "file" field), mirroring +// avatar/uploadUserAvatar. List/delete are plain JSON requests. + +export async function listAttachments(campaignId: string): Promise { + const res = await Request<{ data: Attachment[] }>({ + method: "GET", + url: `/campaigns/${campaignId}/attachments`, + authorization: true, + }); + return res.data ?? []; +} + +export async function uploadAttachment( + campaignId: string, + file: File, + opts?: UploadAttachmentOptions, +): Promise { + const fd = new FormData(); + fd.append("file", file, file.name); + if (opts?.sequenceId) fd.append("sequence_id", opts.sequenceId); + return await Request({ + method: "POST", + url: `/campaigns/${campaignId}/attachments`, + data: fd, + authorization: true, + }); +} + +export async function deleteAttachment(campaignId: string, attachmentId: string): Promise { + await Request({ + method: "DELETE", + url: `/campaigns/${campaignId}/attachments/${attachmentId}`, + authorization: true, + }); +} diff --git a/web/src/lib/api/client/app/generation/write.ts b/web/src/lib/api/client/app/generation/write.ts new file mode 100644 index 00000000..8bcabaa6 --- /dev/null +++ b/web/src/lib/api/client/app/generation/write.ts @@ -0,0 +1,14 @@ +import type { WriteRequest, WriteResponse } from "@/lib/api/models/app/generation/Write"; +import Request from "../../Request"; + +// POST /generation/write — drafts email copy from a prompt. Returns a 402 when +// the org is out of generation credits; that surfaces as an AppError with +// status 402 (handled at the call site with a friendly toast). +export default async function write(body: WriteRequest): Promise { + return await Request({ + method: "POST", + url: "/generation/write", + data: body, + authorization: true, + }); +} diff --git a/web/src/lib/api/hooks/app/campaigns/useCampaignABVariants.ts b/web/src/lib/api/hooks/app/campaigns/useCampaignABVariants.ts new file mode 100644 index 00000000..4f4cd92a --- /dev/null +++ b/web/src/lib/api/hooks/app/campaigns/useCampaignABVariants.ts @@ -0,0 +1,46 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + listABVariants, + createABVariant, + updateABVariant, + deleteABVariant, +} from "@/lib/api/client/app/campaigns/abVariants"; +import type { + CreateABVariantInput, + UpdateABVariantInput, +} from "@/lib/api/models/app/campaigns/ABVariant"; + +const key = (campaignId: string) => ["campaigns", campaignId, "ab-variants"]; + +export function useCampaignABVariants(campaignId: string) { + return useQuery({ + queryKey: key(campaignId), + queryFn: () => listABVariants(campaignId), + enabled: !!campaignId, + }); +} + +export function useCreateABVariant(campaignId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateABVariantInput) => createABVariant(campaignId, input), + onSuccess: () => qc.invalidateQueries({ queryKey: key(campaignId) }), + }); +} + +export function useUpdateABVariant(campaignId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ variantId, input }: { variantId: string; input: UpdateABVariantInput }) => + updateABVariant(campaignId, variantId, input), + onSuccess: () => qc.invalidateQueries({ queryKey: key(campaignId) }), + }); +} + +export function useDeleteABVariant(campaignId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (variantId: string) => deleteABVariant(campaignId, variantId), + onSuccess: () => qc.invalidateQueries({ queryKey: key(campaignId) }), + }); +} diff --git a/web/src/lib/api/hooks/app/campaigns/useCampaignAttachments.ts b/web/src/lib/api/hooks/app/campaigns/useCampaignAttachments.ts new file mode 100644 index 00000000..2f9a6fc6 --- /dev/null +++ b/web/src/lib/api/hooks/app/campaigns/useCampaignAttachments.ts @@ -0,0 +1,34 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + listAttachments, + uploadAttachment, + deleteAttachment, +} from "@/lib/api/client/app/campaigns/attachments"; +import type { UploadAttachmentOptions } from "@/lib/api/models/app/campaigns/Attachment"; + +const key = (campaignId: string) => ["campaigns", campaignId, "attachments"]; + +export function useCampaignAttachments(campaignId: string) { + return useQuery({ + queryKey: key(campaignId), + queryFn: () => listAttachments(campaignId), + enabled: !!campaignId, + }); +} + +export function useUploadAttachment(campaignId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ file, opts }: { file: File; opts?: UploadAttachmentOptions }) => + uploadAttachment(campaignId, file, opts), + onSuccess: () => qc.invalidateQueries({ queryKey: key(campaignId) }), + }); +} + +export function useDeleteAttachment(campaignId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (attachmentId: string) => deleteAttachment(campaignId, attachmentId), + onSuccess: () => qc.invalidateQueries({ queryKey: key(campaignId) }), + }); +} diff --git a/web/src/lib/api/hooks/app/generation/useGenerateWrite.ts b/web/src/lib/api/hooks/app/generation/useGenerateWrite.ts new file mode 100644 index 00000000..f0627f7a --- /dev/null +++ b/web/src/lib/api/hooks/app/generation/useGenerateWrite.ts @@ -0,0 +1,12 @@ +import { useMutation } from "@tanstack/react-query"; +import write from "@/lib/api/client/app/generation/write"; +import type { WriteRequest } from "@/lib/api/models/app/generation/Write"; + +// On-demand AI draft generation. A mutation, not a query, because it's run +// explicitly from the "Write with AI" control. A 402 (out of credits) rejects +// with an AppError carrying status 402 for the call site to message. +export default function useGenerateWrite() { + return useMutation({ + mutationFn: (body: WriteRequest) => write(body), + }); +} diff --git a/web/src/lib/api/models/app/campaigns/ABVariant.ts b/web/src/lib/api/models/app/campaigns/ABVariant.ts new file mode 100644 index 00000000..977f0617 --- /dev/null +++ b/web/src/lib/api/models/app/campaigns/ABVariant.ts @@ -0,0 +1,37 @@ +// An A/B test variant for a campaign. When sequence_id is set the variant is +// scoped to that step; null = campaign-level (applies to every step). +export default interface ABVariant { + id: string; + campaign_id: string; + sequence_id?: string | null; + name: string; + weight: number; + subject: string; + body_html: string; + body_plain: string; + is_control: boolean; + is_active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface CreateABVariantInput { + name: string; + sequence_id?: string | null; + weight?: number; + subject?: string; + body_html?: string; + body_plain?: string; + is_control?: boolean; + is_active?: boolean; +} + +export interface UpdateABVariantInput { + name?: string; + weight?: number; + subject?: string; + body_html?: string; + body_plain?: string; + is_control?: boolean; + is_active?: boolean; +} diff --git a/web/src/lib/api/models/app/campaigns/Attachment.ts b/web/src/lib/api/models/app/campaigns/Attachment.ts new file mode 100644 index 00000000..32042146 --- /dev/null +++ b/web/src/lib/api/models/app/campaigns/Attachment.ts @@ -0,0 +1,17 @@ +// A file attached to a campaign. When sequence_id is set the attachment is +// scoped to that step; null/absent = campaign-level (attached to every step). +export default interface Attachment { + id: string; + filename: string; + size: number; + mime_type: string; + url: string; + sequence_id?: string | null; + created_at: Date; +} + +// Options for POST /campaigns/:id/attachments. The file rides as the multipart +// "file" field; sequence_id is an optional form field that scopes the upload. +export interface UploadAttachmentOptions { + sequenceId?: string | null; +} diff --git a/web/src/lib/api/models/app/campaigns/sequences/Branching.ts b/web/src/lib/api/models/app/campaigns/sequences/Branching.ts new file mode 100644 index 00000000..0fd7bc64 --- /dev/null +++ b/web/src/lib/api/models/app/campaigns/sequences/Branching.ts @@ -0,0 +1,39 @@ +// Step branching — conditional routing between steps. Each branch fires when its +// conditions match (recipient opened / clicked / replied, optionally within N +// days) and routes to a target step, or stops the sequence when the target is +// null. +// +// Shipped on the sequence PATCH body as `conditions: { branches: [...] }` +// (PATCH /campaigns/:id/sequences/:seqId), so it rides the existing +// useUpdateSequence mutation. + +export type BranchField = "opened" | "clicked" | "replied" | "random"; +export type BranchOperator = "within_days" | "always" | "chance"; + +export interface BranchCondition { + field: BranchField; + operator: BranchOperator; + // For `within_days`: the number of days the event must fall within. For + // `random`/`chance`: the percentage (1-99) of contacts that take this branch + // (deterministic per contact). Omitted/ignored for `always`. + value?: number; +} + +export interface SequenceBranch { + branch_id: string; + // null = stop the sequence when this branch matches. + target_sequence_id: string | null; + conditions: BranchCondition[]; +} + +// The full conditions payload carried on the sequence record + PATCH body. +export interface SequenceConditions { + branches: SequenceBranch[]; +} + +export const BRANCH_FIELD_LABELS: Record = { + opened: "opened the email", + clicked: "clicked a link", + replied: "replied", + random: "random split", +}; diff --git a/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts b/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts index 8f4e9c34..a25ecb8f 100644 --- a/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts +++ b/web/src/lib/api/models/app/campaigns/sequences/Sequence.ts @@ -1,3 +1,5 @@ +import type { SequenceConditions } from "./Branching"; + export default interface Sequence { id: string; name: string; @@ -11,6 +13,10 @@ export default interface Sequence { wait_after: number; + // Conditional step routing. Absent when the step has no branches; a PATCH + // with this field replaces the step's branch set wholesale. + conditions?: SequenceConditions | null; + updated_at: Date; created_at: Date; } diff --git a/web/src/lib/api/models/app/generation/Write.ts b/web/src/lib/api/models/app/generation/Write.ts new file mode 100644 index 00000000..f9a83c4f --- /dev/null +++ b/web/src/lib/api/models/app/generation/Write.ts @@ -0,0 +1,24 @@ +// AI writing assistant — POST /generation/write. Drafts email copy from a short +// prompt; the response reports the remaining generation credits and the model +// that produced the text. A 402 means the org is out of credits. +export interface WriteRequest { + prompt: string; + tone?: string; +} + +export interface WriteResponse { + text: string; + credits_remaining: number; + model: string; +} + +// Tone presets surfaced in the "Write with AI" popover. `value` is sent as the +// `tone` field; an empty value lets the backend pick its default. +export const WRITE_TONES: { value: string; label: string }[] = [ + { value: "", label: "Default" }, + { value: "friendly", label: "Friendly" }, + { value: "professional", label: "Professional" }, + { value: "casual", label: "Casual" }, + { value: "concise", label: "Concise" }, + { value: "persuasive", label: "Persuasive" }, +]; diff --git a/web/src/lib/helper/formatBytes.ts b/web/src/lib/helper/formatBytes.ts new file mode 100644 index 00000000..388d75b0 --- /dev/null +++ b/web/src/lib/helper/formatBytes.ts @@ -0,0 +1,11 @@ +// Human-readable byte sizes for file lists (attachments, uploads, etc.). +// 1500 -> "1.5 KB", 0 -> "0 B". +export default function formatBytes(bytes: number, decimals = 1): string { + if (!bytes || bytes <= 0) return "0 B"; + const k = 1024; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1); + const value = bytes / Math.pow(k, i); + const dm = i === 0 ? 0 : decimals; + return `${value.toFixed(dm)} ${units[i]}`; +} From b9d83f1b2ce81c1fddaf3423233ddd32f000cdf6 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 5 Jun 2026 06:04:14 +0200 Subject: [PATCH 33/60] feat: add campaign flow canvas Replace the sequence step list with a React Flow canvas for branching outreach flows, including auto-layout, edge editing, and inline step editing. --- web/package.json | 2 + web/pnpm-lock.yaml | 200 +++++ .../app/app/campaigns/[id]/sequences/page.tsx | 77 +- .../app/campaigns/sequences/CampaignFlow.tsx | 727 ++++++++++++++++++ web/src/global.css | 15 + 5 files changed, 951 insertions(+), 70 deletions(-) create mode 100644 web/src/components/app/campaigns/sequences/CampaignFlow.tsx diff --git a/web/package.json b/web/package.json index 93855ce2..0d973482 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@dagrejs/dagre": "^3.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -69,6 +70,7 @@ "@tiptap/pm": "^3.9.1", "@tiptap/react": "^3.8.0", "@types/papaparse": "^5.3.16", + "@xyflow/react": "^12.11.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0675a246..7a598daf 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@dagrejs/dagre': + specifier: ^3.0.0 + version: 3.0.0 '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -173,6 +176,9 @@ importers: '@types/papaparse': specifier: ^5.3.16 version: 5.5.0 + '@xyflow/react': + specifier: ^12.11.0 + version: 12.11.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(immer@11.1.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@zxcvbn-ts/core': specifier: ^3.0.4 version: 3.0.4 @@ -460,6 +466,12 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@dagrejs/dagre@3.0.0': + resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==} + + '@dagrejs/graphlib@4.0.1': + resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1877,6 +1889,24 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2015,6 +2045,22 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@xyflow/react@12.11.0': + resolution: {integrity: sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.77': + resolution: {integrity: sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==} + '@zxcvbn-ts/core@3.0.4': resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==} @@ -2135,6 +2181,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2187,6 +2236,44 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-urls@6.0.1: resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} engines: {node: '>=20'} @@ -3434,6 +3521,21 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.10: resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==} engines: {node: '>=12.20.0'} @@ -3622,6 +3724,12 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dagrejs/dagre@3.0.0': + dependencies: + '@dagrejs/graphlib': 4.0.1 + + '@dagrejs/graphlib@4.0.1': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.0)': dependencies: react: 19.2.0 @@ -4897,6 +5005,27 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -5088,6 +5217,31 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@xyflow/react@12.11.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(immer@11.1.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@xyflow/system': 0.0.77 + classcat: 5.0.5 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + zustand: 4.5.7(@types/react@19.2.7)(immer@11.1.3)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.77': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + '@zxcvbn-ts/core@3.0.4': dependencies: fastest-levenshtein: 1.0.16 @@ -5208,6 +5362,8 @@ snapshots: dependencies: clsx: 2.1.1 + classcat@5.0.5: {} + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -5262,6 +5418,42 @@ snapshots: csstype@3.2.3: {} + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + data-urls@6.0.1: dependencies: whatwg-mimetype: 5.0.0 @@ -6415,6 +6607,14 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@19.2.7)(immer@11.1.3)(react@19.2.0): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.7 + immer: 11.1.3 + react: 19.2.0 + zustand@5.0.10(@types/react@19.2.7)(immer@11.1.3)(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)): optionalDependencies: '@types/react': 19.2.7 diff --git a/web/src/app/app/campaigns/[id]/sequences/page.tsx b/web/src/app/app/campaigns/[id]/sequences/page.tsx index 5a4398cb..fc20b043 100644 --- a/web/src/app/app/campaigns/[id]/sequences/page.tsx +++ b/web/src/app/app/campaigns/[id]/sequences/page.tsx @@ -2,11 +2,9 @@ import React from "react"; import { LayersIcon, Loader2Icon, PlusIcon } from "lucide-react"; import toast from "react-hot-toast"; import { useCampaign } from "@/hooks/context/campaign"; -import StepRail from "@/components/app/campaigns/sequences/StepRail"; -import SequenceView from "@/components/app/campaigns/sequences/SequenceView"; +import CampaignFlow from "@/components/app/campaigns/sequences/CampaignFlow"; import useSequences from "@/lib/api/hooks/app/campaigns/sequences/useSequences"; import useCreateSequence from "@/lib/api/hooks/app/campaigns/sequences/useCreateSequence"; -import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; @@ -27,30 +25,16 @@ function SequencesBuilder({ campaignId }: { campaignId: string }) { const { data: sequences } = useSequences(campaignId); const createSequence = useCreateSequence(campaignId); const [creating, setCreating] = React.useState(false); - const [selectedId, setSelectedId] = React.useState(""); - - // Keep a valid selection: default to the first step, and recover if the - // selected step is deleted. - React.useEffect(() => { - if (sequences.length === 0) { - if (selectedId !== "") setSelectedId(""); - return; - } - if (!sequences.some((s) => s.id === selectedId)) { - setSelectedId(sequences[0].id); - } - }, [sequences, selectedId]); async function create() { if (creating) return; setCreating(true); try { - const created = await toast.promise(createSequence.mutateAsync(), { + await toast.promise(createSequence.mutateAsync(), { loading: "Adding step…", success: "Step added.", error: (err: AppError) => buildError(err), }); - setSelectedId((created as Sequence).id); } finally { setCreating(false); } @@ -62,10 +46,10 @@ function SequencesBuilder({ campaignId }: { campaignId: string }) {
-

Build your sequence

+

Build your flow

- Add steps to automate the outreach flow. The first step sends immediately; - later steps wait and thread as follow-ups. + Add your first step, then connect steps to branch on opens, clicks, or replies. + The first step sends immediately; later steps wait and thread as follow-ups.

+ +
+ ); +} + +function StopNode() { + return ( +
+ + + Stop +
+ ); +} + +const nodeTypes = { step: StepNode, stop: StopNode }; + +export default function CampaignFlow({ campaignId }: { campaignId: string }) { + const { data: sequences } = useSequences(campaignId); + const createSequence = useCreateSequence(campaignId); + const qc = useQueryClient(); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [selectedEdge, setSelectedEdge] = React.useState<{ sourceId: string; branchId: string } | null>(null); + const [selectedElse, setSelectedElse] = React.useState(null); + const [editStepId, setEditStepId] = React.useState(null); + const [adding, setAdding] = React.useState(false); + // Topology fingerprint of the last render; when it changes (a step or branch + // was added/removed) we re-run the auto-layout instead of keeping stale + // positions, so new branches fan out immediately without hitting "Tidy up". + const structureSig = React.useRef(""); + + const seqById = React.useMemo(() => { + const m = new Map(); + for (const s of sequences) m.set(s.id, s); + return m; + }, [sequences]); + + const saveBranches = React.useCallback( + async (sourceId: string, branches: SequenceBranch[]) => { + try { + await updateSequence(campaignId, sourceId, { conditions: { branches } }); + qc.invalidateQueries({ queryKey: ["campaigns", campaignId, "sequences"] }); + } catch { + toast.error("Couldn't save the branch"); + } + }, + [campaignId, qc], + ); + + // Set a step's "else" (fallback) path. `"default"` removes any explicit + // catch-all so the step falls through to the linear next step; a step id or + // null (Stop) writes a single empty-conditions branch kept LAST in the array + // so it is evaluated after every conditional branch. + const setElse = React.useCallback( + (sourceId: string, target: string | null | "default") => { + const src = seqById.get(sourceId); + if (!src) return; + const rest = (src.conditions?.branches ?? []).filter((b) => (b.conditions?.length ?? 0) > 0); + if (target !== "default") { + rest.push({ branch_id: newBranchId(), target_sequence_id: target, conditions: [] }); + } + saveBranches(sourceId, rest); + }, + [seqById, saveBranches], + ); + + const addStep = React.useCallback(async () => { + if (adding) return; + setAdding(true); + try { + await createSequence.mutateAsync(); + toast.success("Step added"); + } catch { + toast.error("Couldn't add the step"); + } finally { + setAdding(false); + } + }, [adding, createSequence]); + + // Add a branch from a step with a sensible default (opened within 3 days -> + // the next step, or stop if none), then open the editor on it. + const addBranch = React.useCallback( + (sourceId: string) => { + const idx = sequences.findIndex((s) => s.id === sourceId); + const defaultTarget = + sequences[idx + 1]?.id ?? sequences.find((s) => s.id !== sourceId)?.id ?? null; + const branch: SequenceBranch = { + branch_id: newBranchId(), + target_sequence_id: defaultTarget, + conditions: [{ field: "opened", operator: "within_days", value: 3 }], + }; + const src = seqById.get(sourceId); + saveBranches(sourceId, [...(src?.conditions?.branches ?? []), branch]); + setSelectedEdge({ sourceId, branchId: branch.branch_id }); + }, + [sequences, seqById, saveBranches], + ); + + // Drag a step's dot onto empty canvas -> add a new step coming off that step. + // The first/only path out of the last step is the plain unconditional "next" + // (no branch); any additional path becomes a conditional branch, so only one + // ever runs per contact. + const handleDragOut = React.useCallback( + async (sourceId: string) => { + if (adding) return; + const src = seqById.get(sourceId); + const idx = sequences.findIndex((s) => s.id === sourceId); + const isLast = idx === sequences.length - 1; + const hasBranches = (src?.conditions?.branches ?? []).length > 0; + setAdding(true); + try { + const created = (await createSequence.mutateAsync()) as Sequence; + if (!(isLast && !hasBranches)) { + const branch: SequenceBranch = { + branch_id: newBranchId(), + target_sequence_id: created.id, + conditions: [{ field: "opened", operator: "within_days", value: 3 }], + }; + await saveBranches(sourceId, [...(src?.conditions?.branches ?? []), branch]); + setSelectedEdge({ sourceId, branchId: branch.branch_id }); + } + toast.success("Step added"); + } catch { + toast.error("Couldn't add the step"); + } finally { + setAdding(false); + } + }, + [adding, sequences, seqById, createSequence, saveBranches], + ); + + React.useEffect(() => { + const stepNodes: Node[] = sequences.map((s, i) => ({ + id: s.id, + type: "step", + position: { x: 0, y: 0 }, + data: { + label: s.name, + subtitle: s.subject, + wait: i === 0 ? "sends now" : `wait ${s.wait_after}d`, + index: i, + branchCount: (s.conditions?.branches ?? []).filter((b) => (b.conditions?.length ?? 0) > 0).length, + onAddBranch: () => addBranch(s.id), + }, + })); + const hasStop = sequences.some((s) => (s.conditions?.branches ?? []).some((b) => b.target_sequence_id === null)); + if (hasStop) { + stepNodes.push({ + id: STOP_ID, + type: "stop", + position: { x: 0, y: 0 }, + data: {}, + }); + } + + const flowEdges: Edge[] = []; + sequences.forEach((s, i) => { + const branches = s.conditions?.branches ?? []; + const conditional = branches.filter((b) => (b.conditions?.length ?? 0) > 0); + // A single empty-conditions branch is the explicit "else" (catch-all), + // kept last on save so it is evaluated after every conditional branch. + const catchAll = branches.find((b) => (b.conditions?.length ?? 0) === 0); + for (const b of conditional) { + const target = b.target_sequence_id ?? STOP_ID; + flowEdges.push({ + id: `br-${s.id}-${b.branch_id}`, + source: s.id, + target, + label: branchLabel(b), + reconnectable: true, + style: { stroke: "#0ea5e9", strokeWidth: 2 }, + labelStyle: { fill: "#0369a1", fontSize: 10, fontWeight: 600 }, + labelBgStyle: { fill: "#e0f2fe" }, + labelBgPadding: [4, 2], + data: { sourceId: s.id, branchId: b.branch_id }, + }); + } + // The "else" path: an explicit catch-all if set, otherwise the implicit + // linear next step. Clickable + reconnectable so a step can fall back to + // any step or to Stop — that is how a step becomes branch-only. + const next = sequences[i + 1]; + if (catchAll) { + flowEdges.push({ + id: `else-${s.id}`, + source: s.id, + target: catchAll.target_sequence_id ?? STOP_ID, + label: "else", + reconnectable: true, + style: { stroke: "#cbd5e1", strokeDasharray: "4 4" }, + labelStyle: { fill: "#94a3b8", fontSize: 10 }, + labelBgStyle: { fill: "#fff" }, + data: { sourceId: s.id, isElse: true, branchId: catchAll.branch_id }, + }); + } else if (next) { + flowEdges.push({ + id: `else-${s.id}`, + source: s.id, + target: next.id, + label: conditional.length > 0 ? "else" : "then", + reconnectable: true, + style: { stroke: "#cbd5e1", strokeDasharray: "4 4" }, + labelStyle: { fill: "#94a3b8", fontSize: 10 }, + labelBgStyle: { fill: "#fff" }, + data: { sourceId: s.id, isElse: true }, + }); + } + }); + + const laid = layoutGraph(stepNodes, flowEdges); + const sig = + stepNodes.map((n) => n.id).sort().join(",") + + "|" + + flowEdges.map((e) => `${e.source}>${e.target}`).sort().join(","); + const structureChanged = sig !== structureSig.current; + structureSig.current = sig; + setNodes((cur) => { + // On a topology change (step/branch added or removed) re-flow from + // scratch so branches fan out; otherwise keep manual drag positions. + if (structureChanged) return laid; + const pos = new Map(cur.map((n) => [n.id, n.position])); + return laid.map((n) => (pos.has(n.id) ? { ...n, position: pos.get(n.id)! } : n)); + }); + setEdges(flowEdges); + }, [sequences, setNodes, setEdges, addBranch]); + + const onConnect = React.useCallback( + (c: Connection) => { + if (!c.source || !c.target || c.source === c.target) return; + const src = seqById.get(c.source); + if (!src) return; + const target = c.target === STOP_ID ? null : c.target; + const branch: SequenceBranch = { + branch_id: newBranchId(), + target_sequence_id: target, + conditions: [{ field: "opened", operator: "within_days", value: 3 }], + }; + saveBranches(c.source, [...(src.conditions?.branches ?? []), branch]); + setSelectedEdge({ sourceId: c.source, branchId: branch.branch_id }); + }, + [seqById, saveBranches], + ); + + const selectedBranch: { source: Sequence; branch: SequenceBranch } | null = React.useMemo(() => { + if (!selectedEdge) return null; + const src = seqById.get(selectedEdge.sourceId); + const br = src?.conditions?.branches?.find((b) => b.branch_id === selectedEdge.branchId); + return src && br ? { source: src, branch: br } : null; + }, [selectedEdge, seqById]); + + const editStep = editStepId ? seqById.get(editStepId) : undefined; + const editIndex = editStep ? sequences.findIndex((s) => s.id === editStep.id) : -1; + + return ( +
+ { + // Dropped on empty canvas (no target node) -> add a step off the source. + if (state.fromNode && !state.toNode) handleDragOut(state.fromNode.id); + }} + onReconnect={(oldEdge, conn) => { + // Dragged an arrow's end onto a different step -> retarget it. + const d = oldEdge.data as { sourceId?: string; branchId?: string; isElse?: boolean } | undefined; + if (!d?.sourceId || !conn.target) return; + const src = seqById.get(d.sourceId); + if (!src) return; + const newTarget = conn.target === STOP_ID ? null : conn.target; + if (d.isElse) { + // Dragging the dashed "else" arrow makes the fallback explicit. + setElse(d.sourceId, newTarget); + return; + } + if (!d.branchId) return; + const branches = (src.conditions?.branches ?? []).map((b) => + b.branch_id === d.branchId ? { ...b, target_sequence_id: newTarget } : b, + ); + saveBranches(d.sourceId, branches); + }} + nodeTypes={nodeTypes} + onEdgeClick={(_, edge) => { + const d = edge.data as { sourceId?: string; branchId?: string; isElse?: boolean } | undefined; + if (!d?.sourceId) return; + if (d.isElse) setSelectedElse(d.sourceId); + else if (d.branchId) setSelectedEdge({ sourceId: d.sourceId, branchId: d.branchId }); + }} + onNodeClick={(_, node) => { + if (node.id !== STOP_ID) setEditStepId(node.id); + }} + fitView + proOptions={{ hideAttribution: true }} + > + + + + +
+ + +
+
+ + +
+ + else — click to send elsewhere or end + + + branch — first match wins, only one runs + + drag a step's bottom dot onto empty space or another step +
+
+
+ + {selectedBranch && ( + setSelectedEdge(null)} + onSave={(updated) => { + const branches = (selectedBranch.source.conditions?.branches ?? []).map((b) => + b.branch_id === updated.branch_id ? updated : b, + ); + saveBranches(selectedBranch.source.id, branches); + setSelectedEdge(null); + }} + onDelete={() => { + const branches = (selectedBranch.source.conditions?.branches ?? []).filter( + (b) => b.branch_id !== selectedBranch.branch.branch_id, + ); + saveBranches(selectedBranch.source.id, branches); + setSelectedEdge(null); + }} + /> + )} + + {selectedElse && + (() => { + const src = seqById.get(selectedElse); + if (!src) return null; + const sIdx = sequences.findIndex((s) => s.id === src.id); + const branches = src.conditions?.branches ?? []; + const catchAll = branches.find((b) => (b.conditions?.length ?? 0) === 0); + const hasConditional = branches.some((b) => (b.conditions?.length ?? 0) > 0); + const current = catchAll ? catchAll.target_sequence_id ?? STOP_ID : "default"; + return ( + setSelectedElse(null)} + onPick={(target) => { + setElse(src.id, target); + setSelectedElse(null); + }} + /> + ); + })()} + + {editStep && ( +
+
+ Edit step + +
+
+ +
+
+ )} +
+ ); +} + +// ── Branch editor (plain-English condition editor for a selected arrow) ────── +function BranchEditor({ + source, + branch, + steps, + onClose, + onSave, + onDelete, +}: { + source: Sequence; + branch: SequenceBranch; + steps: Sequence[]; + onClose: () => void; + onSave: (b: SequenceBranch) => void; + onDelete: () => void; +}) { + const c0 = branch.conditions?.[0] ?? { field: "opened", operator: "within_days", value: 3 }; + const [field, setField] = React.useState(c0.field); + const [operator, setOperator] = React.useState(c0.operator); + const [days, setDays] = React.useState(c0.value ?? (c0.field === "random" ? 50 : 3)); + const sel = + "h-7 rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-800 focus:border-sky-400 focus:outline-none"; + const sourceIdx = steps.findIndex((s) => s.id === source.id); + const targetIdx = steps.findIndex((s) => s.id === branch.target_sequence_id); + const targetLabel = + branch.target_sequence_id === null + ? "Stop the sequence" + : targetIdx >= 0 + ? `Step ${targetIdx + 1}${steps[targetIdx].name ? ` · ${steps[targetIdx].name}` : ""}` + : "—"; + + return ( +
+
+ + After step {sourceIdx + 1} + + +
+
+
+ if the contact + +
+ {field === "random" ? ( +
+ setDays(Math.max(1, Math.min(99, Number(e.target.value) || 1)))} + className={`${sel} w-16 text-center`} + /> + % of contacts (chosen at random) +
+ ) : ( +
+ + {operator === "within_days" && ( + <> + setDays(Math.max(1, Math.min(60, Number(e.target.value) || 1)))} + className={`${sel} w-16 text-center`} + /> + days + + )} +
+ )} +
+ then go to + {targetLabel} +
+

+ Drag the arrow's end onto another step to change where this goes. +

+
+
+ + +
+
+ ); +} + +// ── Else editor (the fallback path when no branch matches) ─────────────────── +// Picks where the contact goes when none of a step's branches fire: continue to +// the next step (default), end the sequence, or jump to a specific step. Setting +// it to "end" or another step is what turns a step branch-only. +function ElseEditor({ + sourceId, + sourceIndex, + hasConditional, + current, + steps, + onClose, + onPick, +}: { + sourceId: string; + sourceIndex: number; + hasConditional: boolean; + current: string; // "default" | STOP_ID | stepId + steps: Sequence[]; + onClose: () => void; + onPick: (target: string | null | "default") => void; +}) { + const sel = + "h-7 w-full rounded-md border border-slate-200 bg-white px-2 text-[12px] text-slate-800 focus:border-sky-400 focus:outline-none"; + return ( +
+
+ + After step {sourceIndex + 1} · else + + +
+

+ {hasConditional + ? "When none of the branches above match, the contact" + : "After this step, the contact"} +

+ +

+ Set this to “stops here” (or another step) to make this step branch-only. You can also drag the dashed + arrow’s end onto another step. +

+
+ ); +} diff --git a/web/src/global.css b/web/src/global.css index b48b63a0..a2f32b17 100644 --- a/web/src/global.css +++ b/web/src/global.css @@ -302,6 +302,21 @@ svg.loading circle { margin: 0; } +/* Campaign Step composer body (TipTap editor + the rendered Preview pane). + Tailwind preflight strips list markers + heading sizes, so restore them. */ +.tiptap-body { word-break: break-word; } +.tiptap-body p { margin: 0 0 0.6em; } +.tiptap-body p:last-child { margin-bottom: 0; } +.tiptap-body h2 { font-size: 1.25rem; line-height: 1.3; font-weight: 600; margin: 0.5em 0 0.3em; color: #0f172a; } +.tiptap-body h3 { font-size: 1.08rem; line-height: 1.3; font-weight: 600; margin: 0.5em 0 0.3em; color: #0f172a; } +.tiptap-body ul { list-style: disc; padding-left: 1.25rem; margin: 0.4em 0; } +.tiptap-body ol { list-style: decimal; padding-left: 1.25rem; margin: 0.4em 0; } +.tiptap-body li { margin: 0.15em 0; } +.tiptap-body li > p { margin: 0; } +.tiptap-body a { color: #0284c7; text-decoration: underline; } +.tiptap-body strong { font-weight: 600; } +.tiptap-body:focus { outline: none; } + @layer base { * { @apply border-border outline-ring/50; From e4cd88d49119c09c1c5d9dde0e98925ad8c11d4b Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 5 Jun 2026 06:04:31 +0200 Subject: [PATCH 34/60] feat: upgrade campaign step composer Replace the legacy email body editor with the rich step composer, add preview/template controls, expose AI writing in campaigns and unibox replies, and close confirms after successful actions. --- web/src/app/app/campaigns/[id]/page.tsx | 4 +- .../campaigns/sequences/RichTextEditor.tsx | 14 +- .../app/campaigns/sequences/SequenceView.tsx | 309 ++++++++++++++---- .../app/campaigns/sequences/WriteWithAI.tsx | 137 ++++++++ .../components/app/unibox/ReplyComposer.tsx | 7 + web/src/hooks/ConfirmProvider.tsx | 8 + 6 files changed, 404 insertions(+), 75 deletions(-) create mode 100644 web/src/components/app/campaigns/sequences/WriteWithAI.tsx diff --git a/web/src/app/app/campaigns/[id]/page.tsx b/web/src/app/app/campaigns/[id]/page.tsx index af42c03f..ca55e6fb 100644 --- a/web/src/app/app/campaigns/[id]/page.tsx +++ b/web/src/app/app/campaigns/[id]/page.tsx @@ -150,7 +150,7 @@ export default function CampaignOverview() { )}
- + {loading ? (
{[...Array(2)].map((_, i) => ( @@ -163,7 +163,7 @@ export default function CampaignOverview() {
) : sequences.length === 0 ? (
-

No sequence data yet

+

No step data yet

Once steps start sending, per-step opens, clicks, and replies show up here.

diff --git a/web/src/components/app/campaigns/sequences/RichTextEditor.tsx b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx index ffc589f2..595dcdec 100644 --- a/web/src/components/app/campaigns/sequences/RichTextEditor.tsx +++ b/web/src/components/app/campaigns/sequences/RichTextEditor.tsx @@ -85,12 +85,14 @@ export default function RichTextEditor({ return (
- - {placeholder && editor.isEmpty && ( -

- {placeholder} -

- )} +
+ + {placeholder && editor.isEmpty && ( +

+ {placeholder} +

+ )} +
); } diff --git a/web/src/components/app/campaigns/sequences/SequenceView.tsx b/web/src/components/app/campaigns/sequences/SequenceView.tsx index 5e1b5dbb..15bbe625 100644 --- a/web/src/components/app/campaigns/sequences/SequenceView.tsx +++ b/web/src/components/app/campaigns/sequences/SequenceView.tsx @@ -1,31 +1,71 @@ +// The campaign Step composer (right pane). A full email editor: rich TipTap +// body, subject with variable insert, a template picker + save-as-template +// (reusing the org template library), an Edit/Preview toggle that renders +// {{variables}} + spintax with sample data, and the advisory content score. + import React from "react"; -import { GitBranchIcon, Loader2Icon } from "lucide-react"; +import { + BookmarkPlusIcon, + EyeIcon, + GitBranchIcon, + Loader2Icon, + PencilLineIcon, + SparklesIcon, +} from "lucide-react"; import toast from "react-hot-toast"; import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; -import EmailEditor from "../../EmailEditor"; +import RichTextEditor, { VariableMenu } from "./RichTextEditor"; +import WriteWithAI from "./WriteWithAI"; import ContentScore from "../ContentScore"; import { Label, TextInput } from "@/components/ui/field"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuTrigger, + SelectButton, +} from "@/components/ui/popover-menu"; import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdateSequence"; +import useTemplates from "@/lib/api/hooks/app/templates/useTemplates"; +import useCreateTemplate from "@/lib/api/hooks/app/templates/useCreateTemplate"; +import { useConfirm } from "@/hooks/context/confirm"; import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; -// Editable subset of a step. wait_after is edited from the stepper rail, so the -// editor pane only owns the content fields — but the save still diffs the same -// body_sync/body_html/body_plain/body_code semantics the original used. -type Draft = Pick< - Sequence, - "name" | "subject" | "body_plain" | "body_html" | "body_sync" | "body_code" ->; +const VARIABLES = ["{{.FirstName}}", "{{.LastName}}", "{{.Email}}", "{{.Company}}", "{{.Phone}}"]; +const SAMPLE: Record = { + FirstName: "Alex", + LastName: "Rivera", + Email: "alex@acme.com", + Company: "Acme", + Phone: "+1 555-0100", +}; + +// Body fields the composer owns. body_sync/body_code are legacy editor-only +// flags (they don't affect sending), so the new composer keeps HTML + plain in +// lockstep and leaves them alone. +type Draft = Pick; function toDraft(s: Sequence): Draft { - return { - name: s.name, - subject: s.subject, - body_plain: s.body_plain, - body_html: s.body_html, - body_sync: s.body_sync, - body_code: s.body_code, - }; + return { name: s.name, subject: s.subject, body_plain: s.body_plain, body_html: s.body_html }; +} + +// Derive plain text from the editor HTML so both alternatives ship populated. +function htmlToPlain(html: string): string { + const withBreaks = html + .replace(/<\s*br\s*\/?>/gi, "\n") + .replace(/<\/\s*(p|div|h[1-6]|li|tr)\s*>/gi, "\n"); + if (typeof document === "undefined") return withBreaks.replace(/<[^>]+>/g, ""); + const tmp = document.createElement("div"); + tmp.innerHTML = withBreaks; + return (tmp.textContent || "").replace(/\n{3,}/g, "\n\n").trim(); +} + +// Preview: expand spintax {a|b} to the first option and fill {{.Vars}} with +// sample values (unknown → empty), matching the send-time render shape. +function renderPreview(s: string): string { + return s + .replace(/\{([^{}|]+(?:\|[^{}]+)+)\}/g, (_, g: string) => g.split("|")[0]) + .replace(/\{\{\.(\w+)\}\}/g, (_, k: string) => SAMPLE[k] ?? ""); } export default function SequenceView({ @@ -38,14 +78,19 @@ export default function SequenceView({ index: number; }) { const updateSequence = useUpdateSequence(campaignId, sequence.id); + const createTemplate = useCreateTemplate(); + const confirm = useConfirm(); + const { data: templates } = useTemplates(""); + const [load, setLoad] = React.useState(false); + const [tab, setTab] = React.useState<"edit" | "preview">("edit"); + const [saveTplOpen, setSaveTplOpen] = React.useState(false); + const [tplName, setTplName] = React.useState(""); - // Draft is seeded from the canonical sequence and reset whenever a different - // step is selected (or the underlying record changes). The committed - // baseline lives in `sequence`; `draft` is the local working copy. const [draft, setDraft] = React.useState(() => toDraft(sequence)); React.useEffect(() => { setDraft(toDraft(sequence)); + setTab("edit"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sequence.id]); @@ -54,47 +99,143 @@ export default function SequenceView({ () => JSON.stringify(baseline) !== JSON.stringify(draft), [baseline, draft], ); - const patch = (p: Partial) => setDraft((d) => ({ ...d, ...p })); + const setBody = (htmlText: string) => patch({ body_html: htmlText, body_plain: htmlToPlain(htmlText) }); async function submit() { if (load || !savable) return; setLoad(true); try { - // Diff against the committed baseline so we only PATCH changed - // fields — identical semantics to the previous implementation. const data: Partial = { ...(draft.name !== baseline.name && { name: draft.name }), ...(draft.subject !== baseline.subject && { subject: draft.subject }), ...(draft.body_plain !== baseline.body_plain && { body_plain: draft.body_plain }), ...(draft.body_html !== baseline.body_html && { body_html: draft.body_html }), - ...(draft.body_sync !== baseline.body_sync && { body_sync: draft.body_sync }), - ...(draft.body_code !== baseline.body_code && { body_code: draft.body_code }), }; await toast.promise(updateSequence.mutateAsync(data), { loading: "Saving step…", success: "Step saved.", error: (err: AppError) => buildError(err), }); - // The mutation hook writes the fresh record into the query cache; the - // `sequence` prop will update and the effect above re-seeds the draft. } finally { setLoad(false); } } + function applyTemplate(t: { name: string; subject: string; body_html: string; body_plain: string }) { + const apply = () => { + patch({ + subject: t.subject || draft.subject, + body_html: t.body_html || (t.body_plain ? `

${t.body_plain.replace(/\n/g, "

")}

` : ""), + body_plain: t.body_plain || htmlToPlain(t.body_html), + }); + toast.success(`Applied "${t.name}"`); + }; + const dirty = draft.subject.trim() || htmlToPlain(draft.body_html).trim(); + if (dirty) { + confirm.show(`Replace this step's content with the "${t.name}" template?`, apply); + } else { + apply(); + } + } + + async function saveAsTemplate() { + const name = tplName.trim(); + if (!name) return; + await toast.promise( + createTemplate.mutateAsync({ + name, + subject: draft.subject, + body_html: draft.body_html, + body_plain: draft.body_plain, + }), + { loading: "Saving template…", success: "Saved to template library.", error: (e: AppError) => buildError(e) }, + ); + setSaveTplOpen(false); + setTplName(""); + } + return (
-
+
Step {index + 1}
-

- Edit the email this step sends. -

+

Compose the email this step sends.

-
+
+ {/* Template picker */} + + + } label="Templates" /> + + + {(templates ?? []).length === 0 ? ( +
+ No templates yet. Save one below. +
+ ) : ( + (templates ?? []).map((t) => ( + + )) + )} +
+
+ + {/* Save as template */} + + + + + + +
+ + +
+
+
+ + { + const html = `

${text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\n{2,}/g, "

") + .replace(/\n/g, "
")}

`; + setBody(draft.body_html ? `${draft.body_html}${html}` : html); + }} + /> +
+ ); +} diff --git a/web/src/components/app/campaigns/sequences/WriteWithAI.tsx b/web/src/components/app/campaigns/sequences/WriteWithAI.tsx new file mode 100644 index 00000000..4fe04a23 --- /dev/null +++ b/web/src/components/app/campaigns/sequences/WriteWithAI.tsx @@ -0,0 +1,137 @@ +// "Write with AI" — a compact popover that drafts email copy from a short +// prompt + tone and hands the result back to the composer. Surfaces remaining +// generation credits and turns a 402 (out of credits) into a friendly toast. +// +// The component is deliberately editor-agnostic: it calls `onInsert(text)` with +// the generated draft, so the host (SequenceView) decides whether to drop it +// into the subject, the body editor, etc. + +import React from "react"; +import { SparklesIcon, Loader2Icon, WandSparklesIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import { Label } from "@/components/ui/field"; +import useGenerateWrite from "@/lib/api/hooks/app/generation/useGenerateWrite"; +import { WRITE_TONES } from "@/lib/api/models/app/generation/Write"; +import type { AppError } from "@/lib/api/client/normalizeError"; +import buildError from "@/lib/helper/buildError"; + +export default function WriteWithAI({ onInsert }: { onInsert: (text: string) => void }) { + const generate = useGenerateWrite(); + const [open, setOpen] = React.useState(false); + const [prompt, setPrompt] = React.useState(""); + const [tone, setTone] = React.useState(""); + // Remembered from the last successful generation so we can show a running + // credit balance without a separate fetch. + const [credits, setCredits] = React.useState(null); + + const run = () => { + const text = prompt.trim(); + if (!text || generate.isPending) return; + generate.mutate( + { prompt: text, tone: tone || undefined }, + { + onSuccess: (res) => { + setCredits(res.credits_remaining); + onInsert(res.text); + toast.success( + `Draft inserted · ${res.credits_remaining} credit${res.credits_remaining === 1 ? "" : "s"} left`, + ); + setPrompt(""); + setOpen(false); + }, + onError: (e) => { + const err = e as unknown as AppError; + if (err?.status === 402) { + toast.error( + "You're out of AI writing credits. Upgrade your plan or wait for your credits to refresh.", + ); + return; + } + toast.error(buildError(err)); + }, + }, + ); + }; + + return ( + + + + + +
+
+ +