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

266 lines
11 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",
"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;
// Дата, которую робот реально назвал в разговоре (AGI передаёт её в chosen_date).
// Нужна и для заказа, и для строки лога: в карточке автодозвона показываем дату,
// а не «завтра/послезавтра» — «день 1» это первый рабочий день окна, который
// в пятницу оказывается понедельником.
const chosenDate = /^\d{4}-\d{2}-\d{2}$/.test(body.chosen_date || "")
? (body.chosen_date as string)
: null;
// ── Идемпотентность: повторный 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,
chosen_date: chosenDate,
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,
chosen_date: chosenDate,
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-файл, пустой график) — восстанавливаем
// по цифре ТАК, КАК РОБОТ ЕЁ ОЗВУЧИЛ: самовывоз «день 1» = сегодня
// (сегодня/завтра/послезавтра), доставка «день 1» = завтра, «день 2» =
// послезавтра (доставка в день готовности не бывает). Раньше для обоих
// типов бралось «сегодня+цифра-1» — доставке это сдвигало день на сутки назад.
const chosenDate = /^\d{4}-\d{2}-\d{2}$/.test(body.chosen_date || "")
? (body.chosen_date as string)
: null;
const dayOffset = callType === "pkp"
? (body.dtmf_day === 3 ? 2 : body.dtmf_day === 2 ? 1 : 0)
: (body.dtmf_day === 2 ? 2 : 1);
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", "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"
? "Просил менеджера"
: "Неверный номер",
};
await supabase.from("order_groups").update(update).eq("id", orderGroupId);
}
// ── no_answer / no_input: решаем, эскалировать ли ──
// Пороги берём из настроек кампании (админка → Автодозвон): раньше здесь
// стояли константы 2 и 5, поэтому поля «Не нажал ×N → менеджер» и
// «Всего попыток» в интерфейсе ни на что не влияли.
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 { data: settings } = await supabase
.from("sms_campaign_settings")
.select("no_input_escalate_after, max_attempts")
.eq("campaign_type", "autocall_slot")
.maybeSingle();
const noInputEscalate = Number(settings?.no_input_escalate_after ?? 2);
const maxAttempts = Number(settings?.max_attempts ?? 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);
}
});