supersam/supabase/functions/autocall-result/index.ts

246 lines
9.7 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,
readJsonBody,
verifyInternalRequest,
} from "../_shared/security.ts";
const MAX_BODY_BYTES = 16 * 1024;
// Outcome enum — единственный источник истины для UI (см. docs/autocall-contract.md)
const OUTCOMES = new Set([
"confirmed",
"confirmed_kept_previous",
"confirmed_silent",
"no_answer",
"no_input_day",
"no_input_half",
"operator",
"opt_out",
"invalid_number",
"error",
]);
type AutocallBody = {
asterisk_uniqueid?: string;
order_group_id?: string | null;
campaign_type?: string;
customer_phone?: string;
attempt_no?: number;
scenario?: string;
call_type?: string;
chosen_date?: string;
call_started_at?: string;
answered_at?: string | null;
duration_sec?: number | null;
sip_code?: number | null;
channel_state?: string | null;
dtmf_day?: number | null;
dtmf_half?: number | null;
changed?: boolean;
outcome?: string;
was_test_mode?: boolean;
};
const DAY_LABEL: Record<number, string> = { 1: "завтра", 2: "послезавтра" };
const HALF_LABEL: Record<number, string> = { 1: "Первая половина дня", 2: "Вторая половина дня" };
function addDays(date: Date, days: number): string {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
/** День по-человечески: сегодня / завтра / послезавтра, иначе дата дд.мм.гггг. */
function relativeDayLabel(dateKey: string): string {
const today = new Date();
for (const [offset, label] of [[0, "сегодня"], [1, "завтра"], [2, "послезавтра"]] as const) {
if (addDays(today, offset) === dateKey) return label;
}
const [y, m, d] = dateKey.split("-");
return y && m && d ? `${d}.${m}.${y}` : dateKey;
}
Deno.serve(async (request) => {
if (request.method === "OPTIONS") {
const cors = getCorsHeaders(request, "private");
return cors ? new Response("ok", { headers: cors }) : jsonResponse({ error: "Origin not allowed" }, 403);
}
if (request.method !== "POST") {
return jsonResponse({ error: "Method not allowed" }, 405);
}
const cors = getCorsHeaders(request, "private") || {};
try {
const { body } = await readJsonBody<AutocallBody>(request, { maxBytes: MAX_BODY_BYTES });
await verifyInternalRequest(request, undefined, {
secretEnvNames: ["INTEGRATION_WEBHOOK_SECRET", "AUTOCALL_WEBHOOK_SECRET"],
tokenEnvNames: ["INTEGRATION_API_KEY"],
});
if (!body.outcome || !OUTCOMES.has(body.outcome)) {
return jsonResponse({ error: `invalid outcome: ${body.outcome}` }, 400, cors);
}
if (!body.asterisk_uniqueid) {
return jsonResponse({ error: "asterisk_uniqueid is required" }, 400, cors);
}
if (!body.customer_phone) {
return jsonResponse({ error: "customer_phone is required" }, 400, cors);
}
const supabase = createServiceClient();
const campaignType = body.campaign_type || "autocall_slot";
const attemptNo = body.attempt_no ?? 1;
const scenario = body.scenario || "A";
const callType = body.call_type === "pkp" ? "pkp" : "dlv";
const wasTest = body.was_test_mode ?? false;
// ── Идемпотентность: повторный curl с тем же uniqueid обновляет строку ──
const { data: existing } = await supabase
.from("autocall_log")
.select("id")
.eq("asterisk_uniqueid", body.asterisk_uniqueid)
.maybeSingle();
if (existing) {
// Уже записано (клиент бросил трубку после фиксации, AGI шлёт повторно
// при следующем save). Обновляем outcome/слот, не плодя строки.
await supabase.from("autocall_log").update({
outcome: body.outcome,
dtmf_day: body.dtmf_day ?? null,
dtmf_half: body.dtmf_half ?? null,
changed: body.changed ?? false,
was_test_mode: body.was_test_mode ?? false,
}).eq("id", existing.id);
return jsonResponse({ ok: true, updated: true }, 200, cors);
}
// ── Вставка в лог ──
const { error: logError } = await supabase.from("autocall_log").insert({
order_group_id: body.order_group_id || null,
campaign_type: campaignType,
customer_phone: body.customer_phone,
attempt_no: attemptNo,
scenario,
call_type: callType,
call_started_at: body.call_started_at || new Date().toISOString(),
answered_at: body.answered_at || null,
duration_sec: body.duration_sec ?? null,
sip_code: body.sip_code ?? null,
channel_state: body.channel_state || null,
dtmf_day: body.dtmf_day ?? null,
dtmf_half: body.dtmf_half ?? null,
changed: body.changed ?? false,
outcome: body.outcome,
was_test_mode: wasTest,
asterisk_uniqueid: body.asterisk_uniqueid,
});
if (logError) throw logError;
// ── Найти заказ, если order_group_id не передан ──
let orderGroupId = body.order_group_id || null;
if (!orderGroupId) {
const { data: group } = await supabase
.from("order_groups")
.select("id")
.eq("customer_phone", body.customer_phone)
.in("delivery_status", ["pending_confirmation", "agreed"])
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (group) orderGroupId = group.id;
}
if (orderGroupId) {
const { data: group } = await supabase
.from("order_groups")
.select("id, delivery_type, delivery_status, notification_status")
.eq("id", orderGroupId)
.single();
const isPickup = group?.delivery_type === "pickup";
// ── confirmed*: записываем слот ──
if (["confirmed", "confirmed_kept_previous", "confirmed_silent"].includes(body.outcome)) {
// День называет сам диалог (chosen_date) — робот перечисляет конкретные
// дни окна самовывоза, поэтому «1/2/3» само по себе дату не задаёт.
// Если дата не пришла (старые call-файлы) — считаем по цифре дня.
const chosenDate = /^\d{4}-\d{2}-\d{2}$/.test(body.chosen_date || "")
? (body.chosen_date as string)
: null;
const dayOffset = body.dtmf_day === 3 ? 2 : body.dtmf_day === 2 ? 1 : 0;
const deliveryDate = chosenDate || addDays(new Date(), body.dtmf_day ? dayOffset : 1);
const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null;
const dayLabel = chosenDate
? relativeDayLabel(chosenDate)
: body.dtmf_day
? DAY_LABEL[body.dtmf_day]
: "завтра";
const comment = `Согласовано роботом: ${dayLabel}${timeSlot ? ", " + timeSlot.toLowerCase() : ""}`;
const update: Record<string, unknown> = {
delivery_date_source: "autocall",
delivery_status: "agreed",
notification_status: "confirmed",
delivery_date: deliveryDate,
called: true,
called_at: new Date().toISOString(),
called_comment: comment,
next_notification_check_at: null,
};
if (timeSlot) update.delivery_time = timeSlot;
if (isPickup) {
update.pickup_date = deliveryDate;
if (timeSlot) update.pickup_time_slot = timeSlot;
}
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
}
// ── Эскалация ──
else if (["operator", "opt_out", "invalid_number"].includes(body.outcome)) {
const update: Record<string, unknown> = {
notification_status: "manual_required",
called: true,
called_at: new Date().toISOString(),
called_comment:
body.outcome === "operator"
? "Просил менеджера"
: body.outcome === "opt_out"
? "Отказался от автозвонков"
: "Неверный номер",
};
if (body.outcome === "opt_out") update.autocall_opt_out = true;
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
}
// ── no_answer / no_input: решаем, эскалировать ли ──
else {
const { count: noInputCount } = await supabase
.from("autocall_log")
.select("id", { count: "exact", head: true })
.eq("order_group_id", orderGroupId)
.in("outcome", ["no_input_day", "no_input_half"]);
const { count: totalAttempts } = await supabase
.from("autocall_log")
.select("id", { count: "exact", head: true })
.eq("order_group_id", orderGroupId);
const noInputEscalate = 2; // из настроек, но пока константа
const maxAttempts = 5; // из настроек, но пока константа
const escalate = (noInputCount ?? 0) >= noInputEscalate || (totalAttempts ?? 0) >= maxAttempts;
if (escalate) {
await supabase.from("order_groups").update({
notification_status: "manual_required",
called: true,
called_at: new Date().toISOString(),
called_comment: "Не дозвонились / не нажал — менеджер",
}).eq("id", orderGroupId);
}
}
}
return jsonResponse({ ok: true }, 200, cors);
} catch (e) {
const status = (e as { status?: number }).status || 500;
return jsonResponse({ ok: false, error: (e as Error).message }, status, cors);
}
});