-- Daily news digest schema. -- PostgreSQL 12+; no application credentials are stored here. BEGIN; CREATE TABLE IF NOT EXISTS news_sources ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR(200) NOT NULL, homepage_url TEXT, feed_url TEXT NOT NULL, region VARCHAR(20) NOT NULL DEFAULT 'international' CHECK (region IN ('domestic', 'international')), category VARCHAR(40) NOT NULL DEFAULT 'general' CHECK (category IN ( 'general', 'ai_agent', 'agri_hardware', 'agri_solutions', 'agri_services', 'policy_market' )), priority SMALLINT NOT NULL DEFAULT 50 CHECK (priority BETWEEN 0 AND 100), enabled BOOLEAN NOT NULL DEFAULT TRUE, last_fetched_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_news_sources_feed_url UNIQUE (feed_url) ); CREATE TABLE IF NOT EXISTS daily_reports ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, report_date DATE NOT NULL, title VARCHAR(300) NOT NULL, introduction TEXT, item_count INTEGER NOT NULL DEFAULT 0 CHECK (item_count >= 0), status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'failed')), email_status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (email_status IN ('pending', 'sent', 'failed', 'skipped')), email_sent_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_daily_reports_report_date UNIQUE (report_date) ); CREATE TABLE IF NOT EXISTS news_articles ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, source_id BIGINT REFERENCES news_sources(id) ON DELETE SET NULL, external_id TEXT, title TEXT NOT NULL, summary TEXT, url TEXT NOT NULL, author VARCHAR(300), language_code VARCHAR(16) NOT NULL DEFAULT 'zh', translated_title TEXT, translated_summary TEXT, translation_status VARCHAR(20) NOT NULL DEFAULT 'not_needed' CHECK (translation_status IN ('not_needed', 'pending', 'translated', 'failed', 'skipped')), translation_provider VARCHAR(80), translated_at TIMESTAMPTZ, region VARCHAR(20) NOT NULL DEFAULT 'international' CHECK (region IN ('domestic', 'international')), category VARCHAR(40) NOT NULL DEFAULT 'general' CHECK (category IN ( 'general', 'ai_agent', 'agri_hardware', 'agri_solutions', 'agri_services', 'policy_market' )), topics TEXT[] NOT NULL DEFAULT '{}', keywords TEXT[] NOT NULL DEFAULT '{}', importance_score SMALLINT NOT NULL DEFAULT 50 CHECK (importance_score BETWEEN 0 AND 100), published_at TIMESTAMPTZ, discovered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), is_read BOOLEAN NOT NULL DEFAULT FALSE, content_hash CHAR(64), raw_payload JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_news_articles_url UNIQUE (url), CONSTRAINT uq_news_articles_source_external UNIQUE (source_id, external_id) ); CREATE TABLE IF NOT EXISTS report_articles ( report_id BIGINT NOT NULL REFERENCES daily_reports(id) ON DELETE CASCADE, article_id BIGINT NOT NULL REFERENCES news_articles(id) ON DELETE CASCADE, section VARCHAR(40) NOT NULL DEFAULT 'general' CHECK (section IN ( 'general', 'ai_agent', 'agri_hardware', 'agri_solutions', 'agri_services', 'policy_market' )), display_order INTEGER NOT NULL DEFAULT 0 CHECK (display_order >= 0), is_highlight BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (report_id, article_id) ); CREATE TABLE IF NOT EXISTS email_deliveries ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, report_id BIGINT NOT NULL REFERENCES daily_reports(id) ON DELETE CASCADE, recipient_id BIGINT, recipient VARCHAR(320) NOT NULL, modules TEXT[] NOT NULL DEFAULT '{}', status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')), provider_id TEXT, error_message TEXT, sent_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Web users. Never store a plaintext password; password_hash is produced by -- the web service using Argon2id or bcrypt. CREATE TABLE IF NOT EXISTS app_users ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email VARCHAR(320) NOT NULL, display_name VARCHAR(120), password_hash TEXT, role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')), is_active BOOLEAN NOT NULL DEFAULT TRUE, email_verified_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_app_users_email UNIQUE (email) ); -- A user may have one or more receiving addresses. This also supports a -- standalone subscription that is not linked to a web login (user_id NULL). CREATE TABLE IF NOT EXISTS newsletter_recipients ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id BIGINT REFERENCES app_users(id) ON DELETE SET NULL, email VARCHAR(320) NOT NULL, display_name VARCHAR(120), enabled BOOLEAN NOT NULL DEFAULT TRUE, send_time TIME NOT NULL DEFAULT TIME '07:30', timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai', send_all_modules BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_newsletter_recipients_email UNIQUE (email) ); -- Module catalog. Add new modules here without changing the recipient table. CREATE TABLE IF NOT EXISTS newsletter_modules ( module_key VARCHAR(40) PRIMARY KEY, module_name VARCHAR(120) NOT NULL, description TEXT, enabled BOOLEAN NOT NULL DEFAULT TRUE, sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); INSERT INTO newsletter_modules (module_key, module_name, description, sort_order) VALUES ('general', '综合热点', '国内外综合资讯', 10), ('ai_agent', 'AI 与 Agent', '大模型、AI 应用和 Agent', 20), ('agri_hardware', '农业硬件', '传感器、无人机、机器人和农机', 30), ('agri_solutions', '农业方案落地', '智慧农场、温室、灌溉和数字化方案', 40), ('agri_services', '农业服务', '农业 SaaS、农技、托管、供应链和运维服务', 50), ('policy_market', '政策与市场', '政策、补贴、投资、招标和市场动态', 60), ('domestic', '国内资讯', '国内来源或国内市场相关资讯', 70), ('international', '国际资讯', '国际来源或国际市场相关资讯', 80) ON CONFLICT (module_key) DO NOTHING; -- Many-to-many preference: one recipient can select any number of modules. CREATE TABLE IF NOT EXISTS recipient_modules ( recipient_id BIGINT NOT NULL REFERENCES newsletter_recipients(id) ON DELETE CASCADE, module_key VARCHAR(40) NOT NULL REFERENCES newsletter_modules(module_key) ON DELETE RESTRICT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (recipient_id, module_key) ); ALTER TABLE email_deliveries ADD COLUMN IF NOT EXISTS recipient_id BIGINT, ADD COLUMN IF NOT EXISTS modules TEXT[] NOT NULL DEFAULT '{}'; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'fk_email_deliveries_recipient' ) THEN ALTER TABLE email_deliveries ADD CONSTRAINT fk_email_deliveries_recipient FOREIGN KEY (recipient_id) REFERENCES newsletter_recipients(id) ON DELETE SET NULL; END IF; END $$; CREATE INDEX IF NOT EXISTS idx_articles_published_at ON news_articles (published_at DESC); CREATE INDEX IF NOT EXISTS idx_articles_category_published ON news_articles (category, published_at DESC); CREATE INDEX IF NOT EXISTS idx_articles_region_published ON news_articles (region, published_at DESC); CREATE INDEX IF NOT EXISTS idx_articles_content_hash ON news_articles (content_hash); CREATE INDEX IF NOT EXISTS idx_report_articles_order ON report_articles (report_id, section, display_order); CREATE INDEX IF NOT EXISTS idx_articles_keywords_gin ON news_articles USING GIN (keywords); CREATE INDEX IF NOT EXISTS idx_articles_topics_gin ON news_articles USING GIN (topics); CREATE INDEX IF NOT EXISTS idx_newsletter_recipients_user ON newsletter_recipients (user_id); CREATE INDEX IF NOT EXISTS idx_newsletter_recipients_enabled ON newsletter_recipients (enabled, send_time); CREATE INDEX IF NOT EXISTS idx_recipient_modules_module ON recipient_modules (module_key); -- The web app uses ILIKE for history searches. This is intentional because -- PostgreSQL's built-in text configurations do not segment Chinese reliably. -- A generated tsvector column is deliberately not used: some PostgreSQL -- versions/drivers reject the expression as non-immutable. COMMIT; -- Optional starter sources. Review the URLs before inserting them. -- INSERT INTO news_sources (name, homepage_url, feed_url, region, category, priority) -- VALUES -- ('Example source', 'https://example.com', 'https://example.com/feed.xml', 'international', 'general', 50);