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:
commit
130c5382cd
108 files changed
+7146
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user