mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1258 lines
48 KiB
TypeScript
1258 lines
48 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, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
|
|
};
|
|
|
|
function getAppOrigin(req: Request) {
|
|
const origin = req.headers.get("origin");
|
|
if (origin) return origin.replace(/\/$/, "");
|
|
|
|
const referer = req.headers.get("referer");
|
|
if (referer) {
|
|
try {
|
|
return new URL(referer).origin;
|
|
} catch {
|
|
// Ignore malformed referer values
|
|
}
|
|
}
|
|
|
|
const forwardedHost = req.headers.get("x-forwarded-host");
|
|
if (forwardedHost) {
|
|
const forwardedProto = req.headers.get("x-forwarded-proto") || "https";
|
|
return `${forwardedProto}://${forwardedHost}`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getRecoveryLinkOptions(req: Request) {
|
|
// Always use production domain for reset links sent via email
|
|
const productionOrigin = "https://avria.cloud";
|
|
return { redirectTo: `${productionOrigin}/reset-password` };
|
|
}
|
|
|
|
function getInviteLinkOptions(req: Request) {
|
|
const productionOrigin = "https://avria.cloud";
|
|
return { redirectTo: `${productionOrigin}/reset-password?mode=invite` };
|
|
}
|
|
|
|
function escapeHtml(value: string) {
|
|
return String(value)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function buildActionEmailHtml({
|
|
title,
|
|
intro,
|
|
buttonLabel,
|
|
actionLink,
|
|
customMessage,
|
|
}: {
|
|
title: string;
|
|
intro: string;
|
|
buttonLabel: string;
|
|
actionLink: string;
|
|
customMessage?: string;
|
|
}) {
|
|
const safeActionLink = escapeHtml(actionLink);
|
|
const customMsgHtml = customMessage
|
|
? `<div style="background-color:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:16px;margin-bottom:20px;font-size:15px;color:#334155;line-height:1.6;">${escapeHtml(customMessage).replace(/\n/g, "<br>")}</div>`
|
|
: "";
|
|
|
|
return `
|
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;padding:24px;background:#ffffff;">
|
|
<h2 style="color:#1e293b;margin:0 0 16px;font-size:24px;line-height:1.25;">${escapeHtml(title)}</h2>
|
|
${customMsgHtml}
|
|
<p style="color:#334155;font-size:15px;line-height:1.6;margin:0 0 24px;">${escapeHtml(intro)}</p>
|
|
<div style="text-align:center;margin:28px 0;">
|
|
<a href="${safeActionLink}" style="background-color:#0f172a;color:#ffffff;padding:13px 28px;text-decoration:none;border-radius:6px;font-weight:700;display:inline-block;font-size:15px;line-height:1.2;">${escapeHtml(buttonLabel)}</a>
|
|
</div>
|
|
<p style="color:#475569;font-size:13px;line-height:1.6;margin:0 0 8px;">If the button does not open, copy and paste this secure link into your browser:</p>
|
|
<p style="word-break:break-all;margin:0 0 24px;"><a href="${safeActionLink}" style="color:#0f172a;text-decoration:underline;font-size:13px;line-height:1.6;">${safeActionLink}</a></p>
|
|
<p style="color:#64748b;font-size:13px;margin:0;">If you did not request this, you can safely ignore this email.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function getVerifiedSender(adminClient: any) {
|
|
const { data: senders, 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) throw error;
|
|
|
|
return senders?.[0] ?? null;
|
|
}
|
|
|
|
async function sendManagedEmail({
|
|
callerClient,
|
|
senderId,
|
|
recipient,
|
|
subject,
|
|
html,
|
|
}: {
|
|
callerClient: any;
|
|
senderId: string;
|
|
recipient: string;
|
|
subject: string;
|
|
html: string;
|
|
}) {
|
|
const { data, error } = await callerClient.functions.invoke("send-smtp-email", {
|
|
body: {
|
|
sender_id: senderId,
|
|
recipient: [recipient],
|
|
subject,
|
|
body: html,
|
|
html,
|
|
debug: false,
|
|
},
|
|
});
|
|
|
|
if (error) throw error;
|
|
if (data && typeof data === "object" && "success" in data && data.success === false) {
|
|
throw new Error((data as { error?: string }).error || "Email send failed");
|
|
}
|
|
}
|
|
|
|
function throwIfDbError(error: { message?: string } | null, context: string) {
|
|
if (error) throw new Error(`${context}: ${error.message || "Database operation failed"}`);
|
|
}
|
|
|
|
async function syncLegalAssociationAssignments(
|
|
adminClient: any,
|
|
userId: string,
|
|
assocIds: string[],
|
|
callerId: string,
|
|
) {
|
|
const { error: deleteError } = await adminClient
|
|
.from("legal_association_assignments")
|
|
.delete()
|
|
.eq("user_id", userId);
|
|
throwIfDbError(deleteError, "Could not clear existing legal association assignments");
|
|
|
|
if (assocIds.length === 0) return;
|
|
|
|
const rows = assocIds.map((aid: string) => ({
|
|
user_id: userId,
|
|
association_id: aid,
|
|
created_by: callerId,
|
|
}));
|
|
|
|
const { error: insertError } = await adminClient
|
|
.from("legal_association_assignments")
|
|
.upsert(rows, { onConflict: "user_id,association_id" });
|
|
throwIfDbError(insertError, "Could not save legal association assignments");
|
|
}
|
|
|
|
async function syncMasterBoardAssignments(
|
|
adminClient: any,
|
|
userId: string,
|
|
assocIds: string[],
|
|
callerId: string,
|
|
) {
|
|
const { error: deleteError } = await adminClient
|
|
.from("master_board_assignments")
|
|
.delete()
|
|
.eq("user_id", userId);
|
|
throwIfDbError(deleteError, "Could not clear existing master board assignments");
|
|
|
|
if (assocIds.length === 0) return;
|
|
|
|
const rows = assocIds.map((aid: string) => ({
|
|
user_id: userId,
|
|
association_id: aid,
|
|
created_by: callerId,
|
|
}));
|
|
|
|
const { error: insertError } = await adminClient
|
|
.from("master_board_assignments")
|
|
.upsert(rows, { onConflict: "user_id,association_id" });
|
|
throwIfDbError(insertError, "Could not save master board assignments");
|
|
}
|
|
|
|
async function listAllAuthUsers(adminClient: any) {
|
|
let allAuthUsers: any[] = [];
|
|
let page = 1;
|
|
while (true) {
|
|
const { data, error } = await adminClient.auth.admin.listUsers({ page, perPage: 1000 });
|
|
if (error) throw error;
|
|
allAuthUsers = allAuthUsers.concat(data.users || []);
|
|
if ((data.users || []).length < 1000) break;
|
|
page++;
|
|
}
|
|
return allAuthUsers;
|
|
}
|
|
|
|
async function buildManagedUsers(adminClient: any) {
|
|
const [authUsers, profilesRes, rolesRes, ownersRes, boardMembersRes, legalAssignmentsRes, masterBoardAssignmentsRes] = await Promise.all([
|
|
listAllAuthUsers(adminClient),
|
|
adminClient.from("profiles").select("*"),
|
|
adminClient.from("user_roles").select("*"),
|
|
adminClient.from("owners").select("id, first_name, last_name, user_id, unit_id, association_id").eq("status", "active").not("user_id", "is", null),
|
|
adminClient.from("board_members").select("user_id, association_id, approval_authority").not("user_id", "is", null),
|
|
adminClient.from("legal_association_assignments").select("user_id, association_id"),
|
|
adminClient.from("master_board_assignments").select("user_id, association_id"),
|
|
]);
|
|
|
|
throwIfDbError(profilesRes.error, "Could not load profiles");
|
|
throwIfDbError(rolesRes.error, "Could not load user roles");
|
|
throwIfDbError(ownersRes.error, "Could not load owner links");
|
|
throwIfDbError(boardMembersRes.error, "Could not load board members");
|
|
throwIfDbError(legalAssignmentsRes.error, "Could not load legal assignments");
|
|
throwIfDbError(masterBoardAssignmentsRes.error, "Could not load master board assignments");
|
|
|
|
const roleMap = new Map<string, string[]>();
|
|
(rolesRes.data || []).forEach((r: any) => {
|
|
if (!roleMap.has(r.user_id)) roleMap.set(r.user_id, []);
|
|
roleMap.get(r.user_id)!.push(r.role);
|
|
});
|
|
|
|
const ownerUnitMap = new Map<string, string[]>();
|
|
(ownersRes.data || []).forEach((o: any) => {
|
|
if (!o.user_id || !o.unit_id) return;
|
|
const existing = ownerUnitMap.get(o.user_id) || [];
|
|
if (!existing.includes(o.unit_id)) existing.push(o.unit_id);
|
|
ownerUnitMap.set(o.user_id, existing);
|
|
});
|
|
|
|
const boardMemberMap = new Map<string, { association_ids: string[]; approval_authority: boolean }>();
|
|
(boardMembersRes.data || []).forEach((bm: any) => {
|
|
if (!bm.user_id) return;
|
|
const existing = boardMemberMap.get(bm.user_id) || { association_ids: [], approval_authority: false };
|
|
if (!existing.association_ids.includes(bm.association_id)) existing.association_ids.push(bm.association_id);
|
|
if (bm.approval_authority) existing.approval_authority = true;
|
|
boardMemberMap.set(bm.user_id, existing);
|
|
});
|
|
|
|
const toAssignmentMap = (rows: any[] = []) => {
|
|
const map = new Map<string, string[]>();
|
|
rows.forEach((assignment: any) => {
|
|
const existing = map.get(assignment.user_id) || [];
|
|
if (!existing.includes(assignment.association_id)) existing.push(assignment.association_id);
|
|
map.set(assignment.user_id, existing);
|
|
});
|
|
return map;
|
|
};
|
|
|
|
const legalAssignmentMap = toAssignmentMap(legalAssignmentsRes.data || []);
|
|
const masterBoardAssignmentMap = toAssignmentMap(masterBoardAssignmentsRes.data || []);
|
|
const profileMap = new Map((profilesRes.data || []).map((p: any) => [p.user_id, p]));
|
|
const usersById = new Map<string, any>();
|
|
for (const authUser of authUsers) {
|
|
const profile: any = profileMap.get(authUser.id) || {};
|
|
usersById.set(authUser.id, {
|
|
user_id: authUser.id,
|
|
full_name: profile.full_name || authUser.user_metadata?.full_name || authUser.user_metadata?.name || null,
|
|
email: profile.email || authUser.email || "",
|
|
phone: profile.phone || authUser.phone || "",
|
|
is_blocked: profile.is_blocked || false,
|
|
created_at: profile.created_at || authUser.created_at,
|
|
roles: roleMap.get(authUser.id) || [],
|
|
linked_unit_ids: ownerUnitMap.get(authUser.id) || [],
|
|
board_member_association_ids: boardMemberMap.get(authUser.id)?.association_ids || [],
|
|
legal_association_ids: legalAssignmentMap.get(authUser.id) || [],
|
|
master_board_association_ids: masterBoardAssignmentMap.get(authUser.id) || [],
|
|
is_bill_approver: boardMemberMap.get(authUser.id)?.approval_authority || false,
|
|
last_sign_in_at: authUser.last_sign_in_at || null,
|
|
auth_email: authUser.email || "",
|
|
primary_owner_id: profile.primary_owner_id || null,
|
|
primary_association_id: profile.primary_association_id || null,
|
|
profile_missing: !profileMap.has(authUser.id),
|
|
});
|
|
}
|
|
|
|
for (const profile of profilesRes.data || []) {
|
|
if (usersById.has(profile.user_id)) continue;
|
|
usersById.set(profile.user_id, {
|
|
user_id: profile.user_id,
|
|
full_name: profile.full_name || null,
|
|
email: profile.email || "",
|
|
phone: profile.phone || "",
|
|
is_blocked: profile.is_blocked || false,
|
|
created_at: profile.created_at,
|
|
roles: roleMap.get(profile.user_id) || [],
|
|
linked_unit_ids: ownerUnitMap.get(profile.user_id) || [],
|
|
board_member_association_ids: boardMemberMap.get(profile.user_id)?.association_ids || [],
|
|
legal_association_ids: legalAssignmentMap.get(profile.user_id) || [],
|
|
master_board_association_ids: masterBoardAssignmentMap.get(profile.user_id) || [],
|
|
is_bill_approver: boardMemberMap.get(profile.user_id)?.approval_authority || false,
|
|
last_sign_in_at: null,
|
|
primary_owner_id: profile.primary_owner_id || null,
|
|
primary_association_id: profile.primary_association_id || null,
|
|
});
|
|
}
|
|
|
|
return Array.from(usersById.values()).sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || "")));
|
|
}
|
|
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === "OPTIONS") {
|
|
return new Response(null, { headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
// Verify the caller
|
|
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 serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
|
|
|
|
// Client with caller's JWT to check role
|
|
const callerClient = createClient(supabaseUrl, anonKey, {
|
|
global: { headers: { Authorization: authHeader } },
|
|
});
|
|
|
|
const token = authHeader.replace("Bearer ", "");
|
|
const {
|
|
data: claimsData,
|
|
error: callerError,
|
|
} = await callerClient.auth.getClaims(token);
|
|
|
|
const callerId = claimsData?.claims?.sub;
|
|
if (callerError || !callerId) {
|
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
status: 401,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
// Check if caller has User Management permission.
|
|
// Admins/managers pass automatically — verified directly here so the function
|
|
// still works when has_feature_permission is missing or errors.
|
|
const adminClientForRoles = createClient(supabaseUrl, serviceRoleKey);
|
|
const { data: callerRolesData } = await adminClientForRoles
|
|
.from("user_roles")
|
|
.select("role")
|
|
.eq("user_id", callerId);
|
|
const callerRoles = (callerRolesData || []).map((r: any) => r.role);
|
|
const isAdminOrManager = callerRoles.includes("admin") || callerRoles.includes("manager");
|
|
|
|
let hasReadAccess: boolean = isAdminOrManager;
|
|
if (!isAdminOrManager) {
|
|
const { data: rpcData } = await callerClient.rpc("has_feature_permission", {
|
|
_user_id: callerId,
|
|
_feature_area: "User Management",
|
|
_action: "read",
|
|
});
|
|
hasReadAccess = !!rpcData;
|
|
}
|
|
if (!hasReadAccess) {
|
|
return new Response(JSON.stringify({ error: "User Management access required" }), {
|
|
status: 403,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
// Helper to check specific action permissions
|
|
const checkPermission = async (action: string) => {
|
|
if (isAdminOrManager) return true;
|
|
const { data: allowed } = await callerClient.rpc("has_feature_permission", {
|
|
_user_id: callerId,
|
|
_feature_area: "User Management",
|
|
_action: action,
|
|
});
|
|
return !!allowed;
|
|
};
|
|
|
|
// Admin client with service role for user management
|
|
const adminClient = createClient(supabaseUrl, serviceRoleKey, {
|
|
auth: { autoRefreshToken: false, persistSession: false },
|
|
});
|
|
|
|
const body = await req.json();
|
|
const { action } = body;
|
|
const recoveryLinkOptions = getRecoveryLinkOptions(req);
|
|
const inviteLinkOptions = getInviteLinkOptions(req);
|
|
|
|
let result: any;
|
|
|
|
switch (action) {
|
|
case "list_users": {
|
|
const { data, error } = await adminClient.auth.admin.listUsers({
|
|
page: body.page || 1,
|
|
perPage: body.perPage || 100,
|
|
});
|
|
if (error) throw error;
|
|
result = data;
|
|
break;
|
|
}
|
|
|
|
case "list_managed_users": {
|
|
result = { users: await buildManagedUsers(adminClient) };
|
|
break;
|
|
}
|
|
|
|
case "create_user": {
|
|
if (!(await checkPermission("create"))) throw new Error("Create permission denied");
|
|
const { password, full_name, role, owner_id, unit_ids, association_id, association_ids, is_board_member, is_arc_member, is_fining_committee, is_bill_approver } = body;
|
|
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
|
|
if (!email || !password) throw new Error("Email and password required");
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) {
|
|
throw new Error(`Invalid email format: "${email}"`);
|
|
}
|
|
|
|
// Normalize to array, supporting both association_id (legacy) and association_ids (new)
|
|
const assocIds: string[] = Array.isArray(association_ids) && association_ids.length > 0
|
|
? association_ids
|
|
: (association_id ? [association_id] : []);
|
|
|
|
// Create auth user
|
|
const { data: newUser, error: createError } =
|
|
await adminClient.auth.admin.createUser({
|
|
email,
|
|
password,
|
|
email_confirm: true,
|
|
user_metadata: { full_name: full_name || "" },
|
|
});
|
|
if (createError) throw createError;
|
|
|
|
const userId = newUser.user.id;
|
|
|
|
// Ensure profile exists (trigger should create it, but update name/email)
|
|
await adminClient
|
|
.from("profiles")
|
|
.update({ full_name: full_name || "", email })
|
|
.eq("user_id", userId);
|
|
|
|
// Set primary role
|
|
if (role) {
|
|
await adminClient
|
|
.from("user_roles")
|
|
.delete()
|
|
.eq("user_id", userId);
|
|
await adminClient
|
|
.from("user_roles")
|
|
.insert({ user_id: userId, role });
|
|
}
|
|
|
|
// Add additional committee roles
|
|
const additionalRoles: string[] = [];
|
|
if (is_board_member && role !== "board_member") additionalRoles.push("board_member");
|
|
if (is_arc_member && role !== "arc_member") additionalRoles.push("arc_member");
|
|
if (is_fining_committee && role !== "fining_member") additionalRoles.push("fining_member");
|
|
if (additionalRoles.length > 0) {
|
|
await adminClient
|
|
.from("user_roles")
|
|
.insert(additionalRoles.map((r) => ({ user_id: userId, role: r })));
|
|
}
|
|
|
|
// Link to owner if provided, or auto-link by email/name match using first association
|
|
const primaryAssocId = assocIds[0] || null;
|
|
if (role !== "legal" && owner_id) {
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: userId })
|
|
.eq("id", owner_id);
|
|
} else if (role !== "legal" && primaryAssocId && email) {
|
|
// Auto-link: find unlinked owner in same association by email match
|
|
const { data: emailMatch } = await adminClient
|
|
.from("owners")
|
|
.select("id")
|
|
.eq("association_id", primaryAssocId)
|
|
.eq("status", "active")
|
|
.is("user_id", null)
|
|
.eq("email", email)
|
|
.limit(1);
|
|
|
|
if (emailMatch && emailMatch.length > 0) {
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: userId })
|
|
.eq("id", emailMatch[0].id);
|
|
} else if (full_name) {
|
|
// Try matching by name (first_name + last_name contains full_name)
|
|
const nameParts = (full_name as string).trim().split(/\s+/);
|
|
const firstName = nameParts[0] || "";
|
|
const lastName = nameParts.slice(1).join(" ") || "";
|
|
|
|
let query = adminClient
|
|
.from("owners")
|
|
.select("id")
|
|
.eq("association_id", primaryAssocId)
|
|
.eq("status", "active")
|
|
.is("user_id", null);
|
|
|
|
if (firstName) query = query.ilike("first_name", `%${firstName}%`);
|
|
if (lastName) query = query.ilike("last_name", `%${lastName}%`);
|
|
|
|
const { data: nameMatch } = await query.limit(1);
|
|
if (nameMatch && nameMatch.length > 0) {
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: userId })
|
|
.eq("id", nameMatch[0].id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Association-only roles (legal, master_board_member): tag communities, no unit links.
|
|
if (role === "legal") {
|
|
await syncLegalAssociationAssignments(adminClient, userId, assocIds, callerId);
|
|
} else if (role === "master_board_member") {
|
|
await syncMasterBoardAssignments(adminClient, userId, assocIds, callerId);
|
|
} else if (Array.isArray(unit_ids) && unit_ids.length > 0) {
|
|
for (const uid of unit_ids) {
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: userId })
|
|
.eq("unit_id", uid)
|
|
.is("user_id", null);
|
|
}
|
|
}
|
|
|
|
// Create board_members records for all assigned associations
|
|
if ((role === "board_member" || is_board_member) && assocIds.length > 0) {
|
|
const boardMemberRows = assocIds.map((aid: string) => ({
|
|
association_id: aid,
|
|
member_name: full_name || email,
|
|
member_email: email,
|
|
user_id: userId,
|
|
role: "Member",
|
|
approval_authority: is_bill_approver || false,
|
|
}));
|
|
await adminClient.from("board_members").insert(boardMemberRows);
|
|
}
|
|
|
|
// Create arc_committee_members records for all assigned associations when ARC member
|
|
if ((role === "arc_member" || is_arc_member) && assocIds.length > 0) {
|
|
// Remove any existing rows for this email (avoid duplicates from manual entries)
|
|
await adminClient.from("arc_committee_members").delete().ilike("email", email);
|
|
const arcRows = assocIds.map((aid: string) => ({
|
|
association_id: aid,
|
|
name: full_name || email,
|
|
email,
|
|
is_active: true,
|
|
}));
|
|
await adminClient.from("arc_committee_members").insert(arcRows);
|
|
}
|
|
|
|
result = { user: newUser.user };
|
|
break;
|
|
}
|
|
|
|
case "update_user": {
|
|
if (!(await checkPermission("edit"))) throw new Error("Edit permission denied");
|
|
const { user_id, email, full_name, role, owner_id, unit_ids, phone, association_id, association_ids, is_board_member, is_arc_member, is_fining_committee, is_bill_approver } = body;
|
|
if (!user_id) throw new Error("user_id required");
|
|
|
|
// Normalize to array
|
|
const assocIds: string[] = Array.isArray(association_ids) && association_ids.length > 0
|
|
? association_ids
|
|
: (association_id ? [association_id] : []);
|
|
|
|
// Update auth email if changed
|
|
if (email) {
|
|
const { error } = await adminClient.auth.admin.updateUserById(
|
|
user_id,
|
|
{ email, email_confirm: true }
|
|
);
|
|
if (error) throw error;
|
|
}
|
|
|
|
// Update profile
|
|
const profileUpdate: any = {};
|
|
if (full_name !== undefined) profileUpdate.full_name = full_name;
|
|
if (email !== undefined) profileUpdate.email = email;
|
|
if (phone !== undefined) profileUpdate.phone = phone;
|
|
if (Object.keys(profileUpdate).length > 0) {
|
|
await adminClient
|
|
.from("profiles")
|
|
.update(profileUpdate)
|
|
.eq("user_id", user_id);
|
|
}
|
|
|
|
// Update roles: set primary + additional committee roles
|
|
if (role) {
|
|
await adminClient
|
|
.from("user_roles")
|
|
.delete()
|
|
.eq("user_id", user_id);
|
|
|
|
const rolesToInsert: { user_id: string; role: string }[] = [
|
|
{ user_id, role },
|
|
];
|
|
if (is_board_member && role !== "board_member") rolesToInsert.push({ user_id, role: "board_member" });
|
|
if (is_arc_member && role !== "arc_member") rolesToInsert.push({ user_id, role: "arc_member" });
|
|
if (is_fining_committee && role !== "fining_member") rolesToInsert.push({ user_id, role: "fining_member" });
|
|
|
|
await adminClient
|
|
.from("user_roles")
|
|
.insert(rolesToInsert);
|
|
}
|
|
|
|
// Sync community assignments. Legal/master_board are association-only; otherwise link by unit_ids/owner_id.
|
|
if (role === "legal") {
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: null })
|
|
.eq("user_id", user_id);
|
|
await syncMasterBoardAssignments(adminClient, user_id, [], callerId);
|
|
await syncLegalAssociationAssignments(adminClient, user_id, assocIds, callerId);
|
|
} else if (role === "master_board_member") {
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: null })
|
|
.eq("user_id", user_id);
|
|
await syncLegalAssociationAssignments(adminClient, user_id, [], callerId);
|
|
await syncMasterBoardAssignments(adminClient, user_id, assocIds, callerId);
|
|
} else if (Array.isArray(unit_ids)) {
|
|
await syncLegalAssociationAssignments(adminClient, user_id, [], callerId);
|
|
await syncMasterBoardAssignments(adminClient, user_id, [], callerId);
|
|
// Unlink all existing owners for this user
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: null })
|
|
.eq("user_id", user_id);
|
|
// Link owners for the specified units
|
|
for (const uid of unit_ids) {
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: user_id })
|
|
.eq("unit_id", uid);
|
|
}
|
|
} else if (owner_id !== undefined) {
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: null })
|
|
.eq("user_id", user_id);
|
|
if (owner_id) {
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: user_id })
|
|
.eq("id", owner_id);
|
|
}
|
|
}
|
|
|
|
// Sync board_members records for all associations
|
|
// Remove all existing board_members for this user
|
|
await adminClient
|
|
.from("board_members")
|
|
.delete()
|
|
.eq("user_id", user_id);
|
|
|
|
// Re-create for each assigned association if board_member role or flag
|
|
if ((role === "board_member" || is_board_member) && assocIds.length > 0) {
|
|
const boardMemberRows = assocIds.map((aid: string) => ({
|
|
association_id: aid,
|
|
member_name: full_name || email || "",
|
|
member_email: email || "",
|
|
user_id,
|
|
role: "Member",
|
|
approval_authority: is_bill_approver || false,
|
|
}));
|
|
await adminClient.from("board_members").insert(boardMemberRows);
|
|
}
|
|
|
|
// Sync arc_committee_members. Always clear existing rows for this user's email,
|
|
// then re-create when the user is an ARC member for each assigned association.
|
|
if (email) {
|
|
await adminClient.from("arc_committee_members").delete().ilike("email", email);
|
|
}
|
|
if ((role === "arc_member" || is_arc_member) && assocIds.length > 0 && email) {
|
|
const arcRows = assocIds.map((aid: string) => ({
|
|
association_id: aid,
|
|
name: full_name || email,
|
|
email,
|
|
is_active: true,
|
|
}));
|
|
await adminClient.from("arc_committee_members").insert(arcRows);
|
|
}
|
|
|
|
result = { success: true };
|
|
break;
|
|
}
|
|
|
|
case "delete_user": {
|
|
if (!(await checkPermission("delete"))) throw new Error("Delete permission denied");
|
|
const { user_id } = body;
|
|
if (!user_id) throw new Error("user_id required");
|
|
|
|
// Unlink from owners and board_members before deleting auth user
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: null })
|
|
.eq("user_id", user_id);
|
|
|
|
await adminClient
|
|
.from("board_members")
|
|
.update({ user_id: null })
|
|
.eq("user_id", user_id);
|
|
|
|
await adminClient
|
|
.from("legal_association_assignments")
|
|
.delete()
|
|
.eq("user_id", user_id);
|
|
|
|
await adminClient
|
|
.from("master_board_assignments")
|
|
.delete()
|
|
.eq("user_id", user_id);
|
|
|
|
// Delete auth user (cascades to profiles and user_roles)
|
|
const { error } = await adminClient.auth.admin.deleteUser(user_id);
|
|
if (error) throw error;
|
|
|
|
result = { success: true };
|
|
break;
|
|
}
|
|
|
|
case "bulk_delete_users": {
|
|
if (!(await checkPermission("delete"))) throw new Error("Delete permission denied");
|
|
const { user_ids } = body;
|
|
if (!Array.isArray(user_ids) || user_ids.length === 0) throw new Error("user_ids array required");
|
|
|
|
const results: { deleted: number; errors: string[] } = { deleted: 0, errors: [] };
|
|
|
|
for (const uid of user_ids) {
|
|
try {
|
|
// Unlink from owners and board_members
|
|
await adminClient
|
|
.from("owners")
|
|
.update({ user_id: null })
|
|
.eq("user_id", uid);
|
|
|
|
await adminClient
|
|
.from("board_members")
|
|
.update({ user_id: null })
|
|
.eq("user_id", uid);
|
|
|
|
await adminClient
|
|
.from("legal_association_assignments")
|
|
.delete()
|
|
.eq("user_id", uid);
|
|
|
|
await adminClient
|
|
.from("master_board_assignments")
|
|
.delete()
|
|
.eq("user_id", uid);
|
|
|
|
// Delete auth user (cascades to profiles and user_roles)
|
|
const { error: delErr } = await adminClient.auth.admin.deleteUser(uid);
|
|
if (delErr) throw delErr;
|
|
results.deleted++;
|
|
} catch (e: any) {
|
|
results.errors.push(`${uid}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
result = results;
|
|
break;
|
|
}
|
|
|
|
case "bulk_update_users": {
|
|
if (!(await checkPermission("edit"))) throw new Error("Edit permission denied");
|
|
const { user_ids, role, association_id, blocked } = body;
|
|
if (!Array.isArray(user_ids) || user_ids.length === 0) throw new Error("user_ids array required");
|
|
|
|
const results: { updated: number; errors: string[] } = { updated: 0, errors: [] };
|
|
|
|
for (const uid of user_ids) {
|
|
try {
|
|
// Update role if provided
|
|
if (role) {
|
|
await adminClient.from("user_roles").delete().eq("user_id", uid);
|
|
await adminClient.from("user_roles").insert({ user_id: uid, role });
|
|
}
|
|
|
|
// Update association link if provided.
|
|
if (association_id !== undefined) {
|
|
await adminClient.from("board_members").delete().eq("user_id", uid);
|
|
await adminClient.from("legal_association_assignments").delete().eq("user_id", uid);
|
|
await adminClient.from("master_board_assignments").delete().eq("user_id", uid);
|
|
|
|
if (association_id) {
|
|
// Get user's profile for name/email
|
|
const { data: profile } = await adminClient
|
|
.from("profiles")
|
|
.select("full_name, email")
|
|
.eq("user_id", uid)
|
|
.single();
|
|
|
|
if (role === "legal") {
|
|
await adminClient.from("legal_association_assignments").insert({ user_id: uid, association_id, created_by: callerId });
|
|
} else if (role === "master_board_member") {
|
|
await adminClient.from("master_board_assignments").insert({ user_id: uid, association_id, created_by: callerId });
|
|
} else {
|
|
await adminClient.from("board_members").insert({
|
|
association_id,
|
|
member_name: profile?.full_name || profile?.email || "",
|
|
member_email: profile?.email || "",
|
|
user_id: uid,
|
|
role: "Member",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update blocked status if provided
|
|
if (blocked !== undefined) {
|
|
await adminClient
|
|
.from("profiles")
|
|
.update({ is_blocked: !!blocked })
|
|
.eq("user_id", uid);
|
|
|
|
await adminClient.auth.admin.updateUserById(uid, {
|
|
ban_duration: blocked ? "876600h" : "none",
|
|
});
|
|
}
|
|
|
|
results.updated++;
|
|
} catch (e: any) {
|
|
results.errors.push(`${uid}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
result = results;
|
|
break;
|
|
}
|
|
|
|
case "sign_in_as_user": {
|
|
// Only admins can impersonate
|
|
const { data: isAdmin } = await callerClient.rpc("has_role", {
|
|
_user_id: callerId,
|
|
_role: "admin",
|
|
});
|
|
if (!isAdmin) throw new Error("Only admins can sign in as another user");
|
|
|
|
const { user_id } = body;
|
|
if (!user_id) throw new Error("user_id required");
|
|
|
|
// Generate a magic link for the target user
|
|
const { data: userData, error: userError } = await adminClient.auth.admin.getUserById(user_id);
|
|
if (userError || !userData?.user?.email) throw new Error("Could not find user email");
|
|
|
|
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
|
|
type: "magiclink",
|
|
email: userData.user.email,
|
|
});
|
|
if (linkError) throw linkError;
|
|
|
|
// Return the token hash and verification type so the client can exchange it
|
|
result = {
|
|
success: true,
|
|
token_hash: linkData?.properties?.hashed_token,
|
|
email: userData.user.email,
|
|
};
|
|
break;
|
|
}
|
|
|
|
case "reset_password": {
|
|
const { user_id, new_password } = body;
|
|
if (!user_id || !new_password)
|
|
throw new Error("user_id and new_password required");
|
|
|
|
const { error } = await adminClient.auth.admin.updateUserById(
|
|
user_id,
|
|
{ password: new_password }
|
|
);
|
|
if (error) throw error;
|
|
|
|
result = { success: true };
|
|
break;
|
|
}
|
|
|
|
case "block_user": {
|
|
const { user_id, blocked } = body;
|
|
if (!user_id) throw new Error("user_id required");
|
|
|
|
// Update profile blocked status
|
|
await adminClient
|
|
.from("profiles")
|
|
.update({ is_blocked: !!blocked })
|
|
.eq("user_id", user_id);
|
|
|
|
// Ban/unban in auth
|
|
const { error } = await adminClient.auth.admin.updateUserById(
|
|
user_id,
|
|
{ ban_duration: blocked ? "876600h" : "none" }
|
|
);
|
|
if (error) throw error;
|
|
|
|
result = { success: true, blocked: !!blocked };
|
|
break;
|
|
}
|
|
|
|
case "send_reset_email": {
|
|
const { email, custom_message } = body;
|
|
if (!email) throw new Error("email required");
|
|
|
|
const { data: linkData, error: linkError } =
|
|
await adminClient.auth.admin.generateLink({
|
|
type: "recovery",
|
|
email,
|
|
...(recoveryLinkOptions ? { options: recoveryLinkOptions } : {}),
|
|
});
|
|
if (linkError) throw linkError;
|
|
|
|
const resetLink = linkData?.properties?.action_link || "";
|
|
|
|
if (!resetLink) throw new Error("Could not generate reset link");
|
|
|
|
const sender = await getVerifiedSender(adminClient);
|
|
if (!sender?.id) throw new Error("No verified email sender is configured");
|
|
|
|
const htmlBody = buildActionEmailHtml({
|
|
title: "Password Reset",
|
|
intro: "Click the button below to reset your password:",
|
|
buttonLabel: "Reset Password",
|
|
actionLink: resetLink,
|
|
customMessage: custom_message,
|
|
});
|
|
|
|
await sendManagedEmail({
|
|
callerClient,
|
|
senderId: sender.id,
|
|
recipient: email,
|
|
subject: "Password Reset Request",
|
|
html: htmlBody,
|
|
});
|
|
|
|
result = { success: true, message: "Password reset link sent" };
|
|
break;
|
|
}
|
|
|
|
case "invite_user": {
|
|
if (!(await checkPermission("create"))) throw new Error("Create permission denied");
|
|
const { email } = body;
|
|
if (!email) throw new Error("email required");
|
|
|
|
// Try invite first; if user already exists, send a recovery link instead
|
|
const { data, error } = await adminClient.auth.admin.inviteUserByEmail(email, inviteLinkOptions);
|
|
|
|
if (error && error.message?.includes("already been registered")) {
|
|
// User exists — generate a recovery/welcome link and send via SMTP
|
|
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
|
|
type: "recovery",
|
|
email,
|
|
...(recoveryLinkOptions ? { options: recoveryLinkOptions } : {}),
|
|
});
|
|
if (linkError) throw linkError;
|
|
|
|
const resetLink = linkData?.properties?.action_link || "";
|
|
|
|
if (!resetLink) throw new Error("Could not generate invitation link");
|
|
|
|
const sender = await getVerifiedSender(adminClient);
|
|
if (!sender?.id) throw new Error("No verified email sender is configured");
|
|
|
|
const htmlBody = buildActionEmailHtml({
|
|
title: "You've Been Invited",
|
|
intro: "You have been invited to access the system. Click the button below to set up your password and get started:",
|
|
buttonLabel: "Set Up Password",
|
|
actionLink: resetLink,
|
|
});
|
|
|
|
await sendManagedEmail({
|
|
callerClient,
|
|
senderId: sender.id,
|
|
recipient: email,
|
|
subject: "You've Been Invited",
|
|
html: htmlBody,
|
|
});
|
|
|
|
result = { success: true, message: `Invitation sent to ${email}` };
|
|
break;
|
|
}
|
|
|
|
if (error) throw error;
|
|
|
|
// New user — ensure profile + default role exist
|
|
if (data?.user) {
|
|
const { data: existingProfile } = await adminClient.from("profiles").select("user_id").eq("user_id", data.user.id).maybeSingle();
|
|
if (!existingProfile) {
|
|
await adminClient.from("profiles").insert({ user_id: data.user.id, full_name: "", email });
|
|
}
|
|
const { data: existingRole } = await adminClient.from("user_roles").select("id").eq("user_id", data.user.id).limit(1);
|
|
if (!existingRole?.length) {
|
|
await adminClient.from("user_roles").insert({ user_id: data.user.id, role: "homeowner" });
|
|
}
|
|
}
|
|
|
|
result = { success: true, message: `Invitation sent to ${email}` };
|
|
break;
|
|
}
|
|
|
|
case "bulk_send_reset_email": {
|
|
const { user_ids } = body;
|
|
if (!user_ids?.length) throw new Error("user_ids required");
|
|
|
|
const results: { sent: number; errors: string[] } = { sent: 0, errors: [] };
|
|
const sender = await getVerifiedSender(adminClient);
|
|
|
|
if (!sender?.id) throw new Error("No verified email sender is configured");
|
|
|
|
for (const uid of user_ids) {
|
|
try {
|
|
// Get user email
|
|
const { data: userData, error: userErr } = await adminClient.auth.admin.getUserById(uid);
|
|
if (userErr || !userData?.user?.email) {
|
|
results.errors.push(`${uid}: user not found`);
|
|
continue;
|
|
}
|
|
|
|
const { data: linkData, error } = await adminClient.auth.admin.generateLink({
|
|
type: "recovery",
|
|
email: userData.user.email,
|
|
...(recoveryLinkOptions ? { options: recoveryLinkOptions } : {}),
|
|
});
|
|
if (error) throw error;
|
|
|
|
const resetLink = linkData?.properties?.action_link || "";
|
|
if (!resetLink) throw new Error("Could not generate reset link");
|
|
|
|
const htmlBody = buildActionEmailHtml({
|
|
title: "Password Reset",
|
|
intro: "Click the button below to reset your password:",
|
|
buttonLabel: "Reset Password",
|
|
actionLink: resetLink,
|
|
});
|
|
|
|
await sendManagedEmail({
|
|
callerClient,
|
|
senderId: sender.id,
|
|
recipient: userData.user.email,
|
|
subject: "Password Reset Request",
|
|
html: htmlBody,
|
|
});
|
|
|
|
results.sent++;
|
|
} catch (e: any) {
|
|
results.errors.push(`${uid}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
result = results;
|
|
break;
|
|
}
|
|
|
|
case "bulk_invite_users": {
|
|
if (!(await checkPermission("create"))) throw new Error("Create permission denied");
|
|
const { emails } = body;
|
|
if (!emails?.length) throw new Error("emails array required");
|
|
|
|
const results: { sent: number; errors: string[] } = { sent: 0, errors: [] };
|
|
const uniqueEmails: string[] = Array.from(new Set(
|
|
(emails as unknown[]).map((email) => String(email || "").trim().toLowerCase()).filter(Boolean)
|
|
)) as string[];
|
|
|
|
for (const email of uniqueEmails) {
|
|
try {
|
|
const { data, error } = await adminClient.auth.admin.inviteUserByEmail(email, inviteLinkOptions);
|
|
if (error && error.message?.includes("already been registered")) {
|
|
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({
|
|
type: "recovery",
|
|
email,
|
|
...(recoveryLinkOptions ? { options: recoveryLinkOptions } : {}),
|
|
});
|
|
if (linkError) throw linkError;
|
|
const resetLink = linkData?.properties?.action_link || "";
|
|
if (!resetLink) throw new Error("Could not generate invitation link");
|
|
const sender = await getVerifiedSender(adminClient);
|
|
if (!sender?.id) throw new Error("No verified email sender is configured");
|
|
await sendManagedEmail({
|
|
callerClient,
|
|
senderId: sender.id,
|
|
recipient: email,
|
|
subject: "You've Been Invited",
|
|
html: buildActionEmailHtml({
|
|
title: "You've Been Invited",
|
|
intro: "You have been invited to access the system. Click the button below to set up your password and get started:",
|
|
buttonLabel: "Set Up Password",
|
|
actionLink: resetLink,
|
|
}),
|
|
});
|
|
results.sent++;
|
|
continue;
|
|
}
|
|
if (error) throw error;
|
|
|
|
if (data?.user) {
|
|
const { data: existingProfile } = await adminClient.from("profiles").select("user_id").eq("user_id", data.user.id).maybeSingle();
|
|
if (!existingProfile) {
|
|
await adminClient.from("profiles").insert({ user_id: data.user.id, full_name: "", email });
|
|
}
|
|
const { data: existingRole } = await adminClient.from("user_roles").select("id").eq("user_id", data.user.id).limit(1);
|
|
if (!existingRole?.length) {
|
|
await adminClient.from("user_roles").insert({ user_id: data.user.id, role: "homeowner" });
|
|
}
|
|
}
|
|
results.sent++;
|
|
} catch (e: any) {
|
|
results.errors.push(`${email}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
result = results;
|
|
break;
|
|
}
|
|
|
|
case "bulk_onboard_owners": {
|
|
if (!(await checkPermission("create"))) throw new Error("Create permission denied");
|
|
const { owner_ids } = body;
|
|
if (!owner_ids?.length) throw new Error("owner_ids required");
|
|
|
|
const { data: owners, error: ownersError } = await adminClient
|
|
.from("owners")
|
|
.select("id, first_name, last_name, email, user_id")
|
|
.in("id", owner_ids);
|
|
if (ownersError) throw ownersError;
|
|
|
|
const results: { sent: number; linked: number; skipped: number; errors: string[] } = { sent: 0, linked: 0, skipped: 0, errors: [] };
|
|
const sender = await getVerifiedSender(adminClient).catch(() => null);
|
|
|
|
for (const owner of owners || []) {
|
|
const email = String(owner.email || "").trim();
|
|
const fullName = `${owner.first_name || ""} ${owner.last_name || ""}`.trim();
|
|
if (!email) {
|
|
results.skipped++;
|
|
results.errors.push(`${fullName || owner.id}: missing email`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
let userId = owner.user_id;
|
|
let shouldSendSetupEmail = Boolean(userId);
|
|
if (userId) {
|
|
const { data: linkedUser, error: linkedUserError } = await adminClient.auth.admin.getUserById(userId);
|
|
const linkedEmail = String(linkedUser?.user?.email || "").trim().toLowerCase();
|
|
if (linkedUserError || !linkedUser?.user || linkedEmail !== email.toLowerCase()) {
|
|
userId = null;
|
|
shouldSendSetupEmail = false;
|
|
}
|
|
}
|
|
|
|
if (!userId) {
|
|
const { data, error } = await adminClient.auth.admin.inviteUserByEmail(email, inviteLinkOptions);
|
|
if (error && error.message?.includes("already been registered")) {
|
|
const { data: usersData } = await adminClient.auth.admin.listUsers({ page: 1, perPage: 1000 });
|
|
userId = usersData.users.find((u: any) => String(u.email || "").toLowerCase() === email.toLowerCase())?.id || null;
|
|
shouldSendSetupEmail = Boolean(userId);
|
|
} else if (error) {
|
|
throw error;
|
|
} else {
|
|
userId = data?.user?.id || null;
|
|
results.sent++;
|
|
}
|
|
}
|
|
|
|
if (!userId) throw new Error("Could not find or create user");
|
|
|
|
await adminClient.from("owners").update({ user_id: userId }).eq("id", owner.id);
|
|
results.linked++;
|
|
|
|
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").limit(1);
|
|
if (!existingRole?.length) await adminClient.from("user_roles").insert({ user_id: userId, role: "homeowner" });
|
|
|
|
if (shouldSendSetupEmail) {
|
|
if (!sender?.id) throw new Error("No verified email sender is configured");
|
|
const { data: linkData, error: linkError } = await adminClient.auth.admin.generateLink({ type: "recovery", email, ...(recoveryLinkOptions ? { options: recoveryLinkOptions } : {}) });
|
|
if (linkError) throw linkError;
|
|
const resetLink = linkData?.properties?.action_link || "";
|
|
if (!resetLink) throw new Error("Could not generate setup link");
|
|
await sendManagedEmail({
|
|
callerClient,
|
|
senderId: sender.id,
|
|
recipient: email,
|
|
subject: "Set Up Your Homeowner Portal Password",
|
|
html: buildActionEmailHtml({ title: "Homeowner Portal Access", intro: "Click the button below to set or update your password for the homeowner portal:", buttonLabel: "Set Password", actionLink: resetLink }),
|
|
});
|
|
results.sent++;
|
|
}
|
|
} catch (e: any) {
|
|
results.errors.push(`${email}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
result = results;
|
|
break;
|
|
}
|
|
|
|
case "sync_auth_users": {
|
|
if (!(await checkPermission("create"))) throw new Error("Create permission denied");
|
|
|
|
const allAuthUsers = await listAllAuthUsers(adminClient);
|
|
|
|
// Get existing profile user_ids
|
|
const { data: existingProfiles } = await adminClient
|
|
.from("profiles")
|
|
.select("user_id");
|
|
const existingIds = new Set((existingProfiles || []).map((p: any) => p.user_id));
|
|
|
|
// Find auth users without profiles
|
|
const missing = allAuthUsers.filter((u: any) => !existingIds.has(u.id));
|
|
|
|
let synced = 0;
|
|
for (const u of missing) {
|
|
const fullName = u.user_metadata?.full_name || u.email?.split("@")[0] || "";
|
|
|
|
const { error: profileError } = await adminClient.from("profiles").insert({
|
|
user_id: u.id,
|
|
full_name: fullName,
|
|
email: u.email || "",
|
|
});
|
|
throwIfDbError(profileError, `Could not create profile for ${u.email || u.id}`);
|
|
|
|
// Create default role if none exists
|
|
const { data: existingRoles } = await adminClient
|
|
.from("user_roles")
|
|
.select("id")
|
|
.eq("user_id", u.id)
|
|
.limit(1);
|
|
if (!existingRoles?.length) {
|
|
const { error: roleError } = await adminClient.from("user_roles").insert({
|
|
user_id: u.id,
|
|
role: "homeowner",
|
|
});
|
|
throwIfDbError(roleError, `Could not create role for ${u.email || u.id}`);
|
|
}
|
|
synced++;
|
|
}
|
|
|
|
result = { success: true, synced, total_auth: allAuthUsers.length, already_existed: existingIds.size };
|
|
break;
|
|
}
|
|
|
|
default:
|
|
throw new Error(`Unknown action: ${action}`);
|
|
}
|
|
|
|
return new Response(JSON.stringify(result), {
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
} catch (error: any) {
|
|
console.error("Admin user management error:", error);
|
|
return new Response(
|
|
JSON.stringify({ error: error.message || "Internal server error" }),
|
|
{
|
|
status: 400,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
}
|
|
);
|
|
}
|
|
});
|