import pg from 'pg'; const { Pool } = pg; if (!process.env.DATABASE_URL) { console.error('FATAL: DATABASE_URL is not set. Point it at your Liara PostgreSQL connection string.'); process.exit(1); } // Liara managed Postgres requires TLS when reached over the public endpoint. // Set DATABASE_SSL=disable only when connecting over the internal private network. const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: process.env.DATABASE_SSL === 'disable' ? false : { rejectUnauthorized: false }, }); export { pool }; // pg dates come back as JS Date objects; the API historically returned ISO-ish // strings from SQLite. Coerce timestamp/date OIDs back to the raw string pg sends. pg.types.setTypeParser(1114, (v) => v); // timestamp without time zone pg.types.setTypeParser(1184, (v) => v); // timestamptz /** * Translate a better-sqlite3-style statement into a pg query. * - named params (`@name`) when a single plain-object arg is passed * - positional params (`?`) otherwise * Returns { text, values } for pool.query. */ function buildQuery(sql, args) { const isNamed = args.length === 1 && args[0] != null && typeof args[0] === 'object' && !Array.isArray(args[0]) && /@\w+/.test(sql); if (isNamed) { const obj = args[0]; const values = []; const text = sql.replace(/@(\w+)/g, (_, name) => { values.push(name in obj ? obj[name] : null); return '$' + values.length; }); return { text, values }; } let i = 0; const text = sql.replace(/\?/g, () => '$' + ++i); return { text, values: args.flat() }; } /** * better-sqlite3-shaped async facade over pg. * db.prepare(sql).get(params) -> first row | undefined * db.prepare(sql).all(params) -> rows[] * db.prepare(sql).run(params) -> { changes, lastInsertRowid, rows } * NOTE: every method returns a Promise — callers MUST await. * For INSERTs that need the new id, append `RETURNING id` to the SQL; * run() exposes it as `lastInsertRowid`. */ export const db = { prepare(sql) { return { async get(...args) { const { text, values } = buildQuery(sql, args); const res = await pool.query(text, values); return res.rows[0]; }, async all(...args) { const { text, values } = buildQuery(sql, args); const res = await pool.query(text, values); return res.rows; }, async run(...args) { const { text, values } = buildQuery(sql, args); const res = await pool.query(text, values); return { changes: res.rowCount, lastInsertRowid: res.rows[0]?.id, rows: res.rows, }; }, }; }, async exec(sql) { await pool.query(sql); }, async query(text, params) { return pool.query(text, params); }, }; // ── schema (idempotent) — runs once at import via top-level await ── await pool.query(` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'admin', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS articles ( id TEXT PRIMARY KEY, title TEXT NOT NULL, category TEXT, type TEXT, author TEXT, author_role TEXT, author_initial TEXT, publish_date TEXT, pages INTEGER DEFAULT 0, price INTEGER DEFAULT 0, is_free INTEGER DEFAULT 1, summary TEXT, body TEXT, cover_image TEXT, tags TEXT, featured INTEGER DEFAULT 0, status TEXT DEFAULT 'published', key_data TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_articles_created ON articles(created_at DESC); CREATE INDEX IF NOT EXISTS idx_articles_featured ON articles(featured); CREATE TABLE IF NOT EXISTS risk_signals ( id TEXT PRIMARY KEY, quote TEXT NOT NULL, name TEXT NOT NULL, date TEXT, level TEXT NOT NULL CHECK (level IN ('critical','high','medium','low','opportunity')), sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_risks_sort ON risk_signals(sort_order, created_at); CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, title_fa TEXT NOT NULL, title_en TEXT, date_fa TEXT, date_en TEXT, month_fa TEXT, month_en TEXT, year TEXT, type TEXT CHECK(type IN ('conference','exhibition','seminar','international')), location_fa TEXT, location_en TEXT, city_fa TEXT, city_en TEXT, description_fa TEXT, description_en TEXT, registration_open INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_events_sort ON events(sort_order, created_at DESC); CREATE TABLE IF NOT EXISTS vision_items ( id TEXT PRIMARY KEY, icon TEXT, title_fa TEXT NOT NULL, title_en TEXT, desc_fa TEXT, desc_en TEXT, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_vision_sort ON vision_items(sort_order, created_at DESC); CREATE TABLE IF NOT EXISTS advisory_board ( id TEXT PRIMARY KEY, name_fa TEXT NOT NULL, name_en TEXT, role_fa TEXT, role_en TEXT, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_advisory_sort ON advisory_board(sort_order, created_at DESC); CREATE TABLE IF NOT EXISTS team_members ( id TEXT PRIMARY KEY, name_fa TEXT NOT NULL, name_en TEXT, role_fa TEXT, role_en TEXT, bio_fa TEXT, bio_en TEXT, expertise TEXT, email TEXT, initial TEXT, photo TEXT, report_count INTEGER DEFAULT 0, is_expert INTEGER DEFAULT 0, expert_room_fa TEXT, expert_room_en TEXT, telegram TEXT, linkedin TEXT, twitter TEXT, website TEXT, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_team_sort ON team_members(sort_order, created_at DESC); CREATE TABLE IF NOT EXISTS plans ( id TEXT PRIMARY KEY, name_fa TEXT NOT NULL, name_en TEXT, price INTEGER DEFAULT 0, period_fa TEXT, period_en TEXT, features TEXT, badge_fa TEXT, badge_en TEXT, is_featured INTEGER DEFAULT 0, cta_fa TEXT, cta_en TEXT, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_plans_sort ON plans(sort_order); CREATE TABLE IF NOT EXISTS radar_items ( id TEXT PRIMARY KEY, category TEXT CHECK(category IN ('market','tech','commodity','geo','energy')), img TEXT, author_name TEXT, author_avatar TEXT, title_fa TEXT, date_fa TEXT, excerpt_fa TEXT, body_fa TEXT, analysis_fa TEXT, title_en TEXT, date_en TEXT, excerpt_en TEXT, body_en TEXT, analysis_en TEXT, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_radar_sort ON radar_items(sort_order, created_at DESC); CREATE TABLE IF NOT EXISTS radar_pages ( slug TEXT PRIMARY KEY, source_categories TEXT, label_fa TEXT, label_en TEXT, latest_heading_fa TEXT, latest_heading_en TEXT, featured_title_fa TEXT, featured_title_en TEXT, featured_date_fa TEXT, featured_date_en TEXT, featured_img TEXT, banner_kicker_fa TEXT, banner_kicker_en TEXT, banner_title_fa TEXT, banner_title_en TEXT, banner_desc_fa TEXT, banner_desc_en TEXT, banner_book_img TEXT, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_radar_pages_sort ON radar_pages(sort_order); CREATE TABLE IF NOT EXISTS banners ( id TEXT PRIMARY KEY, overline_fa TEXT, overline_en TEXT, title_fa TEXT NOT NULL, title_en TEXT, subtitle_fa TEXT, subtitle_en TEXT, cta_label_fa TEXT, cta_label_en TEXT, cta_url TEXT, image TEXT, position TEXT DEFAULT 'hero', active INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_banners_active ON banners(active, sort_order); CREATE TABLE IF NOT EXISTS factory_reports ( id TEXT PRIMARY KEY, img TEXT, tag_fa TEXT, tag_en TEXT, title_fa TEXT NOT NULL, title_en TEXT, date_fa TEXT, date_en TEXT, excerpt_fa TEXT, excerpt_en TEXT, scroll_dir TEXT CHECK(scroll_dir IN ('left','right','up')) DEFAULT 'up', sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_factory_reports_sort ON factory_reports(sort_order, created_at DESC); CREATE TABLE IF NOT EXISTS integrations ( id TEXT PRIMARY KEY, name TEXT NOT NULL, icon TEXT, logo TEXT, accent TEXT, grid_col INTEGER DEFAULT 1, grid_row INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_integrations_sort ON integrations(sort_order, created_at DESC); CREATE TABLE IF NOT EXISTS institute_stats ( id TEXT PRIMARY KEY, label_fa TEXT NOT NULL, label_en TEXT, value INTEGER DEFAULT 0, suffix_fa TEXT, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_institute_stats_sort ON institute_stats(sort_order); CREATE TABLE IF NOT EXISTS market_prices ( id TEXT PRIMARY KEY, name TEXT NOT NULL, value REAL DEFAULT 0, unit TEXT, change REAL DEFAULT 0, change_percent REAL DEFAULT 0, trend TEXT CHECK(trend IN ('up','down','flat')) DEFAULT 'flat', sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_market_prices_sort ON market_prices(sort_order); CREATE TABLE IF NOT EXISTS market_chart_points ( id TEXT PRIMARY KEY, series TEXT NOT NULL CHECK(series IN ('history','export','comparison')), label TEXT NOT NULL, value REAL DEFAULT 0, meta TEXT, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_chart_points_series ON market_chart_points(series, sort_order); CREATE TABLE IF NOT EXISTS prices ( symbol TEXT PRIMARY KEY, name TEXT NOT NULL, value REAL, unit TEXT, change_value REAL, change_pct REAL, source_url TEXT, fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS members ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, phone TEXT, avatar TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_members_email ON members(email); CREATE TABLE IF NOT EXISTS purchases ( id SERIAL PRIMARY KEY, member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE, article_id TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE, amount INTEGER NOT NULL DEFAULT 0, purchased_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(member_id, article_id) ); CREATE INDEX IF NOT EXISTS idx_purchases_member ON purchases(member_id, purchased_at DESC); `); // idempotent column adds (Postgres supports IF NOT EXISTS on ADD COLUMN) for (const sql of [ "ALTER TABLE articles ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'published'", 'ALTER TABLE articles ADD COLUMN IF NOT EXISTS key_data TEXT', 'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS body_fa TEXT', 'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS body_en TEXT', 'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS analysis_fa TEXT', 'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS analysis_en TEXT', 'ALTER TABLE banners ADD COLUMN IF NOT EXISTS overline_fa TEXT', 'ALTER TABLE banners ADD COLUMN IF NOT EXISTS overline_en TEXT', "ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'admin'", ]) { await pool.query(sql); } // promote the founding admin (lowest id) to owner if none exists yet const { rows: ownerRows } = await pool.query("SELECT 1 FROM users WHERE role = 'owner' LIMIT 1"); if (ownerRows.length === 0) { await pool.query("UPDATE users SET role = 'owner' WHERE id = (SELECT id FROM users ORDER BY id LIMIT 1)"); } export function rowToPrice(row) { if (!row) return null; return { symbol: row.symbol, name: row.name, value: row.value, unit: row.unit, changeValue: row.change_value, changePct: row.change_pct, sourceUrl: row.source_url, fetchedAt: row.fetched_at, }; } export async function upsertPrice(p) { await pool.query( `INSERT INTO prices (symbol, name, value, unit, change_value, change_pct, source_url, fetched_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now()) ON CONFLICT(symbol) DO UPDATE SET name = EXCLUDED.name, value = EXCLUDED.value, unit = EXCLUDED.unit, change_value = EXCLUDED.change_value, change_pct = EXCLUDED.change_pct, source_url = EXCLUDED.source_url, fetched_at = EXCLUDED.fetched_at`, [ p.symbol, p.name, p.value ?? null, p.unit ?? null, p.changeValue ?? null, p.changePct ?? null, p.sourceUrl ?? null, ], ); } export function rowToEvent(row) { if (!row) return null; return { id: row.id, titleFa: row.title_fa, titleEn: row.title_en, dateFa: row.date_fa, dateEn: row.date_en, monthFa: row.month_fa, monthEn: row.month_en, year: row.year, type: row.type, locationFa: row.location_fa, locationEn: row.location_en, cityFa: row.city_fa, cityEn: row.city_en, descriptionFa: row.description_fa, descriptionEn: row.description_en, registrationOpen: !!row.registration_open, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToTeamMember(row) { if (!row) return null; return { id: row.id, nameFa: row.name_fa, nameEn: row.name_en, roleFa: row.role_fa, roleEn: row.role_en, bioFa: row.bio_fa, bioEn: row.bio_en, expertise: row.expertise ? JSON.parse(row.expertise) : [], email: row.email, initial: row.initial, photo: row.photo, reportCount: row.report_count, isExpert: !!row.is_expert, expertRoomFa: row.expert_room_fa, expertRoomEn: row.expert_room_en, telegram: row.telegram, linkedin: row.linkedin, twitter: row.twitter, website: row.website, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToPlan(row) { if (!row) return null; return { id: row.id, nameFa: row.name_fa, nameEn: row.name_en, price: row.price, periodFa: row.period_fa, periodEn: row.period_en, features: row.features ? JSON.parse(row.features) : [], badgeFa: row.badge_fa, badgeEn: row.badge_en, isFeatured: !!row.is_featured, ctaFa: row.cta_fa, ctaEn: row.cta_en, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToRiskSignal(row) { if (!row) return null; return { id: row.id, quote: row.quote, name: row.name, date: row.date, level: row.level, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToFactoryReport(row) { if (!row) return null; return { id: row.id, img: row.img, tagFa: row.tag_fa, tagEn: row.tag_en, titleFa: row.title_fa, titleEn: row.title_en, dateFa: row.date_fa, dateEn: row.date_en, excerptFa: row.excerpt_fa, excerptEn: row.excerpt_en, scrollDir: row.scroll_dir, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToIntegration(row) { if (!row) return null; return { id: row.id, name: row.name, icon: row.icon, logo: row.logo, accent: row.accent, gridCol: row.grid_col, gridRow: row.grid_row, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToInstituteStat(row) { if (!row) return null; return { id: row.id, labelFa: row.label_fa, labelEn: row.label_en, value: row.value, suffixFa: row.suffix_fa, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToMarketPrice(row) { if (!row) return null; return { id: row.id, name: row.name, value: row.value, unit: row.unit, change: row.change, changePercent: row.change_percent, trend: row.trend, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToMarketChartPoint(row) { if (!row) return null; return { id: row.id, series: row.series, label: row.label, value: row.value, meta: row.meta, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToVisionItem(row) { if (!row) return null; return { id: row.id, icon: row.icon, titleFa: row.title_fa, titleEn: row.title_en, descFa: row.desc_fa, descEn: row.desc_en, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToAdvisoryMember(row) { if (!row) return null; return { id: row.id, nameFa: row.name_fa, nameEn: row.name_en, roleFa: row.role_fa, roleEn: row.role_en, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToRadarItem(row) { if (!row) return null; return { id: row.id, category: row.category, img: row.img, authorName: row.author_name, authorAvatar: row.author_avatar, titleFa: row.title_fa, dateFa: row.date_fa, excerptFa: row.excerpt_fa, bodyFa: row.body_fa || null, analysisFa: row.analysis_fa || null, titleEn: row.title_en, dateEn: row.date_en, excerptEn: row.excerpt_en, bodyEn: row.body_en || null, analysisEn: row.analysis_en || null, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToRadarPage(row) { if (!row) return null; return { slug: row.slug, sourceCategories: row.source_categories ? JSON.parse(row.source_categories) : [], labelFa: row.label_fa, labelEn: row.label_en, latestHeadingFa: row.latest_heading_fa, latestHeadingEn: row.latest_heading_en, featuredTitleFa: row.featured_title_fa, featuredTitleEn: row.featured_title_en, featuredDateFa: row.featured_date_fa, featuredDateEn: row.featured_date_en, featuredImg: row.featured_img, bannerKickerFa: row.banner_kicker_fa, bannerKickerEn: row.banner_kicker_en, bannerTitleFa: row.banner_title_fa, bannerTitleEn: row.banner_title_en, bannerDescFa: row.banner_desc_fa, bannerDescEn: row.banner_desc_en, bannerBookImg: row.banner_book_img, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToBanner(row) { if (!row) return null; return { id: row.id, overlineFa: row.overline_fa, overlineEn: row.overline_en, titleFa: row.title_fa, titleEn: row.title_en, subtitleFa: row.subtitle_fa, subtitleEn: row.subtitle_en, ctaLabelFa: row.cta_label_fa, ctaLabelEn: row.cta_label_en, ctaUrl: row.cta_url, image: row.image, position: row.position, active: !!row.active, sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at, }; } export function rowToArticle(row) { if (!row) return null; return { id: row.id, title: row.title, category: row.category, type: row.type, author: row.author, authorRole: row.author_role, authorInitial: row.author_initial, publishDate: row.publish_date, pages: row.pages, price: row.price, isFree: !!row.is_free, summary: row.summary, body: row.body, coverImage: row.cover_image, tags: row.tags ? JSON.parse(row.tags) : [], featured: !!row.featured, createdAt: row.created_at, updatedAt: row.updated_at, }; }