272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
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",
|
||
called: true,
|
||
called_at: new Date().toISOString(),
|
||
called_comment: comment,
|
||
next_notification_check_at: null,
|
||
};
|
||
// Самовывоз и доставка живут в РАЗНЫХ полях (решение владельца): для
|
||
// самовывоза пишем только pickup_date + половину дня, delivery_date не
|
||
// трогаем — иначе дата дублируется в двух полях и всплывает в плане
|
||
// доставки. Маркер «согласовано роботом» общий — delivery_date_source,
|
||
// по нему работают и карточка, и SQL-статус autocall_status(og).
|
||
if (isPickup) {
|
||
update.pickup_date = deliveryDate;
|
||
if (timeSlot) update.pickup_time_slot = timeSlot;
|
||
} else {
|
||
update.delivery_date = deliveryDate;
|
||
if (timeSlot) update.delivery_time = 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);
|
||
}
|
||
});
|