Releases: dripips/rubby-hrms
Release list
v2.1 — Multi-tenant complete
v2.1 — Multi-tenant cleanup complete
Завершение архитектурного сдвига v2.0. Все legacy callsites мигрированы, e2e-тесты подтверждают изоляцию tenants.
Что приехало
Migrated 45 of 68 callsites (через скрипт + ручная проверка). Bulk-pattern:
- Controllers (inheriting `ApplicationController`) → `current_company` helper
- API / services / jobs / models / mailers → `Current.company || Company.kept.first` (explicit fallback chain)
Files touched (40+ контроллеров и сервисов):
- 7 AI controllers (`ai/applicants`, `ai/kpi`, `ai/leaves`, `ai/offboarding`, `ai/onboarding`, `ai/openings`, `ai/rounds`)
- 14 главных контроллеров (`dashboard`, `employees`, `job_openings`, `leave_requests`, etc)
- 12 settings контроллеров
- Mailers, helpers, jobs, models, notifiers, services
Remaining 23 callsites — все explicit `Current.company || Company.kept.first` fallback chains, не legacy. Это и есть целевой v2.0 pattern.
E2E multi-tenant spec (10 tests)
`spec/requests/multi_tenant_spec.rb`:
- ✅ Subdomain resolution: `acme.example.com` → `Company.find_by(subdomain: "acme")`
- ✅ Apex fallback: `example.com` → first company (single-tenant compat)
- ✅ Unknown subdomain → 404 (HTML for browser, JSON for /api/ paths* with `{error: "tenant_not_found"}`)
- ✅ Ignored subdomains (`www` / `api` / `app`) → fallback OK
- ✅ AppSetting isolation: per-tenant settings не пересекаются (verified with 2 real companies)
- ✅ Employee scope: company-scoped queries возвращают только свои
- ✅ `current_company` helper: delegates to `Current.company`, falls back to first
Quality
- 142 examples (+10 multi-tenant), 0 failures
- Rubocop: 0 offenses on 259 files
- Zeitwerk: clean
Что это даёт
Теперь HRMS реально готов крутиться в SaaS-режиме:
- `acme.hrms.example.com` → видит только данные Acme
- `zenith.hrms.example.com` → видит только данные Zenith
- `hrms.example.com` (без поддомена) → fallback на первую компанию (single-tenant установка работает как и раньше)
Hosted demo на одной инстансе для нескольких клиентов теперь технически возможен.
v2.0.1 — OCR scan-PDF + CSP + Accessibility
v2.0.1 — Polish pass
Three quality-focused improvements without breaking changes.
📄 OCR scan-PDF — реально работает
Раньше `TextExtractor#extract_from_image_pdf` возвращал "ocr_pdf_not_implemented_locally". Теперь — полноценная цепочка:
`scan PDF → pdftoppm (poppler) → PNG per page → tesseract (rus+eng) → join → text`
- Dockerfile уже содержит `poppler-utils` + `tesseract-ocr-rus/eng` — на продакшен работает из коробки
- Cached detection of `pdftoppm` (class-level singleton) → не shell-out'ит каждый раз
- Windows dev / setup без OCR-инструментов получает `empty_result("poppler_not_installed")` — graceful
🛡️ CSP report-only в production
Foundation для CSP. Сейчас в report-only — браузер шлёт violations но ничего не блокирует. Через 2 недели наблюдения и устранения false-positive → можно включать enforce.
- `config/initializers/content_security_policy.rb` — реальная политика с CDN allowlist (jsdelivr, google-fonts, mc.webvisor / mc.yandex для Metrika)
- `POST /csp_violations` принимает оба формата (классический `csp-report` + новый reporting-api array), пишет через `Rails.error.report` в `log/errors.log`
♿ Accessibility quick wins
- Skip-to-main link — keyboard-only юзеры миновают sidebar+topbar одним `Tab`
- Universal `:focus-visible` outline (3px blue) — WCAG 2.1 minimum
- Notifications bell с aria-label с unread count для screen-reader'ов
Specs
- +12 examples (132 total), 0 failures
- Rubocop: 0 offenses on 258 files
- Zeitwerk: clean
Next: v2.1
Multi-tenant cleanup (replace remaining `Company.kept.first` callsites) + end-to-end test with two tenants.
v2.0 — Multi-tenant + Docker deploy 🚀
v2.0 — Multi-tenant foundation + Docker deploy story
Major architectural shift: foundation for hosting multiple companies on one HRMS instance + production-grade Docker deployment.
🏢 Multi-tenant routing
- Each company can have a subdomain (e.g. `acme`)
- HRMS at `https://acme.hrms.example.com\` → `Current.company = Company.find_by(subdomain: "acme")`
- `https://hrms.example.com\` → falls back to first company (single-tenant compatibility)
- Subdomain `acme.hrms.example.com` with no matching company → 404 (JSON for API, HTML for browser)
- `IGNORED_SUBDOMAINS = [www, api, app]` → marketing pages keep working
Setting up: superadmin sets the subdomain in Settings → Коммуникации → 🏢 Subdomain компании. Validation: `[a-z0-9-]{1,50}`.
Migration path: 6 hot files (introduced in v1.6+) now use `Current.company` / `current_company` helper. 14 legacy callsites still use `Company.kept.first` — they work via fallback and will migrate incrementally in v2.1.
🐳 Docker deploy
- `scripts/update.sh` — zero-downtime upgrade:
- Backup DB + storage (auto-calls `scripts/backup.sh`)
- `git checkout` to target tag (or latest master)
- `docker compose build` new image
- Migrations run on new image BEFORE container swap (no broken intermediate state)
- Rolling restart of `app` + `worker`
- Healthcheck loop on `/up` for 60s
- On failure: prints rollback command
- `docker-compose.prod.yml` override:
- Pre-built image (`APP_IMAGE=ghcr.io/...`) instead of local build
- Healthcheck on app container
- PostgreSQL tuning: shared_buffers 512MB, work_mem 16MB
- JSON-file logging with rotation
- Puma: 3 workers × 10 threads
Run with: `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d`
Migration
- `companies.subdomain` (unique partial index, nullable)
i18n
- +6 keys × 3 locales
Quality
- Zeitwerk: clean
- 120 specs, 0 failures
- Rubocop: 0 offenses on 255 files
Next: v2.1 (incremental tenant refactor)
- Replace remaining 14 legacy `Company.kept.first` callsites
- Test multi-tenant flow end-to-end with two real tenants
- Per-tenant settings ownership (one company can't read another's AppSetting)
v1.12 — Chat with context-bound discussions + CalDAV
v1.12 — Chat that knows context
Чат в HRMS теперь живёт двумя слоями:
- Standalone direct messages на `/conversations` (как Telegram-lite)
- Context-bound discussions прямо на странице сущности (Leave Request / Job Applicant) — обсуждение прикреплено к конкретному артефакту, без переключения вкладок
💬 Standalone chat
- `/conversations` — список с unread-badge в sidebar nav
- `/conversations/new` — multi-recipient selector
- Composer: Enter=send, Shift+Enter=newline
- Live broadcast через Turbo Streams — новое сообщение прилетает в открытые вкладки участников без F5
- Read-tracking (last_read_at) → unread-каунтер обнуляется при открытии
🎯 Context-bound discussions (главная фича)
- Открой `/leave_requests/123` → внизу страницы дискуссия с HR + manager + employee автоматически
- `/job_applicants/456` → recruiter + HR могут обсуждать кандидата inline
- `Conversation.discussion_for(target, viewer)` — idempotent: 1 тред на 1 сущность, новые участники подтягиваются автоматически
- В общем `/conversations` context-bound треды получают tag `🌴 Leave #123` со ссылкой на сущность
- Pivot из «ещё один Slack»: discussion привязан к артефакту → видно зачем вообще писать сообщение
📅 CalDAV subscribe feed
`GET /api/v1/me/calendar.ics?token=` — подписываемый feed для Google Calendar / Outlook / Apple Calendar:
- Интервью пользователя (interviewer = current_user, ±диапазон 30d/180d)
- Дни рождения сотрудников (для HR/manager/superadmin, ≤90d вперёд)
- Без cancelled/no_show
- Bearer auth через `?token=...` query-param (Google Calendar не умеет Authorization header — поэтому fallback)
Migrations
- `conversations` (with polymorphic `targetable`, unique partial index)
- `conversation_participants` (composite unique)
- `messages` (conversation_id + created_at)
i18n
- +22 keys × 3 locales → 2355/2355/2355
Quality
- 120 examples, 0 failures (1 pre-existing pending)
- Rubocop: 0 offenses
- Zeitwerk: clean
v1.11 — Authenticated API (Bearer tokens)
v1.11 — API expansion: 5 → 15 endpoints + Bearer auth
Public API was narrow (careers-embed only). Now mobile clients, scripts and integrations have full self-service.
🔑 API Tokens
- Generate in Profile → Security → API tokens → "Issue token"
- Format: `hrms_<8-char-prefix>_<64-hex-raw>`
- Stored only as bcrypt hash + visible prefix — even a compromised DB doesn't leak working tokens
- One-time reveal: token shown ONCE on creation, then masked everywhere
- Revoke any token anytime → all clients using it get 401 immediately
- `last_used_at` tracked (rate-limited to 60s)
- Optional `expires_at` per token
🆕 New endpoints
Public (no auth):
- `GET /api/v1/departments` — for filter dropdowns
- `GET /api/v1/positions`
Authenticated (Bearer):
- `GET /api/v1/me` / `PATCH /api/v1/me` — profile, locale, time_zone, integrations
- `GET /api/v1/me/leave_requests` / `POST` — list + create
- `GET /api/v1/me/kpi` — assignments + evaluations
- `GET /api/v1/me/documents` — paginated
- `GET /api/v1/me/notifications` (?filter=unread)
- `POST /api/v1/me/notifications/:id/read` / `POST .../read_all`
📖 Swagger UI updated
Open `/api-docs/` — all 15 endpoints listed with Bearer auth flow, request bodies, response schemas, error responses (401/404/422). Try-it-out enabled.
Examples
```bash
TOKEN="hrms_abc12345_$(generated)"
curl -H "Authorization: Bearer $TOKEN" /api/v1/me
curl -H "Authorization: Bearer $TOKEN" /api/v1/me/kpi
Request leave
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json"
-d '{"leave_type_id":1,"started_on":"2026-08-01","ended_on":"2026-08-14"}'
/api/v1/me/leave_requests
```
Quality
- 118 examples (was 93 in v1.10), 0 failures
- Rubocop: 0 offenses
- Brakeman: 0 warnings
v1.10 — RSpec coverage + OpenAPI/Swagger
v1.10 — Coverage push + Public API docs + 4 prod bugfixes
🧪 Coverage
RSpec went from 38 → 93 examples (smoke + 5 new suites). Writing the suites caught 4 production bugs that would have hit users.
| Suite | Examples |
|---|---|
spec/services/icalendar_builder_spec.rb |
13 (RFC 5545: escape, fold, UTC, multi-event) |
spec/services/ai_budget_guard_spec.rb |
9 (thresholds, cache TTL, invalidate) |
spec/services/gdpr_exporter_spec.rb |
7 (JSON serializability, all 10 sections) |
spec/services/gdpr_deleter_spec.rb |
6 (anonymization, transactional atomicity) |
spec/requests/telegram_webhooks_spec.rb |
8 (secret verify, /start binding, TTL expiry, error swallowing) |
spec/requests/api/v1/openings_spec.rb |
10 (paginated list, search, show, config) |
🐛 Bugfixes (caught by tests)
IcalendarBuilder#escape—gsubreplacement special-case was halving backslashes →\in input →\in output → RFC 5545 violation. Moved to block-form. Would break any calendar import containing backslash.GdprExporter—a.target_value→a.target(same bug as v1.4 dashboard widget; column never existed). DSAR export crashed for any user with KPI assignments.GdprExporter—kpi_evaluations.comment→.notes(wrong column name).GdprDeleter—gender: nilon employees table that isNOT NULL integer default 0→PG::NotNullViolationrolled back the whole transaction. Users could not delete their accounts in production. Changed togender: 0.- API routes —
resources :openingsproduced:idparam but controller readparams[:code]→/api/v1/openings/JOB-0001returned 404. Addedparam: :code.
📖 Public API documentation
public/api-docs/openapi.yaml— OpenAPI 3 spec for/api/v1/{ping,openings,openings/:code,openings/:code/apply,config}. Honeypot field, X-API-Key, multipart/form-data, pagination, search/filter parameters — all documented.public/api-docs/index.html— interactive Swagger UI served from CDN. Try-it-out enabled.- Live at
https://your-instance.com/api-docs/ - README has a new "Public API" section with curl examples.
Stats
- 93 examples, 0 failures (1 pre-existing pending)
- Rubocop: 0 offenses
- Brakeman: 0 warnings
v1.9 — OSS hygiene + Performance
v1.9 — OSS hygiene + Performance instrumentation
📚 OSS hygiene
Three files every serious open-source project needs:
- CHANGELOG.md — compiled history of v1.0–v1.8 in Keep-a-Changelog format. Sections per release: Added / Fixed / Migration / Security / i18n.
- ROADMAP.md — done milestones, v1.9–v1.12 planned items, v2.0 multi-tenant + Docker deploy story (per request: Docker container + scripts/install.sh rewrite + scripts/update.sh for zero-downtime upgrades + docker-compose.prod.yml + hosted demo).
- CONTRIBUTING.md — local setup, conventions, test/lint commands, i18n parity workflow, screenshot regen, PR flow, "good first issue" suggestions.
⚡ Performance instrumentation
- bullet (dev + test groups) — N+1 detector. Enabled in dev with rails footer panel + log warnings. Test-mode raise is deferred to v1.11 (need baseline audit first — current full-seed smoke would fail).
- rack-mini-profiler (dev only) — speed badge in page corner.
/?pp=helpfor options. config/initializers/bullet.rb— guarded withreturn unless defined?so prod without the gem still boots.
Next: v1.10 will use bullet output to fix N+1 baseline, add fragment caching to hot dashboard widgets, and push RSpec coverage to >80%.
v1.8 — Smoke CI guards + .ics export
v1.8 — CI runtime guards + Calendar .ics export
CI: ловим runtime-ошибки ДО merge'а
Раньше CI проверял только статику (Rubocop, Brakeman) и unit-тесты — runtime-ошибки в шаблонах ускользали. Уже было два таких бага в продакшене: `a.target_value` на KpiAssignment, `@employee.gender_ref` — оба пробились через CI и упали у пользователя.
- Zeitwerk autoload check — ловит class-naming mismatches (typo в имени класса → файл не загружается)
- Smoke spec (`spec/requests/smoke_spec.rb`) — 38 GET-проверок: логин как admin, обход всех ключевых страниц (dashboard, recruitment, kpi, documents, profile-tabs, settings-tabs, ai_runs, careers). Любой `ActionView::Template::Error` теперь блокирует merge.
Bonus fix: seeds.rb broken на свежей БД
В `db/seeds.rb` юзеры создавались после `employees.rb`, который их ищет через `User.find_by!`. На fresh DB seed падал. В dev обходилось потому что юзеры существовали из предыдущих ручных запусков. Перенёс блок выше + пароль `password123` теперь и в test env (для smoke-логина в CI).
📅 .ics export для календаря интервью
- `app/services/icalendar_builder.rb` — мини-RFC-5545 generator без gem'а `icalendar` (экономия ~50KB transitive deps). UTF-8, escape TEXT, fold-lines >75 octets.
- `GET /recruitment/calendar.ics` → отдаёт upcoming-60-дней (или одно через `?round_id=`)
- Кнопка «📅 Экспорт в .ics» в page-header `/recruitment/calendar`
- Скачанный файл подписывается в Google Calendar / Outlook / Apple Calendar (одинаково везде по стандарту)
i18n
- +2 ключа × 3 локали → паритет 2318/2318/2318
v1.7 — One-click Telegram linking
v1.7 — One-click Telegram + manual fallback
Сотрудник подключает Telegram одной кнопкой — открывается бот, жмёт Start, готово. Manual путь через @userinfobot остаётся как fallback.
Flow
- Сотрудник на /profile/integrations жмёт «🔗 Открыть Telegram»
- Бэк генерит one-time токен (10 мин TTL) → редирект на `t.me/?start=`
- Сотрудник жмёт Start в Telegram-приложении
- Telegram POST'ит наш `/telegram/webhook` с подписью в `X-Telegram-Bot-Api-Secret-Token`
- Webhook валидирует secret, матчит token, сохраняет chat_id, бот пишет юзеру "✓ Готово, привязано к email@..."
Setup
Админу в /settings/communications:
- Кнопка Зарегистрировать webhook → setWebhook + generated secret_token
- Бэдж «активен/не настроен», re-register / remove
- На localhost блокируется с подсказкой про `APP_BASE_URL` + ngrok-туннель
UI states
Profile/integrations показывает один из трёх блоков:
- Webhook активен → большая primary-кнопка "Открыть Telegram" + collapsed `` с manual fallback
- Webhook нет → manual инструкция через @userinfobot
- Бот не настроен → ⚠ alert и disabled-поле
Безопасность
- `X-Telegram-Bot-Api-Secret-Token` верифицируется через `ActiveSupport::SecurityUtils.secure_compare`
- `tg_link_token`: 16-byte URL-safe random, 10 мин TTL, single-use (зануляется на успех)
- Webhook endpoint: `skip_before_action :verify_authenticity_token` + `protect_from_forgery with: :null_session`
Migration
- `users.tg_link_token` (unique partial index, NULLable)
- `users.tg_link_token_at` (для TTL-проверки)
i18n
- +24 ключа × 3 локали → паритет 2316/2316/2316
Production-only
Telegram требует HTTPS-публичный URL для webhook. На `localhost:4000` registration блокируется — для теста используй `APP_BASE_URL=https://your-ngrok.io\` или продакшен-домен. Manual fallback работает везде.
v1.6 — Slack + Telegram delivery
v1.6 — Slack + Telegram channels для уведомлений
Стаффские уведомления (interview-reminders, document-expiring, AI-runs, applicant stage changes) теперь летят не только в БД и email, но и в Slack и Telegram.
Что подключается
- Slack — per-user incoming webhook. Юзер сам создаёт Slack-app, генерит webhook URL для нужного канала, пастит в `/profile/integrations`.
- Telegram — company-wide бот (создаётся через @Botfather, токен сохраняется в `/settings/communications`) + личный chat_id юзера.
Архитектура
- `app/notifiers/delivery_methods/{slack,telegram}.rb` — кастомные Noticed v3 DeliveryMethods с per-recipient URL/auth.
- `ApplicationNotifier` дополняет deliver_by `:slack` / `:telegram` с conditional `if:` через computed `event_kind` + `user.notify_for?(kind, channel)`.
- `User::NOTIFICATION_CHANNELS = [in_app, email, slack, telegram]` — единый каталог. Все 6 существующих kinds (`ai_run_completed`, `interview_*`, `applicant_stage_change`, `document_expiring`) получают slack/telegram = false по дефолту.
UI
- /profile/integrations (новая вкладка профиля) — Slack webhook + Telegram chat_id. Кнопки Тест реально шлют тестовое сообщение и репортят результат.
- /profile/notifications — две новые колонки (Slack / Telegram), disabled и со ссылкой "подключить" пока канал не настроен.
- /settings/communications — секция для Bot Token компании (BotFather → 123456:ABC-DEF...).
Migration
- `users.slack_webhook_url` + `users.telegram_chat_id`
i18n
- +27 ключей × 3 локали → паритет 2269/2269/2269
Mobile
Inherits v1.5 — формы интеграций, таблица уведомлений, settings-страницы responsive из коробки.
Bonus fix
- `bundle update nokogiri 1.19.2 → 1.19.3` (CVE patches, blocked CI)