Files
topsysops-app/backend/src/routes/public.js
T
ogrechkoandClaude Sonnet 5 130c5382cd Initial commit: TopSysOps site + admin panel
Multi-page public site (Next.js), Node/Express backend with Postgres,
and Vite admin/account app, restructured from a single-page layout
into dedicated service, promotions, cases, about, FAQ, and blog pages
for SEO.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013otXCiTZsxdZ4SJKZ9wUky
2026-08-25 23:12:16 +03:00

110 lines
3.6 KiB
JavaScript

const express = require('express');
const rateLimit = require('express-rate-limit');
const pool = require('../db');
const router = express.Router();
router.get('/services', async (req, res) => {
const { rows: categories } = await pool.query(
'SELECT id, slug, title, description, icon FROM service_categories ORDER BY sort_order, id'
);
const { rows: items } = await pool.query(
'SELECT id, category_id, name, price_text, period, note FROM service_items WHERE active = true ORDER BY sort_order, id'
);
const grouped = categories
.map((cat) => ({ ...cat, items: items.filter((it) => it.category_id === cat.id) }))
.filter((cat) => cat.items.length > 0);
res.json(grouped);
});
router.get('/promotions', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, title, price_text, old_price_text, description FROM promotions WHERE active = true ORDER BY sort_order, id'
);
res.json(rows);
});
router.get('/reviews', async (req, res) => {
const { rows } = await pool.query(
"SELECT id, name, role, rating, text, created_at FROM reviews WHERE status = 'approved' ORDER BY created_at DESC"
);
res.json(rows);
});
router.get('/cases', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, title, tag, status, tone, description FROM cases WHERE active = true ORDER BY sort_order, id'
);
res.json(rows);
});
router.get('/faqs', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, question, answer FROM faqs WHERE active = true ORDER BY sort_order, id'
);
res.json(rows);
});
router.get('/blog', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, slug, title, excerpt, published_at FROM blog_posts WHERE published = true ORDER BY published_at DESC'
);
res.json(rows);
});
router.get('/blog/:slug', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, slug, title, excerpt, content, published_at FROM blog_posts WHERE slug = $1 AND published = true',
[req.params.slug]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.get('/contact', async (req, res) => {
const { rows } = await pool.query('SELECT address, phone, email, hours, map_embed FROM contact_info WHERE id = 1');
res.json(rows[0] || { address: '', phone: '', email: '', hours: '', map_embed: '' });
});
const reviewLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many reviews submitted, try again later' },
});
router.post('/reviews', reviewLimiter, async (req, res) => {
const { name, role, rating, text, website } = req.body || {};
// honeypot field - real users never fill it in, bots often do
if (website) return res.status(201).json({ ok: true });
if (!name || !text || !rating) {
return res.status(400).json({ error: 'name, rating and text are required' });
}
const ratingNum = Number(rating);
if (!Number.isInteger(ratingNum) || ratingNum < 1 || ratingNum > 5) {
return res.status(400).json({ error: 'rating must be an integer from 1 to 5' });
}
await pool.query(
"INSERT INTO reviews (name, role, rating, text, status) VALUES ($1, $2, $3, $4, 'pending')",
[String(name).slice(0, 120), role ? String(role).slice(0, 160) : null, ratingNum, String(text).slice(0, 2000)]
);
res.status(201).json({ ok: true });
});
router.post('/stats/visit', async (req, res) => {
await pool.query(
`INSERT INTO visits (date, count) VALUES (CURRENT_DATE, 1)
ON CONFLICT (date) DO UPDATE SET count = visits.count + 1`
);
res.status(204).end();
});
module.exports = router;