steelforesight/panel/db.js

797 lines
24 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,
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,
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 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 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);
`);
// 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,
fileUrl: 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: 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,
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: 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,
};
}