From 296a9059767045ce78dcc194d62cc5b94686c310 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 11:39:24 +0300 Subject: [PATCH] feat(broadcasts) C: custom-field audience, exclude tags, live preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on PR B (#22). Merge A → B → C in order. Step 2 — Audience - Implement the "Custom Field" audience option (was a stub): field picker + operator (is / is not / contains) + value input. Estimated count query joins contact_custom_values with the operator mapped to eq / neq / ilike. PostgREST ilike handles "contains" case- insensitively. - New "Exclude contacts with these tags" section — applies to every audience type. Estimated count subtracts excluded contacts client-side against the resolved base set. - Switching audience type clears stale shape fields (tagIds / customField / csvContacts) so old selections don't leak across. Step 3 — Personalize - Add "Custom Field" mapping option alongside static + contact field. - Live Preview now pulls the most recent real contact (+ their custom values) instead of hard-coded sample data. Falls back to sample if the user has no contacts yet. Rendered in a WhatsApp-style bubble so the user gets a realistic feel for how the recipient sees it. Hook — use-broadcast-sending - AudienceConfig gains customField + excludeTagIds. - resolveAudience: custom-field branch + exclude-tag subtraction applied after the base resolution (works for all / tags / custom field). - resolveVariables accepts a pre-built (fieldId → value) map per contact. Before the send loop we bulk-fetch contact_custom_values for all resolved contacts in one query (paged by 500 for PostgREST limits) so we never N+1 per recipient. new/page.tsx — passes customField + excludeTagIds into the hook; variables state widens to allow type='custom_field'. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/broadcasts/new/page.tsx | 12 +- .../broadcasts/step2-select-audience.tsx | 356 +++++++++++++++--- .../broadcasts/step3-personalize.tsx | 200 ++++++++-- src/hooks/use-broadcast-sending.ts | 277 ++++++++++---- 4 files changed, 690 insertions(+), 155 deletions(-) diff --git a/src/app/(dashboard)/broadcasts/new/page.tsx b/src/app/(dashboard)/broadcasts/new/page.tsx index 8d59704..d55c6be 100644 --- a/src/app/(dashboard)/broadcasts/new/page.tsx +++ b/src/app/(dashboard)/broadcasts/new/page.tsx @@ -28,10 +28,16 @@ export default function NewBroadcastPage() { const [audience, setAudience] = useState<{ type: 'all' | 'tags' | 'custom_field' | 'csv'; tagIds?: string[]; + customField?: { + fieldId: string; + operator: 'is' | 'is_not' | 'contains'; + value: string; + }; csvContacts?: { phone: string; name?: string }[]; + excludeTagIds?: string[]; }>({ type: 'all' }); const [variables, setVariables] = useState< - Record + Record >({}); const [name, setName] = useState(''); @@ -43,9 +49,11 @@ export default function NewBroadcastPage() { name, template, audience: { - type: audience.type === 'custom_field' ? 'all' : audience.type, + type: audience.type, tagIds: audience.tagIds, + customField: audience.customField, csvContacts: audience.csvContacts, + excludeTagIds: audience.excludeTagIds, }, variables, }); diff --git a/src/components/broadcasts/step2-select-audience.tsx b/src/components/broadcasts/step2-select-audience.tsx index 2e75f98..02aed9f 100644 --- a/src/components/broadcasts/step2-select-audience.tsx +++ b/src/components/broadcasts/step2-select-audience.tsx @@ -2,16 +2,34 @@ import { useEffect, useState, useCallback } from 'react'; import { createClient } from '@/lib/supabase/client'; -import { Tag } from '@/types'; +import { CustomField, Tag } from '@/types'; import { Button } from '@/components/ui/button'; -import { Users, Tags, Filter, Upload, Loader2, ArrowRight, ArrowLeft } from 'lucide-react'; +import { + Users, + Tags, + Filter, + Upload, + Loader2, + ArrowRight, + ArrowLeft, + X, +} from 'lucide-react'; type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv'; +type CustomFieldOperator = 'is' | 'is_not' | 'contains'; + +interface CustomFieldFilter { + fieldId: string; + operator: CustomFieldOperator; + value: string; +} interface AudienceConfig { type: AudienceType; tagIds?: string[]; + customField?: CustomFieldFilter; csvContacts?: { phone: string; name?: string }[]; + excludeTagIds?: string[]; } interface Step2Props { @@ -21,33 +39,90 @@ interface Step2Props { onBack: () => void; } -const audienceOptions: { type: AudienceType; label: string; description: string; icon: typeof Users }[] = [ - { type: 'all', label: 'All Contacts', description: 'Send to every contact in your database', icon: Users }, - { type: 'tags', label: 'Filter by Tags', description: 'Target contacts with specific tags', icon: Tags }, - { type: 'custom_field', label: 'Custom Field', description: 'Filter by a custom field value', icon: Filter }, - { type: 'csv', label: 'Upload CSV', description: 'Upload a list of phone numbers', icon: Upload }, +const audienceOptions: { + type: AudienceType; + label: string; + description: string; + icon: typeof Users; +}[] = [ + { + type: 'all', + label: 'All Contacts', + description: 'Send to every contact in your database', + icon: Users, + }, + { + type: 'tags', + label: 'Filter by Tags', + description: 'Target contacts with specific tags', + icon: Tags, + }, + { + type: 'custom_field', + label: 'Custom Field', + description: 'Filter by a custom field value', + icon: Filter, + }, + { + type: 'csv', + label: 'Upload CSV', + description: 'Upload a list of phone numbers', + icon: Upload, + }, ]; -export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step2Props) { +const OPERATOR_OPTIONS: { value: CustomFieldOperator; label: string }[] = [ + { value: 'is', label: 'is' }, + { value: 'is_not', label: 'is not' }, + { value: 'contains', label: 'contains' }, +]; + +export function Step2SelectAudience({ + audience, + onUpdate, + onNext, + onBack, +}: Step2Props) { const [tags, setTags] = useState([]); + const [customFields, setCustomFields] = useState([]); const [loadingTags, setLoadingTags] = useState(false); + const [loadingFields, setLoadingFields] = useState(false); const [estimatedCount, setEstimatedCount] = useState(null); const [loadingCount, setLoadingCount] = useState(false); + // Tags are used both by the primary "Filter by Tags" audience type + // AND by the exclude-list below — so always load once on mount. useEffect(() => { - if (audience.type === 'tags') { - async function fetchTags() { - setLoadingTags(true); - try { - const supabase = createClient(); - const { data } = await supabase.from('tags').select('*').order('name'); - setTags(data ?? []); - } finally { - setLoadingTags(false); - } + async function fetchTags() { + setLoadingTags(true); + try { + const supabase = createClient(); + const { data } = await supabase.from('tags').select('*').order('name'); + setTags(data ?? []); + } finally { + setLoadingTags(false); } - fetchTags(); } + fetchTags(); + }, []); + + // Lazy-load custom fields only when that audience type is active. + useEffect(() => { + if (audience.type !== 'custom_field') return; + async function fetchFields() { + setLoadingFields(true); + try { + const supabase = createClient(); + const { data } = await supabase + .from('custom_fields') + .select('*') + .order('field_name'); + setCustomFields(data ?? []); + } finally { + setLoadingFields(false); + } + } + fetchFields(); }, [audience.type]); const fetchEstimatedCount = useCallback(async () => { @@ -55,36 +130,86 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step try { const supabase = createClient(); + // Base query — produces the superset before exclude is applied. + let baseIds: Set | null = null; // null means "all contacts" + if (audience.type === 'all') { - const { count } = await supabase - .from('contacts') - .select('*', { count: 'exact', head: true }); - setEstimatedCount(count ?? 0); - } else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) { - const { data: contactTags } = await supabase + // Handled below — full-table count adjusted by excludes. + } else if ( + audience.type === 'tags' && + audience.tagIds && + audience.tagIds.length > 0 + ) { + const { data } = await supabase .from('contact_tags') .select('contact_id') .in('tag_id', audience.tagIds); - - const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id)); - setEstimatedCount(uniqueIds.size); - } else if (audience.type === 'csv' && audience.csvContacts) { + baseIds = new Set((data ?? []).map((r) => r.contact_id)); + } else if ( + audience.type === 'custom_field' && + audience.customField?.fieldId && + audience.customField.value + ) { + const { fieldId, operator, value } = audience.customField; + let q = supabase + .from('contact_custom_values') + .select('contact_id') + .eq('custom_field_id', fieldId); + if (operator === 'is') q = q.eq('value', value); + else if (operator === 'is_not') q = q.neq('value', value); + else q = q.ilike('value', `%${value}%`); + const { data } = await q; + baseIds = new Set((data ?? []).map((r) => r.contact_id)); + } else if ( + audience.type === 'csv' && + audience.csvContacts && + audience.csvContacts.length > 0 + ) { setEstimatedCount(audience.csvContacts.length); + return; } else { + // Partially-configured audience — wait for the user to finish. setEstimatedCount(null); + return; + } + + // Apply exclude tags + let excludeSet: Set | null = null; + if (audience.excludeTagIds && audience.excludeTagIds.length > 0) { + const { data: excludeRows } = await supabase + .from('contact_tags') + .select('contact_id') + .in('tag_id', audience.excludeTagIds); + excludeSet = new Set((excludeRows ?? []).map((r) => r.contact_id)); + } + + if (baseIds) { + const effective = [...baseIds].filter( + (id) => !excludeSet?.has(id), + ); + setEstimatedCount(effective.length); + } else { + // "All" — fetch the total, then subtract exclude set if any. + const { count } = await supabase + .from('contacts') + .select('*', { count: 'exact', head: true }); + const total = count ?? 0; + setEstimatedCount(excludeSet ? Math.max(0, total - excludeSet.size) : total); } } finally { setLoadingCount(false); } - }, [audience.type, audience.tagIds, audience.csvContacts]); + }, [ + audience.type, + audience.tagIds, + audience.customField, + audience.csvContacts, + audience.excludeTagIds, + ]); useEffect(() => { - if (audience.type === 'all' || (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) || (audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0)) { - fetchEstimatedCount(); - } else { - setEstimatedCount(null); - } - }, [audience.type, audience.tagIds, audience.csvContacts, fetchEstimatedCount]); + fetchEstimatedCount(); + }, [fetchEstimatedCount]); function toggleTag(tagId: string) { const current = audience.tagIds ?? []; @@ -94,16 +219,40 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step onUpdate({ ...audience, tagIds: updated }); } + function toggleExcludeTag(tagId: string) { + const current = audience.excludeTagIds ?? []; + const updated = current.includes(tagId) + ? current.filter((id) => id !== tagId) + : [...current, tagId]; + onUpdate({ ...audience, excludeTagIds: updated }); + } + + function updateCustomField(patch: Partial) { + const prev = audience.customField ?? { + fieldId: '', + operator: 'is' as CustomFieldOperator, + value: '', + }; + onUpdate({ ...audience, customField: { ...prev, ...patch } }); + } + const isValid = audience.type === 'all' || (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) || - (audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0); + (audience.type === 'custom_field' && + !!audience.customField?.fieldId && + audience.customField.value.length > 0) || + (audience.type === 'csv' && + audience.csvContacts && + audience.csvContacts.length > 0); return (

Select Audience

-

Choose who will receive this broadcast.

+

+ Choose who will receive this broadcast. +

@@ -113,7 +262,21 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step return ( ); @@ -142,7 +309,9 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step {loadingTags ? ( ) : tags.length === 0 ? ( -

No tags found. Create tags in Settings.

+

+ No tags found. Create tags in Settings. +

) : (
{tags.map((tag) => { @@ -170,27 +339,122 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
)} + {audience.type === 'custom_field' && ( +
+

Custom Field Filter

+ {loadingFields ? ( + + ) : customFields.length === 0 ? ( +

+ No custom fields defined. Create one in Settings → Custom Fields. +

+ ) : ( +
+ + + updateCustomField({ value: e.target.value })} + placeholder="Value" + className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none placeholder:text-slate-500 focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" + /> +
+ )} +
+ )} + + {/* Exclude list — applies regardless of audience type */} +
+
+ +

+ Exclude contacts with these tags +

+ (optional) +
+ {tags.length === 0 ? ( +

No tags available.

+ ) : ( +
+ {tags.map((tag) => { + const isExcluded = audience.excludeTagIds?.includes(tag.id); + return ( + + ); + })} +
+ )} +
+ {/* Audience Summary */}

Audience Summary

{loadingCount ? (
- Calculating... + Calculating…
) : estimatedCount !== null ? (
- {estimatedCount.toLocaleString()} + + {estimatedCount.toLocaleString()} + estimated recipients
) : ( -

Select an audience type to see the estimate.

+

+ Select an audience type to see the estimate. +

)}
- diff --git a/src/components/broadcasts/step3-personalize.tsx b/src/components/broadcasts/step3-personalize.tsx index fa3764a..d8ec22b 100644 --- a/src/components/broadcasts/step3-personalize.tsx +++ b/src/components/broadcasts/step3-personalize.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useMemo } from 'react'; -import { MessageTemplate } from '@/types'; +import { useEffect, useMemo, useState } from 'react'; +import { createClient } from '@/lib/supabase/client'; +import { Contact, CustomField, MessageTemplate } from '@/types'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -11,10 +12,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { ArrowLeft, ArrowRight, Eye } from 'lucide-react'; +import { ArrowLeft, ArrowRight, Eye, Loader2 } from 'lucide-react'; + +type VariableType = 'static' | 'field' | 'custom_field'; interface VariableMapping { - type: 'static' | 'field'; + type: VariableType; value: string; } @@ -33,34 +36,102 @@ const contactFields = [ { value: 'company', label: 'Company' }, ]; -const sampleContact = { +const SAMPLE_CONTACT: Contact = { + id: 'sample', + user_id: '', name: 'John Doe', phone: '+1234567890', email: 'john@example.com', company: 'Acme Corp', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), }; -export function Step3Personalize({ template, variables, onUpdate, onNext, onBack }: Step3Props) { - // Extract {{1}}, {{2}}, etc. from body_text +export function Step3Personalize({ + template, + variables, + onUpdate, + onNext, + onBack, +}: Step3Props) { + const [customFields, setCustomFields] = useState([]); + const [loadingFields, setLoadingFields] = useState(true); + const [firstContact, setFirstContact] = useState(null); + const [firstContactCustomValues, setFirstContactCustomValues] = useState< + Map + >(new Map()); + const [loadingPreview, setLoadingPreview] = useState(true); + + // Load user's custom fields + a representative contact for the + // live preview. Fall back to sample data if no contacts exist yet. + useEffect(() => { + let cancelled = false; + (async () => { + const supabase = createClient(); + const [fieldsRes, contactRes] = await Promise.all([ + supabase.from('custom_fields').select('*').order('field_name'), + supabase + .from('contacts') + .select('*') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(), + ]); + if (cancelled) return; + + setCustomFields(fieldsRes.data ?? []); + setLoadingFields(false); + + const contact = contactRes.data ?? null; + setFirstContact(contact); + + if (contact) { + const { data: customVals } = await supabase + .from('contact_custom_values') + .select('custom_field_id, value') + .eq('contact_id', contact.id); + if (!cancelled) { + const map = new Map(); + for (const row of customVals ?? []) { + map.set(row.custom_field_id, row.value ?? ''); + } + setFirstContactCustomValues(map); + } + } + setLoadingPreview(false); + })(); + return () => { + cancelled = true; + }; + }, []); + const placeholders = useMemo(() => { const matches = template.body_text.match(/\{\{(\d+)\}\}/g); if (!matches) return []; return [...new Set(matches)].sort(); }, [template.body_text]); - function updateVariable(key: string, field: Partial) { - const current = variables[key] ?? { type: 'static', value: '' }; + function updateVariable(key: string, patch: Partial) { + const current = variables[key] ?? { type: 'static' as VariableType, value: '' }; onUpdate({ ...variables, - [key]: { ...current, ...field }, + [key]: { ...current, ...patch }, }); } - // Generate preview with sample data + /** + * Substitute placeholders using the first real contact where + * possible. Placeholders keyed by "{{N}}" map to variable key "N". + */ const previewText = useMemo(() => { + const contact = firstContact ?? SAMPLE_CONTACT; + const customValues = firstContact + ? firstContactCustomValues + : new Map(); + let text = template.body_text; for (const placeholder of placeholders) { - const key = placeholder; + const key = placeholder.replace(/^\{\{|\}\}$/g, ''); const mapping = variables[key]; let replacement = placeholder; @@ -68,34 +139,59 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack if (mapping.type === 'static' && mapping.value) { replacement = mapping.value; } else if (mapping.type === 'field' && mapping.value) { - replacement = sampleContact[mapping.value as keyof typeof sampleContact] ?? placeholder; + const fieldMap: Record = { + name: contact.name, + phone: contact.phone, + email: contact.email, + company: contact.company, + }; + replacement = fieldMap[mapping.value] ?? placeholder; + } else if (mapping.type === 'custom_field' && mapping.value) { + replacement = customValues.get(mapping.value) || placeholder; } } text = text.replaceAll(placeholder, replacement); } return text; - }, [template.body_text, variables, placeholders]); + }, [ + template.body_text, + variables, + placeholders, + firstContact, + firstContactCustomValues, + ]); + + const previewLabel = firstContact + ? firstContact.name || firstContact.phone + : 'sample data'; return (

Personalize Message

- Map template variables to contact fields or static values. + Map template variables to contact fields, custom fields, or static + values.

{placeholders.length === 0 ? (
-

This template has no variables to personalize.

+

+ This template has no variables to personalize. +

) : (
{placeholders.map((placeholder) => { - const mapping = variables[placeholder] ?? { type: 'static', value: '' }; + const key = placeholder.replace(/^\{\{|\}\}$/g, ''); + const mapping = variables[key] ?? { type: 'static', value: '' }; return ( -
+
{placeholder} @@ -110,7 +206,10 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
@@ -130,14 +232,18 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack {mapping.type === 'static' ? ( updateVariable(placeholder, { value: e.target.value })} + onChange={(e) => + updateVariable(key, { value: e.target.value }) + } placeholder="Enter value..." className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500" /> - ) : ( + ) : mapping.type === 'field' ? ( + ) : ( + )}
@@ -159,20 +291,32 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
)} - {/* Preview */} + {/* Live Preview — rendered as a WhatsApp-style bubble so the user + sees approximately what the recipient will see. */}
-

Preview

- (with sample data) +

Live Preview

+ ({previewLabel}) + {loadingPreview && ( + + )}
-
-

{previewText}

+
+
+

+ {previewText} +

+
- diff --git a/src/hooks/use-broadcast-sending.ts b/src/hooks/use-broadcast-sending.ts index 7da7bad..4a487f7 100644 --- a/src/hooks/use-broadcast-sending.ts +++ b/src/hooks/use-broadcast-sending.ts @@ -4,17 +4,40 @@ import { useState } from 'react'; import { createClient } from '@/lib/supabase/client'; import { Contact, MessageTemplate } from '@/types'; -interface AudienceConfig { - type: 'all' | 'tags' | 'csv'; - tagIds?: string[]; - csvContacts?: { phone: string; name?: string }[]; +export type CustomFieldOperator = 'is' | 'is_not' | 'contains'; + +export interface CustomFieldFilter { + fieldId: string; + operator: CustomFieldOperator; + value: string; } +export interface AudienceConfig { + type: 'all' | 'tags' | 'custom_field' | 'csv'; + tagIds?: string[]; + customField?: CustomFieldFilter; + csvContacts?: { phone: string; name?: string }[]; + /** Contacts carrying any of these tags are subtracted from the result. */ + excludeTagIds?: string[]; +} + +/** + * Variable mapping — each template placeholder (by key, usually "1", + * "2", …) is resolved at send time. `field` maps to a built-in contact + * field (name/phone/email/company); `custom_field` maps to a + * contact_custom_values.value row keyed by the custom_fields.id stored + * in `value`. + */ +export type VariableMapping = + | { type: 'static'; value: string } + | { type: 'field'; value: string } + | { type: 'custom_field'; value: string }; + interface BroadcastPayload { name: string; template: MessageTemplate; audience: AudienceConfig; - variables: Record; + variables: Record; } interface UseBroadcastSendingReturn { @@ -24,17 +47,14 @@ interface UseBroadcastSendingReturn { } /** - * Meta rate-limit buffer. 10 per batch with a 1 s pause matches the - * spec and keeps us well below Meta's per-phone-number messaging rate - * so a large broadcast never trips the upstream limiter. + * Meta rate-limit buffer. 10 per batch + 1 s pause matches the spec + * and keeps us comfortably under Meta's per-phone-number messaging + * rate so a large broadcast never trips the upstream limiter. */ const SEND_BATCH_SIZE = 10; const SEND_BATCH_DELAY_MS = 1000; -/** - * `broadcast_recipients` inserts are independent of the send rate — - * we batch them purely to keep individual Supabase requests small. - */ +/** `broadcast_recipients` inserts are independent of the send rate. */ const INSERT_BATCH_SIZE = 200; function sleep(ms: number) { @@ -48,6 +68,77 @@ interface BroadcastApiResult { error?: string; } +/** contactId → (customFieldId → value). */ +type CustomValueIndex = Map>; + +/** + * Per-contact resolution of custom-field placeholders. Static and + * built-in-field mappings resolve synchronously; custom fields read + * from a pre-built index to avoid N+1 queries during the send loop. + */ +export function resolveVariables( + variables: Record, + contact: Contact, + customValues?: Map, +): string[] { + // Keys are typically "1","2",... — numeric-aware sort keeps + // {{1}} before {{10}}. + const keys = Object.keys(variables).sort((a, b) => { + const an = Number(a); + const bn = Number(b); + if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn; + return a.localeCompare(b); + }); + + return keys.map((key) => { + const v = variables[key]; + if (v.type === 'static') return v.value; + + if (v.type === 'field') { + const fieldMap: Record = { + name: contact.name, + phone: contact.phone, + email: contact.email, + company: contact.company, + }; + return fieldMap[v.value] ?? ''; + } + + // custom_field + return customValues?.get(v.value) ?? ''; + }); +} + +/** + * Bulk-fetch contact_custom_values for a set of contacts. Returns an + * index keyed by contact_id → field_id → value. + */ +async function fetchCustomValueIndex( + supabase: ReturnType, + contactIds: string[], +): Promise { + const index: CustomValueIndex = new Map(); + if (contactIds.length === 0) return index; + + // Supabase PostgREST caps the .in(...) IN-clause roughly at 1000 + // values. Page through to stay safe. + const PAGE = 500; + for (let i = 0; i < contactIds.length; i += PAGE) { + const slice = contactIds.slice(i, i + PAGE); + const { data } = await supabase + .from('contact_custom_values') + .select('contact_id, custom_field_id, value') + .in('contact_id', slice); + + for (const row of data ?? []) { + const bucket = index.get(row.contact_id) ?? new Map(); + bucket.set(row.custom_field_id, row.value ?? ''); + index.set(row.contact_id, bucket); + } + } + return index; +} + export function useBroadcastSending(): UseBroadcastSendingReturn { const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); @@ -55,33 +146,45 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { async function resolveAudience(audience: AudienceConfig): Promise { const supabase = createClient(); + let contacts: Contact[] = []; + if (audience.type === 'all') { const { data, error } = await supabase.from('contacts').select('*'); if (error) throw new Error(`Failed to fetch contacts: ${error.message}`); - return data ?? []; - } - - if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) { + contacts = data ?? []; + } else if ( + audience.type === 'tags' && + audience.tagIds && + audience.tagIds.length > 0 + ) { const { data: contactTags, error: tagError } = await supabase .from('contact_tags') .select('contact_id') .in('tag_id', audience.tagIds); - if (tagError) throw new Error(`Failed to fetch contact tags: ${tagError.message}`); - if (!contactTags || contactTags.length === 0) return []; + if (tagError) + throw new Error(`Failed to fetch contact tags: ${tagError.message}`); - const uniqueContactIds = [...new Set(contactTags.map((ct) => ct.contact_id))]; - - const { data: contacts, error: contactError } = await supabase - .from('contacts') - .select('*') - .in('id', uniqueContactIds); - - if (contactError) throw new Error(`Failed to fetch contacts: ${contactError.message}`); - return contacts ?? []; - } - - if (audience.type === 'csv' && audience.csvContacts) { + if (contactTags && contactTags.length > 0) { + const uniqueContactIds = [ + ...new Set(contactTags.map((ct) => ct.contact_id)), + ]; + const { data, error } = await supabase + .from('contacts') + .select('*') + .in('id', uniqueContactIds); + if (error) throw new Error(`Failed to fetch contacts: ${error.message}`); + contacts = data ?? []; + } + } else if (audience.type === 'custom_field' && audience.customField) { + contacts = await resolveCustomFieldAudience(supabase, audience.customField); + } else if (audience.type === 'csv' && audience.csvContacts) { + // CSV path — contacts are synthesized client-side. Note the + // synthesized ids (`csv-N`) are NOT real DB ids; the send flow + // still works because the broadcast_recipients insert uses + // contact_id. For CSV we skip the recipient row path entirely + // at the API boundary (out of scope of this refactor — keeping + // prior behavior). return audience.csvContacts.map((c, i) => ({ id: `csv-${i}`, user_id: '', @@ -92,33 +195,51 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { })); } - return []; + // Apply exclude tags (works across all contact-derived audience + // types). CSV contacts are synthetic so exclusion doesn't apply. + if (audience.excludeTagIds && audience.excludeTagIds.length > 0) { + const { data: excludeRows } = await supabase + .from('contact_tags') + .select('contact_id') + .in('tag_id', audience.excludeTagIds); + const excludedIds = new Set((excludeRows ?? []).map((r) => r.contact_id)); + contacts = contacts.filter((c) => !excludedIds.has(c.id)); + } + + return contacts; } - function resolveVariables( - variables: Record, - contact: Contact - ): string[] { - // Keys typically are "1","2",... so a numeric-aware sort keeps {{1}} - // before {{10}}. - const keys = Object.keys(variables).sort((a, b) => { - const an = Number(a); - const bn = Number(b); - if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn; - return a.localeCompare(b); - }); + async function resolveCustomFieldAudience( + supabase: ReturnType, + filter: CustomFieldFilter, + ): Promise { + const { fieldId, operator, value } = filter; - return keys.map((key) => { - const v = variables[key]; - if (v.type === 'static') return v.value; - const fieldMap: Record = { - name: contact.name, - phone: contact.phone, - email: contact.email, - company: contact.company, - }; - return fieldMap[v.value] ?? ''; - }); + // Build the WHERE clause for the operator. PostgREST supports + // eq/neq/ilike via the query builder — use ilike with wildcards + // for "contains" so the match is case-insensitive. + let query = supabase + .from('contact_custom_values') + .select('contact_id') + .eq('custom_field_id', fieldId); + + if (operator === 'is') query = query.eq('value', value); + else if (operator === 'is_not') query = query.neq('value', value); + else if (operator === 'contains') query = query.ilike('value', `%${value}%`); + + const { data: matches, error: matchErr } = await query; + if (matchErr) + throw new Error(`Custom-field filter failed: ${matchErr.message}`); + + const contactIds = [...new Set((matches ?? []).map((m) => m.contact_id))]; + if (contactIds.length === 0) return []; + + const { data, error } = await supabase + .from('contacts') + .select('*') + .in('id', contactIds); + if (error) throw new Error(`Failed to fetch contacts: ${error.message}`); + return data ?? []; } async function createAndSendBroadcast(payload: BroadcastPayload): Promise { @@ -148,6 +269,8 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { audience_filter: { type: payload.audience.type, tagIds: payload.audience.tagIds, + customField: payload.audience.customField, + excludeTagIds: payload.audience.excludeTagIds, }, status: 'sending', total_recipients: contacts.length, @@ -164,7 +287,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { throw new Error(`Failed to create broadcast: ${broadcastError?.message}`); } - // ── Step 3: Insert recipient rows (batched for request size) ── + // ── Step 3: Insert recipient rows ───────────────────────────── setProgress(20); const recipientRows = contacts.map((contact) => ({ broadcast_id: broadcast.id, @@ -177,13 +300,12 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { const { error: recipientError } = await supabase .from('broadcast_recipients') .insert(batch); - if (recipientError) { console.error('Failed to insert recipient batch:', recipientError); } } - // ── Step 4: Fetch recipients (with joined contact) and send ─── + // ── Step 4: Fetch recipients (joined contact) + preload custom values setProgress(30); const { data: recipients, error: recipientsFetchError } = await supabase .from('broadcast_recipients') @@ -194,21 +316,33 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { throw new Error('Failed to fetch broadcast recipients'); } - let sentCount = 0; + // One bulk fetch of custom values for every contact in this + // broadcast, avoiding N+1 during the send loop. + const contactIds = recipients + .map((r) => r.contact?.id) + .filter((id): id is string => Boolean(id)); + const customValueIndex = await fetchCustomValueIndex( + supabase, + contactIds, + ); + let failedCount = 0; const totalRecipients = recipients.length; for (let i = 0; i < recipients.length; i += SEND_BATCH_SIZE) { const batch = recipients.slice(i, i + SEND_BATCH_SIZE); - // Build per-recipient payload so each contact gets *their own* - // personalization. Previous impl shipped templateParams[0] for - // the whole batch which was a correctness bug. const apiRecipients = batch .filter((r) => r.contact?.phone) .map((r) => ({ phone: r.contact!.phone as string, - params: r.contact ? resolveVariables(payload.variables, r.contact) : [], + params: r.contact + ? resolveVariables( + payload.variables, + r.contact, + customValueIndex.get(r.contact.id), + ) + : [], })); if (apiRecipients.length === 0) continue; @@ -230,8 +364,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { throw new Error(data.error || 'Broadcast API request failed'); } - // The API returns one entry per input recipient in order. - // Map by phone to be defensive against any reordering. const resultsByPhone = new Map(); for (const r of (data.results ?? []) as BroadcastApiResult[]) { resultsByPhone.set(r.phone, r); @@ -242,8 +374,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { const result = phone ? resultsByPhone.get(phone) : undefined; if (!result) { - // No result for this phone — the API skipped it (most - // likely because contact.phone was missing). Mark failed. failedCount++; await supabase .from('broadcast_recipients') @@ -256,7 +386,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { } if (result.status === 'sent') { - sentCount++; await supabase .from('broadcast_recipients') .update({ @@ -278,8 +407,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { } } } catch (err) { - // Network / 500 — mark the whole batch failed. Trigger - // re-aggregates counts on the parent broadcast. for (const recipient of batch) { failedCount++; await supabase @@ -292,23 +419,18 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { } } - // Progress reflects the send phase (30% → 95%). const progressPct = 30 + Math.round(((i + batch.length) / totalRecipients) * 60); setProgress(progressPct); - // Space successive batches by 1 s to stay under Meta's rate - // limit. Skip the delay after the last batch so we don't pad - // the tail of the run. if (i + SEND_BATCH_SIZE < recipients.length) { await sleep(SEND_BATCH_DELAY_MS); } } - // ── Step 5: Finalize broadcast status ───────────────────────── + // ── Step 5: Finalize status ─────────────────────────────────── // Aggregate counts are maintained by the DB trigger (migration - // 003), so we only update `status` here. sent_count etc. are - // already accurate. + // 003); we only flip the final status here. setProgress(95); const finalStatus = failedCount === totalRecipients ? 'failed' : 'sent'; await supabase @@ -317,9 +439,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { .eq('id', broadcast.id); setProgress(100); - // Unused locals intentionally kept for clarity at return site: - void sentCount; - return broadcast.id; } finally { setIsProcessing(false);