7922bffb6d
Пачка 1 замечаний владельца о едином виде рекламных экранов. Песочница выключена 02.08.2026 — каждая дыра ниже стоила живых денег. 1. Заголовок объявления в АВТО-правиле. Кабинет МТС требует его для рекламы сайта; утром 02.08 поле довезли до разовой формы, а в авто его не было вовсе — накопитель создавал кампанию без ad_headline, и она сгорела бы в кабинете уже после списания. Колонка client_tg_auto_rule.ad_headline, приём в контроллере (обязателен только при включённом авто и не-телеграмной ссылке), перенос в кампанию, поле на экране. 2. Картинка/видео в авто-правиле. Колонка media_path, отдельная загрузка POST /api/telegram/auto-rule/media, перенос в кампанию, поле на экране. 3. Проверка медиа переписана по настоящим требованиям кабинета, снятым глазами 02.08. Было mimes:png,jpg,jpeg,gif,mp4|max:51200 — врало по пяти пунктам: принимало GIF, пропускало вдвое больший вес, не смотрело пиксели и длительность, зря отказывало в mov/webm. Стало правило App\Rules\ClientTg\MtsMedia: картинка JPEG/PNG до 25 МБ и 640x360...5120x2880, видео до 20 МБ, 3-55 секунд, от 640x360. Формат картинки определяется по содержимому, длительность и кадр видео читает App\Support\Mp4Probe из контейнера — без внешних программ. Отказ человеческий: «Картинка слишком маленькая: 300x200. Нужна не меньше 640x360». 4. Цена показа с медиа — 600 руб. с картинкой, 680 с видео — теперь видна ДО загрузки файла, на обоих экранах. Сверх плана, найдено по дороге: 5. Предохранитель накопителя: правило, сохранённое до 02.08 со ссылкой на сайт и пустым заголовком, всё равно ушло бы в кабинет. Теперь такая пачка держится черновиком, в журнал пишется причина no_headline. 6. Отказ сервера доходит до клиента его словами. Оба экрана глушили ответ общей фразой «Не удалось рассчитать кампанию», и человек не понимал, что не так с файлом. Границы честности: длительность и размер кадра читаются только у mp4/mov/m4v; у webm/mkv/mpeg/wmv проверяются формат и вес — так и записано в Mp4Probe. Проверено: 281 тест телеграм-модуля, 1753 фронтенд-теста, статанализ 0, Pint чист. Глазами НЕ принимали — приёмка живьём в пачке 5. На боевой не выкачено. Журнал схемы: v9.64 — номер взят как максимум по всем веткам плюс один, чтобы не повторить столкновения 29.07 и 01.08. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
189 lines
9.2 KiB
TypeScript
189 lines
9.2 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;
|
||
}
|
||
|
||
/** 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;
|
||
}
|