Add ACMCC app source, Supabase backend, and project config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:19:26 -04:00
parent 313b51b412
commit 183fe0a93c
1422 changed files with 259271 additions and 0 deletions
@@ -0,0 +1,361 @@
// 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 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);
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;
}
// 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)
.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(),
});
}
}
// 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" } });
}
});