SaaS для замены устаревшего Excel-чеклиста, которым клиент вручную проверял работу ассистентов стоматологии и считал премию. Хост: LXC 151 (198.51.100.1, узел pve3), публикация через Traefik (dental-checklist.skud24.ru).
Статус (2026-08-29)
- ✅ API (NestJS 11): LIVE, LXC 151, порт 3000
- ✅ PostgreSQL 16: миграция
20260828093808_initприменена - ✅ Seed: демо-сеть, 13 пунктов чек-листа, 3 ассистента, SUPER_ADMIN
- ✅ Auth (JWT): логин
admin@host1.example.internalпроверен,accessTokenвыдаётся - ✅ Traefik интеграция: через
traefik-ha(LXC 109, узелpve2), file-provider - ✅ Домен:
dental-checklist.skud24.ru,/api/healthz→{"status":"ok"}через HTTPS - ✅ Фронтенд (React SPA): LIVE на том же домене/порту — логин-форма + конструктор чек-листа (Stage 2 UI), раздаётся тем же NestJS-контейнером (
@nestjs/serve-static), проверено через Traefik:GET /иGET /checklist→ 200, отдают собранныйindex.html - ✅ Форма проверки + расчёт % (Stage 3): LIVE — контролёр (SUPER_ADMIN/FILIAL_ADMIN) заводит проверку по кабинету/ассистенту, отмечает Да/Нет по каждому пункту текущего чек-листа, backend считает
percent = round(compliant/total*100),GET /inspections→ 200 через Traefik - ✅ TLS-сертификат: выпущен 2026-09-03, Let's Encrypt,
CN=dental-checklist.skud24.ru, действителен до 2026-12-02. Не подхватился сам — см. «TLS не выпустился: дыра в NAT для порта 80» ниже, фикс на уровне MikroTik, задокументирован подробно в mikrotik-router и traefik-ha. - ✅ Дашборды + личный кабинет ассистента (Stage 4): LIVE, задеплоено 2026-08-29 — код был готов и локально проверен заранее, в этой сессии выполнен только прод-деплой. Добавлены демо-логины
filial1@host1.example.internal(FILIAL_ADMIN) иassistant1@host1.example.internal(ASSISTANT), оба проверены через/api/auth/loginна проде —accessTokenвыдаётся. Доменdental-checklist.skud24.ruотвечает 200 снаружи (проверено из внешней сети, не только сpve3). - ✅ Багфикс (2026-08-31): бесконечный цикл падающих запросов (
net::ERR_INSUFFICIENT_RESOURCES) при открытии формы «Новая проверка» — пофикшен, задеплоен, проверен вживую через Chrome (см. «Баги в проде и фиксы» ниже).
Архитектура
Мультитенантная модель: 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-файла клиента (внешний вид, чистота кабинета/тумбочки, время прихода на смену, стерильность инструментов, личные вещи, зарядка батареек, маркировка растворов и т.д.).
Технологический стек
- Backend: NestJS 11 + TypeScript, Node 22
- ORM: Prisma 6
- БД: PostgreSQL 16 (
postgres:16-alpine) - Auth: passport-jwt + bcrypt
- Frontend: Vite + React 19 + TypeScript,
react-router-domv7, Tailwind CSS v4 (@tailwindcss/vite, без PostCSS-конфига) - Раздача фронтенда: тот же контейнер/домен, что и API —
@nestjs/serve-static@5.0.5(пин версии:latest/12.xтребует Nest 12, несовместим с проектом на Nest 11),exclude: ['/api/{path}'](Express 5 требует именованный wildcard-синтаксис{path}, не голый*) — так не нужен отдельный домен/CORS для фронта - Оркестрация: docker compose (
postgres+api), собственныйDockerfile(3-стадийная сборка:build-web(Vite) →build(Nest) →runtime, копирует обаdistиweb-dist)
Развёртывание
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").
- Репозиторий:
claude-deploy/dental-checklistв Gitea на LXC 117 (198.51.100.3:3000), приватный. Сервисный аккаунтclaude-deployсоздан отдельно от личногоmix. - Первичная заливка кода: локальный
git initв рабочей копии,git bundle create --all, перенос бандла через файлообменник (тот же паттерн, что и для деплоя, см. выше) на LXC 117, тамgit fetch <bundle> && git push gitea. /opt/dental-checklistна LXC 151 переведён в git-режим на месте:git init,git remote add origin(HTTP с basic-auth в URL, LAN-only),git checkout -f -B main origin/main— содержимое на диске совпало с запушенным один в один, конфликтов не было..envиnode_modules/dist— в.gitignore, не трогаются приgit reset --hard.- Deploy-ключ из общего пула лабы (генерился ранее, был непривязан) — публичная часть добавлена в
authorized_keysLXC 151, приватная — Actions secretDEPLOY_KEYэтого репозитория. .gitea/workflows/deploy.yml: push вmain→ раннер (node:20-bullseye, после фикса лейблов — см. gitea-deploy) ставитopenssh-client→ SSH на198.51.100.1→git fetch && git reset --hard origin/main && docker compose up -d --build→ healthz-проверка. Полностью автоматически, без ручных действий послеgit push.- Проверено end-to-end 2026-09-01: push с фиксом workflow (коммит
f67bdf4) → run задеплоился за ~2.5 мин → на LXC 151git logпоказывает актуальный коммит,docker compose ps— контейнеры живы,/healthz→ 200. - Первый прогон (run #1) завис на 20 минут из-за дефолтного (слишком большого для канала лабы) образа раннера — подробная диагностика и фикс (маппинг лейблов на лёгкий образ) в gitea-deploy, не дублируется здесь.
Итог: для будущих изменений в этом проекте достаточно закоммитить и git push gitea main — файлообменник-паттерн из истории деплоев выше остаётся резервным вариантом (например, для первичной заливки в новый репозиторий или если Gitea/раннер недоступны).
Графики и общее фото к проверке (Stage 5/6, частично, 2026-09-01)
По запросу пользователя ("Хочется графиков и всё таки возможности добавлять фото происшествий") — уточнено через явный выбор пользователя: графики "Тренд % по времени" + "Рейтинг ассистентов" (без разбивки по пунктам чек-листа), фото — один общий блок вложений на всю проверку, не по каждому пункту с «Нет».
- Prisma:
Inspection.photoUrls String[] @default([]), миграция20260901161500_add_inspection_photo_urlsнаписана вручную (в контейнере агента нет доступного Postgres/docker дляprisma migrate dev— обычныйALTER TABLE ... ADD COLUMN). - Backend:
UploadsModule(POST /api/uploads, multerdiskStorageв/app/uploads, до 6 файлов/10MB, только jpeg/png/webp/heic, роли SUPER_ADMIN/FILIAL_ADMIN — те же, что создают проверку), раздача статикой через второйServeStaticModule.forRoot({serveRoot:'/uploads'}).CreateInspectionDto.photoUrls?, сохраняется вinspections.service.ts. НовыйGET /api/dashboard/trend?days=N— группировка проверок по дню, средний %, доступен SUPER_ADMIN (вся сеть)/FILIAL_ADMIN (свой филиал). - Frontend:
rechartsдобавлен вweb/package.json. На дашборде —TrendChart(line chart, реюз/dashboard/trend) иAssistantsBarChart(bar chart, реюз уже существующего/dashboard/assistants, без нового эндпоинта). Личный кабинет ассистента получил свой мини-график тренда — переиспользуетmySummary.history(разворачивается в хронологический порядок), без отдельного запроса. Новый хукuseAuthedUpload()вauth.tsx(аналогuseAuthedFetch, та же мемоизацияuseCallback([token, logout])— чтобы не повторить баг бесконечного цикла из 2026-08-31) — используется в форме «Новая проверка» для загрузки фото перед сохранением самой проверки. Галерея превью — в развёрнутой карточке проверки в списке. - Техдолг закрыт по пути:
prisma(CLI) перенесён изdevDependenciesвdependencies— рантайм-образ теперь его видит;CMDвDockerfileизменён наnpx prisma migrate deploy && node dist/main.js— миграции применяются автоматически при каждом старте контейнера (идемпотентно), больше не нужен ручной шаг после деплоя (было тех. долгом с самого Stage 1, см. «Известные проблемы деплоя» №3 выше — тот пункт теперь относится только к seed, не к миграциям). docker-compose.yml: добавлен volumeuploads:/app/uploads— фото переживают пересоздание контейнераapi.- Деплой: тот же git-бандл-через-файлообменник паттерн, что и при первичной заливке в Gitea (см. gitea-deploy) — использован повторно, т.к. этот сеанс работы вёлся из окружения агента, у которого нет прямого сетевого доступа к LXC 117 (только через SSH-мост на pve3,
pct exec 117). Токен для пуша в Gitea от имениclaude-deployсгенерирован на месте черезdocker exec -u git gitea gitea admin user generate-access-token(не нужен был сохранённый пароль сервис-аккаунта). Раннер (run #3) на этот раз занял заметно дольше обычного (~19 минут вместо ~2.5) — новые npm-зависимости (recharts,@types/multer,prismaв основных deps) качались на медленном канале лабы во всех трёх стадиях многостадийногоDockerfile; сам процесс не завис (в отличие от инцидента с раннер-образом из первого прогона), просто был медленным — командаdocker compose up -d --buildдождалась завершения штатно. - Проверено вживую (2026-09-01): логин под исправленным паролем (см. ниже), дашборд —
GET /api/dashboard/trend,/dashboard/assistants,/dashboard/filialsвсе 200, блоки «Тренд % за 30 дней» и «Рейтинг ассистентов» отрендерились, консоль браузера чистая.POST /api/uploadsс тестовым PNG вернул{"urls":[...]}, файл отдаётся статикой (200 image/png) с/uploads/<uuid>.png.
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) фото к любому пункту чек-листа, не только общий блок.
- Чек-лист по филиалам:
ChecklistTemplateпереведён сnetworkIdнаfilialId(@unique— один шаблон на филиал), миграция20260903120000_checklist_template_per_filialбэкфиллит существующий шаблон на первый филиал сети (на проде на тот момент — ровно один филиал, потерь нет).POST/PATCH /checklist/itemsтеперь доступныFILIAL_ADMIN(только свой филиал) иSUPER_ADMIN(любой, черезfilialId);assertFilialAccessпроверяет владение черезitem.template.filialId. НовыйGET /checklist/export?filialId=— CSV (UTF-8 BOM,;-разделитель для Excel). Баг и фикс в этой же сессии: первая версия экспорта падала 500 (ERR_INVALID_CHAR) — имя файла вContent-Dispositionсобиралось из кириллического названия филиала, а HTTP-заголовок должен быть ASCII (\p{L}в regex матчит и кириллицу, не только латиницу — ложное чувство безопасности от "санитайза"). Исправлено:filename=— ASCII (id филиала),filename*=UTF-8''...— читаемое имя (RFC 5987,encodeURIComponent). - Сотрудники:
PATCH /assistants/:id(переименование, увольнение/восстановление —active), доступенFILIAL_ADMINдля своего филиала. Новая страницаEmployeesPage.tsx— список ассистентов, добавление, редактирование, создание логина прямо из карточки сотрудника (переиспользует существующийPOST /users). - Дата проверки задним числом: бэкенд уже поддерживал
CreateInspectionDto.checkedAt(не использовалось на фронте) — добавлен<input type="datetime-local">в форму «Новая проверка», по умолчанию сейчас,max— сейчас (нельзя будущим числом). - Фото на любой пункт:
InspectionResult.photoUrlуже был в схеме (Stage 3) — добавлена загрузка файла на каждый пункт формы, до сохранения проверки каждое фото грузится отдельным вызовомPOST /uploads(не пачкой — пунктов может быть больше лимита в 6 файлов/запрос уUploadsController), урл попадает вresults[].photoUrl. Отображается миниатюрой в развёрнутой карточке проверки рядом с общим блоком фото. - Деплой: git bundle → litterbox.catbox.moe → pve3 → push в Gitea (тот же паттерн, что раньше). Новая проблема CI, задокументирована как техдолг: раннер
lab-runner(Gitea Actions) дважды подряд зависал в процессе SSH-шага (docker compose up -d --buildв самом деле отрабатывал на LXC 151 — новый коммит, пересобранный контейнер, healthz живой, — но джоба на раннере всё равно репортилаfailure, похоже SSH-сессия рвётся на медленном канале лабы до финальногоcurl healthz). Оба раза помогdocker restart gitea-runnerна LXC 117 + пустой ретригер-коммит — после рестарта раннер подхватывал задачу и на этот раз реально доезжал до конца. Стоит на будущее: увеличить эффективный таймаут SSH-шага вdeploy.yml(ServerAliveInterval/ServerAliveCountMaxна клиенте) или разбить его на более мелкие шаги, чтобы не терять весь прогресс сообщения о статусе job'ы при обрыве соединения. - Проверено вживую (curl с хоста в лабе + Chrome):
FILIAL_ADMIN(filial1@host1.example.internal) добавил и заархивировал тестовый пункт своего чек-листа, переименовал и вернул имя тестовому ассистенту — оба через прод API. Экспорт CSV — 200, корректные заголовки, BOM+;в теле. Три новые страницы (/checklistс селектором филиала и кнопкой экспорта,/inspectionsс датой и фото-инпутами на каждый пункт,/employees) открыты в Chrome подadmin@host1.example.internal, консоль браузера чистая, элементы на месте.
Кабинеты и редактирование логинов/паролей (2026-09-03, продолжение)
По двум следующим запросам пользователя в той же сессии.
- Кабинеты для SUPER_ADMIN/FILIAL_ADMIN. Бэкенд (
POST /cabinets,GET /cabinets?filialId=) уже поддерживал это с ранних этапов — не хватало UI. Добавлена секция «Кабинеты» на страницеEmployeesPage.tsx(переименована в «Сотрудники и кабинеты»): список кабинетов филиала + форма добавления, видна SUPER_ADMIN (с выбором филиала) и FILIAL_ADMIN (свой филиал). Проверено вживую curl:POST /cabinetsпод SUPER_ADMIN — 201, кабинет появляется вGET /cabinets?filialId=. - Редактирование логина/пароля. Новый
PATCH /users/:id(UsersService.update): SUPER_ADMIN может менять email/пароль/активность любому пользователю своей сети; FILIAL_ADMIN — только пользователям с ролью ASSISTANT в своём же филиале (иначе 403Можно менять логин/пароль только сотрудников своего филиала).GET /usersдополнен опциональным?filialId=(фильтр только для SUPER_ADMIN — FILIAL_ADMIN всегда видит только свой филиал). На фронте:AssistantRowпоказывает реальный email из связанногоUserи кнопку «Логин/пароль» →EditLoginForm(email + опциональный новый пароль, шлёт только изменённые поля); новая секцияFilialAdminSection(видна только SUPER_ADMIN) — управление логином самого админа филиала, включая создание, если аккаунта ещё нет.
Проверено вживую 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!), чтобы не сломать ранее выданные учётки.
- Деплой: тот же паттерн (git bundle → litterbox → pve3 → push в Gitea), два коммита запушены вместе (кабинеты + логины, отдельным merge-коммитом из-за дивергенции веток — см. gitea-deploy). Раннер завис в третий раз на этом же пуше (SSH-шаг), тем же способом:
docker restart gitea-runnerна LXC 117 + пустой ретригер-коммит → следующий прогон дошёл до конца штатно. Подтверждён паттерн разрешения дивергенции веток наpve3:git merge --ff-onlyпервым, при ошибкеNot possible to fast-forward—git merge --no-ff -m "..."вместо него, без потери истории.
Security-аудит и фиксы (2026-09-03, продолжение)
По запросу пользователя ("проведи проверки безопасности, в том числе на брутфорс"). Живой аудит прод-инстанса (curl с хоста в лабе + чтение кода) + фиксы по итогам.
Найдено (до фикса):
- Критично: брутфорс на
/auth/loginне блокировался вообще. Живой тест — 15 подряд запросов на прод, все обработаны, ни одного 429/лока/задержки (~450мс/каждый — только bcrypt cost=12). Ни в Nest (@nestjs/throttlerне был установлен), ни в Traefik (роутdental-checklist.skud24.ru— единственный в лабе вообще без единого middleware, ниipWhiteList, ниrateLimit, ниbasicAuth). - Высоко: дефолтные пароли на проде.
filial1@host1.example.internalиassistant1@host1.example.internal— до сих порChangeMe123!(хардкод-фолбэк вprisma/seed.ts,SEED_FILIAL_ADMIN_PASSWORD/SEED_ASSISTANT_PASSWORDв проде не выставлялись). - Средне:
helmetне подключён (нет CSP/HSTS/X-Frame-Options,X-Powered-By: Expressсветит стек); CORS —cors: trueвmain.ts→Access-Control-Allow-Origin: *на все роуты. - Низко: тайминг-утечка на
/auth/login(known-email ~130мс vs unknown-email ~118мс —AuthService.loginкоротко замыкает доbcrypt.compare, если юзера нет; текст ошибки одинаковый, только тайминг);npm audit— critical/high вnode-tar(транзитивноbcrypt→@mapbox/node-pre-gyp, только build-time, в рантайме не достижимо). - Без замечаний: Prisma везде параметризован (SQLi не найден); bcrypt cost=12;
JWT_SECRETна проде 65 симв.;ValidationPipecwhitelist+forbidNonWhitelisted;.envв.gitignore, в истории git не найден; загрузка файлов — mime-whitelist +randomUUID()-имена + лимиты; тенантный скоуп (assertFilialAccess) — без обхода.
Зафикшено (тот же день, коммит 9169959 + слит с рестартом раннера в 31aceed/6a74396):
@nestjs/throttler— глобально 100/мин на IP (щедрый дефолт), наPOST /auth/loginчерез@Throttle({default:{limit:5,ttl:60_000}})— 5 попыток/мин на IP. Лимит по IP, не по email (email почти всегда угадываем —assistant1@host1.example.internalи т.п.).helmet()вmain.ts(безcontentSecurityPolicy— фронт грузится с одного origin, но внешние ресурсы не аудированы, включать CSP вслепую рискованно).- CORS сужен с
cors: trueдоapp.enableCors({origin: CORS_ORIGIN}), дефолт — прод-домен, настраивается через новую envCORS_ORIGIN(список через запятую). prisma/seed.ts: убран хардкод-фолбэкChangeMe123!—SEED_ADMIN_PASSWORD/SEED_FILIAL_ADMIN_PASSWORD/SEED_ASSISTANT_PASSWORDтеперь обязательны, сид падает без них вместо тихой установки дефолтного пароля..env.exampleобновлён.- Пароли
filial1@host1.example.internalиassistant1@host1.example.internalзаменены на новые случайные (16 симв., смешанный алфавит) черезPATCH /users/:idпод SUPER_ADMIN — новые пароли выданы пользователю в чате, в wiki не пишутся (см. общее правило про credentials в начале этого документа/в правилах сессии). СтарыйChangeMe123!подтверждён отклонённым (401) после смены. - Timing-утечка и
npm audit(node-tar) — не фикшены в этот раз, за рамками того, что пользователь запросил чинить в этой сессии; таймингова утечка низкого риска (текст ошибки не отличается), node-tar не достижим в рантайме.
Деплой: тот же паттерн, раннер завис 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, продолжение)
По трём запросам пользователя в той же сессии (два пришли по ходу работы над первым).
- Фильтры графиков по периоду и сотрудникам. Раньше
TrendChart/AssistantsBarChartне принимали параметров вообще. Новый общийDashboardQueryDto(from/to/assistantIds, все опциональные) наGET /dashboard/trendиGET /dashboard/assistants— при отсутствии параметров поведение не меняется (весь период, все сотрудники), так чтоAssistantsSummaryTableв другом месте дашборда, которая эндпоинт переиспользует без фильтров, не пострадала. На фронте — новыйChartsFilterBar(пресеты Неделя/Месяц/Квартал/Свой период + чекбоксы сотрудников с «Выбрать всех»/«Снять всех») иuseChartFilters(), состояние которых разделяетChartsModuleмежду обоими графиками — «дальнейшая динамика» (TrendChart) и агрегатный рейтинг (AssistantsBarChart) всегда показывают один и тот же срез данных. Дефолт — месяц (последние 30 дней), как просил пользователь явно. - Супер-админ назначает админов филиала. Уточнено вопросом пользователю — ответ «всё вместе»: переназначение филиала, несколько админов на филиал одновременно (раньше UI ограничивал одним), возможность деактивировать/восстановить. Новый
UpdateUserDto.filialId(только SUPER_ADMIN, только для цели с ролью FILIAL_ADMIN, новый филиал проверяется на принадлежность той же сети) вUsersService.update. Фронт (EmployeesPage.tsx,FilialAdminSection): кнопка «Назначить админа филиала» больше не прячется при наличии одного админа, у каждой строки — select с переназначением филиала и кнопка активен/неактивен. - Редактирование кабинетов.
Cabinet.active Boolean @default(true)(миграция20260903140000_cabinet_active) — кабинеты не удаляются жёстко (на них ссылаются старые проверки), только деактивируются. НовыйPATCH /cabinets/:id(UpdateCabinetDto:name?,active?), доступен SUPER_ADMIN и FILIAL_ADMIN (свой филиал, черезassertFilialAccess). Фронт:CabinetRow— инлайн-переименование + тоггл активности вместо старого статичного списка. - Деплой: тот же паттерн git bundle → litterbox → pve3 → push в Gitea, все три доработки в одном коммите
e0b48f1. Раннер завис в пятый раз за сессию (восьмой случай за всё время лабы) — тем же способом (docker restart gitea-runner+ пустой ретригер-коммит), дивергенция веток наpve3снова разрешена паттерномgit merge --ff-only || git merge --no-ff. Сборка (без новых npm-зависимостей) заняла обычные ~2 минуты, зависание раннера подтверждено поdocker logs gitea-runner(застрял сразу послеNewParallelExecutor), не спутано с долгой сборкой на этот раз. - Проверено вживую curl (SUPER_ADMIN + FILIAL_ADMIN):
GET /dashboard/trend/assistantsбез параметров — прежнее поведение (все сотрудники, весь период); сfrom/to/assistantIds(корректный id ассистента, не id логина-юзера — это разные сущности) — отфильтрованные данные, подтверждено на конкретном ассистенте (2 проверки, отфильтрованныйtrendвернул ровно точки за период). Создание второго админа филиалу, у которого уже был один — 201 (раньше UI это блокировал). Переназначение админа на другой филиал под SUPER_ADMIN —filialIdменяется, подтверждено повторнымGET /users. Деактивация/реактивация админа —isActiveменяется. FILIAL_ADMIN, пытающийся переназначить филиал (себе или другому админу) — 403 (существующая проверка скоупа отсекает раньше, чем код успевает дойти до логикиfilialId). Кабинеты: переименование и деактивация под SUPER_ADMIN и FILIAL_ADMIN (свой филиал) — 200; FILIAL_ADMIN, пытающийся отредактировать кабинет чужого филиала — 403 (assertFilialAccess).
Баги в проде и фиксы
Бесконечный цикл 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-мост, см. выше) снова протестированы и дали новый важный вывод:
- n8n SSH-мост при ручных base64-чанках оказался ненадёжен даже на маленьких кусках. Уменьшение чанка с 8000 до 2000 байт не решило проблему — при повторной отправке одного и того же 8000-байтного, а затем 2000-байтного куска подряд несколько раз, на приёмной стороне каждый раз получался разный неверный sha256 при точно совпадающем количестве байт. Это подтверждает, что порча — не разовая случайность и не проблема кодирования на исходной стороне, а систематическая ненадёжность самого транспорта (SSH-нода n8n /
printf >> fileпаттерн) на больших однострочных payload'ах, воспроизводимая на разных размерах чанка. Вывод: для файлов заметного размера через этот SSH-мост чанки использовать как последнее средство, обязательно с sha256-проверкой каждого куска сразу после дописывания, а не только по итоговому размеру файла. - Найден и проверен более надёжный способ, если
pve3может ходить в интернет (может — провереноcurlдоraw.githubusercontent.comи сторонних хостов): файл заливается на анонимный файлообменник прямо из рабочей среды агента (в этот раз сработалиhttps://litter.catbox.moeчерезlitterbox.catbox.moe/resources/internals/api.phpиhttps://d.uguu.se;0x0.st,transfer.sh,file.io,catbox.moeобычный,pixeldrain,gofile.io— в этот раз не сработали или требуют auth/токен для прямой ссылки), sha256 сверяется на стороне-источнике сразу после загрузки, затем наpve3одной командой:curl -sL -o файл URL && sha256sum файл. Это один SSH-вызов с коротким телом команды (сама ссылка короткая, а не мегабайты base64 внутри JSON) — обходит проблему порчи на корню, потому что большой payload вообще не проходит через SSH-ноду n8n построчно. Общий паттерн на будущее: сначала пробовать файлообменник + прямойcurlс проверенной ссылкой, base64-чанки через 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 | Тест на реальных данных клиента, перенос истории (если есть), запуск |
Роли и формула зафиксированы по требованиям клиента: премию по деньгам система не считает, только % выполнения — это отдаётся бухгалтерии. Отметки о нарушениях ставит старший врач/администратор (контролёр), не сам ассистент.
Связано с
- skud24-home-lab — общая топология лабы
- proxmox-pve3 — физический узел LXC 151
- traefik-ha — реверс-прокси, LXC 109 на
pve2, куда добавлен файл/opt/skud24-traefik/dynamic/dental-checklist.yml - nas-openmediavault — источник решения не использовать NFS для этого деплоя
- gatekeeper-telegram-platform — аналогичный по структуре NestJS-SaaS деплой на том же узле
pve3 - gitea-deploy — CI/CD, через который теперь деплоится этот проект
- mikrotik-router — источник и фикс дыры в NAT для порта 80 (2026-09-03)
Источник
- Сессия Cowork, 2026-08-28: планирование (7 вопросов пользователю), реализация Stage 1 backend, деплой на LXC 151, диагностика и фикс двух багов (env-переменная, порт-биндинг), настройка Traefik-маршрута.
- Сессия Cowork, 2026-08-28 (продолжение): по решению пользователя ("сначала фронтенд") реализован React SPA — логин + конструктор чек-листа (Stage 2 UI), раздача с того же origin через
@nestjs/serve-static, проверено локально (Playwright-скриншоты) и передеплоено на LXC 151/домен. - Сессия Cowork, 2026-08-28 (продолжение, "давай дальше"): реализован Stage 3 — модуль
inspectionsна бэке (моделиInspection/InspectionResultуже были в схеме с Stage 1), форма проверки на фронте (Да/Нет по каждому активному пункту, обязательное покрытие всех пунктов), расчётpercent, список проверок с цветным индикатором и разворачиваемыми результатами. Проверено локально (curl + Playwright) и передеплоено на LXC 151/домен. - Сессия Cowork, 2026-08-29: прод-деплой Stage 4 (код и локальная проверка — из прошлой сессии). Передача архива через base64-чанки по n8n SSH-мосту (файлообменники были недоступны), найдена и починена побитая посередине передача (чанк №5, диагностика по sha256 каждого чанка). Пересборка Docker, прод-healthz 200. Прод-seed через
ts-nodeне завёлся (ошибки типов/moduleResolution в облегчённом рантайм-образе) — обойдено запуском чистого JS-скрипта с той же логикой upsert. Добавлены и проверены логином демо-аккаунтыfilial1@host1.example.internal/assistant1@host1.example.internal. Домен проверен снаружи (не только сpve3) — 200. - Сессия Cowork, 2026-08-31: пользователь прислал скриншот бага — бесконечный цикл
ERR_INSUFFICIENT_RESOURCESна форме «Новая проверка». Найдена причина (немемоизированныйuseAuthedFetch()как зависимостьuseEffect), фикс уже был в коде на момент начала этой сессии (обёрнут вuseCallback). Перенос архива наpve3через n8n SSH-мост чанками оказался нестабилен даже на чанках по 2000 байт (неоднократная порча одного и того же контента с разным итоговым хешем при совпадающей длине) — вместо чанков применён новый способ: загрузка на анонимный файлообменник (litter.catbox.moe) с проверкой sha256 на обеих сторонах, скачивание наpve3одной командойcurl. Пересборка Docker (docker compose up -d --build), контейнерapiпересоздан,healthz200. Фикс проверен вживую через Chrome: логинfilial1@host1.example.internal, открыта форма «Новая проверка», сетевые запросы/api/checklist/items,/api/cabinets,/api/assistants— по одному вызову каждый, 200, без повторов; дропдауны «Кабинет»/«Ассистент» заполнены реальными данными; консоль без ошибок. - Сессия Cowork, 2026-09-03 (продолжение, после фикса TLS): пользователь запросил 4 доработки — свой чек-лист на филиал с экспортом, самостоятельное ведение списка сотрудников админом филиала, редактируемая дата проверки (постфактум по видеонаблюдению), фото на любой пункт чек-листа. Реализовано на бэке и фронте, задеплоено через Gitea (раннер дважды завис на SSH-шаге — помог рестарт + ретригер), пофикшен попутный баг (500 на экспорте CSV из-за кириллицы в
Content-Disposition), проверено вживую curl+Chrome под обеими ролями. - Сессия Cowork, 2026-09-03 (продолжение того же дня): пользователь запросил ещё 2 доработки — добавление кабинетов супер-админом и админом филиала, редактирование логинов/паролей (супер-админ — по всей сети, админ филиала — только сотрудники своего филиала). Кабинеты — только фронт (бэк уже поддерживал). Логины/пароли — новый
PATCH /users/:idс ролевым скоупингом. Задеплоено через Gitea (раннер завис в третий раз за сессию, тот же workaround), проверено вживую curl под всеми комбинациями ролей. - Сессия Cowork, 2026-09-01: пользователь запросил графики + фото происшествий (уточнено вопросом: «Тренд % по времени» + «Рейтинг ассистентов», общее фото на проверку). Мимоходом исправлена ошибочная запись в этой же странице (см. «Известные проблемы деплоя» №1) — реальный пароль
admin@host1.example.internalнайден в.env(SEED_ADMIN_PASSWORD), а не через SQL-патч, как было ошибочно записано ранее. Реализованы: Prisma-миграцияphotoUrls,UploadsModule,GET /dashboard/trend,TrendChart/AssistantsBarChartна recharts,useAuthedUpload(), фото-галерея в списке проверок.prismaперенесён вdependencies,DockerfileCMD теперь сам гоняетprisma migrate deployпри каждом старте — закрыт техдолг ручного применения миграций. Задеплоено через Gitea (тот же bundle+токен-через-CLI паттерн, что при первичной заливке — см. gitea-deploy), раннер в этот раз занял ~19 минут (новые npm-зависимости на медленном канале лабы, не зависание). Проверено вживую через Chrome (дашборд, оба графика, консоль чистая) и через curl (POST /api/uploads, раздача/uploads/...).
Комментарии