Files
warmbly/internal/app/contact/handler.go
T
Matthew Meszaros efa914025c feat: stop an active campaign sitting at "Queued / Not started" with nothing sending: a campaign is one self-perpetuating task, so a tick that found nothing due parked its successor at the literal next-due moment (three days out for a "wait 3 days" step) and that parked task was also the next time anything re-read the campaign, so leads imported meanwhile stayed invisible until it fired and the reconciler never noticed because it only re-seeds chains with no pending task; deferral parks are now capped at config.CampaignMaxDeferMinutes via scheduler.DeferSlot at all three enqueue sites (a tick that actually sent still parks at its paced interval, so send spacing is untouched), the reconciler re-checks any active campaign parked beyond CampaignStaleParkHours and pulls its wakeup forward when the real next slot is CampaignReparkMarginMinutes sooner, attaching leads to a running campaign wakes it immediately through one CampaignWaker seam in the contact service that covers add/update/bulk-edit/import/Sheets-sync, even distribution now paces across the whole sender pool via poolRemainingOn instead of the one mailbox the tick picked (a three-mailbox campaign was sending at one mailbox's rate), the flat +/-20 minute jitter that was wider than the interval it perturbed is scaled to half the distance to the slot so it stops landing slots in the past where notBefore collapsed them onto the min-gap, and on the dashboard a full-day window renders "12am-midnight" instead of "12am-12pm", the campaign lead strip uses the server's campaign-wide lead_counts instead of counting the 50 loaded rows, and channel state moves out of a ref into React state so a live campaign's panel stops reading "Disconnected" forever
2026-08-25 07:39:18 -07:00

165 lines
6.0 KiB
Go

package contact
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/utils/paging"
"github.com/warmbly/warmbly/internal/utils/validate"
)
func (s *contactService) Add(ctx context.Context, userID string, orgID uuid.UUID, contacts []models.AddContact) ([]models.Contact, *errx.Error) {
// Enforce contact limit if subscription repos are available.
//
// This path reads the plan directly instead of going through the feature
// gate, so it never saw the self-host short-circuit: every self-hosted org
// was capped at the seeded Free Trial plan's 100 contacts even though
// BILLING_PROVIDER=none unlocks every other limit.
if !config.SelfHosted() && s.subRepo != nil && s.planRepo != nil {
uid, parseErr := uuid.Parse(userID)
if parseErr == nil {
sub, err := s.subRepo.GetByUserID(ctx, uid)
if err == nil && sub != nil {
plan, err := s.planRepo.GetByID(ctx, sub.PlanID)
if err == nil && plan != nil && plan.MaxContacts > 0 {
currentCount, xerr := s.contactRepository.GetContactCount(ctx, userID)
if xerr == nil {
newTotal := currentCount + len(contacts)
if newTotal > int(plan.MaxContacts) {
return nil, errx.New(errx.Forbidden, "contact limit reached for your plan")
}
}
}
}
}
}
created, xerr := s.contactRepository.Add(ctx, userID, orgID, contacts)
if xerr != nil {
return nil, xerr
}
s.publishContactsReload(ctx, userID, "contacts:add")
var attached []string
for i := range contacts {
attached = append(attached, contacts[i].Campaigns...)
}
s.wakeCampaigns(ctx, orgID, attached)
return created, nil
}
func (s *contactService) Search(ctx context.Context, orgID, cursor, category, limit string, filters models.SearchContacts) (*models.ContactsResult, *errx.Error) {
cursorId, err := paging.DecodeCursor(cursor)
if err != nil {
return nil, err
}
categoryId, err := validate.Uuid(category)
if err != nil {
return nil, err
}
limitN, err := validate.Limit(limit)
if err != nil {
return nil, err
}
// The lead_status filter is a single-campaign Leads-view feature: an invalid
// value or the wrong campaign cardinality is a client contract error (400),
// not a silently-ignored no-op.
if filters.LeadStatus != "" {
if !models.ValidLeadStatus(filters.LeadStatus) {
return nil, errx.New(errx.BadRequest, "invalid lead_status")
}
if len(filters.CampaignIDs) != 1 {
return nil, errx.New(errx.BadRequest, "lead_status requires exactly one campaign_id")
}
}
return s.contactRepository.Search(ctx, orgID, categoryId, cursorId, filters, limitN)
}
func (s *contactService) SearchCounts(ctx context.Context, orgID string) (*models.ContactsCounts, *errx.Error) {
return s.contactRepository.SearchCounts(ctx, orgID)
}
func (s *contactService) ListCustomFieldKeys(ctx context.Context, orgID uuid.UUID) ([]string, *errx.Error) {
keys, err := s.contactRepository.DistinctCustomFieldKeys(ctx, orgID)
if err != nil {
return nil, errx.InternalError()
}
return keys, nil
}
func (s *contactService) CampaignLeadCounts(ctx context.Context, orgID, campaignID string) (*models.CampaignLeadCounts, *errx.Error) {
if _, err := validate.Uuid(campaignID); err != nil {
return nil, err
}
return s.contactRepository.CampaignLeadCounts(ctx, orgID, campaignID)
}
func (s *contactService) BulkUpdate(ctx context.Context, userID string, orgID uuid.UUID, data *models.BulkEditContactsData) ([]models.Contact, *errx.Error) {
updated, xerr := s.contactRepository.BulkUpdate(ctx, userID, orgID, data)
if xerr != nil {
return nil, xerr
}
s.publishContactsReload(ctx, userID, "contacts:bulk_update")
s.wakeCampaigns(ctx, orgID, data.AddCampaigns)
return updated, nil
}
func (s *contactService) Update(ctx context.Context, userID, contactID string, orgID uuid.UUID, data *models.UpdateContact) (*models.Contact, *errx.Error) {
updated, xerr := s.contactRepository.Update(ctx, userID, contactID, orgID, data)
if xerr != nil {
return nil, xerr
}
s.publishContactsReload(ctx, userID, "contacts:update:"+contactID)
s.wakeCampaigns(ctx, orgID, data.Campaigns)
return updated, nil
}
func (s *contactService) BulkDelete(ctx context.Context, userID string, orgID uuid.UUID, contactIDs []string) *errx.Error {
if xerr := s.contactRepository.BulkDelete(ctx, userID, orgID, contactIDs); xerr != nil {
return xerr
}
s.publishContactsReload(ctx, userID, "contacts:bulk_delete")
return nil
}
func (s *contactService) Delete(ctx context.Context, userID string, orgID uuid.UUID, contactID string) *errx.Error {
if xerr := s.contactRepository.Delete(ctx, userID, orgID, contactID); xerr != nil {
return xerr
}
s.publishContactsReload(ctx, userID, "contacts:delete:"+contactID)
return nil
}
func (s *contactService) GetDetail(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID) (*models.ContactDetail, *errx.Error) {
return s.contactRepository.GetDetail(ctx, userID, orgID, contactID)
}
func (s *contactService) GetByEmail(ctx context.Context, orgID *uuid.UUID, email string) (*models.Contact, *errx.Error) {
if orgID == nil || strings.TrimSpace(email) == "" {
return nil, nil
}
// The repo already returns (nil, nil) when no contact matches, so an
// unknown sender flows through as a clean "no contact" rather than an error.
return s.contactRepository.GetByEmailAndOrganization(ctx, *orgID, email)
}
func (s *contactService) ListSentEmails(ctx context.Context, userID, contactID uuid.UUID, limit int, beforeSentAt *time.Time, beforeTaskID *uuid.UUID) (*models.ContactSentEmailsResult, *errx.Error) {
return s.contactRepository.ListSentEmails(ctx, userID, contactID, limit, beforeSentAt, beforeTaskID)
}
func (s *contactService) ListTimeline(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID, limit int, before *time.Time) (*models.ContactTimelineResult, *errx.Error) {
return s.contactRepository.ListTimeline(ctx, userID, orgID, contactID, limit, before)
}