mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
1188 lines
47 KiB
Go
1188 lines
47 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// CampaignContactProgress represents the progress of a contact in a campaign
|
|
type CampaignContactProgress struct {
|
|
CampaignID uuid.UUID
|
|
ContactID uuid.UUID
|
|
SequenceID uuid.UUID
|
|
SentAt *time.Time
|
|
OpenedAt *time.Time
|
|
ClickedAt *time.Time
|
|
RepliedAt *time.Time
|
|
BouncedAt *time.Time
|
|
ComplainedAt *time.Time
|
|
// ReplyClass is the layered classifier verdict for the contact's reply
|
|
// (positive | negative | neutral | auto_reply | out_of_office | unsubscribe |
|
|
// unknown; "" when no reply was classified). Read by the reply_* branch
|
|
// conditions. RepliedAt is set ONLY for human replies, so an automated reply
|
|
// can carry a ReplyClass here without ever tripping "replied"/stop_on_reply.
|
|
ReplyClass string
|
|
// AILabel is the case a "switch" sequence step stored for the contact on this
|
|
// step ("" when the step has no labels or the AI could not decide). Read by
|
|
// the ai_label branch conditions.
|
|
AILabel string
|
|
}
|
|
|
|
// CampaignProgress represents overall campaign progress
|
|
type CampaignProgress struct {
|
|
TotalContacts int
|
|
TotalSequences int
|
|
EmailsSent int
|
|
EmailsPending int
|
|
EmailsOpened int
|
|
EmailsClicked int
|
|
EmailsReplied int
|
|
EmailsBounced int
|
|
EmailsComplained int
|
|
}
|
|
|
|
// CampaignRollingRates holds windowed send/bounce/complaint counts for a
|
|
// campaign, used by the deliverability circuit breaker so it reacts to recent
|
|
// behaviour rather than a campaign's lifetime average.
|
|
type CampaignRollingRates struct {
|
|
Sent int
|
|
Bounced int
|
|
Complained int
|
|
}
|
|
|
|
// ContactSequencePair represents a contact and sequence combination
|
|
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 {
|
|
CampaignID uuid.UUID
|
|
SequenceID uuid.UUID
|
|
}
|
|
|
|
// StuckDispatch is a send that was reserved and handed to a worker but whose
|
|
// outcome never came back: no EMAIL_SENT stamped it, no EMAIL_FAILED walked it
|
|
// back. The reclaimer resolves these.
|
|
type StuckDispatch struct {
|
|
CampaignID uuid.UUID
|
|
ContactID uuid.UUID
|
|
SequenceID uuid.UUID
|
|
TaskID *uuid.UUID
|
|
DispatchedAt time.Time
|
|
}
|
|
|
|
// CampaignProgressRepository defines methods for campaign progress tracking
|
|
type CampaignProgressRepository interface {
|
|
// ReserveSend claims (campaign, contact, step) for one send BEFORE the
|
|
// command goes on the bus, which is what lets a later tick tell "dispatched,
|
|
// outcome unknown" apart from "never attempted". It stamps dispatched_at and
|
|
// counts the send against the day's counters in one transaction, and reports
|
|
// false when the step is already in flight or already sent — in which case
|
|
// the caller must NOT dispatch. Resolve every reservation exactly once:
|
|
// RecordEmailSent on a successful hand-off, ReleaseSend when the command
|
|
// provably never left, RecordSendFailure on a worker failure.
|
|
ReserveSend(ctx context.Context, campaignID, contactID, sequenceID, taskID uuid.UUID, newLead bool) (bool, error)
|
|
// ReleaseSend gives a reservation back when the send provably never reached
|
|
// the bus (no worker assigned, worker offline), so the step is retried on the
|
|
// next tick without spending an attempt. Only an unstamped reservation is
|
|
// touched, so it can never undo a real send.
|
|
ReleaseSend(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, newLead bool) error
|
|
// Record email status
|
|
RecordEmailSent(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error
|
|
// StampDispatchedSend is the repair path for a send whose reservation was
|
|
// written but whose sent_at stamp was lost (the control plane died, or its
|
|
// write failed, between the dispatch and the stamp). The worker's own
|
|
// EMAIL_SENT calls it, so a delivered email always ends up with the timing
|
|
// stamp follow-up pacing reads. Returns true when it actually repaired one.
|
|
StampDispatchedSend(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) (bool, error)
|
|
// ListStuckDispatches returns reservations older than olderThan that no
|
|
// worker result ever resolved.
|
|
ListStuckDispatches(ctx context.Context, olderThan time.Duration, limit int) ([]StuckDispatch, error)
|
|
// RecordSendFailure walks back a step the worker could not send: the
|
|
// reservation and sent_at are cleared so routing offers the step again, and
|
|
// the attempt is counted. It returns the attempts so far and whether the
|
|
// lead has now exhausted
|
|
// config.CampaignSendMaxAttempts. rolledBack is false when there was
|
|
// nothing to walk back (a duplicate result, or the send was already retried).
|
|
RecordSendFailure(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, reason string) (attempts int, exhausted bool, rolledBack bool, err error)
|
|
// HasSentSteps reports whether the contact has any other step of the
|
|
// campaign stamped sent, which is what decides if a failed send was the
|
|
// lead's first step (a "new lead" for the daily new-lead counter).
|
|
HasSentSteps(ctx context.Context, campaignID, contactID uuid.UUID) (bool, error)
|
|
RecordEmailOpened(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, machine bool) error
|
|
RecordEmailClicked(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error
|
|
RecordEmailReplied(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error
|
|
RecordEmailBounced(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error
|
|
RecordEmailComplained(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error
|
|
|
|
// RecordReplyClassification stores the layered classifier verdict
|
|
// (class/confidence/source) for the contact's reply on the given step. This
|
|
// is the data the reply_* branch conditions read. It does NOT stamp
|
|
// replied_at — only a HUMAN reply should set replied_at (so automated replies
|
|
// never trip stop_on_reply / the "replied" condition). Callers stamp
|
|
// replied_at separately via RecordEmailReplied for human replies only.
|
|
RecordReplyClassification(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, class, source string, confidence float64) error
|
|
// RecordAILabel stores the case a "switch" sequence step chose for the contact
|
|
// on that step. Upserts (the AI step runs before its progress row is stamped
|
|
// sent). Read by the ai_label branch conditions when routing out of the step.
|
|
RecordAILabel(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, label string) error
|
|
// GetLatestReplyClass returns the most-recent classified reply class for a
|
|
// contact in a campaign ("" when none). Convenience getter for the branch
|
|
// evaluator / callers that need only the class.
|
|
GetLatestReplyClass(ctx context.Context, contactID, campaignID uuid.UUID) (string, error)
|
|
|
|
// GetResolvedAIVariables returns the per-recipient AI variable text already
|
|
// generated for this (campaign, contact, step), keyed by variable id (empty
|
|
// map when the row or column is empty). The send path reads this first so a
|
|
// task redelivery reuses cached copy instead of re-generating and re-charging.
|
|
GetResolvedAIVariables(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) (map[string]string, error)
|
|
// SaveResolvedAIVariable upserts one resolved AI variable (varID -> text) into
|
|
// the ai_variables_resolved jsonb, creating the progress row if it is missing
|
|
// (the AI resolve runs before the row is stamped sent), mirroring RecordAILabel.
|
|
SaveResolvedAIVariable(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, varID, text string) error
|
|
|
|
// ClaimInstantFire atomically claims the one-time right to run the instant
|
|
// action chain for the contact's current step FOR A SINGLE EVENT KIND
|
|
// ("reply" / "open" / "click"). It appends eventKind to the instant_fired
|
|
// array only when that kind is not already present and reports whether THIS
|
|
// call won the claim (true) or that kind had already fired (false). This is
|
|
// the per-(step, event) exactly-once gate behind instant automations: a
|
|
// redelivered event of the same kind (or an auto-reply followed by a human
|
|
// reply on the same step) sees the kind in the array and is a no-op, while a
|
|
// DIFFERENT kind on the same step (a contact who opens AND clicks AND replies)
|
|
// is still free to fire its own chain exactly once. The progress row already
|
|
// exists at call time (RecordReplyClassification / RecordEmailOpened /
|
|
// RecordEmailClicked upsert/update it just before), so a missing row
|
|
// (claimed == false) safely means "nothing to fire".
|
|
ClaimInstantFire(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, eventKind string) (bool, error)
|
|
|
|
// Query methods
|
|
GetCampaignProgress(ctx context.Context, campaignID uuid.UUID) (*CampaignProgress, error)
|
|
GetCampaignRollingRates(ctx context.Context, campaignID uuid.UUID, since time.Time) (*CampaignRollingRates, error)
|
|
GetContactProgress(ctx context.Context, campaignID, contactID uuid.UUID) ([]CampaignContactProgress, error)
|
|
GetContactLastSequenceTime(ctx context.Context, contactID, campaignID uuid.UUID) (*time.Time, error)
|
|
CheckContactHasReplied(ctx context.Context, contactID, campaignID uuid.UUID) (bool, error)
|
|
CountEmailsSentTodayByOrganization(ctx context.Context, organizationID uuid.UUID) (int, error)
|
|
GetLatestCampaignSequenceForContact(ctx context.Context, contactID uuid.UUID) (*CampaignSequencePair, error)
|
|
|
|
// FindNextRoutedPair selects the next (contact, step) to send by following
|
|
// each contact's step rules (the branching tree) rather than a flat position
|
|
// order. prioritizeNewLeads sorts first-step pairs first; excludeNewLeads
|
|
// drops first-step pairs entirely so the new-lead/day cap can be enforced
|
|
// while follow-ups keep flowing. The second return value, when the pair is
|
|
// nil, is the soonest time a waiting contact's condition window elapses — the
|
|
// scheduler should defer and re-check then rather than completing.
|
|
FindNextRoutedPair(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool) (*ContactSequencePair, *time.Time, error)
|
|
}
|
|
|
|
type campaignProgressRepository struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
// NewCampaignProgressRepository creates a new campaign progress repository
|
|
func NewCampaignProgressRepository(db *pgxpool.Pool) CampaignProgressRepository {
|
|
return &campaignProgressRepository{db: db}
|
|
}
|
|
|
|
// ReserveSend claims the step for one send before the command is published.
|
|
// The claim and the day's counters move together because both must survive a
|
|
// crash in the dispatch window: a reservation without its count would let the
|
|
// daily cap over-send, and a count without its reservation would charge for a
|
|
// send nobody ever made.
|
|
//
|
|
// The ON CONFLICT ... WHERE is the whole exactly-once gate: a row already in
|
|
// flight (dispatched_at set) or already sent updates nothing and returns no
|
|
// row, so two ticks that picked the same pair cannot both dispatch. A step
|
|
// walked back after a worker failure has both cleared and is claimable again.
|
|
func (r *campaignProgressRepository) ReserveSend(ctx context.Context, campaignID, contactID, sequenceID, taskID uuid.UUID, newLead bool) (bool, error) {
|
|
tx, err := r.db.Begin(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var claimed bool
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, dispatched_at, dispatch_task_id)
|
|
VALUES ($1, $2, $3, NOW(), $4)
|
|
ON CONFLICT (campaign_id, contact_id, sequence_id)
|
|
DO UPDATE SET dispatched_at = NOW(), dispatch_task_id = $4, failed_at = NULL, failure_reason = ''
|
|
WHERE campaign_contact_progress.sent_at IS NULL
|
|
AND campaign_contact_progress.dispatched_at IS NULL
|
|
RETURNING true
|
|
`, campaignID, contactID, sequenceID, taskID).Scan(&claimed)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
newLeadInc := 0
|
|
if newLead {
|
|
newLeadInc = 1
|
|
}
|
|
if _, err := tx.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); err != nil {
|
|
return false, err
|
|
}
|
|
return true, tx.Commit(ctx)
|
|
}
|
|
|
|
// ReleaseSend undoes a reservation whose command never left, counters included.
|
|
// Guarded on sent_at IS NULL so a reservation that was stamped in the meantime
|
|
// (the worker answered first) is never released.
|
|
func (r *campaignProgressRepository) ReleaseSend(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, newLead bool) error {
|
|
tx, err := r.db.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var released bool
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE campaign_contact_progress
|
|
SET dispatched_at = NULL, dispatch_task_id = NULL
|
|
WHERE campaign_id = $1 AND contact_id = $2 AND sequence_id = $3
|
|
AND sent_at IS NULL AND dispatched_at IS NOT NULL
|
|
RETURNING true
|
|
`, campaignID, contactID, sequenceID).Scan(&released)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
newLeadDec := 0
|
|
if newLead {
|
|
newLeadDec = 1
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE campaign_daily_sends
|
|
SET emails_sent = GREATEST(emails_sent - 1, 0),
|
|
new_leads_started = GREATEST(new_leads_started - $2, 0)
|
|
WHERE campaign_id = $1 AND send_date = CURRENT_DATE
|
|
`, campaignID, newLeadDec); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// RecordEmailSent stamps a step as handed to the worker. A retry after a
|
|
// worker-reported failure clears the failure marker again; send_attempts is
|
|
// kept as history so the retry cap still holds. dispatched_at is backfilled for
|
|
// callers that stamp without reserving first (action nodes, instant chains),
|
|
// so "attempted" is never narrower than "sent".
|
|
func (r *campaignProgressRepository) RecordEmailSent(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error {
|
|
query := `
|
|
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at, dispatched_at)
|
|
VALUES ($1, $2, $3, NOW(), NOW())
|
|
ON CONFLICT (campaign_id, contact_id, sequence_id)
|
|
DO UPDATE SET sent_at = NOW(),
|
|
dispatched_at = COALESCE(campaign_contact_progress.dispatched_at, NOW()),
|
|
failed_at = NULL, failure_reason = ''
|
|
`
|
|
|
|
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
|
|
return err
|
|
}
|
|
|
|
// StampDispatchedSend stamps a reservation the control plane never got to
|
|
// stamp itself. Guarded on dispatched_at so it can only ever complete a send
|
|
// somebody really reserved, and on sent_at so it never moves a stamp that is
|
|
// already there.
|
|
func (r *campaignProgressRepository) StampDispatchedSend(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) (bool, error) {
|
|
tag, err := r.db.Exec(ctx, `
|
|
UPDATE campaign_contact_progress
|
|
SET sent_at = NOW(), failed_at = NULL, failure_reason = ''
|
|
WHERE campaign_id = $1 AND contact_id = $2 AND sequence_id = $3
|
|
AND sent_at IS NULL AND dispatched_at IS NOT NULL
|
|
`, campaignID, contactID, sequenceID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return tag.RowsAffected() > 0, nil
|
|
}
|
|
|
|
// ListStuckDispatches returns reservations no worker result ever resolved,
|
|
// oldest first.
|
|
func (r *campaignProgressRepository) ListStuckDispatches(ctx context.Context, olderThan time.Duration, limit int) ([]StuckDispatch, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
rows, err := r.db.Query(ctx, `
|
|
SELECT campaign_id, contact_id, sequence_id, dispatch_task_id, dispatched_at
|
|
FROM campaign_contact_progress
|
|
WHERE sent_at IS NULL
|
|
AND dispatched_at IS NOT NULL
|
|
AND dispatched_at < NOW() - make_interval(secs => $1)
|
|
ORDER BY dispatched_at ASC
|
|
LIMIT $2
|
|
`, olderThan.Seconds(), limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []StuckDispatch
|
|
for rows.Next() {
|
|
var s StuckDispatch
|
|
if err := rows.Scan(&s.CampaignID, &s.ContactID, &s.SequenceID, &s.TaskID, &s.DispatchedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RecordSendFailure clears the reservation and sent_at on a step and counts the
|
|
// attempt. Only a row that is currently in flight or stamped sent is touched,
|
|
// so a duplicate worker result after the step was already walked back (or
|
|
// re-sent) is a no-op.
|
|
func (r *campaignProgressRepository) RecordSendFailure(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, reason string) (int, bool, bool, error) {
|
|
if len(reason) > 500 {
|
|
reason = reason[:500]
|
|
}
|
|
query := `
|
|
UPDATE campaign_contact_progress
|
|
SET sent_at = NULL,
|
|
dispatched_at = NULL,
|
|
dispatch_task_id = NULL,
|
|
send_attempts = send_attempts + 1,
|
|
failed_at = NOW(),
|
|
failure_reason = $4
|
|
WHERE campaign_id = $1 AND contact_id = $2 AND sequence_id = $3
|
|
AND (sent_at IS NOT NULL OR dispatched_at IS NOT NULL)
|
|
RETURNING send_attempts
|
|
`
|
|
var attempts int
|
|
err := r.db.QueryRow(ctx, query, campaignID, contactID, sequenceID, reason).Scan(&attempts)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return 0, false, false, nil
|
|
}
|
|
return 0, false, false, err
|
|
}
|
|
return attempts, attempts >= config.CampaignSendMaxAttempts, true, nil
|
|
}
|
|
|
|
// HasSentSteps reports whether any step of the campaign is stamped sent for
|
|
// the contact.
|
|
func (r *campaignProgressRepository) HasSentSteps(ctx context.Context, campaignID, contactID uuid.UUID) (bool, error) {
|
|
var has bool
|
|
err := r.db.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM campaign_contact_progress
|
|
WHERE campaign_id = $1 AND contact_id = $2 AND sent_at IS NOT NULL
|
|
)
|
|
`, campaignID, contactID).Scan(&has)
|
|
return has, err
|
|
}
|
|
|
|
// RecordEmailOpened records that an email was opened. machine marks automated
|
|
// fetches (Apple MPP prefetch, UA-less clients): the first open stamps
|
|
// opened_at with the flag, and a later HUMAN open upgrades a machine open to
|
|
// human (keeping the original timestamp). Human opens are never downgraded.
|
|
func (r *campaignProgressRepository) RecordEmailOpened(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, machine bool) error {
|
|
query := `
|
|
UPDATE campaign_contact_progress
|
|
SET opened_at = COALESCE(opened_at, NOW()),
|
|
opened_machine = $4
|
|
WHERE campaign_id = $1
|
|
AND contact_id = $2
|
|
AND sequence_id = $3
|
|
AND (opened_at IS NULL OR (opened_machine = true AND $4 = false))
|
|
`
|
|
|
|
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, machine)
|
|
return err
|
|
}
|
|
|
|
// RecordEmailClicked records that an email link was clicked
|
|
func (r *campaignProgressRepository) RecordEmailClicked(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error {
|
|
query := `
|
|
UPDATE campaign_contact_progress
|
|
SET clicked_at = NOW()
|
|
WHERE campaign_id = $1
|
|
AND contact_id = $2
|
|
AND sequence_id = $3
|
|
AND clicked_at IS NULL
|
|
`
|
|
|
|
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
|
|
return err
|
|
}
|
|
|
|
// RecordEmailReplied records that a contact replied
|
|
func (r *campaignProgressRepository) RecordEmailReplied(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error {
|
|
query := `
|
|
UPDATE campaign_contact_progress
|
|
SET replied_at = NOW()
|
|
WHERE campaign_id = $1
|
|
AND contact_id = $2
|
|
AND sequence_id = $3
|
|
AND replied_at IS NULL
|
|
`
|
|
|
|
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
|
|
return err
|
|
}
|
|
|
|
// RecordEmailBounced records that an email bounced
|
|
func (r *campaignProgressRepository) RecordEmailBounced(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error {
|
|
query := `
|
|
UPDATE campaign_contact_progress
|
|
SET bounced_at = NOW()
|
|
WHERE campaign_id = $1
|
|
AND contact_id = $2
|
|
AND sequence_id = $3
|
|
AND bounced_at IS NULL
|
|
`
|
|
|
|
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
|
|
return err
|
|
}
|
|
|
|
// RecordEmailComplained records that a contact filed a spam complaint
|
|
func (r *campaignProgressRepository) RecordEmailComplained(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error {
|
|
query := `
|
|
UPDATE campaign_contact_progress
|
|
SET complained_at = NOW()
|
|
WHERE campaign_id = $1
|
|
AND contact_id = $2
|
|
AND sequence_id = $3
|
|
AND complained_at IS NULL
|
|
`
|
|
|
|
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
|
|
return err
|
|
}
|
|
|
|
// RecordReplyClassification persists the classifier verdict on the progress row.
|
|
// It upserts so the classification lands even if the reply arrives before the
|
|
// step's progress row is materialized (rare, but threading can race). It never
|
|
// touches replied_at — human-vs-automated gating lives in the caller, which
|
|
// stamps replied_at via RecordEmailReplied only for human replies.
|
|
func (r *campaignProgressRepository) RecordReplyClassification(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, class, source string, confidence float64) error {
|
|
query := `
|
|
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, reply_class, reply_confidence, reply_source)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (campaign_id, contact_id, sequence_id)
|
|
DO UPDATE SET reply_class = EXCLUDED.reply_class,
|
|
reply_confidence = EXCLUDED.reply_confidence,
|
|
reply_source = EXCLUDED.reply_source
|
|
`
|
|
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, class, confidence, source)
|
|
return err
|
|
}
|
|
|
|
// RecordAILabel persists an AI step's chosen label on the progress row.
|
|
func (r *campaignProgressRepository) RecordAILabel(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, label string) error {
|
|
query := `
|
|
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, ai_label)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (campaign_id, contact_id, sequence_id)
|
|
DO UPDATE SET ai_label = EXCLUDED.ai_label
|
|
`
|
|
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, label)
|
|
return err
|
|
}
|
|
|
|
// GetResolvedAIVariables reads the ai_variables_resolved jsonb for the row and
|
|
// decodes it into a var-id -> text map. A missing row or empty column yields an
|
|
// empty (non-nil) map, never an error.
|
|
func (r *campaignProgressRepository) GetResolvedAIVariables(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) (map[string]string, error) {
|
|
query := `
|
|
SELECT COALESCE(ai_variables_resolved, '{}'::jsonb)
|
|
FROM campaign_contact_progress
|
|
WHERE campaign_id = $1 AND contact_id = $2 AND sequence_id = $3
|
|
`
|
|
var raw []byte
|
|
err := r.db.QueryRow(ctx, query, campaignID, contactID, sequenceID).Scan(&raw)
|
|
if err == sql.ErrNoRows {
|
|
return map[string]string{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]string{}
|
|
if len(raw) > 0 {
|
|
if uerr := json.Unmarshal(raw, &out); uerr != nil {
|
|
return map[string]string{}, nil
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SaveResolvedAIVariable upserts one key into ai_variables_resolved. It inserts
|
|
// the progress row (jsonb built from the single key) when absent — the AI
|
|
// resolve runs before the row is stamped sent — and otherwise merges the key in
|
|
// with jsonb_set, mirroring RecordAILabel's missing-row handling.
|
|
func (r *campaignProgressRepository) SaveResolvedAIVariable(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, varID, text string) error {
|
|
query := `
|
|
INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, ai_variables_resolved)
|
|
VALUES ($1, $2, $3, jsonb_build_object($4::text, $5::text))
|
|
ON CONFLICT (campaign_id, contact_id, sequence_id)
|
|
DO UPDATE SET ai_variables_resolved =
|
|
COALESCE(campaign_contact_progress.ai_variables_resolved, '{}'::jsonb)
|
|
|| jsonb_build_object($4::text, $5::text)
|
|
`
|
|
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, varID, text)
|
|
return err
|
|
}
|
|
|
|
// GetLatestReplyClass returns the most-recent non-empty reply_class for a
|
|
// contact in a campaign, or "" when none has been classified.
|
|
func (r *campaignProgressRepository) GetLatestReplyClass(ctx context.Context, contactID, campaignID uuid.UUID) (string, error) {
|
|
query := `
|
|
SELECT reply_class
|
|
FROM campaign_contact_progress
|
|
WHERE contact_id = $1 AND campaign_id = $2 AND reply_class <> ''
|
|
ORDER BY COALESCE(sent_at, '-infinity'::timestamptz) DESC
|
|
LIMIT 1
|
|
`
|
|
var class string
|
|
err := r.db.QueryRow(ctx, query, contactID, campaignID).Scan(&class)
|
|
if err == sql.ErrNoRows {
|
|
return "", nil
|
|
}
|
|
return class, err
|
|
}
|
|
|
|
// ClaimInstantFire appends eventKind to instant_fired exactly once per (campaign,
|
|
// contact, step, eventKind). The conditional NOT ($4 = ANY(instant_fired)) makes
|
|
// the claim atomic under concurrent events of the same kind: at most one UPDATE
|
|
// affects a row for a given kind, so RowsAffected() == 1 identifies the single
|
|
// caller that may run that kind's chain. Different kinds on the same step append
|
|
// independently, so an open, a click, and a reply on one step each fire once.
|
|
func (r *campaignProgressRepository) ClaimInstantFire(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID, eventKind string) (bool, error) {
|
|
query := `
|
|
UPDATE campaign_contact_progress
|
|
SET instant_fired = array_append(instant_fired, $4)
|
|
WHERE campaign_id = $1
|
|
AND contact_id = $2
|
|
AND sequence_id = $3
|
|
AND NOT ($4 = ANY(instant_fired))
|
|
`
|
|
tag, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID, eventKind)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return tag.RowsAffected() == 1, nil
|
|
}
|
|
|
|
// GetCampaignProgress retrieves overall campaign progress statistics
|
|
func (r *campaignProgressRepository) GetCampaignProgress(ctx context.Context, campaignID uuid.UUID) (*CampaignProgress, error) {
|
|
query := `
|
|
WITH campaign_stats AS (
|
|
SELECT
|
|
COUNT(DISTINCT cl.contact_id) as total_contacts,
|
|
COUNT(DISTINCT s.id) as total_sequences,
|
|
COUNT(CASE WHEN ccp.sent_at IS NOT NULL THEN 1 END) as emails_sent,
|
|
COUNT(CASE WHEN ccp.opened_at IS NOT NULL THEN 1 END) as emails_opened,
|
|
COUNT(CASE WHEN ccp.clicked_at IS NOT NULL THEN 1 END) as emails_clicked,
|
|
COUNT(CASE WHEN ccp.replied_at IS NOT NULL THEN 1 END) as emails_replied,
|
|
COUNT(CASE WHEN ccp.bounced_at IS NOT NULL THEN 1 END) as emails_bounced,
|
|
COUNT(CASE WHEN ccp.complained_at IS NOT NULL THEN 1 END) as emails_complained
|
|
FROM campaigns c
|
|
LEFT JOIN campaign_leads cl ON c.id = cl.campaign_id
|
|
LEFT JOIN sequences s ON c.id = s.campaign_id
|
|
LEFT JOIN campaign_contact_progress ccp ON c.id = ccp.campaign_id
|
|
WHERE c.id = $1
|
|
GROUP BY c.id
|
|
)
|
|
SELECT
|
|
total_contacts,
|
|
total_sequences,
|
|
emails_sent,
|
|
(total_contacts * total_sequences) - emails_sent as emails_pending,
|
|
emails_opened,
|
|
emails_clicked,
|
|
emails_replied,
|
|
emails_bounced,
|
|
emails_complained
|
|
FROM campaign_stats
|
|
`
|
|
|
|
progress := &CampaignProgress{}
|
|
err := r.db.QueryRow(ctx, query, campaignID).Scan(
|
|
&progress.TotalContacts,
|
|
&progress.TotalSequences,
|
|
&progress.EmailsSent,
|
|
&progress.EmailsPending,
|
|
&progress.EmailsOpened,
|
|
&progress.EmailsClicked,
|
|
&progress.EmailsReplied,
|
|
&progress.EmailsBounced,
|
|
&progress.EmailsComplained,
|
|
)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return &CampaignProgress{}, nil
|
|
}
|
|
|
|
return progress, err
|
|
}
|
|
|
|
// GetCampaignRollingRates returns send/bounce/complaint counts for a campaign
|
|
// within the window [since, now], computed from the per-contact progress
|
|
// timestamps so the breaker can react to recent behaviour.
|
|
func (r *campaignProgressRepository) GetCampaignRollingRates(ctx context.Context, campaignID uuid.UUID, since time.Time) (*CampaignRollingRates, error) {
|
|
query := `
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE sent_at IS NOT NULL AND sent_at >= $2) AS sent,
|
|
COUNT(*) FILTER (WHERE bounced_at IS NOT NULL AND bounced_at >= $2) AS bounced,
|
|
COUNT(*) FILTER (WHERE complained_at IS NOT NULL AND complained_at >= $2) AS complained
|
|
FROM campaign_contact_progress
|
|
WHERE campaign_id = $1
|
|
`
|
|
out := &CampaignRollingRates{}
|
|
err := r.db.QueryRow(ctx, query, campaignID, since).Scan(&out.Sent, &out.Bounced, &out.Complained)
|
|
if err == sql.ErrNoRows {
|
|
return &CampaignRollingRates{}, nil
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// GetContactProgress retrieves progress for a specific contact in a campaign
|
|
func (r *campaignProgressRepository) GetContactProgress(ctx context.Context, campaignID, contactID uuid.UUID) ([]CampaignContactProgress, error) {
|
|
query := `
|
|
SELECT campaign_id, contact_id, sequence_id, sent_at, opened_at, clicked_at, replied_at, bounced_at, complained_at, COALESCE(reply_class, ''), COALESCE(ai_label, '')
|
|
FROM campaign_contact_progress
|
|
WHERE campaign_id = $1 AND contact_id = $2
|
|
ORDER BY sent_at ASC
|
|
`
|
|
|
|
rows, err := r.db.Query(ctx, query, campaignID, contactID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var progressList []CampaignContactProgress
|
|
for rows.Next() {
|
|
progress := CampaignContactProgress{}
|
|
err := rows.Scan(
|
|
&progress.CampaignID,
|
|
&progress.ContactID,
|
|
&progress.SequenceID,
|
|
&progress.SentAt,
|
|
&progress.OpenedAt,
|
|
&progress.ClickedAt,
|
|
&progress.RepliedAt,
|
|
&progress.BouncedAt,
|
|
&progress.ComplainedAt,
|
|
&progress.ReplyClass,
|
|
&progress.AILabel,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
progressList = append(progressList, progress)
|
|
}
|
|
|
|
return progressList, rows.Err()
|
|
}
|
|
|
|
// GetContactLastSequenceTime retrieves the last email sent time for a contact
|
|
func (r *campaignProgressRepository) GetContactLastSequenceTime(ctx context.Context, contactID, campaignID uuid.UUID) (*time.Time, error) {
|
|
query := `
|
|
SELECT MAX(sent_at)
|
|
FROM campaign_contact_progress
|
|
WHERE contact_id = $1 AND campaign_id = $2
|
|
`
|
|
|
|
var lastTime *time.Time
|
|
err := r.db.QueryRow(ctx, query, contactID, campaignID).Scan(&lastTime)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
|
|
return lastTime, err
|
|
}
|
|
|
|
// CheckContactHasReplied checks if a contact has replied to any email in the campaign
|
|
func (r *campaignProgressRepository) CheckContactHasReplied(ctx context.Context, contactID, campaignID uuid.UUID) (bool, error) {
|
|
query := `
|
|
SELECT EXISTS(
|
|
SELECT 1
|
|
FROM campaign_contact_progress
|
|
WHERE contact_id = $1
|
|
AND campaign_id = $2
|
|
AND replied_at IS NOT NULL
|
|
)
|
|
`
|
|
|
|
var hasReplied bool
|
|
err := r.db.QueryRow(ctx, query, contactID, campaignID).Scan(&hasReplied)
|
|
return hasReplied, err
|
|
}
|
|
|
|
// CountEmailsSentTodayByOrganization returns how many campaign emails were sent today by an organization.
|
|
func (r *campaignProgressRepository) CountEmailsSentTodayByOrganization(ctx context.Context, organizationID uuid.UUID) (int, error) {
|
|
query := `
|
|
SELECT COUNT(*)
|
|
FROM campaign_contact_progress ccp
|
|
JOIN campaigns c ON c.id = ccp.campaign_id
|
|
WHERE c.organization_id = $1
|
|
AND ccp.sent_at IS NOT NULL
|
|
AND DATE(ccp.sent_at) = CURRENT_DATE
|
|
`
|
|
|
|
var count int
|
|
err := r.db.QueryRow(ctx, query, organizationID).Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
func (r *campaignProgressRepository) GetLatestCampaignSequenceForContact(ctx context.Context, contactID uuid.UUID) (*CampaignSequencePair, error) {
|
|
query := `
|
|
SELECT campaign_id, sequence_id
|
|
FROM campaign_contact_progress
|
|
WHERE contact_id = $1
|
|
AND sent_at IS NOT NULL
|
|
ORDER BY sent_at DESC
|
|
LIMIT 1
|
|
`
|
|
out := &CampaignSequencePair{}
|
|
if err := r.db.QueryRow(ctx, query, contactID).Scan(&out.CampaignID, &out.SequenceID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// FindNextRoutedPair selects the next (contact, step) to send by FOLLOWING THE
|
|
// FLOW graph. 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).
|
|
//
|
|
// There is no implicit advance by position: a step with no outgoing connection
|
|
// ends the contact's flow. The campaign wizard connects the steps it creates
|
|
// in order, and the canvas connects steps as they are dragged.
|
|
//
|
|
// 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 ATTEMPTED (a loop) stops the
|
|
// contact. Attempted means sent_at OR dispatched_at: a step reserved before its
|
|
// command went on the bus counts, even if the sent_at stamp was lost, which is
|
|
// what stops a crash or a failed progress write in that window from handing the
|
|
// same email to a worker twice.
|
|
// A contact whose step failed in the worker more than CampaignSendMaxAttempts
|
|
// times is dropped, the same way a bounced or suppressed contact is.
|
|
//
|
|
// Conditions are evaluated SEND-RELATIVE with a three-valued result: a contact
|
|
// whose next step isn't decidable yet (an engagement window still open) is not
|
|
// returned.
|
|
//
|
|
// Only a pair that is DUE is returned: a new lead is due now; a routed step is
|
|
// due wait_after days after the contact's last step (plus a wait node's
|
|
// minutes). The first due contact in list order wins. Contacts whose next step
|
|
// is due later never block the ones behind them: without this, the one lead
|
|
// routed to a "wait 3 days" follow-up parks the whole campaign for 3 days while
|
|
// every other lead's first email sits queued. When nothing is due, the second
|
|
// value is the soonest moment something will be (a step becoming due or a
|
|
// condition window closing) so the scheduler defers exactly until then.
|
|
// Returns (nil, nil, nil) when the campaign is genuinely complete.
|
|
func (r *campaignProgressRepository) FindNextRoutedPair(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string, prioritizeNewLeads, excludeNewLeads bool) (*ContactSequencePair, *time.Time, error) {
|
|
// 1. Load steps (position + branch tree + wait) once, ordered by position.
|
|
type stepInfo struct {
|
|
id uuid.UUID
|
|
bc models.BranchConditions
|
|
waitAfter int
|
|
waitMinutes int // a "wait" node's own delay, gating the step after it
|
|
}
|
|
srows, err := r.db.Query(ctx, `SELECT id, conditions, wait_after, kind, action FROM sequences WHERE campaign_id = $1 ORDER BY position ASC, created_at ASC`, campaignID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var steps []stepInfo
|
|
idxByID := map[uuid.UUID]int{}
|
|
for srows.Next() {
|
|
var si stepInfo
|
|
var raw, action []byte
|
|
var kind string
|
|
if serr := srows.Scan(&si.id, &raw, &si.waitAfter, &kind, &action); serr != nil {
|
|
srows.Close()
|
|
return nil, nil, serr
|
|
}
|
|
if len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &si.bc)
|
|
}
|
|
if kind != "email" && len(action) > 0 {
|
|
var cfg models.ActionConfig
|
|
if json.Unmarshal(action, &cfg) == nil && cfg.Type == "wait" && cfg.WaitMinutes != nil && *cfg.WaitMinutes > 0 {
|
|
si.waitMinutes = *cfg.WaitMinutes
|
|
}
|
|
}
|
|
idxByID[si.id] = len(steps)
|
|
steps = append(steps, si)
|
|
}
|
|
srows.Close()
|
|
if serr := srows.Err(); serr != nil {
|
|
return nil, nil, serr
|
|
}
|
|
if len(steps) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
entry := steps[0].id
|
|
now := time.Now()
|
|
|
|
// Route-aware stop_on_reply. Rather than blanket-excluding every contact who
|
|
// has replied (which also kills the reply branch's OWN follow-up steps), a
|
|
// replied contact keeps moving ONLY while their next routed step is part of the
|
|
// REPLY FLOW: the subgraph downstream of a reply branch that is NOT also part
|
|
// of the cold sequence. The normal / fall-through cold sequence stops; the
|
|
// reply branch's path (its actions AND any follow-up emails) runs to
|
|
// completion. Compute the reply-flow step set once and load the flag.
|
|
var stopOnReply bool
|
|
if serr := r.db.QueryRow(ctx, `SELECT stop_on_reply FROM campaigns WHERE id = $1`, campaignID).Scan(&stopOnReply); serr != nil {
|
|
return nil, nil, serr
|
|
}
|
|
replyFlowSteps := map[uuid.UUID]bool{}
|
|
if stopOnReply {
|
|
// bfs walks a directed reachability closure from `seeds`, following the
|
|
// targets selected by `follow`, and records every visited step into `into`.
|
|
bfs := func(seeds []uuid.UUID, into map[uuid.UUID]bool, follow func(b *models.Branch) bool) {
|
|
queue := append([]uuid.UUID(nil), seeds...)
|
|
for _, s := range seeds {
|
|
into[s] = true
|
|
}
|
|
for len(queue) > 0 {
|
|
cur := queue[0]
|
|
queue = queue[1:]
|
|
idx, ok := idxByID[cur]
|
|
if !ok {
|
|
continue
|
|
}
|
|
for j := range steps[idx].bc.Branches {
|
|
b := &steps[idx].bc.Branches[j]
|
|
if b.TargetSequenceID == nil || into[*b.TargetSequenceID] || !follow(b) {
|
|
continue
|
|
}
|
|
into[*b.TargetSequenceID] = true
|
|
queue = append(queue, *b.TargetSequenceID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Cold trunk: every step a NON-replier reaches from the entry, i.e.
|
|
// following every branch EXCEPT the ones that route on a positive reply.
|
|
// A step that also sits here (a reply branch merged back into the cold
|
|
// sequence) is NOT reply flow — a replier must stop when they reach it.
|
|
coldReachable := map[uuid.UUID]bool{}
|
|
bfs([]uuid.UUID{entry}, coldReachable, func(b *models.Branch) bool {
|
|
return !branchHasPositiveReplyCondition(b)
|
|
})
|
|
|
|
// 2. Reply flow: everything reachable from a positive-reply branch target
|
|
// (following ALL onward branches, so internal reply-flow branching is
|
|
// kept), MINUS the cold trunk.
|
|
var seeds []uuid.UUID
|
|
for i := range steps {
|
|
for j := range steps[i].bc.Branches {
|
|
b := &steps[i].bc.Branches[j]
|
|
if b.TargetSequenceID != nil && branchHasPositiveReplyCondition(b) && !coldReachable[*b.TargetSequenceID] {
|
|
seeds = append(seeds, *b.TargetSequenceID)
|
|
}
|
|
}
|
|
}
|
|
bfs(seeds, replyFlowSteps, func(b *models.Branch) bool {
|
|
return b.TargetSequenceID != nil && !coldReachable[*b.TargetSequenceID]
|
|
})
|
|
}
|
|
|
|
// routeResult is the outcome of routing a contact out of their current step:
|
|
// send `target`, fully `stop`, or `wait` until a condition window elapses.
|
|
type routeResult struct {
|
|
target *uuid.UUID
|
|
stop bool
|
|
wait *time.Time
|
|
}
|
|
// routeNext follows the first DECIDABLE branch out of fromID. A branch whose
|
|
// window is still open leaves the contact waiting (so "if opened within 3d"
|
|
// gets its 3 days instead of being judged the instant the step is sent).
|
|
routeNext := func(fromID uuid.UUID, prog *CampaignContactProgress, sentAt time.Time) routeResult {
|
|
idx, ok := idxByID[fromID]
|
|
if !ok {
|
|
return routeResult{stop: true}
|
|
}
|
|
bc := steps[idx].bc
|
|
// Routing is purely the connections the user drew. A step with no
|
|
// outgoing connection, or whose connections don't match, ends the
|
|
// contact (STOP). There is NO implicit "advance to the next step by
|
|
// position" — steps are only linked when explicitly connected, and an
|
|
// unconditional connection (a branch with no conditions) is the "just go
|
|
// there after the wait" default.
|
|
if len(bc.Branches) == 0 {
|
|
return routeResult{stop: true}
|
|
}
|
|
for i := range bc.Branches {
|
|
b := &bc.Branches[i]
|
|
st, recheck := evaluateBranchState(b, prog, sentAt, now)
|
|
if st == BranchNoMatch {
|
|
continue
|
|
}
|
|
if st == BranchUndecided {
|
|
rc := recheck
|
|
return routeResult{wait: &rc}
|
|
}
|
|
// Matched: a nil / deleted target ends the contact (STOP).
|
|
if b.TargetSequenceID == nil {
|
|
return routeResult{stop: true}
|
|
}
|
|
if _, live := idxByID[*b.TargetSequenceID]; !live {
|
|
return routeResult{stop: true}
|
|
}
|
|
t := *b.TargetSequenceID
|
|
return routeResult{target: &t}
|
|
}
|
|
// Nothing matched -> the flow ends with STOP.
|
|
return routeResult{stop: true}
|
|
}
|
|
|
|
// routeReplyOnly is the routing used for a contact who HAS REPLIED but is still
|
|
// on the cold trunk (stop_on_reply on). It considers ONLY positive-reply
|
|
// branches that lead into the reply flow, giving them priority regardless of
|
|
// declared order, and never waits on a cold window. The first such branch that
|
|
// matches right now routes the contact into the reply flow; anything else means
|
|
// "no reply handling here for this reply" -> STOP the cold sequence. This is
|
|
// what lets a non-instant reply branch fire even when an engagement branch is
|
|
// declared ahead of it, and stops a replier instead of deferring on a cold
|
|
// "did not open within N days" window forever.
|
|
routeReplyOnly := func(fromID uuid.UUID, prog *CampaignContactProgress, sentAt time.Time) routeResult {
|
|
idx, ok := idxByID[fromID]
|
|
if !ok {
|
|
return routeResult{stop: true}
|
|
}
|
|
for i := range steps[idx].bc.Branches {
|
|
b := &steps[idx].bc.Branches[i]
|
|
if b.TargetSequenceID == nil || !branchHasPositiveReplyCondition(b) || !replyFlowSteps[*b.TargetSequenceID] {
|
|
continue
|
|
}
|
|
if st, _ := evaluateBranchState(b, prog, sentAt, now); st != BranchMatch {
|
|
continue
|
|
}
|
|
if _, live := idxByID[*b.TargetSequenceID]; !live {
|
|
continue
|
|
}
|
|
t := *b.TargetSequenceID
|
|
return routeResult{target: &t}
|
|
}
|
|
return routeResult{stop: true}
|
|
}
|
|
|
|
// 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(lp.reply_class, ''), COALESCE(lp.ai_label, ''),
|
|
COALESCE(ss.ids, '{}') AS sent_ids,
|
|
EXISTS (
|
|
SELECT 1 FROM campaign_contact_progress rp
|
|
WHERE rp.campaign_id = $1 AND rp.contact_id = cl.contact_id AND rp.replied_at IS NOT NULL
|
|
) AS has_replied
|
|
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, reply_class, ai_label
|
|
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 OR p2.dispatched_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 campaign_contact_progress f
|
|
WHERE f.campaign_id = $1 AND f.contact_id = cl.contact_id
|
|
AND f.sent_at IS NULL AND f.failed_at IS NOT NULL
|
|
AND f.send_attempts >= $2
|
|
)
|
|
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, config.CampaignSendMaxAttempts)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer rows.Close()
|
|
// nextDue is the soonest moment anything becomes sendable: a step whose
|
|
// wait elapses or a condition window that closes. Only reported when no
|
|
// contact is due right now.
|
|
var nextDue *time.Time
|
|
noteDue := func(at time.Time) {
|
|
if nextDue == nil || at.Before(*nextDue) {
|
|
nextDue = &at
|
|
}
|
|
}
|
|
// A task fires at (or a breath before) the slot it was scheduled for, so
|
|
// "due" tolerates the same grace the scheduler's hard-floor check does.
|
|
dueBy := now.Add(config.CampaignNotDueGraceSeconds * time.Second)
|
|
for rows.Next() {
|
|
var contactID uuid.UUID
|
|
var lastSeq *uuid.UUID
|
|
var sentAt, openedAt, clickedAt, repliedAt *time.Time
|
|
var replyClass, aiLabel string
|
|
var sentIDs []uuid.UUID
|
|
var hasReplied bool
|
|
if serr := rows.Scan(&contactID, &lastSeq, &sentAt, &openedAt, &clickedAt, &repliedAt, &replyClass, &aiLabel, &sentIDs, &hasReplied); serr != nil {
|
|
return nil, nil, serr
|
|
}
|
|
|
|
isNew := lastSeq == nil
|
|
var res routeResult
|
|
if isNew {
|
|
if excludeNewLeads {
|
|
continue
|
|
}
|
|
e := entry
|
|
res = routeResult{target: &e}
|
|
} else {
|
|
prog := &CampaignContactProgress{
|
|
CampaignID: campaignID, ContactID: contactID, SequenceID: *lastSeq,
|
|
SentAt: sentAt, OpenedAt: openedAt, ClickedAt: clickedAt, RepliedAt: repliedAt,
|
|
ReplyClass: replyClass, AILabel: aiLabel,
|
|
}
|
|
sa := time.Time{}
|
|
if sentAt != nil {
|
|
sa = *sentAt
|
|
}
|
|
res = routeNext(*lastSeq, prog, sa)
|
|
|
|
// Route-aware stop_on_reply for a contact who has replied:
|
|
// - still on the cold trunk (lastSeq not in the reply flow): they may
|
|
// only ENTER the reply flow via a positive-reply branch. Any cold
|
|
// route, an undecided cold window, or a stop ends them here — no cold
|
|
// sends and no deferring on a cold window.
|
|
// - already inside the reply flow: keep the reply flow's own routing
|
|
// (its waits are legitimate), but if it would route back out into the
|
|
// cold sequence, stop instead.
|
|
if stopOnReply && hasReplied {
|
|
if !replyFlowSteps[*lastSeq] {
|
|
res = routeReplyOnly(*lastSeq, prog, sa)
|
|
} else if res.target != nil && !replyFlowSteps[*res.target] {
|
|
res = routeResult{stop: true}
|
|
}
|
|
}
|
|
}
|
|
|
|
if res.wait != nil {
|
|
// Not decidable yet — remember the soonest window so the scheduler
|
|
// can re-check exactly then instead of guessing or completing.
|
|
noteDue(*res.wait)
|
|
continue
|
|
}
|
|
if res.stop || res.target == nil {
|
|
continue // reached the end / a STOP (incl. a replied contact stopped above)
|
|
}
|
|
// Loop guard: never re-send a step the contact already received.
|
|
already := false
|
|
for _, sid := range sentIDs {
|
|
if sid == *res.target {
|
|
already = true
|
|
break
|
|
}
|
|
}
|
|
if already {
|
|
continue
|
|
}
|
|
// When is this step due? Skip it (but remember when) if not yet: the
|
|
// contacts behind this one may be sendable right now.
|
|
if !isNew && sentAt != nil {
|
|
due := sentAt.Add(24 * time.Hour * time.Duration(steps[idxByID[*res.target]].waitAfter))
|
|
if last, ok := idxByID[*lastSeq]; ok && steps[last].waitMinutes > 0 {
|
|
due = due.Add(time.Duration(steps[last].waitMinutes) * time.Minute)
|
|
}
|
|
if due.After(dueBy) {
|
|
noteDue(due)
|
|
continue
|
|
}
|
|
}
|
|
return &ContactSequencePair{ContactID: contactID, SequenceID: *res.target, IsNewLead: isNew}, nil, nil
|
|
}
|
|
if rerr := rows.Err(); rerr != nil {
|
|
return nil, nil, rerr
|
|
}
|
|
// Nobody sendable now. Hand back the soonest moment somebody will be so the
|
|
// scheduler defers until then rather than completing.
|
|
return nil, nextDue, nil
|
|
}
|
|
|
|
// branchHasPositiveReplyCondition reports whether a branch is a "reply branch":
|
|
// one that routes on a POSITIVE reply signal (a human reply, or a classified
|
|
// reply intent). Its target seeds the reply flow used by route-aware
|
|
// stop_on_reply. not_replied is deliberately excluded — it is the "did NOT
|
|
// reply" continuation of the normal cold sequence, not the reply flow.
|
|
func branchHasPositiveReplyCondition(b *models.Branch) bool {
|
|
for i := range b.Conditions {
|
|
switch b.Conditions[i].Field {
|
|
case "replied", "reply_positive", "reply_negative", "reply_neutral", "reply_automated":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|