@@ -543,15 +438,9 @@ export default function ComposeEmailPage() {
{ setRecipients(""); setCc(""); setBcc(""); setShowCc(false); setShowBcc(false); setSubject(""); setContent(""); setAttachments([]); setTemplateId("none"); }}>Cancel
- {sendMethod === "mailchimp" && (
- handleSendMailchimp(false)} disabled={sending || uploading}>
- {sending ? : }
- Save as Draft in Mailchimp
-
- )}
- {sending || uploading ? : (sendMethod === "mailchimp" ? : )}
- {sending || uploading ? "Sending..." : (sendMethod === "mailchimp" ? "Send via Mailchimp" : "Send Email")}
+ {sending || uploading ? : }
+ {sending || uploading ? "Sending..." : "Send Email"}
diff --git a/src/pages/HostingerReachPage.tsx b/src/pages/HostingerReachPage.tsx
new file mode 100644
index 0000000..573a9b5
--- /dev/null
+++ b/src/pages/HostingerReachPage.tsx
@@ -0,0 +1,266 @@
+import React, { useEffect, useState } from "react";
+import { supabase } from "@/integrations/supabase/client";
+import { useToast } from "@/hooks/use-toast";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Badge } from "@/components/ui/badge";
+import { Separator } from "@/components/ui/separator";
+import { Loader2, Mail, RefreshCw, CheckCircle2, AlertCircle, Plug, Send, ExternalLink, Users } from "lucide-react";
+import { format } from "date-fns";
+
+interface Association { id: string; name: string; }
+interface SegmentRow {
+ association_id: string;
+ segment_uuid: string | null;
+ segment_name: string | null;
+ last_sync_at: string | null;
+ last_sync_status: string | null;
+ last_sync_count: number | null;
+ last_sync_error: string | null;
+}
+
+const REACH_DASHBOARD_URL = "https://hpanel.hostinger.com/reach";
+
+export default function HostingerReachPage() {
+ const { toast } = useToast();
+ const [associations, setAssociations] = useState
([]);
+ const [selectedAssoc, setSelectedAssoc] = useState("");
+
+ // Global token config
+ const [configId, setConfigId] = useState(null);
+ const [apiToken, setApiToken] = useState("");
+ const [testing, setTesting] = useState(false);
+ const [savingToken, setSavingToken] = useState(false);
+
+ // Per-association sync state
+ const [ownerCount, setOwnerCount] = useState(0);
+ const [segment, setSegment] = useState(null);
+ const [syncing, setSyncing] = useState(false);
+
+ useEffect(() => {
+ (async () => {
+ const { data } = await supabase.from("associations").select("id, name").eq("status", "active").order("name");
+ setAssociations(data || []);
+ if (data && data.length > 0) setSelectedAssoc(data[0].id);
+ const { data: cfg } = await supabase
+ .from("hostinger_reach_config").select("id").order("updated_at", { ascending: false }).limit(1).maybeSingle();
+ setConfigId(cfg?.id ?? null);
+ })();
+ }, []);
+
+ useEffect(() => {
+ if (!selectedAssoc) return;
+ loadOwnerCount(selectedAssoc);
+ loadSegment(selectedAssoc);
+ }, [selectedAssoc]);
+
+ const loadOwnerCount = async (id: string) => {
+ const { count } = await supabase.from("owners").select("id", { count: "exact", head: true })
+ .eq("association_id", id).eq("status", "active").not("email", "is", null);
+ setOwnerCount(count || 0);
+ };
+
+ const loadSegment = async (id: string) => {
+ const { data } = await supabase.from("hostinger_reach_segments").select("*").eq("association_id", id).maybeSingle();
+ setSegment((data as SegmentRow) ?? null);
+ };
+
+ const handleTest = async () => {
+ setTesting(true);
+ try {
+ const { data, error } = await supabase.functions.invoke("reach-connection", {
+ body: apiToken.trim() ? { api_token: apiToken.trim() } : {},
+ });
+ if (error) throw error;
+ if (!data.success) throw new Error(data.error || "Connection failed");
+ toast({ title: "Connected", description: `Reach reachable — ${data.segment_count} segment(s).` });
+ } catch (e: any) {
+ toast({ variant: "destructive", title: "Connection failed", description: e.message });
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ const handleSaveToken = async () => {
+ if (!apiToken.trim()) {
+ toast({ variant: "destructive", title: "Missing token", description: "Paste your Hostinger Reach API token." });
+ return;
+ }
+ setSavingToken(true);
+ try {
+ const { data: { user } } = await supabase.auth.getUser();
+ const payload = { api_token: apiToken.trim(), created_by: user?.id };
+ const { error } = configId
+ ? await supabase.from("hostinger_reach_config").update(payload).eq("id", configId)
+ : await supabase.from("hostinger_reach_config").insert(payload);
+ if (error) throw error;
+ const { data: cfg } = await supabase
+ .from("hostinger_reach_config").select("id").order("updated_at", { ascending: false }).limit(1).maybeSingle();
+ setConfigId(cfg?.id ?? null);
+ setApiToken("");
+ toast({ title: "Saved", description: "Hostinger Reach API token stored." });
+ } catch (e: any) {
+ toast({ variant: "destructive", title: "Save failed", description: e.message });
+ } finally {
+ setSavingToken(false);
+ }
+ };
+
+ const handleSync = async () => {
+ if (!configId) {
+ toast({ variant: "destructive", title: "Not connected", description: "Save your Reach API token first." });
+ return;
+ }
+ setSyncing(true);
+ try {
+ const { data, error } = await supabase.functions.invoke("reach-sync", { body: { association_id: selectedAssoc } });
+ if (error) throw error;
+ if (!data.success) throw new Error(data.error || "Sync failed");
+ toast({
+ title: "Sync complete",
+ description: `${data.succeeded} synced${data.failed > 0 ? `, ${data.failed} failed` : ""} → segment "${data.segment_name}".`,
+ });
+ loadSegment(selectedAssoc);
+ } catch (e: any) {
+ toast({ variant: "destructive", title: "Sync failed", description: e.message });
+ } finally {
+ setSyncing(false);
+ }
+ };
+
+ const assocName = associations.find((a) => a.id === selectedAssoc)?.name;
+
+ return (
+
+
+
+
+
Hostinger Reach
+
+ Push each association's owners into Hostinger Reach as contacts, organized into a per-association segment. Compose and send campaigns from the Reach dashboard.
+
+
+
+
+ {/* Connection */}
+
+
+
+
API Connection
+
+ One account-wide token. Create it in hPanel → Reach → API. It's stored securely and never shown again after saving.
+
+
+ {configId && (
+
+ Connected
+
+ )}
+
+
+
+ Hostinger Reach API Token
+ setApiToken(e.target.value)}
+ placeholder={configId ? "•••••••• (saved) — paste a new token to replace" : "Paste your API token"}
+ />
+
+
+
+ {testing ? : }
+ Test Connection
+
+
+ {savingToken ? : null}
+ {configId ? "Replace Token" : "Save Token"}
+
+
+
+
+
+ {/* Sync */}
+
+
+ Sync Owners to Reach
+ Each association's active owners (with email) are pushed as contacts and grouped into their own Reach segment.
+
+
+
+ Association
+
+
+
+ {associations.map((a) => {a.name} )}
+
+
+
+
+
+
+
+ {ownerCount} active owner(s) with email
+ {segment?.segment_name && <> · segment: {segment.segment_name} >}
+
+
Re-running is safe — existing contacts aren't duplicated.
+
+
+ {syncing ? : }
+ Sync Now
+
+
+
+ {segment?.last_sync_at && (
+ <>
+
+
+
Last sync
+
{format(new Date(segment.last_sync_at), "PPpp")}
+
+ {segment.last_sync_status === "success" && (
+
+ Success
+
+ )}
+ {segment.last_sync_status === "partial" && (
+
+ Partial
+
+ )}
+ {segment.last_sync_status === "failed" && (
+
Failed
+ )}
+
{segment.last_sync_count ?? 0} synced
+
+ {segment.last_sync_error && (
+
{segment.last_sync_error}
+ )}
+
+ >
+ )}
+
+
+
+ {/* Campaigns live in Reach */}
+
+
+ Send Campaigns in Reach
+
+ Hostinger Reach campaigns are composed and sent from the Reach dashboard. Sync owners here, then target the
+ {assocName ? <> {assocName} > : " association"} segment in Reach.
+
+
+
+
+ Open Hostinger Reach
+
+
+
+
+ );
+}
diff --git a/src/pages/MailchimpPage.tsx b/src/pages/MailchimpPage.tsx
deleted file mode 100644
index e64add9..0000000
--- a/src/pages/MailchimpPage.tsx
+++ /dev/null
@@ -1,530 +0,0 @@
-import React, { useEffect, useState } from "react";
-import { supabase } from "@/integrations/supabase/client";
-import { useToast } from "@/hooks/use-toast";
-import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Badge } from "@/components/ui/badge";
-import { Separator } from "@/components/ui/separator";
-import { Loader2, Mail, RefreshCw, CheckCircle2, AlertCircle, Plus, Send, ExternalLink } from "lucide-react";
-import { Textarea } from "@/components/ui/textarea";
-import { format } from "date-fns";
-
-interface Association { id: string; name: string; }
-interface MailchimpConfig {
- id: string;
- association_id: string;
- api_key: string;
- server_prefix: string;
- audience_id: string | null;
- audience_name: string | null;
- last_sync_at: string | null;
- last_sync_status: string | null;
- last_sync_count: number | null;
- last_sync_error: string | null;
-}
-interface Audience { id: string; name: string; stats?: { member_count?: number } }
-
-export default function MailchimpPage() {
- const { toast } = useToast();
- const [associations, setAssociations] = useState([]);
- const [selectedAssoc, setSelectedAssoc] = useState("");
- const [config, setConfig] = useState(null);
- const [loading, setLoading] = useState(false);
- const [syncing, setSyncing] = useState(false);
-
- // Form state
- const [apiKey, setApiKey] = useState("");
- const [serverPrefix, setServerPrefix] = useState("");
- const [audiences, setAudiences] = useState([]);
- const [selectedAudience, setSelectedAudience] = useState("");
- const [newAudienceName, setNewAudienceName] = useState("");
- const [showCreate, setShowCreate] = useState(false);
- const [ownerCount, setOwnerCount] = useState(0);
-
- // Campaign state
- const [campaignSubject, setCampaignSubject] = useState("");
- const [campaignFromName, setCampaignFromName] = useState("");
- const [campaignReplyTo, setCampaignReplyTo] = useState("");
- const [campaignHtml, setCampaignHtml] = useState("");
- const [sendingCampaign, setSendingCampaign] = useState(false);
- const [lastCampaign, setLastCampaign] = useState<{ web_id?: number; sent: boolean } | null>(null);
-
- useEffect(() => {
- (async () => {
- const { data } = await supabase.from("associations").select("id, name").eq("status", "active").order("name");
- setAssociations(data || []);
- if (data && data.length > 0) setSelectedAssoc(data[0].id);
- })();
- }, []);
-
- useEffect(() => {
- if (!selectedAssoc) return;
- loadConfig(selectedAssoc);
- loadOwnerCount(selectedAssoc);
- }, [selectedAssoc]);
-
- const loadOwnerCount = async (id: string) => {
- const { count } = await supabase.from("owners").select("id", { count: "exact", head: true })
- .eq("association_id", id).eq("status", "active").not("email", "is", null);
- setOwnerCount(count || 0);
- };
-
- const loadConfig = async (id: string) => {
- setLoading(true);
- setConfig(null);
- setApiKey(""); setServerPrefix(""); setAudiences([]); setSelectedAudience(""); setShowCreate(false);
- try {
- const { data, error } = await supabase.from("mailchimp_configs").select("*").eq("association_id", id).maybeSingle();
- if (error) throw error;
- if (data) {
- setConfig(data as any);
- setApiKey(data.api_key);
- setServerPrefix(data.server_prefix);
- setSelectedAudience(data.audience_id || "");
- }
- } catch (e: any) {
- toast({ variant: "destructive", title: "Load error", description: e.message });
- } finally {
- setLoading(false);
- }
- };
-
- const detectPrefix = (key: string) => {
- const parts = key.split("-");
- return parts.length > 1 ? parts[parts.length - 1] : "";
- };
-
- const handleApiKeyChange = (val: string) => {
- setApiKey(val);
- if (!serverPrefix) {
- const detected = detectPrefix(val.trim());
- if (detected) setServerPrefix(detected);
- }
- };
-
- const handleTestAndLoad = async () => {
- if (!apiKey || !serverPrefix) {
- toast({ variant: "destructive", title: "Missing info", description: "Enter API key and server prefix." });
- return;
- }
- setLoading(true);
- try {
- const { data, error } = await supabase.functions.invoke("mailchimp-audiences", {
- body: { action: "list", api_key: apiKey, server_prefix: serverPrefix },
- });
- if (error) throw error;
- if (!data.success) throw new Error(data.error || "Failed to connect");
- setAudiences(data.lists || []);
- toast({ title: "Connected", description: `Found ${data.lists?.length || 0} audience(s).` });
- } catch (e: any) {
- toast({ variant: "destructive", title: "Mailchimp connection failed", description: e.message });
- } finally {
- setLoading(false);
- }
- };
-
- const handleCreateAudience = async () => {
- const assoc = associations.find(a => a.id === selectedAssoc);
- const name = newAudienceName.trim() || assoc?.name || "HOA Owners";
- setLoading(true);
- try {
- const { data, error } = await supabase.functions.invoke("mailchimp-audiences", {
- body: {
- action: "create",
- api_key: apiKey,
- server_prefix: serverPrefix,
- audience_name: name,
- association_name: assoc?.name,
- },
- });
- if (error) throw error;
- if (!data.success) throw new Error(data.error || "Create failed");
- toast({ title: "Audience created", description: data.list?.name });
- setSelectedAudience(data.list.id);
- await handleTestAndLoad();
- setShowCreate(false);
- setNewAudienceName("");
- } catch (e: any) {
- toast({ variant: "destructive", title: "Create failed", description: e.message });
- } finally {
- setLoading(false);
- }
- };
-
- const handleSave = async () => {
- if (!selectedAssoc || !apiKey || !serverPrefix) {
- toast({ variant: "destructive", title: "Missing fields", description: "API key, server prefix and audience are required." });
- return;
- }
- setLoading(true);
- try {
- const audience = audiences.find(a => a.id === selectedAudience);
- const { data: { user } } = await supabase.auth.getUser();
- const payload = {
- association_id: selectedAssoc,
- api_key: apiKey,
- server_prefix: serverPrefix,
- audience_id: selectedAudience || null,
- audience_name: audience?.name || null,
- created_by: user?.id,
- };
- const { error } = config
- ? await supabase.from("mailchimp_configs").update(payload).eq("id", config.id)
- : await supabase.from("mailchimp_configs").insert(payload);
- if (error) throw error;
- toast({ title: "Saved", description: "Mailchimp configuration updated." });
- loadConfig(selectedAssoc);
- } catch (e: any) {
- toast({ variant: "destructive", title: "Save failed", description: e.message });
- } finally {
- setLoading(false);
- }
- };
-
- const handleSync = async () => {
- if (!config?.audience_id) {
- toast({ variant: "destructive", title: "Not configured", description: "Save Mailchimp config with an audience first." });
- return;
- }
- setSyncing(true);
- try {
- const { data, error } = await supabase.functions.invoke("mailchimp-sync", {
- body: { association_id: selectedAssoc },
- });
- if (error) throw error;
- if (!data.success) throw new Error(data.error || "Sync failed");
- toast({
- title: "Sync complete",
- description: `${data.succeeded} synced${data.failed > 0 ? `, ${data.failed} failed` : ""}.`,
- });
- loadConfig(selectedAssoc);
- } catch (e: any) {
- toast({ variant: "destructive", title: "Sync failed", description: e.message });
- } finally {
- setSyncing(false);
- }
- };
-
- const handleDisconnect = async () => {
- if (!config) return;
- if (!confirm("Disconnect Mailchimp for this association? Owners already in Mailchimp won't be removed.")) return;
- setLoading(true);
- try {
- const { error } = await supabase.from("mailchimp_configs").delete().eq("id", config.id);
- if (error) throw error;
- toast({ title: "Disconnected" });
- loadConfig(selectedAssoc);
- } catch (e: any) {
- toast({ variant: "destructive", title: "Failed", description: e.message });
- } finally {
- setLoading(false);
- }
- };
-
- const handleSendCampaign = async (sendNow: boolean) => {
- if (!campaignSubject || !campaignFromName || !campaignReplyTo || !campaignHtml) {
- toast({ variant: "destructive", title: "Missing fields", description: "Subject, from name, reply-to and content are required." });
- return;
- }
- setSendingCampaign(true);
- setLastCampaign(null);
- try {
- const { data, error } = await supabase.functions.invoke("mailchimp-campaign", {
- body: {
- association_id: selectedAssoc,
- subject: campaignSubject,
- from_name: campaignFromName,
- reply_to: campaignReplyTo,
- html: campaignHtml,
- send_now: sendNow,
- },
- });
- if (error) throw error;
- if (!data.success) throw new Error(data.error || "Campaign failed");
- setLastCampaign({ web_id: data.web_id, sent: data.sent });
- toast({
- title: sendNow ? "Campaign sent!" : "Draft created",
- description: sendNow
- ? "Mailchimp is delivering your campaign to the audience."
- : "Saved as a draft in Mailchimp — review and send from there.",
- });
- if (sendNow) {
- setCampaignSubject(""); setCampaignHtml("");
- }
- } catch (e: any) {
- toast({ variant: "destructive", title: "Campaign failed", description: e.message });
- } finally {
- setSendingCampaign(false);
- }
- };
-
- return (
-
-
-
-
-
Mailchimp Integration
-
Sync owners from each association into a Mailchimp audience for marketing campaigns.
-
-
-
-
-
- Select Association
- Each association uses its own Mailchimp account and audience.
-
-
-
-
-
- {associations.map(a => {a.name} )}
-
-
- {selectedAssoc && (
-
- {ownerCount} active owner(s) with email in this association.
-
- )}
-
-
-
- {selectedAssoc && (
-
-
-
- Mailchimp Credentials
-
- Get your API key from Mailchimp → Account → Extras → API keys. The server prefix is the suffix after the dash (e.g. us21).
-
-
- {config && (
-
- Connected
-
- )}
-
-
-
-
-
-
- {loading ? : }
- Test & Load Audiences
-
-
setShowCreate(!showCreate)} disabled={!apiKey || !serverPrefix}>
- Create New Audience
-
-
-
- {showCreate && (
-
- New audience name
- setNewAudienceName(e.target.value)}
- placeholder={associations.find(a => a.id === selectedAssoc)?.name || "HOA Owners"}
- />
-
- {loading ? : null}
- Create in Mailchimp
-
-
- )}
-
- {audiences.length > 0 && (
-
- Audience
-
-
-
- {audiences.map(a => (
-
- {a.name} {a.stats?.member_count != null && `(${a.stats.member_count} members)`}
-
- ))}
-
-
-
- )}
-
- {config?.audience_name && audiences.length === 0 && (
-
- Currently linked: {config.audience_name} — click "Test & Load" to change.
-
- )}
-
-
-
- {loading ? : null}
- Save Configuration
-
- {config && (
-
- Disconnect
-
- )}
-
-
-
- )}
-
- {config?.audience_id && (
-
-
- Sync Owners to Mailchimp
-
- Pushes all active owners with email to the linked audience. Existing members are updated; new members are added as subscribed.
-
-
-
-
-
-
- Audience: {config.audience_name}
-
-
- {ownerCount} owner(s) will be synced.
-
-
-
- {syncing ? : }
- Sync Now
-
-
-
- {config.last_sync_at && (
- <>
-
-
-
Last sync
-
{format(new Date(config.last_sync_at), "PPpp")}
-
- {config.last_sync_status === "success" && (
-
- Success
-
- )}
- {config.last_sync_status === "partial" && (
-
- Partial
-
- )}
- {config.last_sync_status === "failed" && (
-
- Failed
-
- )}
-
{config.last_sync_count} synced
-
- {config.last_sync_error && (
-
{config.last_sync_error}
- )}
-
- >
- )}
-
-
- )}
-
- {selectedAssoc && !config?.audience_id && (
-
-
- Send Campaign via Mailchimp
-
- Save your Mailchimp credentials and select an audience above to unlock the campaign sender.
-
-
-
- )}
-
- {config?.audience_id && (
-
-
- Send Campaign via Mailchimp
-
- Compose and send an email campaign directly through Mailchimp to {config.audience_name} . You can send immediately or save as a draft to review in Mailchimp.
-
-
-
-
-
- Reply-To Email
- setCampaignReplyTo(e.target.value)} placeholder="board@yourhoa.com" />
-
-
-
-
- handleSendCampaign(true)} disabled={sendingCampaign}>
- {sendingCampaign ? : }
- Send Now to {ownerCount} Recipients
-
- handleSendCampaign(false)} disabled={sendingCampaign}>
- Save as Draft in Mailchimp
-
-
-
- {lastCampaign && (
-
-
- {lastCampaign.sent ? "✓ Campaign sent successfully" : "✓ Draft saved in Mailchimp"}
-
- {lastCampaign.web_id && (
-
- Open in Mailchimp
-
- )}
-
- )}
-
-
- )}
-
- );
-}
diff --git a/supabase/.temp/cli-latest b/supabase/.temp/cli-latest
index f98a4ce..95ea209 100644
--- a/supabase/.temp/cli-latest
+++ b/supabase/.temp/cli-latest
@@ -1 +1 @@
-v2.105.0
\ No newline at end of file
+v2.106.0
\ No newline at end of file
diff --git a/supabase/functions/buildium-import-apply/index.ts b/supabase/functions/buildium-import-apply/index.ts
index 5455871..abebc32 100644
--- a/supabase/functions/buildium-import-apply/index.ts
+++ b/supabase/functions/buildium-import-apply/index.ts
@@ -260,10 +260,7 @@ Deno.serve(async (req) => {
const files: Array<{ id: string; name: string }> = Array.isArray(p._arc_files) ? p._arc_files : [];
const buildiumAssocId: string | null = p._arc_buildium_association_id || null;
const buildiumArcId: string | null = p.buildium_arc_request_id || null;
- const decisionNotes: string | null = p.decision_notes || null;
- const reviewDate: string | null = p.review_date || null;
const deciderName: string | null = p._arc_decider_name || null;
- const deciderDate: string | null = p._arc_decider_date || null;
const clean = stripPrivate(p);
@@ -282,27 +279,32 @@ Deno.serve(async (req) => {
appId = ins.id;
}
- // Seed a system comment with the Buildium decision (since comments/voters aren't exposed via API)
- if (appId && (decisionNotes || deciderName)) {
- const { data: existingComment } = await supabase
- .from("arc_application_comments")
- .select("id")
- .eq("application_id", appId)
+ // Buildium's API exposes no comment threads or per-member votes — only the final decision
+ // and who recorded it. Surface that decision as a recorded vote in the Committee Review
+ // (entity_votes is what the ARC review UI reads). The decision text itself already lives in
+ // arc_applications.decision_notes. Idempotent: re-syncing replaces the prior Buildium vote.
+ const decisionRaw: string = String(p._arc_decision || "").toLowerCase();
+ const voteDir: "approve" | "deny" | null = decisionRaw.includes("approve")
+ ? "approve"
+ : (decisionRaw.includes("den") || decisionRaw.includes("reject") ? "deny" : null);
+ if (appId && voteDir) {
+ const voterName = `${deciderName || "Buildium"} (Buildium)`;
+ await supabase
+ .from("entity_votes")
+ .delete()
+ .eq("entity_type", "arc_application")
+ .eq("entity_id", appId)
.is("user_id", null)
- .ilike("comment", "%[Imported from Buildium]%")
- .maybeSingle();
- if (!existingComment) {
- const seed =
- `[Imported from Buildium]\n` +
- (deciderName ? `Decision by: ${deciderName}${deciderDate ? ` on ${deciderDate}` : ""}\n` : "") +
- (reviewDate && !deciderDate ? `Decision date: ${reviewDate}\n` : "") +
- (decisionNotes ? `Decision notes: ${decisionNotes}` : "");
- await supabase.from("arc_application_comments").insert({
- application_id: appId,
- user_id: null,
- comment: seed.trim(),
- });
- }
+ .ilike("voter_name", "% (Buildium)");
+ const { error: voteErr } = await supabase.from("entity_votes").insert({
+ entity_type: "arc_application",
+ entity_id: appId,
+ vote: voteDir,
+ user_id: null,
+ voter_name: voterName,
+ recorded_by: null,
+ });
+ if (voteErr) console.warn(`ARC vote record failed for ${appId}: ${voteErr.message}`);
}
// Download attached files from Buildium and upload into the arc-files bucket
diff --git a/supabase/functions/buildium-import-stage/index.ts b/supabase/functions/buildium-import-stage/index.ts
index 5591785..759cf51 100644
--- a/supabase/functions/buildium-import-stage/index.ts
+++ b/supabase/functions/buildium-import-stage/index.ts
@@ -443,6 +443,21 @@ Deno.serve(async (req) => {
const ownerByBuildiumLocal = new Map();
for (const o of ownersAll || []) ownerByBuildiumLocal.set(String(o.buildium_owner_id), o);
+ // Bridge: a Buildium ARC request only exposes OwnershipAccountId, but local owners are
+ // keyed by the *association owner* id. Map ownership account -> { unit, owners } so we can
+ // resolve the request's unit (property address) and owner.
+ const arcOwnershipAccounts = await buildiumFetchAll("/v1/associations/ownershipaccounts", clientId, clientSecret);
+ const ownershipAccountById = new Map();
+ for (const acct of arcOwnershipAccounts) {
+ const ownerIds: string[] = [];
+ if (Array.isArray(acct.AssociationOwnerIds)) for (const id of acct.AssociationOwnerIds) ownerIds.push(String(id));
+ if (acct.AssociationOwnerId) ownerIds.push(String(acct.AssociationOwnerId));
+ ownershipAccountById.set(String(acct.Id), {
+ unitBuildiumId: normId(acct.UnitId),
+ ownerBuildiumIds: [...new Set(ownerIds)],
+ });
+ }
+
for (const ba of buildiumAssocs) {
const assocLocalId = bAssocIdToLocalId.get(String(ba.Id));
if (!assocLocalId || !isSelected(assocLocalId)) continue;
@@ -462,10 +477,30 @@ Deno.serve(async (req) => {
for (const r of arcRequests) {
const buildiumArcId = String(r.Id);
- // Buildium ARC payload doesn't expose UnitId — resolve unit via the owner's unit later.
- const buildiumUnitId = normId(r.UnitId);
- const buildiumOwnerId = normId(r.OwnershipAccountId || r.AssociationOwnerId);
- const localOwner = buildiumOwnerId ? ownerByBuildiumLocal.get(buildiumOwnerId) : null;
+ // Buildium ARC requests expose only OwnershipAccountId. Bridge it to the unit + owner
+ // via the ownership-accounts map built above.
+ const ownershipAccountId = normId(r.OwnershipAccountId);
+ const acctInfo = ownershipAccountId ? ownershipAccountById.get(ownershipAccountId) : null;
+
+ // Resolve unit: prefer the ARC's own UnitId if present, else the ownership account's unit.
+ const buildiumUnitId = normId(r.UnitId) || acctInfo?.unitBuildiumId || null;
+
+ // Resolve owner: walk the ownership account's association-owner ids (the id space local
+ // owners are keyed by) and take the first that maps to a local owner.
+ let resolvedOwnerBuildiumId: string | null = normId(r.AssociationOwnerId);
+ let localOwner = resolvedOwnerBuildiumId ? ownerByBuildiumLocal.get(resolvedOwnerBuildiumId) : null;
+ if (!localOwner && acctInfo) {
+ for (const boid of acctInfo.ownerBuildiumIds) {
+ const cand = ownerByBuildiumLocal.get(boid);
+ if (cand) { localOwner = cand; resolvedOwnerBuildiumId = boid; break; }
+ }
+ // Even if no local owner exists yet, keep the first association-owner id so apply can
+ // recover it after owners are imported in the same run.
+ if (!resolvedOwnerBuildiumId && acctInfo.ownerBuildiumIds.length) {
+ resolvedOwnerBuildiumId = acctInfo.ownerBuildiumIds[0];
+ }
+ }
+
const localUnit = buildiumUnitId
? unitByBuildiumId.get(buildiumUnitId)
: (localOwner?.unit_id ? { id: localOwner.unit_id } : null);
@@ -478,9 +513,12 @@ Deno.serve(async (req) => {
const submittedDate = r.SubmittedDateTime
? String(r.SubmittedDateTime).split("T")[0]
: (r.SubmittedDate ? String(r.SubmittedDate).split("T")[0] : null);
- const decisionDate = r.DecisionDateTime
- ? String(r.DecisionDateTime).split("T")[0]
- : (r.DecisionDate ? String(r.DecisionDate).split("T")[0] : null);
+ // Buildium's ARC resource has no decision-date field — the decision is recorded via the
+ // last update, so use LastUpdatedDateTime as the review/decision date for finalized requests.
+ const isFinalDecision = /approve|den|reject/i.test(String(r.Decision || ""));
+ const decisionDate = isFinalDecision
+ ? (String(r.DecisionDateTime || r.LastUpdatedDateTime || "").split("T")[0] || null)
+ : null;
const decisionNotes = r.DecisionDescription
? String(r.DecisionDescription)
: (r.Decision && !/pending/i.test(String(r.Decision)) ? `Decision: ${r.Decision}` : null);
@@ -527,21 +565,27 @@ Deno.serve(async (req) => {
decision_notes: decisionNotes,
buildium_arc_request_id: buildiumArcId,
_resolve_unit_buildium_id: buildiumUnitId,
- _resolve_owner_buildium_id: buildiumOwnerId,
+ _resolve_owner_buildium_id: resolvedOwnerBuildiumId,
_arc_files: files,
_arc_buildium_association_id: String(ba.Id),
_arc_decider_name: deciderName,
_arc_decider_date: deciderDate,
+ _arc_decision: r.Decision || null,
};
const match = arcByBuildium.get(`${assocLocalId}|${buildiumArcId}`);
if (match) {
+ // Never downgrade an existing owner/unit link to null when Buildium can't resolve one.
+ if (!incoming.owner_id && match.owner_id) incoming.owner_id = match.owner_id;
+ if (!incoming.unit_id && match.unit_id) incoming.unit_id = match.unit_id;
const d = diff(match, {
status: incoming.status,
decision_notes: incoming.decision_notes,
review_date: incoming.review_date,
title: incoming.title,
description: incoming.description,
+ owner_id: incoming.owner_id,
+ unit_id: incoming.unit_id,
});
if (Object.keys(d).length === 0 && (!includeArcFiles || files.length === 0)) continue;
stage(
diff --git a/supabase/functions/reach-connection/index.ts b/supabase/functions/reach-connection/index.ts
new file mode 100644
index 0000000..ece7976
--- /dev/null
+++ b/supabase/functions/reach-connection/index.ts
@@ -0,0 +1,56 @@
+// Hostinger Reach — connection test. Validates the stored global API token by listing segments.
+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";
+
+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);
+
+ // A token can be supplied in the body to test before saving; otherwise use the stored one.
+ const body = await req.json().catch(() => ({}));
+ let token: string | null = typeof body.api_token === "string" && body.api_token.trim() ? body.api_token.trim() : null;
+ if (!token) {
+ const { data: cfg } = await admin
+ .from("hostinger_reach_config").select("api_token").order("updated_at", { ascending: false }).limit(1).maybeSingle();
+ token = cfg?.api_token || null;
+ }
+ if (!token) return json({ success: false, error: "No Hostinger Reach API token configured." }, 400);
+
+ const res = await fetch(`${REACH_BASE}/segmentation/segments`, {
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
+ });
+ const text = await res.text();
+ if (!res.ok) {
+ return json({ success: false, error: `Reach API ${res.status}: ${text.slice(0, 300)}` }, 200);
+ }
+ let parsed: any = {};
+ try { parsed = JSON.parse(text); } catch { /* ignore */ }
+ const list = Array.isArray(parsed) ? parsed : (parsed.data ?? parsed.segments ?? []);
+ return json({ success: true, segment_count: Array.isArray(list) ? list.length : 0 });
+ } catch (err) {
+ return json({ success: false, error: (err as Error).message }, 500);
+ }
+});
diff --git a/supabase/functions/reach-sync/index.ts b/supabase/functions/reach-sync/index.ts
new file mode 100644
index 0000000..961534c
--- /dev/null
+++ b/supabase/functions/reach-sync/index.ts
@@ -0,0 +1,154 @@
+// 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);
+ }
+});
diff --git a/supabase/migrations/20260611190000_arc_finalized_lock_allow_service_role.sql b/supabase/migrations/20260611190000_arc_finalized_lock_allow_service_role.sql
new file mode 100644
index 0000000..8cedf32
--- /dev/null
+++ b/supabase/migrations/20260611190000_arc_finalized_lock_allow_service_role.sql
@@ -0,0 +1,23 @@
+-- Allow privileged backend contexts (service role / no JWT, e.g. the Buildium import) to update
+-- finalized ARC applications, alongside admins. Client writes by non-admins remain blocked by RLS,
+-- so this does not weaken the user-facing lock.
+CREATE OR REPLACE FUNCTION public.prevent_updates_on_finalized_arc()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $function$
+BEGIN
+ IF lower(COALESCE(OLD.status,'')) IN ('approved','denied') THEN
+ -- auth.uid() IS NULL => no end-user JWT (service role / backend job); admins also exempt.
+ IF auth.uid() IS NULL OR public.has_role(auth.uid(), 'admin'::public.app_role) THEN
+ RETURN NEW;
+ END IF;
+
+ RAISE EXCEPTION 'This ARC application has been finalized (approved or denied) and is locked from further changes.'
+ USING ERRCODE = 'check_violation';
+ END IF;
+
+ RETURN NEW;
+END;
+$function$;
diff --git a/supabase/migrations/20260611193000_hostinger_reach_integration.sql b/supabase/migrations/20260611193000_hostinger_reach_integration.sql
new file mode 100644
index 0000000..aba5ca3
--- /dev/null
+++ b/supabase/migrations/20260611193000_hostinger_reach_integration.sql
@@ -0,0 +1,39 @@
+-- Hostinger Reach integration: one global API token + per-association segment/sync tracking.
+
+CREATE TABLE IF NOT EXISTS public.hostinger_reach_config (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ api_token text NOT NULL,
+ created_by uuid,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS public.hostinger_reach_segments (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ association_id uuid NOT NULL UNIQUE REFERENCES public.associations(id) ON DELETE CASCADE,
+ segment_uuid text,
+ segment_name text,
+ last_sync_at timestamptz,
+ last_sync_status text,
+ last_sync_count integer,
+ last_sync_error text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+ALTER TABLE public.hostinger_reach_config ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.hostinger_reach_segments ENABLE ROW LEVEL SECURITY;
+
+-- Admin-only access (service role bypasses RLS for the edge functions).
+CREATE POLICY "Admins manage reach config" ON public.hostinger_reach_config
+ FOR ALL USING (public.has_role(auth.uid(), 'admin'::public.app_role))
+ WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
+
+CREATE POLICY "Admins manage reach segments" ON public.hostinger_reach_segments
+ FOR ALL USING (public.has_role(auth.uid(), 'admin'::public.app_role))
+ WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
+
+CREATE TRIGGER set_reach_config_updated_at BEFORE UPDATE ON public.hostinger_reach_config
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+CREATE TRIGGER set_reach_segments_updated_at BEFORE UPDATE ON public.hostinger_reach_segments
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();