SaaS для замены устаревшего Excel-чеклиста, которым клиент вручную проверял работу ассистентов стоматологии и считал премию. Хост: LXC 151 (198.51.100.1, узел pve3), публикация через Traefik (dental-checklist.skud24.ru).

Статус (2026-08-29)

Архитектура

Мультитенантная модель: Network (сеть клиник) → Filial (клиника) → Cabinet/Assistant, с ролями SUPER_ADMIN (видит всю сеть) / FILIAL_ADMIN (свой филиал) / ASSISTANT (свои результаты и премия). С 2026-09-03 чек-лист привязан к филиалу (ChecklistTemplate.filialId, было — к сети): у каждого филиала свой набор пунктов, которым управляет его FILIAL_ADMIN (см. «Чек-лист по филиалам…» ниже).

Версионирование пунктов чек-листа — через soft-archive (ChecklistItem.isActive/archivedAt), не hard delete: старые проверки (Inspection/InspectionResult) остаются целостными даже если пункт позже архивирован.

Стартовый шаблон — 13 пунктов дословно мигрированы из Excel-файла клиента (внешний вид, чистота кабинета/тумбочки, время прихода на смену, стерильность инструментов, личные вещи, зарядка батареек, маркировка растворов и т.д.).

Технологический стек

Развёртывание

LXC 151 создан на pve3 как unprivileged-контейнер, диск на local-lvm (сознательно не NFS werewr — в nas-openmediavault задокументированы fsync-проблемы NFS под похожую нагрузку Postgres, см. историю zabbix-monitoring).

Файлы проекта (без package-lock.json, регенерируется при npm install) переданы на pve3 через временный публичный файлообменник (litterbox.catbox.moe, 1 час) с проверкой sha256 на обеих сторонах — прямое встраивание base64 в промт ранее давало молчаливую порчу данных на большом объёме (~17KB), решение зафиксировано как общий паттерн для будущих таких переносов в этой лабе.

Фронтенд-передеплой (2026-08-28): тот же паттерн — обновлённый проект (backend + новый каталог web/ с фронтендом) упакован в tar.gz, залит на litterbox.catbox.moe, скачан на pve3 напрямую через curl, закинут в LXC 151 через pct push, распакован поверх /opt/dental-checklist с сохранением .env (бэкап/восстановление вручную, т.к. .env не входит в архив), пересобран docker compose up -d --build. Сборка (два npm install + vite build + nest build + prisma generate) заняла ~9 минут на pve3 — предсказуемо небыстро для слабого железа лабы, но не блокер.

Передеплой Stage 3 (2026-08-28, тем же днём): тот же паттерн, но Docker-слои npm install/prisma generate закэшировались (менялся только код src//web/src/) — пересборка заняла ~95 секунд вместо ~9 минут. Общий вывод: полная пересборка нужна в основном при смене package.json/prisma/schema.prisma, для точечных правок кода кэш docker-слоёв делает передеплой быстрым.

Передеплой Stage 4 (2026-08-29): отказ файлообменников, base64-чанки через n8n SSH-мост. litterbox.catbox.moe и tmpfiles.org в этот раз оказались недоступны/не сработали — вместо них архив (~99 KB) передан на pve3 напрямую через существующий n8n-workflow SSH-моста (mcp__n8n__execute_workflow, воркфлоу 7gcxezIoIJbSct0t), закодированный в base64 и разбитый на 17 чанков по 8000 байт (лимит на длину одного chatInput), каждый чанк дописывался на pve3 через printf '%s' '<чанк>' >> файл. Важный урок: совпадение итогового размера файла (байт в байт) не гарантирует целостность содержимого — один чанк (№5) на приёмной стороне оказался побит при полностью совпадающей общей длине. Диагностика: sha256 всего файла (не совпал) → sha256 каждого из 17 чанков отдельно, локально и удалённо через dd if=файл bs=1 skip=<offset> count=<size> | sha256sum по диапазонам байт — нашёлся ровно один расходящийся чанк. Почин: вырезаны dd-ом целые "до" и "после" куски вокруг битого чанка, битый чанк передан заново в отдельный файл, три части склеены cat-ом, итоговый sha256 сверен и совпал. Общий вывод для лабы: при большом ручном base64-переносе чанками всегда сверять sha256 по чанкам, а не только финальный размер файла — молчаливая порча возможна даже при точном совпадении длины.

Прод-seed на Stage 4 не завёлся штатным способом. npx ts-node prisma/seed.ts в рантайм-контейнере упал дважды: сначала TS7016 (нет типов для bcrypt — рантайм-образ собран без devDependencies/@types/*), после локального npm i -g ts-node (доустановка внутри контейнера) — TS5109 (moduleResolution должен быть NodeNext при module: NodeNext, конфликт tsconfig.json проекта с настройками ts-node по умолчанию). Обходной путь: логика seed.ts вручную переписана в чистый JS (require('@prisma/client'), require('bcrypt'), без типов), передана в контейнер тем же base64-чанк-паттерном (один чанк, 3820 байт), и запущена как node seed.js строго из /app — запуск из /tmp падал MODULE_NOT_FOUND, потому что Node резолвит node_modules от рабочей директории, а не от расположения скрипта. Скрипт идемпотентен (upsert), старых данных не задел. Технический долг остаётся: нормальный путь — компилировать seed.ts в dist на этапе сборки Dockerfile, чтобы не городить такие обходы на каждый чистый передеплой.

Известные проблемы деплоя (для будущих таких же деплоев в лабе)

1. SEED_ADMIN_PASSWORD не был прописан в environment сервиса api в docker-compose.yml — seed-скрипт (prisma/seed.ts) ушёл на дефолтный пароль ChangeMe123! вместо сгенерированного случайного. Реальный пароль пропатчен вручную через SQL UPDATE "User" SET "passwordHash" = ... (bcrypt-хеш посчитан локально), локальный docker-compose.yml исправлен на будущее — переменная добавлена в environment. - ⚠️ Уточнение (2026-09-01): ChangeMe123! для admin@host1.example.internal на проде не работает (подтверждено пользователем — 401 «Неверный email или пароль»). Актуальный пароль лежит в /opt/dental-checklist/.env на LXC 151, переменная SEED_ADMIN_PASSWORD — вход этим значением подтверждён (POST /api/auth/login → 200, accessToken выдан, роль SUPER_ADMIN). Сам пароль в открытом тексте здесь не хранится (см. .env на хосте либо спросить у агента в текущей сессии). 2. Порт api был замаплен как "198.51.100.2:3000:3000" — Docker слушал только на loopback самого LXC 151, недоступен снаружи контейнера вообще, даже с другого узла кластера. Так как Traefik стоит на LXC 109 (pve2), а приложение — на LXC 151 (pve3), это дало 502 Bad Gateway: Traefik матчил роут, но не мог достучаться до бэкенда (curl с pve2 на 198.51.100.1:3000 — Connection refused). Общий вывод для кластера smarthome: когда Traefik и приложение живут на разных узлах/LXC, биндинг порта на 198.51.100.2 не работает в принципе — нужно слушать на всех интерфейсах ("3000:3000"), а сетевую границу безопасности в таком случае обеспечивает JWT-авторизация на уровне приложения, не изоляция портом. Исправлено на LXC 151 и синхронизировано в исходниках проекта. 3. Технический долг: seed запускается вручную через ts-node --transpile-only с отдельным tsconfig.seed.json внутри уже работающего рантайм-контейнера (ts-node/typescript/@types/* доустанавливаются на лету через npm install, т.к. рантайм-образ Dockerfile собран без devDependencies) — вместо нормальной компиляции seed.ts в dist при билде. Стоит доработать Dockerfile перед следующим чистым передеплоем. 4. Дефолтный пароль демо-юзеров Stage 4. SEED_FILIAL_ADMIN_PASSWORD/SEED_ASSISTANT_PASSWORD не прописаны в .env на проде (аналогично проблеме №1 выше с SEED_ADMIN_PASSWORD) — оба новых демо-логина (filial1@host1.example.internal, assistant1@host1.example.internal) сидят на дефолте ChangeMe123! из кода seed.ts. Не критично для демо/теста, но нужно сменить перед реальным использованием клиентом.

Git-деплой через Gitea (2026-09-01)

Настроен полноценный CI/CD через gitea-deploy вместо ручного файлообменник+SSH паттерна выше — по запросу пользователя ("теперь можно деплоить через git").

Итог: для будущих изменений в этом проекте достаточно закоммитить и git push gitea main — файлообменник-паттерн из истории деплоев выше остаётся резервным вариантом (например, для первичной заливки в новый репозиторий или если Gitea/раннер недоступны).

Графики и общее фото к проверке (Stage 5/6, частично, 2026-09-01)

По запросу пользователя ("Хочется графиков и всё таки возможности добавлять фото происшествий") — уточнено через явный выбор пользователя: графики "Тренд % по времени" + "Рейтинг ассистентов" (без разбивки по пунктам чек-листа), фото — один общий блок вложений на всю проверку, не по каждому пункту с «Нет».

TLS не выпустился: дыра в NAT для порта 80 (2026-09-03)

Пользователь сообщил скриншотом — браузер показывает «Подключение к сайту не защищено» для dental-checklist.skud24.ru, попросил сделать так же, как у skud24.ru.

Диагностика: конфиг Traefik (dynamic/dental-checklist.yml) корректен — certResolver: le, как у всех. Но в acme.json сертификата для этого домена не было вообще (0 из 34). Рестарт Traefik (штатный способ форснуть retry ACME для file-provider роута, см. traefik-ha) вскрыл более широкую проблему: ACME renewal сейчас падает для всех доменов — Timeout during connect (likely firewall problem) на HTTP-01 challenge. Порт 80 снаружи не отвечал вообще (проверено curl из внешней сети — таймаут; порт 443 при этом отвечал нормально).

Причина найдена в NAT-таблице MikroTik (198.51.100.4, REST API): для порта 443 есть два правила dst-nat — hairpin (in-interface=bridge, только LAN) и отдельное внешнее (dst-address=198.51.100.5, без ограничения по интерфейсу — реально работает снаружи). Для порта 80 внешнего правила-аналога не было вообще — только hairpin (LAN-only) и один disabled=true черновик без dst-address. То есть HTTP-01 challenge не мог пройти в принципе ни для одного домена лабы, не только для dental-checklist — существующие домены были не видны только потому, что их старые сертификаты ещё не истекли.

Фикс: добавлено NAT-правило-зеркало по образцу рабочего 443-правила, через REST API MikroTik (PUT /rest/ip/firewall/nat):

chain=dstnat action=dst-nat protocol=tcp
dst-address=198.51.100.5 dst-port=80
to-addresses=198.51.100.6 to-ports=80
comment="HTTP external -> Traefik"

Аддитивно, существующие правила не тронуты. После этого рестарт Traefik → сертификат для dental-checklist.skud24.ru выпустился в течение ~2 минут, подтверждено вживую (openssl s_client с хоста в лабе, а не из облачного контейнера агента — у него исходящий TLS идёт через прокси Anthropic и показывает не тот сертификат): issuer=Let's Encrypt CN=YR1, subject=CN=dental-checklist.skud24.ru, годен до 2026-12-02.

Важно на будущее: эта дыра в NAT, скорее всего, существовала с самого начала (или появилась в ходе одного из инцидентов с MikroTik — см. mikrotik-mangle-outage-incident) и полноценно не «ломала» ничего до сих пор просто потому, что все существующие сертификаты были выпущены раньше и продолжали действовать. Теперь, когда правило для порта 80 добавлено, renewal должен полноценно проходить для всех ~26 доменов лабы, а не только рендериться по старому кэшу — стоит через месяц-два перепроверить, что сертификаты действительно обновляются, а не просто доживают старый срок.

Чек-лист по филиалам, сотрудники, дата задним числом, фото на любой пункт (2026-09-03)

По запросу пользователя (4 пункта): 1) свой чек-лист на филиал + экспорт, 2) админ филиала сам ведёт список сотрудников, 3) дата/время проверки редактируемо (постфактум по видеонаблюдению), 4) фото к любому пункту чек-листа, не только общий блок.

Кабинеты и редактирование логинов/паролей (2026-09-03, продолжение)

По двум следующим запросам пользователя в той же сессии.

Проверено вживую curl под всеми сценариями: SUPER_ADMIN меняет пароль filial1@host1.example.internal → логин новым паролем 200; FILIAL_ADMIN меняет пароль своего assistant1@host1.example.internal → логин новым паролем 200; FILIAL_ADMIN пытается сменить пароль себе (не ASSISTANT) → 403; FILIAL_ADMIN пытается сменить пароль SUPER_ADMIN → 403. Тестовые пароли после проверки возвращены на дефолтные (ChangeMe123!), чтобы не сломать ранее выданные учётки.

Security-аудит и фиксы (2026-09-03, продолжение)

По запросу пользователя ("проведи проверки безопасности, в том числе на брутфорс"). Живой аудит прод-инстанса (curl с хоста в лабе + чтение кода) + фиксы по итогам.

Найдено (до фикса):

Зафикшено (тот же день, коммит 9169959 + слит с рестартом раннера в 31aceed/6a74396):

Деплой: тот же паттерн, раннер завис 4-й раз за день на этом пуше (после рестарта пришлось ещё раз реально ждать — сборка с двумя новыми npm-зависимостями заняла ~14 минут вместо обычных ~2, как и в сессии 2026-09-01 с recharts — не путать с зависанием, процесс docker buildx bake/npm run build был живым на хосте всё это время, просто медленный канал лабы).

Проверено вживую после деплоя: 8 подряд запросов на /auth/login — первые 5 обработаны (401 на неверный пароль), 6-й и далее — 429; заголовки helmet на месте (Strict-Transport-Security, X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff и т.д.); запрос с Origin: https://evil-attacker.example не получил Access-Control-Allow-* в ответе; легитимный вход admin@host1.example.internal после сброса минутного окна — 200; смена паролей filial1/assistant1 — новые пароли логинятся (200), старый ChangeMe123! — 401.

Фильтры графиков, мульти-админ филиала, редактирование кабинетов (2026-09-03, продолжение)

По трём запросам пользователя в той же сессии (два пришли по ходу работы над первым).

Баги в проде и фиксы

Бесконечный цикл ERR_INSUFFICIENT_RESOURCES на форме «Новая проверка» (найден и пофикшен 2026-08-31). Пользователь прислал скриншот: при открытии формы создания проверки браузер уходил в бесконечный цикл падающих запросов к API, вкладка захлёбывалась (net::ERR_INSUFFICIENT_RESOURCES — браузер сам обрубает, слишком много одновременных соединений). Причина — классический React-баг: хук useAuthedFetch() (web/src/lib/auth.tsx) возвращал новую функцию на каждый рендер (не обёрнутую в useCallback), эта функция использовалась как зависимость useEffect на странице проверки → useEffect перезапускался на каждый рендер → каждый вызов менял состояние → рендер → снова useEffect → бесконечный цикл фетчей. Фикс: обернуть возвращаемую функцию в useCallback(..., [token, logout]). Задеплоено и проверено вживую через Chrome (см. ниже, раздел «Передача файлов» — новый способ переноса).

Передача файлов на pve3: новый надёжный способ + предостережение про n8n SSH-мост

При деплое этого багфикса (2026-08-31) оба ранее использовавшихся способа переноса архива на pve3 (файлообменники litterbox/tmpfiles и base64-чанки по 8000 байт через n8n SSH-мост, см. выше) снова протестированы и дали новый важный вывод:

Роадмап (7 этапов, реализован Stage 1)

| Этап | Статус | Содержание | |---|---|---| | 1 | ✅ done | Модель данных + роли/авторизация (мультифилиальность в схеме сразу) | | 2 | ✅ done | Конструктор чек-листа — админ сам добавляет/архивирует пункты через интерфейс (React SPA, роль-гейтинг: редактирование/архивация видны только SUPER_ADMIN) | | 3 | ✅ done | Форма проверки + расчёт % — контролёр отмечает Да/Нет по всем текущим пунктам чек-листа за один визит, percent = compliant/total*100, округление до целого | | 4 | ✅ done | Дашборды (сеть/филиал/кабинет/ассистент) + личный кабинет ассистента (видит только свои результаты/премию) | | 5 | 🔶 частично | Графики на дашборде — сделано, включая фильтры по периоду и сотрудникам (см. ниже). Уведомления Telegram/email/MAX + экспорт отчётов — не сделано | | 6 | 🔶 частично | Общее фото-вложение к проверке в целом — сделано (см. ниже; по решению пользователя — один общий блок вложений на визит, не по каждому пункту с «Нет») | | 7 | ⏳ TODO | Тест на реальных данных клиента, перенос истории (если есть), запуск |

Роли и формула зафиксированы по требованиям клиента: премию по деньгам система не считает, только % выполнения — это отдаётся бухгалтерии. Отметки о нарушениях ставит старший врач/администратор (контролёр), не сам ассистент.

Связано с

Источник