Files
portal/app/resources/js/api/telegram.ts
T
Дмитрий 00cc072e2f feat,телеграм-реклама: «Моя база номеров» заработала — пункт больше не ведёт в никуда
Это снимает запрет на выкат телеграм-рекламы.

Пункт «Моя база номеров» стоял в выборе аудитории с 27.07, а писать в таблицу
client_tg_contacts не умела НИ ОДНА строка кода: ни экрана загрузки, ни серверной
ручки, ни переноса из сделок. У любого клиента пункт всегда показывал ноль.
Клиент выбрал бы «свою базу», увидел пустоту и решил, что портал потерял его
клиентов. Песочница выключена, деньги живые — катить в таком виде было нельзя.

Читающая половина при этом была готова с самого начала: TelegramAudienceService
умеет и нормализацию, и схлопывание дублей, и вычитание стоп-листа. Не хватало
только записи, поэтому работа вышла куда меньше, чем казалось.

Сервер:
- TelegramBazaService — разбор файла, замена базы, очистка. Номер берётся по
  одному в строке либо первым столбцом таблицы, разделители запятая, точка с
  запятой, табуляция. Мусорные строки не роняют разбор, а считаются отдельно.
- три ручки: GET, POST и DELETE /api/telegram/contacts.
- потолок 200 000 номеров и 10 МБ на файл; обрыв по потолку не молчит, а
  возвращается признаком.

Экран, в шаге «Кому показываем»:
- сколько номеров в базе, заливка файла, очистка;
- итог заливки целиком: принято, повторов, не похоже на номер. «Принято 1200»
  без остального читалось бы как «файл зашёл полностью»;
- после заливки счётчик охвата пересчитывается сам.

Решения, которые стоит знать:
- заливка ЗАМЕНЯЕТ базу, а не добавляет. Так предсказуемее: клиент держит базу у
  себя и заливает заново. Подмешивание копило бы номера, от которых он не смог бы
  избавиться — построчного удаления в интерфейсе нет. На экране это написано до
  нажатия, молчаливой потери нет.
- файл без единого годного номера отклоняется, старая база остаётся цела. Иначе
  клиент залил бы файл не того формата и потерял всё.
- замена сделана удалением и вставкой, а не upsert: право UPDATE на таблице не
  выдано, upsert упёрся бы в это на бою и молча правил бы ноль строк.
- наружу отдаём только счётчики. Номера — персональные данные, экрану они не
  нужны и в ответах не появляются.

Приёмка: 12 тестов сервера и 9 тестов экрана, все до кода и все красные по верной
причине — сервер отвечал 405, блока на экране не было.

Замеры: телеграм-модуль 325 тестов 0 падений, экраны 244 файла 1855 тестов
0 падений, статанализ 0, форматтер чисто. Проверка типов 6 ошибок, все чужие,
столько же было до работы.

Для выката, проверить на бою: право USAGE на счётчике client_tg_contacts_id_seq.
Замерил в тестовой базе — счётчик без права, но ровно так же выглядит и счётчик
client_tg_campaigns_id_seq, в который портал на бою пишет. То есть новых прав эта
работа не требует, но проверка дешёвая, а пропущенный grant на бою даёт отказ,
которого на dev не видно.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:13:31 +03:00

277 lines
14 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 { apiClient, ensureCsrfCookie } from './client';
/**
* API-модуль клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.2).
*
* Эндпоинты под [auth:sanctum, tenant], префикс /api/telegram. GET'ы не требуют
* CSRF-cookie, мутации (POST) — требуют.
*
* 🔴 Отличие от СМС-близнеца: создание (create) и запуск (launch) РАЗДЕЛЕНЫ.
* create возвращает ЧЕРНОВИК со сметой и числом кандидатов; launch переводит его
* в очередь и запускает робота кабинета МТС (в песочнице — только черновик).
*/
/** Способ выбора аудитории: свежие сделки за период / своя база / свой список. */
export type TelegramAudienceKind = 'deals' | 'base' | 'list';
/** Кампания клиентской Telegram-рекламы (строка списка, POST-ответ и часть GET .../{id}). */
export interface TelegramCampaign {
id: number;
status: string;
status_reason?: string | null;
ad_text: string;
ad_link: string;
media_path: string | null;
/** Документ модератору, приложённый при пересдаче отклонённой кампании (задача 3.6). */
moderator_file_path?: string | null;
ord_category: string;
budget_cap_rub: string;
audience_kind: TelegramAudienceKind;
audience_params: Record<string, unknown> | null;
planned_count: number;
matched_count: number | null;
estimated_cost_rub: string;
actual_cost_rub: string | null;
/** null = авто-кампания (накопитель), иначе id создавшего менеджера. */
created_by?: number | null;
created_at?: string;
}
/** Ответ GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
export interface TelegramIndex {
campaigns: TelegramCampaign[];
sandbox: boolean;
balance_rub: string;
frozen_rub: string;
}
/** Тело POST /api/telegram/campaigns — создание черновика кампании. */
export interface TelegramCreatePayload {
ad_text: string;
ad_link: string;
audience_kind: TelegramAudienceKind;
budget_cap_rub: string;
audience_days?: number;
ord_category?: string;
phones?: string[];
/** Заголовок объявления — кабинет МТС требует его для рекламы САЙТА (до 40 знаков). */
ad_headline?: string;
}
/** GET /api/telegram/campaigns — список кампаний тенанта + режим + баланс. */
export async function fetchTelegram(): Promise<TelegramIndex> {
const { data } = await apiClient.get<TelegramIndex>('/api/telegram/campaigns');
return data;
}
/** POST /api/telegram/campaigns — создать ЧЕРНОВИК кампании (деньги/робот не трогаются). */
export async function createTelegram(payload: TelegramCreatePayload): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramCampaign>('/api/telegram/campaigns', payload);
return data;
}
/** Ответ оценки охвата: сколько людей и во сколько обойдётся, плюс порог площадки. */
export interface TelegramOcenka {
planned_count: number;
estimated_cost_rub: string;
/** Минимум кандидатов у МТС Маркетолог — правило площадки, не наше. */
min_count: number;
enough: boolean;
}
/** Что спрашиваем у оценки — только про аудиторию, объявление на охват не влияет. */
export interface TelegramOcenkaPayload {
audience_kind: TelegramAudienceKind;
audience_days?: number;
phones?: string[];
/**
* Вид медиа объявления: у МТС показ с картинкой и видео стоит дороже, и смета
* обязана считаться по тому, что клиент выбрал. Не передан — считаем без медиа.
*/
media_kind?: 'none' | 'image' | 'video';
}
/**
* POST /api/telegram/campaigns/estimate — сколько людей увидит рекламу и во сколько
* это обойдётся, БЕЗ создания кампании.
*
* 🔴 Не путать с `createTelegram`: та тоже возвращает охват, но СОЗДАЁТ черновик в базе.
* Живой счётчик дёргается на каждое изменение поля, и через неё он засорил бы клиенту
* список кампаний десятками призраков.
*/
export async function estimateTelegram(payload: TelegramOcenkaPayload): Promise<TelegramOcenka> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramOcenka>('/api/telegram/campaigns/estimate', payload);
return data;
}
/** GET /api/telegram/campaigns/{id} — детали кампании. */
export async function fetchTelegramCampaign(id: number): Promise<{ campaign: TelegramCampaign }> {
const { data } = await apiClient.get<{ campaign: TelegramCampaign }>(`/api/telegram/campaigns/${id}`);
return data;
}
/** POST /api/telegram/campaigns/{id}/launch — запустить черновик (draft → queued + робот). */
export async function launchTelegram(id: number): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/launch`);
return data;
}
/** Тело POST /api/telegram/campaigns/{id}/resubmit — правки + опц. документ модератору. */
export interface TelegramResubmitPayload {
ad_text: string;
ad_link: string;
ord_category?: string;
/** Лицензия/договор для модератора МТС (png/jpg/pdf ≤10 МБ). */
moderator_file?: File | null;
}
/**
* POST /api/telegram/campaigns/{id}/resubmit — пересдать ОТКЛОНЁННУЮ кампанию
* (rejected → queued). Отправляем multipart, т.к. можно приложить документ модератору.
*/
export async function resubmitTelegram(id: number, payload: TelegramResubmitPayload): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const form = new FormData();
form.append('ad_text', payload.ad_text);
form.append('ad_link', payload.ad_link);
if (payload.ord_category) {
form.append('ord_category', payload.ord_category);
}
if (payload.moderator_file) {
form.append('moderator_file', payload.moderator_file);
}
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/resubmit`, form);
return data;
}
/**
* Что кабинет МТС принимает в поле медиа (снято глазами 02.08.2026). Список идёт
* в атрибут accept у полей загрузки — чтобы клиенту в проводнике не показывали
* заведомо негодные файлы. Настоящая проверка — на сервере (правило MtsMedia).
*/
export const MEDIA_ACCEPT =
'image/jpeg,image/png,video/mp4,video/webm,video/quicktime,video/x-matroska,video/mpeg,video/x-m4v,video/x-ms-wmv';
/** Требования МТС к медиа человеческим языком — подсказка под полем загрузки. */
export const MEDIA_TREBOVANIYA =
'Картинка: JPEG или PNG, до 25 МБ, от 640×360 до 5120×2880. Видео: до 20 МБ, 3–55 секунд, от 640×360.';
/**
* Показ с медиа дороже обычного — клиент должен знать это ДО загрузки файла, а не
* после списания денег.
*
* 🔴 Чисел здесь намеренно НЕТ. Раньше стояло «600 ₽ за тысячу, с видео — 680 ₽» —
* это была закупочная цена МТС без НДС, а не то, что платит клиент; вдобавок она
* протухала бы при любой правке тарифа в админке. Точную сумму считает сервер и
* показывает смета — она знает и про медиа, и про наценку.
*/
export const MEDIA_CENA =
'Картинка и видео делают показ дороже. Смета пересчитается сама, как только выберете файл.';
/**
* POST /api/telegram/campaigns/{id}/media — приложить картинку/видео к ЧЕРНОВИКУ
* объявления. Требования — см. MEDIA_TREBOVANIYA. Робот отдаёт файл кабинету МТС
* при запуске.
*/
export async function uploadTelegramMedia(id: number, file: File): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const form = new FormData();
form.append('media', file);
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/media`, form);
return data;
}
/**
* Правило авто-рекламы Telegram (план §Этап 5, задача 5.4). Одно на тенанта: клиент
* из кабинета включает авто, задаёт объявление, порог пачки, бюджет и дневной лимит.
*/
export interface TelegramAutoRule {
enabled: boolean;
ad_text: string;
ad_link: string;
/** Заголовок объявления — МТС требует его, когда реклама ведёт на САЙТ (до 40 знаков). */
ad_headline: string;
/** Картинка/видео объявления; грузится отдельно (uploadAutoRuleMedia), в PUT не ходит. */
media_path?: string | null;
ord_category: string;
budget_cap_rub: string;
daily_limit_rub: string;
/** Сколько кандидатов накопить перед отправкой пачки; null → порог по умолчанию (367). */
batch_threshold: number | null;
}
/** GET /api/telegram/auto-rule — правило авто-рекламы тенанта (или дефолты). */
export async function fetchAutoRule(): Promise<TelegramAutoRule> {
const { data } = await apiClient.get<TelegramAutoRule>('/api/telegram/auto-rule');
return data;
}
/** PUT /api/telegram/auto-rule — сохранить правило авто-рекламы. */
export async function saveAutoRule(payload: TelegramAutoRule): Promise<TelegramAutoRule> {
await ensureCsrfCookie();
const { data } = await apiClient.put<TelegramAutoRule>('/api/telegram/auto-rule', payload);
return data;
}
/**
* POST /api/telegram/auto-rule/media — приложить картинку/видео к авто-правилу.
* Оно повторяется в каждой авто-пачке. Правило должно быть уже сохранено — иначе 422.
*/
export async function uploadAutoRuleMedia(file: File): Promise<TelegramAutoRule> {
await ensureCsrfCookie();
const form = new FormData();
form.append('media', file);
const { data } = await apiClient.post<TelegramAutoRule>('/api/telegram/auto-rule/media', form);
return data;
}
// --- «Моя база номеров» (client_tg_contacts) --------------------------------
//
// 🔴 Наружу ходят только счётчики. Номера — персональные данные, экрану они не нужны:
// он показывает «в базе N номеров», а сами номера остаются на сервере.
/** Сколько номеров в базе тенанта. */
export interface TgBazaRazmer {
count: number;
}
/** Итог заливки файла: что приняли, что схлопнули, что не разобрали. */
export interface TgBazaItog extends TgBazaRazmer {
accepted: number;
duplicates: number;
rejected: number;
/** Файл оказался длиннее потолка — взяли первые `max` номеров. */
oborvano: boolean;
max: number;
}
/** GET /api/telegram/contacts — размер базы. */
export async function fetchTgBaza(): Promise<TgBazaRazmer> {
const { data } = await apiClient.get<TgBazaRazmer>('/api/telegram/contacts');
return data;
}
/**
* POST /api/telegram/contacts — залить файл с номерами.
*
* 🪤 ЗАМЕНЯЕТ базу целиком, а не добавляет к ней. Экран обязан сказать об этом до
* нажатия, иначе клиент узнает о потере после.
*/
export async function uploadTgBaza(file: File): Promise<TgBazaItog> {
await ensureCsrfCookie();
const form = new FormData();
form.append('file', file);
const { data } = await apiClient.post<TgBazaItog>('/api/telegram/contacts', form);
return data;
}
/** DELETE /api/telegram/contacts — очистить базу целиком. */
export async function clearTgBaza(): Promise<TgBazaRazmer> {
await ensureCsrfCookie();
const { data } = await apiClient.delete<TgBazaRazmer>('/api/telegram/contacts');
return data;
}