diff --git a/API_DOCS.md b/API_DOCS.md new file mode 100644 index 0000000..3b4ca63 --- /dev/null +++ b/API_DOCS.md @@ -0,0 +1,525 @@ +# TGEX Backend API v2 + +## Аутентификация + +**JWT Bearer Token** в заголовке `Authorization: Bearer ` + +### Получение токена через Telegram Bot + +Логика точно такая же как и раньше + +### Эндпоинты + +| Метод | Эндпоинт | Описание | Auth | +|-------|----------|----------|------| +| `GET` | `/auth/complete` | Обмен временного токена на JWT | ❌ | +| `GET` | `/auth/me` | Получить текущего пользователя | ✅ | + +**Response** `/auth/me`: +```json +{ + "id": "uuid", + "telegram_id": 123456789, + "username": "string | null" +} +``` + +--- + +## Workspaces (Рабочие пространства) + +Воркспейсы - изолированные пространства для команд. У пользователя может быть несколько воркспейсов. + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| `GET` | `/workspaces` | Список воркспейсов пользователя (пагинация) | +| `POST` | `/workspaces` | Создать воркспейс | +| `PATCH` | `/workspaces/{workspace_id}` | Обновить название | +| `DELETE` | `/workspaces/{workspace_id}` | Удалить воркспейс | + +**Body** `POST /workspaces`: +```json +{ + "name": "string" +} +``` + +**Response**: +```json +{ + "id": "uuid", + "name": "string" +} +``` + +--- + +## Workspace Members (Участники воркспейса) + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| `GET` | `/workspaces/{workspace_id}/members` | Список участников (пагинация) | +| `PUT` | `/workspaces/{workspace_id}/members/{user_id}/permissions` | Обновить права участника | + +**Права доступа** (`PermissionKey`): +- `admin_full` - полный доступ +- `projects_read` - чтение проектов +- `projects_write` - запись проектов +- `placements_read` - чтение размещений +- `placements_write` - запись размещений +- `analytics_read` - чтение аналитики + +**Scope types**: `project` (ограничение прав на конкретный проект) + +**Body** `PUT permissions`: +```json +{ + "permissions": [ + { + "key": "admin_full" + }, + { + "key": "projects_write", + "scopes": [ + {"type": "project", "id": "project-uuid"} + ] + } + ] +} +``` + +--- + +## Workspace Invites (Приглашения) + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| `GET` | `/workspaces/{workspace_id}/invites` | Список приглашений (пагинация) | +| `POST` | `/workspaces/{workspace_id}/invites` | Создать приглашение | + +**Body** `POST /invites`: +```json +{ + "username": "telegram_username" // без @ +} +``` + +**Статусы приглашения**: `PENDING`, `ACCEPTED`, `REJECTED` + +--- + +## Projects (Проекты / Целевые каналы) + +Проекты = целевые Telegram каналы, которые продвигаются. Создаются через Telegram бота. + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| `GET` | `/workspaces/{workspace_id}/projects` | Список проектов (пагинация) | + +**Response**: +```json +{ + "id": "uuid", + "telegram_id": -1001234567890, + "title": "string", + "username": "string | null", + "status": "ACTIVE | INACTIVE | ARCHIVED" +} +``` + +**Управление проектами**: через Telegram бота (добавить/удалить канал) + +--- + +## Channels (Каналы для размещений) + +Каталог Telegram каналов, где можно размещать рекламу. + +| Метод | Эндпоинт | Описание | Фильтры | +|-------|----------|----------|---------| +| `GET` | `/channels` | Список каналов (пагинация) | `?username=...` | + +**Response**: +```json +{ + "id": "uuid", + "telegram_id": -1002345678901, + "title": "string", + "username": "string | null", + "description": "string | null", + "subscribers_count": 15000 +} +``` + +--- + +## Purchase Plan (План закупа) + +План закупа - список каналов для покупки рекламы в рамках проекта. + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| `GET` | `/workspaces/{ws}/projects/{proj}/purchase-plan/channels` | Список каналов плана (пагинация) | +| `POST` | `/workspaces/{ws}/projects/{proj}/purchase-plan/channels` | Добавить канал в план | +| `PATCH` | `/workspaces/{ws}/projects/{proj}/purchase-plan/channels/{id}` | Обновить параметры канала | +| `DELETE` | `/workspaces/{ws}/projects/{proj}/purchase-plan/channels/{id}` | Удалить из плана | + +**Body** `POST`: +```json +{ + "channel_id": "uuid", + "planned_date": "2025-01-15T12:00:00Z", // optional + "price": 5000.50, // optional + "comment": "string" // optional +} +``` + +**Body** `PATCH`: +```json +{ + "planned_date": "2025-01-15T12:00:00Z", // optional + "price": 5000.50, // optional + "comment": "string" // optional +} +``` + +**Response**: +```json +{ + "id": "uuid", + "status": "PLANNED | COMPLETED | CANCELLED", + "planned_date": "2025-01-15T12:00:00Z | null", + "price": 5000.50, + "comment": "string | null", + "channel": { + "id": "uuid", + "title": "string", + "username": "string | null", + "subscribers_count": 15000 + } +} +``` + +--- + +## Creatives (Креативы) + +Креативы = рекламные материалы (текст с пригласительной ссылкой). + +| Метод | Эндпоинт | Описание | Фильтры | +|-------|----------|----------|---------| +| `GET` | `/workspaces/{ws}/creatives` | Список креативов (пагинация) | `?project_id=...&include_archived=false` | +| `GET` | `/workspaces/{ws}/creatives/{id}` | Один креатив | | +| `POST` | `/workspaces/{ws}/creatives?project_id=...` | Создать креатив | | +| `PATCH` | `/workspaces/{ws}/creatives/{id}` | Обновить креатив | | +| `DELETE` | `/workspaces/{ws}/creatives/{id}` | Удалить (архивировать) | | + +**Body** `POST/PATCH`: +```json +{ + "name": "string", + "text": "Текст с {invite_link} тегом" +} +``` + +**Response**: +```json +{ + "id": "uuid", + "name": "string", + "text": "string", + "status": "ACTIVE | ARCHIVED", + "placements_count": 5 +} +``` + +**Важно**: `{invite_link}` в тексте заменяется на уникальную ссылку при создании размещения. + +--- + +## Placements (Размещения) + +Размещения = факт размещения креатива в канале. + +| Метод | Эндпоинт | Описание | Фильтры | +|-------|----------|----------|---------| +| `GET` | `/workspaces/{ws}/placements` | Список размещений (пагинация) | `?project_id=...&placement_channel_id=...&creative_id=...&include_archived=false` | +| `GET` | `/workspaces/{ws}/placements/{id}` | Одно размещение | | +| `POST` | `/workspaces/{ws}/placements` | Создать размещение | | +| `PATCH` | `/workspaces/{ws}/placements/{id}` | Обновить размещение | | +| `DELETE` | `/workspaces/{ws}/placements/{id}` | Удалить (архивировать) | | + +**Body** `POST`: +```json +{ + "project_id": "uuid", + "placement_channel_id": "uuid", + "creative_id": "uuid", + "placement_date": "2025-01-15T12:00:00Z", + "cost": 5000.50, // optional + "comment": "string", // optional + "ad_post_url": "https://t.me/channel/123", // optional + "invite_link_type": "PUBLIC | APPROVAL" +} +``` + +**Body** `PATCH`: +```json +{ + "placement_date": "2025-01-15T12:00:00Z", // optional + "cost": 5000.50, // optional + "comment": "string", // optional + "ad_post_url": "https://t.me/channel/123" // optional +} +``` + +**Response**: +```json +{ + "id": "uuid", + "placement_date": "2025-01-15T12:00:00Z", + "cost": 5000.50, + "comment": "string | null", + "ad_post_url": "string | null", + "invite_link_type": "PUBLIC | APPROVAL", + "invite_link": "https://t.me/+UniqueCode", + "status": "ACTIVE | ARCHIVED", + "subscriptions_count": 42, + "views_count": 1850, + "views_availability": "UNKNOWN | AVAILABLE | UNAVAILABLE | MANUAL", + "last_views_fetch_at": "2025-01-15T14:30:00Z", + "project": { + "id": "uuid", + "title": "string", + "username": "string | null" + }, + "placement_channel": { + "id": "uuid", + "title": "string", + "username": "string | null", + "subscribers_count": 15000 + }, + "creative": { + "id": "uuid", + "name": "string" + } +} +``` + +**Типы пригласительных ссылок**: +- `PUBLIC` - открытая ссылка +- `APPROVAL` - с одобрением через бота + +**Статус просмотров**: +- `UNKNOWN` - еще не проверялось +- `AVAILABLE` - просмотры доступны (автообновление) +- `UNAVAILABLE` - нет доступа к просмотрам +- `MANUAL` - ручной ввод + +--- + +## Views History (История просмотров) + +| Метод | Эндпоинт | Описание | Фильтры | +|-------|----------|----------|---------| +| `GET` | `/workspaces/{ws}/placements/{id}/views/history` | История просмотров (пагинация) | `?from_date=...&to_date=...` | + +**Response**: +```json +{ + "id": "uuid", + "views_count": 1850, + "fetched_at": "2025-01-15T14:30:00Z" +} +``` + +--- + +## Analytics (Аналитика) + +Все эндпоинты поддерживают фильтр `?project_id=...` для конкретного проекта. + +### Аналитика размещений + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| `GET` | `/workspaces/{ws}/analytics/placements` | Аналитика по размещениям (пагинация) | + +**Response**: +```json +{ + "placement_id": "uuid", + "placement_channel_title": "string", + "placement_channel_username": "string | null", + "creative_name": "string", + "placement_date": "2025-01-15T12:00:00Z", + "cost": 5000.50, + "subscriptions_count": 42, + "views_count": 1850, + "cps": 119.05, // cost per subscription + "cpm": 2702.70 // cost per mille views +} +``` + +### Аналитика креативов + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| `GET` | `/workspaces/{ws}/analytics/creatives` | Аналитика по креативам (пагинация) | + +**Response**: +```json +{ + "creative_id": "uuid", + "creative_name": "string", + "placements_count": 5, + "total_cost": 25000.00, + "total_subscriptions": 210, + "total_views": 9250, + "avg_cps": 119.05, + "avg_cpm": 2702.70 +} +``` + +### Аналитика каналов + +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| `GET` | `/workspaces/{ws}/analytics/channels` | Аналитика по каналам (пагинация) | + +**Response**: +```json +{ + "channel_id": "uuid", + "channel_title": "string", + "channel_username": "string | null", + "placements_count": 3, + "total_cost": 15000.00, + "total_subscriptions": 126, + "total_views": 5550, + "avg_cps": 119.05, + "avg_cpm": 2702.70 +} +``` + +### Аналитика расходов + +| Метод | Эндпоинт | Описание | Фильтры | +|-------|----------|----------|---------| +| `GET` | `/workspaces/{ws}/analytics/spending` | Динамика расходов | `?date_from=...&date_to=...&grouping=DAY\|WEEK\|MONTH` | + +**Response**: +```json +{ + "items": [ + { + "date": "2025-01-15", + "cost": 5000.50, + "subscriptions": 42, + "views": 1850 + } + ], + "total_cost": 25000.00, + "total_subscriptions": 210, + "total_views": 9250 +} +``` + +**Группировка** (`DateGrouping`): `DAY`, `WEEK`, `MONTH` + +--- + +## Пагинация + +Все эндпоинты с пагинацией возвращают: + +```json +{ + "items": [...], + "total": 42, + "page": 1, + "size": 50, + "pages": 1 +} +``` + +**Query параметры**: `?page=1&size=50` + +--- + +## Статусы и Enum + +### WorkspaceUserStatus +- `ACTIVE` - активный участник +- `INVITED` - приглашён, но не принял + +### WorkspaceInviteStatus +- `PENDING` - ожидает принятия +- `ACCEPTED` - принято +- `REJECTED` - отклонено + +### ProjectStatus / ChannelStatus +- `ACTIVE` - активен +- `INACTIVE` - неактивен +- `ARCHIVED` - архивирован + +### CreativeStatus / PlacementStatus +- `ACTIVE` - активен +- `ARCHIVED` - архивирован + +### PurchasePlanStatus +- `PLANNED` - запланирован +- `COMPLETED` - завершён +- `CANCELLED` - отменён + +### InviteLinkType +- `PUBLIC` - открытая ссылка +- `APPROVAL` - с одобрением бота + +### PostViewsAvailability +- `UNKNOWN` - неизвестно (ещё не проверялось) +- `AVAILABLE` - доступны (автообновление) +- `UNAVAILABLE` - недоступны +- `MANUAL` - ручной ввод + +--- + +## Коды ошибок HTTP + +- `200` - OK +- `201` - Created +- `204` - No Content (успешное удаление) +- `400` - Bad Request (валидация) +- `401` - Unauthorized (нет токена) +- `403` - Forbidden (нет прав) +- `404` - Not Found +- `500` - Internal Server Error + +**Формат ошибки**: +```json +{ + "detail": "Error message" +} +``` + +--- + +## Особенности + +1. **Все UUID в формате**: `xxxxxxxx-xxxx-4xxx-xxxx-xxxxxxxxxxxx` +2. **Даты в ISO 8601**: `2025-01-15T12:00:00Z` (UTC) +3. **Метрики автообновляются**: просмотры обновляются воркером каждые 60 секунд (если `views_availability = AVAILABLE`) +4. **Подписки отслеживаются**: через Telegram бота при присоединении по invite_link +5. **Права проверяются**: все эндпоинты под воркспейсами требуют соответствующих прав +6. **Soft delete**: удаление = архивирование (статус `ARCHIVED`) + +--- + +## Telegram Bot Integration + +- Создание проектов (добавление целевых каналов) +- Создание креативов через диалог +- Приглашения в воркспейсы +- Отслеживание подписок через invite links +- Начальная аутентификация (`/start` → временный токен) + diff --git a/api.json b/api.json deleted file mode 100644 index c95adf1..0000000 --- a/api.json +++ /dev/null @@ -1,2609 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "FastAPI", - "version": "unknown" - }, - "paths": { - "/api/v1/auth/complete": { - "get": { - "tags": ["auth"], - "summary": "Complete Auth", - "operationId": "complete_auth_api_v1_auth_complete_get", - "parameters": [ - { - "name": "token", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Token" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ValidateLoginTokenOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/me": { - "get": { - "tags": ["auth"], - "summary": "Get Me", - "operationId": "get_me_api_v1_auth_me_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserOutput" - } - } - } - } - }, - "security": [ - { - "HTTPBearer": [] - } - ] - } - }, - "/api/v1/target_channels": { - "get": { - "tags": ["target_channels"], - "summary": "Get User Target Chans", - "operationId": "get_user_target_chans_api_v1_target_channels_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetUserTargetChansOutput" - } - } - } - } - }, - "security": [ - { - "HTTPBearer": [] - } - ] - } - }, - "/api/v1/target_channels/{channel_id}": { - "delete": { - "tags": ["target_channels"], - "summary": "Disconnect Target Chan", - "operationId": "disconnect_target_chan_api_v1_target_channels__channel_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "channel_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Channel Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/external_channels/target/{target_channel_id}": { - "get": { - "tags": ["external_channels"], - "summary": "Get External Channels", - "description": "Get all external channels for a specific target channel.", - "operationId": "get_external_channels_api_v1_external_channels_target__target_channel_id__get", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "target_channel_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Target Channel Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetExternalChannelsOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/external_channels": { - "post": { - "tags": ["external_channels"], - "summary": "Create External Channel", - "description": "Create a new external channel and link it to target channels.", - "operationId": "create_external_channel_api_v1_external_channels_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExternalChannelInput" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalChannelOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - }, - "security": [ - { - "HTTPBearer": [] - } - ] - } - }, - "/api/v1/external_channels/{channel_id}": { - "patch": { - "tags": ["external_channels"], - "summary": "Update External Channel", - "description": "Update external channel fields (title, username, description, subscribers_count).", - "operationId": "update_external_channel_api_v1_external_channels__channel_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "channel_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Channel Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateExternalChannelInput" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalChannelOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": ["external_channels"], - "summary": "Delete External Channel", - "description": "Delete an external channel.", - "operationId": "delete_external_channel_api_v1_external_channels__channel_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "channel_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Channel Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/external_channels/{channel_id}/links": { - "patch": { - "tags": ["external_channels"], - "summary": "Update External Channel Links", - "description": "Update target channel links for an existing external channel.\n\nYou can add new target channels, remove existing ones, or both in a single request.", - "operationId": "update_external_channel_links_api_v1_external_channels__channel_id__links_patch", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "channel_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Channel Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateExternalChannelLinksInput" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExternalChannelOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/creatives": { - "get": { - "tags": ["creatives"], - "summary": "List Creatives", - "operationId": "list_creatives_api_v1_creatives_get", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "target_channel_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Target Channel Id" - } - }, - { - "name": "include_archived", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Include Archived" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetCreativesOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": ["creatives"], - "summary": "Create Creative", - "operationId": "create_creative_api_v1_creatives_post", - "security": [ - { - "HTTPBearer": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCreativeInput" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreativeOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/creatives/{creative_id}": { - "get": { - "tags": ["creatives"], - "summary": "Get Creative", - "operationId": "get_creative_api_v1_creatives__creative_id__get", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "creative_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Creative Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreativeOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "tags": ["creatives"], - "summary": "Update Creative", - "operationId": "update_creative_api_v1_creatives__creative_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "creative_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Creative Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateCreativeInput" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreativeOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": ["creatives"], - "summary": "Delete Creative", - "operationId": "delete_creative_api_v1_creatives__creative_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "creative_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Creative Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/placements": { - "get": { - "tags": ["placements"], - "summary": "List Placements", - "operationId": "list_placements_api_v1_placements_get", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "target_channel_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Target Channel Id" - } - }, - { - "name": "external_channel_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "External Channel Id" - } - }, - { - "name": "creative_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Creative Id" - } - }, - { - "name": "include_archived", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Include Archived" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetPlacementsOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": ["placements"], - "summary": "Create Placement", - "operationId": "create_placement_api_v1_placements_post", - "security": [ - { - "HTTPBearer": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreatePlacementInput" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlacementOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/placements/{placement_id}": { - "get": { - "tags": ["placements"], - "summary": "Get Placement", - "operationId": "get_placement_api_v1_placements__placement_id__get", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "placement_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Placement Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlacementOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "tags": ["placements"], - "summary": "Update Placement", - "operationId": "update_placement_api_v1_placements__placement_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "placement_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Placement Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdatePlacementInput" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlacementOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": ["placements"], - "summary": "Delete Placement", - "operationId": "delete_placement_api_v1_placements__placement_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "placement_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Placement Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/placements/{placement_id}/views/history": { - "get": { - "tags": ["views"], - "summary": "Get Views History", - "description": "Получить историю просмотров для закупа.", - "operationId": "get_views_history_api_v1_placements__placement_id__views_history_get", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "placement_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Placement Id" - } - }, - { - "name": "from_date", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "From Date" - } - }, - { - "name": "to_date", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "To Date" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetViewsHistoryOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/placements/{placement_id}/views/fetch": { - "post": { - "tags": ["views"], - "summary": "Fetch Views", - "description": "Получить актуальные просмотры через Telegram API.", - "operationId": "fetch_views_api_v1_placements__placement_id__views_fetch_post", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "placement_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Placement Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FetchViewsManuallyOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/placements/{placement_id}/views/manual": { - "post": { - "tags": ["views"], - "summary": "Update Views Manually", - "description": "Обновить просмотры вручную.", - "operationId": "update_views_manually_api_v1_placements__placement_id__views_manual_post", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "placement_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid", - "title": "Placement Id" - } - }, - { - "name": "views_count", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "title": "Views Count" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlacementOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/analytics/placements": { - "get": { - "tags": ["analytics"], - "summary": "Get Placements Analytics", - "operationId": "get_placements_analytics_api_v1_analytics_placements_get", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "target_channel_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Target Channel Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetPlacementsAnalyticsOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/analytics/creatives": { - "get": { - "tags": ["analytics"], - "summary": "Get Creatives Analytics", - "operationId": "get_creatives_analytics_api_v1_analytics_creatives_get", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "target_channel_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Target Channel Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetCreativesAnalyticsOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/analytics/external-channels": { - "get": { - "tags": ["analytics"], - "summary": "Get External Channels Analytics", - "operationId": "get_external_channels_analytics_api_v1_analytics_external_channels_get", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "target_channel_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Target Channel Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetExternalChannelsAnalyticsOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/analytics/spending": { - "get": { - "tags": ["analytics"], - "summary": "Get Spending Analytics", - "operationId": "get_spending_analytics_api_v1_analytics_spending_get", - "security": [ - { - "HTTPBearer": [] - } - ], - "parameters": [ - { - "name": "target_channel_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "Target Channel Id" - } - }, - { - "name": "date_from", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Date From" - } - }, - { - "name": "date_to", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Date To" - } - }, - { - "name": "grouping", - "in": "query", - "required": false, - "schema": { - "$ref": "#/components/schemas/DateGrouping", - "default": "day" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetSpendingAnalyticsOutput" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "ConnectTargetChanOutput": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "telegram_id": { - "type": "integer", - "title": "Telegram Id" - }, - "title": { - "type": "string", - "title": "Title" - }, - "username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Username" - }, - "is_active": { - "type": "boolean", - "title": "Is Active" - } - }, - "type": "object", - "required": ["id", "telegram_id", "title", "username", "is_active"], - "title": "ConnectTargetChanOutput" - }, - "CreateCreativeInput": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "text": { - "type": "string", - "title": "Text" - }, - "target_channel_id": { - "type": "string", - "format": "uuid", - "title": "Target Channel Id" - } - }, - "type": "object", - "required": ["name", "text", "target_channel_id"], - "title": "CreateCreativeInput" - }, - "CreateExternalChannelInput": { - "properties": { - "telegram_id": { - "type": "integer", - "title": "Telegram Id" - }, - "title": { - "type": "string", - "title": "Title" - }, - "username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Username" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "subscribers_count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Subscribers Count" - }, - "target_channel_ids": { - "items": { - "type": "string", - "format": "uuid" - }, - "type": "array", - "title": "Target Channel Ids" - } - }, - "type": "object", - "required": ["telegram_id", "title", "target_channel_ids"], - "title": "CreateExternalChannelInput" - }, - "CreatePlacementInput": { - "properties": { - "target_channel_id": { - "type": "string", - "format": "uuid", - "title": "Target Channel Id" - }, - "external_channel_id": { - "type": "string", - "format": "uuid", - "title": "External Channel Id" - }, - "creative_id": { - "type": "string", - "format": "uuid", - "title": "Creative Id" - }, - "placement_date": { - "type": "string", - "format": "date-time", - "title": "Placement Date" - }, - "cost": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cost" - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Comment" - }, - "ad_post_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Ad Post Url" - }, - "invite_link_type": { - "$ref": "#/components/schemas/InviteLinkType", - "default": "public" - } - }, - "type": "object", - "required": [ - "target_channel_id", - "external_channel_id", - "creative_id", - "placement_date" - ], - "title": "CreatePlacementInput" - }, - "CreativeAnalyticsOutput": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "placements_count": { - "type": "integer", - "title": "Placements Count" - }, - "total_cost": { - "type": "number", - "title": "Total Cost" - }, - "total_subscriptions": { - "type": "integer", - "title": "Total Subscriptions" - }, - "total_views": { - "type": "integer", - "title": "Total Views" - }, - "avg_cpf": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Avg Cpf" - }, - "avg_cpm": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Avg Cpm" - } - }, - "type": "object", - "required": [ - "id", - "name", - "placements_count", - "total_cost", - "total_subscriptions", - "total_views", - "avg_cpf", - "avg_cpm" - ], - "title": "CreativeAnalyticsOutput" - }, - "CreativeOutput": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "name": { - "type": "string", - "title": "Name" - }, - "text": { - "type": "string", - "title": "Text" - }, - "target_channel_id": { - "type": "string", - "format": "uuid", - "title": "Target Channel Id" - }, - "target_channel_title": { - "type": "string", - "title": "Target Channel Title" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "status": { - "$ref": "#/components/schemas/CreativeStatus" - }, - "placements_count": { - "type": "integer", - "title": "Placements Count" - } - }, - "type": "object", - "required": [ - "id", - "name", - "text", - "target_channel_id", - "target_channel_title", - "created_at", - "status", - "placements_count" - ], - "title": "CreativeOutput" - }, - "CreativeStatus": { - "type": "string", - "enum": ["active", "archived"], - "title": "CreativeStatus" - }, - "DateGrouping": { - "type": "string", - "enum": ["day", "week", "month", "quarter", "year"], - "title": "DateGrouping" - }, - "ExternalChannelAnalyticsOutput": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "title": { - "type": "string", - "title": "Title" - }, - "username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Username" - }, - "placements_count": { - "type": "integer", - "title": "Placements Count" - }, - "total_cost": { - "type": "number", - "title": "Total Cost" - }, - "total_subscriptions": { - "type": "integer", - "title": "Total Subscriptions" - }, - "total_views": { - "type": "integer", - "title": "Total Views" - }, - "avg_cpf": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Avg Cpf" - }, - "avg_cpm": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Avg Cpm" - } - }, - "type": "object", - "required": [ - "id", - "title", - "username", - "placements_count", - "total_cost", - "total_subscriptions", - "total_views", - "avg_cpf", - "avg_cpm" - ], - "title": "ExternalChannelAnalyticsOutput" - }, - "ExternalChannelOutput": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "telegram_id": { - "type": "integer", - "title": "Telegram Id" - }, - "title": { - "type": "string", - "title": "Title" - }, - "username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Username" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "subscribers_count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Subscribers Count" - } - }, - "type": "object", - "required": [ - "id", - "telegram_id", - "title", - "username", - "description", - "subscribers_count" - ], - "title": "ExternalChannelOutput" - }, - "FetchViewsManuallyOutput": { - "properties": { - "placement_id": { - "type": "string", - "format": "uuid", - "title": "Placement Id" - }, - "views_count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Views Count" - }, - "views_availability": { - "$ref": "#/components/schemas/PostViewsAvailability" - }, - "fetched_at": { - "type": "string", - "format": "date-time", - "title": "Fetched At" - }, - "error_message": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error Message" - } - }, - "type": "object", - "required": [ - "placement_id", - "views_count", - "views_availability", - "fetched_at" - ], - "title": "FetchViewsManuallyOutput" - }, - "GetCreativesAnalyticsOutput": { - "properties": { - "creatives": { - "items": { - "$ref": "#/components/schemas/CreativeAnalyticsOutput" - }, - "type": "array", - "title": "Creatives" - } - }, - "type": "object", - "required": ["creatives"], - "title": "GetCreativesAnalyticsOutput" - }, - "GetCreativesOutput": { - "properties": { - "creatives": { - "items": { - "$ref": "#/components/schemas/CreativeOutput" - }, - "type": "array", - "title": "Creatives" - } - }, - "type": "object", - "required": ["creatives"], - "title": "GetCreativesOutput" - }, - "GetExternalChannelsAnalyticsOutput": { - "properties": { - "external_channels": { - "items": { - "$ref": "#/components/schemas/ExternalChannelAnalyticsOutput" - }, - "type": "array", - "title": "External Channels" - } - }, - "type": "object", - "required": ["external_channels"], - "title": "GetExternalChannelsAnalyticsOutput" - }, - "GetExternalChannelsOutput": { - "properties": { - "external_channels": { - "items": { - "$ref": "#/components/schemas/ExternalChannelOutput" - }, - "type": "array", - "title": "External Channels" - } - }, - "type": "object", - "required": ["external_channels"], - "title": "GetExternalChannelsOutput" - }, - "GetPlacementsAnalyticsOutput": { - "properties": { - "placements": { - "items": { - "$ref": "#/components/schemas/PlacementAnalyticsOutput" - }, - "type": "array", - "title": "Placements" - } - }, - "type": "object", - "required": ["placements"], - "title": "GetPlacementsAnalyticsOutput" - }, - "GetPlacementsOutput": { - "properties": { - "placements": { - "items": { - "$ref": "#/components/schemas/PlacementOutput" - }, - "type": "array", - "title": "Placements" - } - }, - "type": "object", - "required": ["placements"], - "title": "GetPlacementsOutput" - }, - "GetSpendingAnalyticsOutput": { - "properties": { - "total_cost": { - "type": "number", - "title": "Total Cost" - }, - "total_subscriptions": { - "type": "integer", - "title": "Total Subscriptions" - }, - "total_views": { - "type": "integer", - "title": "Total Views" - }, - "avg_cpf": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Avg Cpf" - }, - "avg_cpm": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Avg Cpm" - }, - "chart_data": { - "items": { - "$ref": "#/components/schemas/SpendingDataPoint" - }, - "type": "array", - "title": "Chart Data" - } - }, - "type": "object", - "required": [ - "total_cost", - "total_subscriptions", - "total_views", - "avg_cpf", - "avg_cpm", - "chart_data" - ], - "title": "GetSpendingAnalyticsOutput" - }, - "GetUserTargetChansOutput": { - "properties": { - "target_channels": { - "items": { - "$ref": "#/components/schemas/ConnectTargetChanOutput" - }, - "type": "array", - "title": "Target Channels" - } - }, - "type": "object", - "required": ["target_channels"], - "title": "GetUserTargetChansOutput" - }, - "GetViewsHistoryOutput": { - "properties": { - "histories": { - "items": { - "$ref": "#/components/schemas/PlacementViewsHistoryOutput" - }, - "type": "array", - "title": "Histories" - } - }, - "type": "object", - "required": ["histories"], - "title": "GetViewsHistoryOutput", - "description": "История просмотров." - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "InviteLinkType": { - "type": "string", - "enum": ["public", "approval"], - "title": "InviteLinkType" - }, - "PlacementAnalyticsOutput": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "target_channel_id": { - "type": "string", - "format": "uuid", - "title": "Target Channel Id" - }, - "target_channel_title": { - "type": "string", - "title": "Target Channel Title" - }, - "external_channel_id": { - "type": "string", - "format": "uuid", - "title": "External Channel Id" - }, - "external_channel_title": { - "type": "string", - "title": "External Channel Title" - }, - "creative_id": { - "type": "string", - "format": "uuid", - "title": "Creative Id" - }, - "creative_name": { - "type": "string", - "title": "Creative Name" - }, - "placement_date": { - "type": "string", - "format": "date-time", - "title": "Placement Date" - }, - "cost": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cost" - }, - "subscriptions_count": { - "type": "integer", - "title": "Subscriptions Count" - }, - "views_count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Views Count" - }, - "cpf": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cpf" - }, - "cpm": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cpm" - } - }, - "type": "object", - "required": [ - "id", - "target_channel_id", - "target_channel_title", - "external_channel_id", - "external_channel_title", - "creative_id", - "creative_name", - "placement_date", - "cost", - "subscriptions_count", - "views_count", - "cpf", - "cpm" - ], - "title": "PlacementAnalyticsOutput" - }, - "PlacementOutput": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "target_channel_id": { - "type": "string", - "format": "uuid", - "title": "Target Channel Id" - }, - "target_channel_title": { - "type": "string", - "title": "Target Channel Title" - }, - "external_channel_id": { - "type": "string", - "format": "uuid", - "title": "External Channel Id" - }, - "external_channel_title": { - "type": "string", - "title": "External Channel Title" - }, - "creative_id": { - "type": "string", - "format": "uuid", - "title": "Creative Id" - }, - "creative_name": { - "type": "string", - "title": "Creative Name" - }, - "placement_date": { - "type": "string", - "format": "date-time", - "title": "Placement Date" - }, - "cost": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cost" - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Comment" - }, - "ad_post_url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Ad Post Url" - }, - "invite_link_type": { - "$ref": "#/components/schemas/InviteLinkType" - }, - "invite_link": { - "type": "string", - "title": "Invite Link" - }, - "status": { - "$ref": "#/components/schemas/PlacementStatus" - }, - "subscriptions_count": { - "type": "integer", - "title": "Subscriptions Count" - }, - "views_count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Views Count" - }, - "views_availability": { - "$ref": "#/components/schemas/PostViewsAvailability" - }, - "last_views_fetch_at": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Last Views Fetch At" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - } - }, - "type": "object", - "required": [ - "id", - "target_channel_id", - "target_channel_title", - "external_channel_id", - "external_channel_title", - "creative_id", - "creative_name", - "placement_date", - "cost", - "comment", - "ad_post_url", - "invite_link_type", - "invite_link", - "status", - "subscriptions_count", - "views_count", - "views_availability", - "last_views_fetch_at", - "created_at" - ], - "title": "PlacementOutput" - }, - "PlacementStatus": { - "type": "string", - "enum": ["active", "archived"], - "title": "PlacementStatus" - }, - "PlacementViewsHistoryOutput": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "placement_id": { - "type": "string", - "format": "uuid", - "title": "Placement Id" - }, - "views_count": { - "type": "integer", - "title": "Views Count" - }, - "fetched_at": { - "type": "string", - "format": "date-time", - "title": "Fetched At" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - } - }, - "type": "object", - "required": [ - "id", - "placement_id", - "views_count", - "fetched_at", - "created_at" - ], - "title": "PlacementViewsHistoryOutput", - "description": "История просмотров placement." - }, - "PostViewsAvailability": { - "type": "string", - "enum": ["unknown", "available", "unavailable", "manual"], - "title": "PostViewsAvailability", - "description": "Статус доступности получения просмотров поста." - }, - "SpendingDataPoint": { - "properties": { - "period": { - "type": "string", - "title": "Period" - }, - "cost": { - "type": "number", - "title": "Cost" - }, - "subscriptions": { - "type": "integer", - "title": "Subscriptions" - }, - "views": { - "type": "integer", - "title": "Views" - }, - "cpf": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cpf" - }, - "cpm": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cpm" - } - }, - "type": "object", - "required": ["period", "cost", "subscriptions", "views", "cpf", "cpm"], - "title": "SpendingDataPoint" - }, - "UpdateCreativeInput": { - "properties": { - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name" - }, - "text": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Text" - }, - "status": { - "anyOf": [ - { - "$ref": "#/components/schemas/CreativeStatus" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "title": "UpdateCreativeInput" - }, - "UpdateExternalChannelInput": { - "properties": { - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Title" - }, - "username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Username" - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description" - }, - "subscribers_count": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Subscribers Count" - } - }, - "type": "object", - "title": "UpdateExternalChannelInput" - }, - "UpdateExternalChannelLinksInput": { - "properties": { - "add_target_channel_ids": { - "items": { - "type": "string", - "format": "uuid" - }, - "type": "array", - "title": "Add Target Channel Ids", - "default": [] - }, - "remove_target_channel_ids": { - "items": { - "type": "string", - "format": "uuid" - }, - "type": "array", - "title": "Remove Target Channel Ids", - "default": [] - } - }, - "type": "object", - "title": "UpdateExternalChannelLinksInput" - }, - "UpdatePlacementInput": { - "properties": { - "placement_date": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Placement Date" - }, - "cost": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Cost" - }, - "comment": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Comment" - }, - "status": { - "anyOf": [ - { - "$ref": "#/components/schemas/PlacementStatus" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "title": "UpdatePlacementInput" - }, - "UserOutput": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "telegram_id": { - "type": "integer", - "title": "Telegram Id" - }, - "username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Username" - } - }, - "type": "object", - "required": ["id", "telegram_id", "username"], - "title": "UserOutput" - }, - "ValidateLoginTokenOutput": { - "properties": { - "access_token": { - "type": "string", - "title": "Access Token" - } - }, - "type": "object", - "required": ["access_token"], - "title": "ValidateLoginTokenOutput" - }, - "ValidationError": { - "properties": { - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "type": "array", - "title": "Location" - }, - "msg": { - "type": "string", - "title": "Message" - }, - "type": { - "type": "string", - "title": "Error Type" - } - }, - "type": "object", - "required": ["loc", "msg", "type"], - "title": "ValidationError" - } - }, - "securitySchemes": { - "HTTPBearer": { - "type": "http", - "scheme": "bearer" - } - } - } -} diff --git a/app/(dashboard)/analytics/costs/page.tsx b/app/(dashboard)/analytics/costs/page.tsx deleted file mode 100644 index 91bd61c..0000000 --- a/app/(dashboard)/analytics/costs/page.tsx +++ /dev/null @@ -1,286 +0,0 @@ -"use client"; - -// ============================================================================ -// Analytics Costs Page -// ============================================================================ - -import React, { useEffect, useState } from "react"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { analyticsApi } from "@/lib/api"; -import { formatCurrency, formatNumber } from "@/lib/utils/format"; -import { Loader2, AlertCircle, DollarSign } from "lucide-react"; -import type { SpendingAnalytics, DateGrouping } from "@/lib/types/api"; -import { useTargetChannel } from "@/components/providers/target-channel-provider"; -import { - LineChart, - Line, - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from "recharts"; - -export default function AnalyticsCostsPage() { - const { selectedChannel } = useTargetChannel(); - const [report, setReport] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [grouping, setGrouping] = useState("week"); - - useEffect(() => { - if (selectedChannel) { - loadData(); - } else { - setLoading(false); - } - }, [grouping, selectedChannel]); - - const loadData = async () => { - if (!selectedChannel) return; - - try { - setLoading(true); - const reportData = await analyticsApi.spending({ - grouping, - target_channel_id: selectedChannel.id, - }); - setReport(reportData); - } catch (err: any) { - setError(err?.error?.message || "Ошибка загрузки аналитики"); - } finally { - setLoading(false); - } - }; - - return ( - <> - -
-
-
-

- Затраты на рекламу -

-

- Динамика расходов по периодам -

-
-
- - {error && ( - - - {error} - - )} - - - - Фильтры - - -
- - -
-
-
- - {loading ? ( - - - - - - ) : report ? ( - <> -
- - - - Всего затрат - - - - -
- {formatCurrency(report.total_cost)} -
-

- За выбранный период -

-
-
- - - - - Подписчики - - - -
- {formatNumber(report.total_subscriptions)} -
-

- Всего привлечено -

-
-
- - - - - Просмотры - - - -
- {formatNumber(report.total_views)} -
-

- Всего просмотров -

-
-
- - - - - Средний CPF - - - -
- {report.avg_cpf ? formatCurrency(report.avg_cpf) : "—"} -
-

- В среднем за период -

-
-
-
- - - - График затрат - Динамика расходов на рекламу - - - - - - - `₽${formatNumber(value)}`} - /> - [ - `₽${formatNumber(value)}`, - "Затраты", - ]} - /> - - - - - - - - - - Детализация по периодам - Затраты и количество закупов - - -
- {report.chart_data.map((period) => ( -
-
-

{period.period}

-

- {formatNumber(period.subscriptions)} подписок • {formatNumber(period.views)} просмотров -

-
-
-

- {formatCurrency(period.cost)} -

-

- {period.cpf ? `CPF: ${formatCurrency(period.cpf)}` : "—"} -

-
-
- ))} -
-
-
- - ) : null} -
- - ); -} diff --git a/app/(dashboard)/analytics/creatives/page.tsx b/app/(dashboard)/analytics/creatives/page.tsx deleted file mode 100644 index 0bb4496..0000000 --- a/app/(dashboard)/analytics/creatives/page.tsx +++ /dev/null @@ -1,606 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { Button } from "@/components/ui/button"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Label } from "@/components/ui/label"; -import { Calendar } from "@/components/ui/calendar"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { cn } from "@/lib/utils"; -import { format } from "date-fns"; -import { ru } from "date-fns/locale"; -import { channelsApi, analyticsApi } from "@/lib/api"; -import { formatCurrency, formatNumber } from "@/lib/utils/format"; -import { - Loader2, - AlertCircle, - CalendarIcon, - ArrowUpDown, - ArrowUp, - ArrowDown, - Download, -} from "lucide-react"; -import type { TargetChannel, CreativeAnalytics } from "@/lib/types/api"; -import * as XLSX from "xlsx"; - -type SortField = - | "name" - | "placements_count" - | "total_cost" - | "total_subscriptions" - | "total_views" - | "avg_cpf" - | "avg_cpm" - | null; -type SortDirection = "asc" | "desc" | null; - -export default function CreativesAnalyticsPage() { - const [channels, setChannels] = useState([]); - const [selectedChannelId, setSelectedChannelId] = useState(""); - const [dateFrom, setDateFrom] = useState(); - const [dateTo, setDateTo] = useState(); - const [creatives, setCreatives] = useState([]); - const [filteredCreatives, setFilteredCreatives] = useState< - CreativeAnalytics[] - >([]); - const [loading, setLoading] = useState(true); - const [loadingData, setLoadingData] = useState(false); - const [error, setError] = useState(null); - const [sortField, setSortField] = useState(null); - const [sortDirection, setSortDirection] = useState(null); - - useEffect(() => { - loadChannels(); - }, []); - - const loadChannels = async () => { - try { - setLoading(true); - const response = await channelsApi.list(); - const activeChannels = response.target_channels.filter((c) => c.is_active); - setChannels(activeChannels); - if (activeChannels.length > 0) { - setSelectedChannelId(activeChannels[0].id); - } - } catch (err: any) { - setError(err?.error?.message || "Ошибка загрузки каналов"); - } finally { - setLoading(false); - } - }; - - const loadCreativesData = async () => { - if (!selectedChannelId) return; - - try { - setLoadingData(true); - const response = await analyticsApi.creatives({ - target_channel_id: selectedChannelId, - }); - setCreatives(response.creatives); - setFilteredCreatives(response.creatives); - } catch (err: any) { - alert(err?.error?.message || "Ошибка загрузки данных"); - } finally { - setLoadingData(false); - } - }; - - // Сортировка - const handleSort = (field: SortField) => { - if (sortField === field) { - if (sortDirection === "asc") { - setSortDirection("desc"); - } else if (sortDirection === "desc") { - setSortField(null); - setSortDirection(null); - } - } else { - setSortField(field); - setSortDirection("asc"); - } - }; - - const getSortIcon = (field: SortField) => { - if (sortField !== field) { - return ; - } - if (sortDirection === "asc") { - return ; - } - return ; - }; - - useEffect(() => { - if (!sortField || !sortDirection) { - setFilteredCreatives([...creatives]); - return; - } - - const sorted = [...creatives].sort((a, b) => { - let aValue: string | number | null = 0; - let bValue: string | number | null = 0; - - switch (sortField) { - case "name": - aValue = a.name; - bValue = b.name; - break; - case "placements_count": - aValue = a.placements_count; - bValue = b.placements_count; - break; - case "total_cost": - aValue = a.total_cost; - bValue = b.total_cost; - break; - case "total_subscriptions": - aValue = a.total_subscriptions; - bValue = b.total_subscriptions; - break; - case "total_views": - aValue = a.total_views; - bValue = b.total_views; - break; - case "avg_cpf": - aValue = a.avg_cpf || 0; - bValue = b.avg_cpf || 0; - break; - case "avg_cpm": - aValue = a.avg_cpm || 0; - bValue = b.avg_cpm || 0; - break; - } - - if (typeof aValue === "string" && typeof bValue === "string") { - return sortDirection === "asc" - ? aValue.localeCompare(bValue) - : bValue.localeCompare(aValue); - } - - if (typeof aValue === "number" && typeof bValue === "number") { - return sortDirection === "asc" ? aValue - bValue : bValue - aValue; - } - - return 0; - }); - - setFilteredCreatives(sorted); - }, [sortField, sortDirection, creatives]); - - // Экспорт - const exportToExcel = () => { - const data = filteredCreatives.map((creative) => ({ - Название: creative.name, - Размещений: creative.placements_count, - "Общая стоимость": creative.total_cost, - Подписки: creative.total_subscriptions, - Просмотры: creative.total_views, - "Средний CPF": creative.avg_cpf || 0, - "Средний CPM": creative.avg_cpm || 0, - })); - - const ws = XLSX.utils.json_to_sheet(data); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, "Креативы"); - - const colWidths = [ - { wch: 30 }, - { wch: 12 }, - { wch: 15 }, - { wch: 12 }, - { wch: 12 }, - { wch: 12 }, - { wch: 12 }, - ]; - ws["!cols"] = colWidths; - - XLSX.writeFile( - wb, - `Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.xlsx` - ); - }; - - const exportToCSV = () => { - const data = filteredCreatives.map((creative) => ({ - Название: creative.name, - Размещений: creative.placements_count, - "Общая стоимость": creative.total_cost, - Подписки: creative.total_subscriptions, - Просмотры: creative.total_views, - "Средний CPF": creative.avg_cpf || 0, - "Средний CPM": creative.avg_cpm || 0, - })); - - const headers = Object.keys(data[0]); - const csvContent = [ - headers.join(","), - ...data.map((row) => - headers - .map((header) => { - const value = row[header as keyof typeof row]; - return value !== null && value !== undefined ? value : ""; - }) - .join(",") - ), - ].join("\n"); - - const blob = new Blob(["\ufeff" + csvContent], { - type: "text/csv;charset=utf-8;", - }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = `Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.csv`; - link.click(); - URL.revokeObjectURL(link.href); - }; - - const exportToTXT = () => { - const data = filteredCreatives.map((creative) => ({ - Название: creative.name, - Размещений: creative.placements_count, - "Общая стоимость": creative.total_cost, - Подписки: creative.total_subscriptions, - Просмотры: creative.total_views, - "Средний CPF": creative.avg_cpf || 0, - "Средний CPM": creative.avg_cpm || 0, - })); - - const headers = Object.keys(data[0]); - const columnWidths = headers.map((header) => { - const maxLength = Math.max( - header.length, - ...data.map((row) => - String(row[header as keyof typeof row] || "").length - ) - ); - return Math.min(maxLength + 2, 30); - }); - - const txtContent = [ - headers.map((header, i) => header.padEnd(columnWidths[i])).join(" | "), - columnWidths.map((width) => "-".repeat(width)).join("-+-"), - ...data.map((row) => - headers - .map((header, i) => - String(row[header as keyof typeof row] || "").padEnd(columnWidths[i]) - ) - .join(" | ") - ), - ].join("\n"); - - const blob = new Blob([txtContent], { type: "text/plain;charset=utf-8" }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = `Креативы_Аналитика_${new Date().toISOString().split("T")[0]}.txt`; - link.click(); - URL.revokeObjectURL(link.href); - }; - - if (loading) { - return ( - <> - -
- -
- - ); - } - - return ( - <> - -
-
-
-

- Аналитика креативов -

-

- Производительность рекламных креативов -

-
-
- - {error && ( - - - {error} - - )} - - {channels.length === 0 ? ( - - - - Нет активных целевых каналов. Добавьте канал для начала работы с - аналитикой. - - - ) : ( - <> - {/* Фильтры */} - - - Фильтры - - Настройте параметры для отображения данных - - - -
-
- - -
- -
- - - - - - - - - -
- -
- - - - - - - - - -
-
- - -
-
- - {/* Таблица */} - {loadingData ? ( - - - - - - ) : filteredCreatives.length > 0 ? ( - - -
- Креативы - - Показатели производительности креативов - -
- - - - - - - - Экспорт в Excel (.xlsx) - - - Экспорт в CSV (.csv) - - - Экспорт в TXT (.txt) - - - -
- - - - - handleSort("name")} - > - Название - {getSortIcon("name")} - - handleSort("placements_count")} - > - Размещений - {getSortIcon("placements_count")} - - handleSort("total_cost")} - > - Общая стоимость - {getSortIcon("total_cost")} - - handleSort("total_subscriptions")} - > - Подписки - {getSortIcon("total_subscriptions")} - - handleSort("total_views")} - > - Просмотры - {getSortIcon("total_views")} - - handleSort("avg_cpf")} - > - Средний CPF - {getSortIcon("avg_cpf")} - - handleSort("avg_cpm")} - > - Средний CPM - {getSortIcon("avg_cpm")} - - - - - {filteredCreatives.map((creative) => ( - - - {creative.name} - - - {formatNumber(creative.placements_count)} - - - {formatCurrency(creative.total_cost)} - - - {formatNumber(creative.total_subscriptions)} - - - {formatNumber(creative.total_views)} - - - {creative.avg_cpf !== null - ? formatCurrency(creative.avg_cpf) - : "—"} - - - {creative.avg_cpm !== null - ? formatCurrency(creative.avg_cpm) - : "—"} - - - ))} - -
-
-
- ) : ( - creatives.length === 0 && - !loadingData && ( - - - - Нет данных по креативам. Примените фильтры для отображения - данных. - - - ) - )} - - )} -
- - ); -} - diff --git a/app/(dashboard)/analytics/page.tsx b/app/(dashboard)/analytics/page.tsx deleted file mode 100644 index 1fd1d5a..0000000 --- a/app/(dashboard)/analytics/page.tsx +++ /dev/null @@ -1,242 +0,0 @@ -"use client"; - -// ============================================================================ -// Analytics Overview Page -// ============================================================================ - -import React, { useEffect, useState } from "react"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { analyticsApi } from "@/lib/api"; -import { - formatNumber, - formatMetric, - formatCurrency, - formatPercent, -} from "@/lib/utils/format"; -import { - BarChart3, - Loader2, - AlertCircle, - TrendingUp, - TrendingDown, - Users, - ShoppingCart, - DollarSign, -} from "lucide-react"; -import type { SpendingAnalytics } from "@/lib/types/api"; -import { useTargetChannel } from "@/components/providers/target-channel-provider"; -import { - LineChart, - Line, - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from "recharts"; - -export default function AnalyticsPage() { - const { selectedChannel } = useTargetChannel(); - const [spending, setSpending] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - if (selectedChannel) { - loadData(); - } else { - setLoading(false); - } - }, [selectedChannel]); - - const loadData = async () => { - if (!selectedChannel) return; - - try { - setLoading(true); - const data = await analyticsApi.spending({ - grouping: "week", - target_channel_id: selectedChannel.id, - }); - setSpending(data); - } catch (err: any) { - setError(err?.error?.message || "Ошибка загрузки аналитики"); - } finally { - setLoading(false); - } - }; - - if (loading) { - return ( - <> - -
- -
- - ); - } - - if (error || !spending) { - return ( - <> - -
- - - - {error || "Не удалось загрузить аналитику"} - - -
- - ); - } - - return ( - <> - -
-
-

Аналитика

-

- Сводная статистика за последний месяц -

-
- -
- - - - Всего затрат - - - - -
- {formatCurrency(spending.total_cost)} -
-

- За выбранный период -

-
-
- - - - - Всего подписчиков - - - - -
- {formatNumber(spending.total_subscriptions)} -
-

- Всего привлечено -

-
-
- - - - Средний CPF - - - -
- {spending.avg_cpf ? `₽${formatMetric(spending.avg_cpf)}` : "—"} -
-

- Стоимость подписки -

-
-
- - - - Средний CPM - - - -
- {spending.avg_cpm ? `₽${formatMetric(spending.avg_cpm)}` : "—"} -
-

- За 1000 просмотров -

-
-
-
- - - - Динамика затрат - Расходы по периодам - - - - - - - `₽${formatNumber(value)}`} - /> - - - - - - - -
- - ); -} diff --git a/app/(dashboard)/analytics/target-channels/page.tsx b/app/(dashboard)/analytics/target-channels/page.tsx deleted file mode 100644 index c8db270..0000000 --- a/app/(dashboard)/analytics/target-channels/page.tsx +++ /dev/null @@ -1,476 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { Button } from "@/components/ui/button"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { ChartConfigPanel } from "@/components/analytics/chart-config-panel"; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragEndEvent, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { channelsApi, analyticsApi } from "@/lib/api"; -import { formatCurrency, formatNumber } from "@/lib/utils/format"; -import { Plus, Loader2, AlertCircle, TrendingUp } from "lucide-react"; -import type { - TargetChannel, - SpendingAnalytics, - AnalyticsMetric, - DateGrouping, - SpendingDataPoint, -} from "@/lib/types/api"; -import { format } from "date-fns"; - -interface ChartData { - id: string; - config: { - targetChannelIds: string[]; - metrics: AnalyticsMetric[]; - dateFrom: Date | null; - dateTo: Date | null; - grouping: DateGrouping; - }; - data: SpendingAnalytics | null; - loading: boolean; - width: "full" | "half"; -} - -// Компонент для sortable item -interface SortableChartItemProps { - chart: ChartData; - index: number; - channels: TargetChannel[]; - chartsLength: number; - onBuild: (chartId: string, config: ChartData["config"]) => void; - onRemove: (chartId: string) => void; - onWidthChange: (chartId: string, width: "full" | "half") => void; -} - -const SortableChartItem: React.FC = ({ - chart, - index, - channels, - chartsLength, - onBuild, - onRemove, - onWidthChange, -}) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: chart.id }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; - - return ( -
- onBuild(chart.id, config)} - onRemove={chartsLength > 1 ? () => onRemove(chart.id) : undefined} - onWidthChange={(width) => onWidthChange(chart.id, width)} - showRemoveButton={chartsLength > 1} - chartNumber={index + 1} - dragHandleProps={{ ...attributes, ...listeners }} - /> -
- ); -}; - -export default function TargetChannelsAnalyticsPage() { - const [channels, setChannels] = useState([]); - const [charts, setCharts] = useState([ - { - id: "chart-1", - config: { - targetChannelIds: [], - metrics: [], - dateFrom: null, - dateTo: null, - grouping: "day", - }, - data: null, - loading: false, - width: "full", - }, - ]); - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - loadChannels(); - }, []); - - const loadChannels = async () => { - try { - setLoading(true); - const response = await channelsApi.list(); - const activeChannels = response.target_channels.filter((c) => c.is_active); - setChannels(activeChannels); - } catch (err: any) { - setError(err?.error?.message || "Ошибка загрузки каналов"); - } finally { - setLoading(false); - } - }; - - const handleBuildChart = async ( - chartId: string, - config: ChartData["config"] - ) => { - const chartIndex = charts.findIndex((c) => c.id === chartId); - if (chartIndex === -1) return; - - // Обновляем состояние графика - const updatedCharts = [...charts]; - updatedCharts[chartIndex].loading = true; - updatedCharts[chartIndex].config = config; - setCharts(updatedCharts); - - try { - // Загружаем данные для каждого канала - const promises = config.targetChannelIds.map((channelId) => - analyticsApi.spending({ - target_channel_id: channelId, - date_from: config.dateFrom - ? format(config.dateFrom, "yyyy-MM-dd'T'HH:mm:ss'Z'") - : undefined, - date_to: config.dateTo - ? format(config.dateTo, "yyyy-MM-dd'T'HH:mm:ss'Z'") - : undefined, - grouping: config.grouping, - }) - ); - - const results = await Promise.all(promises); - - // Объединяем данные (если несколько каналов) - const combinedData: SpendingAnalytics = results.reduce( - (acc, result) => { - acc.total_cost += result.total_cost; - acc.total_subscriptions += result.total_subscriptions; - acc.total_views += result.total_views; - - // Объединяем chart_data по периодам - result.chart_data.forEach((dataPoint) => { - const existing = acc.chart_data.find( - (d) => d.period === dataPoint.period - ); - if (existing) { - existing.cost += dataPoint.cost; - existing.subscriptions += dataPoint.subscriptions; - existing.views += dataPoint.views; - // CPF и CPM пересчитаем после - } else { - acc.chart_data.push({ ...dataPoint }); - } - }); - - return acc; - }, - { - total_cost: 0, - total_subscriptions: 0, - total_views: 0, - avg_cpf: null, - avg_cpm: null, - chart_data: [] as SpendingDataPoint[], - } - ); - - // Пересчитываем CPF и CPM для каждого периода - combinedData.chart_data.forEach((dataPoint) => { - dataPoint.cpf = - dataPoint.subscriptions > 0 - ? dataPoint.cost / dataPoint.subscriptions - : null; - dataPoint.cpm = - dataPoint.views > 0 ? (dataPoint.cost / dataPoint.views) * 1000 : null; - }); - - // Пересчитываем средние значения - combinedData.avg_cpf = - combinedData.total_subscriptions > 0 - ? combinedData.total_cost / combinedData.total_subscriptions - : null; - combinedData.avg_cpm = - combinedData.total_views > 0 - ? (combinedData.total_cost / combinedData.total_views) * 1000 - : null; - - // Сортируем по периодам - combinedData.chart_data.sort((a, b) => - a.period.localeCompare(b.period) - ); - - // Обновляем данные графика - const finalCharts = [...updatedCharts]; - finalCharts[chartIndex].data = combinedData; - finalCharts[chartIndex].loading = false; - setCharts(finalCharts); - } catch (err: any) { - alert(err?.error?.message || "Ошибка загрузки данных"); - const finalCharts = [...updatedCharts]; - finalCharts[chartIndex].loading = false; - setCharts(finalCharts); - } - }; - - const handleAddChart = () => { - setCharts([ - ...charts, - { - id: `chart-${Date.now()}`, - config: { - targetChannelIds: [], - metrics: [], - dateFrom: null, - dateTo: null, - grouping: "day", - }, - data: null, - loading: false, - width: "full", - }, - ]); - }; - - const handleRemoveChart = (chartId: string) => { - if (charts.length === 1) { - alert("Должен остаться хотя бы один график"); - return; - } - setCharts(charts.filter((c) => c.id !== chartId)); - }; - - const handleWidthChange = (chartId: string, width: "full" | "half") => { - setCharts( - charts.map((chart) => - chart.id === chartId ? { ...chart, width } : chart - ) - ); - }; - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - setCharts((items) => { - const oldIndex = items.findIndex((item) => item.id === active.id); - const newIndex = items.findIndex((item) => item.id === over.id); - - return arrayMove(items, oldIndex, newIndex); - }); - } - }; - - // Собираем все данные для таблицы - const getAllTableData = (): SpendingDataPoint[] => { - const allData: SpendingDataPoint[] = []; - const periodMap = new Map(); - - charts.forEach((chart) => { - if (!chart.data) return; - chart.data.chart_data.forEach((dataPoint) => { - const existing = periodMap.get(dataPoint.period); - if (existing) { - existing.cost += dataPoint.cost; - existing.subscriptions += dataPoint.subscriptions; - existing.views += dataPoint.views; - // Пересчитываем CPF и CPM - existing.cpf = - existing.subscriptions > 0 - ? existing.cost / existing.subscriptions - : null; - existing.cpm = - existing.views > 0 ? (existing.cost / existing.views) * 1000 : null; - } else { - periodMap.set(dataPoint.period, { ...dataPoint }); - } - }); - }); - - return Array.from(periodMap.values()).sort((a, b) => - a.period.localeCompare(b.period) - ); - }; - - const tableData = getAllTableData(); - - if (loading) { - return ( - <> - -
- -
- - ); - } - - return ( - <> - -
-
-
-

- Аналитика целевых каналов -

-

- Создайте графики для анализа показателей -

-
- -
- - {error && ( - - - {error} - - )} - - {channels.length === 0 ? ( - - - - Нет активных целевых каналов. Добавьте канал для начала работы с - аналитикой. - - - ) : ( - <> - {/* Графики */} - - c.id)} - strategy={verticalListSortingStrategy} - > -
- {charts.map((chart, index) => ( - - ))} -
-
-
- - {/* Таблица */} - {tableData.length > 0 && ( - - - Табличное представление - - - - - - Период - Расходы - Подписки - Просмотры - CPF - CPM - - - - {tableData.map((row) => ( - - - {row.period} - - - {formatCurrency(row.cost)} - - - {formatNumber(row.subscriptions)} - - - {formatNumber(row.views)} - - - {row.cpf !== null ? formatCurrency(row.cpf) : "—"} - - - {row.cpm !== null ? formatCurrency(row.cpm) : "—"} - - - ))} - -
-
-
- )} - - )} -
- - ); -} - diff --git a/app/(dashboard)/channels/[id]/page.tsx b/app/(dashboard)/channels/[id]/page.tsx deleted file mode 100644 index 750e804..0000000 --- a/app/(dashboard)/channels/[id]/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -// ============================================================================ -// Target Channel Detail Page -// ============================================================================ - -import React, { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { use } from "react"; - -interface PageProps { - params: Promise<{ id: string }>; -} - -export default function ChannelDetailPage({ params }: PageProps) { - const router = useRouter(); - const resolvedParams = use(params); - - useEffect(() => { - // Redirect back to channels list - // Detail page functionality not yet implemented in backend API - router.push("/channels"); - }, [router, resolvedParams.id]); - - return null; -} diff --git a/app/(dashboard)/channels/page.tsx b/app/(dashboard)/channels/page.tsx deleted file mode 100644 index cd6b618..0000000 --- a/app/(dashboard)/channels/page.tsx +++ /dev/null @@ -1,249 +0,0 @@ -"use client"; - -// ============================================================================ -// Target Channels List Page -// ============================================================================ - -import React, { useEffect, useState } from "react"; -import Link from "next/link"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { channelsApi } from "@/lib/api"; -import { formatNumber, formatMetric, formatUsername } from "@/lib/utils/format"; -import { - Target, - Users, - TrendingUp, - ExternalLink as ExternalLinkIcon, - Loader2, - AlertCircle, - Plus, - Eye, -} from "lucide-react"; -import type { TargetChannel } from "@/lib/types/api"; -import { Alert, AlertDescription } from "@/components/ui/alert"; - -export default function ChannelsPage() { - const [channels, setChannels] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState<"all" | "active" | "inactive">( - "all" - ); - - useEffect(() => { - const loadChannels = async () => { - try { - setLoading(true); - const response = await channelsApi.list(); - setChannels(response.target_channels); - } catch (err: any) { - setError( - err?.error?.message || err?.detail || "Ошибка загрузки каналов" - ); - } finally { - setLoading(false); - } - }; - - loadChannels(); - }, []); - - const filteredChannels = channels.filter((channel) => { - if (activeTab === "active") return channel.is_active; - if (activeTab === "inactive") return !channel.is_active; - return true; - }); - - const handleDisconnect = async (id: string, title: string) => { - if (!confirm(`Вы уверены, что хотите отключить канал "${title}"?`)) { - return; - } - - try { - await channelsApi.delete(id); - setChannels((prev) => prev.filter((ch) => ch.id !== id)); - } catch (err: any) { - alert(err?.error?.message || err?.detail || "Ошибка отключения канала"); - } - }; - - return ( - <> - -
-
-
-

- Целевые каналы -

-

- Каналы, в которые вы приводите подписчиков -

-
-
- - - -
-
- Подключение нового канала - - Добавьте бота в свой Telegram-канал с правами администратора - -
- -
-
- -
-
    -
  1. Откройте свой Telegram-канал
  2. -
  3. Добавьте @tgex_bot в администраторы канала
  4. -
  5. - Предоставьте боту права:{" "} - создавать инвайт-ссылки и{" "} - видеть вступления -
  6. -
  7. Канал автоматически появится в списке ниже
  8. -
-
-
-
- - {error && ( - - - {error} - - )} - - setActiveTab(v as any)} - className="w-full" - > - - Все ({channels.length}) - - Активные ({channels.filter((c) => c.is_active).length}) - - - Отключенные ({channels.filter((c) => !c.is_active).length}) - - - - - {loading ? ( -
- -
- ) : filteredChannels.length === 0 ? ( - - - -

- {activeTab === "all" && "Нет подключенных каналов"} - {activeTab === "active" && "Нет активных каналов"} - {activeTab === "inactive" && "Нет отключенных каналов"} -

-

- Добавьте бота в свой канал, чтобы начать работу -

-
-
- ) : ( -
- {filteredChannels.map((channel) => ( - - -
-
- - - {channel.title} - - - {channel.username ? ( - - {formatUsername(channel.username)} - - ) : ( - - Приватный канал - - )} - -
- - {channel.is_active ? "Активен" : "Отключен"} - -
-
- -
- Telegram ID:{" "} - - {channel.telegram_id} - -
- -
- -
-
-
- ))} -
- )} -
-
-
- - ); -} diff --git a/app/(dashboard)/creatives/[id]/page.tsx b/app/(dashboard)/creatives/[id]/page.tsx deleted file mode 100644 index f935388..0000000 --- a/app/(dashboard)/creatives/[id]/page.tsx +++ /dev/null @@ -1,286 +0,0 @@ -"use client"; - -// ============================================================================ -// Creative Detail Page -// ============================================================================ - -import React, { useEffect, useState } from "react"; -import { use } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { creativesApi } from "@/lib/api"; -import { - formatNumber, - formatMetric, - formatCurrency, - formatDate, -} from "@/lib/utils/format"; -import { - Folder, - ArrowLeft, - Loader2, - AlertCircle, - Archive, - Trash2, - BarChart3, - Users, - TrendingUp, -} from "lucide-react"; -import type { Creative } from "@/lib/types/api"; - -interface PageProps { - params: Promise<{ id: string }>; -} - -export default function CreativeDetailPage({ params }: PageProps) { - const resolvedParams = use(params); - const router = useRouter(); - const [creative, setCreative] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const loadCreative = async () => { - try { - setLoading(true); - const data = await creativesApi.get(resolvedParams.id); - setCreative(data); - } catch (err: any) { - setError(err?.error?.message || "Ошибка загрузки креатива"); - } finally { - setLoading(false); - } - }; - - loadCreative(); - }, [resolvedParams.id]); - - const handleArchive = async () => { - if (!creative) return; - - try { - await creativesApi.update(creative.id, { - status: creative.status === "active" ? "archived" : "active", - }); - setCreative({ - ...creative, - status: creative.status === "active" ? "archived" : "active", - }); - } catch (err: any) { - alert(err?.error?.message || "Ошибка обновления креатива"); - } - }; - - const handleDelete = async () => { - if (!creative) return; - if (!confirm(`Удалить креатив "${creative.name}"?`)) return; - - try { - await creativesApi.delete(creative.id); - router.push("/creatives"); - } catch (err: any) { - alert(err?.error?.message || "Ошибка удаления креатива"); - } - }; - - if (loading) { - return ( - <> - -
- -
- - ); - } - - if (error || !creative) { - return ( - <> - -
- - - {error || "Креатив не найден"} - - -
- - ); - } - - const previewText = creative.text.replace( - "{link}", - "https://t.me/+InviteLinkExample" - ); - - return ( - <> - -
-
-
-
- -

- {creative.name} -

- {creative.status === "archived" && ( - Архив - )} -
-
- Целевой канал: {creative.target_channel_title} -
-
-
- - -
-
- -
- - - - Всего закупов - - - - -
- {formatNumber(creative.placements_count)} -
-

- Всего размещений -

-
-
- - - - - Целевой канал - - - - -
- {creative.target_channel_title} -
-

- Канал для рекламы -

-
-
-
- -
- - - Текст креатива - Шаблон с переменной {"{link}"} - - -
-
-                  {creative.text}
-                
-
-
-
- - - - Предпросмотр - - С подставленной пригласительной ссылкой - - - -
-

{previewText}

-
-
-
-
- - - - Информация о размещениях - Статистика использования креатива - - -
-
-

Всего размещений:

-

- {creative.placements_count} -

-
-
-

Создан:

-

{formatDate(creative.created_at)}

-
-
- -
-
-
-
-
- - ); -} diff --git a/app/(dashboard)/creatives/new/page.tsx b/app/(dashboard)/creatives/new/page.tsx deleted file mode 100644 index 3cc6ee0..0000000 --- a/app/(dashboard)/creatives/new/page.tsx +++ /dev/null @@ -1,227 +0,0 @@ -"use client"; - -// ============================================================================ -// Create Creative Page -// ============================================================================ - -import React, { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { creativesApi, channelsApi } from "@/lib/api"; -import { AlertCircle, Loader2, ArrowLeft, Info } from "lucide-react"; -import type { TargetChannel } from "@/lib/types/api"; -import { useTargetChannel } from "@/components/providers/target-channel-provider"; - -export default function CreateCreativePage() { - const router = useRouter(); - const { selectedChannel } = useTargetChannel(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const [formData, setFormData] = useState({ - name: "", - text: "", - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - - // Validation - if (!formData.name.trim()) { - setError("Введите название креатива"); - return; - } - - if (!formData.text.trim()) { - setError("Введите текст креатива"); - return; - } - - if (!formData.text.includes("{link}")) { - setError("Текст должен содержать переменную {link} для вставки ссылки"); - return; - } - - if (!selectedChannel) { - setError("Выберите целевой канал в верхнем меню"); - return; - } - - try { - setIsLoading(true); - await creativesApi.create({ - ...formData, - target_channel_id: selectedChannel.id, - }); - router.push("/creatives"); - } catch (err: any) { - setError(err?.error?.message || "Ошибка создания креатива"); - } finally { - setIsLoading(false); - } - }; - - const previewText = formData.text.replace( - "{link}", - "https://t.me/+InviteLinkExample" - ); - - return ( - <> - -
-
-
-

- Создать креатив -

-

- Новый шаблон рекламного сообщения -

-
- -
- - {error && ( - - - {error} - - )} - -
- - - Основная информация - - Название и целевой канал креатива - - - -
- - - setFormData({ ...formData, name: e.target.value }) - } - placeholder='Например: Креатив "Присоединяйся"' - /> -
- - {selectedChannel && ( - - - - Креатив будет создан для канала: {selectedChannel.title} - - - )} -
-
- - - - Текст сообщения - - Используйте переменную {"{link}"} для вставки ссылки - - - -
- -