mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
abd46bcb2b
- HostingerReachPage (replaces MailchimpPage): connect Reach via reach-connection, per-association segment sync via reach-sync - ARC Applications: Buildium import review/matching updates - buildium-import-stage/apply: latest staging + apply changes (already deployed to Supabase) - migrations: hostinger_reach_integration + arc_finalized_lock service role (already applied to live DB) - CI: note that deployment is VPS-side polling (auto-deploy.sh cron) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
155 lines
6.8 KiB
TypeScript
155 lines
6.8 KiB
TypeScript
// 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<string, unknown> = {
|
|
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);
|
|
}
|
|
});
|