chore: add Coolify deployment scaffolding (Dockerfiles, prod compose, git hygiene)

- apps/api/Dockerfile: build NestJS, run prisma migrate deploy on start
- apps/web/Dockerfile + nginx.conf: build Vite, serve static, proxy /api -> api
- docker-compose.coolify.yml: full prod stack (postgres, redis, minio, keycloak, api, web)
- .dockerignore / .gitignore / .gitattributes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Danil Suhomlinov
2026-06-08 17:42:45 +03:00
commit 33800292aa
186 changed files with 30437 additions and 0 deletions
+502
View File
@@ -0,0 +1,502 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# HRM Medpark — Полное описание системы
> HR-management система для **Medpark International Hospital** (Кишинёв, Молдова), заменяющая Excel/MS Forms процессы для отдела кадров и контроля качества медицинского персонала.
>
> UI — только на румынском (`ro`). Backend сообщения / валидации — также на румынском.
---
## 1. Архитектура
### Monorepo (pnpm workspace)
```
hrm-medpark/
├── apps/
│ ├── api/ ← NestJS + Prisma + PostgreSQL
│ └── web/ ← React + Vite + Mantine
├── docker-compose.yml ← Postgres, Redis, Keycloak, MinIO
└── package.json ← root scripts (api:dev, web:dev, db:migrate)
```
### Stack
| Слой | Технологии |
|------------|------------------------------------------------------------------------------------|
| Backend | NestJS 10 (Fastify), Prisma 5, PostgreSQL 16, BullMQ (Redis), nestjs-i18n, Passport-JWT (Keycloak JWKS) |
| Frontend | React 18, Vite, Mantine v7, React Router v6, TanStack Query v5, react-hook-form + Zod, dayjs |
| Auth | Keycloak (внешний IdP), JWT валидация через JWKS |
| Storage | MinIO (S3-совместимый) для DOCX-документов |
| Notif | n8n webhooks (cron-based reminders) |
| Документы | `docx` (npm) — рендер из TipTap-JSON шаблонов (`AnexaTemplate`), редактируются в `/admin/anexa-templates` |
### Глобальный API префикс
Все REST-эндпоинты под `/api/v1/...` (через `app.setGlobalPrefix('api/v1')`).
Vite proxy на dev: `/api → http://localhost:3001`.
---
## 2. Аутентификация
- Токены выдаёт Keycloak.
- `KeycloakStrategy` (passport-jwt) валидирует через JWKS.
- Роли извлекаются из `realm_access.roles` и `resource_access[clientId].roles`.
- Если в токене нет ни одной из 7 HRM-ролей — `UnauthorizedException`.
### Роли
| Role | Назначение |
|-------------------|------------------------------------------------------------|
| `hr_admin` | Полный CRUD по сотрудникам, документам, кампаниям, риск-картам |
| `hr_specialist` | CREATE/UPDATE по основным сущностям, без удаления |
| `manager` | Редактирование форм оценки своих подчинённых |
| `nursing_director`| Утверждение финальной категории при оценке |
| `quality_auditor` | Заполняет блоки A-D в формах оценки |
| `medic_familie` | Выдаёт вердикт по контролю медиц. (apt / inapt / etc.) |
| `employee` | Read-only доступ к собственным данным |
`@Roles(...)` декоратор + `RolesGuard` на каждом контроллере.
---
## 3. База данных (Prisma, 20 моделей)
Ключевые сущности:
```
Employee (1) ──┬── (N) IdentityDocument
├── (N) FamilyMember
├── (N) Education
├── (N) Qualification
├── (N) Training
├── (N) DisciplinarySanction
├── (N) EmploymentContract
├── (1) Benefit ← upsert (one-to-one)
├── (1) EmployeeMedicalProfile ← upsert
├── (N) MedicalCheckup
└── (N) PerformanceEvaluation
Department (self-referencing tree, parentId) — adjacency list
WorkplaceRiskCard (1) ── (N) EmployeeMedicalProfile ← FK
PerformanceCampaign (1) ── (N) PerformanceEvaluation
```
### Reference (read-only) таблицы:
- `DisabilityGrade`, `TaxExemption`, `WorkSchedule`
### Поля типа `@db.Date` — используются для дат без времени (рождение, экспирация).
### `@db.Decimal` — для денежных и физических величин (зарплата, дозы радиации).
### Audit log
- `AuditService.logRead(...)` и `logChange({ action: 'CREATE'|'UPDATE'|'DELETE' })`.
- `AuditModule` помечен `@Global()` — доступен везде без явного импорта.
- `PrismaModule` тоже `@Global()`.
---
## 4. Backend — модули
```
apps/api/src/modules/
├── auth/ ← KeycloakStrategy + AuthGuard
├── employees/ ← основной CRUD + 7 sub-resources
│ ├── employees.{controller,service,module}.ts
│ ├── dto/{create,query}-employee.dto.ts
│ └── sub-resources/
│ ├── identity-documents/
│ ├── family-members/
│ ├── educations/
│ ├── qualifications/
│ ├── trainings/
│ ├── disciplinary-sanctions/
│ ├── benefit/
│ ├── contracts/
│ └── sub-resources.service-factory.ts ← общая фабрика subCreate/subUpdate/subRemove
├── departments/ ← дерево отделов
├── reference/ ← read-only справочники, без аудита
├── evaluation/ ← Phase 4 — оценка персонала
│ ├── evaluation.{controller,service,module}.ts
│ ├── dto/{create-campaign,update-form,approve-form}.dto.ts
│ └── workers/evaluation-notifications.processor.ts ← BullMQ
├── medical/ ← Phase 5 — медицинский контроль
│ ├── medical.{controller,module}.ts
│ ├── dto/{risk-card,medical-profile,checkup}.dto.ts
│ └── services/
│ ├── risk-cards.service.ts
│ ├── medical-profile.service.ts
│ ├── checkup.service.ts
│ ├── bulk.service.ts
│ ├── document-generator.service.ts ← рендер из TipTap-JSON через tiptap-to-docx
│ ├── tiptap-to-docx.ts ← конвертер TipTap doc → docx, поддержка repeatRows + variableChip
│ └── storage.service.ts ← MinIO client
├── admin/
│ └── anexa-templates/ ← CRUD на AnexaTemplate (only hr_admin), drafts + версии
├── inventory/ ← Vestimentație / Echipament — InventoryItem CRUD + adjust-stock
├── contracts/ ← глобальный список CIM (отдельная страница)
├── notifications/ ← BullMQ daily-expiry cron (08:00 EE/Bucharest) → n8n webhook
│ └── workers/daily-expiry.processor.ts
└── dashboard/ ← агрегированная статистика
```
### Sub-resource pattern (employees)
Все вложенные ресурсы (документы, семья, образование...) идут под:
```
GET /employees/:employeeId/<resource>
POST /employees/:employeeId/<resource>
PATCH /employees/:employeeId/<resource>/:id
DELETE /employees/:employeeId/<resource>/:id
```
Сервисы используют общую фабрику `subCreate/subUpdate/subRemove` с проверкой принадлежности к employeeId. Все write-операции пишут в audit log.
Роли по умолчанию:
- `GET` — все HR роли
- `POST/PATCH``hr_admin`, `hr_specialist`
- `DELETE` — только `hr_admin`
### Особенности
- **DisciplinarySanctions**: `dataExpirarii = dataAplicarii + 6 месяцев` вычисляется на сервере, не принимается от клиента.
- **Benefit**: используется `prisma.benefit.upsert()` — у сотрудника всегда максимум одна запись.
- **EmployeeMedicalProfile**: `dozaTotalaMsv` — computed поле (`externa + interna`) в response.
---
## 5. Frontend — pages & роутинг
```
apps/web/src/
├── App.tsx ← AppShell + NAV_ITEMS + Routes
├── main.tsx ← Mantine theme (medpark teal #008286, Montserrat)
├── api/
│ ├── client.ts ← axios baseURL '/api/v1' + bearer token
│ └── types.ts ← все TS-интерфейсы синхронизированные с Prisma
├── pages/
│ ├── auth/LoginPage.tsx
│ ├── dashboard/DashboardPage.tsx
│ ├── employees/
│ │ ├── EmployeesPage.tsx ← список + поиск + фильтр
│ │ ├── EmployeeDetailPage.tsx ← header + 10 табов
│ │ ├── employeeSchema.ts ← Zod + IDNP checksum
│ │ ├── components/
│ │ │ ├── EmployeeHeader.tsx
│ │ │ └── EmployeeDrawer.tsx ← создание / редактирование
│ │ ├── tabs/
│ │ │ ├── PersonalTab.tsx
│ │ │ ├── DocumenteTab.tsx ← подсветка expirare < 30 дней (amber/red)
│ │ │ ├── FamilieTab.tsx
│ │ │ ├── StudiiTab.tsx
│ │ │ ├── CalificariTab.tsx
│ │ │ ├── TrainingTab.tsx
│ │ │ ├── SanctiuniTab.tsx ← активные (не stinsa) = красная строка
│ │ │ ├── BeneficiiTab.tsx
│ │ │ ├── ContracteTab.tsx
│ │ │ └── MedicalTab.tsx
│ │ └── drawers/
│ │ └── *Drawer.tsx ← по одному на каждый sub-resource
│ ├── departments/DepartmentsPage.tsx ← дерево с раскрытием
│ ├── evaluation/
│ │ ├── EvaluationPage.tsx ← список кампаний
│ │ ├── CampaignDetailPage.tsx ← список форм / категорий
│ │ ├── EvaluationFormPage.tsx ← заполнение блоков A-D
│ │ └── components/{ScoreInput,StatusBadge,CategoryBadge}.tsx
│ └── medical/
│ ├── RiskCardsPage.tsx ← карты риска NU-10-MS-2026
│ ├── MedicalControlPage.tsx ← bulk-select сотрудников + Generează documente
│ └── MedicalInboxPage.tsx ← inbox для medic_familie
└── styles/global.css ← CSS-переменные бренда
```
### Маршрутизация (фрагмент App.tsx)
```
/ → DashboardPage
/employees → EmployeesPage
/employees/:id → EmployeeDetailPage
/departments → DepartmentsPage
/evaluation → EvaluationPage
/evaluation/:id → CampaignDetailPage
/evaluation/form/:id → EvaluationFormPage
/risk-cards → RiskCardsPage
/medical → MedicalControlPage
/medic-inbox → MedicalInboxPage
```
> Маршруты `/admin/templates` и `/admin/templates/:type` удалены. Бэкенд-API `admin/anexa-templates` (CRUD + версии) сохранён.
### State management
- **TanStack Query** — fetch + кэш. Reference data: `staleTime: 300_000` (5 мин).
- Все табы сотрудника шарят один `useQuery(['employee', id])` — нет per-tab fetch'ей.
- Sub-resource записи делают `invalidateQueries(['employee', id])` → весь профиль рефетчится.
- Ключ кэша для медицины: `['medical-profile', employeeId]`, `['medical-checkups', employeeId]`, `['risk-cards']`.
---
## 6. Бизнес-логика — критичные алгоритмы
### IDNP (Moldovan ID number) — 13 цифр
Реализовано **дважды**: на бэке (custom validator в DTO) и на фронте (Zod) для live-фидбека.
```ts
function validateIdnp(idnp: string): boolean {
const weights = [7, 3, 1, 7, 3, 1, 7, 3, 1, 7, 3, 1];
const sum = weights.reduce((acc, w, i) => acc + w * +idnp[i], 0);
return sum % 10 === +idnp[12];
}
```
### Performance Evaluation — расчёт категории
```
function calculateCategory(form):
total = blockA + blockB + blockC + blockD + (testJci * 0.1)
if total >= 90 && expert_score >= threshold → 'superioara'
else if total >= 75 → 'cat_I'
else if total >= 50 → 'cat_II'
else → 'fara_categorie'
```
### Eligibility для оценки
Сотрудник попадает в кампанию если стаж в компании на cutoff > 6 месяцев (по `dataAngajarii` контракта).
### Anexa-документы (NU-10-MS-2026)
Генерируются в `document-generator.service.ts` через `docx` библиотеку:
| Документ | Когда генерируется |
|--------------------------|--------------------------------------------------------|
| Anexa 3 (Fișa solicitare) | при инициации контроля (bulk) |
| Anexa 4 (Fișa evaluare) | при инициации контроля (bulk) |
| Anexa 4B (Suplim. radiații) | если `expusRadiatiiIonizante = true` |
| Anexa 6 (Verdict) | после `medic_familie` ставит вердикт (`/checkups/:id/complete`) |
DOCX сохраняются в MinIO (`s3://hrm-docs/<key>`), URL пишется в `MedicalCheckup.documenteGenerate` (JSON массив `{name, url, type}`).
### Скачивание / удаление документов
- **Скачивание**: фронт извлекает key из `s3://bucket/key`, вызывает `GET /medical/documents/presign?key=...`, открывает presigned URL в новой вкладке.
- **Удаление одного**: `DELETE /medical/checkups/:id/documents?name=...` — удаляет из MinIO И из массива.
- **Удаление всех**: `DELETE /medical/checkups/:id/documents/all` — параллельно убирает все файлы из MinIO + чистит массив.
### Контроль медицинский — типы
```
la_angajare ← перед трудоустройством
periodic ← плановый по карте риска
la_reluarea_activitatii ← после длительного отсутствия
la_incetarea_expunerii ← при увольнении из вредной среды
suplimentar ← по запросу
```
При `complete` для типов `la_angajare/periodic/la_reluarea_activitatii` обновляется `EmployeeMedicalProfile.dataUltimControlMedical` через `updateMany`.
### Notifications (BullMQ)
`evaluation-notifications.processor.ts``@Process('campaign-reminder')`. Постит в n8n webhook с массивом сотрудников. Cron-планирование на 14 дней до ожидаемого срока.
---
## 7. Brand & UX
### Цвета (Medpark)
- **Teal** `#008286` — primary, акценты, ссылки
- **Charcoal** `#58595b` — основной текст
- **Amber** `#fbb034` — предупреждения (умеренно)
- **Red** `#b11116` — деструктив, истёкшие даты, активные санкции
### Шрифт
- **Montserrat**, weights 300/500/600/700 (импортируется в `index.html`)
### Mantine theme
```ts
const medparkTeal = ['#e6f4f4', ..., '#008286', ..., '#003d3f']; // 10 shades
createTheme({
fontFamily: "'Montserrat', Arial, sans-serif",
primaryColor: 'medpark',
colors: { medpark: medparkTeal },
});
```
### Конвенции
- Заголовки страниц: `<Title order={2}>` + teal underline (40×3 px, `borderRadius: 2`)
- Таблицы: `borderBottom: '2px solid teal'` на `<thead>`, `borderBottom: '1px solid #e9ecef'` на строках
- Кнопки primary: `background: teal`, `fontWeight: 500`, `height: 40`
- Drawers: `size="xl"`, секции с teal-left-border divider
- Badges по статусам: `medpark` (success), `red` (danger), `gray` (neutral), `orange` (warning)
- DateInput: `valueFormat="DD.MM.YYYY"`, на бэк отправляем `YYYY-MM-DD` (через `dayjs`)
### Подсветка дат
```ts
const days = dayjs(dataExpirarii).diff(dayjs(), 'day');
if (days < 0) return 'red'; // истёкло
if (days < 30) return 'amber'; // истекает скоро
```
---
## 8. Интернационализация
- Только румынский (`ro`).
- Backend: nestjs-i18n с файлом `apps/api/i18n/ro/translation.json`.
- Frontend: ключи захардкожены в компонентах (без i18next), либо в `apps/web/src/i18n/ro.json`.
> Языковой переключатель в UI **отсутствует** — было удалено по требованию.
---
## 9. Workflow разработки
### Запуск
```bash
# 1. Инфраструктура
docker compose up -d # postgres, redis, keycloak, minio
# 2. Миграции (первый раз)
pnpm db:migrate
# 3. Dev mode
pnpm dev # одновременно api (3001) + web (5173)
# или раздельно:
pnpm api:dev
pnpm web:dev
```
### Доступ
- Frontend: http://localhost:5173
- API: http://localhost:3001/api/v1
- Prisma Studio: `pnpm db:studio`
- Keycloak admin: http://localhost:8080
- MinIO console: http://localhost:9001 (minioadmin/minioadmin)
### При изменении схемы
```bash
# отредактировать apps/api/prisma/schema.prisma
pnpm --filter api prisma:migrate dev --name <migration_name>
# Prisma Client регенерится автоматически
```
### Линт / типчек
```bash
pnpm --filter api typecheck
pnpm --filter web typecheck
```
---
## 10. Соглашения по коду
### TypeScript
- `apps/web/src/api/types.ts` — единственный источник истины для интерфейсов на фронте; обновляется вручную при изменении Prisma-схемы.
- DTO бэкенда → используют `class-validator` декораторы.
### Auth в контроллерах
```ts
@Controller('...')
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class ... {
@Get(...)
@Roles('hr_admin', 'hr_specialist')
...(@Request() req: AuthReq) {
return this.svc.method(..., req.user.id, req.user.role);
}
}
```
### Audit
Каждый write-метод в сервисе ОБЯЗАН вызвать `this.audit.logChange({ userId, userRole, action, entity, entityId })`.
Read-методы — `logRead({ userId, userRole, entity, entityId })` для чувствительных данных.
### React Query mutations
```ts
const mutation = useMutation({
mutationFn: (data) => apiClient.post(...),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['...'] });
notifications.show({ color: 'medpark', title: '...', message: '...' });
},
onError: (err) => {
const msg = (err as any)?.response?.data?.message ?? 'Eroare';
notifications.show({ color: 'red', title: 'Eroare', message: msg });
},
});
```
---
## 11. Roadmap & статус
| Phase | Статус | Что входит |
|------------------------------|--------------|--------------------------------------------------------------|
| Phase 1: Employee master data | ✅ Done | CRUD + 8 sub-resources + drawer + детальная страница |
| Phase 2: Contracts | ⚠️ Частично | Schema + sub-resource controller, но нет специальной страницы |
| Phase 3: Departments | ✅ Done | Tree CRUD |
| Phase 4: Performance Evaluation | ✅ Done | Кампании, формы, утверждение, BullMQ напоминания |
| Phase 5: Medical Control | ✅ Done | Risk Cards, профили, checkups, bulk + DOCX, MinIO, inbox |
| Phase 5b: Anexa template editor | ✅ Done | TipTap v3 редактор шаблонов в `/admin/anexa-templates` (бэкенд + API), repeatRows, verdict.checkbox; **UI редактора удалён из фронтенда** |
| Phase 5c: Inventory | ✅ Done | InventoryItem CRUD, atomic stock $transaction в Benefit upsert |
| Phase 5d: Notifications | ✅ Done | BullMQ daily-expiry cron → n8n webhook (документы, calificări, contracte, medical, sancțiuni) |
| Phase 6: Polish | 🔲 Pending | Дашборды per-role, performance тесты, GDPR DPIA |
### Pending hardening (TODO)
-`POST /auth/dev-login` защищён `NODE_ENV === 'production'` guard (`ALLOW_DEV_LOGIN=true` для тестового продакшена)
- ✅ BullMQ daily cron → n8n webhook (docs/categories/contracts/medical expiry) — `notifications` модуль
- 🔲 Excel HR import (`POST /employees/import` через `exceljs`) — НЕ требование, отложено
- 🔲 Seed data для DisabilityGrade / TaxExemption (department seed готов)
- 🔲 Dev-mode Keycloak bypass (сейчас все API возвращают 401 без валидного JWT)
---
## 12. Где искать что
| Задача | Файл / директория |
|-------------------------------------|------------------------------------------------------------------|
| Поменять схему БД | `apps/api/prisma/schema.prisma` |
| Добавить эндпоинт | `apps/api/src/modules/<module>/*.controller.ts` |
| Изменить роли доступа | `@Roles(...)` в контроллере |
| Изменить тему / цвета | `apps/web/src/main.tsx` + `apps/web/src/styles/global.css` |
| Поменять навигацию | `apps/web/src/App.tsx``NAV_ITEMS` |
| Добавить TypeScript-тип | `apps/web/src/api/types.ts` |
| Добавить sub-resource сотрудника | `apps/api/src/modules/employees/sub-resources/<name>/` |
| Изменить DOCX-документ | `apps/api/src/modules/medical/services/document-generator.service.ts` (рендер) + `tiptap-to-docx.ts` (конвертер) + `/admin/anexa-templates` UI (контент) |
| Поправить cron-нотификации | `apps/api/src/modules/notifications/notifications.service.ts` (правила expiry) + `notifications.module.ts` (cron-расписание) |
| Управление складом | `apps/api/src/modules/inventory/` + `apps/web/src/pages/inventory/` |
| Аудит-логи | таблица `AuditLog`, доступ через `AuditService` |
| Reference-справочники | `apps/api/src/modules/reference/reference.controller.ts` |
---
## 13. Полезные команды
```bash
# Открыть Prisma Studio (визуальный БД-инспектор)
pnpm db:studio
# Создать миграцию после изменения schema.prisma
pnpm --filter api exec prisma migrate dev --name <name>
# Перегенерировать Prisma Client
pnpm --filter api exec prisma generate
# Type check всего монорепо
pnpm -r typecheck
# Сбросить БД (только в dev!)
pnpm --filter api exec prisma migrate reset
```