mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
376 lines
17 KiB
Go
376 lines
17 KiB
Go
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// MiniCategory is the lightweight shape we attach to contact responses
|
|
// so the UI can render category chips without doing a second lookup. It
|
|
// is a denormalised slice of the row in `categories` plus nothing else.
|
|
type MiniCategory struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Title string `json:"title"`
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
type Contact struct {
|
|
ID uuid.UUID `json:"id"`
|
|
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Email string `json:"email"`
|
|
Company string `json:"company"`
|
|
Phone string `json:"phone"`
|
|
|
|
CustomFields map[string]string `json:"custom_fields"`
|
|
|
|
Subscribed bool `json:"subscribed"`
|
|
Campaigns []MiniCampaign `json:"campaigns"`
|
|
Categories []MiniCategory `json:"categories"`
|
|
|
|
// Pre-send verification state (see internal/pkg/emailverify). Populated by
|
|
// the verification scheduler / on-demand verify; the campaign send path uses
|
|
// VerificationStatus == "invalid" to drop addresses before a worker sends.
|
|
// VerificationStatus is one of: valid | risky | invalid | unknown.
|
|
VerificationStatus string `json:"verification_status"`
|
|
VerificationReason string `json:"verification_reason"`
|
|
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"`
|
|
|
|
// CampaignLead is this contact's processing state WITHIN a single campaign.
|
|
// Populated by Search ONLY when the query filters by exactly one campaign
|
|
// (the campaign Leads view); nil otherwise. Lets the Leads list show which
|
|
// leads are queued, in progress, replied, bounced, or unsubscribed.
|
|
CampaignLead *ContactCampaignProgress `json:"campaign_lead,omitempty"`
|
|
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ContactCampaignProgress is a contact's aggregate processing state inside one
|
|
// campaign, derived from campaign_contact_progress (across all of the campaign's
|
|
// steps) plus the contact's subscription flag.
|
|
type ContactCampaignProgress struct {
|
|
// Status is the single derived state shown in the Leads list:
|
|
// pending — a lead, but no email sent yet (queued)
|
|
// active — some but not all steps sent, still progressing through the flow
|
|
// completed — every sequence step was sent, no reply (done, nothing left to send)
|
|
// replied — the contact has replied (terminal/positive)
|
|
// bounced — a send hard-bounced (terminal/negative)
|
|
// failed — the mailbox could not send a step after every retry (terminal/negative)
|
|
// unsubscribed — the contact is unsubscribed/suppressed (terminal)
|
|
Status string `json:"status"`
|
|
Sent int `json:"sent"`
|
|
Opened int `json:"opened"`
|
|
Clicked int `json:"clicked"`
|
|
Replied int `json:"replied"`
|
|
Bounced int `json:"bounced"`
|
|
LastActivityAt *time.Time `json:"last_activity_at,omitempty"`
|
|
// CurrentStep is the label of the step the contact is on now — the latest
|
|
// step actually sent ("Email 2", a custom step name, or an action label).
|
|
// Empty when nothing has been sent yet (status "pending").
|
|
CurrentStep string `json:"current_step,omitempty"`
|
|
// FailureReason is the worker's reason for the last failed send, set only
|
|
// when Status is "failed".
|
|
FailureReason string `json:"failure_reason,omitempty"`
|
|
}
|
|
|
|
// Lead status constants for ContactCampaignProgress.Status.
|
|
const (
|
|
LeadStatusPending = "pending"
|
|
LeadStatusActive = "active"
|
|
LeadStatusCompleted = "completed"
|
|
LeadStatusReplied = "replied"
|
|
LeadStatusBounced = "bounced"
|
|
LeadStatusFailed = "failed"
|
|
LeadStatusUnsubscribed = "unsubscribed"
|
|
)
|
|
|
|
// ValidLeadStatus reports whether s is one of the derived lead-status values.
|
|
// Used to gate the single-campaign Leads-view `lead_status` search filter.
|
|
func ValidLeadStatus(s string) bool {
|
|
switch s {
|
|
case LeadStatusPending, LeadStatusActive, LeadStatusCompleted, LeadStatusReplied, LeadStatusBounced, LeadStatusFailed, LeadStatusUnsubscribed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type ContactsResult struct {
|
|
Data []Contact `json:"data"`
|
|
Pagination Pagination `json:"pagination"`
|
|
// Counts is populated only when the search request asks for it
|
|
// (`?counts=true`, first page). It carries org-wide facet totals for the
|
|
// browse sidebar (all/subscribed/unsubscribed/in-campaign/not-contacted +
|
|
// per category) and is independent of the request's filters, like the
|
|
// campaigns-overview drawer counts.
|
|
Counts *ContactsCounts `json:"counts,omitempty"`
|
|
// LeadCounts carries per-status lead totals for ONE campaign (the campaign
|
|
// Leads view). Populated only on the first page when the search filters by
|
|
// exactly one campaign_id; nil otherwise. Independent of the request's
|
|
// lead_status filter, so the scope chips can show every bucket's total.
|
|
LeadCounts *CampaignLeadCounts `json:"lead_counts,omitempty"`
|
|
}
|
|
|
|
// CampaignLeadCounts are per-status lead totals within a single campaign,
|
|
// derived the same way as ContactCampaignProgress.Status (unsubscribed >
|
|
// bounced > replied > failed > completed > processing > queued). Drives the
|
|
// Leads-view scope chips.
|
|
type CampaignLeadCounts struct {
|
|
Total int `json:"total"`
|
|
Queued int `json:"queued"` // pending: a lead, no email sent yet
|
|
Processing int `json:"processing"` // active: some steps sent, more to send
|
|
Completed int `json:"completed"` // done: every step sent, no reply
|
|
Replied int `json:"replied"`
|
|
Bounced int `json:"bounced"`
|
|
Failed int `json:"failed"` // a step could not be sent after every retry
|
|
Unsubscribed int `json:"unsubscribed"`
|
|
}
|
|
|
|
// ContactsCounts are org-wide contact facet totals for the browse sidebar.
|
|
type ContactsCounts struct {
|
|
Total int `json:"total"`
|
|
Subscribed int `json:"subscribed"`
|
|
Unsubscribed int `json:"unsubscribed"`
|
|
InCampaign int `json:"in_campaign"`
|
|
NotContacted int `json:"not_contacted"`
|
|
Categories []ContactCategoryCount `json:"categories"`
|
|
}
|
|
|
|
// ContactCategoryCount is the number of org contacts carrying one category.
|
|
type ContactCategoryCount struct {
|
|
CategoryID string `json:"category_id"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// ContactEngagement summarises every email touchpoint we have for a
|
|
// single contact. It's denormalised on read so the contact 360 view
|
|
// can render counts and "last X" timestamps in a single round-trip.
|
|
type ContactEngagement struct {
|
|
TotalSent int `json:"total_sent"`
|
|
TotalOpened int `json:"total_opened"`
|
|
TotalClicked int `json:"total_clicked"`
|
|
TotalReplied int `json:"total_replied"`
|
|
TotalBounced int `json:"total_bounced"`
|
|
TotalComplained int `json:"total_complained"`
|
|
|
|
LastSentAt *time.Time `json:"last_sent_at,omitempty"`
|
|
LastOpenedAt *time.Time `json:"last_opened_at,omitempty"`
|
|
LastClickedAt *time.Time `json:"last_clicked_at,omitempty"`
|
|
LastRepliedAt *time.Time `json:"last_replied_at,omitempty"`
|
|
LastBouncedAt *time.Time `json:"last_bounced_at,omitempty"`
|
|
}
|
|
|
|
// ContactSuppression mirrors a row from suppressed_recipients for the
|
|
// contact's email. Null on the wire when the contact is not suppressed.
|
|
type ContactSuppression struct {
|
|
Reason string `json:"reason"`
|
|
Source string `json:"source"` // bounce | complaint | unsubscribe
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// ContactDetail is the hydrated read model returned by GET /contacts/:id.
|
|
// It bundles everything the slide-over needs in one payload so the UI
|
|
// doesn't have to fan out a half-dozen requests on open.
|
|
type ContactDetail struct {
|
|
Contact
|
|
Engagement ContactEngagement `json:"engagement"`
|
|
Suppression *ContactSuppression `json:"suppression,omitempty"`
|
|
}
|
|
|
|
// ContactSentEmail is one row in the "Emails sent to this contact"
|
|
// list. Each row corresponds to a single delivered (or attempted)
|
|
// task. Engagement timestamps come from campaign_contact_progress
|
|
// when present; some legacy rows may have nil progress.
|
|
type ContactSentEmail struct {
|
|
TaskID uuid.UUID `json:"task_id"`
|
|
Status string `json:"status"`
|
|
MessageID string `json:"message_id"`
|
|
Subject string `json:"subject"`
|
|
SentAt time.Time `json:"sent_at"`
|
|
|
|
// Sender mailbox
|
|
EmailAccountID *uuid.UUID `json:"email_account_id,omitempty"`
|
|
EmailAccountEmail *string `json:"email_account_email,omitempty"`
|
|
EmailAccountName *string `json:"email_account_name,omitempty"`
|
|
|
|
// Campaign + sequence context
|
|
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
|
|
CampaignName *string `json:"campaign_name,omitempty"`
|
|
SequenceID *uuid.UUID `json:"step_id,omitempty"`
|
|
SequenceName *string `json:"step_name,omitempty"`
|
|
|
|
// Engagement (from campaign_contact_progress, may be nil).
|
|
OpenedAt *time.Time `json:"opened_at,omitempty"`
|
|
ClickedAt *time.Time `json:"clicked_at,omitempty"`
|
|
RepliedAt *time.Time `json:"replied_at,omitempty"`
|
|
BouncedAt *time.Time `json:"bounced_at,omitempty"`
|
|
}
|
|
|
|
type ContactSentEmailsResult struct {
|
|
Data []ContactSentEmail `json:"data"`
|
|
Pagination Pagination `json:"pagination"`
|
|
}
|
|
|
|
// ContactTimelineEventType is a closed enum so the frontend can pick
|
|
// the right icon / colour without parsing free text.
|
|
type ContactTimelineEventType string
|
|
|
|
const (
|
|
TimelineEmailSent ContactTimelineEventType = "email_sent"
|
|
TimelineEmailOpened ContactTimelineEventType = "email_opened"
|
|
TimelineEmailClicked ContactTimelineEventType = "email_clicked"
|
|
TimelineEmailReplied ContactTimelineEventType = "email_replied"
|
|
TimelineEmailBounced ContactTimelineEventType = "email_bounced"
|
|
TimelineReplyReceived ContactTimelineEventType = "reply_received"
|
|
TimelineDeliverability ContactTimelineEventType = "deliverability"
|
|
TimelineSuppressed ContactTimelineEventType = "suppressed"
|
|
TimelineNote ContactTimelineEventType = "note"
|
|
|
|
// Meetings booked through a connected scheduling provider (Calendly /
|
|
// Cal.com). The event time is when the booking arrived; ScheduledFor holds
|
|
// when the call itself is set for.
|
|
TimelineMeetingBooked ContactTimelineEventType = "meeting_booked"
|
|
TimelineMeetingRescheduled ContactTimelineEventType = "meeting_rescheduled"
|
|
TimelineMeetingCanceled ContactTimelineEventType = "meeting_canceled"
|
|
)
|
|
|
|
// ContactTimelineEvent is one entry in the merged activity feed. The
|
|
// optional fields are tagged with omitempty so the JSON stays compact
|
|
// for event types that don't carry that data.
|
|
type ContactTimelineEvent struct {
|
|
Type ContactTimelineEventType `json:"type"`
|
|
At time.Time `json:"at"`
|
|
|
|
// Mailbox sender (email_sent / opened / clicked / replied / bounced).
|
|
EmailAccountID *uuid.UUID `json:"email_account_id,omitempty"`
|
|
EmailAccountEmail *string `json:"email_account_email,omitempty"`
|
|
EmailAccountName *string `json:"email_account_name,omitempty"`
|
|
|
|
// Campaign / sequence linkage. Optional because notes, suppression,
|
|
// and out-of-campaign reply intents don't always have one.
|
|
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
|
|
CampaignName *string `json:"campaign_name,omitempty"`
|
|
SequenceID *uuid.UUID `json:"step_id,omitempty"`
|
|
SequenceName *string `json:"step_name,omitempty"`
|
|
|
|
// Task linkage for engagement events.
|
|
TaskID *uuid.UUID `json:"task_id,omitempty"`
|
|
Subject *string `json:"subject,omitempty"`
|
|
|
|
// Type-specific.
|
|
Reason *string `json:"reason,omitempty"` // deliverability / suppression / meeting cancellation
|
|
Source *string `json:"source,omitempty"` // suppression: bounce/complaint/unsubscribe; meeting: calendly/cal_com
|
|
Provider *string `json:"provider,omitempty"` // deliverability provider
|
|
Intent *string `json:"intent,omitempty"` // reply_intent classification
|
|
Content *string `json:"content,omitempty"` // note body
|
|
|
|
// Meeting events (meeting_booked / rescheduled / canceled).
|
|
ScheduledFor *time.Time `json:"scheduled_for,omitempty"` // when the call is set for
|
|
JoinURL *string `json:"join_url,omitempty"` // video/conference link
|
|
MeetingState *string `json:"meeting_state,omitempty"` // booked / rescheduled / canceled
|
|
|
|
// Author (notes).
|
|
UserID *uuid.UUID `json:"user_id,omitempty"`
|
|
}
|
|
|
|
type ContactTimelineResult struct {
|
|
Data []ContactTimelineEvent `json:"data"`
|
|
// True if we hit the per-call cap and the caller should paginate
|
|
// via the `before` query param.
|
|
HasMore bool `json:"has_more"`
|
|
}
|
|
|
|
type UpdateContact struct {
|
|
FirstName *string `json:"first_name"`
|
|
LastName *string `json:"last_name"`
|
|
Company *string `json:"company"`
|
|
Phone *string `json:"phone"`
|
|
CustomFields *map[string]string `json:"custom_fields"`
|
|
Subscribed *bool `json:"subscribed"`
|
|
Campaigns []string `json:"campaigns"` // List of campaign IDs to set (nil = leave as-is)
|
|
Categories []string `json:"categories"` // List of category IDs to set (nil = leave as-is)
|
|
AddCategories []string `json:"add_categories"` // Diff-style add (ignored when Categories is set)
|
|
RemoveCategories []string `json:"remove_categories"` // Diff-style remove (ignored when Categories is set)
|
|
}
|
|
|
|
type AddContact struct {
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Email string `json:"email"`
|
|
Company string `json:"company"`
|
|
Phone string `json:"phone"`
|
|
Campaigns []string `json:"campaigns"`
|
|
Categories []string `json:"categories"`
|
|
|
|
CustomFields map[string]string `json:"custom_fields"`
|
|
}
|
|
|
|
type SearchContactsFilterType string
|
|
|
|
const (
|
|
SearchContactsFilterTypeEqual SearchContactsFilterType = "equal"
|
|
SearchContactsFilterTypeStartsWith SearchContactsFilterType = "starts_with"
|
|
SearchContactsFilterTypeEndsWith SearchContactsFilterType = "ends_with"
|
|
SearchContactsFilterTypeContains SearchContactsFilterType = "contains"
|
|
)
|
|
|
|
type SearchContactsFilter struct {
|
|
Name string `json:"name"`
|
|
Value string `json:"value"`
|
|
Type SearchContactsFilterType `json:"type"`
|
|
}
|
|
|
|
type SearchContacts struct {
|
|
Query string `json:"query"` // Text search across core fields
|
|
CustomFieldFilters []SearchContactsFilter `json:"custom_field_filters"` // Custom Field Filters
|
|
CampaignIDs []string `json:"campaign_ids"` // Contacts must be in ALL these campaigns
|
|
LeadStatus string `json:"lead_status"` // Filter by derived lead status; requires exactly one campaign_id
|
|
CategoryIDs []string `json:"category_ids"` // Contacts must have ALL these categories
|
|
MinCampaigns *int `json:"min_campaigns"` // Minimum number of associated campaigns
|
|
MaxCampaigns *int `json:"max_campaigns"` // Maximum number of associated campaigns
|
|
Subscribed *bool `json:"subscribed"` // Filter by subscription status
|
|
CreatedAfter *time.Time `json:"created_after"` // Contacts created after this date
|
|
CreatedBefore *time.Time `json:"created_before"` // Contacts created before this date
|
|
UpdatedAfter *time.Time `json:"updated_after"` // Contacts updated after this date
|
|
UpdatedBefore *time.Time `json:"updated_before"` // Contacts updated before this date
|
|
SortBy string `json:"sort_by"` // e.g., "first_name ASC", "campaign_count DESC"
|
|
Reverse bool `json:"reverse"` // ASC or DESC
|
|
}
|
|
|
|
type BulkEditContactsFieldType string
|
|
|
|
const (
|
|
BulkAddField BulkEditContactsFieldType = "ADD"
|
|
BulkEditField BulkEditContactsFieldType = "EDIT"
|
|
BulkDeleteField BulkEditContactsFieldType = "DELETE"
|
|
BulkRenameField BulkEditContactsFieldType = "RENAME"
|
|
)
|
|
|
|
type BulkEditContactsField struct {
|
|
Type BulkEditContactsFieldType `json:"type"`
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
type BulkEditContactsData struct {
|
|
Contacts []string `json:"contacts"`
|
|
|
|
AddCampaigns []string `json:"add_campaigns"`
|
|
RemoveCampaigns []string `json:"remove_campaigns"`
|
|
AddCategories []string `json:"add_categories,omitempty"`
|
|
RemoveCategories []string `json:"remove_categories,omitempty"`
|
|
Fields []BulkEditContactsField `json:"fields"`
|
|
Subscribe *bool `json:"subscribe"`
|
|
}
|