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
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-25 23:12:16 +03:00
commit 130c5382cd
108 files changed
+7146

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
node_modules
npm-debug.log
.env
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --omit=dev
COPY . .
EXPOSE 4000
CMD ["node", "src/index.js"]
+55
View File
@@ -0,0 +1,55 @@
CREATE TABLE IF NOT EXISTS admin_users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS service_categories (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT DEFAULT '',
icon TEXT DEFAULT 'Server',
sort_order INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS service_items (
id SERIAL PRIMARY KEY,
category_id INT NOT NULL REFERENCES service_categories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
price_text TEXT NOT NULL,
period TEXT DEFAULT '',
note TEXT DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE IF NOT EXISTS promotions (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
price_text TEXT NOT NULL,
old_price_text TEXT,
description TEXT DEFAULT '',
active BOOLEAN NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
role TEXT,
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS visits (
date DATE PRIMARY KEY,
count INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_service_items_category ON service_items(category_id);
CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status);
+18
View File
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS cases (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
tag TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'в работе',
tone TEXT NOT NULL DEFAULT 'warn' CHECK (tone IN ('ok', 'warn', 'danger', 'neutral')),
description TEXT DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true
);
CREATE TABLE IF NOT EXISTS faqs (
id SERIAL PRIMARY KEY,
question TEXT NOT NULL,
answer TEXT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true
);
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
company TEXT,
phone TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS support_messages (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
sender TEXT NOT NULL CHECK (sender IN ('customer', 'staff')),
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_support_messages_customer ON support_messages(customer_id);
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS contact_info (
id INT PRIMARY KEY DEFAULT 1,
address TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
hours TEXT NOT NULL DEFAULT '',
map_embed TEXT NOT NULL DEFAULT '',
CHECK (id = 1)
);
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS blog_posts (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
excerpt TEXT DEFAULT '',
content TEXT NOT NULL DEFAULT '',
published BOOLEAN NOT NULL DEFAULT false,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_blog_posts_published ON blog_posts(published, published_at);
@@ -0,0 +1,55 @@
-- Обновление прайс-листа: убирает старую заглушку "Разработка сайтов" (от 5 000 ₽)
-- и добавляет новую линейку "Разработка под ключ" + "White-label и партнёрство".
-- Это настоящая миграция (не разовый скрипт) - применяется САМА при следующем
-- запуске бэкенда (docker compose up -d --build), вручную ничего запускать не нужно.
-- Безопасна к повторному запуску (WHERE NOT EXISTS), но система миграций и так
-- гарантирует, что этот файл выполнится только один раз.
BEGIN;
-- 1) Убираем старую заглушку "Разработка сайтов" (каскадно удалит её позицию "Лендинг")
DELETE FROM service_categories WHERE slug = 'web';
-- 2) Новая категория: Разработка под ключ
INSERT INTO service_categories (slug, title, description, icon, sort_order)
SELECT 'development', 'Разработка под ключ',
'От быстрого MVP до продакшн-разработки и сопровождения', 'Rocket',
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM service_categories)
WHERE NOT EXISTS (SELECT 1 FROM service_categories WHERE slug = 'development');
INSERT INTO service_items (category_id, name, price_text, period, note, sort_order)
SELECT (SELECT id FROM service_categories WHERE slug = 'development'),
v.name, v.price_text, v.period, v.note, v.sort_order
FROM (VALUES
('MVP «Быстрый старт»', 'от 120 000 ₽', '', 'Посадочная страница, форма/бот для заявок, простая админка - 2-3 недели', 0),
('MVP Pro', 'от 450 000 ₽', '', 'Личный кабинет с авторизацией, оплата, ИИ-ассистент - 4-6 недель', 1),
('Продакшн-разработка', 'от 1 200 000 ₽', '', 'Архитектура, тесты, DevOps, масштабирование - от 2 месяцев, точная смета после дискавери', 2),
('Discovery-воркшоп', 'от 30 000 ₽', '', 'Фиксация ТЗ и оценки перед стартом проекта - 1-2 дня', 3),
('Сопровождение проекта', 'от 40 000 ₽', '/мес', 'Ежемесячные доработки уже готовых продуктов', 4)
) AS v(name, price_text, period, note, sort_order)
WHERE NOT EXISTS (
SELECT 1 FROM service_items
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'development') AND name = v.name
);
-- 3) Новая категория: White-label и партнёрство
INSERT INTO service_categories (slug, title, description, icon, sort_order)
SELECT 'partners', 'White-label и партнёрство',
'Готовые решения для других компаний и программы сотрудничества', 'Handshake',
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM service_categories)
WHERE NOT EXISTS (SELECT 1 FROM service_categories WHERE slug = 'partners');
INSERT INTO service_items (category_id, name, price_text, period, note, sort_order)
SELECT (SELECT id FROM service_categories WHERE slug = 'partners'),
v.name, v.price_text, v.period, v.note, v.sort_order
FROM (VALUES
('White-label «Кабинет + ИИ-ассистент»', 'от 500 000 ₽', '', 'Личный кабинет с авторизацией и ИИ-чатом под брендом вашей компании', 0),
('IT-мониторинг сайта', 'от 10 000 ₽', '/мес', 'Аптайм, скорость загрузки, уведомления об инцидентах', 1),
('Реферальная программа', '10%', 'от суммы контракта', 'Бонус вам за рекомендацию нас новому клиенту', 2),
('Партнёрская программа', 'по договорённости', '', 'Комиссия для хостинг-провайдеров и поставщиков оборудования за рекомендации', 3)
) AS v(name, price_text, period, note, sort_order)
WHERE NOT EXISTS (
SELECT 1 FROM service_items
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'partners') AND name = v.name
);
COMMIT;
+20
View File
@@ -0,0 +1,20 @@
-- Разводим простую посадочную страницу и полноценный MVP по разным ценам:
-- "Лендинг" - без бота-формы и админки, просто сайт;
-- "MVP «Быстрый старт»" (добавлен в 006) - с ботом/формой заявок и админкой.
-- Автоматически применяется при следующем запуске бэкенда, вручную запускать
-- ничего не нужно. Безопасна к повторному запуску (WHERE NOT EXISTS).
INSERT INTO service_items (category_id, name, price_text, period, note, sort_order)
SELECT
(SELECT id FROM service_categories WHERE slug = 'development'),
'Лендинг',
'от 20 000 ₽',
'',
'Простая посадочная страница под продукт или услугу - без формы-бота и админки, 1 неделя',
-1
WHERE EXISTS (SELECT 1 FROM service_categories WHERE slug = 'development')
AND NOT EXISTS (
SELECT 1 FROM service_items
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'development')
AND name = 'Лендинг'
);
@@ -0,0 +1,38 @@
-- Разводит пересекающиеся предложения по почтовым серверам (акция vs услуга) -
-- явно показывает разницу в составе, и добавляет ориентировочные показатели
-- (сроки/% улучшения/объём) в описания кейсов, с пометкой, что цифры оценочные
-- и уточняются по каждому проекту отдельно.
-- Автоматически применяется при следующем запуске бэкенда, вручную запускать
-- ничего не нужно. UPDATE-ы безопасны к повторному запуску.
BEGIN;
-- 1) Услуги "Почтовый сервер" - уточняем, что НЕ входит (в отличие от акции)
UPDATE service_items
SET note = 'Развёртывание с нуля: установка, домены и почтовые ящики. Без SPF/DKIM/DMARC, антиспама и веб-почты - это в акции «Почтовый сервер под ключ»'
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'network')
AND name = 'Почтовый сервер (ваше оборудование)';
UPDATE service_items
SET note = 'Аренда сервера + ежемесячное администрирование почты. Разовая настройка SPF/DKIM/DMARC и антиспама - отдельно или по акции «Почтовый сервер под ключ»'
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'network')
AND name = 'Почтовый сервер (наше оборудование)';
-- 2) Акция "Почтовый сервер под ключ" - явно перечисляем полный состав пакета
UPDATE promotions
SET description = 'Разовая настройка на вашем оборудовании: SPF/DKIM/DMARC, антиспам, веб-почта и 3 месяца сопровождения - всё включено в стоимость по акционной цене'
WHERE title = 'Почтовый сервер под ключ';
-- 3) Кейсы - добавляем ориентировочные цифры (сроки/объём/% улучшения)
UPDATE cases
SET description = 'Перенесли серверы 12 магазинов на новое оборудование без остановки продаж за ~3 недели, снизили время отклика сети на ~30%* (*оценка, уточняется с клиентом по итогам проекта).'
WHERE title = 'Миграция инфраструктуры розничной сети';
UPDATE cases
SET description = 'Внедрили Telegram-бота с RAG на базе базы знаний компании за ~4 недели - снизили нагрузку на операторов поддержки на ~40%* по числу типовых обращений (*оценка, уточняется с клиентом).'
WHERE title = 'ИИ-ассистент для отдела поддержки';
UPDATE cases
SET description = 'Проводим комплексный аудит защищённости и внедряем систему мониторинга угроз 24/7, срок проекта - ориентировочно 6-8 недель* (*уточняется по ходу работ).'
WHERE title = 'Аудит кибербезопасности финтех-проекта';
COMMIT;
@@ -0,0 +1,140 @@
-- Первые статьи блога под конкретные коммерческие направления (IT-аутсорсинг,
-- почтовые серверы, кибербезопасность, ИИ-автоматизация). Автоматически
-- применяется при следующем запуске бэкенда, вручную запускать не нужно.
-- Безопасна к повторному запуску (ON CONFLICT DO NOTHING по slug).
BEGIN;
INSERT INTO blog_posts (slug, title, excerpt, content, published, published_at)
VALUES (
'it-autsorsing-kak-vybrat-podryadchika',
'Как выбрать IT-аутсорсинг для бизнеса: 7 критериев',
'На что смотреть при выборе подрядчика на IT-аутсорсинг, чтобы не переплатить и не остаться без поддержки в критический момент.',
$html$
<p>Передача IT-инфраструктуры на аутсорсинг снимает с бизнеса часть операционной нагрузки, но выбор подрядчика легко превращается в лотерею. Вот на что стоит смотреть до подписания договора.</p>
<h2>1. Время реакции на инциденты закреплено в договоре</h2>
<p>«Быстро отреагируем» - не показатель. Спрашивайте конкретное время реакции по каждому уровню критичности инцидента и убедитесь, что оно зафиксировано в SLA, а не устно.</p>
<h2>2. Есть разные тарифы под разную критичность бизнеса</h2>
<p>Интернет-магазину с пиковыми нагрузками нужна поддержка 24/7, а небольшому офису может хватить графика 5/2. Хороший подрядчик предлагает несколько уровней тарифа, а не один пакет «для всех».</p>
<h2>3. NDA и защищённый доступ к инфраструктуре - по умолчанию</h2>
<p>Подрядчик получает доступ к серверам, базам данных и переписке. Подписание NDA и использование защищённых каналов доступа (а не паролей в мессенджере) должно быть стандартной практикой, а не опцией по запросу.</p>
<h2>4. Прозрачная отчётность о состоянии инфраструктуры</h2>
<p>Вы должны понимать, что происходит с вашими серверами, даже не разбираясь в администрировании глубоко: сколько было инцидентов, что чинили, какие риски видят специалисты.</p>
<h2>5. Возможность разовых работ без абонемента</h2>
<p>Не всегда нужен полный абонемент - иногда достаточно разовой настройки сервера или аудита. Гибкость в этом вопросе часто говорит о зрелости подрядчика больше, чем маркетинговые обещания.</p>
<h2>6. Выезд специалиста - если работаете не полностью удалённо</h2>
<p>Даже при удалённой поддержке иногда нужен физический доступ к оборудованию: замена комплектующих, работа с сетевым железом. Уточняйте регион выезда и сроки реакции по выездным заявкам отдельно от удалённых.</p>
<h2>7. Референсы и живые кейсы, а не только логотипы клиентов</h2>
<p>Логотип на сайте ничего не доказывает. Спрашивайте, какие задачи решали для похожего на ваш бизнеса и какие результаты получили - сроки, объём работ, что изменилось.</p>
<p>Мы в TopSysOps работаем именно по такой модели: несколько тарифов IT-аутсорсинга от базовой удалённой поддержки до 24/7, NDA перед стартом и подробный <a href="/uslugi/support">прайс-лист на техподдержку</a>. Если сомневаетесь, с чего начать - <a href="/kontakty">напишите нам</a>, обсудим вашу инфраструктуру бесплатно.</p>
$html$,
true,
now()
)
ON CONFLICT (slug) DO NOTHING;
INSERT INTO blog_posts (slug, title, excerpt, content, published, published_at)
VALUES (
'nastroyka-pochtovogo-servera-spf-dkim-dmarc',
'Настройка корпоративной почты: SPF, DKIM, DMARC и защита от спама - чек-лист',
'Разбираем, зачем нужны SPF, DKIM и DMARC для корпоративной почты и как проверить, что ваш домен не помечают спамом.',
$html$
<p>Если письма от вашей компании попадают в спам или, того хуже, кто-то отправляет фишинг от имени вашего домена - вероятно, у почтового сервера не настроены базовые механизмы защиты. Разбираем, что это такое и зачем нужно.</p>
<h2>SPF - кто имеет право отправлять почту от вашего домена</h2>
<p>SPF (Sender Policy Framework) - это запись в DNS, которая перечисляет серверы, которым разрешено отправлять письма от имени вашего домена. Без неё любой сервер в мире технически может подделать письмо «от вас».</p>
<h2>DKIM - цифровая подпись письма</h2>
<p>DKIM добавляет к каждому письму криптографическую подпись, которую можно проверить на стороне получателя. Это подтверждает, что письмо не было изменено по дороге и действительно отправлено с вашего сервера.</p>
<h2>DMARC - что делать с письмами, которые не прошли проверку</h2>
<p>DMARC связывает SPF и DKIM и объясняет почтовым сервисам получателей, что делать с письмом, которое не прошло проверки: пропустить, отправить в спам или отклонить. Также DMARC присылает отчёты о том, кто пытается отправлять письма от вашего домена.</p>
<h2>Антиспам и веб-почта - вторая часть уравнения</h2>
<p>SPF/DKIM/DMARC защищают репутацию домена на исходящих письмах. Отдельно нужен антиспам-фильтр на входящих, чтобы сотрудники не разбирали десятки спам-писем вручную, и веб-почта для доступа к ящику из браузера без настройки почтового клиента на каждом устройстве.</p>
<h2>Как понять, что пора этим заняться</h2>
<ul>
<li>Письма клиентам иногда попадают в папку «Спам»</li>
<li>Были случаи фишинговых писем «от имени» вашей компании</li>
<li>Почтовый сервер настраивался разово несколько лет назад и с тех пор не пересматривался</li>
<li>Нет ни SPF, ни DKIM, ни DMARC записей в DNS вашего домена (это можно быстро проверить любым онлайн-чекером)</li>
</ul>
<p>У нас есть отдельная услуга по <a href="/uslugi/network">развёртыванию и администрированию почтовых серверов</a>, а на комплексную настройку с SPF/DKIM/DMARC, антиспамом, веб-почтой и тремя месяцами сопровождения действует акция «Почтовый сервер под ключ» - подробности на <a href="/aktsii">странице акций</a>.</p>
$html$,
true,
now()
)
ON CONFLICT (slug) DO NOTHING;
INSERT INTO blog_posts (slug, title, excerpt, content, published, published_at)
VALUES (
'kiberbezopasnost-malogo-biznesa-s-chego-nachat',
'Кибербезопасность малого и среднего бизнеса: с чего начать',
'Небольшой компании не нужен SOC на 20 человек - но базовый набор мер по кибербезопасности нужен любому бизнесу с серверами и клиентскими данными.',
$html$
<p>Кибербезопасность часто воспринимается как что-то нужное только банкам и крупным корпорациям. На практике небольшая компания - удобная мишень именно потому, что базовые меры защиты у неё, как правило, не настроены.</p>
<h2>1. Аудит - прежде чем что-то внедрять</h2>
<p>Нельзя защитить то, о чём не знаешь. Первый шаг - аудит текущей инфраструктуры: какие сервисы смотрят наружу, какие пароли и доступы используются, где хранятся резервные копии и кто имеет к ним доступ.</p>
<h2>2. Мониторинг вместо разовой проверки</h2>
<p>Аудит - это снимок на конкретный момент. Угрозы появляются постоянно, поэтому кроме разового аудита нужен регулярный мониторинг: логи, алерты о подозрительной активности, обновления уязвимого ПО.</p>
<h2>3. IDS/IPS - обнаружение и предотвращение вторжений</h2>
<p>Системы обнаружения (IDS) и предотвращения (IPS) вторжений отслеживают трафик на предмет подозрительных паттернов - сканирование портов, попытки перебора паролей, известные сигнатуры атак - и либо оповещают, либо блокируют их автоматически.</p>
<h2>4. Резервное копирование, отделённое от основной инфраструктуры</h2>
<p>Один из самых частых сценариев атаки - шифровальщик, который уничтожает и рабочие данные, и бэкапы, если они лежат в одном месте. Резервные копии должны быть изолированы и регулярно проверяться на возможность восстановления.</p>
<h2>5. NDA и разграничение доступов для подрядчиков</h2>
<p>Если с инфраструктурой работает внешняя команда (в том числе на аутсорсе), убедитесь, что доступы выданы по принципу минимально необходимых прав, а не «админ всем».</p>
<h2>С чего начать конкретно</h2>
<p>Если у вас пока нет ни одного из этих пунктов - начните с аудита. Он покажет, какие меры критичны прямо сейчас, а что можно внедрить постепенно. Мы проводим <a href="/uslugi/security">аудит и мониторинг кибербезопасности</a>, включая внедрение IDS/IPS - можно начать с разового аудита без обязательств по абонементу.</p>
$html$,
true,
now()
)
ON CONFLICT (slug) DO NOTHING;
INSERT INTO blog_posts (slug, title, excerpt, content, published, published_at)
VALUES (
'ii-assistent-dlya-biznesa-kak-vnedrit',
'ИИ-ассистент для бизнеса: как внедрить и не прогореть',
'ИИ-бот для поддержки клиентов или сотрудников - не «просто чат-бот». Разбираем, что действительно нужно для рабочего внедрения.',
$html$
<p>Спрос на «внедрить ИИ» часто формулируется расплывчато: «хотим бота, который отвечает на вопросы». В реальности между таким запросом и рабочим инструментом есть несколько важных шагов.</p>
<h2>RAG, а не просто обёртка над языковой моделью</h2>
<p>Просто подключить готовую модель недостаточно - она не знает деталей вашего бизнеса и может отвечать общими фразами или ошибаться в конкретике. RAG (Retrieval-Augmented Generation) добавляет ассистенту доступ к базе знаний вашей компании: документации, FAQ, истории обращений - и ответы становятся конкретными, а не общими.</p>
<h2>Интеграция с реальными каналами</h2>
<p>Ассистент, до которого нужно отдельно заходить на непонятную страницу, используется гораздо реже, чем бот в Telegram или на сайте, куда клиенты и так пишут. Интеграция с мессенджерами, CRM и внутренними сервисами - то, что определяет, будут ли им пользоваться.</p>
<h2>Где ИИ-ассистент окупается быстрее всего</h2>
<ul>
<li>Ответы на типовые вопросы поддержки - снижает нагрузку на операторов на рутинных обращениях</li>
<li>Первичная квалификация заявок до передачи специалисту</li>
<li>Внутренний ассистент для сотрудников по регламентам и базе знаний компании</li>
</ul>
<h2>Что нужно для старта</h2>
<p>Нужна база знаний (даже неструктурированная - её можно привести в порядок в процессе), понимание, в каком канале живут ваши клиенты или сотрудники, и готовность донастраивать ассистента после запуска: первая версия почти никогда не бывает финальной.</p>
<p>Мы разрабатываем ботов и ИИ-ассистентов с обучением на данных компании и дальнейшим помесячным сопровождением - подробнее на странице <a href="/uslugi/ai">«ИИ и автоматизация»</a>. Если пока не уверены, что именно автоматизировать в первую очередь - <a href="/kontakty">напишите нам</a>, поможем определить приоритеты.</p>
$html$,
true,
now()
)
ON CONFLICT (slug) DO NOTHING;
COMMIT;
+20
View File
@@ -0,0 +1,20 @@
{
"name": "topsysops-backend",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0"
}
}
@@ -0,0 +1,55 @@
-- Обновление прайс-листа: убирает старую заглушку "Разработка сайтов" (от 5 000 ₽)
-- и добавляет новую линейку "Разработка под ключ" + "White-label и партнёрство".
-- Безопасно выполнять повторно - использует WHERE NOT EXISTS, дублей не создаст.
-- Запуск:
-- docker compose exec -T db psql -U topsysops topsysops < backend/scripts/2026-07-update-services.sql
BEGIN;
-- 1) Убираем старую заглушку "Разработка сайтов" (каскадно удалит её позицию "Лендинг")
DELETE FROM service_categories WHERE slug = 'web';
-- 2) Новая категория: Разработка под ключ
INSERT INTO service_categories (slug, title, description, icon, sort_order)
SELECT 'development', 'Разработка под ключ',
'От быстрого MVP до продакшн-разработки и сопровождения', 'Rocket',
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM service_categories)
WHERE NOT EXISTS (SELECT 1 FROM service_categories WHERE slug = 'development');
INSERT INTO service_items (category_id, name, price_text, period, note, sort_order)
SELECT (SELECT id FROM service_categories WHERE slug = 'development'),
v.name, v.price_text, v.period, v.note, v.sort_order
FROM (VALUES
('MVP «Быстрый старт»', 'от 120 000 ₽', '', 'Посадочная страница, форма/бот для заявок, простая админка - 2-3 недели', 0),
('MVP Pro', 'от 450 000 ₽', '', 'Личный кабинет с авторизацией, оплата, ИИ-ассистент - 4-6 недель', 1),
('Продакшн-разработка', 'от 1 200 000 ₽', '', 'Архитектура, тесты, DevOps, масштабирование - от 2 месяцев, точная смета после дискавери', 2),
('Discovery-воркшоп', 'от 30 000 ₽', '', 'Фиксация ТЗ и оценки перед стартом проекта - 1-2 дня', 3),
('Сопровождение проекта', 'от 40 000 ₽', '/мес', 'Ежемесячные доработки уже готовых продуктов', 4)
) AS v(name, price_text, period, note, sort_order)
WHERE NOT EXISTS (
SELECT 1 FROM service_items
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'development') AND name = v.name
);
-- 3) Новая категория: White-label и партнёрство
INSERT INTO service_categories (slug, title, description, icon, sort_order)
SELECT 'partners', 'White-label и партнёрство',
'Готовые решения для других компаний и программы сотрудничества', 'Handshake',
(SELECT COALESCE(MAX(sort_order), -1) + 1 FROM service_categories)
WHERE NOT EXISTS (SELECT 1 FROM service_categories WHERE slug = 'partners');
INSERT INTO service_items (category_id, name, price_text, period, note, sort_order)
SELECT (SELECT id FROM service_categories WHERE slug = 'partners'),
v.name, v.price_text, v.period, v.note, v.sort_order
FROM (VALUES
('White-label «Кабинет + ИИ-ассистент»', 'от 500 000 ₽', '', 'Личный кабинет с авторизацией и ИИ-чатом под брендом вашей компании', 0),
('IT-мониторинг сайта', 'от 10 000 ₽', '/мес', 'Аптайм, скорость загрузки, уведомления об инцидентах', 1),
('Реферальная программа', '10%', 'от суммы контракта', 'Бонус вам за рекомендацию нас новому клиенту', 2),
('Партнёрская программа', 'по договорённости', '', 'Комиссия для хостинг-провайдеров и поставщиков оборудования за рекомендации', 3)
) AS v(name, price_text, period, note, sort_order)
WHERE NOT EXISTS (
SELECT 1 FROM service_items
WHERE category_id = (SELECT id FROM service_categories WHERE slug = 'partners') AND name = v.name
);
COMMIT;
+38
View File
@@ -0,0 +1,38 @@
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const COOKIE_NAME = 'ts_admin_session';
if (!SECRET) {
console.warn('[auth] JWT_SECRET is not set - set a long random value in .env before going to production');
}
function issueToken(res, payload) {
const token = jwt.sign({ ...payload, type: 'admin' }, SECRET || 'insecure-dev-secret', { expiresIn: '12h' });
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.COOKIE_SECURE !== 'false',
maxAge: 12 * 60 * 60 * 1000,
path: '/',
});
}
function clearToken(res) {
res.clearCookie(COOKIE_NAME, { path: '/' });
}
function requireAuth(req, res, next) {
const token = req.cookies ? req.cookies[COOKIE_NAME] : null;
if (!token) return res.status(401).json({ error: 'unauthorized' });
try {
const payload = jwt.verify(token, SECRET || 'insecure-dev-secret');
if (payload.type !== 'admin') return res.status(401).json({ error: 'unauthorized' });
req.admin = payload;
next();
} catch (e) {
return res.status(401).json({ error: 'unauthorized' });
}
}
module.exports = { issueToken, clearToken, requireAuth, COOKIE_NAME };
+34
View File
@@ -0,0 +1,34 @@
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET;
const COOKIE_NAME = 'ts_customer_session';
function issueCustomerToken(res, payload) {
const token = jwt.sign({ ...payload, type: 'customer' }, SECRET || 'insecure-dev-secret', { expiresIn: '7d' });
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.COOKIE_SECURE !== 'false',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/',
});
}
function clearCustomerToken(res) {
res.clearCookie(COOKIE_NAME, { path: '/' });
}
function requireCustomerAuth(req, res, next) {
const token = req.cookies ? req.cookies[COOKIE_NAME] : null;
if (!token) return res.status(401).json({ error: 'unauthorized' });
try {
const payload = jwt.verify(token, SECRET || 'insecure-dev-secret');
if (payload.type !== 'customer') return res.status(401).json({ error: 'unauthorized' });
req.customer = payload;
next();
} catch (e) {
return res.status(401).json({ error: 'unauthorized' });
}
}
module.exports = { issueCustomerToken, clearCustomerToken, requireCustomerAuth, COOKIE_NAME };
+11
View File
@@ -0,0 +1,11 @@
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
pool.on('error', (err) => {
console.error('[db] unexpected error on idle client', err);
});
module.exports = pool;
+106
View File
@@ -0,0 +1,106 @@
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const compression = require('compression');
const migrate = require('./migrate');
const seed = require('./seed');
const pool = require('./db');
const { requireAuth } = require('./auth');
const { requireCustomerAuth } = require('./customerAuth');
const authRouter = require('./routes/auth');
const publicRouter = require('./routes/public');
const adminRouter = require('./routes/admin');
const customerAuthRouter = require('./routes/customerAuth');
const accountRouter = require('./routes/account');
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(helmet({ contentSecurityPolicy: false }));
app.use(compression());
app.use(express.json({ limit: '200kb' }));
app.use(cookieParser());
app.get('/api/health', (req, res) => res.json({ ok: true }));
app.get('/sitemap.xml', async (req, res) => {
const base = (process.env.PUBLIC_SITE_URL || 'https://top-sysops.ru').replace(/\/$/, '');
let posts = [];
try {
const { rows } = await pool.query(
'SELECT slug, updated_at FROM blog_posts WHERE published = true ORDER BY published_at DESC'
);
posts = rows;
} catch (err) {
console.error('[sitemap] failed to load blog posts', err);
}
const urls = [
{ loc: `${base}/`, changefreq: 'weekly', priority: '1.0' },
{ loc: `${base}/blog`, changefreq: 'weekly', priority: '0.7' },
...posts.map((p) => ({
loc: `${base}/blog/${p.slug}`,
changefreq: 'monthly',
priority: '0.6',
lastmod: new Date(p.updated_at).toISOString().slice(0, 10),
})),
];
const body = urls
.map(
(u) =>
` <url>\n <loc>${u.loc}</loc>\n${u.lastmod ? ` <lastmod>${u.lastmod}</lastmod>\n` : ''} <changefreq>${u.changefreq}</changefreq>\n <priority>${u.priority}</priority>\n </url>`
)
.join('\n');
res.set('Content-Type', 'application/xml');
res.send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`);
});
app.use('/api/auth', authRouter);
app.use('/api', publicRouter);
app.use('/api/admin', requireAuth, adminRouter);
app.use('/api/customer', customerAuthRouter);
app.use('/api/account', requireCustomerAuth, accountRouter);
app.use((req, res) => {
res.status(404).json({ error: 'not found' });
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error('[error]', err);
res.status(500).json({ error: 'internal server error' });
});
const PORT = process.env.PORT || 4000;
async function waitForDatabaseAndMigrate() {
const maxAttempts = 30;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await migrate();
return;
} catch (err) {
console.log(`[start] database not ready yet (attempt ${attempt}/${maxAttempts}): ${err.message}`);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
throw new Error('database did not become ready in time');
}
async function start() {
await waitForDatabaseAndMigrate();
await seed();
app.listen(PORT, () => console.log(`[start] backend listening on port ${PORT}`));
}
start().catch((err) => {
console.error('[start] fatal error during startup', err);
process.exit(1);
});
+37
View File
@@ -0,0 +1,37 @@
const fs = require('fs');
const path = require('path');
const pool = require('./db');
async function migrate() {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT now()
)
`);
const dir = path.join(__dirname, '..', 'migrations');
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
for (const file of files) {
const { rows } = await pool.query('SELECT 1 FROM schema_migrations WHERE filename = $1', [file]);
if (rows.length) continue;
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
console.log(`[migrate] applying ${file}`);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
}
module.exports = migrate;
+105
View File
@@ -0,0 +1,105 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const pool = require('../db');
const router = express.Router();
/* ---------- support messages (chat with us) ---------- */
router.get('/messages', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, sender, body, created_at FROM support_messages WHERE customer_id = $1 ORDER BY created_at ASC',
[req.customer.sub]
);
res.json(rows);
});
const messageLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many messages, try again later' },
});
router.post('/messages', messageLimiter, async (req, res) => {
const { body } = req.body || {};
if (!body || !String(body).trim()) return res.status(400).json({ error: 'body is required' });
const { rows } = await pool.query(
"INSERT INTO support_messages (customer_id, sender, body) VALUES ($1, 'customer', $2) RETURNING id, sender, body, created_at",
[req.customer.sub, String(body).slice(0, 4000)]
);
res.status(201).json(rows[0]);
});
/* ---------- ai assistant (OpenRouter) - customer-facing chat ---------- */
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'openai/gpt-4o-mini';
router.get('/ai/status', (req, res) => {
res.json({ configured: Boolean(OPENROUTER_API_KEY) });
});
const aiLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 15,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many messages, try again later' },
});
router.post('/ai/chat', aiLimiter, async (req, res) => {
if (!OPENROUTER_API_KEY) {
return res.status(503).json({ error: 'AI is not configured' });
}
const { messages } = req.body || {};
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ error: 'messages array is required' });
}
const trimmed = messages.slice(-20).map((m) => ({
role: m.role === 'assistant' ? 'assistant' : 'user',
content: String(m.content || '').slice(0, 2000),
}));
try {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
'HTTP-Referer': process.env.PUBLIC_SITE_URL || 'https://top-sysops.ru',
'X-Title': 'TopSysOps Client Assistant',
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [
{
role: 'system',
content:
'Ты - ассистент компании TopSysOps (top-sysops.ru) для зарегистрированных клиентов. Отвечаешь на вопросы об услугах, тарифах и IT-инфраструктуре в целом, кратко и дружелюбно, на русском языке. Если вопрос требует точного расчёта стоимости или доступа к данным конкретного клиента - предложи написать в разделе "Написать нам" личного кабинета, там ответит сотрудник компании.',
},
...trimmed,
],
}),
});
if (!response.ok) {
const text = await response.text();
console.error('[account-ai] OpenRouter error', response.status, text);
return res.status(502).json({ error: 'AI provider request failed' });
}
const data = await response.json();
const reply = data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : '';
res.json({ reply });
} catch (err) {
console.error('[account-ai] request failed', err);
res.status(502).json({ error: 'AI provider request failed' });
}
});
module.exports = router;
+465
View File
@@ -0,0 +1,465 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const pool = require('../db');
const { sanitizeHtml } = require('../sanitizeHtml');
const router = express.Router();
/* ---------- reviews ---------- */
router.get('/reviews', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM reviews ORDER BY created_at DESC');
res.json(rows);
});
router.patch('/reviews/:id', async (req, res) => {
const { status } = req.body || {};
if (!['approved', 'rejected', 'pending'].includes(status)) {
return res.status(400).json({ error: 'status must be approved, rejected or pending' });
}
const { rows } = await pool.query('UPDATE reviews SET status = $1 WHERE id = $2 RETURNING *', [status, req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/reviews/:id', async (req, res) => {
await pool.query('DELETE FROM reviews WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- services / price list ---------- */
router.get('/services', async (req, res) => {
const { rows: categories } = await pool.query('SELECT * FROM service_categories ORDER BY sort_order, id');
const { rows: items } = await pool.query('SELECT * FROM service_items ORDER BY sort_order, id');
res.json(categories.map((cat) => ({ ...cat, items: items.filter((it) => it.category_id === cat.id) })));
});
router.post('/services/categories', async (req, res) => {
const { slug, title, description, icon } = req.body || {};
if (!slug || !title) return res.status(400).json({ error: 'slug and title are required' });
const { rows: maxRows } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM service_categories');
try {
const { rows } = await pool.query(
'INSERT INTO service_categories (slug, title, description, icon, sort_order) VALUES ($1,$2,$3,$4,$5) RETURNING *',
[slug, title, description || '', icon || 'Server', maxRows[0].next]
);
res.status(201).json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'a category with this slug already exists' });
throw err;
}
});
router.put('/services/categories/:id', async (req, res) => {
const { title, description, icon, sort_order } = req.body || {};
const { rows } = await pool.query(
`UPDATE service_categories SET
title = COALESCE($1, title),
description = COALESCE($2, description),
icon = COALESCE($3, icon),
sort_order = COALESCE($4, sort_order)
WHERE id = $5 RETURNING *`,
[title, description, icon, sort_order, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/services/categories/:id', async (req, res) => {
await pool.query('DELETE FROM service_categories WHERE id = $1', [req.params.id]);
res.status(204).end();
});
router.post('/services/items', async (req, res) => {
const { category_id, name, price_text, period, note } = req.body || {};
if (!category_id || !name || !price_text) {
return res.status(400).json({ error: 'category_id, name and price_text are required' });
}
const { rows: maxRows } = await pool.query(
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM service_items WHERE category_id = $1',
[category_id]
);
const { rows } = await pool.query(
'INSERT INTO service_items (category_id, name, price_text, period, note, sort_order) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *',
[category_id, name, price_text, period || '', note || '', maxRows[0].next]
);
res.status(201).json(rows[0]);
});
router.put('/services/items/:id', async (req, res) => {
const { name, price_text, period, note, active, sort_order } = req.body || {};
const { rows } = await pool.query(
`UPDATE service_items SET
name = COALESCE($1, name),
price_text = COALESCE($2, price_text),
period = COALESCE($3, period),
note = COALESCE($4, note),
active = COALESCE($5, active),
sort_order = COALESCE($6, sort_order)
WHERE id = $7 RETURNING *`,
[name, price_text, period, note, active, sort_order, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/services/items/:id', async (req, res) => {
await pool.query('DELETE FROM service_items WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- promotions ---------- */
router.get('/promotions', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM promotions ORDER BY sort_order, id');
res.json(rows);
});
router.post('/promotions', async (req, res) => {
const { title, price_text, old_price_text, description } = req.body || {};
if (!title || !price_text) return res.status(400).json({ error: 'title and price_text are required' });
const { rows: maxRows } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM promotions');
const { rows } = await pool.query(
'INSERT INTO promotions (title, price_text, old_price_text, description, sort_order) VALUES ($1,$2,$3,$4,$5) RETURNING *',
[title, price_text, old_price_text || null, description || '', maxRows[0].next]
);
res.status(201).json(rows[0]);
});
router.put('/promotions/:id', async (req, res) => {
const { title, price_text, old_price_text, description, active, sort_order } = req.body || {};
const { rows: existingRows } = await pool.query('SELECT * FROM promotions WHERE id = $1', [req.params.id]);
if (!existingRows.length) return res.status(404).json({ error: 'not found' });
// old_price_text - особый случай: null должен явно ОЧИЩАТЬ поле (форма
// редактирования иногда так и делает), но если поле вообще не прислали
// (например, обновляем только sort_order при сортировке) - нужно сохранить
// прежнее значение, а не затереть его в null.
const nextOldPriceText = old_price_text === undefined ? existingRows[0].old_price_text : old_price_text;
const { rows } = await pool.query(
`UPDATE promotions SET
title = COALESCE($1, title),
price_text = COALESCE($2, price_text),
old_price_text = $3,
description = COALESCE($4, description),
active = COALESCE($5, active),
sort_order = COALESCE($6, sort_order)
WHERE id = $7 RETURNING *`,
[title, price_text, nextOldPriceText, description, active, sort_order, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/promotions/:id', async (req, res) => {
await pool.query('DELETE FROM promotions WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- cases ---------- */
router.get('/cases', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM cases ORDER BY sort_order, id');
res.json(rows);
});
router.post('/cases', async (req, res) => {
const { title, tag, status, tone, description } = req.body || {};
if (!title) return res.status(400).json({ error: 'title is required' });
const { rows: maxRows } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM cases');
const { rows } = await pool.query(
'INSERT INTO cases (title, tag, status, tone, description, sort_order) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *',
[title, tag || '', status || 'в работе', tone || 'warn', description || '', maxRows[0].next]
);
res.status(201).json(rows[0]);
});
router.put('/cases/:id', async (req, res) => {
const { title, tag, status, tone, description, active, sort_order } = req.body || {};
const { rows } = await pool.query(
`UPDATE cases SET
title = COALESCE($1, title),
tag = COALESCE($2, tag),
status = COALESCE($3, status),
tone = COALESCE($4, tone),
description = COALESCE($5, description),
active = COALESCE($6, active),
sort_order = COALESCE($7, sort_order)
WHERE id = $8 RETURNING *`,
[title, tag, status, tone, description, active, sort_order, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/cases/:id', async (req, res) => {
await pool.query('DELETE FROM cases WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- faqs ---------- */
router.get('/faqs', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM faqs ORDER BY sort_order, id');
res.json(rows);
});
router.post('/faqs', async (req, res) => {
const { question, answer } = req.body || {};
if (!question || !answer) return res.status(400).json({ error: 'question and answer are required' });
const { rows: maxRows } = await pool.query('SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM faqs');
const { rows } = await pool.query(
'INSERT INTO faqs (question, answer, sort_order) VALUES ($1,$2,$3) RETURNING *',
[question, answer, maxRows[0].next]
);
res.status(201).json(rows[0]);
});
router.put('/faqs/:id', async (req, res) => {
const { question, answer, active, sort_order } = req.body || {};
const { rows } = await pool.query(
`UPDATE faqs SET
question = COALESCE($1, question),
answer = COALESCE($2, answer),
active = COALESCE($3, active),
sort_order = COALESCE($4, sort_order)
WHERE id = $5 RETURNING *`,
[question, answer, active, sort_order, req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.delete('/faqs/:id', async (req, res) => {
await pool.query('DELETE FROM faqs WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- admin users management ---------- */
router.get('/admins', async (req, res) => {
const { rows } = await pool.query('SELECT id, username, created_at FROM admin_users ORDER BY created_at ASC');
res.json(rows);
});
router.post('/admins', async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
if (String(password).length < 8) return res.status(400).json({ error: 'password must be at least 8 characters' });
const hash = await bcrypt.hash(password, 12);
try {
const { rows } = await pool.query(
'INSERT INTO admin_users (username, password_hash) VALUES ($1, $2) RETURNING id, username, created_at',
[String(username).trim().slice(0, 60), hash]
);
res.status(201).json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'this username is already taken' });
throw err;
}
});
router.delete('/admins/:id', async (req, res) => {
const targetId = Number(req.params.id);
if (targetId === req.admin.sub) {
return res.status(400).json({ error: 'you cannot delete your own account while logged in' });
}
const { rows: countRows } = await pool.query('SELECT COUNT(*)::int AS c FROM admin_users');
if (countRows[0].c <= 1) {
return res.status(400).json({ error: 'cannot delete the last remaining admin' });
}
await pool.query('DELETE FROM admin_users WHERE id = $1', [targetId]);
res.status(204).end();
});
/* ---------- contact info ---------- */
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: '' });
});
router.put('/contact', async (req, res) => {
const { address, phone, email, hours, map_embed } = req.body || {};
const { rows } = await pool.query(
`INSERT INTO contact_info (id, address, phone, email, hours, map_embed)
VALUES (1, $1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
address = EXCLUDED.address,
phone = EXCLUDED.phone,
email = EXCLUDED.email,
hours = EXCLUDED.hours,
map_embed = EXCLUDED.map_embed
RETURNING *`,
[address || '', phone || '', email || '', hours || '', map_embed || '']
);
res.json(rows[0]);
});
/* ---------- customers / support messages ---------- */
router.get('/customers', async (req, res) => {
const { rows } = await pool.query(`
SELECT c.id, c.name, c.email, c.company, c.created_at,
lm.body AS last_message_body,
lm.sender AS last_message_sender,
lm.created_at AS last_message_at
FROM customers c
LEFT JOIN LATERAL (
SELECT body, sender, created_at FROM support_messages
WHERE customer_id = c.id ORDER BY created_at DESC LIMIT 1
) lm ON true
ORDER BY COALESCE(lm.created_at, c.created_at) DESC
`);
res.json(rows);
});
router.get('/customers/:id/messages', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, sender, body, created_at FROM support_messages WHERE customer_id = $1 ORDER BY created_at ASC',
[req.params.id]
);
res.json(rows);
});
router.post('/customers/:id/messages', async (req, res) => {
const { body } = req.body || {};
if (!body || !String(body).trim()) return res.status(400).json({ error: 'body is required' });
const { rows } = await pool.query(
"INSERT INTO support_messages (customer_id, sender, body) VALUES ($1, 'staff', $2) RETURNING id, sender, body, created_at",
[req.params.id, String(body).slice(0, 4000)]
);
res.status(201).json(rows[0]);
});
/* ---------- blog ---------- */
router.get('/blog', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM blog_posts ORDER BY created_at DESC');
res.json(rows);
});
router.post('/blog', async (req, res) => {
const { slug, title, excerpt, content, published } = req.body || {};
if (!slug || !title) return res.status(400).json({ error: 'slug and title are required' });
try {
const { rows } = await pool.query(
`INSERT INTO blog_posts (slug, title, excerpt, content, published, published_at)
VALUES ($1,$2,$3,$4,$5, CASE WHEN $5 THEN now() ELSE NULL END) RETURNING *`,
[slug, title, excerpt || '', sanitizeHtml(content || ''), Boolean(published)]
);
res.status(201).json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'a post with this slug already exists' });
throw err;
}
});
router.put('/blog/:id', async (req, res) => {
const { title, excerpt, content, published } = req.body || {};
const { rows: existingRows } = await pool.query('SELECT * FROM blog_posts WHERE id = $1', [req.params.id]);
if (!existingRows.length) return res.status(404).json({ error: 'not found' });
const existing = existingRows[0];
const cleanContent = content === undefined ? undefined : sanitizeHtml(content);
const nowPublishing = published === true && !existing.published;
const { rows } = await pool.query(
`UPDATE blog_posts SET
title = COALESCE($1, title),
excerpt = COALESCE($2, excerpt),
content = COALESCE($3, content),
published = COALESCE($4, published),
published_at = CASE WHEN $5 THEN now() ELSE published_at END,
updated_at = now()
WHERE id = $6 RETURNING *`,
[title, excerpt, cleanContent, published, nowPublishing, req.params.id]
);
res.json(rows[0]);
});
router.delete('/blog/:id', async (req, res) => {
await pool.query('DELETE FROM blog_posts WHERE id = $1', [req.params.id]);
res.status(204).end();
});
/* ---------- ai assistant (OpenRouter) - staff-only tool inside the admin panel ---------- */
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'openai/gpt-4o-mini';
router.get('/ai/status', (req, res) => {
res.json({ configured: Boolean(OPENROUTER_API_KEY), model: OPENROUTER_MODEL });
});
router.post('/ai/chat', async (req, res) => {
if (!OPENROUTER_API_KEY) {
return res.status(503).json({ error: 'OPENROUTER_API_KEY is not configured on the server' });
}
const { messages } = req.body || {};
if (!Array.isArray(messages) || messages.length === 0) {
return res.status(400).json({ error: 'messages array is required' });
}
const trimmed = messages.slice(-20).map((m) => ({
role: ['user', 'assistant', 'system'].includes(m.role) ? m.role : 'user',
content: String(m.content || '').slice(0, 4000),
}));
try {
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
'HTTP-Referer': process.env.PUBLIC_SITE_URL || 'https://top-sysops.ru',
'X-Title': 'TopSysOps Admin Assistant',
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [
{
role: 'system',
content:
'Ты - внутренний ассистент админ-панели IT-компании TopSysOps. Помогаешь сотруднику: помогаешь формулировать ответы на отзывы клиентов, отвечаешь на вопросы по прайс-листу и организационным задачам. Отвечай кратко и по делу, на русском языке.',
},
...trimmed,
],
}),
});
if (!response.ok) {
const text = await response.text();
console.error('[ai] OpenRouter error', response.status, text);
return res.status(502).json({ error: 'AI provider request failed' });
}
const data = await response.json();
const reply = data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : '';
res.json({ reply });
} catch (err) {
console.error('[ai] request failed', err);
res.status(502).json({ error: 'AI provider request failed' });
}
});
/* ---------- stats ---------- */
router.get('/stats', async (req, res) => {
const { rows } = await pool.query(
"SELECT to_char(date, 'YYYY-MM-DD') AS date, count FROM visits WHERE date >= CURRENT_DATE - INTERVAL '29 days' ORDER BY date"
);
const { rows: totalRows } = await pool.query('SELECT COALESCE(SUM(count), 0)::int AS total FROM visits');
res.json({ byDay: rows, total: totalRows[0].total });
});
module.exports = router;
+59
View File
@@ -0,0 +1,59 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const pool = require('../db');
const { issueToken, clearToken, requireAuth } = require('../auth');
const router = express.Router();
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many attempts, try again later' },
});
router.post('/login', loginLimiter, async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ error: 'username and password are required' });
const { rows } = await pool.query('SELECT * FROM admin_users WHERE username = $1', [username]);
const user = rows[0];
if (!user) return res.status(401).json({ error: 'invalid credentials' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
issueToken(res, { sub: user.id, username: user.username });
res.json({ username: user.username });
});
router.post('/logout', (req, res) => {
clearToken(res);
res.json({ ok: true });
});
router.get('/me', requireAuth, (req, res) => {
res.json({ id: req.admin.sub, username: req.admin.username });
});
router.post('/change-password', requireAuth, async (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword || String(newPassword).length < 8) {
return res.status(400).json({ error: 'newPassword must be at least 8 characters' });
}
const { rows } = await pool.query('SELECT * FROM admin_users WHERE id = $1', [req.admin.sub]);
const user = rows[0];
if (!user) return res.status(404).json({ error: 'not found' });
const ok = await bcrypt.compare(currentPassword, user.password_hash);
if (!ok) return res.status(401).json({ error: 'current password is incorrect' });
const hash = await bcrypt.hash(newPassword, 12);
await pool.query('UPDATE admin_users SET password_hash = $1 WHERE id = $2', [hash, user.id]);
res.json({ ok: true });
});
module.exports = router;
+100
View File
@@ -0,0 +1,100 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const pool = require('../db');
const { issueCustomerToken, clearCustomerToken, requireCustomerAuth } = require('../customerAuth');
const router = express.Router();
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many attempts, try again later' },
});
router.post('/register', authLimiter, async (req, res) => {
const { name, email, password, company, phone } = req.body || {};
if (!name || !email || !password) {
return res.status(400).json({ error: 'name, email and password are required' });
}
if (String(password).length < 8) {
return res.status(400).json({ error: 'password must be at least 8 characters' });
}
const normalizedEmail = String(email).trim().toLowerCase();
const { rows: existing } = await pool.query('SELECT id FROM customers WHERE email = $1', [normalizedEmail]);
if (existing.length) return res.status(409).json({ error: 'this email is already registered' });
const hash = await bcrypt.hash(password, 12);
const { rows } = await pool.query(
'INSERT INTO customers (name, email, password_hash, company, phone) VALUES ($1,$2,$3,$4,$5) RETURNING id, name, email, company, phone',
[
String(name).slice(0, 120),
normalizedEmail,
hash,
company ? String(company).slice(0, 160) : null,
phone ? String(phone).slice(0, 40) : null,
]
);
issueCustomerToken(res, { sub: rows[0].id, email: rows[0].email });
res.status(201).json(rows[0]);
});
router.post('/login', authLimiter, async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
const normalizedEmail = String(email).trim().toLowerCase();
const { rows } = await pool.query('SELECT * FROM customers WHERE email = $1', [normalizedEmail]);
const user = rows[0];
if (!user) return res.status(401).json({ error: 'invalid credentials' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
issueCustomerToken(res, { sub: user.id, email: user.email });
res.json({ id: user.id, name: user.name, email: user.email, company: user.company, phone: user.phone });
});
router.post('/logout', (req, res) => {
clearCustomerToken(res);
res.json({ ok: true });
});
router.get('/me', requireCustomerAuth, async (req, res) => {
const { rows } = await pool.query('SELECT id, name, email, company, phone FROM customers WHERE id = $1', [req.customer.sub]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
});
router.put('/me', requireCustomerAuth, async (req, res) => {
const { name, company, phone } = req.body || {};
const { rows } = await pool.query(
'UPDATE customers SET name = COALESCE($1, name), company = $2, phone = $3 WHERE id = $4 RETURNING id, name, email, company, phone',
[name, company || null, phone || null, req.customer.sub]
);
res.json(rows[0]);
});
router.post('/change-password', requireCustomerAuth, async (req, res) => {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword || String(newPassword).length < 8) {
return res.status(400).json({ error: 'newPassword must be at least 8 characters' });
}
const { rows } = await pool.query('SELECT * FROM customers WHERE id = $1', [req.customer.sub]);
const user = rows[0];
if (!user) return res.status(404).json({ error: 'not found' });
const ok = await bcrypt.compare(currentPassword, user.password_hash);
if (!ok) return res.status(401).json({ error: 'current password is incorrect' });
const hash = await bcrypt.hash(newPassword, 12);
await pool.query('UPDATE customers SET password_hash = $1 WHERE id = $2', [hash, user.id]);
res.json({ ok: true });
});
module.exports = router;
+109
View File
@@ -0,0 +1,109 @@
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;
+17
View File
@@ -0,0 +1,17 @@
// Лёгкая санитизация HTML для контента блога, который создают только
// авторизованные сотрудники через рич-текст редактор в админке. Это не полноценная
// библиотека санитизации - если этот контент когда-нибудь станет доступен для
// публичного ввода (не только сотрудников), нужно будет подключить полноценный
// пакет (например sanitize-html или isomorphic-dompurify).
function sanitizeHtml(html) {
if (!html) return '';
let out = String(html);
out = out.replace(/<\/?(script|iframe|object|embed|link|meta|base|style)[^>]*>/gi, '');
out = out.replace(/\son\w+\s*=\s*"[^"]*"/gi, '');
out = out.replace(/\son\w+\s*=\s*'[^']*'/gi, '');
out = out.replace(/\son\w+\s*=\s*[^\s>]+/gi, '');
out = out.replace(/(href|src)\s*=\s*("|')\s*javascript:[^"']*\2/gi, '$1="#"');
return out;
}
module.exports = { sanitizeHtml };
+167
View File
@@ -0,0 +1,167 @@
const bcrypt = require('bcryptjs');
const pool = require('./db');
async function ensureAdmin() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS c FROM admin_users');
if (rows[0].c > 0) return;
const username = process.env.ADMIN_USERNAME || 'admin';
const password = process.env.ADMIN_PASSWORD;
if (!password) {
console.warn('[seed] ADMIN_PASSWORD is not set in .env - no admin user was created. Set it and restart the backend.');
return;
}
const hash = await bcrypt.hash(password, 12);
await pool.query('INSERT INTO admin_users (username, password_hash) VALUES ($1, $2)', [username, hash]);
console.log(`[seed] created admin user "${username}" - please log in and change the password from the admin panel`);
}
async function ensureContent() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS c FROM service_categories');
if (rows[0].c > 0) return;
const categories = [
{ slug: 'ai', title: 'ИИ и автоматизация', description: 'Внедрение ИИ, боты и ассистенты, автоматизация процессов', icon: 'Bot', items: [
['Бот / ИИ-ассистент', 'от 20 000 ₽', '/проект', 'Автоматизация бизнес-процессов'],
['Интеграция с сервисами', 'от 25 000 ₽', '/проект', 'Подключение CRM, мессенджеров, API'],
] },
{ slug: 'support', title: 'IT-поддержка', description: 'Удалённое и выездное сопровождение инфраструктуры', icon: 'Headphones', items: [
['Базовый тариф', 'от 30 000 ₽', '/мес', 'Удалённая поддержка, график 5/2'],
['Премиум тариф', 'от 100 000 ₽', '/мес', 'Полное сопровождение, выезды, 24/7'],
] },
{ slug: 'security', title: 'Кибербезопасность', description: 'Аудит, мониторинг и защита от угроз', icon: 'ShieldCheck', items: [
['Аудит и мониторинг', 'от 50 000 ₽', '/мес', 'Комплексная защита данных и систем'],
['Внедрение IDS/IPS', 'от 30 000 ₽', '/проект', 'Обнаружение и предотвращение вторжений'],
] },
{ slug: 'db', title: 'Администрирование БД', description: 'Настройка, оптимизация, резервное копирование', icon: 'Database', items: [
['Настройка и оптимизация', 'от 3 000 ₽', '/час', 'MySQL, PostgreSQL и другие СУБД'],
['Резервное копирование', 'от 4 000 ₽', '/день', 'Бэкапы и восстановление данных'],
] },
{ slug: 'network', title: 'Сетевая инфраструктура', description: 'Проектирование и поддержка корпоративных сетей', icon: 'Network', items: [
['Настройка сервера', 'от 25 000 ₽', '', 'Подбор и настройка оборудования'],
['Почтовый сервер (ваше оборудование)', 'от 20 000 ₽', '', 'Развёртывание с нуля'],
['Почтовый сервер (наше оборудование)', 'от 30 000 ₽', '/мес', 'Аренда и обслуживание'],
['Проектирование сети', 'от 20 000 ₽', '/проект', 'Монтаж и настройка'],
['Управление оборудованием', 'от 6 000 ₽', '/мес', 'Роутеры и коммутаторы'],
] },
{ slug: 'cloud', title: 'Облачные решения', description: 'Миграция и оптимизация затрат на облако', icon: 'Cloud', items: [
['Миграция в облако', 'от 25 000 ₽', '/проект', 'Перенос и настройка сервисов'],
] },
{ slug: 'development', title: 'Разработка под ключ', description: 'От быстрого MVP до продакшн-разработки и сопровождения', icon: 'Rocket', items: [
['Лендинг', 'от 20 000 ₽', '', 'Простая посадочная страница под продукт или услугу - без формы-бота и админки, 1 неделя'],
['MVP «Быстрый старт»', 'от 120 000 ₽', '', 'Посадочная страница, форма/бот для заявок, простая админка - 2-3 недели'],
['MVP Pro', 'от 450 000 ₽', '', 'Личный кабинет с авторизацией, оплата, ИИ-ассистент - 4-6 недель'],
['Продакшн-разработка', 'от 1 200 000 ₽', '', 'Архитектура, тесты, DevOps, масштабирование - от 2 месяцев, точная смета после дискавери'],
['Discovery-воркшоп', 'от 30 000 ₽', '', 'Фиксация ТЗ и оценки перед стартом проекта - 1-2 дня'],
['Сопровождение проекта', 'от 40 000 ₽', '/мес', 'Ежемесячные доработки уже готовых продуктов'],
] },
{ slug: 'partners', title: 'White-label и партнёрство', description: 'Готовые решения для других компаний и программы сотрудничества', icon: 'Handshake', items: [
['White-label «Кабинет + ИИ-ассистент»', 'от 500 000 ₽', '', 'Личный кабинет с авторизацией и ИИ-чатом под брендом вашей компании'],
['IT-мониторинг сайта', 'от 10 000 ₽', '/мес', 'Аптайм, скорость загрузки, уведомления об инцидентах'],
['Реферальная программа', '10%', 'от суммы контракта', 'Бонус вам за рекомендацию нас новому клиенту'],
['Партнёрская программа', 'по договорённости', '', 'Комиссия для хостинг-провайдеров и поставщиков оборудования за рекомендации'],
] },
{ slug: 'extra', title: 'Дополнительные услуги', description: 'Разовые и почасовые работы вне основных тарифов', icon: 'Server', items: [
['Экстренный выезд специалиста', '10 000 ₽', '', 'В течение 2 часов, по Санкт-Петербургу'],
['Выезд специалиста', '5 000 ₽', '', 'В течение дня, по Санкт-Петербургу'],
['Почасовые работы', 'от 3 000 ₽', '/час', 'Иные работы по IT-инфраструктуре'],
['Удалённая работа', 'от 15 000 ₽', '/мес', 'Аренда мощностей для 1-5 сотрудников'],
] },
];
for (let i = 0; i < categories.length; i++) {
const cat = categories[i];
const { rows: catRows } = await pool.query(
'INSERT INTO service_categories (slug, title, description, icon, sort_order) VALUES ($1,$2,$3,$4,$5) RETURNING id',
[cat.slug, cat.title, cat.description, cat.icon, i]
);
const categoryId = catRows[0].id;
for (let j = 0; j < cat.items.length; j++) {
const [name, price, period, note] = cat.items[j];
await pool.query(
'INSERT INTO service_items (category_id, name, price_text, period, note, sort_order) VALUES ($1,$2,$3,$4,$5,$6)',
[categoryId, name, price, period, note, j]
);
}
}
const promotions = [
['Бонус для новых клиентов', '15 000 ₽', null, 'Бонус на любые услуги при заключении договора на IT-аутсорсинг, от 10 рабочих мест.'],
['Год без забот', 'от 20 000 ₽/мес', 'от 30 000 ₽', 'IT-аутсорсинг базовый 5/2 при оплате за 12 месяцев - скидка 30%.'],
['Премиум 24/7', 'от 80 000 ₽/мес', 'от 100 000 ₽', 'Полное сопровождение при договоре от 6 месяцев - скидка 20%.'],
['Почтовый сервер под ключ', '35 000 ₽', 'от 50 000 ₽', 'SPF/DKIM/DMARC, антиспам, веб-почта, 3 месяца сопровождения.'],
['Бот + ИИ-ассистент', '35 000 ₽', 'от 45 000 ₽', 'Telegram-бот с обучением на ваших данных (RAG).'],
];
for (let i = 0; i < promotions.length; i++) {
const [title, price, oldPrice, desc] = promotions[i];
await pool.query(
'INSERT INTO promotions (title, price_text, old_price_text, description, sort_order) VALUES ($1,$2,$3,$4,$5)',
[title, price, oldPrice, desc, i]
);
}
console.log('[seed] inserted starter services and promotions - edit or delete them from the admin panel any time');
}
async function ensureCasesAndFaqs() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS c FROM cases');
if (rows[0].c === 0) {
const cases = [
['Миграция инфраструктуры розничной сети', 'Сетевая инфраструктура', 'завершён', 'ok', 'Перенесли серверы 12 магазинов на новое оборудование без остановки продаж, снизили время отклика сети.'],
['ИИ-ассистент для отдела поддержки', 'ИИ и автоматизация', 'завершён', 'ok', 'Внедрили Telegram-бота с RAG на базе базы знаний компании - снизили нагрузку на операторов.'],
['Аудит кибербезопасности финтех-проекта', 'Кибербезопасность', 'в работе', 'warn', 'Проводим комплексный аудит защищённости и внедряем систему мониторинга угроз 24/7.'],
];
for (let i = 0; i < cases.length; i++) {
const [title, tag, status, tone, description] = cases[i];
await pool.query(
'INSERT INTO cases (title, tag, status, tone, description, sort_order) VALUES ($1,$2,$3,$4,$5,$6)',
[title, tag, status, tone, description, i]
);
}
}
const { rows: faqRows } = await pool.query('SELECT COUNT(*)::int AS c FROM faqs');
if (faqRows[0].c === 0) {
const faqs = [
['Как быстро вы реагируете на заявки?', 'Для клиентов на поддержке 24/7 среднее время реакции - около 15 минут. Для разовых заявок мы связываемся в течение рабочего дня.'],
['Работаете ли вы полностью удалённо?', 'Да, большинство работ выполняется удалённо. Выезд специалиста доступен по Санкт-Петербургу.'],
['Что входит в базовый тариф IT-аутсорсинга?', 'Удалённая поддержка инфраструктуры по графику 5/2: мониторинг, устранение инцидентов, консультации.'],
['Как происходит оплата услуг?', 'По разовым работам - по факту выполнения или по предоплате для крупных проектов. По абонентскому обслуживанию - ежемесячно.'],
['Можно ли заказать разовую услугу без абонемента?', 'Да, доступны разовые работы: настройка сервера, аудит безопасности, разработка бота и другие услуги из прайс-листа.'],
['Как обеспечивается конфиденциальность данных?', 'Мы подписываем NDA перед началом работ и используем защищённые каналы доступа к вашей инфраструктуре.'],
];
for (let i = 0; i < faqs.length; i++) {
const [question, answer] = faqs[i];
await pool.query('INSERT INTO faqs (question, answer, sort_order) VALUES ($1,$2,$3)', [question, answer, i]);
}
}
}
async function ensureContactInfo() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS c FROM contact_info');
if (rows[0].c > 0) return;
await pool.query(
'INSERT INTO contact_info (id, address, phone, email, hours, map_embed) VALUES (1, $1, $2, $3, $4, $5)',
[
'г. Санкт-Петербург, ул. Вавиловых, д. 10, к1',
'+7 (988) 845-47-65',
'info@top-sysops.ru',
'Пн-Пт: 8:00-18:00',
'https://yandex.ru/map-widget/v1/?um=constructor%3Aefc560cea4e680eb7a1e2826c633bc8b25312218ff4c0e69e571089cbb4372a5&source=constructor',
]
);
}
async function seed() {
await ensureAdmin();
await ensureContent();
await ensureCasesAndFaqs();
await ensureContactInfo();
}
module.exports = seed;