mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-21 00:01:28 +00:00
1999 lines
60 KiB
Go
1999 lines
60 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/email"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/infrastructure/db"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/encrypt"
|
|
"github.com/warmbly/warmbly/internal/utils"
|
|
)
|
|
|
|
type ContactRepository interface {
|
|
Add(ctx context.Context, userID string, contacts []models.AddContact) ([]models.Contact, *errx.Error)
|
|
GetByID(ctx context.Context, contactID uuid.UUID) (*models.Contact, *errx.Error)
|
|
GetByEmailAndOrganization(ctx context.Context, organizationID uuid.UUID, email string) (*models.Contact, *errx.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)
|
|
BulkUpdate(ctx context.Context, userID string, data *models.BulkEditContactsData) ([]models.Contact, *errx.Error)
|
|
Update(ctx context.Context, userID, contactID string, data *models.UpdateContact) (*models.Contact, *errx.Error)
|
|
BulkDelete(ctx context.Context, userID string, contactIDs []string) *errx.Error
|
|
Delete(ctx context.Context, userID string, contactID string) *errx.Error
|
|
GetContactCount(ctx context.Context, userID string) (int, *errx.Error)
|
|
|
|
// 360 view read paths. orgID is optional — when nil, the suppression
|
|
// + deliverability + reply joins are skipped (they're org-scoped).
|
|
GetDetail(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID) (*models.ContactDetail, *errx.Error)
|
|
ListSentEmails(ctx context.Context, userID, contactID uuid.UUID, limit int, beforeSentAt *time.Time, beforeTaskID *uuid.UUID) (*models.ContactSentEmailsResult, *errx.Error)
|
|
ListTimeline(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID, limit int, before *time.Time) (*models.ContactTimelineResult, *errx.Error)
|
|
}
|
|
|
|
type contactRepository struct {
|
|
DB *db.DB
|
|
Encrypt *encrypt.Encrypter
|
|
}
|
|
|
|
func NewContactRepostory(db *db.DB) ContactRepository {
|
|
return &contactRepository{
|
|
DB: db,
|
|
}
|
|
}
|
|
|
|
// parseCategoryIDs accepts string IDs from a JSON body and returns a
|
|
// deduped slice of uuid.UUIDs. Empty strings are skipped silently —
|
|
// they're a normal artifact of clients sending [""] to "clear" a list.
|
|
// A malformed (non-UUID) entry is a client bug worth surfacing as 400.
|
|
func parseCategoryIDs(raw []string) ([]uuid.UUID, *errx.Error) {
|
|
if len(raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
seen := make(map[uuid.UUID]struct{}, len(raw))
|
|
out := make([]uuid.UUID, 0, len(raw))
|
|
for _, s := range raw {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
id, err := uuid.Parse(s)
|
|
if err != nil {
|
|
return nil, errx.ErrUuid
|
|
}
|
|
if _, dup := seen[id]; dup {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *contactRepository) Add(ctx context.Context, userID string, contacts []models.AddContact) ([]models.Contact, *errx.Error) {
|
|
// Validate userID up front. The handler should have caught a
|
|
// malformed JWT subject, but a defensive check here keeps any
|
|
// invalid value from blowing up pgx as "InternalError 500".
|
|
if _, perr := uuid.Parse(userID); perr != nil {
|
|
return nil, errx.ErrUuid
|
|
}
|
|
|
|
// Normalize + validate every contact before opening a transaction.
|
|
// Catching bad input here lets us return 400 instead of letting
|
|
// pgx fail mid-batch (which used to surface as a generic 500).
|
|
normalized := make([]models.AddContact, 0, len(contacts))
|
|
campaignIDs := make([][]uuid.UUID, 0, len(contacts))
|
|
categoryIDs := make([][]uuid.UUID, 0, len(contacts))
|
|
for _, lead := range contacts {
|
|
lead.Email = strings.TrimSpace(lead.Email)
|
|
if !email.IsValid(lead.Email) {
|
|
return nil, errx.ErrEmail
|
|
}
|
|
lead.FirstName = strings.TrimSpace(lead.FirstName)
|
|
lead.LastName = strings.TrimSpace(lead.LastName)
|
|
lead.Company = strings.TrimSpace(lead.Company)
|
|
lead.Phone = strings.TrimSpace(lead.Phone)
|
|
|
|
// JSONB column is NOT NULL; encoding a nil map sends NULL.
|
|
// Replace nil with an empty map so the INSERT can't violate
|
|
// the constraint.
|
|
if lead.CustomFields == nil {
|
|
lead.CustomFields = map[string]string{}
|
|
}
|
|
for key := range lead.CustomFields {
|
|
if !utils.IsValidJSONKey(key) {
|
|
return nil, errx.ErrJSONKey
|
|
}
|
|
}
|
|
|
|
// Approximate size check using JSON payload.
|
|
data, jerr := json.Marshal(lead)
|
|
if jerr != nil {
|
|
return nil, errx.ErrContactSerialize
|
|
}
|
|
if len(data) > config.MaxContactSize {
|
|
return nil, errx.ErrContactSize
|
|
}
|
|
|
|
// Parse + dedupe campaign IDs. Skip blanks. Invalid UUIDs are
|
|
// a user error → 400, not a server crash.
|
|
cidSet := make(map[uuid.UUID]struct{}, len(lead.Campaigns))
|
|
cids := make([]uuid.UUID, 0, len(lead.Campaigns))
|
|
for _, raw := range lead.Campaigns {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
cid, cerr := uuid.Parse(raw)
|
|
if cerr != nil {
|
|
return nil, errx.ErrUuid
|
|
}
|
|
if _, dup := cidSet[cid]; dup {
|
|
continue
|
|
}
|
|
cidSet[cid] = struct{}{}
|
|
cids = append(cids, cid)
|
|
}
|
|
|
|
// Parse + dedupe category IDs. Same rules as campaigns.
|
|
catSet := make(map[uuid.UUID]struct{}, len(lead.Categories))
|
|
cats := make([]uuid.UUID, 0, len(lead.Categories))
|
|
for _, raw := range lead.Categories {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
cid, cerr := uuid.Parse(raw)
|
|
if cerr != nil {
|
|
return nil, errx.ErrUuid
|
|
}
|
|
if _, dup := catSet[cid]; dup {
|
|
continue
|
|
}
|
|
catSet[cid] = struct{}{}
|
|
cats = append(cats, cid)
|
|
}
|
|
|
|
normalized = append(normalized, lead)
|
|
campaignIDs = append(campaignIDs, cids)
|
|
categoryIDs = append(categoryIDs, cats)
|
|
}
|
|
|
|
tx, err := r.DB.Begin(ctx)
|
|
if err != nil {
|
|
db.CaptureError(err, "", nil, "begin")
|
|
return nil, errx.InternalError()
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// Upsert contacts in a single batch round-trip.
|
|
insertBatch := pgx.Batch{}
|
|
for _, lead := range normalized {
|
|
insertBatch.Queue(
|
|
`INSERT INTO contacts (
|
|
id, user_id, first_name, last_name, email, company, phone, custom_fields
|
|
) VALUES (
|
|
gen_random_uuid(), $1, $2, $3, LOWER($4), $5, $6, $7
|
|
)
|
|
ON CONFLICT (user_id, (LOWER(email))) DO UPDATE SET
|
|
first_name = EXCLUDED.first_name,
|
|
last_name = EXCLUDED.last_name,
|
|
company = EXCLUDED.company,
|
|
phone = EXCLUDED.phone,
|
|
custom_fields = contacts.custom_fields || EXCLUDED.custom_fields,
|
|
updated_at = NOW()
|
|
RETURNING id, first_name, last_name, email, company, phone, custom_fields, subscribed, updated_at, created_at`,
|
|
userID, lead.FirstName, lead.LastName, lead.Email, lead.Company, lead.Phone, lead.CustomFields,
|
|
)
|
|
}
|
|
|
|
br := tx.SendBatch(ctx, &insertBatch)
|
|
|
|
ncontacts := make([]models.Contact, 0, len(normalized))
|
|
for range normalized {
|
|
ncon := models.Contact{
|
|
Campaigns: []models.MiniCampaign{},
|
|
Categories: []models.MiniCategory{},
|
|
Subscribed: true,
|
|
}
|
|
if err := br.QueryRow().Scan(
|
|
&ncon.ID, &ncon.FirstName, &ncon.LastName, &ncon.Email, &ncon.Company,
|
|
&ncon.Phone, &ncon.CustomFields, &ncon.Subscribed, &ncon.UpdatedAt, &ncon.CreatedAt,
|
|
); err != nil {
|
|
br.Close()
|
|
db.CaptureError(err, "", nil, "batch queryrow")
|
|
return nil, errx.InternalError()
|
|
}
|
|
// Defensive: backend code occasionally returns nil custom_fields
|
|
// from older rows. Normalize for the JSON response.
|
|
if ncon.CustomFields == nil {
|
|
ncon.CustomFields = map[string]string{}
|
|
}
|
|
ncontacts = append(ncontacts, ncon)
|
|
}
|
|
if err := br.Close(); err != nil {
|
|
db.CaptureError(err, "", nil, "batch close")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
// Link campaigns. Original code's RETURNING clause referenced a
|
|
// non-inserted table, which is invalid SQL; resolve by inserting
|
|
// first, then SELECTing the name back from `campaigns` in a
|
|
// separate statement. Scoped to the user's own campaigns.
|
|
for i, cids := range campaignIDs {
|
|
if len(cids) == 0 {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO campaign_leads (contact_id, campaign_id)
|
|
SELECT $1, c.id
|
|
FROM campaigns c
|
|
WHERE c.id = ANY($2) AND c.user_id = $3
|
|
ON CONFLICT (campaign_id, contact_id) DO NOTHING
|
|
`, ncontacts[i].ID, cids, userID); err != nil {
|
|
db.CaptureError(err, "", nil, "campaign_leads insert")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT c.id, c.name
|
|
FROM campaigns c
|
|
JOIN campaign_leads cl ON cl.campaign_id = c.id
|
|
WHERE cl.contact_id = $1 AND c.user_id = $2
|
|
`, ncontacts[i].ID, userID)
|
|
if err != nil {
|
|
db.CaptureError(err, "", nil, "campaign_leads select")
|
|
return nil, errx.InternalError()
|
|
}
|
|
linked := make([]models.MiniCampaign, 0)
|
|
for rows.Next() {
|
|
var mc models.MiniCampaign
|
|
if err := rows.Scan(&mc.ID, &mc.Name); err != nil {
|
|
rows.Close()
|
|
db.CaptureError(err, "", nil, "campaign scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
linked = append(linked, mc)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
db.CaptureError(err, "", nil, "campaign rows")
|
|
return nil, errx.InternalError()
|
|
}
|
|
ncontacts[i].Campaigns = linked
|
|
}
|
|
|
|
// Link categories. Scoped to the user's own categories so a
|
|
// malicious or stale ID can't attach foreign data.
|
|
for i, cats := range categoryIDs {
|
|
if len(cats) == 0 {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO contact_categories (contact_id, category_id)
|
|
SELECT $1, cat.id
|
|
FROM categories cat
|
|
WHERE cat.id = ANY($2) AND cat.user_id = $3
|
|
ON CONFLICT (contact_id, category_id) DO NOTHING
|
|
`, ncontacts[i].ID, cats, userID); err != nil {
|
|
db.CaptureError(err, "", nil, "contact_categories insert")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT cat.id, cat.title, cat.color
|
|
FROM categories cat
|
|
JOIN contact_categories cc ON cc.category_id = cat.id
|
|
WHERE cc.contact_id = $1 AND cat.user_id = $2
|
|
ORDER BY cat.position ASC, cat.title ASC
|
|
`, ncontacts[i].ID, userID)
|
|
if err != nil {
|
|
db.CaptureError(err, "", nil, "contact_categories select")
|
|
return nil, errx.InternalError()
|
|
}
|
|
linked := make([]models.MiniCategory, 0)
|
|
for rows.Next() {
|
|
var mc models.MiniCategory
|
|
if err := rows.Scan(&mc.ID, &mc.Title, &mc.Color); err != nil {
|
|
rows.Close()
|
|
db.CaptureError(err, "", nil, "category scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
linked = append(linked, mc)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
db.CaptureError(err, "", nil, "category rows")
|
|
return nil, errx.InternalError()
|
|
}
|
|
ncontacts[i].Categories = linked
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
db.CaptureError(err, "", nil, "commit")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
return ncontacts, nil
|
|
}
|
|
|
|
// GetByID retrieves a contact by ID without requiring userID (for internal service use)
|
|
func (r *contactRepository) GetByID(ctx context.Context, contactID uuid.UUID) (*models.Contact, *errx.Error) {
|
|
query := `
|
|
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
|
|
FROM contacts c
|
|
WHERE c.id = $1
|
|
`
|
|
|
|
var contact models.Contact
|
|
err := r.DB.QueryRow(ctx, query, contactID).Scan(
|
|
&contact.ID, &contact.FirstName, &contact.LastName, &contact.Email,
|
|
&contact.Company, &contact.Phone, &contact.CustomFields, &contact.Subscribed,
|
|
&contact.UpdatedAt, &contact.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
db.CaptureError(err, query, []any{contactID}, "queryrow")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
contact.Campaigns = []models.MiniCampaign{}
|
|
contact.Categories = []models.MiniCategory{}
|
|
return &contact, nil
|
|
}
|
|
|
|
func (r *contactRepository) GetByEmailAndOrganization(ctx context.Context, organizationID uuid.UUID, email string) (*models.Contact, *errx.Error) {
|
|
query := `
|
|
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
|
|
FROM contacts c
|
|
WHERE c.organization_id = $1
|
|
AND LOWER(c.email) = LOWER($2)
|
|
ORDER BY c.updated_at DESC
|
|
LIMIT 1
|
|
`
|
|
|
|
var contact models.Contact
|
|
err := r.DB.QueryRow(ctx, query, organizationID, strings.TrimSpace(email)).Scan(
|
|
&contact.ID, &contact.FirstName, &contact.LastName, &contact.Email,
|
|
&contact.Company, &contact.Phone, &contact.CustomFields, &contact.Subscribed,
|
|
&contact.UpdatedAt, &contact.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
db.CaptureError(err, query, []any{organizationID, email}, "queryrow")
|
|
return nil, errx.InternalError()
|
|
}
|
|
contact.Campaigns = []models.MiniCampaign{}
|
|
contact.Categories = []models.MiniCategory{}
|
|
return &contact, nil
|
|
}
|
|
|
|
func (r *contactRepository) Search(
|
|
ctx context.Context,
|
|
userID string,
|
|
category,
|
|
cursor *string,
|
|
filters models.SearchContacts,
|
|
limit int32,
|
|
) (*models.ContactsResult, *errx.Error) {
|
|
var whereClauses []string
|
|
var args []any
|
|
argIndex := 1
|
|
|
|
if filters.Offset < 0 {
|
|
filters.Offset = 0
|
|
}
|
|
|
|
// -----------------------------
|
|
// Base filter: user_id
|
|
// -----------------------------
|
|
whereClauses = append(whereClauses, fmt.Sprintf("c.user_id = $%d", argIndex))
|
|
args = append(args, userID)
|
|
argIndex++
|
|
|
|
// -----------------------------
|
|
// Text search across core fields
|
|
// -----------------------------
|
|
if filters.Query != "" {
|
|
q := "%" + filters.Query + "%"
|
|
whereClauses = append(whereClauses, fmt.Sprintf(`
|
|
(c.first_name ILIKE $%d OR
|
|
c.last_name ILIKE $%d OR
|
|
c.email ILIKE $%d OR
|
|
c.company ILIKE $%d OR
|
|
c.phone ILIKE $%d)
|
|
`, argIndex, argIndex+1, argIndex+2, argIndex+3, argIndex+4))
|
|
args = append(args, q, q, q, q, q)
|
|
argIndex += 5
|
|
}
|
|
|
|
// -----------------------------
|
|
// Custom field filters (JSONB)
|
|
// -----------------------------
|
|
for _, f := range filters.CustomFieldFilters {
|
|
if f.Name == "" || f.Value == "" || !utils.IsValidJSONKey(f.Name) {
|
|
continue
|
|
}
|
|
var op, val string
|
|
switch f.Type {
|
|
case models.SearchContactsFilterTypeEqual:
|
|
op = "="
|
|
val = f.Value
|
|
case models.SearchContactsFilterTypeStartsWith:
|
|
op = "ILIKE"
|
|
val = f.Value + "%"
|
|
case models.SearchContactsFilterTypeEndsWith:
|
|
op = "ILIKE"
|
|
val = "%" + f.Value
|
|
case models.SearchContactsFilterTypeContains:
|
|
op = "ILIKE"
|
|
val = "%" + f.Value + "%"
|
|
default:
|
|
op = "ILIKE"
|
|
val = "%" + f.Value + "%"
|
|
}
|
|
whereClauses = append(whereClauses, fmt.Sprintf(`c.custom_fields ->> '%s' %s $%d`, f.Name, op, argIndex))
|
|
args = append(args, val)
|
|
argIndex++
|
|
}
|
|
|
|
// -----------------------------
|
|
// Subscription filter
|
|
// -----------------------------
|
|
if filters.Subscribed != nil {
|
|
whereClauses = append(whereClauses, fmt.Sprintf("c.subscribed = $%d", argIndex))
|
|
args = append(args, *filters.Subscribed)
|
|
argIndex++
|
|
}
|
|
|
|
// -----------------------------
|
|
// Date filters
|
|
// -----------------------------
|
|
if filters.CreatedAfter != nil {
|
|
whereClauses = append(whereClauses, fmt.Sprintf("c.created_at > $%d", argIndex))
|
|
args = append(args, *filters.CreatedAfter)
|
|
argIndex++
|
|
}
|
|
if filters.CreatedBefore != nil {
|
|
whereClauses = append(whereClauses, fmt.Sprintf("c.created_at < $%d", argIndex))
|
|
args = append(args, *filters.CreatedBefore)
|
|
argIndex++
|
|
}
|
|
if filters.UpdatedAfter != nil {
|
|
whereClauses = append(whereClauses, fmt.Sprintf("c.updated_at > $%d", argIndex))
|
|
args = append(args, *filters.UpdatedAfter)
|
|
argIndex++
|
|
}
|
|
if filters.UpdatedBefore != nil {
|
|
whereClauses = append(whereClauses, fmt.Sprintf("c.updated_at < $%d", argIndex))
|
|
args = append(args, *filters.UpdatedBefore)
|
|
argIndex++
|
|
}
|
|
|
|
// -----------------------------
|
|
// Campaign IDs filter (must be in ALL specified campaigns)
|
|
// -----------------------------
|
|
if len(filters.CampaignIDs) > 0 {
|
|
placeholders := make([]string, len(filters.CampaignIDs))
|
|
for i, id := range filters.CampaignIDs {
|
|
placeholders[i] = fmt.Sprintf("$%d", argIndex)
|
|
args = append(args, id)
|
|
argIndex++
|
|
}
|
|
campaignClause := fmt.Sprintf(`
|
|
c.id IN (
|
|
SELECT contact_id
|
|
FROM campaign_leads
|
|
WHERE campaign_id IN (%s)
|
|
GROUP BY contact_id
|
|
HAVING COUNT(DISTINCT campaign_id) = %d
|
|
)
|
|
`, strings.Join(placeholders, ","), len(filters.CampaignIDs))
|
|
whereClauses = append(whereClauses, campaignClause)
|
|
}
|
|
|
|
// -----------------------------
|
|
// Category IDs filter (must have ALL specified categories)
|
|
// -----------------------------
|
|
if len(filters.CategoryIDs) > 0 {
|
|
placeholders := make([]string, len(filters.CategoryIDs))
|
|
for i, id := range filters.CategoryIDs {
|
|
placeholders[i] = fmt.Sprintf("$%d", argIndex)
|
|
args = append(args, id)
|
|
argIndex++
|
|
}
|
|
categoryClause := fmt.Sprintf(`
|
|
c.id IN (
|
|
SELECT contact_id
|
|
FROM contact_categories
|
|
WHERE category_id IN (%s)
|
|
GROUP BY contact_id
|
|
HAVING COUNT(DISTINCT category_id) = %d
|
|
)
|
|
`, strings.Join(placeholders, ","), len(filters.CategoryIDs))
|
|
whereClauses = append(whereClauses, categoryClause)
|
|
}
|
|
|
|
// -----------------------------
|
|
// Sort logic
|
|
// -----------------------------
|
|
sortBy := "c.created_at"
|
|
direction := "DESC"
|
|
allowedSorts := map[string]bool{
|
|
"first_name": true,
|
|
"last_name": true,
|
|
"email": true,
|
|
"created_at": true,
|
|
"updated_at": true,
|
|
"campaign_count": true,
|
|
}
|
|
|
|
if filters.SortBy != "" && allowedSorts[filters.SortBy] {
|
|
if filters.SortBy == "campaign_count" {
|
|
sortBy = "campaign_count"
|
|
} else {
|
|
sortBy = "c." + filters.SortBy
|
|
}
|
|
}
|
|
if filters.Reverse {
|
|
direction = "ASC"
|
|
} else {
|
|
direction = "DESC"
|
|
}
|
|
|
|
// -----------------------------
|
|
// Cursor pagination
|
|
// -----------------------------
|
|
if cursor != nil && *cursor != "" {
|
|
cursorOp := ">"
|
|
if direction == "DESC" {
|
|
cursorOp = "<"
|
|
}
|
|
sortSub := fmt.Sprintf("(SELECT %s FROM contacts WHERE id = $%d)", sortBy, argIndex)
|
|
args = append(args, *cursor)
|
|
argIndex++
|
|
|
|
whereClauses = append(whereClauses, fmt.Sprintf(`
|
|
(
|
|
(%s %s %s)
|
|
OR (%s = %s AND c.id >= $%d)
|
|
)
|
|
`, sortBy, cursorOp, sortSub, sortBy, sortSub, argIndex))
|
|
args = append(args, *cursor)
|
|
argIndex++
|
|
}
|
|
|
|
// -----------------------------
|
|
// Campaign count filters (min/max)
|
|
// -----------------------------
|
|
campaignCountClauses := []string{}
|
|
if filters.MinCampaigns != nil {
|
|
campaignCountClauses = append(campaignCountClauses, fmt.Sprintf("COALESCE(cl.campaign_count,0) >= $%d", argIndex))
|
|
args = append(args, *filters.MinCampaigns)
|
|
argIndex++
|
|
}
|
|
if filters.MaxCampaigns != nil {
|
|
campaignCountClauses = append(campaignCountClauses, fmt.Sprintf("COALESCE(cl.campaign_count,0) <= $%d", argIndex))
|
|
args = append(args, *filters.MaxCampaigns)
|
|
argIndex++
|
|
}
|
|
|
|
// -----------------------------
|
|
// Build WHERE SQL
|
|
// -----------------------------
|
|
whereSQL := ""
|
|
if len(whereClauses) > 0 {
|
|
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
|
|
}
|
|
if len(campaignCountClauses) > 0 {
|
|
if whereSQL == "" {
|
|
whereSQL = "WHERE " + strings.Join(campaignCountClauses, " AND ")
|
|
} else {
|
|
whereSQL += " AND " + strings.Join(campaignCountClauses, " AND ")
|
|
}
|
|
}
|
|
|
|
// Main query.
|
|
//
|
|
// Both the `campaigns` and `categories` agg subqueries need the
|
|
// user_id so they can't leak rows from other users that happen to
|
|
// share a contact id (theoretically impossible thanks to the outer
|
|
// WHERE, but cheap defence-in-depth). They reuse the same $%d
|
|
// placeholder so we only append userID once.
|
|
query := fmt.Sprintf(`
|
|
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,
|
|
COALESCE(cl.campaign_count,0) AS campaign_count,
|
|
COALESCE(
|
|
(
|
|
SELECT json_agg(json_build_object('id', cam.id, 'name', cam.name))
|
|
FROM campaign_leads cl2
|
|
JOIN campaigns cam ON cl2.campaign_id = cam.id
|
|
WHERE cl2.contact_id = c.id
|
|
AND cam.user_id = $%d
|
|
), '[]'::json
|
|
) AS campaigns,
|
|
COALESCE(
|
|
(
|
|
SELECT json_agg(json_build_object('id', cat.id, 'title', cat.title, 'color', cat.color) ORDER BY cat.position ASC, cat.title ASC)
|
|
FROM contact_categories cc
|
|
JOIN categories cat ON cc.category_id = cat.id
|
|
WHERE cc.contact_id = c.id
|
|
AND cat.user_id = $%d
|
|
), '[]'::json
|
|
) AS categories
|
|
FROM contacts c
|
|
LEFT JOIN (
|
|
SELECT contact_id, COUNT(campaign_id) AS campaign_count
|
|
FROM campaign_leads
|
|
GROUP BY contact_id
|
|
) cl ON c.id = cl.contact_id
|
|
%s
|
|
ORDER BY %s %s, c.id ASC
|
|
LIMIT $%d
|
|
`, argIndex, argIndex, whereSQL, sortBy, direction, argIndex+1)
|
|
|
|
args = append(args, userID, limit+1)
|
|
|
|
// Skip total count if cursor exists
|
|
var totalCount *int64
|
|
if cursor == nil || *cursor == "" {
|
|
countQuery := fmt.Sprintf(`
|
|
SELECT COUNT(*)
|
|
FROM contacts c
|
|
LEFT JOIN (
|
|
SELECT contact_id, COUNT(campaign_id) AS campaign_count
|
|
FROM campaign_leads
|
|
GROUP BY contact_id
|
|
) cl ON c.id = cl.contact_id
|
|
%s
|
|
`, whereSQL)
|
|
var tmp int64
|
|
if err := r.DB.QueryRow(ctx, countQuery, args[:argIndex-1]...).Scan(&tmp); err != nil {
|
|
db.CaptureError(err, "countQuery", args, "queryrow")
|
|
return nil, errx.InternalError()
|
|
}
|
|
totalCount = &tmp
|
|
}
|
|
|
|
// -----------------------------
|
|
// Execute query
|
|
// -----------------------------
|
|
rows, err := r.DB.Query(ctx, query, args...)
|
|
if err != nil {
|
|
db.CaptureError(err, query, args, "query")
|
|
return nil, errx.InternalError()
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Initialize as non-nil so JSON marshals to [] on zero rows. A nil
|
|
// slice marshals to `null`, and the frontend's flatMap((p) => p.data)
|
|
// then produces [null], which crashes any downstream `.subscribed`
|
|
// access. Always return an array.
|
|
contacts := make([]models.Contact, 0, limit+1)
|
|
for rows.Next() {
|
|
var c models.Contact
|
|
var campaignCount int
|
|
var campaignsJSON []byte
|
|
var categoriesJSON []byte
|
|
|
|
if err := rows.Scan(
|
|
&c.ID, &c.FirstName, &c.LastName, &c.Email,
|
|
&c.Company, &c.Phone, &c.CustomFields, &c.Subscribed,
|
|
&c.UpdatedAt, &c.CreatedAt, &campaignCount, &campaignsJSON, &categoriesJSON,
|
|
); err != nil {
|
|
db.CaptureError(err, "", nil, "scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
if len(campaignsJSON) > 0 {
|
|
var campaigns []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(campaignsJSON, &campaigns); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
c.Campaigns = make([]models.MiniCampaign, len(campaigns))
|
|
for i, cm := range campaigns {
|
|
c.Campaigns[i] = models.MiniCampaign{ID: cm.ID, Name: cm.Name}
|
|
}
|
|
} else {
|
|
c.Campaigns = []models.MiniCampaign{}
|
|
}
|
|
|
|
if len(categoriesJSON) > 0 {
|
|
if err := json.Unmarshal(categoriesJSON, &c.Categories); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
}
|
|
if c.Categories == nil {
|
|
c.Categories = []models.MiniCategory{}
|
|
}
|
|
|
|
contacts = append(contacts, c)
|
|
}
|
|
|
|
// Next cursor
|
|
var nextCursor *uuid.UUID
|
|
var hasMore bool
|
|
if len(contacts) > int(limit) {
|
|
hasMore = true
|
|
nextID := contacts[limit].ID
|
|
nextCursor = &nextID
|
|
contacts = contacts[:limit]
|
|
}
|
|
|
|
return &models.ContactsResult{
|
|
Data: contacts,
|
|
Pagination: models.Pagination{
|
|
Total: totalCount,
|
|
NextCursor: nextCursor,
|
|
HasMore: hasMore,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (r *contactRepository) Update(ctx context.Context, userID, contactID string, data *models.UpdateContact) (*models.Contact, *errx.Error) {
|
|
tx, err := r.DB.Begin(ctx)
|
|
if err != nil {
|
|
db.CaptureError(err, "", nil, "begin")
|
|
return nil, errx.InternalError()
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// Validate contact existence and fetch current data
|
|
var c models.Contact
|
|
var campaignsJSON []byte
|
|
|
|
query := `
|
|
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,
|
|
COALESCE(
|
|
(
|
|
SELECT json_agg(json_build_object('id', cam.id, 'name', cam.name))
|
|
FROM campaign_leads cl2
|
|
JOIN campaigns cam ON cl2.campaign_id = cam.id
|
|
WHERE cl2.contact_id = c.id AND cam.user_id = $2
|
|
),
|
|
'[]'::json
|
|
) AS campaigns
|
|
FROM contacts c
|
|
WHERE c.id = $1 AND c.user_id = $2
|
|
`
|
|
|
|
params := []any{
|
|
contactID,
|
|
userID,
|
|
}
|
|
|
|
err = tx.QueryRow(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
).Scan(
|
|
&c.ID, &c.FirstName, &c.LastName, &c.Email,
|
|
&c.Company, &c.Phone, &c.CustomFields, &c.Subscribed,
|
|
&c.UpdatedAt, &c.CreatedAt, &campaignsJSON,
|
|
)
|
|
if err == pgx.ErrNoRows {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
db.CaptureError(err, query, params, "queryrow")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
// Unmarshal current campaigns
|
|
if len(campaignsJSON) > 0 {
|
|
var campaigns []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(campaignsJSON, &campaigns); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
c.Campaigns = make([]models.MiniCampaign, len(campaigns))
|
|
for i, camp := range campaigns {
|
|
c.Campaigns[i] = models.MiniCampaign{
|
|
ID: camp.ID,
|
|
Name: camp.Name,
|
|
}
|
|
}
|
|
} else {
|
|
c.Campaigns = make([]models.MiniCampaign, 0)
|
|
}
|
|
|
|
// Build update query for contacts table
|
|
var setClauses []string
|
|
var args []interface{}
|
|
argIndex := 1
|
|
|
|
// Update fields if provided
|
|
if data.FirstName != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("first_name = $%d", argIndex))
|
|
args = append(args, *data.FirstName)
|
|
argIndex++
|
|
}
|
|
if data.LastName != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("last_name = $%d", argIndex))
|
|
args = append(args, *data.LastName)
|
|
argIndex++
|
|
}
|
|
if data.Company != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("company = $%d", argIndex))
|
|
args = append(args, *data.Company)
|
|
argIndex++
|
|
}
|
|
if data.Phone != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("phone = $%d", argIndex))
|
|
args = append(args, *data.Phone)
|
|
argIndex++
|
|
}
|
|
if data.Subscribed != nil {
|
|
setClauses = append(setClauses, fmt.Sprintf("subscribed = $%d", argIndex))
|
|
args = append(args, *data.Subscribed)
|
|
argIndex++
|
|
}
|
|
if data.CustomFields != nil {
|
|
for key := range *data.CustomFields {
|
|
if !utils.IsValidJSONKey(key) {
|
|
return nil, errx.ErrJSONKey
|
|
}
|
|
}
|
|
// Merge existing custom_fields with updates
|
|
mergedFields := make(map[string]string)
|
|
for k, v := range c.CustomFields {
|
|
mergedFields[k] = v
|
|
}
|
|
for k, v := range *data.CustomFields {
|
|
if v == "" {
|
|
delete(mergedFields, k) // Remove key if value is empty
|
|
} else {
|
|
mergedFields[k] = v // Update or add key
|
|
}
|
|
}
|
|
setClauses = append(setClauses, fmt.Sprintf("custom_fields = $%d", argIndex))
|
|
args = append(args, mergedFields)
|
|
argIndex++
|
|
}
|
|
|
|
// Always update updated_at
|
|
setClauses = append(setClauses, "updated_at = NOW()")
|
|
|
|
// If no fields to update, skip contacts table update
|
|
var updatedContact models.Contact
|
|
if len(setClauses) > 1 { // >1 because updated_at is always included
|
|
args = append(args, contactID, userID)
|
|
query := fmt.Sprintf(`
|
|
UPDATE contacts
|
|
SET %s
|
|
WHERE id = $%d AND user_id = $%d
|
|
RETURNING id, first_name, last_name, email, company, phone, custom_fields, subscribed, updated_at, created_at`,
|
|
strings.Join(setClauses, ", "), argIndex, argIndex+1)
|
|
err = tx.QueryRow(ctx, query, args...).Scan(
|
|
&updatedContact.ID, &updatedContact.FirstName, &updatedContact.LastName, &updatedContact.Email,
|
|
&updatedContact.Company, &updatedContact.Phone, &updatedContact.CustomFields, &updatedContact.Subscribed,
|
|
&updatedContact.UpdatedAt, &updatedContact.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
db.CaptureError(err, query, args, "queryrow")
|
|
return nil, errx.InternalError()
|
|
}
|
|
} else {
|
|
updatedContact = c // No fields updated, use existing contact
|
|
}
|
|
|
|
// Update campaigns if provided
|
|
if data.Campaigns != nil {
|
|
// Get current campaign IDs
|
|
currentCampaignIDs := make([]string, len(updatedContact.Campaigns))
|
|
for i, c := range updatedContact.Campaigns {
|
|
currentCampaignIDs[i] = c.ID
|
|
}
|
|
|
|
// Compute campaigns to insert and delete
|
|
toInsert := utils.Difference(data.Campaigns, currentCampaignIDs)
|
|
toDelete := utils.Difference(currentCampaignIDs, data.Campaigns)
|
|
|
|
// Delete removed campaigns
|
|
query = `
|
|
DELETE FROM campaign_leads
|
|
WHERE contact_id = $1 AND campaign_id = $2
|
|
`
|
|
for _, campaignID := range toDelete {
|
|
params := []any{
|
|
contactID,
|
|
campaignID,
|
|
}
|
|
_, err = tx.Exec(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
)
|
|
if err != nil {
|
|
db.CaptureError(err, query, params, "exec")
|
|
return nil, errx.InternalError()
|
|
}
|
|
}
|
|
|
|
// Insert new campaigns
|
|
query = `
|
|
INSERT INTO campaign_leads (contact_id, campaign_id)
|
|
SELECT $1, id
|
|
FROM campaigns
|
|
WHERE id = $2 AND user_id = $3
|
|
ON CONFLICT (campaign_id, contact_id) DO NOTHING
|
|
`
|
|
for _, campaignID := range toInsert {
|
|
params := []any{
|
|
contactID,
|
|
campaignID,
|
|
userID,
|
|
}
|
|
_, err = tx.Exec(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
)
|
|
if err != nil {
|
|
db.CaptureError(err, query, params, "exec")
|
|
return nil, errx.InternalError()
|
|
}
|
|
}
|
|
|
|
// Fetch updated campaigns
|
|
var newCampaignsJSON []byte
|
|
|
|
query = `
|
|
SELECT COALESCE(
|
|
(
|
|
SELECT json_agg(json_build_object('id', cam.id, 'name', cam.name))
|
|
FROM campaign_leads cl
|
|
JOIN campaigns cam ON cl.campaign_id = cam.id
|
|
WHERE cl.contact_id =$1 AND cam.user_id = $2
|
|
),
|
|
'[]'::json
|
|
)
|
|
`
|
|
|
|
params := []any{
|
|
contactID,
|
|
userID,
|
|
}
|
|
|
|
err = tx.QueryRow(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
).Scan(&newCampaignsJSON)
|
|
if err != nil {
|
|
db.CaptureError(err, query, params, "queryrow")
|
|
return nil, errx.InternalError()
|
|
}
|
|
if len(newCampaignsJSON) > 0 {
|
|
var campaigns []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(newCampaignsJSON, &campaigns); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
updatedContact.Campaigns = make([]models.MiniCampaign, len(campaigns))
|
|
for i, c := range campaigns {
|
|
updatedContact.Campaigns[i] = models.MiniCampaign{
|
|
ID: c.ID,
|
|
Name: c.Name,
|
|
}
|
|
}
|
|
} else {
|
|
updatedContact.Campaigns = make([]models.MiniCampaign, 0)
|
|
}
|
|
}
|
|
|
|
// Categories. Two modes supported on the request:
|
|
// - `categories: [..]` → set absolute (full replace).
|
|
// - `add_categories / remove_categories` → diff style.
|
|
// When the absolute form is non-nil it wins; the diff is ignored.
|
|
categoriesChanged := false
|
|
if data.Categories != nil {
|
|
ids, perr := parseCategoryIDs(data.Categories)
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
// Wipe then insert; scoped to user-owned categories.
|
|
if _, err := tx.Exec(ctx, `DELETE FROM contact_categories WHERE contact_id = $1`, contactID); err != nil {
|
|
db.CaptureError(err, "", nil, "categories wipe")
|
|
return nil, errx.InternalError()
|
|
}
|
|
if len(ids) > 0 {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO contact_categories (contact_id, category_id)
|
|
SELECT $1, cat.id
|
|
FROM categories cat
|
|
WHERE cat.id = ANY($2) AND cat.user_id = $3
|
|
ON CONFLICT (contact_id, category_id) DO NOTHING
|
|
`, contactID, ids, userID); err != nil {
|
|
db.CaptureError(err, "", nil, "categories insert")
|
|
return nil, errx.InternalError()
|
|
}
|
|
}
|
|
categoriesChanged = true
|
|
} else {
|
|
if len(data.AddCategories) > 0 {
|
|
ids, perr := parseCategoryIDs(data.AddCategories)
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO contact_categories (contact_id, category_id)
|
|
SELECT $1, cat.id
|
|
FROM categories cat
|
|
WHERE cat.id = ANY($2) AND cat.user_id = $3
|
|
ON CONFLICT (contact_id, category_id) DO NOTHING
|
|
`, contactID, ids, userID); err != nil {
|
|
db.CaptureError(err, "", nil, "categories add")
|
|
return nil, errx.InternalError()
|
|
}
|
|
categoriesChanged = true
|
|
}
|
|
if len(data.RemoveCategories) > 0 {
|
|
ids, perr := parseCategoryIDs(data.RemoveCategories)
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
DELETE FROM contact_categories
|
|
WHERE contact_id = $1 AND category_id = ANY($2)
|
|
`, contactID, ids); err != nil {
|
|
db.CaptureError(err, "", nil, "categories remove")
|
|
return nil, errx.InternalError()
|
|
}
|
|
categoriesChanged = true
|
|
}
|
|
}
|
|
|
|
// Always re-read categories so the response reflects current state
|
|
// (cheap, indexed lookup).
|
|
if categoriesChanged || updatedContact.Categories == nil {
|
|
var catJSON []byte
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT COALESCE(
|
|
(
|
|
SELECT json_agg(json_build_object('id', cat.id, 'title', cat.title, 'color', cat.color) ORDER BY cat.position ASC, cat.title ASC)
|
|
FROM contact_categories cc
|
|
JOIN categories cat ON cc.category_id = cat.id
|
|
WHERE cc.contact_id = $1 AND cat.user_id = $2
|
|
),
|
|
'[]'::json
|
|
)
|
|
`, contactID, userID).Scan(&catJSON); err != nil {
|
|
db.CaptureError(err, "", nil, "categories reload")
|
|
return nil, errx.InternalError()
|
|
}
|
|
updatedContact.Categories = make([]models.MiniCategory, 0)
|
|
if len(catJSON) > 0 {
|
|
if err := json.Unmarshal(catJSON, &updatedContact.Categories); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
db.CaptureError(err, "", nil, "commit")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
return &updatedContact, nil
|
|
}
|
|
|
|
func (r *contactRepository) BulkUpdate(ctx context.Context, userID string, data *models.BulkEditContactsData) ([]models.Contact, *errx.Error) {
|
|
tx, err := r.DB.Begin(ctx)
|
|
if err != nil {
|
|
db.CaptureError(err, "", nil, "begin")
|
|
return nil, errx.InternalError()
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
b := &pgx.Batch{}
|
|
|
|
if data.Subscribe != nil {
|
|
b.Queue(`UPDATE contacts
|
|
SET subscribed = $1, updated_at = NOW()
|
|
WHERE user_id = $2 AND id = ANY($3)`,
|
|
*data.Subscribe, userID, data.Contacts)
|
|
}
|
|
|
|
if len(data.RemoveCampaigns) > 0 {
|
|
b.Queue(`DELETE FROM campaign_leads cl
|
|
USING contacts c, campaigns cam
|
|
WHERE cl.contact_id = c.id
|
|
AND cl.campaign_id = cam.id
|
|
AND c.user_id = $1
|
|
AND cam.user_id = $1
|
|
AND cl.contact_id = ANY($2)
|
|
AND cl.campaign_id = ANY($3)`,
|
|
userID, data.Contacts, data.RemoveCampaigns)
|
|
}
|
|
|
|
if len(data.AddCampaigns) > 0 {
|
|
b.Queue(`INSERT INTO campaign_leads (contact_id, campaign_id)
|
|
SELECT c.id, cam.id
|
|
FROM contacts c
|
|
CROSS JOIN campaigns cam
|
|
WHERE c.user_id = $1
|
|
AND c.id = ANY($2)
|
|
AND cam.id = ANY($3::uuid[])
|
|
AND cam.user_id = $1
|
|
ON CONFLICT DO NOTHING`,
|
|
userID, data.Contacts, data.AddCampaigns)
|
|
}
|
|
|
|
if len(data.RemoveCategories) > 0 {
|
|
b.Queue(`DELETE FROM contact_categories cc
|
|
USING contacts c
|
|
WHERE cc.contact_id = c.id
|
|
AND c.user_id = $1
|
|
AND cc.contact_id = ANY($2)
|
|
AND cc.category_id = ANY($3::uuid[])`,
|
|
userID, data.Contacts, data.RemoveCategories)
|
|
}
|
|
|
|
if len(data.AddCategories) > 0 {
|
|
b.Queue(`INSERT INTO contact_categories (contact_id, category_id)
|
|
SELECT c.id, cat.id
|
|
FROM contacts c
|
|
CROSS JOIN categories cat
|
|
WHERE c.user_id = $1
|
|
AND c.id = ANY($2)
|
|
AND cat.id = ANY($3::uuid[])
|
|
AND cat.user_id = $1
|
|
ON CONFLICT DO NOTHING`,
|
|
userID, data.Contacts, data.AddCategories)
|
|
}
|
|
|
|
for _, p := range data.Fields {
|
|
switch p.Type {
|
|
case models.BulkAddField:
|
|
b.Queue(`UPDATE contacts
|
|
SET custom_fields = custom_fields || jsonb_build_object($1,$2),
|
|
updated_at = NOW()
|
|
WHERE user_id = $3 AND id = ANY($4)`,
|
|
p.Key, p.Value, userID, data.Contacts)
|
|
case models.BulkEditField:
|
|
b.Queue(`UPDATE contacts
|
|
SET custom_fields = jsonb_set(custom_fields, ARRAY[$1], to_jsonb($2::text)),
|
|
updated_at = NOW()
|
|
WHERE user_id = $3 AND id = ANY($4)`,
|
|
p.Key, p.Value, userID, data.Contacts)
|
|
case models.BulkDeleteField:
|
|
b.Queue(`UPDATE contacts
|
|
SET custom_fields = custom_fields - $1,
|
|
updated_at = NOW()
|
|
WHERE user_id = $2 AND id = ANY($3)`,
|
|
p.Key, userID, data.Contacts)
|
|
case models.BulkRenameField:
|
|
b.Queue(`UPDATE contacts
|
|
SET custom_fields = (custom_fields - $1) || jsonb_build_object($2, custom_fields->$1),
|
|
updated_at = NOW()
|
|
WHERE user_id = $3 AND id = ANY($4)
|
|
AND custom_fields ? $1`,
|
|
p.Key, p.Value, userID, data.Contacts)
|
|
}
|
|
}
|
|
|
|
br := tx.SendBatch(ctx, b)
|
|
|
|
for i := 0; i < b.Len(); i++ {
|
|
if _, err := br.Exec(); err != nil {
|
|
br.Close()
|
|
db.CaptureError(err, "", nil, "batch exec")
|
|
return nil, errx.InternalError()
|
|
}
|
|
}
|
|
|
|
br.Close()
|
|
|
|
query := `
|
|
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,
|
|
COALESCE(
|
|
(
|
|
SELECT json_agg(json_build_object('id', cam.id, 'name', cam.name))
|
|
FROM campaign_leads cl
|
|
JOIN campaigns cam ON cl.campaign_id = cam.id
|
|
WHERE cl.contact_id =c.id AND cam.user_id = $2
|
|
),
|
|
'[]'::json
|
|
) AS campaigns,
|
|
COALESCE(
|
|
(
|
|
SELECT json_agg(json_build_object('id', cat.id, 'title', cat.title, 'color', cat.color) ORDER BY cat.position ASC, cat.title ASC)
|
|
FROM contact_categories cc
|
|
JOIN categories cat ON cc.category_id = cat.id
|
|
WHERE cc.contact_id = c.id AND cat.user_id = $2
|
|
),
|
|
'[]'::json
|
|
) AS categories
|
|
FROM contacts c
|
|
WHERE c.user_id = $2 AND c.id = ANY($1)
|
|
`
|
|
|
|
params := []any{
|
|
data.Contacts,
|
|
userID,
|
|
}
|
|
rows, err := tx.Query(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
)
|
|
if err != nil {
|
|
db.CaptureError(err, "", nil, "fetch updated contacts")
|
|
return nil, errx.InternalError()
|
|
}
|
|
defer rows.Close()
|
|
|
|
var updatedContacts []models.Contact
|
|
|
|
for rows.Next() {
|
|
var c models.Contact
|
|
var campaignsJSON []byte
|
|
var categoriesJSON []byte
|
|
|
|
err := rows.Scan(
|
|
&c.ID, &c.FirstName, &c.LastName, &c.Email,
|
|
&c.Company, &c.Phone, &c.CustomFields, &c.Subscribed,
|
|
&c.UpdatedAt, &c.CreatedAt, &campaignsJSON, &categoriesJSON,
|
|
)
|
|
if err != nil {
|
|
db.CaptureError(err, "", nil, "scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
// Unmarshal campaigns
|
|
if len(campaignsJSON) > 0 {
|
|
var campaigns []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(campaignsJSON, &campaigns); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
c.Campaigns = make([]models.MiniCampaign, len(campaigns))
|
|
for i, camp := range campaigns {
|
|
c.Campaigns[i] = models.MiniCampaign{
|
|
ID: camp.ID,
|
|
Name: camp.Name,
|
|
}
|
|
}
|
|
} else {
|
|
c.Campaigns = make([]models.MiniCampaign, 0)
|
|
}
|
|
|
|
c.Categories = make([]models.MiniCategory, 0)
|
|
if len(categoriesJSON) > 0 {
|
|
if err := json.Unmarshal(categoriesJSON, &c.Categories); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
}
|
|
|
|
updatedContacts = append(updatedContacts, c)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
db.CaptureError(err, "", nil, "commit")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
return updatedContacts, nil
|
|
}
|
|
|
|
func (r *contactRepository) BulkDelete(ctx context.Context, userID string, IDs []string) *errx.Error {
|
|
query := `
|
|
DELETE FROM contacts
|
|
WHERE id = ANY($1) AND user_id = $2
|
|
`
|
|
params := []any{
|
|
IDs,
|
|
userID,
|
|
}
|
|
_, err := r.DB.Exec(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
)
|
|
if err != nil {
|
|
db.CaptureError(err, query, params, "exec")
|
|
return errx.InternalError()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *contactRepository) Delete(ctx context.Context, userID, ID string) *errx.Error {
|
|
query := `
|
|
DELETE FROM contacts
|
|
WHERE id = $1 AND user_id = $2
|
|
`
|
|
params := []any{
|
|
ID,
|
|
userID,
|
|
}
|
|
cmd, err := r.DB.Exec(
|
|
ctx,
|
|
query,
|
|
params...,
|
|
)
|
|
if err != nil {
|
|
db.CaptureError(err, query, params, "exec")
|
|
return errx.InternalError()
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return errx.ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetByEmailsAndUser returns the contacts whose lowercased email is in
|
|
// the given list, scoped to a single user. Used by the import path to
|
|
// detect collisions before doing the bulk upsert. The map is keyed by
|
|
// lowercased email so the caller doesn't have to normalize again.
|
|
func (r *contactRepository) GetByEmailsAndUser(ctx context.Context, userID uuid.UUID, emails []string) (map[string]models.Contact, *errx.Error) {
|
|
out := make(map[string]models.Contact, len(emails))
|
|
if len(emails) == 0 {
|
|
return out, nil
|
|
}
|
|
norm := make([]string, 0, len(emails))
|
|
for _, e := range emails {
|
|
e = strings.ToLower(strings.TrimSpace(e))
|
|
if e == "" {
|
|
continue
|
|
}
|
|
norm = append(norm, e)
|
|
}
|
|
if len(norm) == 0 {
|
|
return out, nil
|
|
}
|
|
|
|
rows, err := r.DB.Query(ctx, `
|
|
SELECT id, first_name, last_name, email, company, phone, custom_fields, subscribed, updated_at, created_at
|
|
FROM contacts
|
|
WHERE user_id = $1 AND LOWER(email) = ANY($2)
|
|
`, userID, norm)
|
|
if err != nil {
|
|
db.CaptureError(err, "", nil, "GetByEmailsAndUser query")
|
|
return nil, errx.InternalError()
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var c models.Contact
|
|
if err := rows.Scan(
|
|
&c.ID, &c.FirstName, &c.LastName, &c.Email,
|
|
&c.Company, &c.Phone, &c.CustomFields, &c.Subscribed,
|
|
&c.UpdatedAt, &c.CreatedAt,
|
|
); err != nil {
|
|
db.CaptureError(err, "", nil, "GetByEmailsAndUser scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
c.Campaigns = []models.MiniCampaign{}
|
|
c.Categories = []models.MiniCategory{}
|
|
out[strings.ToLower(c.Email)] = c
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ExportAll fetches every contact matching the given selection so it
|
|
// can be streamed out as CSV/XLSX/JSON. There is no pagination — the
|
|
// caller is expected to enforce max upstream (handler does).
|
|
//
|
|
// Three selection modes overlap with the search filter machinery:
|
|
// - filters != nil → reuse the SearchContacts WHERE-builder.
|
|
// - contactIDs > 0 → constrain to just those rows.
|
|
// - both nil/empty → "every contact this user owns".
|
|
func (r *contactRepository) ExportAll(ctx context.Context, userID string, filters *models.SearchContacts, contactIDs []string, max int) ([]models.Contact, *errx.Error) {
|
|
if _, perr := uuid.Parse(userID); perr != nil {
|
|
return nil, errx.ErrUuid
|
|
}
|
|
if max <= 0 {
|
|
max = models.MaxContactExportRows
|
|
}
|
|
|
|
// Fall back to "everyone" by walking Search in pages. Reuse Search
|
|
// to keep WHERE-builder logic in one place: a divergent copy here
|
|
// would drift away from production semantics as filters evolve.
|
|
var search models.SearchContacts
|
|
if filters != nil {
|
|
search = *filters
|
|
}
|
|
// If a specific id list is provided, narrow further by pulling
|
|
// directly. We still pass through Search so we get the same joined
|
|
// categories/campaigns shape.
|
|
idSet := make(map[uuid.UUID]struct{}, len(contactIDs))
|
|
useIDFilter := false
|
|
for _, raw := range contactIDs {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
id, perr := uuid.Parse(raw)
|
|
if perr != nil {
|
|
return nil, errx.ErrUuid
|
|
}
|
|
idSet[id] = struct{}{}
|
|
useIDFilter = true
|
|
}
|
|
|
|
out := make([]models.Contact, 0, 256)
|
|
var cursor *string
|
|
pageSize := int32(500)
|
|
for {
|
|
page, xerr := r.Search(ctx, userID, nil, cursor, search, pageSize)
|
|
if xerr != nil {
|
|
return nil, xerr
|
|
}
|
|
for _, c := range page.Data {
|
|
if useIDFilter {
|
|
if _, ok := idSet[c.ID]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, c)
|
|
if len(out) >= max {
|
|
return out, nil
|
|
}
|
|
}
|
|
if !page.Pagination.HasMore || page.Pagination.NextCursor == nil {
|
|
break
|
|
}
|
|
s := page.Pagination.NextCursor.String()
|
|
cursor = &s
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *contactRepository) GetContactCount(ctx context.Context, userID string) (int, *errx.Error) {
|
|
query := `SELECT COUNT(*) FROM contacts WHERE user_id = $1`
|
|
var count int
|
|
err := r.DB.QueryRow(ctx, query, userID).Scan(&count)
|
|
if err != nil {
|
|
db.CaptureError(err, query, []any{userID}, "queryrow")
|
|
return 0, errx.InternalError()
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// GetDetail loads the contact 360 payload: core fields + categories +
|
|
// campaigns + engagement counts + suppression. Single round-trip via a
|
|
// few separate queries (one main select + a couple of small aggregates)
|
|
// so the query plans stay simple and cheap to reason about.
|
|
//
|
|
// orgID is optional because not every caller has an org context (e.g.
|
|
// an API key scoped to a user without a selected org). When nil we
|
|
// skip the org-scoped joins (suppression, deliverability) and return
|
|
// zeros for those fields.
|
|
func (r *contactRepository) GetDetail(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID) (*models.ContactDetail, *errx.Error) {
|
|
// 1. Core contact + categories + campaigns. Same shape as Search
|
|
// so the UI gets identical fields back.
|
|
var detail models.ContactDetail
|
|
var campaignsJSON, categoriesJSON []byte
|
|
mainQuery := `
|
|
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,
|
|
COALESCE(
|
|
(
|
|
SELECT json_agg(json_build_object('id', cam.id, 'name', cam.name))
|
|
FROM campaign_leads cl
|
|
JOIN campaigns cam ON cam.id = cl.campaign_id
|
|
WHERE cl.contact_id = c.id AND cam.user_id = $1
|
|
), '[]'::json
|
|
) AS campaigns,
|
|
COALESCE(
|
|
(
|
|
SELECT json_agg(json_build_object('id', cat.id, 'title', cat.title, 'color', cat.color) ORDER BY cat.position ASC, cat.title ASC)
|
|
FROM contact_categories cc
|
|
JOIN categories cat ON cat.id = cc.category_id
|
|
WHERE cc.contact_id = c.id AND cat.user_id = $1
|
|
), '[]'::json
|
|
) AS categories
|
|
FROM contacts c
|
|
WHERE c.id = $2 AND c.user_id = $1
|
|
`
|
|
err := r.DB.QueryRow(ctx, mainQuery, userID, contactID).Scan(
|
|
&detail.ID, &detail.FirstName, &detail.LastName, &detail.Email,
|
|
&detail.Company, &detail.Phone, &detail.CustomFields, &detail.Subscribed,
|
|
&detail.UpdatedAt, &detail.CreatedAt, &campaignsJSON, &categoriesJSON,
|
|
)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
db.CaptureError(err, mainQuery, []any{userID, contactID}, "GetDetail main")
|
|
return nil, errx.InternalError()
|
|
}
|
|
if detail.CustomFields == nil {
|
|
detail.CustomFields = map[string]string{}
|
|
}
|
|
detail.Campaigns = []models.MiniCampaign{}
|
|
if len(campaignsJSON) > 0 {
|
|
var raw []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(campaignsJSON, &raw); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
detail.Campaigns = make([]models.MiniCampaign, len(raw))
|
|
for i, m := range raw {
|
|
detail.Campaigns[i] = models.MiniCampaign{ID: m.ID, Name: m.Name}
|
|
}
|
|
}
|
|
detail.Categories = []models.MiniCategory{}
|
|
if len(categoriesJSON) > 0 {
|
|
if err := json.Unmarshal(categoriesJSON, &detail.Categories); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
}
|
|
|
|
// 2. Engagement aggregates. campaign_contact_progress is the canonical
|
|
// sent/opened/clicked/replied/bounced ledger keyed by (campaign,
|
|
// contact, sequence). Counts come from non-null timestamp columns,
|
|
// "last X" comes from MAX() of each.
|
|
engQuery := `
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE sent_at IS NOT NULL) AS sent,
|
|
COUNT(*) FILTER (WHERE opened_at IS NOT NULL) AS opened,
|
|
COUNT(*) FILTER (WHERE clicked_at IS NOT NULL) AS clicked,
|
|
COUNT(*) FILTER (WHERE replied_at IS NOT NULL) AS replied,
|
|
COUNT(*) FILTER (WHERE bounced_at IS NOT NULL) AS bounced,
|
|
MAX(sent_at), MAX(opened_at), MAX(clicked_at), MAX(replied_at), MAX(bounced_at)
|
|
FROM campaign_contact_progress
|
|
WHERE contact_id = $1
|
|
`
|
|
if err := r.DB.QueryRow(ctx, engQuery, contactID).Scan(
|
|
&detail.Engagement.TotalSent, &detail.Engagement.TotalOpened,
|
|
&detail.Engagement.TotalClicked, &detail.Engagement.TotalReplied,
|
|
&detail.Engagement.TotalBounced,
|
|
&detail.Engagement.LastSentAt, &detail.Engagement.LastOpenedAt,
|
|
&detail.Engagement.LastClickedAt, &detail.Engagement.LastRepliedAt,
|
|
&detail.Engagement.LastBouncedAt,
|
|
); err != nil {
|
|
db.CaptureError(err, engQuery, []any{contactID}, "GetDetail engagement")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
// 3. Org-scoped extras. Only run when we have an org id.
|
|
if orgID != nil {
|
|
// Complaints don't live in campaign_contact_progress — they
|
|
// arrive via deliverability_events. Count rows of type
|
|
// "complaint" pointing at this contact (either by contact_id
|
|
// or by recipient_email fallback for older rows).
|
|
complaintQuery := `
|
|
SELECT COUNT(*)
|
|
FROM deliverability_events
|
|
WHERE organization_id = $1
|
|
AND event_type = 'complaint'
|
|
AND (contact_id = $2 OR LOWER(recipient_email) = LOWER($3))
|
|
`
|
|
if err := r.DB.QueryRow(ctx, complaintQuery, *orgID, contactID, detail.Email).Scan(
|
|
&detail.Engagement.TotalComplained,
|
|
); err != nil {
|
|
db.CaptureError(err, complaintQuery, []any{*orgID, contactID, detail.Email}, "GetDetail complaints")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
// Suppression — there's at most one row per (org, email)
|
|
// thanks to the unique constraint.
|
|
suppQuery := `
|
|
SELECT reason, source, expires_at, created_at
|
|
FROM suppressed_recipients
|
|
WHERE organization_id = $1 AND LOWER(email) = LOWER($2)
|
|
`
|
|
var s models.ContactSuppression
|
|
err := r.DB.QueryRow(ctx, suppQuery, *orgID, detail.Email).Scan(
|
|
&s.Reason, &s.Source, &s.ExpiresAt, &s.CreatedAt,
|
|
)
|
|
switch {
|
|
case err == nil:
|
|
detail.Suppression = &s
|
|
case err == pgx.ErrNoRows:
|
|
// not suppressed; leave nil
|
|
default:
|
|
db.CaptureError(err, suppQuery, []any{*orgID, detail.Email}, "GetDetail suppression")
|
|
return nil, errx.InternalError()
|
|
}
|
|
}
|
|
|
|
return &detail, nil
|
|
}
|
|
|
|
// ListSentEmails returns one row per task we sent (or attempted to
|
|
// send) to the contact, ordered by sent time DESC. Uses keyset
|
|
// pagination on (created_at, task_id) so we can scroll through the
|
|
// full history without blowing up offset.
|
|
//
|
|
// We deliberately scope by the contact's owning user via the
|
|
// campaign join — this keeps multi-tenant safety even though the
|
|
// tasks table itself has no user_id column.
|
|
func (r *contactRepository) ListSentEmails(ctx context.Context, userID, contactID uuid.UUID, limit int, beforeSentAt *time.Time, beforeTaskID *uuid.UUID) (*models.ContactSentEmailsResult, *errx.Error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
|
|
args := []any{userID, contactID}
|
|
cursorClause := ""
|
|
if beforeSentAt != nil && beforeTaskID != nil {
|
|
cursorClause = "AND (t.created_at, t.id) < ($3, $4)"
|
|
args = append(args, *beforeSentAt, *beforeTaskID)
|
|
}
|
|
args = append(args, limit+1)
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
t.id, t.status::text, t.message_id, t.created_at,
|
|
ea.id, ea.email, ea.name,
|
|
cam.id, cam.name,
|
|
seq.id, seq.name,
|
|
COALESCE(et.subject, seq.subject, '') AS subject,
|
|
ccp.opened_at, ccp.clicked_at, ccp.replied_at, ccp.bounced_at
|
|
FROM tasks t
|
|
JOIN campaign_tasks ct ON ct.task_id = t.id
|
|
LEFT JOIN email_accounts ea ON ea.id = t.email_account_id
|
|
LEFT JOIN campaigns cam ON cam.id = ct.campaign_id
|
|
LEFT JOIN sequences seq ON seq.id = ct.sequence_id
|
|
LEFT JOIN email_tasks et ON et.task_id = t.id
|
|
LEFT JOIN campaign_contact_progress ccp
|
|
ON ccp.campaign_id = ct.campaign_id
|
|
AND ccp.contact_id = ct.contact_id
|
|
AND ccp.sequence_id = ct.sequence_id
|
|
WHERE ct.contact_id = $2
|
|
AND cam.user_id = $1
|
|
%s
|
|
ORDER BY t.created_at DESC, t.id DESC
|
|
LIMIT $%d
|
|
`, cursorClause, len(args))
|
|
|
|
rows, err := r.DB.Query(ctx, query, args...)
|
|
if err != nil {
|
|
db.CaptureError(err, query, args, "ListSentEmails")
|
|
return nil, errx.InternalError()
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]models.ContactSentEmail, 0, limit)
|
|
for rows.Next() {
|
|
var e models.ContactSentEmail
|
|
if err := rows.Scan(
|
|
&e.TaskID, &e.Status, &e.MessageID, &e.SentAt,
|
|
&e.EmailAccountID, &e.EmailAccountEmail, &e.EmailAccountName,
|
|
&e.CampaignID, &e.CampaignName,
|
|
&e.SequenceID, &e.SequenceName,
|
|
&e.Subject,
|
|
&e.OpenedAt, &e.ClickedAt, &e.RepliedAt, &e.BouncedAt,
|
|
); err != nil {
|
|
db.CaptureError(err, "", nil, "ListSentEmails scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
|
|
hasMore := false
|
|
var nextCursor *uuid.UUID
|
|
if len(out) > limit {
|
|
hasMore = true
|
|
nextCursor = &out[limit].TaskID
|
|
out = out[:limit]
|
|
}
|
|
|
|
return &models.ContactSentEmailsResult{
|
|
Data: out,
|
|
Pagination: models.Pagination{
|
|
NextCursor: nextCursor,
|
|
HasMore: hasMore,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// ListTimeline merges per-contact events from several source tables
|
|
// into a single, reverse-chronological feed.
|
|
//
|
|
// Sources:
|
|
// - campaign_contact_progress → sent / opened / clicked / replied / bounced
|
|
// - reply_intents → received replies (with intent classification)
|
|
// - deliverability_events → bounce / complaint
|
|
// - suppressed_recipients → suppression added
|
|
// - contact_notes → CRM notes
|
|
//
|
|
// We pull up to (limit) candidates from each source ordered by time
|
|
// DESC, then merge-sort in Go. This avoids a 5-way UNION with
|
|
// matching column lists (each source has a different shape), and the
|
|
// per-source limit caps the read at roughly 5*limit rows.
|
|
//
|
|
// The `before` cursor is a wall-clock time; everything strictly older
|
|
// than it is eligible. The caller paginates by setting `before` to
|
|
// the oldest returned event's `At` on the next call.
|
|
func (r *contactRepository) ListTimeline(ctx context.Context, userID uuid.UUID, orgID *uuid.UUID, contactID uuid.UUID, limit int, before *time.Time) (*models.ContactTimelineResult, *errx.Error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
|
|
// We resolve the contact's email up front because some org-scoped
|
|
// joins (suppression, deliverability fallback, reply_intents) key
|
|
// off email rather than contact_id.
|
|
var contactEmail string
|
|
if err := r.DB.QueryRow(ctx,
|
|
`SELECT email FROM contacts WHERE id = $1 AND user_id = $2`,
|
|
contactID, userID,
|
|
).Scan(&contactEmail); err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil, errx.ErrNotFound
|
|
}
|
|
db.CaptureError(err, "", nil, "ListTimeline contact email")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
// "before" defaults to "now + 1 minute" so the first page picks
|
|
// up everything. Using a future bound keeps the SQL uniform — every
|
|
// query passes the same predicate.
|
|
bound := time.Now().Add(time.Minute)
|
|
if before != nil {
|
|
bound = *before
|
|
}
|
|
|
|
events := make([]models.ContactTimelineEvent, 0, limit*2)
|
|
|
|
// 1. Engagement events from campaign_contact_progress. One progress
|
|
// row can emit up to 5 events (sent/opened/clicked/replied/bounced).
|
|
progressQuery := `
|
|
SELECT
|
|
ccp.sent_at, ccp.opened_at, ccp.clicked_at, ccp.replied_at, ccp.bounced_at,
|
|
cam.id, cam.name,
|
|
seq.id, seq.name, seq.subject,
|
|
ea.id, ea.email, ea.name
|
|
FROM campaign_contact_progress ccp
|
|
JOIN campaigns cam ON cam.id = ccp.campaign_id
|
|
JOIN sequences seq ON seq.id = ccp.sequence_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT ea.id, ea.email, ea.name
|
|
FROM tasks t
|
|
JOIN campaign_tasks ct ON ct.task_id = t.id
|
|
JOIN email_accounts ea ON ea.id = t.email_account_id
|
|
WHERE ct.campaign_id = ccp.campaign_id
|
|
AND ct.contact_id = ccp.contact_id
|
|
AND ct.sequence_id = ccp.sequence_id
|
|
ORDER BY t.created_at DESC
|
|
LIMIT 1
|
|
) ea ON TRUE
|
|
WHERE ccp.contact_id = $1
|
|
AND cam.user_id = $2
|
|
AND COALESCE(ccp.sent_at, ccp.opened_at, ccp.clicked_at, ccp.replied_at, ccp.bounced_at) < $3
|
|
ORDER BY GREATEST(
|
|
COALESCE(ccp.sent_at, 'epoch'),
|
|
COALESCE(ccp.opened_at, 'epoch'),
|
|
COALESCE(ccp.clicked_at, 'epoch'),
|
|
COALESCE(ccp.replied_at, 'epoch'),
|
|
COALESCE(ccp.bounced_at, 'epoch')
|
|
) DESC
|
|
LIMIT $4
|
|
`
|
|
prows, err := r.DB.Query(ctx, progressQuery, contactID, userID, bound, limit)
|
|
if err != nil {
|
|
db.CaptureError(err, progressQuery, []any{contactID, userID, bound, limit}, "ListTimeline progress")
|
|
return nil, errx.InternalError()
|
|
}
|
|
for prows.Next() {
|
|
var sentAt, openedAt, clickedAt, repliedAt, bouncedAt *time.Time
|
|
var campID, seqID, eaID *uuid.UUID
|
|
var campName, seqName, seqSubject, eaEmail, eaName *string
|
|
if err := prows.Scan(
|
|
&sentAt, &openedAt, &clickedAt, &repliedAt, &bouncedAt,
|
|
&campID, &campName,
|
|
&seqID, &seqName, &seqSubject,
|
|
&eaID, &eaEmail, &eaName,
|
|
); err != nil {
|
|
prows.Close()
|
|
db.CaptureError(err, "", nil, "ListTimeline progress scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
baseSubject := seqSubject
|
|
makeEvent := func(t *time.Time, ty models.ContactTimelineEventType) {
|
|
if t == nil || !t.Before(bound) {
|
|
return
|
|
}
|
|
ev := models.ContactTimelineEvent{
|
|
Type: ty,
|
|
At: *t,
|
|
EmailAccountID: eaID,
|
|
EmailAccountEmail: eaEmail,
|
|
EmailAccountName: eaName,
|
|
CampaignID: campID,
|
|
CampaignName: campName,
|
|
SequenceID: seqID,
|
|
SequenceName: seqName,
|
|
}
|
|
if baseSubject != nil && *baseSubject != "" {
|
|
ev.Subject = baseSubject
|
|
}
|
|
events = append(events, ev)
|
|
}
|
|
makeEvent(sentAt, models.TimelineEmailSent)
|
|
makeEvent(openedAt, models.TimelineEmailOpened)
|
|
makeEvent(clickedAt, models.TimelineEmailClicked)
|
|
makeEvent(repliedAt, models.TimelineEmailReplied)
|
|
makeEvent(bouncedAt, models.TimelineEmailBounced)
|
|
}
|
|
prows.Close()
|
|
|
|
if orgID != nil {
|
|
// 2. Reply intents (inbound replies with classification).
|
|
replyQuery := `
|
|
SELECT ri.created_at, ri.intent, ri.campaign_id, cam.name, ri.task_id
|
|
FROM reply_intents ri
|
|
LEFT JOIN campaigns cam ON cam.id = ri.campaign_id
|
|
WHERE ri.organization_id = $1
|
|
AND LOWER(ri.contact_email) = LOWER($2)
|
|
AND ri.created_at < $3
|
|
ORDER BY ri.created_at DESC
|
|
LIMIT $4
|
|
`
|
|
rrows, err := r.DB.Query(ctx, replyQuery, *orgID, contactEmail, bound, limit)
|
|
if err != nil {
|
|
db.CaptureError(err, replyQuery, nil, "ListTimeline replies")
|
|
return nil, errx.InternalError()
|
|
}
|
|
for rrows.Next() {
|
|
var ev models.ContactTimelineEvent
|
|
var intent string
|
|
if err := rrows.Scan(&ev.At, &intent, &ev.CampaignID, &ev.CampaignName, &ev.TaskID); err != nil {
|
|
rrows.Close()
|
|
db.CaptureError(err, "", nil, "ListTimeline replies scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
ev.Type = models.TimelineReplyReceived
|
|
ev.Intent = &intent
|
|
events = append(events, ev)
|
|
}
|
|
rrows.Close()
|
|
|
|
// 3. Deliverability events (bounce / complaint / unsubscribe).
|
|
delivQuery := `
|
|
SELECT de.created_at, de.event_type, de.provider, de.reason,
|
|
de.campaign_id, cam.name, de.task_id
|
|
FROM deliverability_events de
|
|
LEFT JOIN campaigns cam ON cam.id = de.campaign_id
|
|
WHERE de.organization_id = $1
|
|
AND (de.contact_id = $2 OR LOWER(de.recipient_email) = LOWER($3))
|
|
AND de.created_at < $4
|
|
ORDER BY de.created_at DESC
|
|
LIMIT $5
|
|
`
|
|
drows, err := r.DB.Query(ctx, delivQuery, *orgID, contactID, contactEmail, bound, limit)
|
|
if err != nil {
|
|
db.CaptureError(err, delivQuery, nil, "ListTimeline deliv")
|
|
return nil, errx.InternalError()
|
|
}
|
|
for drows.Next() {
|
|
var ev models.ContactTimelineEvent
|
|
var eventType, provider, reason string
|
|
if err := drows.Scan(&ev.At, &eventType, &provider, &reason, &ev.CampaignID, &ev.CampaignName, &ev.TaskID); err != nil {
|
|
drows.Close()
|
|
db.CaptureError(err, "", nil, "ListTimeline deliv scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
ev.Type = models.TimelineDeliverability
|
|
ev.Source = &eventType
|
|
ev.Provider = &provider
|
|
if reason != "" {
|
|
ev.Reason = &reason
|
|
}
|
|
events = append(events, ev)
|
|
}
|
|
drows.Close()
|
|
|
|
// 4. Suppression — emit one event at create time. We treat
|
|
// later updates as the same event for now.
|
|
suppQuery := `
|
|
SELECT created_at, reason, source
|
|
FROM suppressed_recipients
|
|
WHERE organization_id = $1
|
|
AND LOWER(email) = LOWER($2)
|
|
AND created_at < $3
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
`
|
|
var sAt time.Time
|
|
var sReason, sSource string
|
|
if err := r.DB.QueryRow(ctx, suppQuery, *orgID, contactEmail, bound).Scan(&sAt, &sReason, &sSource); err == nil {
|
|
ev := models.ContactTimelineEvent{
|
|
Type: models.TimelineSuppressed,
|
|
At: sAt,
|
|
Source: &sSource,
|
|
}
|
|
if sReason != "" {
|
|
ev.Reason = &sReason
|
|
}
|
|
events = append(events, ev)
|
|
} else if err != pgx.ErrNoRows {
|
|
db.CaptureError(err, suppQuery, nil, "ListTimeline suppression")
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
// 5. Notes.
|
|
notesQuery := `
|
|
SELECT created_at, user_id, content
|
|
FROM contact_notes
|
|
WHERE contact_id = $1
|
|
AND organization_id = $2
|
|
AND created_at < $3
|
|
ORDER BY created_at DESC
|
|
LIMIT $4
|
|
`
|
|
nrows, err := r.DB.Query(ctx, notesQuery, contactID, *orgID, bound, limit)
|
|
if err != nil {
|
|
db.CaptureError(err, notesQuery, nil, "ListTimeline notes")
|
|
return nil, errx.InternalError()
|
|
}
|
|
for nrows.Next() {
|
|
var ev models.ContactTimelineEvent
|
|
var uid uuid.UUID
|
|
var content string
|
|
if err := nrows.Scan(&ev.At, &uid, &content); err != nil {
|
|
nrows.Close()
|
|
db.CaptureError(err, "", nil, "ListTimeline notes scan")
|
|
return nil, errx.InternalError()
|
|
}
|
|
ev.Type = models.TimelineNote
|
|
ev.UserID = &uid
|
|
ev.Content = &content
|
|
events = append(events, ev)
|
|
}
|
|
nrows.Close()
|
|
}
|
|
|
|
// Merge sort: newest first.
|
|
sort.Slice(events, func(i, j int) bool { return events[i].At.After(events[j].At) })
|
|
|
|
hasMore := false
|
|
if len(events) > limit {
|
|
hasMore = true
|
|
events = events[:limit]
|
|
}
|
|
|
|
return &models.ContactTimelineResult{
|
|
Data: events,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|