const { Pool } = require('pg'); const config = require('../config'); const pool = new Pool(config.db); pool.on('error', (err) => { console.error('[DB] Unexpected error on idle client', err); }); const query = (text, params) => pool.query(text, params); const migrate = async () => { // users — расширили под план/api_key/баланс await query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, password TEXT NOT NULL, name VARCHAR(255), plan VARCHAR(20) DEFAULT 'free', api_key VARCHAR(64) UNIQUE, tokens_used BIGINT DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() ); `); // channels — базовые настройки канала await query(` CREATE TABLE IF NOT EXISTS channels ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, tg_channel_id VARCHAR(255), tg_username VARCHAR(255), bot_token TEXT, niche TEXT, -- узкая тематика audience TEXT, -- описание ЦА goal VARCHAR(50) DEFAULT 'educational', -- educational/news/entertainment/expert/sales language VARCHAR(10) DEFAULT 'ru', region VARCHAR(50) DEFAULT 'ru', -- ru/cis/west is_active BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); `); // channel_style — стилевые настройки (1:1 с channels) await query(` CREATE TABLE IF NOT EXISTS channel_style ( channel_id INTEGER PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, tone VARCHAR(50) DEFAULT 'friendly', -- friendly/serious/ironic/provocative/academic/custom tone_custom TEXT, -- если tone='custom' formality VARCHAR(20) DEFAULT 'informal', -- formal(вы)/informal(ты) humor VARCHAR(20) DEFAULT 'moderate', -- none/dry/moderate/playful post_length VARCHAR(20) DEFAULT 'medium', -- short(<300)/medium(300-800)/long(800-2000) structure VARCHAR(50) DEFAULT 'mixed', -- plain/lists/headers/mixed emoji_level VARCHAR(20) DEFAULT 'moderate', -- none/moderate/active hashtags_mode VARCHAR(20) DEFAULT 'end', -- none/end/inline cta_mode VARCHAR(20) DEFAULT 'sometimes', -- always/sometimes/never example_posts JSONB DEFAULT '[]'::jsonb, -- массив строк-эталонов (few-shot) banned_words JSONB DEFAULT '[]'::jsonb, -- стоп-слова banned_topics JSONB DEFAULT '[]'::jsonb, -- запрещённые темы expertise JSONB DEFAULT '[]'::jsonb, -- темы где автор силён updated_at TIMESTAMPTZ DEFAULT NOW() ); `); // channel_schedule — расписание и рубрики await query(` CREATE TABLE IF NOT EXISTS channel_schedule ( channel_id INTEGER PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE, posts_per_day INTEGER DEFAULT 1, time_slots JSONB DEFAULT '[]'::jsonb, -- ["09:00","18:00"] timezone VARCHAR(50) DEFAULT 'Europe/Moscow', rubrics JSONB DEFAULT '[]'::jsonb, -- [{name,description,days:[1,3,5]}] sources JSONB DEFAULT '[]'::jsonb, -- [{type:'rss',url:'...'},{type:'tg',channel:'@...'}] auto_publish BOOLEAN DEFAULT false, -- авто-постинг без подтверждения updated_at TIMESTAMPTZ DEFAULT NOW() ); `); // generation_jobs — задачи генерации await query(` CREATE TABLE IF NOT EXISTS generation_jobs ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, channel_id INTEGER REFERENCES channels(id) ON DELETE SET NULL, type VARCHAR(50) NOT NULL, -- post/article/topics/rewrite topic TEXT, rubric VARCHAR(255), prompt_debug TEXT, -- финальный промпт для отладки result TEXT, tokens_in INTEGER, tokens_out INTEGER, cost_cents INTEGER DEFAULT 0, status VARCHAR(20) DEFAULT 'pending', error TEXT, metadata JSONB DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); `); // posts — готовые посты (черновики и опубликованные) await query(` CREATE TABLE IF NOT EXISTS posts ( id SERIAL PRIMARY KEY, channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, job_id INTEGER REFERENCES generation_jobs(id) ON DELETE SET NULL, content TEXT NOT NULL, image_url TEXT, status VARCHAR(20) DEFAULT 'draft', -- draft/scheduled/published/failed scheduled_at TIMESTAMPTZ, published_at TIMESTAMPTZ, tg_message_id BIGINT, metadata JSONB DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); `); // articles — статьи для публичного блога zeropost.ru await query(` CREATE TABLE IF NOT EXISTS articles ( id SERIAL PRIMARY KEY, slug VARCHAR(255) UNIQUE NOT NULL, title VARCHAR(500) NOT NULL, excerpt TEXT, content TEXT NOT NULL, cover_url TEXT, tags JSONB DEFAULT '[]'::jsonb, author VARCHAR(100) DEFAULT 'ZeroPost AI', reading_time INTEGER, status VARCHAR(20) DEFAULT 'published', views INTEGER DEFAULT 0, seo_title VARCHAR(500), seo_descr TEXT, job_id INTEGER REFERENCES generation_jobs(id) ON DELETE SET NULL, published_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); `); // editor_notes — короткие заметки от редактора-человека await query(` CREATE TABLE IF NOT EXISTS editor_notes ( id SERIAL PRIMARY KEY, title VARCHAR(255), content TEXT NOT NULL, author VARCHAR(100) DEFAULT 'Редактор', tags JSONB DEFAULT '[]'::jsonb, is_pinned BOOLEAN DEFAULT false, is_published BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); `); // series — тематические серии статей await query(` CREATE TABLE IF NOT EXISTS series ( id SERIAL PRIMARY KEY, slug VARCHAR(120) UNIQUE NOT NULL, title VARCHAR(255) NOT NULL, intro TEXT, icon VARCHAR(40), color VARCHAR(20) DEFAULT 'emerald', article_ids JSONB DEFAULT '[]'::jsonb, is_featured BOOLEAN DEFAULT false, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); `); // индексы await query(` CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id); CREATE INDEX IF NOT EXISTS idx_posts_channel ON posts(channel_id); CREATE INDEX IF NOT EXISTS idx_posts_scheduled ON posts(scheduled_at) WHERE status='scheduled'; CREATE INDEX IF NOT EXISTS idx_jobs_status ON generation_jobs(status); CREATE INDEX IF NOT EXISTS idx_jobs_user ON generation_jobs(user_id); CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug); CREATE INDEX IF NOT EXISTS idx_articles_status_pub ON articles(status, published_at DESC); CREATE INDEX IF NOT EXISTS idx_notes_pub ON editor_notes(is_published, created_at DESC); CREATE INDEX IF NOT EXISTS idx_series_slug ON series(slug); `); console.log('[DB] Migrations applied'); }; module.exports = { query, migrate };