import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "npm:@supabase/supabase-js@2.116.0"; const PROD_ORIGINS = new Set([ "https://app.caterium.ru", "https://caterium.ru", "https://www.caterium.ru", ]); const WORKERS_DEV_ORIGIN = /^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.workers\.dev$/i; const LOCAL_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d{1,5})?$/i; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const ALLOWED_ROLES = new Set(["owner", "admin", "manager", "operator", "viewer"]); function allowedOrigin(origin: string) { if (!origin) return true; return PROD_ORIGINS.has(origin) || WORKERS_DEV_ORIGIN.test(origin) || LOCAL_ORIGIN.test(origin); } function corsHeaders(req: Request) { const origin = req.headers.get("Origin") || ""; const headers: Record = { "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", "Access-Control-Allow-Methods": "POST, OPTIONS", "Content-Type": "application/json", "Vary": "Origin", }; if (origin && allowedOrigin(origin)) headers["Access-Control-Allow-Origin"] = origin; return headers; } const reply = (req: Request, body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: corsHeaders(req) }); type PrepData = { status?: string; user_id?: string | null; email?: string; display_name?: string; role?: string; }; function publicError(error: unknown) { const message = error instanceof Error ? error.message : String(error || ""); if (/access denied|permission|not allowed/i.test(message)) return { status: 403, error: "Недостаточно прав для добавления сотрудника.", code: "forbidden" }; if (/subscription|blocked|plan/i.test(message)) return { status: 403, error: "Добавление сотрудника недоступно для текущего тарифа.", code: "plan_restricted" }; if (/limit|maximum|max_members/i.test(message)) return { status: 409, error: "Достигнут лимит сотрудников для текущего тарифа.", code: "member_limit" }; if (/already registered|already exists|duplicate/i.test(message)) return { status: 409, error: "Аккаунт с этим email уже существует.", code: "account_exists" }; return { status: 500, error: "Не удалось добавить сотрудника. Повторите попытку.", code: "employee_create_failed" }; } Deno.serve(async (req: Request) => { const origin = req.headers.get("Origin") || ""; if (origin && !allowedOrigin(origin)) return reply(req, { error: "Origin is not allowed", code: "origin_not_allowed" }, 403); if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders(req) }); if (req.method !== "POST") return reply(req, { error: "Method not allowed", code: "method_not_allowed" }, 405); try { const auth = req.headers.get("Authorization") || ""; if (!/^Bearer\s+\S+/i.test(auth)) return reply(req, { error: "Требуется авторизация.", code: "unauthorized" }, 401); const url = Deno.env.get("SUPABASE_URL") || ""; const anon = Deno.env.get("SUPABASE_ANON_KEY") || ""; const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""; if (!url || !anon || !serviceKey) { console.error("[caterium-create-employee] incomplete server auth configuration"); return reply(req, { error: "Сервис временно недоступен.", code: "server_configuration" }, 503); } let body: Record; try { body = await req.json(); } catch (_) { return reply(req, { error: "Некорректный формат запроса.", code: "invalid_json" }, 400); } const workspaceId = String(body.workspace_id || "").trim(); const email = String(body.email || "").trim().toLowerCase(); const displayName = String(body.display_name || "").trim(); const role = String(body.role || "manager").trim().toLowerCase(); if (!UUID_RE.test(workspaceId)) return reply(req, { error: "Некорректная компания.", code: "invalid_workspace" }, 400); if (!EMAIL_RE.test(email) || email.length > 254) return reply(req, { error: "Введите корректный email.", code: "invalid_email" }, 400); if (displayName.length > 120) return reply(req, { error: "Имя сотрудника слишком длинное.", code: "invalid_display_name" }, 400); if (!ALLOWED_ROLES.has(role)) return reply(req, { error: "Некорректная роль сотрудника.", code: "invalid_role" }, 400); const caller = createClient(url, anon, { global: { headers: { Authorization: auth } }, auth: { persistSession: false, autoRefreshToken: false }, }); const service = createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } }); const prepare = async () => { const result = await caller.rpc("sun_employee_prepare_v28", { p_workspace: workspaceId, p_email: email, p_display_name: displayName, p_role: role, }); if (result.error) throw new Error(result.error.message); return (result.data || {}) as PrepData; }; const finalize = async (userId: string, created: boolean, temporaryPassword: string | null = null) => { const fin = await caller.rpc("sun_employee_finalize_v28", { p_workspace: workspaceId, p_user_id: userId, p_display_name: displayName, p_role: role, }); if (fin.error) throw new Error(fin.error.message); return reply(req, { ...fin.data, created, temporary_password: temporaryPassword, must_change_password: created, }); }; const prep = await prepare(); const prepStatus = String(prep.status || ""); if (prepStatus === "already_member") { return reply(req, { status: "already_member", user_id: prep.user_id, email: prep.email || email, display_name: prep.display_name || displayName, role: prep.role || role, created: false, temporary_password: null, must_change_password: false, }); } if (prepStatus === "existing") { const existingUserId = String(prep.user_id || ""); if (!UUID_RE.test(existingUserId)) throw new Error("Existing employee account has no valid user id"); return await finalize(existingUserId, false, null); } if (prepStatus !== "new") throw new Error(`Unexpected employee prepare status: ${prepStatus || "empty"}`); const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"; const bytes = crypto.getRandomValues(new Uint8Array(12)); let password = ""; for (const b of bytes) password += alphabet[b % alphabet.length]; password = password.slice(0, 6) + "-" + password.slice(6); const created = await service.auth.admin.createUser({ email, password, email_confirm: true, user_metadata: { name: displayName || email.split("@")[0], must_change_password: true, registration_source: "caterium_employee_admin", }, }); if (created.error || !created.data.user) { const retryPrep = await prepare(); const retryStatus = String(retryPrep.status || ""); if (retryStatus === "already_member") { return reply(req, { status: "already_member", user_id: retryPrep.user_id, email: retryPrep.email || email, display_name: retryPrep.display_name || displayName, role: retryPrep.role || role, created: false, temporary_password: null, must_change_password: false, }); } if (retryStatus === "existing" && retryPrep.user_id) return await finalize(String(retryPrep.user_id), false, null); throw new Error(created.error?.message || "Employee auth creation failed"); } return await finalize(created.data.user.id, true, password); } catch (error) { console.error("[caterium-create-employee]", error); const safe = publicError(error); return reply(req, { error: safe.error, code: safe.code }, safe.status); } });