package seed // Dev-org history: funnel progress, stats rollups, a live unified inbox, and // campaign logs. All timestamps are computed relative to NOW() so "today" // always has activity, no matter when the seed runs. Patterns ported from // internal/sandbox/history.go without coupling to that package. import ( "context" "fmt" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // devDayExpr renders "NOW() - INTERVAL 'N days'" or NULL for a negative offset. func devDayExpr(daysAgo float64) string { if daysAgo < 0 { return "NULL" } return fmt.Sprintf("NOW() - INTERVAL '%f days'", daysAgo) } type devFunnelState int const ( devQueued devFunnelState = iota devDone devProcessing devReplied devBounced ) // devClassify buckets a lead index into a funnel state (ported from the // sandbox classify): ~37% done, ~25% processing, ~12% replied, ~12% bounced, // rest queued. Index modulo keeps it stable across re-seeds. func devClassify(i int) devFunnelState { switch i % 8 { case 0, 1, 2: return devDone case 3, 4: return devProcessing case 5: return devReplied case 6: return devBounced default: return devQueued } } // devProgressStep is one campaign_contact_progress row; offsets are in days // ago, negative means "unset". type devProgressStep struct { seq uuid.UUID sent, opened, clicked float64 replied, bounced float64 } // devProgressRows builds the rows for lead i. The processing cohort's latest // send lands a few hours ago at index-staggered times, so the campaign always // shows sends from today. Queued leads get no rows. func devProgressRows(i int) []devProgressStep { j := float64(i%5) * 0.09 // per-contact jitter so timestamps don't stack switch devClassify(i) { case devDone: rows := []devProgressStep{ {seq: devSeqStep1, sent: 13.2 - j, opened: 13.1 - j, clicked: 13.0 - j, replied: -1, bounced: -1}, {seq: devSeqStep2, sent: 8.4 - j, opened: 8.3 - j, clicked: -1, replied: -1, bounced: -1}, {seq: devSeqStep3, sent: 3.1 - j, opened: 2.9 - j, clicked: -1, replied: -1, bounced: -1}, } return rows case devProcessing: // Step 2 went out today, staggered by index (~30min .. ~5.5h ago). return []devProgressStep{ {seq: devSeqStep1, sent: 4.2 - j, opened: 4.1 - j, clicked: -1, replied: -1, bounced: -1}, {seq: devSeqStep2, sent: 0.02 + float64(i)*0.01, opened: -1, clicked: -1, replied: -1, bounced: -1}, } case devReplied: return []devProgressStep{ {seq: devSeqStep1, sent: 6.5 - j, opened: 6.4 - j, clicked: 6.35 - j, replied: -1, bounced: -1}, {seq: devSeqStep2, sent: 3.2 - j, opened: 3.0 - j, clicked: -1, replied: 2.6 - j, bounced: -1}, } case devBounced: return []devProgressStep{ {seq: devSeqStep1, sent: 5.8 - j, opened: -1, clicked: -1, replied: -1, bounced: 5.75 - j}, } default: return nil } } // seedDevProgress writes the mid-flight funnel for the active campaign. func seedDevProgress(ctx context.Context, pool *pgxpool.Pool) error { for i := 0; i < devLeadCount; i++ { for _, r := range devProgressRows(i) { sql := fmt.Sprintf(` INSERT INTO campaign_contact_progress (campaign_id, contact_id, sequence_id, sent_at, opened_at, clicked_at, replied_at, bounced_at) VALUES ($1, $2, $3, %s, %s, %s, %s, %s) ON CONFLICT (campaign_id, contact_id, sequence_id) DO UPDATE SET sent_at = EXCLUDED.sent_at, opened_at = EXCLUDED.opened_at, clicked_at = EXCLUDED.clicked_at, replied_at = EXCLUDED.replied_at, bounced_at = EXCLUDED.bounced_at`, devDayExpr(r.sent), devDayExpr(r.opened), devDayExpr(r.clicked), devDayExpr(r.replied), devDayExpr(r.bounced)) if _, err := pool.Exec(ctx, sql, DevCampaignActiveID, devContactID(i), r.seq); err != nil { return fmt.Errorf("progress %d: %w", i, err) } } } return nil } // seedDevStats backfills the daily aggregates the dashboard reads: campaign // sends, per-mailbox warmup stats, and per-mailbox send counts, all 14 days // including today. func seedDevStats(ctx context.Context, pool *pgxpool.Pool) error { if _, err := pool.Exec(ctx, ` INSERT INTO campaign_daily_sends (campaign_id, send_date, emails_sent, new_leads_started) SELECT $1, d::date, 8 + ((EXTRACT(DAY FROM d)::int * 7) % 23), 1 + ((EXTRACT(DAY FROM d)::int * 3) % 4) FROM generate_series(NOW() - INTERVAL '13 days', NOW(), INTERVAL '1 day') AS d ON CONFLICT (campaign_id, send_date) DO UPDATE SET emails_sent = EXCLUDED.emails_sent, new_leads_started = EXCLUDED.new_leads_started `, DevCampaignActiveID); err != nil { return fmt.Errorf("campaign_daily_sends: %w", err) } for _, id := range []uuid.UUID{DevMailboxSendID, DevMailboxOutboundID, DevMailboxGrowthID, DevMailboxPartnersID} { // Warmup volume ramps 12 -> 38 toward today, with occasional replies. if _, err := pool.Exec(ctx, ` INSERT INTO warmup_statistics (email_account_id, date, emails_sent, emails_replied, target_volume) SELECT $1, (NOW() - (g.n || ' days')::interval)::date, LEAST(38, 12 + (13 - g.n) * 2), GREATEST(0, (13 - g.n) / 3), 40 FROM generate_series(0, 13) AS g(n) ON CONFLICT (email_account_id, date) DO UPDATE SET emails_sent = EXCLUDED.emails_sent, emails_replied = EXCLUDED.emails_replied, target_volume = EXCLUDED.target_volume `, id); err != nil { return fmt.Errorf("warmup_statistics %s: %w", id, err) } if _, err := pool.Exec(ctx, ` INSERT INTO daily_email_counts (email_account_id, date, count) SELECT $1, d::date, 4 + ((EXTRACT(DAY FROM d)::int * 5) % 13) FROM generate_series(NOW() - INTERVAL '13 days', NOW(), INTERVAL '1 day') AS d ON CONFLICT (email_account_id, date) DO UPDATE SET count = EXCLUDED.count `, id); err != nil { return fmt.Errorf("daily_email_counts %s: %w", id, err) } } return nil } // devUniboxMsg is one message in the dev user's unified inbox. Every emailID // is a dev-org mailbox so the web ReplyComposer can resolve the sender // (the API's email_id becomes account_id in the client). type devUniboxMsg struct { emailID uuid.UUID threadID string messageID string parentID string uid int from string to string subject string snippet string seen bool daysAgo float64 } // seedDevUnibox seeds 13 messages across 8 threads (reply chains, an // objection, OOO, a bounce, a warmup-style exchange) plus thread labels. // The two seedBaseline messages remain as older history. func seedDevUnibox(ctx context.Context, pool *pgxpool.Pool) error { // Reuse the baseline uid_validity values (101/201) for the first two // mailboxes so no duplicate INBOX folder rows appear. mailboxes := []struct { emailID uuid.UUID uidValidity int }{ {DevMailboxSendID, 101}, {DevMailboxOutboundID, 201}, {DevMailboxGrowthID, 301}, {DevMailboxPartnersID, 401}, } for _, mb := range mailboxes { if _, err := pool.Exec(ctx, ` INSERT INTO unibox_mailboxes (email_id, uid_validity, mailbox, attributes, highestmodseq, updated_at) VALUES ($1, $2, 'INBOX', ARRAY['\HasNoChildren'], 1, NOW()) ON CONFLICT (email_id, uid_validity) DO UPDATE SET mailbox = EXCLUDED.mailbox, updated_at = NOW() `, mb.emailID, mb.uidValidity); err != nil { return fmt.Errorf("unibox mailbox %s: %w", mb.emailID, err) } } sender := "Dev Sender " outbound := "Dev Outbound " growth := "Dev Growth " partners := "Dev Partners " msgs := []devUniboxMsg{ // Positive pricing thread with Mira (reply chain, 3 messages). {DevMailboxSendID, "dev-thread-mira", "", "", 111, "Mira Kovacs ", sender, "Re: Quick question about outbound at Lumina Labs", "This is timely. We are re-evaluating our outbound stack this quarter. What does the team plan cost for 8 seats?", true, 2.6}, {DevMailboxSendID, "dev-thread-mira", "", "", 112, sender, "Mira Kovacs ", "Re: Quick question about outbound at Lumina Labs", "Great to hear. For 8 seats the team plan lands at $340/mo, and I attached the placement report we run for every mailbox.", true, 2.4}, {DevMailboxSendID, "dev-thread-mira", "", "", 113, "Mira Kovacs ", sender, "Re: Quick question about outbound at Lumina Labs", "The report is exactly what our COO wants to see. Can you hold that price until the end of the month?", false, 0.2}, // Objection thread with Jonas (2 messages). {DevMailboxOutboundID, "dev-thread-jonas", "", "", 211, "Jonas Weber ", outbound, "Re: Quick question about outbound at Fieldstone", "We already route everything through our CRM's sending add-on. Not sure what you would add on top of that.", true, 3.4}, {DevMailboxOutboundID, "dev-thread-jonas", "", "", 212, outbound, "Jonas Weber ", "Re: Quick question about outbound at Fieldstone", "Fair question. CRM add-ons send fine but rarely warm or monitor placement. Happy to run a free placement test on one mailbox so you can compare.", true, 3.2}, // Out of office (1 message). {DevMailboxGrowthID, "dev-thread-talia-ooo", "", "", 311, "Talia Reyes ", growth, "Automatic reply: Quick question about outbound at Arcadia Metrics", "I am out of office until next Monday with limited email access. For urgent matters contact ops@arcadia-metrics.test.", true, 4.8}, // Bounce (1 message). {DevMailboxOutboundID, "dev-thread-omar-bounce", "", "", 213, "Mail Delivery Subsystem ", outbound, "Delivery Status Notification (Failure)", "The message to omar.said@bluepeak.test could not be delivered. The recipient address was rejected by the server.", false, 5.7}, // Warmup-style exchange (2 messages). {DevMailboxPartnersID, "dev-thread-warmup", "", "", 411, "Liam Farrell ", partners, "Notes from the offsite", "Thanks for sending those over. The planning doc matches what we discussed, I will pass it along to the team.", true, 7.5}, {DevMailboxPartnersID, "dev-thread-warmup", "", "", 412, partners, "Liam Farrell ", "Re: Notes from the offsite", "Sounds good. Let me know if anything in the doc needs another pass before Friday.", true, 7.3}, // Meeting thread with Nadia (2 messages). {DevMailboxSendID, "dev-thread-nadia", "", "", 114, "Nadia Osei ", sender, "Re: Quick question about outbound at Cinderworks", "Thursday at 2pm works for a walkthrough. Send the invite and I will loop in our ops lead.", false, 1.6}, {DevMailboxSendID, "dev-thread-nadia", "", "", 115, sender, "Nadia Osei ", "Re: Quick question about outbound at Cinderworks", "Invite sent for Thursday 2pm. I included the pilot scope so your ops lead can skim it beforehand.", true, 1.5}, // Not interested (1 message). {DevMailboxGrowthID, "dev-thread-ruben", "", "", 312, "Ruben Ortiz ", growth, "Re: Closing the loop", "Not a fit for us right now, please take me off this list. Thanks for keeping it short.", true, 6.4}, // Fresh question, arrived today (1 message). {DevMailboxSendID, "dev-thread-chloe", "", "", 116, "Chloe Bennett ", sender, "Re: Quick question about outbound at Emberly", "Quick question before we talk: can partners see placement reports for the client mailboxes they manage?", false, 0.1}, } for i, m := range msgs { sql := fmt.Sprintf(` INSERT INTO unibox_emails ( id, user_id, email_id, mailbox, thread_id, message_id, gmail_id, parent_id, uid, mod_seq, flags, bcc, cc, from_addr, in_reply_to, reply_to, to_addr, subject, size, internal_date, sent_date, snippet, seen, created_at, updated_at ) VALUES ( $1, $2, $3, 0, $4, $5, '', $6, $7, 1, $8, '{}', '{}', $9, '{}', '{}', $10, $11, $12, %s, %s, $13, $14, NOW(), NOW() ) ON CONFLICT (id) DO UPDATE SET email_id = EXCLUDED.email_id, thread_id = EXCLUDED.thread_id, message_id = EXCLUDED.message_id, parent_id = EXCLUDED.parent_id, uid = EXCLUDED.uid, flags = EXCLUDED.flags, from_addr = EXCLUDED.from_addr, to_addr = EXCLUDED.to_addr, subject = EXCLUDED.subject, size = EXCLUDED.size, internal_date = EXCLUDED.internal_date, sent_date = EXCLUDED.sent_date, snippet = EXCLUDED.snippet, seen = EXCLUDED.seen, updated_at = NOW()`, devDayExpr(m.daysAgo), devDayExpr(m.daysAgo)) flags := []string{} if m.seen { flags = []string{"\\Seen"} } if _, err := pool.Exec(ctx, sql, devEntityID("10", i+1), DevUserID, m.emailID, m.threadID, m.messageID, m.parentID, m.uid, flags, []string{m.from}, []string{m.to}, m.subject, int64(len(m.snippet)), m.snippet, m.seen); err != nil { return fmt.Errorf("unibox %s: %w", m.messageID, err) } } labels := []struct { threadID string category uuid.UUID }{ {"dev-thread-mira", devCategoryLead}, {"dev-thread-nadia", devCategoryLead}, {"dev-thread-chloe", devCategoryCustomer}, {"dev-thread-jonas", devCategoryChurn}, } for _, l := range labels { if _, err := pool.Exec(ctx, ` INSERT INTO unibox_thread_labels (user_id, thread_id, category_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING `, DevUserID, l.threadID, l.category); err != nil { return fmt.Errorf("thread label %s: %w", l.threadID, err) } } return nil } // seedDevCampaignLogs writes the active campaign's activity feed with fixed // IDs so re-seeding never duplicates entries. func seedDevCampaignLogs(ctx context.Context, pool *pgxpool.Pool) error { logs := []struct { campaign uuid.UUID event string message string daysAgo float64 }{ {DevCampaignActiveID, "started", "Campaign moved to active", 13.95}, {DevCampaignActiveID, "contacts_added", "24 contacts added from the RevOps list", 13.9}, {DevCampaignActiveID, "sequence_added", "Step 3 - break-up added", 10.0}, {DevCampaignActiveID, "contact_bounced", "omar.said@bluepeak.test hard bounced and was suppressed", 5.7}, {DevCampaignActiveID, "contact_replied", "mira.kovacs@lumina-labs.test replied", 2.6}, {DevCampaignActiveID, "contact_replied", "chloe.bennett@emberly.test replied", 0.1}, {DevCampaignDraftID, "created", "Campaign created", 2.0}, } for i, l := range logs { sql := fmt.Sprintf(` INSERT INTO campaign_logs (id, campaign_id, event_type, message, metadata, created_at) VALUES ($1, $2, $3, $4, '{}'::jsonb, %s) ON CONFLICT (id) DO NOTHING`, devDayExpr(l.daysAgo)) if _, err := pool.Exec(ctx, sql, devEntityID("12", i+1), l.campaign, l.event, l.message); err != nil { return fmt.Errorf("log %d: %w", i, err) } } return nil }