788 lines
23 KiB
JavaScript
788 lines
23 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'",
|
|
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS blocks TEXT',
|
|
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS sources 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 members ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ',
|
|
]) {
|
|
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);
|
|
`);
|
|
|
|
// 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)");
|
|
}
|
|
|
|
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,
|
|
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
source: row.source || 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,
|
|
blocks: row.blocks ? JSON.parse(row.blocks) : [],
|
|
sources: row.sources ? JSON.parse(row.sources) : [],
|
|
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
featured: !!row.featured,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|