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

214 lines
7.9 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_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);
}
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 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) {
return jsonResponse({ ok: true, duplicate: 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_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)) {
const dayOffset = body.dtmf_day === 2 ? 2 : 1;
const deliveryDate = addDays(new Date(), dayOffset);
const timeSlot = body.dtmf_half ? HALF_LABEL[body.dtmf_half] : null;
const dayLabel = 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,
};
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", "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);
}
});