22ac6e4f13
Решение владельца В-203 — «делай». Это не строка приёмочного листа, а мина, найденная разведкой: журнал отдавал ВСЕ сообщения рассылки одним ответом. На рассылке в двадцать тысяч номеров это двадцать тысяч строк за раз и подвисший экран — ровно то, что Этап 4 уже вынул из базы номеров. Этап 5 сделал мину горячее: в журнал добавилась судьба каждого номера, и человек стал открывать его чаще. Теперь по 50 строк, внизу подпись «Всего строк: 120 · страница 2 из 3» и переключатель. На рассылке в одну страницу переключателя нет — не шуметь там, где листать нечего. Размер страницы адресом не задерёшь: потолок 200, иначе страницы обходятся одним параметром и мы возвращаемся туда, откуда ушли. Номер страницы передаётся серверу явно. Сам по себе постраничный вывод берёт его из общего запроса приложения — и вторая страница выходит неотличимой от первой; эту дыру мы уже ловили живьём 30 июля на базе номеров. Главное решение здесь про доверие к числам: итог по судьбам и предложение досыла считаются по ВСЕЙ рассылке, а не по видимой странице. Иначе человек, листнув, увидел бы другой итог и не понял, какому верить. А досыл — это ещё и деньги: считать не дошедших по видимой странице значило бы называть заниженное число и брать не ту сумму. Стерегут это отдельные тесты — и на сервере, и на экране. Восемь тестов на сервере и пять на экране, все доказаны вырезом; вырезов вышло одиннадцать. Но главным прибором тут был браузер: вырез «убрать номер страницы» проверка запросом не видит вовсе — это записано в самом уроке. Живьём пройдены все три страницы: пятьдесят номеров, потом другие пятьдесят, потом остаток в двадцать; подпись менялась, а итог «доставлено 80, не доставлено 40» и кнопка «Дослать не дошедшим (40)» на всех трёх остались прежними. Стенд возвращён. Попутно поймана болезнь измерителя, уже второй раз за смену: он считал только «не сошлось» и не видел «рухнуло», отчего доложил два красных вместо семи. Прибор обязан читать весь отчёт, а не то поле, которое ты ждал. Один существующий тест пришлось поправить — он закреплял прежний вызов без номера страницы. Поправлен так, чтобы стеречь новое поведение, и к нему добавлен второй: страницу не назвали — просим первую.
566 lines
26 KiB
TypeScript
566 lines
26 KiB
TypeScript
import { apiClient, ensureCsrfCookie } from './client';
|
||
|
||
/**
|
||
* API-модуль СМС-канала прогрева клиентов (Task 9).
|
||
*
|
||
* Эндпоинты под [auth:sanctum, tenant], префикс /api/sms. GET'ы не требуют
|
||
* CSRF-cookie, мутации (POST/PATCH/DELETE) — требуют.
|
||
*/
|
||
|
||
/** Кампания клиентского СМС-рассылки (строка GET /api/sms/campaigns, а также POST-ответ и часть GET .../{id}). */
|
||
export interface ClientSmsCampaign {
|
||
id: number;
|
||
title: string;
|
||
body: string;
|
||
sender_name: string;
|
||
source: string;
|
||
audience_days: number | null;
|
||
status: string;
|
||
segments: number;
|
||
planned_count: number;
|
||
sent_count: number;
|
||
total_sms: number;
|
||
price_rub_per_sms: string;
|
||
estimated_cost_rub: string;
|
||
actual_cost_rub: string | null;
|
||
/** Почему рассылка остановлена: 'client' (сам клиент), 'no_funds' (кончились деньги), 'watchdog'. */
|
||
stop_reason?: string | null;
|
||
/** Сколько получателей ждут своего утра (статус waiting_window). */
|
||
waiting_window_count?: number;
|
||
/** Сколько ждут, пока мы выясним их регион, — это ожидание само не пройдёт. */
|
||
waiting_region_count?: number;
|
||
/**
|
||
* Можно ли продолжить эту рассылку (строка листа 4.13). Решает СЕРВЕР — экран
|
||
* причины остановки не разбирает (В-146). Деньги в этот признак не входят: кнопка
|
||
* видна и при нехватке, а портал по нажатию говорит, сколько не хватает (В-189).
|
||
*/
|
||
can_resume?: boolean;
|
||
/** Сколько сообщений ещё уйдёт, если продолжить. */
|
||
resume_pending_count?: number;
|
||
/** Итог по судьбам этой рассылки (строка листа 5.3). Сервер считает его сам. */
|
||
delivery?: ClientSmsDeliveryTotals;
|
||
created_at?: string;
|
||
}
|
||
|
||
/**
|
||
* Итог рассылки по судьбам (строка листа 5.3): не «отправлено 900», а
|
||
* «доставлено 812, не доставлено 74, ещё в пути 14».
|
||
*
|
||
* 🔴 Считает СЕРВЕР (`ClientSmsDeliveryCounter`) — экран не складывает судьбы сам.
|
||
* Второй счётчик на экране однажды разошёлся бы с первым, а речь про деньги.
|
||
* `on_the_way` — это и явное «везу» от оператора, и те, кого мы ещё не спрашивали:
|
||
* для человека это одно состояние «пока не знаем» (В-214).
|
||
*/
|
||
export interface ClientSmsDeliveryTotals {
|
||
delivered: number;
|
||
not_delivered: number;
|
||
not_sent: number;
|
||
on_the_way: number;
|
||
}
|
||
|
||
/**
|
||
* Можно ли дослать не дошедшим и скольким (строка листа 5.6, решение владельца В-200).
|
||
*
|
||
* 🔴 Решает СЕРВЕР (`ClientSmsController::resendDecision`) — экран условие не
|
||
* повторяет. Разойдись эти два расчёта, человек нажимал бы кнопку и получал отказ,
|
||
* или не видел бы кнопки там, где досыл возможен.
|
||
*/
|
||
export interface ClientSmsResendOffer {
|
||
can: boolean;
|
||
count: number;
|
||
}
|
||
|
||
/** Отдельное сообщение кампании (строка messages[] в GET /api/sms/campaigns/{id}). */
|
||
export interface ClientSmsMessage {
|
||
id: number;
|
||
phone: string;
|
||
operator: string | null;
|
||
provider_key: string | null;
|
||
status: string;
|
||
cost_rub: string;
|
||
provider_message_id: string | null;
|
||
error: string | null;
|
||
/**
|
||
* Судьба сообщения (строка листа 5.1). 🔴 НЕ то же самое, что `status`: тот
|
||
* говорит, отдали ли мы сообщение оператору (и по нему считаются деньги),
|
||
* а это — дошло ли оно до человека. Пусто = ещё не спрашивали (В-202, В-214).
|
||
*/
|
||
delivery_status: string | null;
|
||
delivered_at: string | null;
|
||
/** Сколько частей насчитал ОПЕРАТОР. Своё число у нас в `segments` — они могут разойтись. */
|
||
provider_parts: number | null;
|
||
created_at?: string;
|
||
}
|
||
|
||
/** Контакт для рассылки (строка GET /api/sms/contacts, а также POST /api/sms/contacts ответ). */
|
||
export interface ClientSmsContact {
|
||
id: number;
|
||
phone: string;
|
||
name: string | null;
|
||
operator: string | null;
|
||
}
|
||
|
||
/** Шаблон текста СМС (строка GET /api/sms/templates, а также POST/PATCH-ответ). */
|
||
export interface ClientSmsTemplate {
|
||
id: number;
|
||
title: string;
|
||
body: string;
|
||
}
|
||
|
||
/** Ответ POST /api/sms/preview — предпросчёт стоимости и охвата рассылки. */
|
||
export interface ClientSmsPreview {
|
||
segments: number;
|
||
sendable_count: number;
|
||
/** Сколько выйдет СМС всего: номера × сколько СМС в тексте. За это и платят (В-120). */
|
||
total_sms?: number;
|
||
skipped: Record<string, number>;
|
||
estimated_cost_rub: string;
|
||
/** Цена этой рассылки — ступень для «накоплено за месяц + этот заказ» (3.8). */
|
||
price_rub_per_sms: string;
|
||
/** Ступень БЕЗ этого заказа — чтобы экран мог сказать про удешевление (3.12). */
|
||
price_rub_per_sms_before?: string;
|
||
/** Сколько СМС клиент отправил с 1-го числа, по московскому календарю (3.11). */
|
||
month_sent_count?: number;
|
||
/**
|
||
* Когда рассылка реально начнётся (3.15). Пусто = сразу. Момент СЕРВЕРНЫЙ:
|
||
* на компьютере клиента может стоять что угодно (В-117).
|
||
*/
|
||
starts_at?: string | null;
|
||
/** «Сейчас» по часам сервера — пара к starts_at. */
|
||
server_now?: string;
|
||
/** Сколько номеров ждут, пока мы выясним их регион (В-116). */
|
||
waiting_region_count?: number;
|
||
/**
|
||
* Сколько номеров из «своего списка» портал не разобрал (строка листа 4.8).
|
||
* Для сделок и своей базы всегда 0 — там номера пришли из базы, а не от человека.
|
||
*/
|
||
rejected_count?: number;
|
||
/** Образцы непонятых номеров — не больше двадцати, чтобы ответ не раздувать. */
|
||
rejected_samples?: string[];
|
||
}
|
||
|
||
/** Ответ GET /api/sms/campaigns — список кампаний тенанта + режим отправки + имя отправителя. */
|
||
export interface ClientSmsIndex {
|
||
campaigns: ClientSmsCampaign[];
|
||
sandbox: boolean;
|
||
sender_name: string;
|
||
balance_rub: string;
|
||
frozen_rub: string;
|
||
}
|
||
|
||
/** Тело POST /api/sms/preview и (без title) базис POST /api/sms/campaigns. */
|
||
export interface ClientSmsPreviewPayload {
|
||
body: string;
|
||
source: 'deals' | 'base' | 'manual';
|
||
audience_days?: number;
|
||
/**
|
||
* Статусы воронки, оставленные отмеченными (строки 2.6–2.7). Только для «по сделкам».
|
||
* Пустой список сервер понимает как «все статусы», а не «никому» (решение В-42).
|
||
*/
|
||
audience_statuses?: string[];
|
||
phones?: string[];
|
||
}
|
||
|
||
/** Тело POST /api/sms/campaigns — создание кампании. */
|
||
export interface ClientSmsCreatePayload extends ClientSmsPreviewPayload {
|
||
title: string;
|
||
/**
|
||
* Ключ заказа (строка листа 1.18). Один и тот же ключ = один и тот же заказ:
|
||
* двойной клик, обрыв связи и повтор браузера вернут первую рассылку и НЕ
|
||
* спишут деньги второй раз. Генерируется экраном при открытии формы.
|
||
*/
|
||
idempotency_key?: string;
|
||
/**
|
||
* «Да, знаю, что похожая рассылка была недавно, всё равно отправляй»
|
||
* (строка листа 1.20). Ставится только после ответа человека на вопрос,
|
||
* который сервер задаёт кодом `duplicate_recent` (строка 1.19).
|
||
*/
|
||
confirmed?: boolean;
|
||
}
|
||
|
||
/** GET /api/sms/campaigns — список кампаний тенанта. */
|
||
export async function fetchClientSms(): Promise<ClientSmsIndex> {
|
||
const { data } = await apiClient.get<ClientSmsIndex>('/api/sms/campaigns');
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/sms/preview — предпросчёт стоимости и охвата рассылки. */
|
||
export async function previewClientSms(payload: ClientSmsPreviewPayload): Promise<ClientSmsPreview> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<ClientSmsPreview>('/api/sms/preview', payload);
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/sms/campaigns — создать кампанию. */
|
||
export async function createClientSms(payload: ClientSmsCreatePayload): Promise<ClientSmsCampaign> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<ClientSmsCampaign>('/api/sms/campaigns', payload);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* Где мы в журнале рассылки: сколько всего строк, какая страница, по скольку.
|
||
* Числа приходят с сервера и на экране НЕ пересчитываются (В-123).
|
||
*/
|
||
export interface ClientSmsJournalPage {
|
||
total: number;
|
||
page: number;
|
||
per_page: number;
|
||
last_page: number;
|
||
}
|
||
|
||
/**
|
||
* GET /api/sms/campaigns/{id} — детали кампании + СТРАНИЦА сообщений + итог по судьбам.
|
||
*
|
||
* `delivery` помечен необязательным намеренно: сервер его отдаёт всегда, но экран
|
||
* обязан пережить ответ без него (старая вкладка, ответ из кэша браузера) и не
|
||
* написать человеку «доставлено undefined». То же и про `messages_page`.
|
||
*
|
||
* 🔴 Итог по судьбам и предложение досыла сервер считает по ВСЕЙ рассылке, а не по
|
||
* этой странице: листнув, человек обязан видеть тот же итог.
|
||
*/
|
||
export async function fetchClientSmsCampaign(
|
||
id: number,
|
||
page = 1,
|
||
): Promise<{
|
||
campaign: ClientSmsCampaign;
|
||
messages: ClientSmsMessage[];
|
||
messages_page?: ClientSmsJournalPage;
|
||
delivery?: ClientSmsDeliveryTotals;
|
||
resend?: ClientSmsResendOffer;
|
||
}> {
|
||
const { data } = await apiClient.get<{
|
||
campaign: ClientSmsCampaign;
|
||
messages: ClientSmsMessage[];
|
||
messages_page?: ClientSmsJournalPage;
|
||
delivery?: ClientSmsDeliveryTotals;
|
||
resend?: ClientSmsResendOffer;
|
||
}>(`/api/sms/campaigns/${id}`, { params: { page } });
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* POST /api/sms/campaigns/{id}/resend — дослать не дошедшим (строка листа 5.6).
|
||
*
|
||
* 🔴 Это ДЕНЬГИ: досыл — отдельная рассылка, и платится она отдельно (решение
|
||
* владельца В-198/В-200). Повторное нажатие второй рассылки не заводит — на сервере
|
||
* стоит постоянный ключ заказа.
|
||
*/
|
||
export async function resendClientSms(id: number): Promise<void> {
|
||
await apiClient.post(`/api/sms/campaigns/${id}/resend`);
|
||
}
|
||
|
||
/**
|
||
* Ответ GET /api/sms/contacts — список своей базы и потолок загрузки.
|
||
*
|
||
* Объект, а не голый массив: экран обязан знать потолок ДО загрузки, а
|
||
* выдумывать число на экране нельзя — только с сервера (строка листа 4.6).
|
||
*/
|
||
export interface ContactsListResult {
|
||
items: ClientSmsContact[];
|
||
max_upload_phones: number;
|
||
/** Сколько номеров в базе ВСЕГО, а не на этой странице (строка листа 4.7). */
|
||
total: number;
|
||
page: number;
|
||
per_page: number;
|
||
last_page: number;
|
||
}
|
||
|
||
/**
|
||
* GET /api/sms/contacts — СТРАНИЦА своей базы + потолок загрузки.
|
||
*
|
||
* База бывает на десятки тысяч номеров, поэтому сервер отдаёт её частями и
|
||
* больше своего потолка не отдаёт никому (строка листа 4.7).
|
||
*/
|
||
export async function fetchContacts(page = 1): Promise<ContactsListResult> {
|
||
const { data } = await apiClient.get<ContactsListResult>('/api/sms/contacts', { params: { page } });
|
||
return data;
|
||
}
|
||
|
||
/** Ответ на добавление номеров в базу (вставкой или файлом). */
|
||
export interface ContactsUploadResult {
|
||
added: number;
|
||
rejected: number;
|
||
rejected_samples: string[];
|
||
}
|
||
|
||
/** POST /api/sms/contacts — загрузить список телефонов (вставка текстом). */
|
||
export async function uploadContacts(phones: string[]): Promise<ContactsUploadResult> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<ContactsUploadResult>('/api/sms/contacts', { phones });
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/sms/contacts/file — загрузить базу Excel-файлом (первая колонка — номера). */
|
||
export async function uploadContactsFile(file: File): Promise<ContactsUploadResult> {
|
||
await ensureCsrfCookie();
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
const { data } = await apiClient.post<ContactsUploadResult>('/api/sms/contacts/file', form);
|
||
return data;
|
||
}
|
||
|
||
/** Адрес примера Excel-файла базы — скачивается по ссылке (cookie SPA GET). */
|
||
export function contactsExampleUrl(): string {
|
||
return '/api/sms/contacts/example';
|
||
}
|
||
|
||
/** DELETE /api/sms/contacts/{id} — удалить контакт. */
|
||
export async function deleteContact(id: number): Promise<void> {
|
||
await ensureCsrfCookie();
|
||
await apiClient.delete(`/api/sms/contacts/${id}`);
|
||
}
|
||
|
||
// ─── Этап 1: «Не писать этим» + остановка рассылки ───────────────────────────
|
||
|
||
/** Номер в стоп-листе самого клиента (строка GET /api/sms/optouts). */
|
||
export interface ClientSmsOptout {
|
||
id: number;
|
||
phone: string;
|
||
/** `client` — внёс клиент, `admin` — внесли со стороны портала. */
|
||
source: string;
|
||
note: string | null;
|
||
}
|
||
|
||
/** Ответ на внесение номеров в «Не писать этим» — руками или файлом. */
|
||
export interface OptoutsUploadResult {
|
||
added: number;
|
||
rejected: number;
|
||
rejected_samples: string[];
|
||
}
|
||
|
||
/** GET /api/sms/optouts — номера, которым клиент запретил писать. */
|
||
export async function fetchOptouts(): Promise<ClientSmsOptout[]> {
|
||
const { data } = await apiClient.get<{ items: ClientSmsOptout[] }>('/api/sms/optouts');
|
||
return data.items;
|
||
}
|
||
|
||
/** POST /api/sms/optouts — внести номера руками. Комментарий один на всю пачку. */
|
||
export async function addOptouts(phones: string[], note: string | null): Promise<OptoutsUploadResult> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<OptoutsUploadResult>('/api/sms/optouts', { phones, note });
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/sms/optouts/file — внести номера Excel-файлом (первая колонка — номера). */
|
||
export async function uploadOptoutsFile(file: File, note: string | null): Promise<OptoutsUploadResult> {
|
||
await ensureCsrfCookie();
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
if (note !== null && note !== '') form.append('note', note);
|
||
const { data } = await apiClient.post<OptoutsUploadResult>('/api/sms/optouts/file', form);
|
||
return data;
|
||
}
|
||
|
||
/** DELETE /api/sms/optouts/{id} — убрать номер из «Не писать этим». */
|
||
export async function deleteOptout(id: number): Promise<void> {
|
||
await ensureCsrfCookie();
|
||
await apiClient.delete(`/api/sms/optouts/${id}`);
|
||
}
|
||
|
||
/**
|
||
* POST /api/sms/campaigns/{id}/cancel — остановить рассылку (строка листа 1.14).
|
||
* Уже начатое одно сообщение доводится до конца, новые не уходят. 409 — рассылку
|
||
* уже нельзя остановить (закончилась или отменена).
|
||
*/
|
||
export async function cancelClientSms(id: number): Promise<void> {
|
||
await ensureCsrfCookie();
|
||
await apiClient.post(`/api/sms/campaigns/${id}/cancel`);
|
||
}
|
||
|
||
/**
|
||
* POST /api/sms/campaigns/{id}/resume — продолжить рассылку, вставшую из-за денег
|
||
* (строка листа 4.13). Уже отправленным повторно не шлём, тому, на кого денег не
|
||
* хватило, шлём обязательно. 422 — продолжить нельзя (не та рассылка) либо денег
|
||
* по-прежнему не хватает: в `message` человеческое объяснение с числами.
|
||
*/
|
||
export async function resumeClientSms(id: number): Promise<void> {
|
||
await ensureCsrfCookie();
|
||
await apiClient.post(`/api/sms/campaigns/${id}/resume`);
|
||
}
|
||
|
||
/** GET /api/sms/templates — список шаблонов текста СМС. */
|
||
export async function fetchTemplates(): Promise<ClientSmsTemplate[]> {
|
||
const { data } = await apiClient.get<ClientSmsTemplate[]>('/api/sms/templates');
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/sms/templates — создать шаблон. */
|
||
export async function saveTemplate(title: string, body: string): Promise<ClientSmsTemplate> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<ClientSmsTemplate>('/api/sms/templates', { title, body });
|
||
return data;
|
||
}
|
||
|
||
/** PATCH /api/sms/templates/{id} — изменить шаблон. */
|
||
export async function updateTemplate(id: number, title: string, body: string): Promise<ClientSmsTemplate> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.patch<ClientSmsTemplate>(`/api/sms/templates/${id}`, { title, body });
|
||
return data;
|
||
}
|
||
|
||
/** DELETE /api/sms/templates/{id} — удалить шаблон. */
|
||
export async function deleteTemplate(id: number): Promise<void> {
|
||
await ensureCsrfCookie();
|
||
await apiClient.delete(`/api/sms/templates/${id}`);
|
||
}
|
||
|
||
// ─── Этап 2: своё имя отправителя + авто-СМС ─────────────────────────────────
|
||
|
||
/** Вид имени = какой документ подтверждает право (требование МТС). */
|
||
export type ClientSmsSenderNameType = 'legal' | 'ip' | 'website' | 'trademark' | 'company';
|
||
|
||
/** Имя отправителя клиента (Task 16a). */
|
||
export interface ClientSmsSender {
|
||
id: number;
|
||
name: string;
|
||
name_type: ClientSmsSenderNameType;
|
||
status: 'pending' | 'active' | 'suspended' | 'rejected' | 'cancelled';
|
||
monthly_fee_rub: string;
|
||
note: string | null;
|
||
paid_until: string | null;
|
||
doc_original_name: string | null;
|
||
consent_doc_original_name: string | null;
|
||
}
|
||
|
||
/** Тип лица клиента (из реквизитов) — определяет доступные виды имени. */
|
||
export type ClientSubjectType = 'individual' | 'sole_proprietor' | 'legal_entity' | null;
|
||
|
||
/** Ответ GET /api/sms/sender — заявка (или null) + эффективное имя + цена/мес + готовы ли реквизиты + тип лица. */
|
||
export interface ClientSmsSenderInfo {
|
||
sender: ClientSmsSender | null;
|
||
effective_name: string;
|
||
name_fee_rub_per_operator: string;
|
||
requisites_ready: boolean;
|
||
subject_type: ClientSubjectType;
|
||
/**
|
||
* Можно ли клиенту вернуть имя в работу кнопкой и сколько за это спишется.
|
||
* Решает СЕРВЕР (строка листа 4.5, журнал В-146): включать можно не всякое
|
||
* отключённое имя, и списывается не всегда полная плата. Экран ничего не
|
||
* считает — рисует кнопку по флагу и печатает присланную сумму.
|
||
*/
|
||
can_enable: boolean;
|
||
enable_charge_rub: string;
|
||
}
|
||
|
||
/** Авто-СМС: одно сообщение каждому новому лиду (GET/POST /api/sms/auto-rule). */
|
||
export interface ClientSmsAutoRule {
|
||
enabled: boolean;
|
||
body: string;
|
||
sender_name: string;
|
||
estimated_cost_rub: string;
|
||
}
|
||
|
||
/**
|
||
* Журнал авто-СМС за последние 30 дней (GET /api/sms/auto-log, строка листа
|
||
* 4.11). Числа сервер считает по всему окну, а `messages` — последние сто
|
||
* событий: журнал, а не выгрузка. `messages_truncated` говорит экрану, что
|
||
* событий было больше, чем показано (В-170).
|
||
*/
|
||
export interface ClientSmsAutoLogEntry {
|
||
id: number;
|
||
phone: string;
|
||
status: string;
|
||
cost_rub: string;
|
||
created_at: string;
|
||
}
|
||
|
||
export interface ClientSmsAutoLogReason {
|
||
reason: string;
|
||
count: number;
|
||
}
|
||
|
||
export interface ClientSmsAutoLog {
|
||
since: string;
|
||
totals: { sent: number; skipped: number };
|
||
reasons: ClientSmsAutoLogReason[];
|
||
messages: ClientSmsAutoLogEntry[];
|
||
messages_limit: number;
|
||
messages_truncated: boolean;
|
||
}
|
||
|
||
export async function fetchAutoLog(): Promise<ClientSmsAutoLog> {
|
||
const { data } = await apiClient.get<ClientSmsAutoLog>('/api/sms/auto-log');
|
||
return data;
|
||
}
|
||
|
||
/** GET /api/sms/sender — заявка на своё имя + эффективное имя + цена. */
|
||
export async function fetchSender(): Promise<ClientSmsSenderInfo> {
|
||
const { data } = await apiClient.get<ClientSmsSenderInfo>('/api/sms/sender');
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* URL готового бланка письма-разрешения в редактируемом Word (.docx) с
|
||
* подставленными реквизитами клиента (чего нет — прочерки). Клиент дописывает
|
||
* недостающее и паспорт перед подписью. Открывается прямой ссылкой (GET,
|
||
* cookie-сессия того же домена) — браузер скачивает файл.
|
||
*/
|
||
export function consentFormUrl(
|
||
name: string,
|
||
name_type: ClientSmsSenderNameType,
|
||
owner_type?: ClientSubjectType,
|
||
owner_name?: string,
|
||
): string {
|
||
const q = new URLSearchParams({ name, name_type });
|
||
// Домен/товарный знак могут быть на физлице (директоре/владельце), даже если
|
||
// клиент — юрлицо/ИП: тогда письмо оформляется от этого физлица.
|
||
if (owner_type) q.set('owner_type', owner_type);
|
||
if (owner_name && owner_name.trim() !== '') q.set('owner_name', owner_name.trim());
|
||
return `/api/sms/sender/consent-form?${q.toString()}`;
|
||
}
|
||
|
||
/**
|
||
* POST /api/sms/sender — заказать своё имя отправителя. Имя регистрирует Лидерра
|
||
* от лица клиента, поэтому multipart с ДВУМЯ файлами: подписанное согласие
|
||
* (бланк готовит портал) и документ-основание (право на имя). Оба обязательны.
|
||
*/
|
||
export async function requestSender(
|
||
name: string,
|
||
name_type: ClientSmsSenderNameType,
|
||
consentDocument: File,
|
||
document: File,
|
||
): Promise<ClientSmsSender> {
|
||
await ensureCsrfCookie();
|
||
const form = new FormData();
|
||
form.append('name', name);
|
||
form.append('name_type', name_type);
|
||
form.append('consent_document', consentDocument);
|
||
form.append('document', document);
|
||
const { data } = await apiClient.post<ClientSmsSender>('/api/sms/sender', form);
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/sms/sender/disable — отключить / отменить своё имя. */
|
||
export async function disableSender(): Promise<{ disabled: boolean; sender: ClientSmsSender }> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<{ disabled: boolean; sender: ClientSmsSender }>('/api/sms/sender/disable');
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* POST /api/sms/sender/enable — вернуть своё имя в работу (строка листа 4.5).
|
||
* Списывает плату за месяц и включает сразу (решение владельца В-134); денег не
|
||
* хватает — сервер отвечает 422 с текстом, где названа недостающая сумма.
|
||
*/
|
||
export async function enableSender(): Promise<{ enabled: boolean; charged_rub: string; sender: ClientSmsSender }> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<{ enabled: boolean; charged_rub: string; sender: ClientSmsSender }>(
|
||
'/api/sms/sender/enable',
|
||
);
|
||
return data;
|
||
}
|
||
|
||
/** GET /api/sms/auto-rule — правило авто-СМС новым лидам. */
|
||
export async function fetchAutoRule(): Promise<ClientSmsAutoRule> {
|
||
const { data } = await apiClient.get<ClientSmsAutoRule>('/api/sms/auto-rule');
|
||
return data;
|
||
}
|
||
|
||
/** POST /api/sms/auto-rule — сохранить правило авто-СМС. */
|
||
export async function saveAutoRule(enabled: boolean, body: string): Promise<ClientSmsAutoRule> {
|
||
await ensureCsrfCookie();
|
||
const { data } = await apiClient.post<ClientSmsAutoRule>('/api/sms/auto-rule', { enabled, body });
|
||
return data;
|
||
}
|