1006 lines
34 KiB
JavaScript
1006 lines
34 KiB
JavaScript
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) => {
|
||
// hasOwn (not `in`) so @__proto__/@constructor can't pull inherited props (prototype pollution)
|
||
values.push(Object.hasOwn(obj, name) ? 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,
|
||
episode_number 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 generator_sources (
|
||
id TEXT PRIMARY KEY,
|
||
label TEXT,
|
||
url TEXT NOT NULL UNIQUE,
|
||
source_type TEXT NOT NULL DEFAULT 'auto',
|
||
enabled INTEGER DEFAULT 1,
|
||
notes 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_generator_sources_sort ON generator_sources(sort_order, created_at);
|
||
|
||
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,
|
||
source_url TEXT,
|
||
title_en TEXT,
|
||
date_en TEXT,
|
||
excerpt_en TEXT,
|
||
body_en TEXT,
|
||
analysis_en TEXT,
|
||
status TEXT DEFAULT 'published',
|
||
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,
|
||
file_url 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 technogen_reports (
|
||
id TEXT PRIMARY KEY,
|
||
code TEXT,
|
||
tag_fa TEXT,
|
||
tag_en TEXT,
|
||
title_fa TEXT NOT NULL,
|
||
title_en TEXT,
|
||
subtitle_fa TEXT,
|
||
subtitle_en TEXT,
|
||
description_fa TEXT,
|
||
description_en TEXT,
|
||
date_fa TEXT,
|
||
date_en TEXT,
|
||
pages_fa TEXT,
|
||
pages_en TEXT,
|
||
is_featured INTEGER DEFAULT 0,
|
||
cover_image TEXT,
|
||
file_url 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_technogen_sort ON technogen_reports(is_featured DESC, sort_order ASC, 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'",
|
||
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS blocks TEXT',
|
||
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS sources TEXT',
|
||
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS episode_number TEXT',
|
||
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS tags TEXT',
|
||
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS source TEXT',
|
||
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS source_url TEXT',
|
||
"ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'published'",
|
||
'ALTER TABLE members ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ',
|
||
'ALTER TABLE members ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
|
||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0',
|
||
'ALTER TABLE factory_reports ADD COLUMN IF NOT EXISTS file_url TEXT',
|
||
]) {
|
||
await pool.query(sql);
|
||
}
|
||
|
||
// per-member activity log (page views + element clicks) — logged-in users only
|
||
await pool.query(`
|
||
CREATE TABLE IF NOT EXISTS member_activity (
|
||
id SERIAL PRIMARY KEY,
|
||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||
kind TEXT NOT NULL, -- 'view' | 'click'
|
||
path TEXT,
|
||
label TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_activity_member ON member_activity(member_id, created_at DESC);
|
||
`);
|
||
|
||
// per-member bookmarks for articles, podcasts and other site content
|
||
await pool.query(`
|
||
CREATE TABLE IF NOT EXISTS member_bookmarks (
|
||
id SERIAL PRIMARY KEY,
|
||
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
||
content_id TEXT NOT NULL,
|
||
content_type TEXT NOT NULL,
|
||
title TEXT NOT NULL,
|
||
summary TEXT,
|
||
cover_image TEXT,
|
||
path TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
UNIQUE(member_id, content_type, content_id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_bookmarks_member ON member_bookmarks(member_id, created_at DESC);
|
||
`);
|
||
|
||
// newsletter subscribers (footer signup) — email is unique
|
||
await pool.query(`
|
||
CREATE TABLE IF NOT EXISTS subscribers (
|
||
id SERIAL PRIMARY KEY,
|
||
email TEXT NOT NULL UNIQUE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
`);
|
||
|
||
// 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)");
|
||
}
|
||
|
||
// seed technogen reports if table is empty
|
||
const { rows: techRows } = await pool.query("SELECT COUNT(*)::int AS cnt FROM technogen_reports");
|
||
if (techRows[0]?.cnt === 0) {
|
||
const INITIAL_TECHNOGEN_REPORTS = [
|
||
{
|
||
id: 'tgn-cs-01',
|
||
code: 'TGN-CS-01',
|
||
tag_fa: 'مطالعه تطبیقی',
|
||
tag_en: 'Comparative Study',
|
||
title_fa: 'پوسکو؛ از فولادساز ملی تا توسعهگر فناوری',
|
||
title_en: 'POSCO: From National Steelmaker to Technology Developer',
|
||
subtitle_fa: 'چگونه یک بنگاه بدون سرمایه، فناوری و تجربه، در پنج دهه به مالک، توسعهدهنده و صادرکننده فناوری تبدیل شد',
|
||
subtitle_en: 'How a firm with zero capital, technology, or experience became an owner, developer, and exporter of steel technology across five decades.',
|
||
description_fa: 'تحلیل عمیق ۵ دهه یادگیری فناورانه، سرمایهگذاری استراتژیک در تحقیق و توسعه، جهش تکنولوژیک و ساخت اکوسیستم نوآوری با مشارکت دانشگاه پوهانگ (POSTECH) و موسسه RIST.',
|
||
description_en: 'In-depth analysis of five decades of technological learning, strategic R&D investment, and innovation ecosystem building.',
|
||
date_fa: 'مرداد ۱۴۰۵',
|
||
date_en: 'August 2026',
|
||
pages_fa: '۳۰ صفحه',
|
||
pages_en: '30 Pages',
|
||
is_featured: 1,
|
||
cover_image: '/technogen/technogen_posco_cover.png',
|
||
file_url: '/technogen/technogen_posco_study.pdf',
|
||
sort_order: 1,
|
||
},
|
||
{
|
||
id: 'tgn-an-01',
|
||
code: 'TGN-AN-01',
|
||
tag_fa: 'یادداشت تحلیلی',
|
||
tag_en: 'Analytical Note',
|
||
title_fa: 'از تولیدکننده تا صاحب فناوری؛ مسیر جهش صنعت ایران',
|
||
title_en: 'From Manufacturer to Technology Owner: Iran\'s Industrial Leap Pathway',
|
||
subtitle_fa: 'درسهای یادگیری صنعتی در کرهجنوبی و برزیل برای ساخت قابلیت فناورانه در ایران',
|
||
subtitle_en: 'Lessons from industrial learning in South Korea and Brazil for building technological capabilities in Iran.',
|
||
description_fa: 'چرا تمرکز صرف بر ظرفیت تولید بدون ساخت قابلیت درونزای طراحی و مهندسی، صنعت را در دام مونتاژ و وابستگی به لایسنسور خارجی نگه میدارد.',
|
||
description_en: 'Why focusing solely on production volume without indigenous engineering keeps industry trapped in dependency.',
|
||
date_fa: 'مرداد ۱۴۰۵',
|
||
date_en: 'August 2026',
|
||
pages_fa: '۴ صفحه',
|
||
pages_en: '4 Pages',
|
||
is_featured: 0,
|
||
cover_image: '/technogen/tgn_an_01_cover.png',
|
||
file_url: '/technogen/tgn_an_01.pdf',
|
||
sort_order: 2,
|
||
},
|
||
{
|
||
id: 'tgn-an-02',
|
||
code: 'TGN-AN-02',
|
||
tag_fa: 'یادداشت تحلیلی',
|
||
tag_en: 'Analytical Note',
|
||
title_fa: 'رشد بدون توسعه؛ چرا صنعتیشدن بدون فناورشدن به سقف میخورد؟',
|
||
title_en: 'Growth Without Development: Why Industrialization Without Technology Hits a Ceiling',
|
||
subtitle_fa: 'بهرهوری سقف دارد؛ فناوری ندارد. چرا انباشت قابلیت فناورانه، شرط عبور از سقف رشد صنعتی است.',
|
||
subtitle_en: 'Productivity has a ceiling; technology does not. Why technological capability accumulation is essential for surpassing industrial growth limits.',
|
||
description_fa: 'بررسی منحنیهای مرز کارایی و تحلیل تفاوت بنیادی میان بهینهسازی عملیاتی موجود و جابهجایی مرز فناوری از طریق خلق دانش فنی.',
|
||
description_en: 'Analyzing efficiency frontiers and the fundamental difference between operational optimization and technological boundary shifts.',
|
||
date_fa: 'مرداد ۱۴۰۵',
|
||
date_en: 'August 2026',
|
||
pages_fa: '۴ صفحه',
|
||
pages_en: '4 Pages',
|
||
is_featured: 0,
|
||
cover_image: '/technogen/tgn_an_02_cover.png',
|
||
file_url: '/technogen/tgn_an_02.pdf',
|
||
sort_order: 3,
|
||
},
|
||
{
|
||
id: 'tgn-an-03',
|
||
code: 'TGN-AN-03',
|
||
tag_fa: 'یادداشت تحلیلی',
|
||
tag_en: 'Analytical Note',
|
||
title_fa: 'توسعهگر فناوری یا مصرفکنندهٔ دائمی؟ انتخابی که بنگاههای بزرگ باید امروز بکنند',
|
||
title_en: 'Technology Developer or Perpetual Consumer? The Critical Choice for Large Enterprises Today',
|
||
subtitle_fa: 'درسهای جهش و میانبر فناورانه از بنگاههای بزرگ کشورهای در حال توسعه؛ از پوسکو و امبرائر تا سیمکس و بائواستیل',
|
||
subtitle_en: 'Technological leapfrogging lessons from large emerging-market enterprises: from POSCO and Embraer to CEMEX and Baosteel.',
|
||
description_fa: 'تفاوت در مقصد سرمایهگذاری: یکی برای «داشتن» فناوری خرج میکند و دیگری برای «توانستن» — یعنی جذب، تغییر، طراحی و بازآفرینی فناوری.',
|
||
description_en: 'The difference in investment purpose: spending to acquire technology versus spending to build the capability to absorb, modify, design, and recreate it.',
|
||
date_fa: 'مرداد ۱۴۰۵',
|
||
date_en: 'August 2026',
|
||
pages_fa: '۴ صفحه',
|
||
pages_en: '4 Pages',
|
||
is_featured: 0,
|
||
cover_image: '/technogen/tgn_an_03_cover.png',
|
||
file_url: '/technogen/tgn_an_03.pdf',
|
||
sort_order: 4,
|
||
},
|
||
];
|
||
for (const item of INITIAL_TECHNOGEN_REPORTS) {
|
||
await pool.query(`
|
||
INSERT INTO technogen_reports (
|
||
id, code, tag_fa, tag_en, title_fa, title_en, subtitle_fa, subtitle_en,
|
||
description_fa, description_en, date_fa, date_en, pages_fa, pages_en,
|
||
is_featured, cover_image, file_url, sort_order
|
||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||
ON CONFLICT (id) DO NOTHING
|
||
`, [
|
||
item.id, item.code, item.tag_fa, item.tag_en, item.title_fa, item.title_en,
|
||
item.subtitle_fa, item.subtitle_en, item.description_fa, item.description_en,
|
||
item.date_fa, item.date_en, item.pages_fa, item.pages_en,
|
||
item.is_featured, item.cover_image, item.file_url, item.sort_order,
|
||
]);
|
||
}
|
||
}
|
||
|
||
export function normalizeMediaUrl(url) {
|
||
if (!url || typeof url !== 'string') return url;
|
||
const match = url.match(/^(?:https?:\/\/[^\/]+)?\/(media|uploads)\/(.+)$/i);
|
||
if (match) {
|
||
return `/${match[1]}/${match[2]}`;
|
||
}
|
||
return url;
|
||
}
|
||
|
||
function normalizeBlocks(blocks) {
|
||
if (!Array.isArray(blocks)) return [];
|
||
return blocks.map(b => {
|
||
if (b && typeof b === 'object' && b.url) {
|
||
return { ...b, url: normalizeMediaUrl(b.url) };
|
||
}
|
||
return b;
|
||
});
|
||
}
|
||
|
||
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: normalizeMediaUrl(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,
|
||
fileUrl: normalizeMediaUrl(row.file_url),
|
||
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: normalizeMediaUrl(row.icon),
|
||
logo: normalizeMediaUrl(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: normalizeMediaUrl(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: normalizeMediaUrl(row.img),
|
||
authorName: row.author_name,
|
||
authorAvatar: normalizeMediaUrl(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,
|
||
tags: row.tags ? JSON.parse(row.tags) : [],
|
||
source: row.source || null,
|
||
sourceUrl: row.source_url || null,
|
||
titleEn: row.title_en,
|
||
dateEn: row.date_en,
|
||
excerptEn: row.excerpt_en,
|
||
bodyEn: row.body_en || null,
|
||
analysisEn: row.analysis_en || null,
|
||
status: row.status || 'published',
|
||
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: normalizeMediaUrl(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: normalizeMediaUrl(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: normalizeMediaUrl(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,
|
||
episodeNumber: row.episode_number,
|
||
pages: row.pages,
|
||
price: row.price,
|
||
isFree: !!row.is_free,
|
||
summary: row.summary,
|
||
body: row.body,
|
||
coverImage: normalizeMediaUrl(row.cover_image),
|
||
blocks: normalizeBlocks(row.blocks ? JSON.parse(row.blocks) : []),
|
||
sources: row.sources ? JSON.parse(row.sources) : [],
|
||
tags: row.tags ? JSON.parse(row.tags) : [],
|
||
featured: !!row.featured,
|
||
status: row.status || 'published',
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
};
|
||
}
|
||
|
||
export function rowToTechnogenReport(row) {
|
||
if (!row) return null;
|
||
return {
|
||
id: row.id,
|
||
code: row.code,
|
||
tagFa: row.tag_fa,
|
||
tagEn: row.tag_en,
|
||
titleFa: row.title_fa,
|
||
titleEn: row.title_en,
|
||
subtitleFa: row.subtitle_fa,
|
||
subtitleEn: row.subtitle_en,
|
||
descriptionFa: row.description_fa,
|
||
descriptionEn: row.description_en,
|
||
dateFa: row.date_fa,
|
||
dateEn: row.date_en,
|
||
pagesFa: row.pages_fa,
|
||
pagesEn: row.pages_en,
|
||
isFeatured: !!row.is_featured,
|
||
coverImage: normalizeMediaUrl(row.cover_image),
|
||
fileUrl: normalizeMediaUrl(row.file_url),
|
||
sortOrder: row.sort_order,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
};
|
||
}
|
||
|