caterium-app/supabase/functions/caterium-create-employee/index.ts
2026-09-11 07:54:22 +03:00

187 lines
8.3 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 "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(["admin", "manager", "kitchen", "courier", "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<string, string> = {
"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<string, unknown>;
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);
}
});