supersam/supabase/functions/manage-users/index.ts

184 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
createServiceClient,
getCorsHeaders,
jsonResponse,
preflightResponse,
readJsonBody,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 8 * 1024;
const ADMIN_ROLES = new Set(["admin", "mega_admin"]);
const isValidEmail = (value: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
/**
* Verify the caller's JWT and return their role from public.users.
* Returns null if not authorized.
*/
async function getCallerRole(request: Request): Promise<string | null> {
const authHeader = request.headers.get("authorization") || "";
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
if (!token) return null;
const supabase = createServiceClient();
const { data: userData, error: userError } = await supabase.auth.getUser(token);
if (userError || !userData?.user) return null;
const userId = userData.user.id;
const { data: userRow, error: roleError } = await supabase
.from("users")
.select("roles(name)")
.eq("id", userId)
.maybeSingle();
if (roleError || !userRow) return null;
return userRow.roles?.name || null;
}
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
return preflightResponse(request, "private");
}
const corsHeaders = getCorsHeaders(request, "private");
if (!corsHeaders) {
return jsonResponse({ ok: false, error: "Origin not allowed" }, 403);
}
try {
// ── Authorization ──
const callerRole = await getCallerRole(request);
if (!callerRole || !ADMIN_ROLES.has(callerRole)) {
return jsonResponse(
{ ok: false, error: "Недостаточно прав. Требуется роль admin или mega_admin." },
403,
corsHeaders,
);
}
const supabase = createServiceClient();
// ── POST: create new user ──
if (request.method === "POST") {
const { body } = await readJsonBody<{ email?: string; name?: string; role?: string }>(
request,
{ maxBytes: MAX_BODY_BYTES },
);
const email = String(body.email || "").trim().toLowerCase();
const name = String(body.name || "").trim();
const role = String(body.role || "").trim().toLowerCase();
if (!email || !isValidEmail(email)) {
return jsonResponse({ ok: false, error: "Некорректный email" }, 400, corsHeaders);
}
if (!name) {
return jsonResponse({ ok: false, error: "Имя обязательно" }, 400, corsHeaders);
}
if (!role) {
return jsonResponse({ ok: false, error: "Роль обязательна" }, 400, corsHeaders);
}
// Check if email already exists in public.users
const { data: existingUser } = await supabase
.from("users")
.select("id")
.eq("email", email)
.maybeSingle();
if (existingUser) {
return jsonResponse({ ok: false, error: "Пользователь с таким email уже существует" }, 409, corsHeaders);
}
// Create auth user — trigger handle_new_user will auto-insert into public.users
// using user_metadata.role and user_metadata.name
const { data: authData, error: authError } = await supabase.auth.admin.createUser({
email,
email_confirm: true,
user_metadata: { name, role },
});
if (authError) {
console.error("auth.createUser error:", authError);
return jsonResponse(
{ ok: false, error: "Ошибка создания пользователя: " + authError.message },
500,
corsHeaders,
);
}
const newUserId = authData.user.id;
return jsonResponse({ ok: true, data: { id: newUserId, email, name, role } }, 201, corsHeaders);
}
// ── DELETE: remove user ──
if (request.method === "DELETE") {
const url = new URL(request.url);
const userId = url.searchParams.get("id");
if (!userId) {
return jsonResponse({ ok: false, error: "Параметр id обязателен" }, 400, corsHeaders);
}
// Get user info before deletion
const { data: userRow } = await supabase
.from("users")
.select("id, email, name")
.eq("id", userId)
.maybeSingle();
if (!userRow) {
return jsonResponse({ ok: false, error: "Пользователь не найден" }, 404, corsHeaders);
}
// Detach FK references before deleting auth user.
// Several tables reference public.users via RESTRICT FKs (no ON DELETE CASCADE):
// order_history, action_logs, suggestions, order_groups.assigned_driver_id, etc.
// Without detaching, auth.admin.deleteUser fails with "Database error deleting user".
const detachStatements: Array<[string, string]> = [
["orders", "manager_id"],
["orders", "logistician_id"],
["orders", "assigned_driver_id"],
["order_logisticians", "assigned_by"],
["order_history", "user_id"],
["delivery_slots", "logistician_id"],
["order_groups", "assigned_driver_id"],
["action_logs", "performed_by"],
["suggestions", "author_id"],
];
for (const [table, column] of detachStatements) {
await supabase.from(table).update({ [column]: null }).eq(column, userId);
}
// Delete auth user (FK ON DELETE CASCADE will remove public.users row)
const { error: authDeleteError } = await supabase.auth.admin.deleteUser(userId);
if (authDeleteError) {
console.error("auth.deleteUser error:", authDeleteError);
// Try deleting public.users directly as fallback
const { error: dbDeleteError } = await supabase.from("users").delete().eq("id", userId);
if (dbDeleteError) {
return jsonResponse(
{ ok: false, error: "Ошибка удаления: " + authDeleteError.message },
500,
corsHeaders,
);
}
}
return jsonResponse({ ok: true, data: { id: userId } }, 200, corsHeaders);
}
return jsonResponse({ ok: false, error: "Method not allowed" }, 405, corsHeaders);
} catch (error) {
if (error instanceof Error && "status" in error) {
const httpError = error as { status: number; message: string };
return jsonResponse({ ok: false, error: httpError.message }, httpError.status, corsHeaders);
}
return jsonResponse(
{ ok: false, error: error instanceof Error ? error.message : "Unexpected error" },
500,
corsHeaders,
);
}
});