1193 lines
54 KiB
JavaScript
1193 lines
54 KiB
JavaScript
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: '2mb' }));
|
|
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) {
|
|
const token = req.cookies?.session;
|
|
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' });
|
|
}
|
|
}
|
|
|
|
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 } });
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
// ---------- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
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');
|
|
}
|
|
});
|