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>
364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
// Buildium Import Apply — applies approved staged rows from buildium_import_staging
|
|
// to live tables in dependency order (units → owners → gl_account → ledger_entry).
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
|
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Headers":
|
|
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
|
};
|
|
|
|
const KIND_ORDER = ["unit", "owner", "gl_account", "ledger_entry", "arc_application"] as const;
|
|
const BUILDIUM_BASE = "https://api.buildium.com";
|
|
|
|
async function buildiumFetch(path: string, clientId: string, clientSecret: string): Promise<Response> {
|
|
return fetch(`${BUILDIUM_BASE}${path}`, {
|
|
headers: {
|
|
"x-buildium-client-id": clientId,
|
|
"x-buildium-client-secret": clientSecret,
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
}
|
|
|
|
function stripPrivate(p: Record<string, any>): Record<string, any> {
|
|
const out: Record<string, any> = {};
|
|
for (const [k, v] of Object.entries(p)) if (!k.startsWith("_")) out[k] = v;
|
|
return out;
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
|
|
|
try {
|
|
const authHeader = req.headers.get("Authorization");
|
|
if (!authHeader?.startsWith("Bearer ")) {
|
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
|
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
|
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
const token = authHeader.replace("Bearer ", "");
|
|
|
|
let userId: string | null = null;
|
|
try {
|
|
const payload = token.split(".")[1];
|
|
const padded = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payload.length / 4) * 4, "=");
|
|
userId = JSON.parse(atob(padded))?.sub ?? null;
|
|
} catch {
|
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
|
|
const auth = createClient(supabaseUrl, anonKey, { global: { headers: { Authorization: authHeader } } });
|
|
const { data: roles } = await auth.from("user_roles").select("role").eq("user_id", userId);
|
|
const isStaff = (roles || []).some((r: any) => r.role === "admin" || r.role === "manager");
|
|
if (!isStaff) {
|
|
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
|
|
const supabase = createClient(supabaseUrl, serviceKey);
|
|
const body = await req.json().catch(() => ({}));
|
|
const batchId: string | null = typeof body.batch_id === "string" ? body.batch_id : null;
|
|
const stagingIds: string[] | null = Array.isArray(body.staging_ids) ? body.staging_ids.filter((s: any) => typeof s === "string") : null;
|
|
|
|
if (!batchId && (!stagingIds || stagingIds.length === 0)) {
|
|
return new Response(JSON.stringify({ error: "Provide batch_id or staging_ids" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
|
|
// Fetch approved staged rows
|
|
let q = supabase.from("buildium_import_staging").select("*").eq("status", "approved");
|
|
if (batchId) q = q.eq("batch_id", batchId);
|
|
if (stagingIds && stagingIds.length > 0) q = q.in("id", stagingIds);
|
|
const { data: staged, error: stagedErr } = await q;
|
|
if (stagedErr) throw stagedErr;
|
|
|
|
if (!staged || staged.length === 0) {
|
|
return new Response(JSON.stringify({ success: true, applied: 0, failed: 0, message: "No approved rows to apply" }), {
|
|
status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
// Group by kind
|
|
const byKind: Record<string, any[]> = {};
|
|
for (const k of KIND_ORDER) byKind[k] = [];
|
|
for (const r of staged) if (KIND_ORDER.includes(r.kind)) byKind[r.kind].push(r);
|
|
|
|
let applied = 0, failed = 0;
|
|
const nowIso = new Date().toISOString();
|
|
|
|
async function markApplied(id: string) {
|
|
await supabase.from("buildium_import_staging").update({
|
|
status: "applied", applied_at: nowIso, apply_error: null,
|
|
}).eq("id", id);
|
|
applied++;
|
|
}
|
|
async function markFailed(id: string, msg: string) {
|
|
await supabase.from("buildium_import_staging").update({
|
|
status: "failed", apply_error: msg.slice(0, 1000),
|
|
}).eq("id", id);
|
|
failed++;
|
|
}
|
|
|
|
// ---- 1. UNITS ----
|
|
const newBuildiumUnitToLocalId = new Map<string, string>();
|
|
for (const row of byKind.unit) {
|
|
try {
|
|
const p = stripPrivate(row.payload || {});
|
|
if (row.action === "update" && row.match_id) {
|
|
const { error } = await supabase.from("units").update(p).eq("id", row.match_id);
|
|
if (error) throw error;
|
|
if (p.buildium_unit_id) newBuildiumUnitToLocalId.set(String(p.buildium_unit_id), row.match_id);
|
|
} else {
|
|
const { data: ins, error } = await supabase.from("units").insert(p).select("id").single();
|
|
if (error) throw error;
|
|
if (p.buildium_unit_id) newBuildiumUnitToLocalId.set(String(p.buildium_unit_id), ins.id);
|
|
}
|
|
await markApplied(row.id);
|
|
} catch (e: any) {
|
|
await markFailed(row.id, e?.message || String(e));
|
|
}
|
|
}
|
|
|
|
// Build unit lookup for downstream owner/ledger resolution
|
|
const { data: allUnits } = await supabase.from("units").select("id, buildium_unit_id").not("buildium_unit_id", "is", null);
|
|
const unitByBuildium = new Map<string, string>();
|
|
for (const u of allUnits || []) unitByBuildium.set(String(u.buildium_unit_id), u.id);
|
|
|
|
// ---- 2. OWNERS ----
|
|
for (const row of byKind.owner) {
|
|
try {
|
|
const p = { ...(row.payload || {}) };
|
|
// Resolve unit_id by buildium id if needed
|
|
if (!p.unit_id && p._resolve_unit_buildium_id) {
|
|
const localUnit = unitByBuildium.get(String(p._resolve_unit_buildium_id));
|
|
if (localUnit) p.unit_id = localUnit;
|
|
}
|
|
const clean = stripPrivate(p);
|
|
if (row.action === "update" && row.match_id) {
|
|
const { error } = await supabase.from("owners").update(clean).eq("id", row.match_id);
|
|
if (error) throw error;
|
|
} else {
|
|
const { error } = await supabase.from("owners").insert(clean);
|
|
if (error) throw error;
|
|
}
|
|
await markApplied(row.id);
|
|
} catch (e: any) {
|
|
await markFailed(row.id, e?.message || String(e));
|
|
}
|
|
}
|
|
|
|
// Build owner lookup
|
|
const { data: allOwners } = await supabase.from("owners").select("id, buildium_owner_id, unit_id").not("buildium_owner_id", "is", null);
|
|
const ownerByBuildium = new Map<string, { id: string; unit_id: string | null }>();
|
|
for (const o of allOwners || []) ownerByBuildium.set(String(o.buildium_owner_id), { id: o.id, unit_id: o.unit_id });
|
|
|
|
// ---- 3. GL ACCOUNTS (parents first) ----
|
|
const glRows = byKind.gl_account.slice().sort((a, b) => {
|
|
const ap = a.payload?._parent_buildium_id ? 1 : 0;
|
|
const bp = b.payload?._parent_buildium_id ? 1 : 0;
|
|
return ap - bp;
|
|
});
|
|
// Need a per-association lookup of account_number -> id for parent linkage
|
|
const acctNumToIdByAssoc = new Map<string, Map<string, string>>();
|
|
async function getAcctMap(assocId: string) {
|
|
let m = acctNumToIdByAssoc.get(assocId);
|
|
if (!m) {
|
|
const { data } = await supabase.from("chart_of_accounts").select("id, account_number").eq("association_id", assocId);
|
|
m = new Map<string, string>();
|
|
for (const r of data || []) m.set(String(r.account_number), r.id);
|
|
acctNumToIdByAssoc.set(assocId, m);
|
|
}
|
|
return m;
|
|
}
|
|
for (const row of glRows) {
|
|
try {
|
|
const p = { ...(row.payload || {}) };
|
|
if (p._parent_buildium_id && row.association_id) {
|
|
const m = await getAcctMap(row.association_id);
|
|
const parentId = m.get(String(p._parent_buildium_id));
|
|
if (parentId) p.parent_account_id = parentId;
|
|
}
|
|
const clean = stripPrivate(p);
|
|
if (row.action === "update" && row.match_id) {
|
|
const { error } = await supabase.from("chart_of_accounts").update(clean).eq("id", row.match_id);
|
|
if (error) throw error;
|
|
} else {
|
|
const { data: ins, error } = await supabase.from("chart_of_accounts").upsert(clean, { onConflict: "association_id, account_number" }).select("id, account_number").single();
|
|
if (error) throw error;
|
|
if (row.association_id && ins) {
|
|
const m = await getAcctMap(row.association_id);
|
|
m.set(String(ins.account_number), ins.id);
|
|
}
|
|
}
|
|
await markApplied(row.id);
|
|
} catch (e: any) {
|
|
await markFailed(row.id, e?.message || String(e));
|
|
}
|
|
}
|
|
|
|
// ---- 4. LEDGER ENTRIES ----
|
|
for (const row of byKind.ledger_entry) {
|
|
try {
|
|
const p = { ...(row.payload || {}) };
|
|
if (!p.unit_id && p._resolve_unit_buildium_id) {
|
|
const u = unitByBuildium.get(String(p._resolve_unit_buildium_id));
|
|
if (u) p.unit_id = u;
|
|
}
|
|
if (!p.owner_id && Array.isArray(p._resolve_owner_buildium_ids)) {
|
|
for (const boid of p._resolve_owner_buildium_ids) {
|
|
const o = ownerByBuildium.get(String(boid));
|
|
if (o) { p.owner_id = o.id; break; }
|
|
}
|
|
}
|
|
// Fallback: any owner attached to this unit
|
|
if (!p.owner_id && p.unit_id) {
|
|
const { data: anyOwner } = await supabase.from("owners").select("id").eq("unit_id", p.unit_id).limit(1).maybeSingle();
|
|
if (anyOwner) p.owner_id = anyOwner.id;
|
|
}
|
|
if (!p.unit_id || !p.owner_id) {
|
|
throw new Error("Could not resolve unit/owner for ledger entry");
|
|
}
|
|
const clean = stripPrivate(p);
|
|
// Skip if a buildium ref with same unit_id+reference_id already exists
|
|
const { data: dupe } = await supabase
|
|
.from("owner_ledger_entries")
|
|
.select("id").eq("unit_id", clean.unit_id).eq("reference_type", "buildium").eq("reference_id", clean.reference_id)
|
|
.maybeSingle();
|
|
if (dupe) {
|
|
await markApplied(row.id);
|
|
continue;
|
|
}
|
|
const { error } = await supabase.from("owner_ledger_entries").insert(clean);
|
|
if (error) throw error;
|
|
await markApplied(row.id);
|
|
} catch (e: any) {
|
|
await markFailed(row.id, e?.message || String(e));
|
|
}
|
|
}
|
|
|
|
// ---- 5. ARC APPLICATIONS ----
|
|
const buildiumClientId = Deno.env.get("BUILDIUM_API_KEY") ?? "";
|
|
const buildiumClientSecret = Deno.env.get("BUILDIUM_API_SECRET") ?? "";
|
|
|
|
for (const row of byKind.arc_application || []) {
|
|
try {
|
|
const p = { ...(row.payload || {}) };
|
|
if (!p.unit_id && p._resolve_unit_buildium_id) {
|
|
const u = unitByBuildium.get(String(p._resolve_unit_buildium_id));
|
|
if (u) p.unit_id = u;
|
|
}
|
|
if (!p.owner_id && p._resolve_owner_buildium_id) {
|
|
const o = ownerByBuildium.get(String(p._resolve_owner_buildium_id));
|
|
if (o) p.owner_id = o.id;
|
|
}
|
|
// Fallback: pick any owner on the unit
|
|
if (!p.owner_id && p.unit_id) {
|
|
const { data: anyOwner } = await supabase.from("owners").select("id").eq("unit_id", p.unit_id).limit(1).maybeSingle();
|
|
if (anyOwner) p.owner_id = anyOwner.id;
|
|
}
|
|
|
|
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 deciderName: string | null = p._arc_decider_name || null;
|
|
|
|
const clean = stripPrivate(p);
|
|
|
|
let appId: string | null = null;
|
|
if (row.action === "update" && row.match_id) {
|
|
const { error } = await supabase.from("arc_applications").update(clean).eq("id", row.match_id);
|
|
if (error) throw error;
|
|
appId = row.match_id;
|
|
} else {
|
|
const { data: ins, error } = await supabase
|
|
.from("arc_applications")
|
|
.insert(clean)
|
|
.select("id")
|
|
.single();
|
|
if (error) throw error;
|
|
appId = ins.id;
|
|
}
|
|
|
|
// 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("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
|
|
if (appId && files.length > 0 && buildiumClientId && buildiumClientSecret && buildiumAssocId && buildiumArcId) {
|
|
for (const f of files) {
|
|
try {
|
|
if (!f.id) continue;
|
|
// Buildium uses a presigned download endpoint
|
|
// Buildium uses the global ownership-accounts ARC path; download endpoint is "downloadrequests" (plural) and POST
|
|
const dlRes = await fetch(
|
|
`${BUILDIUM_BASE}/v1/associations/ownershipaccounts/architecturalrequests/${buildiumArcId}/files/${f.id}/downloadrequests`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"x-buildium-client-id": buildiumClientId,
|
|
"x-buildium-client-secret": buildiumClientSecret,
|
|
Accept: "application/json",
|
|
},
|
|
},
|
|
);
|
|
if (!dlRes.ok) {
|
|
console.warn(`ARC file presign ${f.id} failed: ${dlRes.status}`);
|
|
continue;
|
|
}
|
|
const dl: any = await dlRes.json().catch(() => ({}));
|
|
const url: string | undefined = dl?.DownloadUrl || dl?.Url || dl?.url;
|
|
if (!url) continue;
|
|
const fileRes = await fetch(url);
|
|
if (!fileRes.ok) continue;
|
|
const buf = await fileRes.arrayBuffer();
|
|
const safeName = (f.name || `buildium-${f.id}`).replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
const storagePath = `${clean.association_id}/${appId}/buildium-${f.id}-${safeName}`;
|
|
await supabase.storage.from("arc-files").upload(storagePath, buf, {
|
|
contentType: fileRes.headers.get("content-type") || "application/octet-stream",
|
|
upsert: true,
|
|
});
|
|
} catch (e) {
|
|
console.warn(`ARC file copy failed for ${f.id}: ${e}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
await markApplied(row.id);
|
|
} catch (e: any) {
|
|
await markFailed(row.id, e?.message || String(e));
|
|
}
|
|
}
|
|
|
|
return new Response(JSON.stringify({ success: true, applied, failed }), {
|
|
status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
} catch (e: any) {
|
|
console.error("buildium-import-apply error", e);
|
|
return new Response(JSON.stringify({ error: e?.message || String(e) }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
|
|
}
|
|
});
|