Files
portal/app/resources/js/api/telegram.ts
T
Дмитрий 76cb44b91d feat(телеграм-реклама): пачка 4б — мастер шагов, живой счётчик охвата и серверная ручка оценки
Замечание владельца: «мастер шагов вместо простыни» и «живой счётчик охвата
вместо кнопки Рассчитать». Форма была одна длинная, охват узнавался только по
нажатию кнопки.

Экран:
- мастер из четырёх шагов: Кому показываем → Объявление → Деньги → Проверка;
- живой счётчик охвата на первом шаге, пересчитывается сам при смене источника
  людей, периода или списка номеров (с задержкой, чтобы не дёргать сервер
  на каждую букву);
- кнопки «Рассчитать» больше нет: «Запустить» создаёт кампанию, прикладывает
  картинку и отправляет в кабинет одним нажатием;
- на шаге «Деньги» рядом смета и остаток баланса — нехватка денег видна ДО
  запуска, а не отказом после;
- сводка на последнем шаге повторяет всё выбранное.

Сервер — новая ручка POST /api/telegram/campaigns/estimate:
🔴 Живой счётчик обязан считать на каждое изменение поля, а охват до сих пор
возвращала только POST /campaigns — и она СОЗДАЁТ черновик в базе. Через неё
счётчик наплодил бы десятки кампаний-призраков в списке клиента. Новая ручка
только читает: ни записи, ни денег, ни робота. На это стоит отдельный тест.

Ручка считает ТЕМ ЖЕ кодом, что и создание кампании (общее тело выборки из
сделок вынесено на оба входа TelegramAudienceService). Отдельный тест сверяет
два ответа: разойдись они — клиент видел бы на экране одно число, а платил
по другому.

🔴 Запуск запрещён, когда людей меньше 367 — правило площадки, не наше. Тот же
предохранитель стоит на сервере (AudienceGateTest); на экране он объясняет
заранее, вместо отказа после оплаты.

Новых прав в базе ручка не требует: читает строго то же, что уже читает
создание кампании, и не пишет никуда.

Тесты: +EstimateApiTest (11 на PHP), +telegram-master-shagov (27 на экранах).
14 прежних проверок формы переехали в набор мастера вместе с кодом — сперва
переписаны на новом месте и там проверены, потом убраны со старого. Устарели
по смыслу только две («Рассчитать создаёт черновик», «правка сбрасывает
смету»): считать вручную больше нечего, устареть расчёту негде.

Замер: экраны 240 файлов, 1829 тестов, 0 падений (было 239/1813); телеграм-
модуль на PHP 292 теста, 0 падений (было 281); статанализ 0 (базовая линия
пересобрана — добавлен только новый Pest-файл, удалений нет); типы 6 чужих
ошибок вместо 7; pint чист.

На боевой НЕ выкачено. Глазами не принято — приёмка в пачке 5.

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

219 lines
11 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[];
}
/**
* 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.';
/**
* Показ с медиа дороже обычного — цены кабинета МТС на 02.08.2026. Клиент должен
* увидеть это ДО загрузки файла, а не после списания денег.
*/
export const MEDIA_CENA = 'С картинкой показ стоит дороже: 600 ₽ за тысячу, с видео — 680 ₽.';
/**
* 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;
}