steelforesight/panel/db.js

660 lines
19 KiB
JavaScript

import Database from 'better-sqlite3';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbPath = path.join(__dirname, 'data.db');
export const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// safe migrations — ignore if column already exists
for (const sql of [
"ALTER TABLE articles ADD COLUMN status TEXT DEFAULT 'published'",
"ALTER TABLE articles ADD COLUMN key_data TEXT",
]) { try { db.exec(sql) } catch {} }
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('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,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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,
title_en TEXT,
date_en TEXT,
excerpt_en TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('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 TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_members_email ON members(email);
CREATE TABLE IF NOT EXISTS purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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 TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(member_id, article_id)
);
CREATE INDEX IF NOT EXISTS idx_purchases_member ON purchases(member_id, purchased_at DESC);
`);
/* idempotent column migrations for already-created tables */
function ensureColumn(table, col, def) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
if (!cols.some((c) => c.name === col)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
}
}
ensureColumn('banners', 'overline_fa', 'TEXT');
ensureColumn('banners', 'overline_en', 'TEXT');
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 function upsertPrice(p) {
db.prepare(`
INSERT INTO prices (symbol, name, value, unit, change_value, change_pct, source_url, fetched_at)
VALUES (@symbol, @name, @value, @unit, @change_value, @change_pct, @source_url, datetime('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
`).run({
symbol: p.symbol,
name: p.name,
value: p.value ?? null,
unit: p.unit ?? null,
change_value: p.changeValue ?? null,
change_pct: p.changePct ?? null,
source_url: 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,
titleEn: row.title_en,
dateEn: row.date_en,
excerptEn: row.excerpt_en,
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,
};
}