Files
warmbly/internal/repository/pg_campaign_progress.go
T

388 lines
12 KiB
Go

package repository
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// 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
}
// CampaignProgress represents overall campaign progress
type CampaignProgress struct {
TotalContacts int
TotalSequences int
EmailsSent int
EmailsPending int
EmailsOpened int
EmailsClicked int
EmailsReplied int
EmailsBounced int
}
// ContactSequencePair represents a contact and sequence combination
type ContactSequencePair struct {
ContactID uuid.UUID
SequenceID uuid.UUID
}
type CampaignSequencePair struct {
CampaignID uuid.UUID
SequenceID uuid.UUID
}
// CampaignProgressRepository defines methods for campaign progress tracking
type CampaignProgressRepository interface {
// Record email status
RecordEmailSent(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error
RecordEmailOpened(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) 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
// Query methods
GetCampaignProgress(ctx context.Context, campaignID uuid.UUID) (*CampaignProgress, 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)
// Find next email to send
FindNextContactSequence(ctx context.Context, campaignID uuid.UUID, orderBy, orderDir, orderField string) (*ContactSequencePair, error)
}
type campaignProgressRepository struct {
db *pgxpool.Pool
}
// NewCampaignProgressRepository creates a new campaign progress repository
func NewCampaignProgressRepository(db *pgxpool.Pool) CampaignProgressRepository {
return &campaignProgressRepository{db: db}
}
// RecordEmailSent records that an email was 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)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (campaign_id, contact_id, sequence_id)
DO UPDATE SET sent_at = NOW()
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
return err
}
// RecordEmailOpened records that an email was opened
func (r *campaignProgressRepository) RecordEmailOpened(ctx context.Context, campaignID, contactID, sequenceID uuid.UUID) error {
query := `
UPDATE campaign_contact_progress
SET opened_at = NOW()
WHERE campaign_id = $1
AND contact_id = $2
AND sequence_id = $3
AND opened_at IS NULL
`
_, err := r.db.Exec(ctx, query, campaignID, contactID, sequenceID)
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
}
// 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
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
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,
)
if err == sql.ErrNoRows {
return &CampaignProgress{}, nil
}
return progress, 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
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,
)
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
}
// FindNextContactSequence finds the next contact/sequence pair that needs to be sent
// 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) {
// Build the ORDER BY clause based on ordering settings
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: // created_at
contactOrder = "c.created_at"
}
// Apply direction
dir := "ASC"
if orderDir == "desc" {
dir = "DESC"
}
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
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
-- Skip contacts that bounced in ANY campaign
AND NOT EXISTS (
SELECT 1 FROM campaign_contact_progress ccp2
WHERE ccp2.contact_id = cl.contact_id
AND ccp2.bounced_at IS NOT NULL
)
-- Skip suppressed recipients (bounce, complaint, unsubscribe from deliverability)
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())
)
),
sent_pairs AS (
-- Get all already-sent pairs
SELECT contact_id, sequence_id
FROM campaign_contact_progress
WHERE campaign_id = $1
AND sent_at IS NOT NULL
)
SELECT ap.contact_id, ap.sequence_id
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
ORDER BY ap.pair_order
LIMIT 1
`
pair := &ContactSequencePair{}
err := r.db.QueryRow(ctx, query, campaignID).Scan(&pair.ContactID, &pair.SequenceID)
if err == sql.ErrNoRows {
return nil, nil // No more emails to send
}
return pair, err
}