mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.99.1";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function normalize(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeEmail(value: unknown) {
|
||||
return normalize(value).toLowerCase();
|
||||
}
|
||||
|
||||
function isUuid(value: string) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function buildActionEmailHtml({ title, intro, buttonLabel, actionLink }: { title: string; intro: string; buttonLabel: string; actionLink: string }) {
|
||||
return `
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<h2 style="color:#1e293b;margin-bottom:16px;">${escapeHtml(title)}</h2>
|
||||
<p style="color:#334155;font-size:15px;line-height:1.6;">${escapeHtml(intro)}</p>
|
||||
<div style="text-align:center;margin:28px 0;">
|
||||
<a href="${escapeHtml(actionLink)}" style="background-color:#0f172a;color:#ffffff;padding:12px 28px;text-decoration:none;border-radius:6px;font-weight:600;display:inline-block;font-size:15px;">${escapeHtml(buttonLabel)}</a>
|
||||
</div>
|
||||
<p style="color:#64748b;font-size:13px;">If you did not expect this email, you can safely ignore it.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function getAuthorizedCaller(req: Request, anonKey: string) {
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
if (!authHeader.startsWith("Bearer ")) return { error: jsonResponse({ error: "Unauthorized" }, 401) };
|
||||
|
||||
const callerClient = createClient(Deno.env.get("SUPABASE_URL")!, anonKey, {
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const { data: claimsData, error: claimsError } = await callerClient.auth.getClaims(token);
|
||||
const callerId = claimsData?.claims?.sub;
|
||||
if (claimsError || !callerId) return { error: jsonResponse({ error: "Unauthorized" }, 401) };
|
||||
|
||||
const [{ data: isAdmin }, { data: isManager }] = await Promise.all([
|
||||
callerClient.rpc("has_role", { _user_id: callerId, _role: "admin" }),
|
||||
callerClient.rpc("has_role", { _user_id: callerId, _role: "manager" }),
|
||||
]);
|
||||
if (!isAdmin && !isManager) return { error: jsonResponse({ error: "Insufficient permissions" }, 403) };
|
||||
|
||||
return { callerClient, callerId, authHeader };
|
||||
}
|
||||
|
||||
async function getVerifiedSender(adminClient: any) {
|
||||
const { data, error } = await adminClient
|
||||
.from("email_senders")
|
||||
.select("id")
|
||||
.eq("is_active", true)
|
||||
.eq("verified", true)
|
||||
.order("is_default", { ascending: false })
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(1);
|
||||
if (error) return null;
|
||||
return data?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function sendManagedEmail(callerClient: any, recipient: string, subject: string, html: string) {
|
||||
const adminClient = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
||||
const sender = await getVerifiedSender(adminClient);
|
||||
if (!sender?.id) return { skipped: true, reason: "No verified email sender is configured" };
|
||||
|
||||
const { data, error } = await callerClient.functions.invoke("send-smtp-email", {
|
||||
body: { sender_id: sender.id, recipient: [recipient], subject, body: html, html, debug: false },
|
||||
});
|
||||
if (error) throw error;
|
||||
if (data?.success === false) throw new Error(data.error || "Email send failed");
|
||||
return { skipped: false };
|
||||
}
|
||||
|
||||
async function findMatchingOwner(adminClient: any, lastName: string, email: string, unitIdentifier: string, accountNumber: string) {
|
||||
const unitFilters = [];
|
||||
if (unitIdentifier) {
|
||||
unitFilters.push(`unit_number.ilike.${unitIdentifier}`);
|
||||
if (isUuid(unitIdentifier)) unitFilters.push(`id.eq.${unitIdentifier}`);
|
||||
}
|
||||
if (accountNumber) unitFilters.push(`account_number.ilike.${accountNumber}`);
|
||||
if (!unitFilters.length) return { unit: null, owner: null };
|
||||
|
||||
const { data: units, error: unitErr } = await adminClient
|
||||
.from("units")
|
||||
.select("id, association_id, unit_number, account_number")
|
||||
.or(unitFilters.join(","));
|
||||
if (unitErr) throw unitErr;
|
||||
if (!units?.length) return { unit: null, owner: null };
|
||||
|
||||
const unitIds = units.map((u: any) => u.id);
|
||||
const { data: owners, error: ownerErr } = await adminClient
|
||||
.from("owners")
|
||||
.select("id, first_name, last_name, email, association_id, unit_id, user_id, exclude_from_signin")
|
||||
.in("unit_id", unitIds)
|
||||
.eq("status", "active")
|
||||
.ilike("last_name", lastName);
|
||||
if (ownerErr) throw ownerErr;
|
||||
|
||||
const owner = (owners || []).find((o: any) => !email || normalizeEmail(o.email) === email) || owners?.[0] || null;
|
||||
const unit = owner ? units.find((u: any) => u.id === owner.unit_id) : units[0];
|
||||
return { unit, owner };
|
||||
}
|
||||
|
||||
async function createOrLinkOwnerAccount(adminClient: any, owner: any, email: string) {
|
||||
const fullName = `${owner.first_name || ""} ${owner.last_name || ""}`.trim();
|
||||
let userId = owner.user_id;
|
||||
|
||||
if (!userId) {
|
||||
const tempPassword = `Temp-${crypto.randomUUID()}!`;
|
||||
const { data: authData, error: createErr } = await adminClient.auth.admin.createUser({
|
||||
email,
|
||||
password: tempPassword,
|
||||
email_confirm: true,
|
||||
user_metadata: { full_name: fullName },
|
||||
});
|
||||
|
||||
if (createErr && createErr.message?.includes("already been registered")) {
|
||||
const { data: users } = await adminClient.auth.admin.listUsers();
|
||||
userId = users.users.find((u: any) => normalizeEmail(u.email) === email)?.id;
|
||||
} else if (createErr) {
|
||||
throw createErr;
|
||||
} else {
|
||||
userId = authData.user?.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userId) throw new Error("Could not create or find user account");
|
||||
|
||||
await adminClient.from("owners").update({ user_id: userId, email }).eq("id", owner.id);
|
||||
|
||||
const { data: existingProfile } = await adminClient.from("profiles").select("user_id").eq("user_id", userId).maybeSingle();
|
||||
if (!existingProfile) await adminClient.from("profiles").insert({ user_id: userId, full_name: fullName, email });
|
||||
else await adminClient.from("profiles").update({ full_name: fullName, email }).eq("user_id", userId);
|
||||
|
||||
const { data: existingRole } = await adminClient.from("user_roles").select("id").eq("user_id", userId).eq("role", "homeowner").maybeSingle();
|
||||
if (!existingRole) await adminClient.from("user_roles").insert({ user_id: userId, role: "homeowner" });
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
||||
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
||||
const adminClient = createClient(supabaseUrl, serviceKey);
|
||||
const body = await req.json();
|
||||
const { action } = body;
|
||||
|
||||
if (action === "submit_request") {
|
||||
const lastName = normalize(body.last_name);
|
||||
const email = normalizeEmail(body.email);
|
||||
const unitIdentifier = normalize(body.unit_identifier || body.unit_number);
|
||||
const accountNumber = normalize(body.account_number);
|
||||
if (!lastName || !email || (!unitIdentifier && !accountNumber)) return jsonResponse({ error: "Last name, email, and either unit ID or account number are required" }, 400);
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return jsonResponse({ error: "Enter a valid email address" }, 400);
|
||||
|
||||
const { unit, owner } = await findMatchingOwner(adminClient, lastName, email, unitIdentifier, accountNumber);
|
||||
const { data, error } = await adminClient.from("owner_registration_requests").insert({
|
||||
last_name: lastName,
|
||||
email,
|
||||
unit_identifier: unitIdentifier,
|
||||
account_number: accountNumber,
|
||||
association_id: owner?.association_id || unit?.association_id || null,
|
||||
unit_id: owner?.unit_id || unit?.id || null,
|
||||
owner_id: owner?.id || null,
|
||||
notes: owner ? null : "No exact owner match was found automatically.",
|
||||
}).select("id").single();
|
||||
if (error) throw error;
|
||||
return jsonResponse({ success: true, id: data.id, matched: Boolean(owner) });
|
||||
}
|
||||
|
||||
if (action === "validate") {
|
||||
const unitNumber = normalize(body.unit_number);
|
||||
const accountNumber = normalize(body.account_number);
|
||||
if (!unitNumber || !accountNumber) return jsonResponse({ error: "Unit number and account number are required" }, 400);
|
||||
|
||||
const unitFilters = [`unit_number.ilike.${unitNumber}`];
|
||||
if (isUuid(unitNumber)) unitFilters.push(`id.eq.${unitNumber}`);
|
||||
|
||||
const { data: units, error: unitErr } = await adminClient
|
||||
.from("units")
|
||||
.select("id, unit_number, address, association_id, account_number")
|
||||
.or(unitFilters.join(","))
|
||||
.or(`account_number.ilike.${accountNumber}`);
|
||||
if (unitErr) throw unitErr;
|
||||
if (!units?.length) return jsonResponse({ error: "No matching unit found. Please check your Unit ID and Account Number." }, 404);
|
||||
|
||||
const { data: owners, error: ownerErr } = await adminClient
|
||||
.from("owners")
|
||||
.select("id, first_name, last_name, email, unit_id, association_id, user_id, exclude_from_signin")
|
||||
.in("unit_id", units.map((u: any) => u.id))
|
||||
.eq("status", "active");
|
||||
if (ownerErr) throw ownerErr;
|
||||
const available = (owners || []).filter((o: any) => !o.exclude_from_signin && !o.user_id);
|
||||
if (!available.length) return jsonResponse({ error: "All owners for this unit are already registered or excluded from sign-in." }, 400);
|
||||
return jsonResponse({ owners: available.map((o: any) => ({ id: o.id, first_name: o.first_name, last_name: o.last_name, email: o.email })), unit: units[0] });
|
||||
}
|
||||
|
||||
if (action === "register") {
|
||||
const email = normalizeEmail(body.email);
|
||||
const password = normalize(body.password);
|
||||
const ownerId = normalize(body.owner_id);
|
||||
if (!email || !password || !ownerId) return jsonResponse({ error: "Email, password, and owner selection are required" }, 400);
|
||||
|
||||
const { data: owner, error: ownerErr } = await adminClient
|
||||
.from("owners")
|
||||
.select("id, first_name, last_name, association_id, unit_id, user_id, exclude_from_signin")
|
||||
.eq("id", ownerId)
|
||||
.single();
|
||||
if (ownerErr || !owner) return jsonResponse({ error: "Owner record not found" }, 404);
|
||||
if (owner.user_id) return jsonResponse({ error: "This owner is already linked to an account" }, 400);
|
||||
if (owner.exclude_from_signin) return jsonResponse({ error: "This owner is excluded from sign-in" }, 400);
|
||||
|
||||
const userId = await createOrLinkOwnerAccount(adminClient, owner, email);
|
||||
const { error: updatePasswordError } = await adminClient.auth.admin.updateUserById(userId, { password });
|
||||
if (updatePasswordError) throw updatePasswordError;
|
||||
return jsonResponse({ success: true, user_id: userId });
|
||||
}
|
||||
|
||||
if (action === "request_password_reset") {
|
||||
const email = normalizeEmail(body.email);
|
||||
const origin = normalize(body.origin) || req.headers.get("origin") || "https://avria.cloud";
|
||||
if (!email) return jsonResponse({ error: "Email is required" }, 400);
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return jsonResponse({ error: "Enter a valid email address" }, 400);
|
||||
|
||||
const { data: owners, error: ownerErr } = await adminClient
|
||||
.from("owners")
|
||||
.select("id, first_name, last_name, email, association_id, unit_id, user_id, exclude_from_signin")
|
||||
.ilike("email", email)
|
||||
.eq("status", "active")
|
||||
.limit(1);
|
||||
if (ownerErr) throw ownerErr;
|
||||
|
||||
const owner = (owners || []).find((o: any) => !o.exclude_from_signin && normalizeEmail(o.email) === email);
|
||||
if (owner) {
|
||||
await createOrLinkOwnerAccount(adminClient, owner, email);
|
||||
const publicClient = createClient(supabaseUrl, anonKey);
|
||||
const { error: resetError } = await publicClient.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${origin.replace(/\/$/, "")}/reset-password`,
|
||||
});
|
||||
if (resetError) throw resetError;
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
if (action === "invite_owner" || action === "approve_request") {
|
||||
const auth = await getAuthorizedCaller(req, anonKey);
|
||||
if (auth.error) return auth.error;
|
||||
const callerClient = auth.callerClient!;
|
||||
const callerId = auth.callerId!;
|
||||
const origin = req.headers.get("origin") || "https://avria.cloud";
|
||||
let owner: any = null;
|
||||
let requestId: string | null = null;
|
||||
let email = normalizeEmail(body.email);
|
||||
|
||||
if (action === "approve_request") {
|
||||
requestId = normalize(body.request_id);
|
||||
if (!requestId) return jsonResponse({ error: "Request ID is required" }, 400);
|
||||
const { data: requestRow, error: reqErr } = await adminClient.from("owner_registration_requests").select("*").eq("id", requestId).single();
|
||||
if (reqErr || !requestRow) return jsonResponse({ error: "Registration request not found" }, 404);
|
||||
email = normalizeEmail(requestRow.email);
|
||||
if (requestRow.owner_id) {
|
||||
const { data } = await adminClient.from("owners").select("id, first_name, last_name, association_id, unit_id, user_id, exclude_from_signin").eq("id", requestRow.owner_id).single();
|
||||
owner = data;
|
||||
} else {
|
||||
const match = await findMatchingOwner(adminClient, requestRow.last_name, email, requestRow.unit_identifier, requestRow.account_number);
|
||||
owner = match.owner;
|
||||
}
|
||||
if (!owner) return jsonResponse({ error: "No matching owner was found for this request" }, 400);
|
||||
} else {
|
||||
const ownerId = normalize(body.owner_id);
|
||||
if (!ownerId) return jsonResponse({ error: "Owner ID is required" }, 400);
|
||||
const { data, error } = await adminClient.from("owners").select("id, first_name, last_name, email, association_id, unit_id, user_id, exclude_from_signin").eq("id", ownerId).single();
|
||||
if (error || !data) return jsonResponse({ error: "Owner record not found" }, 404);
|
||||
owner = data;
|
||||
email = normalizeEmail(body.email || owner.email);
|
||||
}
|
||||
|
||||
if (!email) return jsonResponse({ error: "Owner email is required before sending an invite" }, 400);
|
||||
if (owner.exclude_from_signin) return jsonResponse({ error: "This owner is excluded from sign-in" }, 400);
|
||||
|
||||
const userId = await createOrLinkOwnerAccount(adminClient, owner, email);
|
||||
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
|
||||
type: "recovery",
|
||||
email,
|
||||
options: { redirectTo: `${origin.replace(/\/$/, "")}/reset-password` },
|
||||
});
|
||||
if (linkError) throw linkError;
|
||||
const actionLink = linkData?.properties?.action_link;
|
||||
if (!actionLink) throw new Error("Could not generate account setup link");
|
||||
|
||||
const html = buildActionEmailHtml({
|
||||
title: action === "approve_request" ? "Your account has been approved" : "You are invited to Avria Community Management",
|
||||
intro: action === "approve_request"
|
||||
? "Your homeowner portal account has been approved. Click below to choose your password and sign in."
|
||||
: "You have been invited to access your homeowner portal. Click below to choose your password and get started.",
|
||||
buttonLabel: "Choose Password",
|
||||
actionLink,
|
||||
});
|
||||
const sendResult = await sendManagedEmail(callerClient, email, action === "approve_request" ? "Your account has been approved" : "Homeowner portal invitation", html);
|
||||
|
||||
if (requestId) {
|
||||
await adminClient.from("owner_registration_requests").update({
|
||||
status: "approved",
|
||||
owner_id: owner.id,
|
||||
unit_id: owner.unit_id,
|
||||
association_id: owner.association_id,
|
||||
reviewed_by: callerId,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
created_user_id: userId,
|
||||
}).eq("id", requestId);
|
||||
}
|
||||
|
||||
return jsonResponse({ success: true, user_id: userId, email_sent: !sendResult.skipped, action_link: sendResult.skipped ? actionLink : undefined, warning: sendResult.skipped ? sendResult.reason : undefined });
|
||||
}
|
||||
|
||||
if (action === "reject_request") {
|
||||
const auth = await getAuthorizedCaller(req, anonKey);
|
||||
if (auth.error) return auth.error;
|
||||
const requestId = normalize(body.request_id);
|
||||
if (!requestId) return jsonResponse({ error: "Request ID is required" }, 400);
|
||||
const { error } = await adminClient.from("owner_registration_requests").update({
|
||||
status: "rejected",
|
||||
notes: normalize(body.notes) || null,
|
||||
reviewed_by: auth.callerId,
|
||||
reviewed_at: new Date().toISOString(),
|
||||
}).eq("id", requestId);
|
||||
if (error) throw error;
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
return jsonResponse({ error: "Invalid action" }, 400);
|
||||
} catch (err: any) {
|
||||
console.error("homeowner-signup error:", err);
|
||||
return jsonResponse({ error: err.message || "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user