Системные настройки
++ Конфигурация внешних сервисов (поиск фото, билинги и т.п.). Видно только админам. +
+diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 0000000..7779f8f
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,158 @@
+# ZeroPost — Roadmap (план фич)
+
+Живой документ. Обновлять по мере выполнения.
+Последнее обновление: 2026-06-01
+
+---
+
+## Контекст / архитектура
+
+- **zeropost.ru** (web) — блог + админка. PM2 `zeropost-web` (pm_id=5, порт 3042), `/var/www/zeropost-web`.
+- **app.zeropost.ru** (tool) — SaaS для управления каналами TG/VK/MAX. PM2 `zeropost-tool` (pm_id=4, порт 3041), `/var/www/zeropost-tool`.
+- **engine** — общий движок API + LLM-pipeline. PM2 `zeropost-engine` (pm_id=3, порт 3030), `/var/www/zeropost-engine`.
+- БД: PostgreSQL, db=`zeropost`.
+- Cron: `*/10 * * * * zeropost-autogen.sh`, `* * * * * zeropost-publish-scheduled.sh` (обе очереди).
+- Internal secret: `zeropost_internal_2026`. TG-прокси: CF Worker в `app_settings.TELEGRAM_API_BASE`.
+
+---
+
+## ✅ DONE
+
+### Photo-search (Yandex)
+- `user_posts.image_credit` (JSONB), `users.is_admin` (BOOLEAN).
+- Engine: proxy `/api/photo-search/*`. Tool: `/system` (admin-only), `PhotoSearchModal`.
+- Web: категория `photo_search` скрыта из blog-админки.
+
+### Auto-publish статей блога → каналы
+- `channels.auto_publish_{enabled,categories,delay_min,template,with_cover,button_text,image_source}`.
+- Engine: `articleAutoPublish.js`, `scheduledPostsRunner.js`, `/api/scheduled-posts/*`.
+- Хук в `PATCH/POST articles` при `draft→published`. Typeahead `/api/articles/admin/search`.
+- Cron обновлён — дёргает обе очереди (`user_posts` + `scheduled_posts`).
+- Web: `ArticlePicker`, `AutoPublishTab`, 4 вкладки в `ChannelEditor`.
+
+### Журнальная главная страница zeropost.ru
+- Engine: `/api/articles/home` (hero / byCategory / popular / recent).
+- Web: `CategoryRow`, `PopularBlock`, `RecentBlock` (группировка Сегодня/Вчера/Эта неделя).
+- `ArticleCard` с 3 размерами (hero/regular/compact) + цветной category badge без дублей.
+- Header упрощён (2 пункта desktop + расширенное мобильное меню).
+
+### Персонаж Зеро
+- 15 поз: `avatar, coding, tools, lock, gears, eureka, confused, facepalm, victory, tired, reading, magnifier, chart, meditate, present` → `/var/www/zeropost-uploads/zero-{name}.webp`.
+- `src/services/zeroCharacter.js` — выбор позы по тексту/категории статьи (эмоциональные триггеры + категорийные).
+- `channels.auto_publish_image_source = 'alternating'` — чётные посты = AI-обложка, нечётные = Зеро.
+- `scheduledPostsRunner` — multipart upload (не URL) чтобы CF Worker не падал.
+
+### Промпт Зеро
+- `src/services/promptBuilder.js` — секция `author_persona` в `buildArticleSystemPrompt`.
+- `src/services/articles.js` — `blogChannel.author_persona` (голос, правила, запрещённые фразы).
+- Статьи теперь пишутся от первого лица с личными историями.
+
+### TG-канал @zeropostru — запуск
+- Welcome-пост от Зеро с аватаром + кнопкой (msg_id=13, закреплён).
+- 4 статьи опубликованы (по одной на каждую категорию), режим alternating.
+
+### zeropost.ru — страница Зеро + TG-продвижение
+- `/about/zero` — страница с описанием персонажа + галерея 8 поз.
+- Footer — TG-банер с аватаром Зеро на каждой странице.
+- Конец каждой статьи — блок «Понравилась заметка? → В канал».
+- `/about` — ссылка «Познакомьтесь с Зеро».
+
+---
+
+## 🚀 ПЛАН: что делать дальше
+
+### СЕЙЧАС (не отложить)
+
+**A. Revoke бота** — токен @zeropostru_bot засветился в этом чате. Зайди в @BotFather → /mybots → выбери бота → API Token → Revoke. Потом обнови в `/admin/channels/1` → Настройки → Bot Token.
+
+**B. Статья на Habr** — главный бесплатный способ получить первые 200-500 живых читателей. Тема: «Я сделал блог, который ведёт ИИ с персонажем-маскотом. Как работает pipeline». Напишу черновик — дай команду.
+
+---
+
+### app.zeropost.ru — приоритетный порядок
+
+#### P1. Календарь публикаций (1–2 дня)
+Самый частый запрос у SMM-инструментов. Без него непонятно «что когда выходит».
+- Страница `/calendar` — визуальная сетка (неделя + месяц).
+- Данные: `user_posts.scheduled_at` + `publish_slots` канала.
+- Карточки по цветам: draft=серый, scheduled=синий, published=зелёный, failed=красный.
+- Drag & drop между датами → PATCH `scheduled_at`.
+- Фильтр по каналу.
+
+#### P2. Превью под платформу (0.5–1 день)
+Сейчас пишешь пост и не знаешь как он будет выглядеть.
+- Компонент `PostPreview` в `ChannelView` справа от textarea.
+- Рендерит Markdown как TG: **жирный**, _курсив_, обрезка caption 1024, превью ссылки.
+- Переключатель TG / VK / MAX — разные ограничения форматирования.
+
+#### P3. Шаблоны постов (0.5 дня)
+Ускоряет создание поста в 3 раза.
+- 7 кнопок-пресетов: Новость, Анонс, Кейс, Лонгрид, Подборка, Опрос-разбор, Личное мнение.
+- Каждый — готовая структура поста + hint для AI.
+- В `ChannelView` рядом с кнопкой «Идеи тем».
+
+#### P4. Аналитика постов (2–3 дня)
+Без метрик невозможно понять что заходит.
+- Таблица `post_metrics(user_post_id, captured_at, views, forwards, reactions JSONB)`.
+- Воркер раз в 15 мин: пуллит views через TG embed для постов < 7 дней.
+- В `ChannelView` у каждого поста — строчка «👁 N ↗ N ❤️ N».
+- График: «лучший день/час для публикации» по медиане views.
+
+#### P5. URL → черновик (1–2 дня)
+Killer feature которой нет у конкурентов в таком качестве.
+- Вставил ссылку → AI читает статью/YouTube/TG-пост → пишет пост в стиле канала.
+- `POST /api/generate/from-url`. Для статей: cheerio + og-meta. Для YouTube: yt-dlp транскрипт.
+
+#### P6. Комментарии + AI-ответы (4–7 дней)
+Большая фича, отдельный спринт.
+- TG webhook → unified inbox.
+- Классификатор haiku: вопрос/спам/похвала/троллинг.
+- Предложенный AI-ответ с кнопкой «отправить».
+
+#### P7–P10. Опросы, хештеги, best-time, URL-shortener
+По 0.5–1 дню каждое, делаем после P1–P5.
+
+---
+
+### zeropost.ru — мелкий должок
+
+| Задача | Срочность |
+|---|---|
+| Revoke бота @zeropostru_bot | 🔴 СЕЙЧАС |
+| Статья на Habr про ZeroPost | 🟠 На этой неделе |
+| Кнопка «Бэкфилл статей» в AutoPublishTab | 🟡 Низкая |
+| История публикаций канала | 🟡 Низкая |
+| Балансы внешних сервисов в /system | 🟡 Низкая |
+
+---
+
+### zeropost.ru — рост аудитории (без бюджета)
+
+| Действие | Ожидаемый результат | Когда |
+|---|---|---|
+| Habr: «Как я сделал AI-блог с маскотом» | 200–500 переходов | На этой неделе |
+| vc.ru: то же самое | 100–300 переходов | На этой неделе |
+| 5 комментариев в AI-чатах TG | 20–50 подписчиков | Постоянно |
+| Взаимный пиар с 3 каналами (tgstat.ru) | 50–150 подписчиков | 1–2 недели |
+| Reddit r/artificial | 50–200 переходов | На этой неделе |
+| SEO (органика) | Долгосрочно | Само, 2–3 мес |
+
+---
+
+## Технический долг
+
+- ENGINE_URL default в `lib/engine.js` tool'а = 3040, должен быть 3030.
+- `per_day` в `autogen_settings` не используется в логике (только `run_hour:run_minute`).
+- TOPIC_BANK заканчивается — нужен AI-генератор новых тем.
+- VK: публикация без фото (нужен 2-step `photos.getWallUploadServer`).
+- MAX: заглушка `throw new Error('не реализована')`.
+
+---
+
+## Не делаем
+
+- Холст / визуальный редактор.
+- Stories / Reels.
+- Команда / роли (до 5+ клиентов).
+- White Label.
+- Парсинг конкурентов через MTProto.
diff --git a/app/api/admin/settings/[key]/route.js b/app/api/admin/settings/[key]/route.js
new file mode 100644
index 0000000..ec2a50a
--- /dev/null
+++ b/app/api/admin/settings/[key]/route.js
@@ -0,0 +1,16 @@
+import { NextResponse } from 'next/server';
+import { requireAdmin } from '@/lib/session';
+import { engine } from '@/lib/engine';
+
+export async function PUT(req, { params }) {
+ const admin = await requireAdmin();
+ if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ try {
+ const { key } = await params;
+ const body = await req.json();
+ const row = await engine.updateSetting(key, body?.value);
+ return NextResponse.json(row);
+ } catch (err) {
+ return NextResponse.json({ error: err.message }, { status: err.status || 500 });
+ }
+}
diff --git a/app/api/admin/settings/route.js b/app/api/admin/settings/route.js
new file mode 100644
index 0000000..3f27a61
--- /dev/null
+++ b/app/api/admin/settings/route.js
@@ -0,0 +1,17 @@
+import { NextResponse } from 'next/server';
+import { requireAdmin } from '@/lib/session';
+import { engine } from '@/lib/engine';
+
+// GET /api/admin/settings?category=photo_search
+export async function GET(req) {
+ const admin = await requireAdmin();
+ if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ try {
+ const { searchParams } = new URL(req.url);
+ const category = searchParams.get('category') || undefined;
+ const rows = await engine.listSettings(category);
+ return NextResponse.json(rows);
+ } catch (err) {
+ return NextResponse.json({ error: err.message }, { status: err.status || 500 });
+ }
+}
diff --git a/app/api/auth/login/route.js b/app/api/auth/login/route.js
index fff57fd..b04b286 100644
--- a/app/api/auth/login/route.js
+++ b/app/api/auth/login/route.js
@@ -16,19 +16,23 @@ export async function POST(req) {
}
const hash = await bcrypt.hash(password, 10);
const { rows } = await q(
- `INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name`,
+ `INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name,is_admin`,
[email, hash]
);
const user = rows[0];
const s = await getSession();
s.userId = user.id;
s.email = user.email;
+ s.isAdmin = !!user.is_admin;
await s.save();
return NextResponse.json({ ok: true, user });
}
// login
- const { rows } = await q(`SELECT id,email,password,name FROM users WHERE email=$1`, [email]);
+ const { rows } = await q(
+ `SELECT id,email,password,name,is_admin FROM users WHERE email=$1`,
+ [email]
+ );
if (!rows.length) {
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
}
@@ -41,6 +45,10 @@ export async function POST(req) {
s.userId = user.id;
s.email = user.email;
s.name = user.name;
+ s.isAdmin = !!user.is_admin;
await s.save();
- return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } });
+ return NextResponse.json({
+ ok: true,
+ user: { id: user.id, email: user.email, name: user.name, isAdmin: !!user.is_admin },
+ });
}
diff --git a/app/api/photo-search/by-query/route.js b/app/api/photo-search/by-query/route.js
new file mode 100644
index 0000000..4121c36
--- /dev/null
+++ b/app/api/photo-search/by-query/route.js
@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server';
+import { requireUser } from '@/lib/session';
+import { engine } from '@/lib/engine';
+
+export async function POST(req) {
+ const user = await requireUser();
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ try {
+ const body = await req.json();
+ const data = await engine.photoSearchByQuery(body);
+ return NextResponse.json(data);
+ } catch (err) {
+ return NextResponse.json(
+ { error: err.message, code: err.code },
+ { status: err.status || 500 }
+ );
+ }
+}
diff --git a/app/api/photo-search/profiles/route.js b/app/api/photo-search/profiles/route.js
new file mode 100644
index 0000000..7d6dc4a
--- /dev/null
+++ b/app/api/photo-search/profiles/route.js
@@ -0,0 +1,14 @@
+import { NextResponse } from 'next/server';
+import { requireUser } from '@/lib/session';
+import { engine } from '@/lib/engine';
+
+export async function GET() {
+ const user = await requireUser();
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ try {
+ const data = await engine.photoSearchProfiles();
+ return NextResponse.json(data);
+ } catch (err) {
+ return NextResponse.json({ error: err.message }, { status: err.status || 500 });
+ }
+}
diff --git a/app/api/photo-search/quota/route.js b/app/api/photo-search/quota/route.js
new file mode 100644
index 0000000..e108bcb
--- /dev/null
+++ b/app/api/photo-search/quota/route.js
@@ -0,0 +1,14 @@
+import { NextResponse } from 'next/server';
+import { requireUser } from '@/lib/session';
+import { engine } from '@/lib/engine';
+
+export async function GET() {
+ const user = await requireUser();
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ try {
+ const data = await engine.photoSearchQuota();
+ return NextResponse.json(data);
+ } catch (err) {
+ return NextResponse.json({ error: err.message }, { status: err.status || 500 });
+ }
+}
diff --git a/app/system/page.js b/app/system/page.js
new file mode 100644
index 0000000..194189b
--- /dev/null
+++ b/app/system/page.js
@@ -0,0 +1,27 @@
+import { redirect } from 'next/navigation';
+import { requireUser } from '@/lib/session';
+import Header from '@/components/Header';
+import SystemSettings from '@/components/SystemSettings';
+
+export const dynamic = 'force-dynamic';
+
+export default async function SystemPage() {
+ const user = await requireUser();
+ if (!user) redirect('/login');
+ if (!user.isAdmin) redirect('/');
+
+ return (
+ <>
+
+ Конфигурация внешних сервисов (поиск фото, билинги и т.п.). Видно только админам.
+ Системные настройки
+