Files
acmcc/supabase/functions/homeowner-signup/index.ts
T
2026-06-01 20:19:26 -04:00

364 lines
17 KiB
TypeScript

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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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);
}
});