// Hostinger Reach — sync an association's active owners into Reach as contacts, and ensure a // per-association segment (matched on the contact `note` marker) exists so the HOA is targetable. import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", }; const REACH_BASE = "https://developers.hostinger.com/api/reach/v1"; // Best-effort E.164 normalization; returns null if it can't be made valid (Reach rejects bad phones). function toE164(raw: string | null | undefined): string | null { if (!raw) return null; const trimmed = String(raw).trim(); const hasPlus = trimmed.startsWith("+"); const digits = trimmed.replace(/[^0-9]/g, ""); if (digits.length < 7 || digits.length > 15) return null; if (hasPlus) return `+${digits}`; if (digits.length === 10) return `+1${digits}`; // assume US 10-digit if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`; return null; // ambiguous → omit rather than risk an API error } Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { ...corsHeaders, "Content-Type": "application/json" } }); try { const authHeader = req.headers.get("Authorization"); if (!authHeader) return json({ error: "Unauthorized" }, 401); const userClient = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, { global: { headers: { Authorization: authHeader } } }, ); const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!); const { data: { user } } = await userClient.auth.getUser(); if (!user) return json({ error: "Unauthorized" }, 401); const { data: roles } = await admin.from("user_roles").select("role").eq("user_id", user.id); if (!(roles || []).some((r: any) => r.role === "admin")) return json({ error: "Admin only" }, 403); const { association_id } = await req.json().catch(() => ({})); if (!association_id) return json({ error: "Missing association_id" }, 400); const { data: cfg } = await admin .from("hostinger_reach_config").select("api_token").order("updated_at", { ascending: false }).limit(1).maybeSingle(); const token = cfg?.api_token; if (!token) return json({ error: "No Hostinger Reach API token configured." }, 400); const authHeaders = { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" }; const { data: assoc } = await admin.from("associations").select("name").eq("id", association_id).maybeSingle(); const assocName = assoc?.name || `Association ${String(association_id).slice(0, 8)}`; const marker = `acmcc-assoc:${association_id}`; const { data: owners, error: ownErr } = await admin .from("owners") .select("first_name, last_name, email, phone") .eq("association_id", association_id) .eq("status", "active") .not("email", "is", null); if (ownErr) return json({ error: ownErr.message }, 500); const valid = (owners || []).filter((o: any) => o.email && String(o.email).includes("@")); let succeeded = 0, failed = 0; let lastError: string | null = null; for (const o of valid) { const payload: Record = { email: String(o.email).trim(), name: o.first_name || undefined, surname: o.last_name || undefined, note: marker, }; const phone = toE164(o.phone); if (phone) payload.phone = phone; try { const r = await fetch(`${REACH_BASE}/contacts`, { method: "POST", headers: authHeaders, body: JSON.stringify(payload), }); if (r.ok) { succeeded++; continue; } const txt = await r.text(); // A contact that already exists is success for sync purposes. if (r.status === 409 || /exist|already|duplicate/i.test(txt)) { succeeded++; continue; } failed++; lastError = `${r.status}: ${txt.slice(0, 200)}`; } catch (e) { failed++; lastError = (e as Error).message; } } // Ensure a segment for this association. let segmentUuid: string | null = null; let segmentName: string | null = null; try { const { data: existing } = await admin .from("hostinger_reach_segments").select("segment_uuid, segment_name").eq("association_id", association_id).maybeSingle(); segmentUuid = existing?.segment_uuid || null; segmentName = existing?.segment_name || null; if (!segmentUuid) { // Look for an existing segment by name before creating a new one. const listRes = await fetch(`${REACH_BASE}/segmentation/segments`, { headers: authHeaders }); if (listRes.ok) { const parsed = await listRes.json().catch(() => ({})); const list: any[] = Array.isArray(parsed) ? parsed : (parsed.data ?? parsed.segments ?? []); const match = list.find((s: any) => (s.name || s.title) === assocName); if (match) { segmentUuid = match.uuid || match.id || null; segmentName = match.name || assocName; } } } if (!segmentUuid) { const createRes = await fetch(`${REACH_BASE}/segmentation/segments`, { method: "POST", headers: authHeaders, body: JSON.stringify({ name: assocName, logic: "AND", conditions: [{ attribute: "note", operator: "equals", value: marker }], }), }); if (createRes.ok) { const created = await createRes.json().catch(() => ({})); const seg = created.data ?? created.segment ?? created; segmentUuid = seg?.uuid || seg?.id || null; segmentName = seg?.name || assocName; } else { const txt = await createRes.text(); lastError = lastError || `segment create ${createRes.status}: ${txt.slice(0, 200)}`; } } } catch (e) { lastError = lastError || `segment: ${(e as Error).message}`; } const status = valid.length === 0 ? "success" : (failed === 0 ? "success" : (succeeded === 0 ? "failed" : "partial")); await admin.from("hostinger_reach_segments").upsert({ association_id, segment_uuid: segmentUuid, segment_name: segmentName || assocName, last_sync_at: new Date().toISOString(), last_sync_status: status, last_sync_count: succeeded, last_sync_error: lastError, }, { onConflict: "association_id" }); return json({ success: true, total: valid.length, succeeded, failed, segment_name: segmentName || assocName, error: lastError }); } catch (err) { return json({ error: (err as Error).message }, 500); } });