steelforesight/panel/server.js

1515 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import multer from 'multer';
import sharp from 'sharp';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { nanoid } from 'nanoid';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js';
import { startScraperLoop, scrapeOnce } from './scraper.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 3001);
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
console.error('FATAL: JWT_SECRET env var is missing or shorter than 32 chars. Set it before starting.');
process.exit(1);
}
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`;
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
// buffer the upload in memory so we can transcode it to WebP before writing to disk
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (/^image\/(jpe?g|png|webp|gif|avif|tiff?)$/.test(file.mimetype)) cb(null, true);
else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif)'));
},
});
const app = express();
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173')
.split(',').map(s => s.trim());
app.use(cors({
origin: (origin, cb) => {
if (!origin || ALLOWED_ORIGINS.includes(origin)) cb(null, true);
else cb(new Error('Not allowed by CORS'));
},
credentials: true,
}));
app.use(cookieParser());
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
frameguard: { action: 'deny' },
}));
app.use(express.json({ limit: '50mb' }));
app.use('/uploads', express.static(uploadsDir, { maxAge: '7d' }));
app.use('/', express.static(path.join(__dirname, 'public')));
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'too_many_attempts' },
standardHeaders: true,
legacyHeaders: false,
});
const contactLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 3,
message: { error: 'too_many_requests' },
standardHeaders: true,
legacyHeaders: false,
});
function authRequired(req, res, next) {
let token = req.cookies?.session;
if (!token) {
const auth = req.headers['authorization'];
if (auth?.startsWith('Bearer ')) token = auth.slice(7);
}
if (!token) return res.status(401).json({ error: 'unauthorized' });
try {
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: 'invalid_token' });
}
}
function memberRequired(req, res, next) {
const auth = req.headers['authorization'];
const token = (auth?.startsWith('Bearer ') ? auth.slice(7) : null) ?? req.query.token ?? null;
if (!token) return res.status(401).json({ error: 'unauthorized' });
try {
const payload = jwt.verify(token, JWT_SECRET);
if (payload.role !== 'member') return res.status(403).json({ error: 'forbidden' });
req.member = payload;
next();
} catch {
return res.status(401).json({ error: 'invalid_token' });
}
}
app.post('/api/auth/login', loginLimiter, (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username_password_required' });
const row = db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get(username);
if (!row || !bcrypt.compareSync(password, row.password_hash)) {
return res.status(401).json({ error: 'bad_credentials' });
}
const token = jwt.sign({ sub: row.id, username: row.username }, JWT_SECRET, { expiresIn: '24h' });
const isProd = process.env.NODE_ENV === 'production';
res.cookie('session', token, {
httpOnly: true,
secure: isProd,
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000,
path: '/',
});
res.json({ user: { id: row.id, username: row.username }, token });
});
app.post('/api/auth/logout', (_req, res) => {
const isProd = process.env.NODE_ENV === 'production';
res.clearCookie('session', { httpOnly: true, secure: isProd, sameSite: 'strict', path: '/' });
res.json({ ok: true });
});
app.get('/api/auth/me', authRequired, (req, res) => {
res.json({ user: req.user });
});
const PUBLIC_FIELDS = `
id, title, category, type, author, author_role, author_initial,
publish_date, pages, price, is_free, summary, body, cover_image,
tags, featured, created_at, updated_at
`;
app.get('/api/articles', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const { category, type } = req.query;
let query = `SELECT ${PUBLIC_FIELDS} FROM articles`;
const params = [];
const conditions = [];
if (category) { conditions.push('category = ?'); params.push(category); }
if (type) { conditions.push('type = ?'); params.push(type); }
if (conditions.length) query += ' WHERE ' + conditions.join(' AND ');
query += ' ORDER BY created_at DESC LIMIT ?';
params.push(limit);
const rows = db.prepare(query).all(...params);
res.json(rows.map(rowToArticle));
});
app.get('/api/articles/:id', (req, res) => {
const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToArticle(row));
});
app.post('/api/uploads', authRequired, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no_file' });
try {
const original = req.file.buffer;
const originalSize = original.length;
const isAnimated = req.file.mimetype === 'image/gif';
// cap very large images, then transcode to WebP. Step quality down until the
// output is ≤ ~25% of the original (or we hit the quality floor).
const target = Math.round(originalSize * 0.25);
let out = null;
for (const quality of [80, 70, 60, 50, 40, 32]) {
const buf = await sharp(original, { animated: isAnimated })
.rotate() // respect EXIF orientation
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true })
.webp({ quality, effort: 4 })
.toBuffer();
out = buf;
if (buf.length <= target) break;
}
// never write a file larger than the original
const finalBuf = out.length < originalSize ? out : await sharp(original, { animated: isAnimated })
.webp({ quality: 80 }).toBuffer();
const filename = `${nanoid(12)}.webp`;
fs.writeFileSync(path.join(uploadsDir, filename), finalBuf);
const relative = `/uploads/${filename}`;
res.json({
url: relative,
absoluteUrl: `${PUBLIC_ORIGIN}${relative}`,
originalSize,
size: finalBuf.length,
reduction: Math.round((1 - finalBuf.length / originalSize) * 100),
});
} catch (err) {
console.error('[upload] webp conversion failed:', err.message);
res.status(500).json({ error: 'conversion_failed' });
}
});
function normalizeArticleBody(b) {
return {
title: String(b.title || '').trim(),
category: b.category || null,
type: b.type || null,
author: b.author || null,
author_role: b.authorRole || null,
author_initial: b.authorInitial || null,
publish_date: b.publishDate || null,
pages: Number(b.pages) || 0,
price: Number(b.price) || 0,
is_free: b.isFree ? 1 : 0,
summary: b.summary || null,
body: b.body || null,
cover_image: b.coverImage || null,
tags: JSON.stringify(Array.isArray(b.tags) ? b.tags : []),
featured: b.featured ? 1 : 0,
};
}
app.post('/api/articles', authRequired, (req, res) => {
const a = normalizeArticleBody(req.body || {});
if (!a.title) return res.status(400).json({ error: 'title_required' });
const id = nanoid(14);
db.prepare(`
INSERT INTO articles (
id, title, category, type, author, author_role, author_initial,
publish_date, pages, price, is_free, summary, body, cover_image,
tags, featured
) VALUES (
@id, @title, @category, @type, @author, @author_role, @author_initial,
@publish_date, @pages, @price, @is_free, @summary, @body, @cover_image,
@tags, @featured
)
`).run({ id, ...a });
const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(id);
res.status(201).json(rowToArticle(row));
});
app.put('/api/articles/:id', authRequired, (req, res) => {
const existing = db.prepare('SELECT id FROM articles WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'not_found' });
const a = normalizeArticleBody(req.body || {});
if (!a.title) return res.status(400).json({ error: 'title_required' });
db.prepare(`
UPDATE articles SET
title = @title, category = @category, type = @type,
author = @author, author_role = @author_role, author_initial = @author_initial,
publish_date = @publish_date, pages = @pages, price = @price, is_free = @is_free,
summary = @summary, body = @body, cover_image = @cover_image,
tags = @tags, featured = @featured,
updated_at = datetime('now')
WHERE id = @id
`).run({ id: req.params.id, ...a });
const row = db.prepare(`SELECT ${PUBLIC_FIELDS} FROM articles WHERE id = ?`).get(req.params.id);
res.json(rowToArticle(row));
});
app.delete('/api/articles/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM articles WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- bulk import (Gemini JSON) ----------
// Gemini grounding leaves [cite: 1921] / [cite: 1921, 1931] markers in the text — strip them
// (and the whitespace artifacts they leave behind) before anything hits the DB.
function stripCites(s) {
return s
.replace(/\[cite:[^\]]*\]/g, '')
.replace(/[ \t]+([.،؛:!؟])/g, '$1') // space before punctuation
.replace(/[ \t]{2,}/g, ' ') // runs of spaces
.replace(/[ \t]+\n/g, '\n') // trailing space before newline (keeps \n\n)
.trim();
}
function deepSanitize(v) {
if (typeof v === 'string') return stripCites(v);
if (Array.isArray(v)) return v.map(deepSanitize);
if (v && typeof v === 'object') {
const o = {};
for (const k of Object.keys(v)) o[k] = deepSanitize(v[k]);
return o;
}
return v;
}
app.post('/api/admin/import', authRequired, (req, res) => {
const { report, radar_items = [], fa_summary_long, key_data } = deepSanitize(req.body || {});
if (!report?.title) return res.status(400).json({ error: 'report.title required' });
const results = { article: null, radar: [] };
// upsert article
const articleId = report.id || nanoid(14);
const body = fa_summary_long
? (key_data ? fa_summary_long + '\n\n---KEY_DATA---\n' + JSON.stringify(key_data, null, 2) : fa_summary_long)
: (key_data ? JSON.stringify(key_data, null, 2) : null);
db.prepare(`
INSERT OR REPLACE INTO articles
(id, title, category, type, author, author_role, author_initial,
publish_date, pages, price, is_free, summary, body, tags, cover_image, featured, status)
VALUES
(@id, @title, @category, @type, @author, @author_role, @author_initial,
@publish_date, @pages, @price, @is_free, @summary, @body, @tags, @cover_image, @featured, 'published')
`).run({
id: articleId,
title: report.title,
category: report.category || null,
type: report.type || 'special',
author: report.author || null,
author_role: report.authorRole || report.author_role || null,
author_initial: report.authorInitial || report.author_initial || null,
publish_date: report.publishDate || report.publish_date || null,
pages: Number(report.pages) || 0,
price: Number(report.price) || 0,
is_free: (report.isFree ?? report.is_free ?? false) ? 1 : 0,
summary: report.summary || null,
body,
tags: JSON.stringify(Array.isArray(report.tags) ? report.tags : []),
cover_image: report.coverImage || report.cover_image || null,
featured: report.featured ? 1 : 0,
});
results.article = articleId;
// upsert radar items
const insertRadar = db.prepare(`
INSERT OR REPLACE INTO radar_items
(id, category, img, title_fa, excerpt_fa, date_fa, author_name, sort_order)
VALUES (@id, @category, @img, @title_fa, @excerpt_fa, @date_fa, @author_name, @sort_order)
`);
radar_items.forEach((item, i) => {
const rid = item.id || nanoid(12);
insertRadar.run({
id: rid,
category: item.category || 'market',
img: item.img || item.image || report.coverImage || '',
title_fa: item.title || '',
excerpt_fa: item.summary || item.excerpt_fa || '',
date_fa: item.date || '',
author_name: report.author || '',
sort_order: i,
});
results.radar.push(rid);
});
res.json({ ok: true, ...results });
});
// ---------- events ----------
const EVENT_FIELDS = `id, title_fa, title_en, date_fa, date_en, month_fa, month_en, year, type,
location_fa, location_en, city_fa, city_en, description_fa, description_en,
registration_open, sort_order, created_at, updated_at`;
app.get('/api/events', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const rows = db.prepare(`SELECT ${EVENT_FIELDS} FROM events ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToEvent));
});
app.get('/api/events/:id', (req, res) => {
const row = db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToEvent(row));
});
function normalizeEventBody(b) {
return {
title_fa: String(b.titleFa || '').trim(),
title_en: b.titleEn || null,
date_fa: b.dateFa || null, date_en: b.dateEn || null,
month_fa: b.monthFa || null, month_en: b.monthEn || null,
year: b.year || null,
type: ['conference','exhibition','seminar','international'].includes(b.type) ? b.type : null,
location_fa: b.locationFa || null, location_en: b.locationEn || null,
city_fa: b.cityFa || null, city_en: b.cityEn || null,
description_fa: b.descriptionFa || null, description_en: b.descriptionEn || null,
registration_open: b.registrationOpen ? 1 : 0,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/events', authRequired, (req, res) => {
const e = normalizeEventBody(req.body || {});
if (!e.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO events (id,title_fa,title_en,date_fa,date_en,month_fa,month_en,year,type,
location_fa,location_en,city_fa,city_en,description_fa,description_en,registration_open,sort_order)
VALUES (@id,@title_fa,@title_en,@date_fa,@date_en,@month_fa,@month_en,@year,@type,
@location_fa,@location_en,@city_fa,@city_en,@description_fa,@description_en,@registration_open,@sort_order)
`).run({ id, ...e });
res.status(201).json(rowToEvent(db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(id)));
});
app.put('/api/events/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const e = normalizeEventBody(req.body || {});
if (!e.title_fa) return res.status(400).json({ error: 'title_fa_required' });
db.prepare(`UPDATE events SET title_fa=@title_fa,title_en=@title_en,date_fa=@date_fa,date_en=@date_en,
month_fa=@month_fa,month_en=@month_en,year=@year,type=@type,location_fa=@location_fa,location_en=@location_en,
city_fa=@city_fa,city_en=@city_en,description_fa=@description_fa,description_en=@description_en,
registration_open=@registration_open,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...e });
res.json(rowToEvent(db.prepare(`SELECT ${EVENT_FIELDS} FROM events WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/events/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- radar ----------
const RADAR_FIELDS = `id, category, img, author_name, author_avatar,
title_fa, date_fa, excerpt_fa, title_en, date_en, excerpt_en,
sort_order, created_at, updated_at`;
app.get('/api/radar', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 500);
const rows = db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToRadarItem));
});
app.get('/api/radar/:id', (req, res) => {
const row = db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToRadarItem(row));
});
function normalizeRadarBody(b) {
return {
category: ['market','tech','commodity','geo','energy'].includes(b.category) ? b.category : null,
img: b.img || null,
author_name: b.authorName || null,
author_avatar: b.authorAvatar || null,
title_fa: String(b.titleFa || '').trim(),
date_fa: b.dateFa || null,
excerpt_fa: b.excerptFa || null,
title_en: b.titleEn || null,
date_en: b.dateEn || null,
excerpt_en: b.excerptEn || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/radar', authRequired, (req, res) => {
const r = normalizeRadarBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO radar_items (id,category,img,author_name,author_avatar,
title_fa,date_fa,excerpt_fa,title_en,date_en,excerpt_en,sort_order)
VALUES (@id,@category,@img,@author_name,@author_avatar,
@title_fa,@date_fa,@excerpt_fa,@title_en,@date_en,@excerpt_en,@sort_order)
`).run({ id, ...r });
res.status(201).json(rowToRadarItem(db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(id)));
});
app.put('/api/radar/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM radar_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const r = normalizeRadarBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
db.prepare(`UPDATE radar_items SET category=@category,img=@img,author_name=@author_name,
author_avatar=@author_avatar,title_fa=@title_fa,date_fa=@date_fa,excerpt_fa=@excerpt_fa,
title_en=@title_en,date_en=@date_en,excerpt_en=@excerpt_en,
sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...r });
res.json(rowToRadarItem(db.prepare(`SELECT ${RADAR_FIELDS} FROM radar_items WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/radar/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM radar_items WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- radar pages (per-category hero + banner) ----------
const RADAR_PAGE_FIELDS = `slug, source_categories, label_fa, label_en,
latest_heading_fa, latest_heading_en, featured_title_fa, featured_title_en,
featured_date_fa, featured_date_en, featured_img,
banner_kicker_fa, banner_kicker_en, banner_title_fa, banner_title_en,
banner_desc_fa, banner_desc_en, banner_book_img,
sort_order, created_at, updated_at`;
app.get('/api/radar-pages', (req, res) => {
const rows = db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages ORDER BY sort_order ASC`).all();
res.json(rows.map(rowToRadarPage));
});
app.get('/api/radar-pages/:slug', (req, res) => {
const row = db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(req.params.slug);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToRadarPage(row));
});
function normalizeRadarPageBody(b) {
return {
source_categories: JSON.stringify(Array.isArray(b.sourceCategories) ? b.sourceCategories : []),
label_fa: String(b.labelFa || '').trim(),
label_en: b.labelEn || null,
latest_heading_fa: b.latestHeadingFa || null,
latest_heading_en: b.latestHeadingEn || null,
featured_title_fa: b.featuredTitleFa || null,
featured_title_en: b.featuredTitleEn || null,
featured_date_fa: b.featuredDateFa || null,
featured_date_en: b.featuredDateEn || null,
featured_img: b.featuredImg || null,
banner_kicker_fa: b.bannerKickerFa || null,
banner_kicker_en: b.bannerKickerEn || null,
banner_title_fa: b.bannerTitleFa || null,
banner_title_en: b.bannerTitleEn || null,
banner_desc_fa: b.bannerDescFa || null,
banner_desc_en: b.bannerDescEn || null,
banner_book_img: b.bannerBookImg || null,
sort_order: Number(b.sortOrder) || 0,
};
}
// upsert by slug (slug is the stable key for the 5 fixed pages)
app.put('/api/radar-pages/:slug', authRequired, (req, res) => {
const slug = req.params.slug;
const r = normalizeRadarPageBody(req.body || {});
if (!r.label_fa) return res.status(400).json({ error: 'label_fa_required' });
const exists = db.prepare('SELECT slug FROM radar_pages WHERE slug = ?').get(slug);
if (exists) {
db.prepare(`UPDATE radar_pages SET source_categories=@source_categories,label_fa=@label_fa,label_en=@label_en,
latest_heading_fa=@latest_heading_fa,latest_heading_en=@latest_heading_en,
featured_title_fa=@featured_title_fa,featured_title_en=@featured_title_en,
featured_date_fa=@featured_date_fa,featured_date_en=@featured_date_en,featured_img=@featured_img,
banner_kicker_fa=@banner_kicker_fa,banner_kicker_en=@banner_kicker_en,
banner_title_fa=@banner_title_fa,banner_title_en=@banner_title_en,
banner_desc_fa=@banner_desc_fa,banner_desc_en=@banner_desc_en,banner_book_img=@banner_book_img,
sort_order=@sort_order,updated_at=datetime('now') WHERE slug=@slug
`).run({ slug, ...r });
} else {
db.prepare(`INSERT INTO radar_pages (slug,source_categories,label_fa,label_en,
latest_heading_fa,latest_heading_en,featured_title_fa,featured_title_en,
featured_date_fa,featured_date_en,featured_img,banner_kicker_fa,banner_kicker_en,
banner_title_fa,banner_title_en,banner_desc_fa,banner_desc_en,banner_book_img,sort_order)
VALUES (@slug,@source_categories,@label_fa,@label_en,@latest_heading_fa,@latest_heading_en,
@featured_title_fa,@featured_title_en,@featured_date_fa,@featured_date_en,@featured_img,
@banner_kicker_fa,@banner_kicker_en,@banner_title_fa,@banner_title_en,
@banner_desc_fa,@banner_desc_en,@banner_book_img,@sort_order)
`).run({ slug, ...r });
}
res.json(rowToRadarPage(db.prepare(`SELECT ${RADAR_PAGE_FIELDS} FROM radar_pages WHERE slug = ?`).get(slug)));
});
// ---------- factory reports ----------
const FACTORY_REPORT_FIELDS = `id, img, tag_fa, tag_en, title_fa, title_en, date_fa, date_en,
excerpt_fa, excerpt_en, scroll_dir, sort_order, created_at, updated_at`;
app.get('/api/factory-reports', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const rows = db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToFactoryReport));
});
app.get('/api/factory-reports/:id', (req, res) => {
const row = db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToFactoryReport(row));
});
function normalizeFactoryReportBody(b) {
return {
img: b.img || null,
tag_fa: b.tagFa || null, tag_en: b.tagEn || null,
title_fa: String(b.titleFa || '').trim(),
title_en: b.titleEn || null,
date_fa: b.dateFa || null, date_en: b.dateEn || null,
excerpt_fa: b.excerptFa || null, excerpt_en: b.excerptEn || null,
scroll_dir: ['left','right','up'].includes(b.scrollDir) ? b.scrollDir : 'up',
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/factory-reports', authRequired, (req, res) => {
const r = normalizeFactoryReportBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO factory_reports (id,img,tag_fa,tag_en,title_fa,title_en,date_fa,date_en,
excerpt_fa,excerpt_en,scroll_dir,sort_order)
VALUES (@id,@img,@tag_fa,@tag_en,@title_fa,@title_en,@date_fa,@date_en,
@excerpt_fa,@excerpt_en,@scroll_dir,@sort_order)
`).run({ id, ...r });
res.status(201).json(rowToFactoryReport(db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(id)));
});
app.put('/api/factory-reports/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM factory_reports WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const r = normalizeFactoryReportBody(req.body || {});
if (!r.title_fa) return res.status(400).json({ error: 'title_fa_required' });
db.prepare(`UPDATE factory_reports SET img=@img,tag_fa=@tag_fa,tag_en=@tag_en,title_fa=@title_fa,title_en=@title_en,
date_fa=@date_fa,date_en=@date_en,excerpt_fa=@excerpt_fa,excerpt_en=@excerpt_en,
scroll_dir=@scroll_dir,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...r });
res.json(rowToFactoryReport(db.prepare(`SELECT ${FACTORY_REPORT_FIELDS} FROM factory_reports WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/factory-reports/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM factory_reports WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- integrations ----------
const INTEGRATION_FIELDS = `id, name, icon, logo, accent, grid_col, grid_row, sort_order, created_at, updated_at`;
const INTEGRATION_ICONS = ['exchange','globe','chart','activity','database','newspaper','building','trending','coins'];
app.get('/api/integrations', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200);
const rows = db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToIntegration));
});
app.get('/api/integrations/:id', (req, res) => {
const row = db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToIntegration(row));
});
function normalizeIntegrationBody(b) {
return {
name: String(b.name || '').trim(),
icon: INTEGRATION_ICONS.includes(b.icon) ? b.icon : 'globe',
logo: b.logo || null,
accent: b.accent || null,
grid_col: Number(b.gridCol) || 1,
grid_row: Number(b.gridRow) || 1,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/integrations', authRequired, (req, res) => {
const it = normalizeIntegrationBody(req.body || {});
if (!it.name) return res.status(400).json({ error: 'name_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO integrations (id,name,icon,logo,accent,grid_col,grid_row,sort_order)
VALUES (@id,@name,@icon,@logo,@accent,@grid_col,@grid_row,@sort_order)
`).run({ id, ...it });
res.status(201).json(rowToIntegration(db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(id)));
});
app.put('/api/integrations/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM integrations WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const it = normalizeIntegrationBody(req.body || {});
if (!it.name) return res.status(400).json({ error: 'name_required' });
db.prepare(`UPDATE integrations SET name=@name,icon=@icon,logo=@logo,accent=@accent,
grid_col=@grid_col,grid_row=@grid_row,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...it });
res.json(rowToIntegration(db.prepare(`SELECT ${INTEGRATION_FIELDS} FROM integrations WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/integrations/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM integrations WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- institute stats ----------
const INSTITUTE_STATS_FIELDS = `id,label_fa,label_en,value,suffix_fa,sort_order,created_at,updated_at`;
app.get('/api/institute-stats', (_req, res) => {
res.json(db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats ORDER BY sort_order ASC`).all().map(rowToInstituteStat));
});
app.get('/api/institute-stats/:id', (req, res) => {
const row = db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToInstituteStat(row));
});
function normalizeInstituteStatBody(b) {
return {
label_fa: String(b.labelFa || '').trim(),
label_en: b.labelEn || null,
value: Number(b.value) || 0,
suffix_fa: b.suffixFa || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/institute-stats', authRequired, (req, res) => {
const s = normalizeInstituteStatBody(req.body || {});
if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' });
const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14);
db.prepare(`INSERT INTO institute_stats (id,label_fa,label_en,value,suffix_fa,sort_order)
VALUES (@id,@label_fa,@label_en,@value,@suffix_fa,@sort_order)
`).run({ id, ...s });
res.status(201).json(rowToInstituteStat(db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(id)));
});
app.put('/api/institute-stats/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM institute_stats WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const s = normalizeInstituteStatBody(req.body || {});
if (!s.label_fa) return res.status(400).json({ error: 'label_fa_required' });
db.prepare(`UPDATE institute_stats SET label_fa=@label_fa,label_en=@label_en,value=@value,
suffix_fa=@suffix_fa,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...s });
res.json(rowToInstituteStat(db.prepare(`SELECT ${INSTITUTE_STATS_FIELDS} FROM institute_stats WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/institute-stats/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM institute_stats WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- market prices ----------
const MARKET_PRICE_FIELDS = `id,name,value,unit,change,change_percent,trend,sort_order,created_at,updated_at`;
app.get('/api/market-prices', (_req, res) => {
res.json(db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices ORDER BY sort_order ASC`).all().map(rowToMarketPrice));
});
app.get('/api/market-prices/:id', (req, res) => {
const row = db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToMarketPrice(row));
});
function normalizeMarketPriceBody(b) {
return {
name: String(b.name || '').trim(),
value: Number(b.value) || 0,
unit: b.unit || null,
change: Number(b.change) || 0,
change_percent: Number(b.changePercent) || 0,
trend: ['up','down','flat'].includes(b.trend) ? b.trend : 'flat',
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/market-prices', authRequired, (req, res) => {
const p = normalizeMarketPriceBody(req.body || {});
if (!p.name) return res.status(400).json({ error: 'name_required' });
const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14);
db.prepare(`INSERT INTO market_prices (id,name,value,unit,change,change_percent,trend,sort_order)
VALUES (@id,@name,@value,@unit,@change,@change_percent,@trend,@sort_order)
`).run({ id, ...p });
res.status(201).json(rowToMarketPrice(db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(id)));
});
app.put('/api/market-prices/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM market_prices WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const p = normalizeMarketPriceBody(req.body || {});
if (!p.name) return res.status(400).json({ error: 'name_required' });
db.prepare(`UPDATE market_prices SET name=@name,value=@value,unit=@unit,change=@change,
change_percent=@change_percent,trend=@trend,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...p });
res.json(rowToMarketPrice(db.prepare(`SELECT ${MARKET_PRICE_FIELDS} FROM market_prices WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/market-prices/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM market_prices WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- market chart points (history | export | comparison) ----------
const CHART_POINT_FIELDS = `id,series,label,value,meta,sort_order,created_at,updated_at`;
const ALLOWED_SERIES = new Set(['history','export','comparison']);
app.get('/api/market-chart-points', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 200, 1000);
const series = ALLOWED_SERIES.has(req.query.series) ? req.query.series : null;
let q = `SELECT ${CHART_POINT_FIELDS} FROM market_chart_points`;
const params = [];
if (series) { q += ' WHERE series = ?'; params.push(series); }
q += ' ORDER BY series ASC, sort_order ASC LIMIT ?';
params.push(limit);
res.json(db.prepare(q).all(...params).map(rowToMarketChartPoint));
});
app.get('/api/market-chart-points/:id', (req, res) => {
const row = db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToMarketChartPoint(row));
});
function normalizeChartPointBody(b) {
let meta = null;
if (b.meta != null && b.meta !== '') {
meta = typeof b.meta === 'string' ? b.meta : JSON.stringify(b.meta);
}
return {
series: ALLOWED_SERIES.has(b.series) ? b.series : null,
label: String(b.label || '').trim(),
value: Number(b.value) || 0,
meta,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/market-chart-points', authRequired, (req, res) => {
const c = normalizeChartPointBody(req.body || {});
if (!c.series) return res.status(400).json({ error: 'series_required' });
if (!c.label) return res.status(400).json({ error: 'label_required' });
const id = (req.body && req.body.id ? String(req.body.id).trim() : '') || nanoid(14);
db.prepare(`INSERT INTO market_chart_points (id,series,label,value,meta,sort_order)
VALUES (@id,@series,@label,@value,@meta,@sort_order)
`).run({ id, ...c });
res.status(201).json(rowToMarketChartPoint(db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(id)));
});
app.put('/api/market-chart-points/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM market_chart_points WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const c = normalizeChartPointBody(req.body || {});
if (!c.series) return res.status(400).json({ error: 'series_required' });
if (!c.label) return res.status(400).json({ error: 'label_required' });
db.prepare(`UPDATE market_chart_points SET series=@series,label=@label,value=@value,meta=@meta,
sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...c });
res.json(rowToMarketChartPoint(db.prepare(`SELECT ${CHART_POINT_FIELDS} FROM market_chart_points WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/market-chart-points/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM market_chart_points WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- vision items (About page) ----------
const VISION_FIELDS = `id, icon, title_fa, title_en, desc_fa, desc_en, sort_order, created_at, updated_at`;
app.get('/api/vision-items', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const rows = db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToVisionItem));
});
app.get('/api/vision-items/:id', (req, res) => {
const row = db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToVisionItem(row));
});
function normalizeVisionBody(b) {
return {
icon: b.icon || null,
title_fa: String(b.titleFa || '').trim(),
title_en: b.titleEn || null,
desc_fa: b.descFa || null,
desc_en: b.descEn || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/vision-items', authRequired, (req, res) => {
const v = normalizeVisionBody(req.body || {});
if (!v.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO vision_items (id,icon,title_fa,title_en,desc_fa,desc_en,sort_order)
VALUES (@id,@icon,@title_fa,@title_en,@desc_fa,@desc_en,@sort_order)
`).run({ id, ...v });
res.status(201).json(rowToVisionItem(db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(id)));
});
app.put('/api/vision-items/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM vision_items WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const v = normalizeVisionBody(req.body || {});
if (!v.title_fa) return res.status(400).json({ error: 'title_fa_required' });
db.prepare(`UPDATE vision_items SET icon=@icon,title_fa=@title_fa,title_en=@title_en,
desc_fa=@desc_fa,desc_en=@desc_en,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...v });
res.json(rowToVisionItem(db.prepare(`SELECT ${VISION_FIELDS} FROM vision_items WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/vision-items/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM vision_items WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- advisory board (About page) ----------
const ADVISORY_FIELDS = `id, name_fa, name_en, role_fa, role_en, sort_order, created_at, updated_at`;
app.get('/api/advisory-board', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const rows = db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board ORDER BY sort_order ASC, created_at DESC LIMIT ?`).all(limit);
res.json(rows.map(rowToAdvisoryMember));
});
app.get('/api/advisory-board/:id', (req, res) => {
const row = db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToAdvisoryMember(row));
});
function normalizeAdvisoryBody(b) {
return {
name_fa: String(b.nameFa || '').trim(),
name_en: b.nameEn || null,
role_fa: b.roleFa || null,
role_en: b.roleEn || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/advisory-board', authRequired, (req, res) => {
const m = normalizeAdvisoryBody(req.body || {});
if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO advisory_board (id,name_fa,name_en,role_fa,role_en,sort_order)
VALUES (@id,@name_fa,@name_en,@role_fa,@role_en,@sort_order)
`).run({ id, ...m });
res.status(201).json(rowToAdvisoryMember(db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(id)));
});
app.put('/api/advisory-board/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM advisory_board WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const m = normalizeAdvisoryBody(req.body || {});
if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
db.prepare(`UPDATE advisory_board SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa,
role_en=@role_en,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...m });
res.json(rowToAdvisoryMember(db.prepare(`SELECT ${ADVISORY_FIELDS} FROM advisory_board WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/advisory-board/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM advisory_board WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- team members & experts ----------
const TEAM_FIELDS = `id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo,
report_count,is_expert,expert_room_fa,expert_room_en,telegram,linkedin,twitter,website,sort_order,created_at,updated_at`;
app.get('/api/team', (req, res) => {
const isExpert = req.query.expert === '1' ? 1 : req.query.expert === '0' ? 0 : null;
let query = `SELECT ${TEAM_FIELDS} FROM team_members`;
if (isExpert !== null) query += ` WHERE is_expert = ${isExpert}`;
query += ' ORDER BY sort_order ASC, created_at DESC';
res.json(db.prepare(query).all().map(rowToTeamMember));
});
app.get('/api/team/:id', (req, res) => {
const row = db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToTeamMember(row));
});
function normalizeTeamBody(b) {
return {
name_fa: String(b.nameFa || '').trim(),
name_en: b.nameEn || null,
role_fa: b.roleFa || null, role_en: b.roleEn || null,
bio_fa: b.bioFa || null, bio_en: b.bioEn || null,
expertise: JSON.stringify(Array.isArray(b.expertise) ? b.expertise : []),
email: b.email || null, initial: b.initial || null, photo: b.photo || null,
report_count: Number(b.reportCount) || 0,
is_expert: b.isExpert ? 1 : 0,
expert_room_fa: b.expertRoomFa || null, expert_room_en: b.expertRoomEn || null,
telegram: b.telegram || null, linkedin: b.linkedin || null,
twitter: b.twitter || null, website: b.website || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/team', authRequired, (req, res) => {
const m = normalizeTeamBody(req.body || {});
if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO team_members (id,name_fa,name_en,role_fa,role_en,bio_fa,bio_en,expertise,email,initial,photo,
report_count,is_expert,expert_room_fa,expert_room_en,telegram,linkedin,twitter,website,sort_order)
VALUES (@id,@name_fa,@name_en,@role_fa,@role_en,@bio_fa,@bio_en,@expertise,@email,@initial,@photo,
@report_count,@is_expert,@expert_room_fa,@expert_room_en,@telegram,@linkedin,@twitter,@website,@sort_order)
`).run({ id, ...m });
res.status(201).json(rowToTeamMember(db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(id)));
});
app.put('/api/team/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM team_members WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const m = normalizeTeamBody(req.body || {});
if (!m.name_fa) return res.status(400).json({ error: 'name_fa_required' });
db.prepare(`UPDATE team_members SET name_fa=@name_fa,name_en=@name_en,role_fa=@role_fa,role_en=@role_en,
bio_fa=@bio_fa,bio_en=@bio_en,expertise=@expertise,email=@email,initial=@initial,photo=@photo,
report_count=@report_count,is_expert=@is_expert,expert_room_fa=@expert_room_fa,expert_room_en=@expert_room_en,
telegram=@telegram,linkedin=@linkedin,twitter=@twitter,website=@website,
sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...m });
res.json(rowToTeamMember(db.prepare(`SELECT ${TEAM_FIELDS} FROM team_members WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/team/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM team_members WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- plans ----------
const PLAN_FIELDS = `id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order,created_at,updated_at`;
app.get('/api/plans', (req, res) => {
res.json(db.prepare(`SELECT ${PLAN_FIELDS} FROM plans ORDER BY sort_order ASC`).all().map(rowToPlan));
});
app.get('/api/plans/:id', (req, res) => {
const row = db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToPlan(row));
});
function normalizePlanBody(b) {
return {
name_fa: String(b.nameFa || '').trim(),
name_en: b.nameEn || null,
price: Number(b.price) || 0,
period_fa: b.periodFa || null, period_en: b.periodEn || null,
features: JSON.stringify(Array.isArray(b.features) ? b.features : []),
badge_fa: b.badgeFa || null, badge_en: b.badgeEn || null,
is_featured: b.isFeatured ? 1 : 0,
cta_fa: b.ctaFa || null, cta_en: b.ctaEn || null,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/plans', authRequired, (req, res) => {
const p = normalizePlanBody(req.body || {});
if (!p.name_fa) return res.status(400).json({ error: 'name_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO plans (id,name_fa,name_en,price,period_fa,period_en,features,badge_fa,badge_en,is_featured,cta_fa,cta_en,sort_order)
VALUES (@id,@name_fa,@name_en,@price,@period_fa,@period_en,@features,@badge_fa,@badge_en,@is_featured,@cta_fa,@cta_en,@sort_order)
`).run({ id, ...p });
res.status(201).json(rowToPlan(db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(id)));
});
app.put('/api/plans/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM plans WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const p = normalizePlanBody(req.body || {});
if (!p.name_fa) return res.status(400).json({ error: 'name_fa_required' });
db.prepare(`UPDATE plans SET name_fa=@name_fa,name_en=@name_en,price=@price,period_fa=@period_fa,period_en=@period_en,
features=@features,badge_fa=@badge_fa,badge_en=@badge_en,is_featured=@is_featured,cta_fa=@cta_fa,cta_en=@cta_en,
sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...p });
res.json(rowToPlan(db.prepare(`SELECT ${PLAN_FIELDS} FROM plans WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/plans/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM plans WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- risk signals ----------
const RISK_FIELDS = `id, quote, name, date, level, sort_order, created_at, updated_at`;
app.get('/api/risks', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const rows = db
.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals ORDER BY sort_order ASC, created_at DESC LIMIT ?`)
.all(limit);
res.json(rows.map(rowToRiskSignal));
});
app.get('/api/risks/:id', (req, res) => {
const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToRiskSignal(row));
});
const ALLOWED_LEVELS = new Set(['critical', 'high', 'medium', 'low', 'opportunity']);
function normalizeRiskBody(b) {
const level = ALLOWED_LEVELS.has(b.level) ? b.level : null;
return {
quote: String(b.quote || '').trim(),
name: String(b.name || '').trim(),
date: b.date || null,
level,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/risks', authRequired, (req, res) => {
const r = normalizeRiskBody(req.body || {});
if (!r.quote || !r.name || !r.level) return res.status(400).json({ error: 'quote_name_level_required' });
const id = nanoid(14);
db.prepare(`
INSERT INTO risk_signals (id, quote, name, date, level, sort_order)
VALUES (@id, @quote, @name, @date, @level, @sort_order)
`).run({ id, ...r });
const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(id);
res.status(201).json(rowToRiskSignal(row));
});
app.put('/api/risks/:id', authRequired, (req, res) => {
const existing = db.prepare('SELECT id FROM risk_signals WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'not_found' });
const r = normalizeRiskBody(req.body || {});
if (!r.quote || !r.name || !r.level) return res.status(400).json({ error: 'quote_name_level_required' });
db.prepare(`
UPDATE risk_signals SET
quote = @quote, name = @name, date = @date, level = @level,
sort_order = @sort_order, updated_at = datetime('now')
WHERE id = @id
`).run({ id: req.params.id, ...r });
const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id);
res.json(rowToRiskSignal(row));
});
app.delete('/api/risks/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM risk_signals WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- users (admin management) ----------
// password_hash is NEVER returned to the client.
app.get('/api/users', authRequired, (_req, res) => {
const rows = db.prepare('SELECT id, username, created_at FROM users ORDER BY id').all();
res.json(rows.map((r) => ({ id: r.id, username: r.username, createdAt: r.created_at })));
});
app.post('/api/users', authRequired, (req, res) => {
const username = String(req.body?.username || '').trim();
const password = String(req.body?.password || '');
if (username.length < 3) return res.status(400).json({ error: 'username_too_short' });
if (password.length < 8) return res.status(400).json({ error: 'password_too_short' });
const exists = db.prepare('SELECT 1 FROM users WHERE username = ?').get(username);
if (exists) return res.status(409).json({ error: 'username_taken' });
const hash = bcrypt.hashSync(password, 10);
const info = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(username, hash);
res.status(201).json({ id: info.lastInsertRowid, username });
});
app.put('/api/users/:id', authRequired, (req, res) => {
const id = Number(req.params.id);
const row = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'not_found' });
const username = req.body?.username != null ? String(req.body.username).trim() : row.username;
const password = req.body?.password != null ? String(req.body.password) : '';
if (username.length < 3) return res.status(400).json({ error: 'username_too_short' });
if (username !== row.username) {
const taken = db.prepare('SELECT 1 FROM users WHERE username = ? AND id != ?').get(username, id);
if (taken) return res.status(409).json({ error: 'username_taken' });
}
if (password) {
if (password.length < 8) return res.status(400).json({ error: 'password_too_short' });
const hash = bcrypt.hashSync(password, 10);
db.prepare('UPDATE users SET username = ?, password_hash = ? WHERE id = ?').run(username, hash, id);
} else {
db.prepare('UPDATE users SET username = ? WHERE id = ?').run(username, id);
}
res.json({ id, username });
});
app.delete('/api/users/:id', authRequired, (req, res) => {
const id = Number(req.params.id);
if (req.user?.sub === id) return res.status(400).json({ error: 'cannot_delete_self' });
const count = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
if (count <= 1) return res.status(400).json({ error: 'cannot_delete_last_user' });
const info = db.prepare('DELETE FROM users WHERE id = ?').run(id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- prices (scraped from tgju.org every 2 min) ----------
app.get('/api/prices', (_req, res) => {
const rows = db
.prepare('SELECT symbol, name, value, unit, change_value, change_pct, source_url, fetched_at FROM prices ORDER BY name')
.all();
res.json(rows.map(rowToPrice));
});
app.post('/api/prices/refresh', authRequired, async (_req, res) => {
try {
const n = await scrapeOnce();
res.json({ ok: true, scraped: n });
} catch (err) {
console.error('[scraper] refresh error:', err);
res.status(500).json({ error: 'scrape_failed' });
}
});
// ---------- banners ----------
const BANNER_FIELDS = `id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order,created_at,updated_at`;
app.get('/api/banners', (req, res) => {
const onlyActive = req.query.active === '1';
let q = `SELECT ${BANNER_FIELDS} FROM banners`;
if (onlyActive) q += ' WHERE active = 1';
q += ' ORDER BY sort_order ASC, created_at DESC';
res.json(db.prepare(q).all().map(rowToBanner));
});
app.get('/api/banners/:id', (req, res) => {
const row = db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToBanner(row));
});
function normalizeBannerBody(b) {
return {
overline_fa: b.overlineFa || null,
overline_en: b.overlineEn || null,
title_fa: String(b.titleFa || '').trim(),
title_en: b.titleEn || null,
subtitle_fa: b.subtitleFa || null,
subtitle_en: b.subtitleEn || null,
cta_label_fa: b.ctaLabelFa || null,
cta_label_en: b.ctaLabelEn || null,
cta_url: b.ctaUrl || null,
image: b.image || null,
position: b.position || 'hero',
active: b.active !== false ? 1 : 0,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/banners', authRequired, (req, res) => {
const bn = normalizeBannerBody(req.body || {});
if (!bn.title_fa) return res.status(400).json({ error: 'title_fa_required' });
const id = nanoid(14);
db.prepare(`INSERT INTO banners (id,overline_fa,overline_en,title_fa,title_en,subtitle_fa,subtitle_en,cta_label_fa,cta_label_en,cta_url,image,position,active,sort_order)
VALUES (@id,@overline_fa,@overline_en,@title_fa,@title_en,@subtitle_fa,@subtitle_en,@cta_label_fa,@cta_label_en,@cta_url,@image,@position,@active,@sort_order)
`).run({ id, ...bn });
res.status(201).json(rowToBanner(db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(id)));
});
app.put('/api/banners/:id', authRequired, (req, res) => {
if (!db.prepare('SELECT id FROM banners WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'not_found' });
const bn = normalizeBannerBody(req.body || {});
if (!bn.title_fa) return res.status(400).json({ error: 'title_fa_required' });
db.prepare(`UPDATE banners SET overline_fa=@overline_fa,overline_en=@overline_en,title_fa=@title_fa,title_en=@title_en,subtitle_fa=@subtitle_fa,subtitle_en=@subtitle_en,
cta_label_fa=@cta_label_fa,cta_label_en=@cta_label_en,cta_url=@cta_url,image=@image,
position=@position,active=@active,sort_order=@sort_order,updated_at=datetime('now') WHERE id=@id
`).run({ id: req.params.id, ...bn });
res.json(rowToBanner(db.prepare(`SELECT ${BANNER_FIELDS} FROM banners WHERE id = ?`).get(req.params.id)));
});
app.delete('/api/banners/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM banners WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
app.get('/api/health', (_req, res) => res.json({ ok: true }));
/* ── Contact form → email ─────────────────────────────── */
import nodemailer from 'nodemailer';
function escHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
app.post('/api/contact', contactLimiter, async (req, res) => {
const { name, phone, email } = req.body || {};
if (!name || !phone) return res.status(400).json({ error: 'name and phone required' });
const mailTo = process.env.MAIL_TO || 'alirezaameria2@gmail.com, mtdemne@gmail.com';
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS },
});
try {
await transporter.sendMail({
from: process.env.MAIL_USER,
to: mailTo,
subject: `درخواست دسترسی نبض صنعت — ${escHtml(name)}`,
html: `<h2>درخواست دسترسی — نبض صنعت</h2>
<p><strong>نام:</strong> ${escHtml(name)}</p>
<p><strong>شماره همراه:</strong> ${escHtml(phone)}</p>
<p><strong>ایمیل:</strong> ${escHtml(email) || '—'}</p>`,
});
res.json({ ok: true });
} catch (err) {
console.error('Mail error:', err.message);
res.status(500).json({ error: 'mail failed' });
}
});
// ---------- PDF analyser ----------
const SECTION_KEYWORDS = {
market: {
words: ['بازار','قیمت','صادرات','واردات','معامله','تجارت','فولاد','آهن','سنگ آهن','بلوم','تختال','کویل','عرضه','تقاضا','تولید','اقتصاد','market','price','trade','export','import','steel','iron ore','billet','slab','coil','supply','demand','production'],
label: 'بازار و زنجیره فولاد',
},
technology: {
words: ['فناوری','هوش مصنوعی','دیجیتال','اتوماسیون','نرم‌افزار','ربات','هوشمند','نوآوری','تکنولوژی','داده','technology','ai','digital','automation','software','robot','smart','innovation','iot','machine learning'],
label: 'فناوری صنعتی',
},
geopolitics: {
words: ['ژئوپلیتیک','تحریم','سیاست','دیپلماسی','جنگ','تنش','بین‌الملل','ژئواکونومی','قدرت','geopolitics','sanction','policy','diplomacy','war','tension','international','brics','conflict'],
label: 'ژئواکونومی',
},
sustainability: {
words: ['محیط زیست','کربن','پایداری','سبز','آلودگی','اقلیم','انرژی پاک','انتشار','تجدیدپذیر','esg','carbon','sustainability','environment','green','climate','emission','renewable','clean energy'],
label: 'پایداری محیطی',
},
foresight: {
words: ['آینده','سناریو','پیش‌بینی','روند','چشم‌انداز','آینده‌نگاری','استراتژی','رصد','تحلیل','future','scenario','forecast','trend','outlook','foresight','strategy','horizon','intelligence'],
label: 'آینده‌نگاری',
},
};
function classifyPdfText(text) {
const lower = text.toLowerCase();
const scores = {};
for (const [cat, data] of Object.entries(SECTION_KEYWORDS)) {
let score = 0; const matched = [];
for (const kw of data.words) {
const hits = (lower.match(new RegExp(kw.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
if (hits > 0) { score += hits; matched.push(kw); }
}
scores[cat] = { score, matched, label: data.label };
}
const sorted = Object.entries(scores).sort((a, b) => b[1].score - a[1].score);
const [topCat, topData] = sorted[0];
const secondScore = sorted[1]?.[1].score || 0;
const confidence = topData.score > 0 ? Math.min(100, Math.round(((topData.score - secondScore) / (topData.score + 1)) * 100 + 40)) : 0;
return {
category: topCat, label: topData.label, score: topData.score, confidence,
keywords: [...new Set(topData.matched)].slice(0, 8),
allScores: Object.fromEntries(sorted.map(([c, d]) => [c, { score: d.score, label: d.label }])),
};
}
function extractPdfTitle(text) {
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 8 && l.length < 180);
return lines[0] || 'بدون عنوان';
}
app.post('/api/pdf-analyze', authRequired, async (req, res) => {
try {
const { base64 } = req.body || {};
if (!base64) return res.status(400).json({ error: 'فایل PDF ارسال نشده' });
const buf = Buffer.from(base64, 'base64');
const { PDFParse } = await import('pdf-parse');
const parser = new PDFParse({ data: buf });
const result = await parser.getText();
const text = result.text || '';
const pageCount = result.total || result.pages?.length || 0;
await parser.destroy();
// detect garbled text: check ratio of Persian/Arabic Unicode chars
const persianChars = (text.match(/[؀-ۿ]/g) || []).length;
const meaningfulChars = text.replace(/\s/g, '').length;
const isGarbled = meaningfulChars > 50 && (persianChars / meaningfulChars) < 0.15;
const title = isGarbled ? '' : extractPdfTitle(text);
const excerpt = isGarbled ? '' : text.replace(/\n{3,}/g, '\n\n').trim().slice(0, 800);
const classification = isGarbled ? null : classifyPdfText(text);
res.json({ title, excerpt, text_length: text.length, page_count: pageCount, classification, is_garbled: isGarbled });
} catch (err) {
console.error('PDF parse error:', err);
res.status(500).json({ error: 'خطا در تجزیه PDF: ' + err.message });
}
});
app.post('/api/pdf-generate', authRequired, async (req, res) => {
const apiKey = process.env.AI_API_KEY || process.env.ANTHROPIC_API_KEY;
if (!apiKey) return res.status(503).json({ error: 'AI_API_KEY در فایل .env تنظیم نشده' });
const { text, category } = req.body || {};
if (!text || text.trim().length < 50) return res.status(400).json({ error: 'متن کافی ارسال نشده' });
const prompt = `متن زیر از یک گزارش تخصصی در حوزه صنعت فولاد است.
بر اساس این متن، یک مقاله جامع و تحلیلی به زبان فارسی بنویس که شامل این بخش‌ها باشد:
۱. **عنوان** (یک عنوان جذاب و حرفه‌ای)
۲. **خلاصه اجرایی** (۲ تا ۳ پاراگراف — مهم‌ترین یافته‌ها)
۳. **نکات کلیدی** (۴ تا ۶ گلوله — bullet points)
۴. **تحلیل تفصیلی** (۳ تا ۵ پاراگراف — بررسی عمیق)
۵. **نتیجه‌گیری** (۱ تا ۲ پاراگراف)
خروجی باید کاملاً فارسی، حرفه‌ای، و مناسب برای یک اندیشکده تخصصی باشد.
${category ? `حوزه: ${category}` : ''}
متن منبع:
---
${text.slice(0, 6000)}
---
فقط محتوای مقاله را بنویس، بدون توضیح اضافه.`;
try {
const { default: OpenAI } = await import('openai');
const clientOpts = { apiKey };
if (process.env.AI_BASE_URL) clientOpts.baseURL = process.env.AI_BASE_URL;
const client = new OpenAI(clientOpts);
const model = process.env.AI_MODEL || 'gpt-4o-mini';
const completion = await client.chat.completions.create({
model,
max_tokens: 2048,
messages: [{ role: 'user', content: prompt }],
});
const generated = completion.choices[0]?.message?.content || '';
res.json({ generated });
} catch (err) {
console.error('AI generate error:', err);
res.status(500).json({ error: 'خطا در تولید محتوا: ' + err.message });
}
});
// ──────────────────────────────────────────
// Member auth & profile (public users, not admins)
// ──────────────────────────────────────────
app.post('/api/members/register', loginLimiter, (req, res) => {
const { name, email, password } = req.body || {};
if (!name || !email || !password) return res.status(400).json({ error: 'همه فیلدها الزامی است' });
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ error: 'ایمیل نامعتبر است' });
if (password.length < 6) return res.status(400).json({ error: 'رمز عبور باید حداقل ۶ کاراکتر باشد' });
const exists = db.prepare('SELECT 1 FROM members WHERE email = ?').get(email);
if (exists) return res.status(409).json({ error: 'این ایمیل قبلاً ثبت شده است' });
const hash = bcrypt.hashSync(password, 10);
const info = db.prepare('INSERT INTO members (name, email, password_hash) VALUES (?, ?, ?)').run(name.trim(), email.toLowerCase().trim(), hash);
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
res.status(201).json({ token, user: { id: info.lastInsertRowid, name: name.trim(), email } });
});
app.post('/api/members/login', loginLimiter, (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).json({ error: 'ایمیل و رمز عبور الزامی است' });
const row = db.prepare('SELECT id, name, email, password_hash, is_active FROM members WHERE email = ?').get(email.toLowerCase().trim());
if (!row || !bcrypt.compareSync(password, row.password_hash)) return res.status(401).json({ error: 'ایمیل یا رمز عبور اشتباه است' });
if (!row.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
const token = jwt.sign({ sub: row.id, email: row.email, name: row.name, role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
res.json({ token, user: { id: row.id, name: row.name, email: row.email } });
});
app.get('/api/members/me', memberRequired, (req, res) => {
const row = db.prepare('SELECT id, name, email, phone, avatar, created_at FROM members WHERE id = ?').get(req.member.sub);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json({ id: row.id, name: row.name, email: row.email, phone: row.phone, avatar: row.avatar, createdAt: row.created_at });
});
app.put('/api/members/me', memberRequired, (req, res) => {
const { name, phone } = req.body || {};
const row = db.prepare('SELECT id FROM members WHERE id = ?').get(req.member.sub);
if (!row) return res.status(404).json({ error: 'not_found' });
const newName = name?.trim() || req.member.name;
db.prepare("UPDATE members SET name = ?, phone = ?, updated_at = datetime('now') WHERE id = ?").run(newName, phone || null, req.member.sub);
res.json({ ok: true });
});
app.put('/api/members/me/password', memberRequired, (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'فیلدها الزامی است' });
if (newPassword.length < 6) return res.status(400).json({ error: 'رمز جدید باید حداقل ۶ کاراکتر باشد' });
const row = db.prepare('SELECT password_hash FROM members WHERE id = ?').get(req.member.sub);
if (!row || !bcrypt.compareSync(currentPassword, row.password_hash)) return res.status(401).json({ error: 'رمز فعلی اشتباه است' });
const hash = bcrypt.hashSync(newPassword, 10);
db.prepare("UPDATE members SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.member.sub);
res.json({ ok: true });
});
app.get('/api/members/me/purchases', memberRequired, (req, res) => {
const rows = db.prepare(`
SELECT p.id, p.amount, p.purchased_at,
a.id AS article_id, a.title, a.category, a.type, a.cover_image, a.pages, a.summary, a.author, a.publish_date
FROM purchases p JOIN articles a ON p.article_id = a.id
WHERE p.member_id = ?
ORDER BY p.purchased_at DESC
`).all(req.member.sub);
res.json(rows.map(r => ({
id: r.id, amount: r.amount, purchasedAt: r.purchased_at,
article: { id: r.article_id, title: r.title, category: r.category, type: r.type, coverImage: r.cover_image, pages: r.pages, summary: r.summary, author: r.author, publishDate: r.publish_date },
})));
});
// admin: grant purchase to a member
app.post('/api/members/:memberId/purchases', authRequired, (req, res) => {
const { articleId, amount = 0 } = req.body || {};
if (!articleId) return res.status(400).json({ error: 'articleId الزامی است' });
const member = db.prepare('SELECT id FROM members WHERE id = ?').get(req.params.memberId);
if (!member) return res.status(404).json({ error: 'عضو پیدا نشد' });
const article = db.prepare('SELECT id, cover_image FROM articles WHERE id = ?').get(articleId);
if (!article) return res.status(404).json({ error: 'مقاله پیدا نشد' });
try {
db.prepare('INSERT OR IGNORE INTO purchases (member_id, article_id, amount) VALUES (?, ?, ?)').run(req.params.memberId, articleId, amount);
res.status(201).json({ ok: true });
} catch (e) {
res.status(409).json({ error: 'قبلاً خریداری شده' });
}
});
// download purchased article file (cover image for now; swap for PDF path later)
app.get('/api/members/me/purchases/:purchaseId/download', memberRequired, (req, res) => {
const row = db.prepare(`
SELECT a.cover_image, a.title FROM purchases p JOIN articles a ON p.article_id = a.id
WHERE p.id = ? AND p.member_id = ?
`).get(req.params.purchaseId, req.member.sub);
if (!row) return res.status(404).json({ error: 'خرید پیدا نشد' });
const filePath = row.cover_image ? path.join(__dirname, 'uploads', path.basename(row.cover_image)) : null;
if (!filePath || !fs.existsSync(filePath)) return res.status(404).json({ error: 'فایل موجود نیست' });
res.download(filePath, row.title + path.extname(filePath));
});
app.listen(PORT, () => {
console.log(`Panel running at ${PUBLIC_ORIGIN}`);
const intervalMs = Number(process.env.SCRAPER_INTERVAL_MS) || 2 * 60 * 1000;
if (process.env.SCRAPER_DISABLED !== '1') {
startScraperLoop(intervalMs);
console.log(`[scraper] tgju.org polling every ${intervalMs / 1000}s`);
} else {
console.log('[scraper] disabled via SCRAPER_DISABLED=1');
}
});