214 lines
7.9 KiB
TypeScript
214 lines
7.9 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_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);
|
||
}
|
||
});
|