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, "'"); } function buildActionEmailHtml({ title, intro, buttonLabel, actionLink }: { title: string; intro: string; buttonLabel: string; actionLink: string }) { return `

${escapeHtml(title)}

${escapeHtml(intro)}

${escapeHtml(buttonLabel)}

If you did not expect this email, you can safely ignore it.

`; } 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); } });