246 lines
9.7 KiB
TypeScript
246 lines
9.7 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",
|
||
"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);
|
||
}
|
||
});
|