steelforesight/panel/db.js

757 lines
22 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) => {
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,
};
}