// 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 { return fetch(`${BUILDIUM_BASE}${path}`, { headers: { "x-buildium-client-id": clientId, "x-buildium-client-secret": clientSecret, Accept: "application/json", }, }); } function stripPrivate(p: Record): Record { const out: Record = {}; 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 = {}; 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(); 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(); 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(); 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>(); 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(); 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" } }); } });