00cc072e2f
Это снимает запрет на выкат телеграм-рекламы. Пункт «Моя база номеров» стоял в выборе аудитории с 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>
277 lines
14 KiB
TypeScript
277 lines
14 KiB
TypeScript
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;
|
||
}
|