Compare commits

..

34 Commits

Author SHA1 Message Date
aleksey 0565f09a72 test(e2e): ретраи WS-коннекта — транзиентный таймаут после deploy-stage
CI / test (push) Successful in 7m12s
CI / deploy-ift (push) Successful in 3m49s
CI / e2e-ift (push) Successful in 1m21s
CI / deploy-stage (push) Successful in 2m58s
CI / e2e-stage (push) Successful in 1m13s
В run 621 после фикса OOM остался один упавший кейс:
admin_websocket_tests падал на gun:await_up(5000) — одиночный
TCP/TLS-таймаут в окно стабилизации traefik/бэкенда сразу после
рестарта контейнера. ws_connect_up/4 теперь ретраит коннект
3 раза с паузой 2s; одинаково в admins- и users-хелперах.
2026-08-24 00:05:25 +03:00
aleksey a061674408 ci(e2e): readiness-wait перед API-сьюитами на стенде
CI / test (push) Successful in 7m38s
CI / deploy-ift (push) Successful in 3m37s
CI / e2e-ift (push) Successful in 1m19s
CI / deploy-stage (push) Successful in 2m47s
CI / e2e-stage (push) Failing after 1m15s
После deploy-stage контейнер ещё стартует, traefik отвечает 502/503,
и первые сьюиты падали с {badmatch,{ok,502}}. run-stand-api-tests.sh
теперь до 150s ждёт, пока HTTP-фронт API_HOST/ADMIN_API_HOST начнёт
отвечать (любой код, кроме 5xx), прежде чем запускать CT.
2026-08-23 23:17:32 +03:00
aleksey 9348c36e52 ci: rerun e2e-stage (в run 617 e2e стартовал в момент рестарта контейнеров после deploy-stage; все 5 падений — транзиентные 502 админ-API)
CI / test (push) Successful in 7m14s
CI / deploy-ift (push) Successful in 3m41s
CI / e2e-ift (push) Successful in 1m16s
CI / deploy-stage (push) Successful in 2m59s
CI / e2e-stage (push) Failing after 59s
2026-08-23 22:20:30 +03:00
aleksey e0e3455281 fix(ai): STT — имя файла по content-type, webm/mp4 уходили как voice.dat
CI / test (push) Successful in 7m46s
CI / deploy-ift (push) Successful in 3m56s
CI / e2e-ift (push) Successful in 1m16s
CI / deploy-stage (push) Successful in 2m54s
CI / e2e-stage (push) Failing after 1m8s
xAI определяет формат аудио по расширению имени файла в
multipart-части, а не по её content-type. Back слал нейтральное
filename="voice.dat": wav проходил случайно (узнаваемый заголовок),
а webm/opus и mp4 из браузерного MediaRecorder получали 400
«Could not detect audio format» → 502 upstream_error → тост
«Распознавание речи временно недоступно».

Расширение выводится из mime (параметры codecs отбрасываются);
неизвестный тип — .wav. Тесты на маппинг и подстановку в multipart.
2026-08-23 21:51:19 +03:00
aleksey 61f7a5f719 revert: STT ipfamily — причина была в истёкшей vless-подписке
CI / test (push) Successful in 7m8s
CI / deploy-ift (push) Successful in 4m5s
CI / e2e-ift (push) Successful in 1m15s
CI / deploy-stage (push) Successful in 2m14s
CI / e2e-stage (push) Successful in 1m13s
Коммит b122cf3 винил IPv6/inet6fb4 в таймаутах STT на stage.
Диагностика показала иное: api.x.ai «вешает» запросы с валидным
ключом с российских IP (dummy-ключ отвечает 400 мгновенно),
а vless-подписка истекла — egress-узлов нет. Возврат к прежнему
поведению httpc-профиля.
2026-08-23 00:15:22 +03:00
aleksey b122cf3339 fix(ai): STT httpc только IPv4 — таймаут на хостах без IPv6
CI / test (push) Successful in 7m10s
CI / deploy-ift (push) Successful in 3m19s
CI / e2e-ift (push) Successful in 1m17s
CI / deploy-stage (push) Successful in 2m16s
CI / e2e-stage (push) Successful in 1m15s
На stage прямой режим к api.x.ai падал upstream_error ровно через
STT_TIMEOUT_MS: DNS Erlang отдаёт AAAA первым, IPv6-маршрута на хосте
нет, httpc с дефолтным inet6fb4 висит на connect до таймаута
(curl/wget с happy-eyeballs проходили). ipfamily=inet на профиле
eventhub_stt; IFT (прямой) и прокси-режим не затрагиваются.
2026-08-22 23:41:29 +03:00
aleksey 4486dc87db Поиск: case-insensitive сравнение в logic_search
CI / test (push) Successful in 8m4s
CI / deploy-ift (push) Successful in 3m53s
CI / e2e-ift (push) Successful in 1m16s
CI / deploy-stage (push) Successful in 2m14s
CI / e2e-stage (push) Successful in 1m13s
- сопоставление имени/описания календарей приведено к lowercase
- юнит-тесты: 20/20
2026-08-22 21:08:04 +03:00
aleksey d46eb6e528 fix(stt): xAI 400 при format=true без language — убираем поле format
CI / test (push) Successful in 7m50s
CI / deploy-ift (push) Successful in 3m56s
CI / e2e-ift (push) Successful in 1m18s
CI / deploy-stage (push) Successful in 2m16s
CI / e2e-stage (push) Successful in 1m11s
Smoke на IFT: xAI отвечает 400 "Field 'language' is required when
'format' is true" → бэк маппит в 502 upstream_error. Проверено curl:
без поля format эндпоинт /v1/stt работает (200), язык определяется
автоматически. Убрано поле format из multipart xAI-адаптера, eunit
обновлён (полный прогон: 1211 тестов зелёные).

Refs EventHub/EventHubBack#79
2026-08-21 21:32:13 +03:00
aleksey 1ef9020c40 Голосовой центр L2: прокси транскрибации POST /v1/ai/transcribe
CI / test (push) Successful in 7m45s
CI / deploy-ift (push) Successful in 2m53s
CI / e2e-ift (push) Successful in 1m23s
CI / deploy-stage (push) Successful in 2m9s
CI / e2e-stage (push) Successful in 1m50s
Back-прокси STT для press-to-talk (канон PRODUCT-AI, голос и тап —
два канала одного движка действий):

- handler_ai_transcribe + logic_ai_transcribe (паттерн logic_geo):
  адаптеры провайдеров xai и openai-совместимый; env STT_PROVIDER,
  STT_URL, STT_API_KEY, STT_MODEL, STT_TIMEOUT_MS, STT_PROXY_URL
- multipart read_file_part в handler_upload_utils (лимит 10 MB)
- ETS rate-limit: 10 req/min на пользователя + глобальный троттлинг
  апстрима; без ключа — 503 unavailable, ошибки апстрима — 502/503
- аудио и транскрипт не персистятся (приват-инвариант канона)
- отдельный httpc-профиль: STT_PROXY_URL применяется только к STT
  (эгресс stt-egress для гео-блокированных провайдеров)
- trails в eventhub_trails.erl, маршрут в eventhub_app.erl,
  docker/.env.example дополнен STT_*

Проверка: eunit модуля 8/8; полный прогон eunit зелёный (1211 тестов),
compile без новых warning'ов.

Refs EventHub/EventHubBack#79
Refs EventHub/EventHubFront#76
2026-08-20 23:56:35 +03:00
aleksey f4d89a262c feat(ai): пилотные метрики ИИ-подсказок — POST /v1/ai/hint-metrics + админ-сводка
CI / test (push) Successful in 7m53s
CI / deploy-ift (push) Successful in 2m41s
CI / e2e-ift (push) Successful in 1m20s
CI / deploy-stage (push) Successful in 2m16s
CI / e2e-stage (push) Successful in 1m13s
Канон PRODUCT-AI (Spec#22): пилотные метрики L1-подсказок для расчёта
голосового гейта L2 (>=20% активных пользователей с >=2 подтверждённых
ghost-бронирований в месяц). Локальные счётчики в Mnesia, без LLM.

- таблица ai_hint_metric (миграция 20260820180000): счётчики
  {user, день UTC, pattern, kind}; ленивый prune при >500 строк на
  пользователя, retention 92 дня
- POST /v1/ai/hint-metrics (Bearer): батч <=20 событий shown/confirm/dismiss,
  202 {accepted:N}; ETS rate limit 30 req/min на пользователя
- GET /v1/admin/ai-metrics/stats (админ-порт): by_kind, active_users_30d,
  users_with_confirms_ge2_30d, gate_ratio_30d, top_patterns_by_confirm
- eunit logic_ai_metrics_tests (5/5); синхронизация списка миграций
  в migration_engine_tests

Refs EventHub/EventHubBack#78
2026-08-20 18:35:29 +03:00
aleksey cc08b3b18a feat(search): default_location календарей в ответе всегда (для вида карты)
CI / test (push) Successful in 7m56s
CI / deploy-ift (push) Successful in 3m31s
CI / e2e-ift (push) Successful in 1m18s
CI / deploy-stage (push) Successful in 2m15s
CI / e2e-stage (push) Successful in 1m48s
format_calendar больше не требует geo_origin: default_location (address/
lat/lon либо null) включается в ответ GET /v1/search всегда, если точка
задана, — вид карты на странице поиска строится и без lat/lon в запросе.
distance_km по-прежнему возвращается только при гео-запросе.

Refs EventHub/EventHubBack#77
2026-08-19 22:01:07 +03:00
aleksey 73d43e5fdc feat(geo): switch geocoder from self-host Photon to OpenCage API
CI / test (push) Successful in 7m44s
CI / deploy-ift (push) Successful in 3m57s
CI / e2e-ift (push) Successful in 1m16s
CI / deploy-stage (push) Successful in 2m36s
CI / e2e-stage (push) Successful in 4m12s
logic_geo проксирует OpenCage (suggest/geocode/reverse): env OPENCAGE_API_KEY,
OPENCAGE_URL, OPENCAGE_TIMEOUT_MS, OPENCAGE_COUNTRYCODE, OPENCAGE_THROTTLE_RPS
(1 rps под free-тариф, 0 - выключить). HTTP 402/403/429 апстрима ->
503 geo_unavailable; source=opencage. Сервис photon и volume photon-data
удалены из swarm compose; eunit-фикстуры под формат OpenCage.

Refs EventHub/EventHubBack#77
2026-08-18 23:30:15 +03:00
aleksey 6b70e1336d feat(geo): Photon proxy, address-only location, nearby search. Refs EventHub/EventHubBack#77
CI / test (push) Successful in 7m38s
CI / deploy-ift (push) Successful in 3m27s
CI / e2e-ift (push) Successful in 1m16s
CI / deploy-stage (push) Successful in 2m18s
CI / e2e-stage (push) Successful in 4m11s
2026-08-17 23:23:10 +03:00
aleksey 501334e241 fix(test): archived month returns 409 before event_in_past.
CI / test (push) Successful in 36m6s
CI / deploy-ift (push) Successful in 2m44s
CI / e2e-ift (push) Successful in 1m19s
CI / deploy-stage (push) Successful in 2m34s
CI / e2e-stage (push) Successful in 4m14s
Refs EventHub/EventHubBack#76

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 16:25:51 +03:00
aleksey e3f5a7ca13 feat(archive): month snapshots on the same node, drop extra-node and HTML /view.
CI / test (push) Failing after 41m15s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
Fixes EventHub/EventHubBack#76

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:59:30 +03:00
aleksey ef31e7fb40 fix(test): sync migration_engine_tests versions with auth_session_device. Refs EventHub/EventHubBack#74
CI / test (push) Successful in 7m23s
CI / deploy-ift (push) Successful in 3m59s
CI / e2e-ift (push) Successful in 1m29s
CI / deploy-stage (push) Successful in 2m15s
CI / e2e-stage (push) Successful in 4m26s
2026-08-16 18:58:54 +03:00
aleksey 0690020e81 feat(auth): list/revoke sessions with device_name and client_type. Fixes EventHub/EventHubBack#74
CI / test (push) Failing after 10m52s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
2026-08-16 18:30:05 +03:00
aleksey 1c701a5254 fix(share): register calendar_share migration and rebuild old PK schema. Refs EventHub/EventHubBack#73
CI / test (push) Successful in 9m32s
CI / deploy-ift (push) Successful in 4m35s
CI / e2e-ift (push) Successful in 1m40s
CI / deploy-stage (push) Successful in 2m27s
CI / e2e-stage (push) Successful in 4m23s
2026-08-16 10:04:16 +03:00
aleksey fb927b7f71 fix(ci): e2e-stage defaults to *.stage.calentiq.com when secrets empty.
CI / test (push) Successful in 7m31s
CI / deploy-ift (push) Successful in 2m35s
CI / e2e-ift (push) Successful in 2m46s
CI / deploy-stage (push) Successful in 2m33s
CI / e2e-stage (push) Successful in 4m22s
Empty E2E_STAGE_* overrode :-defaults with blank hosts. Refs EventHub/EventHubBack#73
2026-08-16 00:25:12 +03:00
aleksey c1b4897a8b fix(test): use commercial fixtures in logic_search_tests.
CI / test (push) Successful in 7m27s
CI / deploy-ift (push) Successful in 3m30s
CI / e2e-ift (push) Successful in 1m30s
CI / deploy-stage (push) Successful in 2m5s
CI / e2e-stage (push) Failing after 3m22s
Personal is never discoverable; assert exclusion explicitly. Refs EventHub/EventHubBack#73
2026-08-16 00:04:26 +03:00
aleksey fcb737eb47 fix(test): ensure calendar_share table in ACL-related eunit suites.
CI / test (push) Failing after 6m13s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
Harden core_calendar_share get/list when table missing. Refs EventHub/EventHubBack#73
2026-08-15 23:48:12 +03:00
aleksey 331000a464 feat(share): calendar_share invite/ACL for personal and commercial.
CI / test (push) Failing after 6m37s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
Co-editor and deputy grants with mirror_to_default; personal out of search; list owned+shared. Refs EventHub/EventHubBack#73
2026-08-15 23:20:11 +03:00
aleksey 00bf72e4d1 test(notify): prefs gate email/push on reminder and waitlist.
CI / test (push) Successful in 7m47s
CI / deploy-ift (push) Successful in 3m5s
CI / e2e-ift (push) Successful in 1m26s
CI / deploy-stage (push) Successful in 2m13s
CI / e2e-stage (push) Failing after 3m23s
Assert email=false skips SMTP; push=true hits web_push_deliver.
Refs EventHub/EventHubBack#75
2026-08-15 21:50:08 +03:00
aleksey 12274f9ed0 feat(notify): Web Push subscriptions and email/push prefs.
CI / test (push) Successful in 7m43s
CI / deploy-ift (push) Successful in 3m33s
CI / e2e-ift (push) Successful in 1m28s
CI / deploy-stage (push) Failing after 3m40s
CI / e2e-stage (push) Has been skipped
Prefs in user.preferences; push_subscription table; VAPID send on
reminder/waitlist. Refs EventHub/EventHubBack#75
2026-08-15 20:19:44 +03:00
aleksey e86d603f30 fix(email): encode Cyrillic binaries with /utf8.
CI / test (push) Successful in 7m11s
CI / deploy-ift (push) Successful in 3m12s
CI / e2e-ift (push) Successful in 1m26s
CI / deploy-stage (push) Failing after 3m33s
CI / e2e-stage (push) Has been skipped
OTP truncates non-ASCII in <<"...">> to 8-bit bytes, garbling Russian
in mail and in-app notification copy.
2026-08-15 00:32:33 +03:00
aleksey 0a1e55e036 feat(email): HTTP API transport via Resend (fallback SMTP).
CI / test (push) Successful in 7m40s
CI / deploy-ift (push) Successful in 3m29s
CI / e2e-ift (push) Successful in 1m27s
CI / deploy-stage (push) Failing after 3m45s
CI / e2e-stage (push) Has been skipped
Outbound SMTP ports are often blocked on VPS; EMAIL_TRANSPORT=http_api
sends via Resend HTTPS (Brevo optional). OpenSMTPd/SMTP remain available.
2026-08-14 23:54:10 +03:00
aleksey fb8869fa86 feat(waitlist): optional event waitlist with FIFO promote.
CI / test (push) Successful in 7m12s
CI / deploy-ift (push) Successful in 2m44s
CI / e2e-ift (push) Successful in 1m28s
CI / deploy-stage (push) Successful in 2m3s
CI / e2e-stage (push) Successful in 1m24s
Commercial calendars opt in via settings.waitlist_enabled; join when full;
auto-promote on cancel/decline/expire with email and in-app notify.

Refs EventHub/EventHubBack#72
2026-08-14 21:44:42 +03:00
aleksey 29111cd514 feat(upload): avatar and calendar cover via local disk storage.
POST /v1/user/me/avatar and /v1/calendars/:id/cover; GET /v1/media;
UPLOAD_DIR on eventhub-data volume; MIME/size limits.
Refs EventHub/EventHubBack#71
Refs EventHub/EventHubSpec#8
2026-08-14 21:27:34 +03:00
aleksey 15ae8d4426 feat(email): send booking reminders before event start.
Job process_reminders in subscription_worker; REMINDER_LEAD_HOURS window;
CalenTIQ email + in-app event_reminder; idempotent reminder_sent.
Refs EventHub/EventHubBack#70
2026-08-14 20:59:38 +03:00
aleksey 347a645933 feat(auth): POST /v1/logout and admin logout revoke refresh session. Refs EventHub/EventHubBack#69
CI / test (push) Successful in 7m32s
CI / deploy-ift (push) Successful in 2m29s
CI / e2e-ift (push) Successful in 1m29s
CI / deploy-stage (push) Successful in 2m3s
CI / e2e-stage (push) Successful in 2m1s
2026-08-14 20:23:16 +03:00
aleksey 302e1e5e9e chore: ignore local Traefik certs; keep make-traefik-certs.sh. [skip ci] 2026-08-14 19:46:18 +03:00
aleksey 69ee037d45 feat(email): SMTP via gen_smtp and CalenTIQ HTML templates. Refs EventHub/EventHubBack#68 2026-08-14 18:51:09 +03:00
aleksey 3d7ae0d372 fix(api): resolve public calendar GET by unique short_name.
CI / test (push) Successful in 7m28s
CI / deploy-ift (push) Successful in 2m46s
CI / e2e-ift (push) Successful in 1m26s
CI / deploy-stage (push) Successful in 2m4s
CI / e2e-stage (push) Successful in 2m4s
2026-08-14 15:00:41 +03:00
aleksey 658cbda4fe chore(rules): ignore .tmp scratch; IFT e2e needs SMOKE_USER. [skip ci] 2026-08-14 12:52:20 +03:00
118 changed files with 8063 additions and 1141 deletions
+120 -119
View File
@@ -1,119 +1,120 @@
--- ---
description: Типичные ошибки агентов EventHub — shell, git, docker, scope, токены (корень + субагенты) description: Типичные ошибки агентов EventHub — shell, git, docker, scope, токены (корень + субагенты)
alwaysApply: true alwaysApply: true
--- ---
# EventHub: типичные ошибки агентов (избегать) # EventHub: типичные ошибки агентов (избегать)
Канон. Субагентам: включать в промпт «следуй `agent-pitfalls.mdc` + `shell-wsl-not-powershell.mdc`». Канон. Субагентам: включать в промпт «следуй `agent-pitfalls.mdc` + `shell-wsl-not-powershell.mdc`».
## 1. Shell / PowerShell ## 1. Shell / PowerShell
| Симптом | Причина | Как правильно | | Симптом | Причина | Как правильно |
|---------|---------|---------------| |---------|---------|---------------|
| `bash: /mnt/c/.../.tmp-*.sh: No such file or directory` при том что `ls` файл видит | **CRLF** от Cursor Write: shebang = `bash\r` | **Всегда** `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../script.sh` (или `sed -i 's/\r$//' && bash`). **Не** голый `wsl -e bash /mnt/c/.../script.sh` | | `bash: /mnt/c/.../.tmp-*.sh: No such file or directory` при том что `ls` файл видит | **CRLF** от Cursor Write: shebang = `bash\r` | **Всегда** `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../script.sh` (или `sed -i 's/\r$//' && bash`). **Не** голый `wsl -e bash /mnt/c/.../script.sh` |
| `unexpected EOF`, `ParserError`, пустой curl | PS ломает `"`, `$`, heredoc, `&&` | Файл `.sh` → `run-wsl-sh.sh` | | IFT e2e: **N skipped**, exit 0 | Нет `SMOKE_USER_*` (голый `npm run test:e2e:ift`) | `run-front-ift.sh` / source `eventhub-ift.env` **до** npm. Не путать со «стенд недоступен». CI уже крутит e2e-ift → локально не дублировать |
| `bash: syntax error near '('` | `grep -E ^(...)` в PS; **`fix(ci):` / `(…)` в `wsl -lc` one-liner** | Только внутри `.sh`; commit → `-F` файл (см. ниже) | | `unexpected EOF`, `ParserError`, пустой curl | PS ломает `"`, `$`, heredoc, `&&` | Файл `.sh` → `run-wsl-sh.sh` |
| `{{.Names}}: command not found` | `docker --format "{{…}}"` в PS | Формат в `.sh` или простой `docker ps` | | `bash: syntax error near '('` | `grep -E ^(...)` в PS; **`fix(ci):` / `(…)` в `wsl -lc` one-liner** | Только внутри `.sh`; commit → `-F` файл (см. ниже) |
| `&&` is not valid | PowerShell не bash | Отдельные команды или `.sh` | | `{{.Names}}: command not found` | `docker --format "{{…}}"` в PS | Формат в `.sh` или простой `docker ps` |
| `&&` is not valid | PowerShell не bash | Отдельные команды или `.sh` |
**Запрещено в PS:** голый `wsl -e bash /mnt/c/.../.tmp-*.sh` (без `run-wsl-sh.sh`), `wsl -e bash -lc "…\"…"`, `curl -d "{\"x\":…}"`, heredoc commit, `npm`/`rebar3`.
**Запрещено в PS:** голый `wsl -e bash /mnt/c/.../.tmp-*.sh` (без `run-wsl-sh.sh`), `wsl -e bash -lc "…\"…"`, `curl -d "{\"x\":…}"`, heredoc commit, `npm`/`rebar3`.
Канон запуска скриптов: см. `shell-wsl-not-powershell.mdc` § CRLF.
Канон запуска скриптов: см. `shell-wsl-not-powershell.mdc` § CRLF.
## 2. Git
## 2. Git
| Симптом | Как правильно |
|---------|---------------| | Симптом | Как правильно |
| `#` в `-m` обрезает сообщение | `-F` файл; в тексте `Refs EventHub/EventHubBack#N` | |---------|---------------|
| `syntax error near '('` на commit | Сообщение `fix(ci): …` inline в `wsl -lc '… git-commit.sh … fix(ci): …'` | **Только** `.tmp-commit-msg.txt` + `git commit -F`; или `.sh` без inline `-m` | | `#` в `-m` обрезает сообщение | `-F` файл; в тексте `Refs EventHub/EventHubBack#N` |
| `Co-authored-by: Cursor <…>` / `<` ParserError | `git commit -F .tmp-msg.txt` из PS **или** `git-commit.sh` **из `.sh`**, не из PS one-liner | | `syntax error near '('` на commit | Сообщение `fix(ci): …` inline в `wsl -lc '… git-commit.sh … fix(ci): …'` | **Только** `.tmp-commit-msg.txt` + `git commit -F`; или `.sh` без inline `-m` |
| `editor` / not a terminal | Не `git commit` без `-F` в WSL; из Windows: `git commit -F file` | | `Co-authored-by: Cursor <…>` / `<` ParserError | `git commit -F .tmp-msg.txt` из PS **или** `git-commit.sh` **из `.sh`**, не из PS one-liner |
| Сотни `M` после reset на `/mnt/c` | `git config core.filemode false` (локально); не «чинить» код | | `editor` / not a terminal | Не `git commit` без `-F` в WSL; из Windows: `git commit -F file` |
| Удалились `.cursor/`, `.idea/` | **`git clean -fdx` только с backup** env + certs; IDE-директории **не трогать** | | Сотни `M` после reset на `/mnt/c` | `git config core.filemode false` (локально); не «чинить» код |
| Push «висит» / ждёт login | **Не** `git push origin` — только `~/.cursor/eventhub/git-push.sh` / `git-push-main.sh` | | Удалились `.cursor/`, `.idea/` | **`git clean -fdx` только с backup** env + certs; IDE-директории **не трогать** |
| Push заблокирован Auto-review | Push в `master` — только по явной просьбе; `request_smart_mode_approval` | | Push «висит» / ждёт login | **Не** `git push origin` — только `~/.cursor/eventhub/git-push.sh` / `git-push-main.sh` |
| Push заблокирован Auto-review | Push в `master` — только по явной просьбе; `request_smart_mode_approval` |
**`git clean` / reset к origin:** сначала сохранить `docker/.env*`, `.env.development`, `docker/traefik/certs/*`.
**`git clean` / reset к origin:** сначала сохранить `docker/.env*`, `.env.development`, `docker/traefik/certs/*`.
**Push:** запрещены голые `git push` / `git push origin …`. Канон — `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh <repo-dir>` (токен из secrets).
**Push:** запрещены голые `git push` / `git push origin …`. Канон — `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh <repo-dir>` (токен из secrets).
**Commit (сообщение со скобками, `#`, `$`, пробелами):** не передавать subject/body аргументом через PowerShell или `wsl -e bash -lc '…'`. Пиши `.tmp-commit-msg.txt` в репо, затем в `.sh`:
**Commit (сообщение со скобками, `#`, `$`, пробелами):** не передавать subject/body аргументом через PowerShell или `wsl -e bash -lc '…'`. Пиши `.tmp-commit-msg.txt` в репо, затем в `.sh`:
```bash
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack ```bash
git add path/to/file cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt git add path/to/file
``` GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
```
Запуск из PS: `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../EventHubBack/.tmp-commit.sh` — **без** голого `wsl -e bash …/.tmp-commit.sh`, **без** `-lc` и **без** `-m "fix(ci): …"`.
Запуск из PS: `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../EventHubBack/.tmp-commit.sh` — **без** голого `wsl -e bash …/.tmp-commit.sh`, **без** `-lc` и **без** `-m "fix(ci): …"`.
## 3. Docker dev (Swarm локально)
## 3. Docker dev (Swarm локально)
| Ошибка | Правильно |
|--------|-----------| | Ошибка | Правильно |
| «Балансировка» → 3× eventhub | **dev:** `traefik=1` + **`eventhub=1`** (см. `EventHubDevOps/docs/STANDS.md`) | |--------|-----------|
| `host-mode port already in use` | У eventhub опубликованы 8080/8445 — **нельзя** scale >1; LB = traefik | | «Балансировка» → 3× eventhub | **dev:** `traefik=1` + **`eventhub=1`** (см. `EventHubDevOps/docs/STANDS.md`) |
| Собрали admin-ui/front без просьбы | «Только бэк» → eventhub (+ traefik если нужен HTTPS), **без** Front/Admin образов | | `host-mode port already in use` | У eventhub опубликованы 8080/8445 — **нельзя** scale >1; LB = traefik |
| Эмулятор не остановился | `docker service scale eventhub_bot-emulator-users=0` | | Собрали admin-ui/front без просьбы | «Только бэк» → eventhub (+ traefik если нужен HTTPS), **без** Front/Admin образов |
| `No such image: eventhub:latest` | Сначала `docker build -t eventhub:latest …` или `docker/build-images.sh` | | Эмулятор не остановился | `docker service scale eventhub_bot-emulator-users=0` |
| Admin API dev не отвечает на :443 | Traefik без ports → **`http://127.0.0.1:8445`**; stage → SSH + `--resolve …:443:127.0.0.1` | | `No such image: eventhub:latest` | Сначала `docker build -t eventhub:latest …` или `docker/build-images.sh` |
| Admin API dev не отвечает на :443 | Traefik без ports → **`http://127.0.0.1:8445`**; stage → SSH + `--resolve …:443:127.0.0.1` |
## 4. Scope (не додумывать)
## 4. Scope (не додумывать)
- Делать **ровно** запрос; не раздувать (полный `build-images.sh`, 3 ноды, admin-ui, spec commit).
- Перед scale/deploy — прочитать compose и STANDS.md. - Делать **ровно** запрос; не раздувать (полный `build-images.sh`, 3 ноды, admin-ui, spec commit).
- Секреты: `gitea.ps1` / `.sh` + env; не inline `source secrets` в PS one-liner. - Перед scale/deploy — прочитать compose и STANDS.md.
- Секреты: `gitea.ps1` / `.sh` + env; не inline `source secrets` в PS one-liner.
## 5. Субагенты (Task)
## 5. Субагенты (Task)
В промпт субагента добавлять:
В промпт субагента добавлять:
```text
Shell: только WSL + .sh (см. shell-wsl-not-powershell.mdc, agent-pitfalls.mdc). ```text
Один репо. Итог на русском. Не push без явного OK. Shell: только WSL + .sh (см. shell-wsl-not-powershell.mdc, agent-pitfalls.mdc).
``` Один репо. Итог на русском. Не push без явного OK.
```
## 6. Экономия токенов / лимитов моделей
## 6. Экономия токенов / лимитов моделей
### Контекст чата
- **Один чат — одна задача**; после «готово / CI зелёный» — новый чат на следующий шаг (см. `WORKFLOW.md` §5). ### Контекст чата
- Workspace: для Back-задачи достаточно **`EventHubBack`** (+ Spec при необходимости); не держать все репо без нужды. - **Один чат — одна задача**; после «готово / CI зелёный» — новый чат на следующий шаг (см. `WORKFLOW.md` §5).
- **`@` точечно** — файл/папка, не `@Codebase` без причины. - Workspace: для Back-задачи достаточно **`EventHubBack`** (+ Spec при необходимости); не держать все репо без нужды.
- Не просить «проанализируй все transcripts» — они огромные. - **`@` точечно** — файл/папка, не `@Codebase` без причины.
- Не просить «проанализируй все transcripts» — они огромные.
### Дешёвые vs дорогие действия
| Дорого | Дешевле | ### Дешёвые vs дорогие действия
|--------|---------| | Дорого | Дешевле |
| Task explore «very thorough» | `grep` + 12 файла | |--------|---------|
| Полный `build-images.sh` | Только нужный образ | | Task explore «very thorough» | `grep` + 12 файла |
| Весь лог CI (80k строк) | API job + 20 строк вокруг ошибки | | Полный `build-images.sh` | Только нужный образ |
| Несколько subagents «на всякий» | 1 subagent, узкий промпт | | Весь лог CI (80k строк) | API job + 20 строк вокруг ошибки |
| Browser deep-walk stage | Smoke-скрипт / один URL | | Несколько subagents «на всякий» | 1 subagent, узкий промпт |
| Browser deep-walk stage | Smoke-скрипт / один URL |
### Меньше итераций
- Сразу **`.sh`**, не 5 попыток PS one-liner (каждая = новый turn). ### Меньше итераций
- Git перед commit: **один** `status` + `diff`, не три раза подряд. - Сразу **`.sh`**, не 5 попыток PS one-liner (каждая = новый turn).
- Тесты: `rebar3 eunit --module=…` если трогали один модуль; не полный suite без нужды. - Git перед commit: **один** `status` + `diff`, не три раза подряд.
- Docker: не rebuild, если `docker images` уже есть нужный tag. - Тесты: `rebar3 eunit --module=…` если трогали один модуль; не полный suite без нужды.
- Docker: не rebuild, если `docker images` уже есть нужный tag.
### Модели и режимы
- **Ask / Plan** — вопросы и варианты без tool calls. ### Модели и режимы
- **Agent** — только когда нужны команды и правки. - **Ask / Plan** — вопросы и варианты без tool calls.
- Bugbot / Security Review — не на каждый мелкий diff. - **Agent** — только когда нужны команды и правки.
- Bugbot / Security Review — не на каждый мелкий diff.
### Scope в запросе (шаблон)
```text ### Scope в запросе (шаблон)
Репо: EventHubBack ```text
Issue: #N Репо: EventHubBack
Scope: только … Issue: #N
Не: front, docker, push, spec Scope: только …
Done: eunit / lint / … Не: front, docker, push, spec
``` Done: eunit / lint / …
```
### Subagents
- Редко; промпт = issue + файлы + done, **без** всей переписки. ### Subagents
- `one-repo-per-agent` — меньше контекста и ошибок. - Редко; промпт = issue + файлы + done, **без** всей переписки.
- `one-repo-per-agent` — меньше контекста и ошибок.
### Повторяемое — в скрипты
Токены verify, smoke creds, stage deploy — **`~/.cursor/eventhub/*.sh`**, не заново через SSH в каждом чате. ### Повторяемое — в скрипты
Токены verify, smoke creds, stage deploy — **`~/.cursor/eventhub/*.sh`**, не заново через SSH в каждом чате.
+6 -4
View File
@@ -36,7 +36,9 @@ docker/.env.stage
# Пул пользователей для stress hot-path (см. test/tsung/ift-users.csv.example) # Пул пользователей для stress hot-path (см. test/tsung/ift-users.csv.example)
/test/tsung/ift-users.csv /test/tsung/ift-users.csv
# Traefik TLS private keys (generated locally for dev/staging). # Traefik TLS certs (generated locally for dev/staging) — не коммитить.
# Никогда не коммитим приватный ключ в репозиторий. docker/traefik/certs/*
docker/traefik/certs/*.key !docker/traefik/certs/make-traefik-certs.sh
docker/traefik/certs/*.pem
# Temporary/scratch files
.tmp-*
+52 -12
View File
@@ -1,21 +1,61 @@
# Скопируйте в .env (файл .env в .gitignore). Для локальной разработки: EVENTHUB_ENV=dev # 小泻芯锌懈褉褍泄褌械 胁 .env (褎邪泄谢 .env .gitignore). 袛谢褟 谢芯泻邪谢褜薪芯泄 褉邪蟹褉邪斜芯褌泻懈: EVENTHUB_ENV=dev
# Режим: dev | stage | prod (stage/prod — обязательные сильные секреты) # 袪械卸懈屑: dev | stage | prod (stage/prod 芯斜褟蟹邪褌械谢褜薪褘械 褋懈谢褜薪褘械 褋械泻褉械褌褘)
EVENTHUB_ENV=dev EVENTHUB_ENV=dev
RELEASE_COOKIE=ваш-очень-длинный-секретный-куки-минимум-32-символа RELEASE_COOKIE=胁邪褕-芯褔械薪褜-写谢懈薪薪褘泄-褋械泻褉械褌薪褘泄-泻褍泻懈-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪
GRAFANA_ADMIN_PASSWORD=сложный-уникальный-пароль GRAFANA_ADMIN_PASSWORD=褋谢芯卸薪褘泄-褍薪懈泻邪谢褜薪褘泄-锌邪褉芯谢褜
# JWT (минимум 32 символа, не из denylist — см. infra_secrets) # JWT (屑懈薪懈屑褍屑 32 褋懈屑胁芯谢邪, 薪械 懈蟹 denylist 褋屑. infra_secrets)
JWT_SECRET=замените-на-случайную-строку-минимум-32-символа-для-user-jwt JWT_SECRET=蟹邪屑械薪懈褌械-薪邪-褋谢褍褔邪泄薪褍褞-褋褌褉芯泻褍-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪-写谢褟-user-jwt
ADMIN_JWT_SECRET=замените-на-другую-случайную-строку-минимум-32-символа ADMIN_JWT_SECRET=蟹邪屑械薪懈褌械-薪邪-写褉褍谐褍褞-褋谢褍褔邪泄薪褍褞-褋褌褉芯泻褍-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪
# Seed админов (обязательны в stage/prod при пустой таблице admin; в dev — опционально) # Seed 邪写屑懈薪芯胁 (芯斜褟蟹邪褌械谢褜薪褘 胁 stage/prod 锌褉懈 锌褍褋褌芯泄 褌邪斜谢懈褑械 admin; dev 芯锌褑懈芯薪邪谢褜薪芯)
ADMIN_SUPER_EMAIL=superadmin@eventhub.local ADMIN_SUPER_EMAIL=superadmin@eventhub.local
ADMIN_SUPER_PASSWORD=замените-сильный-пароль-суперадмина ADMIN_SUPER_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-褋褍锌械褉邪写屑懈薪邪
ADMIN_EMAIL=admin@eventhub.local ADMIN_EMAIL=admin@eventhub.local
ADMIN_PASSWORD=замените-сильный-пароль-админа ADMIN_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-邪写屑懈薪邪
ADMIN_MODER_EMAIL=moderator@eventhub.local ADMIN_MODER_EMAIL=moderator@eventhub.local
ADMIN_MODER_PASSWORD=замените-сильный-пароль-модератора ADMIN_MODER_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-屑芯写械褉邪褌芯褉邪
ADMIN_SUPPORT_EMAIL=support@eventhub.local ADMIN_SUPPORT_EMAIL=support@eventhub.local
ADMIN_SUPPORT_PASSWORD=замените-сильный-пароль-поддержки ADMIN_SUPPORT_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-锌芯写写械褉卸泻懈
# Email: EMAIL_TRANSPORT=auto|smtp|http_api|log (auto: API key 鈫 http_api, else SMTP_HOST 鈫 smtp, else log)
EMAIL_TRANSPORT=auto
# HTTP API (HTTPS 鈥 when outbound SMTP ports blocked). Canon: Resend (Brevo optional).
EMAIL_API_KEY=
EMAIL_API_PROVIDER=resend
# EMAIL_API_URL=https://api.resend.com/emails
# Web Push (VAPID). Generate: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@calentiq.com
# SMTP (锌褍褋褌芯 SMTP_HOST + 薪械褌 API key = 褌芯谢褜泻芯 谢芯谐; 锌邪褉芯谢褜 薪械 泻芯屑屑懈褌懈褌褜)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@calentiq.com
SMTP_TLS=if_available
PUBLIC_APP_URL=https://stage.calentiq.com
# 袨泻薪芯 薪邪锌芯屑懈薪邪薪懈泄 芯 booking (褔邪褋褘 写芯 褋褌邪褉褌邪; Back#70)
REMINDER_LEAD_HOURS=24
# Uploads (avatar/cover) 鈥 泻邪褌邪谢芯谐 薪邪 volume /app/data (Back#71)
UPLOAD_DIR=/app/data/uploads
UPLOAD_MAX_BYTES=2097152
# OpenCage (DevOps#16). 象耱铋 觌 = /v1/geo -> 503 (degrade). Free: 2500/耋蜿, 1 rps.
OPENCAGE_API_KEY=
OPENCAGE_TIMEOUT_MS=3000
# STT-прокси голосового центра (Back#79, Front#76). Без STT_API_KEY
# /v1/ai/transcribe отвечает 503 unavailable, клиент деградирует в палитру.
# Провайдеры: xai (POST {STT_URL}, Bearer) | openai (OpenAI-совместимый).
STT_PROVIDER=xai
STT_URL=https://api.x.ai/v1/stt
STT_API_KEY=
# Опционально: модель для openai-провайдера и таймаут апстрима.
STT_MODEL=
STT_TIMEOUT_MS=15000
# Опционально: HTTP-эгресс (сервис stt-egress в стеке, vless-образ);
# гео-блокированные STT-провайдеры вызываются через него.
STT_PROXY_URL=
+1
View File
@@ -15,6 +15,7 @@ RUN rebar3 get-deps
COPY include/ include/ COPY include/ include/
COPY src/ src/ COPY src/ src/
COPY priv/ priv/
COPY src/config/sys.config ./config/sys.config COPY src/config/sys.config ./config/sys.config
COPY src/config/vm.args ./config/vm.args COPY src/config/vm.args ./config/vm.args
+17
View File
@@ -41,6 +41,23 @@ services:
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD} - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
- CLUSTER_MODE=true - CLUSTER_MODE=true
- DNS_NAME=eventhub-node - DNS_NAME=eventhub-node
- EMAIL_TRANSPORT=${EMAIL_TRANSPORT:-auto}
- EMAIL_API_KEY=${EMAIL_API_KEY:-}
- EMAIL_API_PROVIDER=${EMAIL_API_PROVIDER:-resend}
- EMAIL_API_URL=${EMAIL_API_URL:-}
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@calentiq.com}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-noreply@calentiq.com}
- SMTP_TLS=${SMTP_TLS:-if_available}
- PUBLIC_APP_URL=${PUBLIC_APP_URL:-https://stage.calentiq.com}
- REMINDER_LEAD_HOURS=${REMINDER_LEAD_HOURS:-24}
- UPLOAD_DIR=${UPLOAD_DIR:-/app/data/uploads}
- UPLOAD_MAX_BYTES=${UPLOAD_MAX_BYTES:-2097152}
networks: networks:
eventhub-net: eventhub-net:
aliases: aliases:
+2
View File
@@ -70,6 +70,8 @@ services:
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD} - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
- CLUSTER_MODE=true - CLUSTER_MODE=true
- DNS_NAME=eventhub-node - DNS_NAME=eventhub-node
- OPENCAGE_API_KEY=${OPENCAGE_API_KEY:-}
- OPENCAGE_TIMEOUT_MS=${OPENCAGE_TIMEOUT_MS:-3000}
networks: networks:
eventhub-net: eventhub-net:
aliases: aliases:
+2 -2
View File
@@ -7,7 +7,7 @@ FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache \ RUN apk add --no-cache \
openssl libstdc++ libgcc ncurses-libs libsodium \ openssl libstdc++ libgcc ncurses-libs libsodium \
file \ file \
&& mkdir -p /app/data \ && mkdir -p /app/data /app/data/uploads \
&& chmod 777 /app/data && chmod 777 /app/data /app/data/uploads
WORKDIR /app WORKDIR /app
-30
View File
@@ -1,30 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIFFzCCAv+gAwIBAgIUUX5W2zeEC7Z8wSBPoFSDNLpV6U0wDQYJKoZIhvcNAQEL
BQAwGzEZMBcGA1UEAwwQKi5ldmVudGh1Yi5sb2NhbDAeFw0yNjA0MjUwNzAzNTZa
Fw0zNjA0MjIwNzAzNTZaMBsxGTAXBgNVBAMMECouZXZlbnRodWIubG9jYWwwggIi
MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDAKnFa4rgzqERaAHGh1sKLy++A
AqFKBIJAtK+8YPz45jtHElT0BJfE2kOo3wZ+iucbE+dg4yfrKkD4bfa6zbwCYP2h
hEHT6w1tI1BMZCrgY+eqEjvgTYLb5oq9J9zs9GawmaW+XCnUVAOWdOFU3MQlBcbn
FJrDAwz4BjmaRFGVMm95Nk2eGD3cObuqQUVMR95YgZxHyX5M7F8jIAdUO6DoB+fT
MQEEudkU/po/Xp2Xx3GJavRDMPoZ4HEGpQlPmU5JzeMAHVVx6Zj8Ls45eGX4qwf7
4OXbAmBRntpjDIz8qQVAl11QakM2ZW2ALEkIqRU6tIiiyk/PO3fuq11IMGNlx1oL
9L8ClSsgsA3UX7YmjpFmvDJX0anBHiUKonHXzroPu4opm9RpUhYkUTiNLoQxOWwz
FeBmfg5vzJ/fd/w/796CU56vCRctG5UpXWD9dQxjXkWVZIoRvuIeGIIW2qy+NkJA
OuF0aTAphm4Hns9yT4EvfJIvLSZKQyFu6oIfvR4e1RTlZmzgVUQ+O50Kk7z6Jwnt
DkFburi/HnJ8I5YS6zeLrNykKhTPOZu8SfFm5UA71SJZorzftE3R6jz9iPj2Ev48
F7rCWhMqfGtiIp70ASx9xv+5ZTQPLEQkvH+R4JYOqWUwmJhtiso6z8a8zB+3MbGX
f2VTurQ1nB6+C9SaIQIDAQABo1MwUTAdBgNVHQ4EFgQU/EItSTyuc3hQdo4uZdh9
L8o6kkMwHwYDVR0jBBgwFoAU/EItSTyuc3hQdo4uZdh9L8o6kkMwDwYDVR0TAQH/
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAY0UTqk6ICLlyFKNoTyybd1Mf+Hsz
Wn39zJnfdfTbw7DTZgAxU+rPkwWVtpAQpzKS9WugoYa7AEUZDphDHQBlzhuT8xd7
0Gy47kZEklY29FaNDKaiStZ+GEU323irC22y2od0AVHVssKGG/tLWL4hUViMYTWx
Yj6zpzovM2BrIzzAvjIQ0DAAFcVEr5lyvCduz83NiqQH6M/onTGXM01nXhUoIZnZ
QV0O4Rsnsi5VcnP8pUI3aKkxZ7rkVb/AktNTe7rX7xRztdTt0BGOOVR0u7Q97cQ6
kjXbrwIf6cura9J1FyyUeGA7B9z1huYm5XslpkhJ3ILd8DHBvKv5n7wDNuNbocGo
FrVza3sruJDwQzJSDmUyC+xoRv112X2R51sjOe9j17zxfSFqjVckWXTvI+ZvoLoX
g3pbAMQ06bmXpDYcg6er9NG/eycZvpYbSCCRS+TRDLSMshg/F6JoGVcHJtCNkTl9
Sl70z3+/ytjdOl/+XYQ6sEUzd4qtBrslCBX78y4elcO8qQIBWBkvYr+BI09MJyW0
C+Zae0G3mifyhWCfqm6Eq5lKhVhO9bF5rL6E+n4QSYK4PQ6GqAkNZ8/yVo8YcI0Z
fYNOIjQTEz9qqTlwDS8PTO0fFZLeiB64P86jf6m6WNhuQ+CC3BP6HP5ACImhy26N
ctzWkY+lExmP/1o=
-----END CERTIFICATE-----
-52
View File
@@ -1,52 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDAKnFa4rgzqERa
AHGh1sKLy++AAqFKBIJAtK+8YPz45jtHElT0BJfE2kOo3wZ+iucbE+dg4yfrKkD4
bfa6zbwCYP2hhEHT6w1tI1BMZCrgY+eqEjvgTYLb5oq9J9zs9GawmaW+XCnUVAOW
dOFU3MQlBcbnFJrDAwz4BjmaRFGVMm95Nk2eGD3cObuqQUVMR95YgZxHyX5M7F8j
IAdUO6DoB+fTMQEEudkU/po/Xp2Xx3GJavRDMPoZ4HEGpQlPmU5JzeMAHVVx6Zj8
Ls45eGX4qwf74OXbAmBRntpjDIz8qQVAl11QakM2ZW2ALEkIqRU6tIiiyk/PO3fu
q11IMGNlx1oL9L8ClSsgsA3UX7YmjpFmvDJX0anBHiUKonHXzroPu4opm9RpUhYk
UTiNLoQxOWwzFeBmfg5vzJ/fd/w/796CU56vCRctG5UpXWD9dQxjXkWVZIoRvuIe
GIIW2qy+NkJAOuF0aTAphm4Hns9yT4EvfJIvLSZKQyFu6oIfvR4e1RTlZmzgVUQ+
O50Kk7z6JwntDkFburi/HnJ8I5YS6zeLrNykKhTPOZu8SfFm5UA71SJZorzftE3R
6jz9iPj2Ev48F7rCWhMqfGtiIp70ASx9xv+5ZTQPLEQkvH+R4JYOqWUwmJhtiso6
z8a8zB+3MbGXf2VTurQ1nB6+C9SaIQIDAQABAoICAAUfLHnGaK/xyG6Yq+6rFXDZ
7OZ9y3Q4Vgto9XUhJaTxp/tPNQXy+GOWCK8f0KG/TIUXfPeDXm1BaU1lyul0MdJj
aOcs6f8+uNMjmZCLq+vipcpN5ZwnHLmFNcXwYc5L/k+vLLB77t56pcTVFStone65
rnE6xpwzgN08dz0SHJWMLFXv+VYhIcb0n/ib+D+d+8Jio/py16OvuiJqyF85nqKI
kWh62iLY3D0Xwx80w6EHvxIxUFTZIZVeesy5Sdv+A0zqyKXYYk5gskPocHUuLROZ
ZWNUfXPeppgOUpZIv5o9XAc6cZGC34s73VUI+SJatVbk+G5UU5e7p0jsdtZFeeSw
94t5Cw9O4E5Rbu0I7iQxuC215m20DRQyh3sHXB4ZtiYgYle7DPsCU5yzQxoo6+Z3
3sOIwO79A4epKcHU8JeZChZYi4CAC/OGhjH5WFRA0LoVdKnJmyfIKJMCvwdD05hA
L1lHSK43+gFQRz07qRxSLNX6E1E8pmMfnv6iah9DPmQz8uQUTPuPa9dHvD8lpm+m
rSGUeNCi7cnlPMszXrS1Q5gDL7nuFA66p1nA5LcMWFrllyj79Snbp7JB+2Yry0G1
9pfVsMB2DjKa282iXWjzqiDQAefRSqBKbrqo0m6Qzd+VC4AFeDXieo7/RvecQbzM
gbRw5hdX3FxIWiJJHK5FAoIBAQDtX7Bwz5LPqfgq/RmXcKODTqbhVIbMFX/6kWYD
zfI2PeRyb85do7uqZLMi81qBS8sLd18RuVPO+FtWjC8IrBWQW1Df8RN3cZsX/azP
bi+FbauJG1RO12xVVJ2utjaSPCztGKc4jsTip/Ox4rIpYAv6qdEwIR9TtzGTL7MC
GeFWuq+Q7HjCIDC46UNnbOn/ZwZpzO2etvdbjgPeRl+HOGom0b8dDbuIsyHssuUh
MnJRh+tWlkdLDjNgDOMGZdERc6b+VEAUKrsi/alhR6NSgQ/J2hUqcumgPjhNRWlL
2EUPQcPFT/0nTsPGfrnmG1cgHlhewMyrVWaWbai2o+avi7gVAoIBAQDPPqAqM1CQ
Za/UuG7i61LrjLK5+wDEGYMvIPP3sHhTPIzQowqddV5b3kjusXFA3lsAYSYDRyub
Qvlt/dpSTkqYt1kTjvhxX98baWTz6Ifu9qGHdAKvVeQz+GQLQsUCAKjEcJLe723O
01oZMuvOICa4VMNTndOCSYA6rj5IzgCSetGFJH/1l+1cw4Ty0I1XLBoxtd7ZHUOL
LpFUzyqeprT497rvCS9fitCNo8nlhGCeNonhNXtMHoYapf9jZWweNExfsZkxPUYn
RORtaKDtWTzWCEpT00uCNiEq1fnBnQVWC9952lkmJJGX+DBd28cZncayn8DiRgtE
MgLhiPbC1/DdAoIBAQDWqfmDG4SV/gzqu3bgSEHm5kLudaVtfGf/LmxGNoG7Yd9t
SgUjE4YMQwLpMZAAa42UsgAspB/RhTH8o8uJGS5rHNmXm4lSNE/gs00LTYb2MEa3
OBrXLePPizUKIchaGMc8J1ZaRGpy2xX8IZmi5zQOHHKMBvgwmfPgOnu1A7iP2UYa
Vm+ihr/pCTImnw1CJmZbWbLuHGK3zkdbROLxAyikMHyXhqq4pd6hEus5SUiuyK8f
38lba900GJNaXsth7+fuCjzkv/v6WdFKoyFk5Ehl763CdKcoffwmjQX7S/qwIHYt
wveaC5XqtQHiwYjaB/c8wTiWHSYoFcVEXhapXD49AoIBAGO7LXRhcVmrtavWfXYx
7Jtx/+hEBCBabqjVYNJHWAAmWVVlwIv5mUj4dXx4Y5nl50ENfy+Apll3J3Vo/hEx
n/BR+0nQOXrFuiYE2BIQSqoSgbUffNNHsCPaWrqYjRnjm1O9INCrw9oOF4Oc36E4
E6oNEFpCzzAZkt2qUErU562V8RHzeKiFTjKf3HDFL99NV+WoMe+lh4WJCWMYttfW
Pay5+nlhoCyILsiak2QiCiTY9g0soYMGBV75lycL/bWjUUWTv7bUV9g57PtBqH/P
u9j78Fjai7HDvr5Qbnpfx9uapxaKnvAeFPpe81eXlf6b0KzQ+A9ryawtrPk/4g/t
vvkCggEBAL46J1suxrPtZFgfhnAQRhmm01nnFhPBp3mVVT1lOu2UMwyAbkaJcyEh
OAGel159ed+cg1SSpQLD8lK4L0aUUd8+hiN5bLDXNXV3leD3ZXpl9byUxvNGS/UA
Wnm2JTCt20R4cuhp9LjcCWgCiqupblJcbjwga2fKRvM7VquVUBcoyoJcV2IT33iQ
qa96afyCoGYExoQ+9SWettHV+FTQEKo+mfvnHdGRgpH49prIFYTKusMxOi1EdqH4
MKKj4mvxi2WBMT31OPTNlpn7RkOzo7+RTIz3fC+sqV6X+joT64z5GkWw2kJlvuJS
OcJYMreefpqPopJhaO1krD+TWml5ciw=
-----END PRIVATE KEY-----
+67 -5
View File
@@ -78,7 +78,9 @@
expires_at :: calendar:datetime(), expires_at :: calendar:datetime(),
revoked :: boolean(), revoked :: boolean(),
created_at :: calendar:datetime(), created_at :: calendar:datetime(),
updated_at :: calendar:datetime() updated_at :: calendar:datetime(),
device_name :: binary(),
user_agent :: binary()
}). }).
%% ------------------- Календари --------------------------------------- %% ------------------- Календари ---------------------------------------
@@ -103,10 +105,27 @@
updated_at :: calendar:datetime() updated_at :: calendar:datetime()
}). }).
%% PK = id. Уникальность пары calendar_id+user_id — в logic.
-record(calendar_share, { -record(calendar_share, {
calendar_id :: binary(), id :: binary(),
user_id :: binary(), calendar_id :: binary(),
rights :: read | write | admin user_id :: binary(),
rights :: read | write | admin,
mirror_to_default :: boolean()
}).
%% Приглашения к calendar_share (соредактор / заместитель).
-record(calendar_share_invite, {
id :: binary(),
calendar_id :: binary(),
inviter_id :: binary(),
invitee_user_id :: binary(), % <<>> если ещё неизвестен
invitee_email :: binary(), % <<>> если только user_id
rights :: read | write | admin,
status :: pending | accepted | declined | expired | cancelled,
token :: binary(),
created_at :: calendar:datetime(),
expires_at :: calendar:datetime()
}). }).
%% Follow чужого календаря (не путать с платной subscription / calendar_share) %% Follow чужого календаря (не путать с платной subscription / calendar_share)
@@ -200,6 +219,16 @@
updated_at :: calendar:datetime() updated_at :: calendar:datetime()
}). }).
%% ------------------- Лист ожидания (commercial, settings.waitlist_enabled) ----
-record(waitlist_entry, {
id :: binary(),
event_id :: binary(),
user_id :: binary(),
status :: waiting | promoted | left,
created_at :: calendar:datetime(),
updated_at :: calendar:datetime()
}).
%% ------------------- Отзывы ------------------------------------------ %% ------------------- Отзывы ------------------------------------------
-record(review, { -record(review, {
id :: binary(), id :: binary(),
@@ -319,13 +348,24 @@
id :: binary(), id :: binary(),
user_id :: binary(), user_id :: binary(),
type :: booking_confirmed | event_reminder | event_cancelled | type :: booking_confirmed | event_reminder | event_cancelled |
specialist_invite | custom, specialist_invite | waitlist_promoted | custom,
title :: binary(), title :: binary(),
body :: binary(), body :: binary(),
is_read :: boolean(), is_read :: boolean(),
created_at :: calendar:datetime() created_at :: calendar:datetime()
}). }).
%% Web Push subscription (Back#75). Prefs live in user.preferences:
%% notify_email / notify_push (boolean, default true).
-record(push_subscription, {
id :: binary(),
user_id :: binary(),
endpoint :: binary(),
p256dh :: binary(),
auth :: binary(),
created_at :: calendar:datetime()
}).
%% Upsert-счётчики админ-статистики (абсолютные значения). %% Upsert-счётчики админ-статистики (абсолютные значения).
%% key: atom() | {atom(), atom()} | {atom(), atom(), atom()} %% key: atom() | {atom(), atom()} | {atom(), atom(), atom()}
%% users_total-подобные totals не храним (table_info), %% users_total-подобные totals не храним (table_info),
@@ -364,7 +404,29 @@
cpu_utilization :: float() cpu_utilization :: float()
}). }).
-record(month_snapshot, {
id :: {binary(), binary()},
calendar_id :: binary(),
year_month :: binary(),
payload :: binary(),
storage :: mnesia | file,
file_rel :: binary(),
created_at :: calendar:datetime()
}).
-record(schema_migration, { -record(schema_migration, {
version :: string(), version :: string(),
applied_at :: calendar:datetime() applied_at :: calendar:datetime()
}).
%% Пилотные метрики ИИ-подсказок (PRODUCT-AI, Spec#22): дневные счётчики
%% shown/confirm/dismiss по паттерну. Id = {user_id, day, pattern_key, kind}.
-record(ai_hint_metric, {
id :: {binary(), binary(), binary(), binary()},
user_id :: binary(),
day :: binary(),
pattern_key :: binary(),
kind :: binary(),
count :: non_neg_integer(),
updated_at :: calendar:datetime()
}). }).
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{heading}}</title>
</head>
<body style="margin:0;padding:0;background:#F4F1EA;">
<span style="display:none!important;visibility:hidden;opacity:0;height:0;width:0;overflow:hidden;">{{preheader}}</span>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#F4F1EA;padding:32px 12px;">
<tr>
<td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background:#FFFFFF;border-radius:16px;overflow:hidden;border:1px solid #E4E0D6;">
<tr>
<td style="background:#324A5F;padding:22px 28px;">
<p style="margin:0;font-family:Georgia,'Times New Roman',serif;font-size:22px;letter-spacing:0.04em;color:#FFFFFF;">CalenTIQ</p>
<p style="margin:6px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#ABC5B5;">One calendar. Three modes for your time.</p>
</td>
</tr>
<tr>
<td style="height:4px;background:#2F7A68;font-size:0;line-height:0;">&nbsp;</td>
</tr>
<tr>
<td style="padding:28px 28px 8px;font-family:Arial,Helvetica,sans-serif;color:#1A1A1A;">
<h1 style="margin:0 0 12px;font-size:22px;line-height:1.3;font-weight:700;color:#324A5F;">{{heading}}</h1>
<p style="margin:0 0 20px;font-size:15px;line-height:1.55;color:#3A3A3A;">{{lead}}</p>
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:10px;background:#FF6A3D;">
<a href="{{cta_url}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:700;color:#FFFFFF;text-decoration:none;">{{cta_label}}</a>
</td>
</tr>
</table>
<p style="margin:20px 0 0;font-size:12px;line-height:1.5;color:#6B6B6B;">Если кнопка не открывается, скопируйте ссылку:<br/>
<a href="{{cta_url}}" style="color:#2F7A68;word-break:break-all;">{{cta_url}}</a>
</p>
</td>
</tr>
<tr>
<td style="padding:8px 28px 28px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:1.5;color:#8A8A8A;">
{{footnote}}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+3 -2
View File
@@ -10,7 +10,8 @@
{meck, "0.9.2"}, {meck, "0.9.2"},
{gun, "2.2.0"}, {gun, "2.2.0"},
{prometheus_cowboy, "0.2.0"}, {prometheus_cowboy, "0.2.0"},
{cowboy_swagger, "2.8.0"} {cowboy_swagger, "2.8.0"},
{gen_smtp, "1.3.0"}
]}. ]}.
{shell, [ {shell, [
@@ -27,7 +28,7 @@
{profiles, [ {profiles, [
{prod, [ {prod, [
{relx, [ {relx, [
{release, {eventhub, "0.0.1"}, [eventhub, sasl,cowboy, jose, jsx, argon2, runtime_tools, os_mon, prometheus_cowboy]}, {release, {eventhub, "0.0.1"}, [eventhub, sasl, cowboy, jose, jsx, argon2, gen_smtp, ssl, runtime_tools, os_mon, prometheus_cowboy]},
{include_erts, true}, {include_erts, true},
{extended_start_script, true}, {extended_start_script, true},
{sys_config, "./src/config/sys.config"} {sys_config, "./src/config/sys.config"}
+31 -8
View File
@@ -12,17 +12,19 @@ set -euo pipefail
STAND="${1:?stand: ift|stage}" STAND="${1:?stand: ift|stage}"
case "${STAND}" in case "${STAND}" in
ift) ift)
API_HOST="${API_HOST:-https://api.ift.eventhub.local}" [[ -n "${API_HOST:-}" ]] || API_HOST="https://api.ift.eventhub.local"
ADMIN_API_HOST="${ADMIN_API_HOST:-https://admin-api.ift.eventhub.local}" [[ -n "${ADMIN_API_HOST:-}" ]] || ADMIN_API_HOST="https://admin-api.ift.eventhub.local"
WS_HOST="${WS_HOST:-wss://ws.ift.eventhub.local}" [[ -n "${WS_HOST:-}" ]] || WS_HOST="wss://ws.ift.eventhub.local"
ADMIN_WS_HOST="${ADMIN_WS_HOST:-wss://admin-ws.ift.eventhub.local}" [[ -n "${ADMIN_WS_HOST:-}" ]] || ADMIN_WS_HOST="wss://admin-ws.ift.eventhub.local"
DEVOPS_SMOKE="/opt/eventhub-ift/devops/scripts/smoke-core.sh" DEVOPS_SMOKE="/opt/eventhub-ift/devops/scripts/smoke-core.sh"
;; ;;
stage) stage)
API_HOST="${API_HOST:-https://api.stage.eventhub.local}" # Публичный stage (канон STAGE-BROWSER.md). Пустые E2E_STAGE_* из CI
ADMIN_API_HOST="${ADMIN_API_HOST:-https://admin-api.stage.eventhub.local}" # не должны оставлять API_HOST="" (иначе CT бьёт в *.eventhub.local).
WS_HOST="${WS_HOST:-wss://ws.stage.eventhub.local}" [[ -n "${API_HOST:-}" ]] || API_HOST="https://api.stage.calentiq.com"
ADMIN_WS_HOST="${ADMIN_WS_HOST:-wss://admin-ws.stage.eventhub.local}" [[ -n "${ADMIN_API_HOST:-}" ]] || ADMIN_API_HOST="https://admin-api.stage.calentiq.com"
[[ -n "${WS_HOST:-}" ]] || WS_HOST="wss://ws.stage.calentiq.com"
[[ -n "${ADMIN_WS_HOST:-}" ]] || ADMIN_WS_HOST="wss://admin-ws.stage.calentiq.com"
DEVOPS_SMOKE="/opt/eventhub-stage/devops/scripts/smoke-core.sh" DEVOPS_SMOKE="/opt/eventhub-stage/devops/scripts/smoke-core.sh"
;; ;;
*) *)
@@ -49,6 +51,27 @@ echo " ADMIN_API_HOST=${ADMIN_API_HOST}"
echo " WS_HOST=${WS_HOST}" echo " WS_HOST=${WS_HOST}"
echo " ADMIN_WS_HOST=${ADMIN_WS_HOST}" echo " ADMIN_WS_HOST=${ADMIN_WS_HOST}"
# После deploy-stage контейнер стенда может ещё стартовать; traefik в
# это время отвечает 502/503, и первые сьюиты падают с {badmatch,{ok,502}}.
# Ждём, пока HTTP-фронт приложений начнёт отвечать (любой код, кроме 5xx).
for probe in "${API_HOST}/v1/calendars" "${ADMIN_API_HOST}/v1/admin/health"; do
ready=0
for i in $(seq 1 30); do
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "${probe}" 2>/dev/null || echo 000)
case "${code}" in
5*) : ;;
000) : ;;
*) ready=1; break ;;
esac
sleep 5
done
if [[ "${ready}" -eq 1 ]]; then
echo " readiness OK: ${probe} (HTTP ${code})"
else
echo "::warning::${probe} не отвечает после 150s — стартуем сьюиты как есть"
fi
done
# Docker Desktop/WSL: --network=host часто НЕ наследует /etc/hosts стенда, # Docker Desktop/WSL: --network=host часто НЕ наследует /etc/hosts стенда,
# из‑за чего CT remote не резолвит *.ift.eventhub.local (health timeout 30s). # из‑за чего CT remote не резолвит *.ift.eventhub.local (health timeout 30s).
docker_add_hosts=() docker_add_hosts=()
-101
View File
@@ -1,101 +0,0 @@
-module(archive_controller).
-compile([{nowarn_deprecated_function, [{slave, start, 3}, {slave, stop, 1}]}]).
-include("records.hrl").
-export([archive_day/1]).
archive_day(Day) ->
ArchiveNode = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
case start_archive_node(ArchiveNode) of
{ok, PeerOrSlave} ->
try
rpc:call(ArchiveNode, mnesia, create_schema, [[ArchiveNode]]),
rpc:call(ArchiveNode, mnesia, start, []),
rpc:call(ArchiveNode, code, ensure_loaded, [archive_fetcher]),
create_archive_table(ArchiveNode, event,
[id, calendar_id, start_time, end_time, event_type,
master_id, specialist_id, title, description,
attachments, edit_history, status, created_at, updated_at],
[calendar_id, start_time, event_type, master_id,
specialist_id, status]),
create_archive_table(ArchiveNode, booking,
[id, event_id, user_id, status, confirmed_at,
created_at, updated_at, notes, reminder_sent],
[event_id, user_id, status]),
create_archive_table(ArchiveNode, review,
[id, user_id, target_type, target_id, rating, comment,
status, reason, created_at, updated_at, likes,
dislikes, edited_at], []),
create_archive_table(ArchiveNode, report,
[id, reporter_id, target_type, target_id, reason,
status, created_at, resolved_at, resolved_by], []),
ok = transfer_data(ArchiveNode, Day),
io:format("Archived day ~s successfully.~n", [Day])
after
stop_archive_node(PeerOrSlave, ArchiveNode)
end;
{error, Reason} ->
io:format("Failed to start archive node: ~p~n", [Reason]),
{error, Reason}
end.
start_archive_node(Node) ->
case os:getenv("CLUSTER_MODE") of
"true" ->
peer:start_link(#{name => Node, host => host()});
_ ->
CookieStr = atom_to_list(erlang:get_cookie()),
case slave:start(host(), Node, "-setcookie " ++ CookieStr) of
{ok, Slave} -> {ok, Slave};
Error -> Error
end
end.
stop_archive_node(PeerOrSlave, Node) ->
case os:getenv("CLUSTER_MODE") of
"true" -> peer:stop(PeerOrSlave);
_ -> slave:stop(Node)
end.
create_archive_table(Node, Tab, Attributes, Indices) ->
Opts = [{disc_only_copies, [Node]},
{attributes, Attributes},
{type, set}] ++ case Indices of
[] -> [];
_ -> [{index, Indices}]
end,
rpc:call(Node, mnesia, create_table, [Tab, Opts]).
transfer_data(ArchiveNode, Day) ->
Tables = [event, booking, review, report],
lists:foreach(fun(Tab) ->
Records = fetch_records(Tab, Day),
rpc:call(ArchiveNode, mnesia, transaction, [
fun() -> [mnesia:write(Rec) || Rec <- Records] end
]),
mnesia:transaction(fun() ->
[mnesia:delete({Tab, element(2, Rec)}) || Rec <- Records]
end)
end, Tables).
fetch_records(event, Day) ->
Start = list_to_binary(Day ++ " 00:00:00"),
End = list_to_binary(Day ++ " 23:59:59"),
mnesia:dirty_select(event, [{#event{start_time = '$1', _ = '_'},
[{'>=','$1', Start},{'=<','$1', End}],
['$_']}]);
fetch_records(booking, Day) ->
mnesia:dirty_select(booking, [{#booking{created_at = '$1', _ = '_'},
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
['$_']}]);
fetch_records(review, Day) ->
mnesia:dirty_select(review, [{#review{created_at = '$1', _ = '_'},
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
['$_']}]);
fetch_records(report, Day) ->
mnesia:dirty_select(report, [{#report{created_at = '$1', _ = '_'},
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
['$_']}]).
host() ->
{ok, Name} = inet:gethostname(),
Name.
-12
View File
@@ -1,12 +0,0 @@
-module(archive_fetcher).
-include("records.hrl").
-export([fetch/3]).
fetch(CalendarId, Year, Month) ->
Start = {{Year, Month, 1}, {0, 0, 0}},
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}},
mnesia:dirty_select(event, [{#event{calendar_id = CalendarId,
start_time = '$1', _ = '_'},
[{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}],
['$_']}]).
-190
View File
@@ -1,190 +0,0 @@
-module(archive_manager).
-behaviour(gen_server).
-compile([{nowarn_deprecated_function, [{slave, start, 3}]}]).
%% Peer start must not run inside handle_call: peer:start_it timeout
%% exits the gen_server and cascades via infra_sup (seen under IFT load).
%% Starts are async single-flight; callers wait or get {error, _}.
-export([start_link/0, get_archive_node/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
-define(IDLE_MS, 30000).
-define(PEER_CONN_MS, 15000).
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
get_archive_node(Day) ->
gen_server:call(?MODULE, {get_node, Day}).
init([]) ->
process_flag(trap_exit, true),
{ok, #{nodes => #{}, starting => #{}}}.
handle_call({get_node, Day}, From, State) ->
Node = archive_node_name(Day),
Nodes = maps:get(nodes, State),
Starting = maps:get(starting, State),
case maps:find(Node, Nodes) of
{ok, _} ->
{reply, {ok, Node}, State#{nodes := touch_node(Nodes, Node)}};
error ->
case is_node_alive(Node) of
true ->
{reply, {ok, Node}, State#{nodes := register_node(Nodes, Node)}};
false ->
case maps:find(Day, Starting) of
{ok, Waiters} ->
{noreply, State#{starting := Starting#{Day => [From | Waiters]}}};
error ->
spawn_starter(Day),
{noreply, State#{starting := Starting#{Day => [From]}}}
end
end
end;
handle_call(_Req, _From, State) ->
{reply, {error, unknown_call}, State}.
handle_cast(_, State) ->
{noreply, State}.
handle_info({start_done, Day, Result}, State) ->
Starting = maps:get(starting, State),
Waiters = maps:get(Day, Starting, []),
NewStarting = maps:remove(Day, Starting),
Nodes = maps:get(nodes, State),
case Result of
{ok, Node} ->
reply_all(Waiters, {ok, Node}),
{noreply, State#{
nodes := register_node(Nodes, Node),
starting := NewStarting
}};
{error, Reason} ->
reply_all(Waiters, {error, Reason}),
{noreply, State#{starting := NewStarting}}
end;
handle_info({release, Node}, State) ->
Nodes = maps:get(nodes, State),
case maps:find(Node, Nodes) of
{ok, #{last_access := Last, timer := _Old}} ->
Idle = erlang:convert_time_unit(
erlang:monotonic_time() - Last, native, millisecond),
if Idle >= ?IDLE_MS ->
stop_archive_node(Node),
{noreply, State#{nodes := maps:remove(Node, Nodes)}};
true ->
{noreply, State#{nodes := touch_node(Nodes, Node)}}
end;
error ->
{noreply, State}
end;
handle_info({'EXIT', _Pid, _Reason}, State) ->
{noreply, State};
handle_info(_, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%%-------------------------------------------------------------------
%%% Internal
%%%-------------------------------------------------------------------
spawn_starter(Day) ->
Parent = self(),
spawn(fun() ->
Result =
try start_archive_node(Day) of
Ok -> Ok
catch
exit:{timeout, _} -> {error, peer_timeout};
exit:Reason -> {error, {exit, Reason}};
error:Reason -> {error, {error, Reason}};
throw:Reason -> {error, {throw, Reason}}
end,
Parent ! {start_done, Day, Result}
end).
reply_all(Waiters, Reply) ->
lists:foreach(fun(From) -> gen_server:reply(From, Reply) end, Waiters).
register_node(Nodes, Node) ->
case maps:find(Node, Nodes) of
{ok, #{timer := OldRef}} ->
_ = erlang:cancel_timer(OldRef),
ok;
error ->
ok
end,
Ref = erlang:send_after(?IDLE_MS, self(), {release, Node}),
Nodes#{Node => #{timer => Ref, last_access => erlang:monotonic_time()}}.
touch_node(Nodes, Node) ->
register_node(Nodes, Node).
is_node_alive(Node) ->
case net_adm:ping(Node) of
pong -> true;
pang -> false
end.
start_archive_node(Day) ->
Node = archive_node_name(Day),
case start_archive_peer(Node) of
{ok, _} ->
case ensure_archive_node_ready(Node) of
ok -> {ok, Node};
{error, Reason} -> {error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
start_archive_peer(Node) ->
case os:getenv("CLUSTER_MODE") of
"true" ->
case peer:start_link(#{
name => Node,
host => host(),
connection_timeout => ?PEER_CONN_MS
}) of
{ok, _} -> {ok, Node};
{error, {already_started, _}} -> {ok, Node};
Error -> Error
end;
_ ->
CookieStr = atom_to_list(erlang:get_cookie()),
case slave:start(host(), Node, "-setcookie " ++ CookieStr) of
{ok, _} -> {ok, Node};
{error, {already_running, _}} -> {ok, Node};
Error -> Error
end
end.
ensure_archive_node_ready(Node) ->
case rpc:call(Node, mnesia, start, [], 5000) of
ok ->
case rpc:call(Node, code, ensure_loaded, [archive_fetcher], 5000) of
{module, archive_fetcher} -> ok;
{error, Reason} -> {error, Reason};
{badrpc, Reason} -> {error, Reason}
end;
{badrpc, Reason} -> {error, Reason};
Other -> {error, Other}
end.
archive_node_name(Day) ->
list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()).
stop_archive_node(Node) ->
rpc:cast(Node, init, stop, []).
host() ->
{ok, Name} = inet:gethostname(),
Name.
-57
View File
@@ -1,57 +0,0 @@
-module(calendar_html_renderer).
-include("records.hrl").
-export([render_month/3, init_cache/0]).
init_cache() ->
case ets:info(archive_html_cache) of
undefined ->
ets:new(archive_html_cache, [set, public, named_table, {keypos, 1}]);
_ -> ok
end.
render_month(Year, Month, Events) ->
Key = {Year, Month},
try ets:lookup(archive_html_cache, Key) of
[{Key, Html}] -> Html;
[] ->
Html = generate_html(Year, Month, Events),
ets:insert(archive_html_cache, {Key, Html}),
Html
catch
_:_ ->
generate_html(Year, Month, Events)
end.
generate_html(Year, Month, Events) ->
DaysInMonth = calendar:last_day_of_the_month(Year, Month),
EventsByDay = group_events_by_day(Events),
DayList = lists:seq(1, DaysInMonth),
DayCells = lists:map(fun(D) ->
DayEvents = maps:get(D, EventsByDay, []),
["<td>", integer_to_list(D), format_events(DayEvents), "</td>"]
end, DayList),
["<html><body><table>",
"<tr>", DayCells, "</tr>",
"</table></body></html>"].
group_events_by_day(Events) ->
lists:foldl(fun(Evt, Acc) ->
case Evt of
#event{start_time = {{_, _, Day}, {_, _, _}}} ->
maps:update_with(Day, fun(List) -> [Evt | List] end, [Evt], Acc);
_ -> Acc
end
end, #{}, Events).
format_events(Events) ->
case Events of
[] -> [];
_ ->
["<ul>",
lists:map(fun(#event{title = Title, start_time = {{_, _, _}, {H, M, _}}}) ->
["<li>", Title, " (", integer_to_list(H), ":",
io_lib:format("~2..0B", [M]), ")</li>"]
end, Events),
"</ul>"]
end.
+68 -3
View File
@@ -6,17 +6,30 @@
-module(core_auth_session). -module(core_auth_session).
-include("records.hrl"). -include("records.hrl").
-include_lib("stdlib/include/ms_transform.hrl"). -include_lib("stdlib/include/ms_transform.hrl").
-export([create/3, get/1, rotate/2, revoke/1, revoke_family/1, revoke_all_for_subject/2]). -export([
create/3, create/5, get/1, rotate/2, revoke/1, revoke_family/1,
revoke_all_for_subject/2, list_active_for_subject/2, revoke_others_for_subject/3
]).
-define(REFRESH_TTL_SECONDS, 30 * 24 * 3600). -define(REFRESH_TTL_SECONDS, 30 * 24 * 3600).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Создать новую сессию устройства/клиента. %%% @doc Создать новую сессию устройства/клиента (без device meta).
%%% @end %%% @end
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary()) -> -spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary()) ->
{ok, #auth_session{}}. {ok, #auth_session{}}.
create(SubjectId, SubjectType, ClientType) -> create(SubjectId, SubjectType, ClientType) ->
create(SubjectId, SubjectType, ClientType, <<>>, <<>>).
%%%-------------------------------------------------------------------
%%% @doc Создать сессию с device_name и user_agent.
%%% @end
%%%-------------------------------------------------------------------
-spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary(),
DeviceName :: binary(), UserAgent :: binary()) ->
{ok, #auth_session{}}.
create(SubjectId, SubjectType, ClientType, DeviceName, UserAgent) ->
Now = calendar:universal_time(), Now = calendar:universal_time(),
ExpiresAt = calendar:gregorian_seconds_to_datetime( ExpiresAt = calendar:gregorian_seconds_to_datetime(
calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS
@@ -31,7 +44,9 @@ create(SubjectId, SubjectType, ClientType) ->
expires_at = ExpiresAt, expires_at = ExpiresAt,
revoked = false, revoked = false,
created_at = Now, created_at = Now,
updated_at = Now updated_at = Now,
device_name = DeviceName,
user_agent = UserAgent
}, },
%% Transaction ensures atomic write of the new session record. %% Transaction ensures atomic write of the new session record.
case mnesia:transaction(fun() -> mnesia:write(Session) end) of case mnesia:transaction(fun() -> mnesia:write(Session) end) of
@@ -169,6 +184,56 @@ revoke_all_for_subject(SubjectId, SubjectType) ->
{error, transaction_failed} {error, transaction_failed}
end. end.
%%%-------------------------------------------------------------------
%%% @doc Активные (не revoked, не expired) сессии субъекта.
%%% @end
%%%-------------------------------------------------------------------
-spec list_active_for_subject(SubjectId :: binary(), SubjectType :: user | admin) ->
[#auth_session{}].
list_active_for_subject(SubjectId, SubjectType) ->
Now = calendar:universal_time(),
MS = ets:fun2ms(fun(#auth_session{subject_id = Sid} = S)
when Sid =:= SubjectId -> S end),
Sessions = mnesia:dirty_select(auth_session, MS),
lists:filter(fun(S) ->
S#auth_session.subject_type =:= SubjectType andalso
S#auth_session.revoked =:= false andalso
S#auth_session.expires_at > Now
end, Sessions).
%%%-------------------------------------------------------------------
%%% @doc Отозвать все активные сессии субъекта, кроме KeepSessionId.
%%% @end
%%%-------------------------------------------------------------------
-spec revoke_others_for_subject(SubjectId :: binary(), SubjectType :: user | admin,
KeepSessionId :: binary()) ->
{ok, non_neg_integer()} | {error, transaction_failed}.
revoke_others_for_subject(SubjectId, SubjectType, KeepSessionId) ->
F = fun() ->
MS = ets:fun2ms(fun(#auth_session{subject_id = Sid} = S)
when Sid =:= SubjectId -> S end),
Sessions = mnesia:select(auth_session, MS, write),
Now = calendar:universal_time(),
lists:foldl(fun(Session, Acc) ->
case Session#auth_session.subject_type =:= SubjectType andalso
Session#auth_session.revoked =:= false andalso
Session#auth_session.session_id =/= KeepSessionId of
true ->
mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
Acc + 1;
false ->
Acc
end
end, 0, Sessions)
end,
case mnesia:transaction(F) of
{atomic, Count} ->
lists:foreach(fun(_) -> dec_counter(SubjectType) end, lists:seq(1, Count)),
{ok, Count};
{aborted, _Reason} ->
{error, transaction_failed}
end.
%%%=================================================================== %%%===================================================================
%%% Internal %%% Internal
%%%=================================================================== %%%===================================================================
+24 -1
View File
@@ -1,6 +1,7 @@
-module(core_calendar). -module(core_calendar).
-include("records.hrl"). -include("records.hrl").
-export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]). -export([create/4, create/5, get_by_id/1, get_by_short_name/1, get_by_id_or_short_name/1,
list_by_owner/1, update/2, delete/1]).
-export([count_calendars/0, list_all/0]). -export([count_calendars/0, list_all/0]).
-export([freeze/2, unfreeze/2]). -export([freeze/2, unfreeze/2]).
-export([count_calendars_by_date/2]). -export([count_calendars_by_date/2]).
@@ -78,6 +79,28 @@ get_by_id(Id) ->
[Calendar] -> {ok, Calendar} [Calendar] -> {ok, Calendar}
end. end.
%% Unique public slug. Empty name is not a slug (many Default calendars).
-spec get_by_short_name(Name :: binary()) -> {ok, #calendar{}} | {error, not_found}.
get_by_short_name(<<>>) ->
{error, not_found};
get_by_short_name(Name) when is_binary(Name) ->
case mnesia:dirty_index_read(calendar, Name, #calendar.short_name) of
[] ->
{error, not_found};
Rows ->
case [C || C <- Rows, C#calendar.status =:= active] of
[C | _] -> {ok, C};
[] -> {error, not_found}
end
end.
-spec get_by_id_or_short_name(IdOrSlug :: binary()) -> {ok, #calendar{}} | {error, not_found}.
get_by_id_or_short_name(IdOrSlug) ->
case get_by_id(IdOrSlug) of
{ok, C} -> {ok, C};
{error, not_found} -> get_by_short_name(IdOrSlug)
end.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Список активных календарей владельца. %%% @doc Список активных календарей владельца.
%%% @end %%% @end
+125
View File
@@ -0,0 +1,125 @@
%%%-------------------------------------------------------------------
%%% @doc Хранение grants calendar_share (соредактор / заместитель).
%%% @end
%%%-------------------------------------------------------------------
-module(core_calendar_share).
-include("records.hrl").
-export([upsert/4, get/2, list_by_calendar/1, list_by_user/1,
delete/2, update_rights/3, update_mirror/3]).
-spec upsert(CalendarId :: binary(), UserId :: binary(),
Rights :: read | write | admin, Mirror :: boolean()) ->
{ok, #calendar_share{}} | {error, term()}.
upsert(CalendarId, UserId, Rights, Mirror) ->
F = fun() ->
case find(CalendarId, UserId) of
[#calendar_share{} = Existing] ->
Updated = Existing#calendar_share{
rights = Rights,
mirror_to_default = Mirror
},
mnesia:write(Updated),
{ok, Updated};
[] ->
Rec = #calendar_share{
id = infra_utils:generate_id(16),
calendar_id = CalendarId,
user_id = UserId,
rights = Rights,
mirror_to_default = Mirror
},
mnesia:write(Rec),
{ok, Rec}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-spec get(CalendarId :: binary(), UserId :: binary()) ->
{ok, #calendar_share{}} | {error, not_found}.
get(CalendarId, UserId) ->
try find_dirty(CalendarId, UserId) of
[Rec] -> {ok, Rec};
[] -> {error, not_found}
catch
exit:{aborted, {no_exists, _}} -> {error, not_found};
error:_ -> {error, not_found}
end.
-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_share{}].
list_by_calendar(CalendarId) ->
try
mnesia:dirty_match_object(#calendar_share{calendar_id = CalendarId, _ = '_'})
catch
exit:{aborted, {no_exists, _}} -> [];
error:_ -> []
end.
-spec list_by_user(UserId :: binary()) -> [#calendar_share{}].
list_by_user(UserId) ->
try
mnesia:dirty_match_object(#calendar_share{user_id = UserId, _ = '_'})
catch
exit:{aborted, {no_exists, _}} -> [];
error:_ -> []
end.
-spec delete(CalendarId :: binary(), UserId :: binary()) -> ok | {error, not_found | term()}.
delete(CalendarId, UserId) ->
F = fun() ->
case find(CalendarId, UserId) of
[] -> {error, not_found};
[#calendar_share{id = Id}] ->
mnesia:delete({calendar_share, Id}),
ok
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-spec update_rights(CalendarId :: binary(), UserId :: binary(),
Rights :: read | write | admin) ->
{ok, #calendar_share{}} | {error, not_found | term()}.
update_rights(CalendarId, UserId, Rights) ->
F = fun() ->
case find(CalendarId, UserId) of
[] -> {error, not_found};
[#calendar_share{} = Rec] ->
Updated = Rec#calendar_share{rights = Rights},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-spec update_mirror(CalendarId :: binary(), UserId :: binary(), Mirror :: boolean()) ->
{ok, #calendar_share{}} | {error, not_found | term()}.
update_mirror(CalendarId, UserId, Mirror) ->
F = fun() ->
case find(CalendarId, UserId) of
[] -> {error, not_found};
[#calendar_share{} = Rec] ->
Updated = Rec#calendar_share{mirror_to_default = Mirror},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
find(CalendarId, UserId) ->
mnesia:match_object(#calendar_share{calendar_id = CalendarId, user_id = UserId, _ = '_'}).
find_dirty(CalendarId, UserId) ->
mnesia:dirty_match_object(
#calendar_share{calendar_id = CalendarId, user_id = UserId, _ = '_'}).
+90
View File
@@ -0,0 +1,90 @@
%%%-------------------------------------------------------------------
%%% @doc Хранение приглашений calendar_share.
%%% @end
%%%-------------------------------------------------------------------
-module(core_calendar_share_invite).
-include("records.hrl").
-export([create/1, get_by_id/1, get_by_token/1, list_by_calendar/1,
list_by_invitee/1, list_by_email/1, update_status/2, find_pending/3,
write/1]).
-spec create(#calendar_share_invite{}) ->
{ok, #calendar_share_invite{}} | {error, term()}.
create(Rec) ->
F = fun() ->
mnesia:write(Rec),
{ok, Rec}
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-spec write(#calendar_share_invite{}) -> ok.
write(Rec) ->
mnesia:dirty_write(Rec).
-spec get_by_id(Id :: binary()) ->
{ok, #calendar_share_invite{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(calendar_share_invite, Id) of
[Rec] -> {ok, Rec};
[] -> {error, not_found}
end.
-spec get_by_token(Token :: binary()) ->
{ok, #calendar_share_invite{}} | {error, not_found}.
get_by_token(Token) ->
case mnesia:dirty_index_read(calendar_share_invite, Token,
#calendar_share_invite.token) of
[Rec] -> {ok, Rec};
[] -> {error, not_found};
[Rec | _] -> {ok, Rec}
end.
-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_share_invite{}].
list_by_calendar(CalendarId) ->
mnesia:dirty_match_object(
#calendar_share_invite{calendar_id = CalendarId, _ = '_'}).
-spec list_by_invitee(UserId :: binary()) -> [#calendar_share_invite{}].
list_by_invitee(UserId) ->
mnesia:dirty_match_object(
#calendar_share_invite{invitee_user_id = UserId, _ = '_'}).
-spec list_by_email(Email :: binary()) -> [#calendar_share_invite{}].
list_by_email(Email) ->
mnesia:dirty_match_object(
#calendar_share_invite{invitee_email = Email, _ = '_'}).
-spec update_status(Id :: binary(), Status :: atom()) ->
{ok, #calendar_share_invite{}} | {error, not_found | term()}.
update_status(Id, Status) ->
F = fun() ->
case mnesia:read(calendar_share_invite, Id) of
[] ->
{error, not_found};
[Rec] ->
Updated = Rec#calendar_share_invite{status = Status},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
-spec find_pending(CalendarId :: binary(), UserId :: binary(), Email :: binary()) ->
[#calendar_share_invite{}].
find_pending(CalendarId, UserId, Email) ->
All = list_by_calendar(CalendarId),
[I || I <- All, I#calendar_share_invite.status =:= pending,
matches_pending(I, UserId, Email)].
matches_pending(#calendar_share_invite{invitee_user_id = U}, UserId, _)
when UserId =/= <<>>, U =:= UserId -> true;
matches_pending(#calendar_share_invite{invitee_email = E}, _, Email)
when Email =/= <<>>, E =/= <<>>, E =:= Email -> true;
matches_pending(_, _, _) -> false.
+73
View File
@@ -0,0 +1,73 @@
%%%-------------------------------------------------------------------
%%% @doc Web Push subscriptions (Back#75).
%%% @end
%%%-------------------------------------------------------------------
-module(core_push_subscription).
-export([upsert/4, list_by_user/1, delete_by_endpoint/2, delete_by_id/1,
get_by_endpoint/1]).
-include("records.hrl").
-spec upsert(binary(), binary(), binary(), binary()) ->
{ok, #push_subscription{}} | {error, term()}.
upsert(UserId, Endpoint, P256dh, Auth) ->
Now = calendar:universal_time(),
case get_by_endpoint(Endpoint) of
{ok, #push_subscription{} = Existing} ->
Rec = Existing#push_subscription{
user_id = UserId,
p256dh = P256dh,
auth = Auth,
created_at = Now
},
case mnesia:transaction(fun() -> mnesia:write(Rec) end) of
{atomic, ok} -> {ok, Rec};
{aborted, Reason} -> {error, Reason}
end;
{error, not_found} ->
Rec = #push_subscription{
id = infra_utils:generate_id(16),
user_id = UserId,
endpoint = Endpoint,
p256dh = P256dh,
auth = Auth,
created_at = Now
},
case mnesia:transaction(fun() -> mnesia:write(Rec) end) of
{atomic, ok} -> {ok, Rec};
{aborted, Reason} -> {error, Reason}
end
end.
-spec list_by_user(binary()) -> [#push_subscription{}].
list_by_user(UserId) ->
mnesia:dirty_match_object(#push_subscription{user_id = UserId, _ = '_'}).
-spec get_by_endpoint(binary()) -> {ok, #push_subscription{}} | {error, not_found}.
get_by_endpoint(Endpoint) ->
case mnesia:dirty_index_read(push_subscription, Endpoint, #push_subscription.endpoint) of
[Rec | _] -> {ok, Rec};
[] ->
case mnesia:dirty_match_object(#push_subscription{endpoint = Endpoint, _ = '_'}) of
[Rec | _] -> {ok, Rec};
[] -> {error, not_found}
end
end.
-spec delete_by_endpoint(binary(), binary()) -> ok | {error, not_found | forbidden}.
delete_by_endpoint(UserId, Endpoint) ->
case get_by_endpoint(Endpoint) of
{ok, #push_subscription{id = Id, user_id = UserId}} ->
delete_by_id(Id);
{ok, #push_subscription{}} ->
{error, forbidden};
{error, not_found} ->
{error, not_found}
end.
-spec delete_by_id(binary()) -> ok | {error, term()}.
delete_by_id(Id) ->
case mnesia:transaction(fun() -> mnesia:delete({push_subscription, Id}) end) of
{atomic, ok} -> ok;
{aborted, Reason} -> {error, Reason}
end.
+66
View File
@@ -0,0 +1,66 @@
-module(core_waitlist).
-include("records.hrl").
-export([create/2, get_by_id/1, update/2,
list_by_event/1, list_waiting_by_event/1,
get_waiting/2]).
-spec create(EventId :: binary(), UserId :: binary()) ->
{ok, #waitlist_entry{}} | {error, term()}.
create(EventId, UserId) ->
Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
Entry = #waitlist_entry{
id = Id,
event_id = EventId,
user_id = UserId,
status = waiting,
created_at = Now,
updated_at = Now
},
case mnesia:dirty_write(Entry) of
ok -> {ok, Entry};
Error -> {error, Error}
end.
-spec get_by_id(binary()) -> {ok, #waitlist_entry{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(waitlist_entry, Id) of
[E] -> {ok, E};
[] -> {error, not_found}
end.
-spec update(binary(), [{atom(), term()}]) ->
{ok, #waitlist_entry{}} | {error, not_found | term()}.
update(Id, Updates) ->
case get_by_id(Id) of
{ok, E0} ->
E1 = lists:foldl(fun apply_upd/2, E0, Updates),
E2 = E1#waitlist_entry{updated_at = calendar:universal_time()},
case mnesia:dirty_write(E2) of
ok -> {ok, E2};
Err -> {error, Err}
end;
{error, _} = Err ->
Err
end.
apply_upd({status, V}, E) -> E#waitlist_entry{status = V};
apply_upd(_, E) -> E.
-spec list_by_event(binary()) -> [#waitlist_entry{}].
list_by_event(EventId) ->
mnesia:dirty_index_read(waitlist_entry, EventId, #waitlist_entry.event_id).
-spec list_waiting_by_event(binary()) -> [#waitlist_entry{}].
list_waiting_by_event(EventId) ->
[E || E <- list_by_event(EventId), E#waitlist_entry.status =:= waiting].
-spec get_waiting(EventId :: binary(), UserId :: binary()) ->
{ok, #waitlist_entry{}} | {error, not_found}.
get_waiting(EventId, UserId) ->
case [E || E <- list_by_event(EventId),
E#waitlist_entry.user_id =:= UserId,
E#waitlist_entry.status =:= waiting] of
[E | _] -> {ok, E};
[] -> {error, not_found}
end.
+4 -1
View File
@@ -11,7 +11,10 @@
cowboy, cowboy,
jsx, jsx,
trails, trails,
cowboy_swagger cowboy_swagger,
ssl,
inets,
gen_smtp
]}, ]},
{env, [ {env, [
{http_port, 8080}, {http_port, 8080},
+28 -2
View File
@@ -57,7 +57,6 @@ start_application() ->
ok = migration_engine:ensure_applied(), ok = migration_engine:ensure_applied(),
%% После wait + migrations: иначе backfill ловит {no_exists, user} на ещё не загруженных таблицах. %% После wait + migrations: иначе backfill ловит {no_exists, user} на ещё не загруженных таблицах.
ok = stats_collector:subscribe(), ok = stats_collector:subscribe(),
calendar_html_renderer:init_cache(),
application:ensure_all_started(cowboy), application:ensure_all_started(cowboy),
start_http(), % Пользовательский API (8080) start_http(), % Пользовательский API (8080)
start_admin_http(), % Административный API (8445) start_admin_http(), % Административный API (8445)
@@ -83,37 +82,61 @@ start_http() ->
{'_', [ {'_', [
{"/metrics/[:registry]", prometheus_cowboy2_handler, []}, {"/metrics/[:registry]", prometheus_cowboy2_handler, []},
{"/health", handler_health, []}, {"/health", handler_health, []},
{"/v1/media/:kind/:owner/:file", handler_media, []},
{"/v1/register", handler_register, []}, {"/v1/register", handler_register, []},
{"/v1/verify", handler_verify, []}, {"/v1/verify", handler_verify, []},
{"/v1/forgot-password", handler_forgot_password, []}, {"/v1/forgot-password", handler_forgot_password, []},
{"/v1/reset-password", handler_reset_password, []}, {"/v1/reset-password", handler_reset_password, []},
{"/v1/login", handler_login, []}, {"/v1/login", handler_login, []},
{"/v1/refresh", handler_refresh, []}, {"/v1/refresh", handler_refresh, []},
{"/v1/logout", handler_logout, []},
{"/v1/sessions", handler_sessions, []},
{"/v1/sessions/revoke-others", handler_sessions, []},
{"/v1/sessions/:id", handler_sessions, []},
{"/v1/user/me", handler_user_me, []}, {"/v1/user/me", handler_user_me, []},
{"/v1/user/me/avatar", handler_user_avatar, []},
{"/v1/notifications/prefs", handler_notification_prefs, []},
{"/v1/push/vapid-public-key", handler_push, []},
{"/v1/push/subscriptions", handler_push, []},
{"/v1/user/bookings", handler_user_bookings, []}, {"/v1/user/bookings", handler_user_bookings, []},
{"/v1/user/booking-requests", handler_user_booking_requests, []}, {"/v1/user/booking-requests", handler_user_booking_requests, []},
{"/v1/user/studio-bookings", handler_user_studio_bookings, []}, {"/v1/user/studio-bookings", handler_user_studio_bookings, []},
{"/v1/user/reviews", handler_user_reviews, []}, {"/v1/user/reviews", handler_user_reviews, []},
{"/v1/user/following", handler_user_following, []}, {"/v1/user/following", handler_user_following, []},
{"/v1/user/specialist-invites", handler_specialist_invites, []}, {"/v1/user/specialist-invites", handler_specialist_invites, []},
{"/v1/user/share-invites", handler_share_invites, []},
{"/v1/user/shares/:calendar_id", handler_calendar_shares, []},
{"/v1/users/lookup", handler_users_lookup, []}, {"/v1/users/lookup", handler_users_lookup, []},
{"/v1/search", handler_search, []}, {"/v1/search", handler_search, []},
{"/v1/geo/suggest", handler_geo, []},
{"/v1/geo/geocode", handler_geo, []},
{"/v1/geo/reverse", handler_geo, []},
{"/v1/ai/hint-metrics", handler_ai_metrics, []},
{"/v1/ai/transcribe", handler_ai_transcribe, []},
{"/v1/calendars", handler_calendars, []}, {"/v1/calendars", handler_calendars, []},
{"/v1/calendars/:id", handler_calendar_by_id, []}, {"/v1/calendars/:id", handler_calendar_by_id, []},
{"/v1/calendars/:id/cover", handler_calendar_cover, []},
{"/v1/calendars/:id/follow", handler_calendar_follow, []}, {"/v1/calendars/:id/follow", handler_calendar_follow, []},
{"/v1/calendars/:id/specialists", handler_calendar_specialists, []}, {"/v1/calendars/:id/specialists", handler_calendar_specialists, []},
{"/v1/calendars/:id/specialists/:user_id", handler_calendar_specialists, []}, {"/v1/calendars/:id/specialists/:user_id", handler_calendar_specialists, []},
{"/v1/calendars/:id/specialist-invites", handler_calendar_specialist_invites, []}, {"/v1/calendars/:id/specialist-invites", handler_calendar_specialist_invites, []},
{"/v1/calendars/:id/specialist-invites/:invite_id", handler_calendar_specialist_invites, []}, {"/v1/calendars/:id/specialist-invites/:invite_id", handler_calendar_specialist_invites, []},
{"/v1/calendars/:id/share-invites", handler_calendar_share_invites, []},
{"/v1/calendars/:id/share-invites/:invite_id", handler_calendar_share_invites, []},
{"/v1/calendars/:id/shares", handler_calendar_shares, []},
{"/v1/calendars/:id/shares/:user_id", handler_calendar_shares, []},
{"/v1/specialist-invites/accept", handler_specialist_invites, []}, {"/v1/specialist-invites/accept", handler_specialist_invites, []},
{"/v1/specialist-invites/:id/accept", handler_specialist_invites, []}, {"/v1/specialist-invites/:id/accept", handler_specialist_invites, []},
{"/v1/specialist-invites/:id/decline", handler_specialist_invites, []}, {"/v1/specialist-invites/:id/decline", handler_specialist_invites, []},
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []}, {"/v1/share-invites/accept", handler_share_invites, []},
{"/v1/share-invites/:id/accept", handler_share_invites, []},
{"/v1/share-invites/:id/decline", handler_share_invites, []},
{"/v1/calendars/:calendar_id/events", handler_events, []}, {"/v1/calendars/:calendar_id/events", handler_events, []},
{"/v1/events/:id", handler_event_by_id, []}, {"/v1/events/:id", handler_event_by_id, []},
{"/v1/events/:id/occurrences", handler_event_occurrences, []}, {"/v1/events/:id/occurrences", handler_event_occurrences, []},
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []}, {"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
{"/v1/events/:id/bookings", handler_bookings, []}, {"/v1/events/:id/bookings", handler_bookings, []},
{"/v1/events/:id/waitlist", handler_event_waitlist, []},
{"/v1/bookings/:id", handler_booking_by_id, []}, {"/v1/bookings/:id", handler_booking_by_id, []},
{"/v1/reviews", handler_reviews, []}, {"/v1/reviews", handler_reviews, []},
{"/v1/reviews/:id", handler_review_by_id, []}, {"/v1/reviews/:id", handler_review_by_id, []},
@@ -147,6 +170,7 @@ start_admin_http() ->
{"/v1/admin/nodes/metrics", admin_handler_node_metrics, []}, {"/v1/admin/nodes/metrics", admin_handler_node_metrics, []},
{"/v1/admin/login", admin_handler_login, []}, {"/v1/admin/login", admin_handler_login, []},
{"/v1/admin/refresh", admin_handler_refresh, []}, {"/v1/admin/refresh", admin_handler_refresh, []},
{"/v1/admin/logout", admin_handler_logout, []},
% ================== ПОЛЬЗОВАТЕЛИ ================== % ================== ПОЛЬЗОВАТЕЛИ ==================
{"/v1/admin/users", admin_handler_users, []}, {"/v1/admin/users", admin_handler_users, []},
{"/v1/admin/users/stats", admin_handler_user_stats, []}, {"/v1/admin/users/stats", admin_handler_user_stats, []},
@@ -185,6 +209,8 @@ start_admin_http() ->
{"/v1/admin/subscriptions", admin_handler_subscriptions, []}, {"/v1/admin/subscriptions", admin_handler_subscriptions, []},
{"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []}, {"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []},
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []}, {"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
% ================== ИИ-МЕТРИКИ ==================
{"/v1/admin/ai-metrics/stats", admin_handler_ai_metrics, []},
% ================== Управление ролями (только для superadmin) ================== % ================== Управление ролями (только для superadmin) ==================
{"/v1/admin/me", admin_handler_me, []}, {"/v1/admin/me", admin_handler_me, []},
{"/v1/admin/admins", admin_handler_admins, []}, {"/v1/admin/admins", admin_handler_admins, []},
@@ -0,0 +1,38 @@
%%%-------------------------------------------------------------------
%%% @doc GET /v1/admin/ai-metrics/stats — сводка пилота ИИ-подсказок
%%% (PRODUCT-AI, Spec#22): CTR-счётчики, гейт-доля активных с >=2
%%% confirm за 30 дней, топ паттернов.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_ai_metrics).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_stats(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec trails() -> [map()].
trails() ->
[ #{
path => <<"/v1/admin/ai-metrics/stats">>,
method => <<"GET">>,
description => <<"AI hint pilot summary (PRODUCT-AI gates)">>,
tags => [<<"Statistics">>],
responses => #{
200 => #{description => <<"AI hint pilot statistics">>},
401 => #{description => <<"Unauthorized">>}
}
} ].
get_stats(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
handler_utils:send_json(Req1, 200, logic_ai_metrics:summary());
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
@@ -206,14 +206,7 @@ convert_field({<<"start_time">>, Val}) ->
convert_field({<<"duration">>, Val}) -> {duration, Val}; convert_field({<<"duration">>, Val}) -> {duration, Val};
convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)}; convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val}; convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
convert_field({<<"location">>, Val}) when is_map(Val) -> convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
Loc = #location{
address = maps:get(<<"address">>, Val, undefined),
lat = maps:get(<<"lat">>, Val, undefined),
lon = maps:get(<<"lon">>, Val, undefined)
},
{location, Loc};
convert_field({<<"location">>, Val}) -> {location, Val};
convert_field({<<"tags">>, Val}) -> {tags, Val}; convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val}; convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val}; convert_field({<<"online_link">>, Val}) -> {online_link, Val};
+3 -2
View File
@@ -22,7 +22,7 @@ init(Req0, _State) ->
{ok, _Token, User} -> {ok, _Token, User} ->
UserId = maps:get(id, User), UserId = maps:get(id, User),
Role = maps:get(role, User), Role = maps:get(role, User),
{ok, Token, RefreshToken} = logic_auth_session:issue_admin_tokens(UserId, Role), {ok, Token, RefreshToken, SessionId} = logic_auth_session:issue_admin_tokens(UserId, Role),
core_admin:update_last_login(UserId), core_admin:update_last_login(UserId),
% Аудит успешного входа % Аудит успешного входа
admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, <<>>, Req1), admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, <<>>, Req1),
@@ -33,7 +33,8 @@ init(Req0, _State) ->
<<"email">> => maps:get(email, User), <<"email">> => maps:get(email, User),
<<"role">> => maps:get(role, User) <<"role">> => maps:get(role, User)
}, },
<<"refresh_token">> => RefreshToken <<"refresh_token">> => RefreshToken,
<<"session_id">> => SessionId
}, },
handler_utils:send_json(Req1, 200, Resp); handler_utils:send_json(Req1, 200, Resp);
{error, insufficient_permissions} -> {error, insufficient_permissions} ->
@@ -0,0 +1,58 @@
%%%-------------------------------------------------------------------
%%% @doc POST /v1/admin/logout — отзыв admin refresh-сессии (вариант A / Back#69).
%%% @end
%%%-------------------------------------------------------------------
-module(admin_handler_logout).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> -> logout(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/admin/logout">>,
method => <<"POST">>,
description => <<"Revoke current admin refresh session">>,
tags => [<<"Admin Auth">>],
requestBody => #{
required => true,
content => #{
<<"application/json">> => #{
schema => #{
type => object,
required => [<<"refresh_token">>],
properties => #{refresh_token => #{type => string}}
}
}
}
},
responses => #{
200 => #{description => <<"Session revoked">>},
400 => #{description => <<"Missing refresh_token or invalid JSON">>},
401 => #{description => <<"Invalid refresh token">>}
}
}
].
logout(Req) ->
{ok, Body, Req1} = cowboy_req:read_body(Req),
try jsx:decode(Body, [return_maps]) of
#{<<"refresh_token">> := RefreshToken} ->
case logic_auth_session:logout_admin(RefreshToken) of
ok ->
handler_utils:send_json(Req1, 200, #{ok => true});
{error, _} ->
handler_utils:send_error(Req1, 401, <<"Invalid refresh token">>)
end;
_ ->
handler_utils:send_error(Req1, 400, <<"Missing refresh_token field">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
end.
+3 -2
View File
@@ -71,10 +71,11 @@ refresh(Req) ->
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"refresh_token">> := RefreshToken} -> #{<<"refresh_token">> := RefreshToken} ->
case logic_auth_session:refresh_admin(RefreshToken) of case logic_auth_session:refresh_admin(RefreshToken) of
{ok, NewToken, NewRefreshToken} -> {ok, NewToken, NewRefreshToken, SessionId} ->
handler_utils:send_json(Req1, 200, #{ handler_utils:send_json(Req1, 200, #{
token => NewToken, token => NewToken,
refresh_token => NewRefreshToken refresh_token => NewRefreshToken,
session_id => SessionId
}); });
{error, reuse_detected} -> {error, reuse_detected} ->
handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>); handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>);
+63
View File
@@ -0,0 +1,63 @@
%%%-------------------------------------------------------------------
%%% @doc POST /v1/ai/hint-metrics — пилотные метрики ИИ-подсказок
%%% (PRODUCT-AI, Spec#22). Bearer required.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_ai_metrics).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
init(Req, Opts) ->
handle(Req, Opts).
trails() ->
[
#{
path => <<"/v1/ai/hint-metrics">>,
method => <<"POST">>,
description => <<"AI hint pilot metrics (shown/confirm/dismiss counters). Bearer required.">>,
tags => [<<"AI">>],
responses => #{
202 => #{description => <<"accepted">>},
400 => #{description => <<"invalid_body">>},
401 => #{description => <<"Unauthorized">>},
429 => #{description => <<"rate_limited">>}
}
}
].
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> -> post(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
post(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
case decode_body(Body) of
{ok, Events} ->
case logic_ai_metrics:record_events(UserId, Events) of
{ok, Count} ->
handler_utils:send_json(Req2, 202, #{<<"accepted">> => Count});
{error, invalid_body} ->
handler_utils:send_error(Req2, 400, <<"invalid_body">>);
{error, rate_limited} ->
handler_utils:send_error(Req2, 429, <<"rate_limited">>)
end;
error ->
handler_utils:send_error(Req2, 400, <<"invalid_body">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
decode_body(Body) when is_binary(Body) ->
try jsx:decode(Body, [return_maps]) of
#{<<"events">> := Events} when is_list(Events) -> {ok, Events};
_ -> error
catch
_:_ -> error
end.
+68
View File
@@ -0,0 +1,68 @@
%%%-------------------------------------------------------------------
%%% @doc POST /v1/ai/transcribe — прокси транскрибации голоса
%%% (PRODUCT-AI, Front#76). Bearer required; multipart-поле file.
%%% Аудио и транскрипт не персистятся (приват-инвариант канона).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_ai_transcribe).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
init(Req, Opts) ->
handle(Req, Opts).
trails() ->
[
#{
path => <<"/v1/ai/transcribe">>,
method => <<"POST">>,
description => <<"Transcribe press-to-talk audio via STT provider proxy. "
"Bearer required; multipart field `file` (audio).">>,
tags => [<<"AI">>],
responses => #{
200 => #{description => <<"{transcript, lang}; empty transcript = unheard">>},
400 => #{description => <<"missing_file / bad_multipart / too_large">>},
401 => #{description => <<"Unauthorized">>},
429 => #{description => <<"rate_limited">>},
502 => #{description => <<"upstream_error">>},
503 => #{description => <<"unavailable">>}
}
}
].
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> -> post(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
post(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
Max = logic_ai_transcribe:max_bytes(),
case handler_upload_utils:read_file_part(Req1, Max) of
{ok, Audio, CType, Req2} ->
case logic_ai_transcribe:transcribe(UserId, Audio, CType) of
{ok, Transcript, Lang} ->
handler_utils:send_json(Req2, 200,
#{<<"transcript">> => Transcript, <<"lang">> => Lang});
{error, rate_limited} ->
handler_utils:send_error(Req2, 429, <<"rate_limited">>);
{error, unavailable} ->
handler_utils:send_error(Req2, 503, <<"unavailable">>);
{error, upstream_error} ->
handler_utils:send_error(Req2, 502, <<"upstream_error">>);
{error, invalid_audio} ->
handler_utils:send_error(Req2, 400, <<"invalid_audio">>)
end;
{error, too_large, Req2} ->
handler_utils:send_error(Req2, 400, <<"too_large">>);
{error, missing_file, Req2} ->
handler_utils:send_error(Req2, 400, <<"missing_file">>);
{error, bad_multipart, Req2} ->
handler_utils:send_error(Req2, 400, <<"bad_multipart">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
+2 -1
View File
@@ -121,7 +121,8 @@ get_calendar(Req) ->
case logic_calendar:get_calendar(UserId, CalendarId) of case logic_calendar:get_calendar(UserId, CalendarId) of
{ok, Calendar} -> {ok, Calendar} ->
Json0 = handler_utils:calendar_to_json(Calendar), Json0 = handler_utils:calendar_to_json(Calendar),
Following = UserId =/= <<>> andalso logic_calendar_follow:is_following(UserId, CalendarId), Following = UserId =/= <<>> andalso
logic_calendar_follow:is_following(UserId, Calendar#calendar.id),
handler_utils:send_json(Req1, 200, Json0#{following => Following}); handler_utils:send_json(Req1, 200, Json0#{following => Following});
{error, access_denied} -> {error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
+99
View File
@@ -0,0 +1,99 @@
%%%-------------------------------------------------------------------
%%% @doc POST /v1/calendars/:id/cover — multipart cover upload (Back#71).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_calendar_cover).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> -> post_cover(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/calendars/:id/cover">>,
method => <<"POST">>,
description => <<"Upload calendar cover image (owner; multipart field file)">>,
tags => [<<"Calendars">>],
requestBody => #{
required => true,
content => #{
<<"multipart/form-data">> => #{
schema => #{
type => object,
required => [<<"file">>],
properties => #{file => #{type => string, format => binary}}
}
}
}
},
responses => #{
200 => #{description => <<"Updated calendar">>},
401 => #{description => <<"Unauthorized">>},
403 => #{description => <<"Forbidden">>},
404 => #{description => <<"Not found">>},
413 => #{description => <<"File too large">>},
415 => #{description => <<"Unsupported media type">>},
400 => #{description => <<"Missing file / bad multipart">>}
}
}
].
post_cover(Req) ->
CalendarId = cowboy_req:binding(id, Req),
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case core_calendar:get_by_id(CalendarId) of
{ok, Cal} ->
case logic_calendar:can_edit(UserId, Cal) of
true ->
do_upload(UserId, CalendarId, Cal, Req1);
false ->
handler_utils:send_error(Req1, 403, <<"Forbidden">>)
end;
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Calendar not found">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
do_upload(UserId, CalendarId, #calendar{image_url = Old}, Req) ->
case handler_upload_utils:read_image_part(Req) of
{ok, Bin, Req2} ->
case logic_upload:save_cover(CalendarId, UserId, Bin) of
{ok, Url} ->
case logic_calendar:update_calendar(UserId, CalendarId, [{<<"image_url">>, Url}]) of
{ok, Updated} ->
_ = logic_upload:maybe_delete_url(Old),
handler_utils:send_json(Req2, 200, handler_utils:calendar_to_json(Updated));
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Forbidden">>);
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, _} ->
handler_utils:send_error(Req2, 400, <<"Update failed">>)
end;
{error, too_large} ->
handler_utils:send_error(Req2, 413, <<"File too large">>);
{error, unsupported_media} ->
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Upload failed">>)
end;
{error, missing_file, Req2} ->
handler_utils:send_error(Req2, 400, <<"Missing file field">>);
{error, too_large, Req2} ->
handler_utils:send_error(Req2, 413, <<"File too large">>);
{error, unsupported_media, Req2} ->
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
{error, bad_multipart, Req2} ->
handler_utils:send_error(Req2, 400, <<"Invalid multipart body">>)
end.
@@ -0,0 +1,134 @@
%%%-------------------------------------------------------------------
%%% @doc Owner/admin: исходящие share invites.
%%%
%%% GET/POST /v1/calendars/:id/share-invites
%%% DELETE /v1/calendars/:id/share-invites/:invite_id
%%% @end
%%%-------------------------------------------------------------------
-module(handler_calendar_share_invites).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-include("records.hrl").
init(Req, Opts) ->
handle(Req, Opts).
trails() ->
IdParam = #{name => <<"id">>, in => <<"path">>, required => true,
schema => #{type => string}},
InviteParam = #{name => <<"invite_id">>, in => <<"path">>, required => true,
schema => #{type => string}},
[
#{path => <<"/v1/calendars/:id/share-invites">>, method => <<"GET">>,
description => <<"List outgoing share invites">>, tags => [<<"Calendars">>],
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/calendars/:id/share-invites">>, method => <<"POST">>,
description => <<"Create share invite">>, tags => [<<"Calendars">>],
parameters => [IdParam], responses => #{201 => #{description => <<"Created">>}}},
#{path => <<"/v1/calendars/:id/share-invites/:invite_id">>, method => <<"DELETE">>,
description => <<"Cancel pending share invite">>, tags => [<<"Calendars">>],
parameters => [IdParam, InviteParam], responses => #{200 => #{description => <<"OK">>}}}
].
handle(Req, _Opts) ->
Method = cowboy_req:method(Req),
InviteId = cowboy_req:binding(invite_id, Req),
case {Method, InviteId} of
{<<"GET">>, undefined} -> list_invites(Req);
{<<"POST">>, undefined} -> create_invite(Req);
{<<"DELETE">>, Id} when is_binary(Id) -> cancel_invite(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
list_invites(Req) ->
case handler_utils:auth_user(Req) of
{ok, ActorId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
case logic_calendar_share_invite:list_outgoing(ActorId, CalendarId) of
{ok, List} ->
handler_utils:send_json(Req1, 200,
[logic_calendar_share_invite:to_json(I) || I <- List]);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
create_invite(Req) ->
case handler_utils:auth_user(Req) of
{ok, ActorId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) ->
case parse_target(Map) of
{error, bad_request} ->
handler_utils:send_error(Req2, 400, <<"user_id or email required">>);
Target ->
Opts = #{rights => maps:get(<<"rights">>, Map, <<"write">>)},
case logic_calendar_share_invite:create(ActorId, CalendarId, Target, Opts) of
{ok, Inv} ->
handler_utils:send_json(Req2, 201,
logic_calendar_share_invite:to_json(Inv));
{error, Reason} ->
map_create_error(Req2, Reason)
end
end;
_ ->
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
cancel_invite(Req) ->
case handler_utils:auth_user(Req) of
{ok, ActorId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
InviteId = cowboy_req:binding(invite_id, Req1),
case logic_calendar_share_invite:cancel(ActorId, CalendarId, InviteId) of
{ok, Inv} ->
handler_utils:send_json(Req1, 200, logic_calendar_share_invite:to_json(Inv));
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Not found">>);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_pending} ->
handler_utils:send_error(Req1, 409, <<"Invite is not pending">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
parse_target(#{<<"user_id">> := UserId}) when is_binary(UserId), UserId =/= <<>> ->
#{user_id => UserId};
parse_target(#{<<"email">> := Email}) when is_binary(Email), Email =/= <<>> ->
#{email => Email};
parse_target(_) ->
{error, bad_request}.
map_create_error(Req, not_found) ->
handler_utils:send_error(Req, 404, <<"Calendar not found">>);
map_create_error(Req, access_denied) ->
handler_utils:send_error(Req, 403, <<"Access denied">>);
map_create_error(Req, user_not_found) ->
handler_utils:send_error(Req, 404, <<"User not found">>);
map_create_error(Req, already_shared) ->
handler_utils:send_error(Req, 409, <<"Already shared">>);
map_create_error(Req, already_pending) ->
handler_utils:send_error(Req, 409, <<"Invite already pending">>);
map_create_error(Req, bad_request) ->
handler_utils:send_error(Req, 400, <<"Bad request">>);
map_create_error(Req, _) ->
handler_utils:send_error(Req, 500, <<"Internal server error">>).
+161
View File
@@ -0,0 +1,161 @@
%%%-------------------------------------------------------------------
%%% @doc Accepted shares on a calendar + revoke / rights / self-mirror.
%%%
%%% GET/DELETE/PUT /v1/calendars/:id/shares[/:user_id]
%%% PUT /v1/user/shares/:calendar_id body: {mirror_to_default}
%%% @end
%%%-------------------------------------------------------------------
-module(handler_calendar_shares).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-include("records.hrl").
init(Req, Opts) ->
handle(Req, Opts).
trails() ->
IdParam = #{name => <<"id">>, in => <<"path">>, required => true,
schema => #{type => string}},
UserParam = #{name => <<"user_id">>, in => <<"path">>, required => true,
schema => #{type => string}},
CalParam = #{name => <<"calendar_id">>, in => <<"path">>, required => true,
schema => #{type => string}},
[
#{path => <<"/v1/calendars/:id/shares">>, method => <<"GET">>,
description => <<"List calendar shares">>, tags => [<<"Calendars">>],
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/calendars/:id/shares/:user_id">>, method => <<"DELETE">>,
description => <<"Revoke share grant">>, tags => [<<"Calendars">>],
parameters => [IdParam, UserParam], responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/calendars/:id/shares/:user_id">>, method => <<"PUT">>,
description => <<"Update share rights">>, tags => [<<"Calendars">>],
parameters => [IdParam, UserParam], responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/user/shares/:calendar_id">>, method => <<"PUT">>,
description => <<"Update own mirror_to_default">>, tags => [<<"Users">>],
parameters => [CalParam], responses => #{200 => #{description => <<"OK">>}}}
].
handle(Req, _Opts) ->
Method = cowboy_req:method(Req),
Path = cowboy_req:path(Req),
case Path of
<<"/v1/user/shares/", _/binary>> ->
case Method of
<<"PUT">> -> update_my_mirror(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end;
_ ->
UserId = cowboy_req:binding(user_id, Req),
case {Method, UserId} of
{<<"GET">>, undefined} -> list_shares(Req);
{<<"DELETE">>, Id} when is_binary(Id) -> revoke_share(Req);
{<<"PUT">>, Id} when is_binary(Id) -> update_rights(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end
end.
list_shares(Req) ->
case handler_utils:auth_user(Req) of
{ok, ActorId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
case logic_calendar_share:list(ActorId, CalendarId) of
{ok, List} ->
handler_utils:send_json(Req1, 200,
[logic_calendar_share:to_json(S) || S <- List]);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
revoke_share(Req) ->
case handler_utils:auth_user(Req) of
{ok, ActorId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
TargetUserId = cowboy_req:binding(user_id, Req1),
case logic_calendar_share:revoke(ActorId, CalendarId, TargetUserId) of
ok ->
handler_utils:send_json(Req1, 200, #{ok => true});
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Not found">>);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
update_rights(Req) ->
case handler_utils:auth_user(Req) of
{ok, ActorId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
TargetUserId = cowboy_req:binding(user_id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"rights">> := RightsBin} when is_binary(RightsBin) ->
case binary_to_existing_atom_safe(RightsBin) of
invalid ->
handler_utils:send_error(Req2, 400, <<"Bad request">>);
Rights ->
case logic_calendar_share:update_rights(ActorId, CalendarId, TargetUserId, Rights) of
{ok, Share} ->
handler_utils:send_json(Req2, 200, logic_calendar_share:to_json(Share));
{error, Reason} ->
map_share_error(Req2, Reason)
end
end;
_ ->
handler_utils:send_error(Req2, 400, <<"rights required">>)
catch
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
update_my_mirror(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(calendar_id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"mirror_to_default">> := Mirror} when is_boolean(Mirror) ->
case logic_calendar_share:update_my_mirror(UserId, CalendarId, Mirror) of
{ok, Share} ->
handler_utils:send_json(Req2, 200, logic_calendar_share:to_json(Share));
{error, Reason} ->
map_share_error(Req2, Reason)
end;
_ ->
handler_utils:send_error(Req2, 400, <<"mirror_to_default required">>)
catch
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
binary_to_existing_atom_safe(<<"read">>) -> read;
binary_to_existing_atom_safe(<<"write">>) -> write;
binary_to_existing_atom_safe(<<"admin">>) -> admin;
binary_to_existing_atom_safe(_) -> invalid.
map_share_error(Req, not_found) ->
handler_utils:send_error(Req, 404, <<"Not found">>);
map_share_error(Req, access_denied) ->
handler_utils:send_error(Req, 403, <<"Access denied">>);
map_share_error(Req, bad_request) ->
handler_utils:send_error(Req, 400, <<"Bad request">>);
map_share_error(Req, invalid) ->
handler_utils:send_error(Req, 400, <<"Bad request">>);
map_share_error(Req, _) ->
handler_utils:send_error(Req, 500, <<"Internal server error">>).
-164
View File
@@ -1,164 +0,0 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик календарного представления (HTML-календарь).
%%%
%%% GET – возвращает HTML-страницу с календарём на указанный месяц.
%%% Требует параметр `month` в формате YYYY-MM.
%%% Доступно только владельцу календаря.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_calendar_view).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
-define(ARCHIVE_CALL_TIMEOUT, 8000).
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) ->
handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/calendars/:calendar_id/view">>,
method => <<"GET">>,
description => <<"Get calendar HTML view for a specific month">>,
tags => [<<"Calendars">>],
parameters => [
#{
name => <<"calendar_id">>,
in => <<"path">>,
description => <<"Calendar ID">>,
required => true,
schema => #{type => string}
},
#{
name => <<"month">>,
in => <<"query">>,
description => <<"Month in YYYY-MM format">>,
required => true,
schema => #{type => string, pattern => <<"^\\d{4}-\\d{2}$">>}
}
],
responses => #{
200 => #{
description => <<"HTML calendar page">>,
content => #{<<"text/html">> => #{schema => #{type => string}}}
},
400 => #{description => <<"Missing or invalid 'month' parameter">>},
401 => #{description => <<"Unauthorized">>},
403 => #{description => <<"Access denied">>}
}
}
].
%%%===================================================================
%%% Внутренние функции
%%%===================================================================
%% @private Основной обработчик запроса.
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) ->
CalendarId = cowboy_req:binding(calendar_id, Req),
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case is_owner(UserId, CalendarId) of
true -> process_view(Req1, CalendarId);
false -> handler_utils:send_error(Req1, 403, <<"Access denied">>)
end;
{error, _Code, _Msg, Req1} ->
handler_utils:send_error(Req1, 401, <<"Unauthorized">>)
end.
%% @private Проверяет, является ли пользователь владельцем календаря.
-spec is_owner(binary(), binary()) -> boolean().
is_owner(UserId, CalendarId) ->
case mnesia:dirty_read({calendar, CalendarId}) of
[#calendar{owner_id = UserId}] -> true;
_ -> false
end.
%% @private Обрабатывает запрос на отображение календаря.
-spec process_view(cowboy_req:req(), binary()) -> {ok, cowboy_req:req(), any()}.
process_view(Req, CalendarId) ->
Qs = cowboy_req:parse_qs(Req),
case lists:keyfind(<<"month">>, 1, Qs) of
{<<"month">>, MonthBin} ->
case binary:split(MonthBin, <<"-">>) of
[YearStr, MonthStr] ->
Year = binary_to_integer(YearStr),
Month = binary_to_integer(MonthStr),
Events = fetch_events(CalendarId, Year, Month),
Html = calendar_html_renderer:render_month(Year, Month, Events),
Headers = #{
<<"content-type">> => <<"text/html">>,
<<"cache-control">> => <<"public, max-age=86400">>
},
cowboy_req:reply(200, Headers, Html, Req),
{ok, Req, undefined};
_ ->
handler_utils:send_error(Req, 400, <<"Invalid 'month' format. Use YYYY-MM">>)
end;
false ->
handler_utils:send_error(Req, 400, <<"Missing 'month' parameter">>)
end.
%% @private Извлекает события для указанного месяца календаря.
-spec fetch_events(binary(), integer(), integer()) -> list(#event{}).
fetch_events(CalendarId, Year, Month) ->
case is_hot(Year, Month) of
true -> fetch_hot_events(CalendarId, Year, Month);
false -> fetch_archive_events(CalendarId, Year, Month)
end.
%% @private Определяет, является ли месяц "горячим" (в пределах 30 дней от текущей даты).
-spec is_hot(integer(), integer()) -> boolean().
is_hot(Year, Month) ->
Current = calendar:local_time(),
Target = {{Year, Month, 1}, {0, 0, 0}},
calendar:datetime_to_gregorian_seconds(Current) -
calendar:datetime_to_gregorian_seconds(Target) < 30 * 86400.
%% @private Извлекает "горячие" события из Mnesia.
-spec fetch_hot_events(binary(), integer(), integer()) -> list(#event{}).
fetch_hot_events(CalendarId, Year, Month) ->
Start = {{Year, Month, 1}, {0, 0, 0}},
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}},
mnesia:dirty_select(event, [
{#event{calendar_id = CalendarId, start_time = '$1', _ = '_'},
[{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}],
['$_']}
]).
%% @private Извлекает архивные события через RPC на архивный узел.
%% При недоступности архива возвращает события из основной Mnesia.
-spec fetch_archive_events(binary(), integer(), integer()) -> list(#event{}).
fetch_archive_events(CalendarId, Year, Month) ->
DayStr = lists:flatten(io_lib:format("~4..0B~2..0B", [Year, Month])),
case safe_get_archive_node(DayStr) of
{ok, Node} ->
case rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month],
?ARCHIVE_CALL_TIMEOUT) of
Events when is_list(Events) -> Events;
_ -> fetch_hot_events(CalendarId, Year, Month)
end;
{error, _} ->
fetch_hot_events(CalendarId, Year, Month)
end.
-spec safe_get_archive_node(string()) -> {ok, node()} | {error, term()}.
safe_get_archive_node(Day) ->
try gen_server:call(archive_manager, {get_node, Day}, ?ARCHIVE_CALL_TIMEOUT) of
{ok, Node} when is_atom(Node) -> {ok, Node};
{error, Reason} -> {error, Reason};
Other -> {error, Other}
catch
exit:{timeout, _} -> {error, timeout};
exit:{noproc, _} -> {error, noproc}
end.
+16 -1
View File
@@ -168,7 +168,7 @@ list_calendars(Req) ->
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
case logic_calendar:list_calendars(UserId) of case logic_calendar:list_calendars(UserId) of
{ok, Calendars} -> {ok, Calendars} ->
Response = [calendar_to_json(C) || C <- Calendars], Response = [calendar_list_json(UserId, C) || C <- Calendars],
handler_utils:send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, _} -> {error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
@@ -181,6 +181,21 @@ list_calendars(Req) ->
%%% Внутренние функции %%% Внутренние функции
%%%=================================================================== %%%===================================================================
-spec calendar_list_json(binary(), #calendar{}) -> map().
calendar_list_json(UserId, #calendar{owner_id = UserId} = Calendar) ->
(calendar_to_json(Calendar))#{role => <<"owner">>};
calendar_list_json(UserId, Calendar) ->
Base = (calendar_to_json(Calendar))#{role => <<"shared">>},
case core_calendar_share:get(Calendar#calendar.id, UserId) of
{ok, Share} ->
Base#{
share_rights => Share#calendar_share.rights,
mirror_to_default => Share#calendar_share.mirror_to_default
};
{error, not_found} ->
Base
end.
-spec calendar_to_json(#calendar{}) -> map(). -spec calendar_to_json(#calendar{}) -> map().
calendar_to_json(Calendar) -> calendar_to_json(Calendar) ->
Base = handler_utils:calendar_to_json(Calendar), Base = handler_utils:calendar_to_json(Calendar),
+5 -8
View File
@@ -186,6 +186,8 @@ update_event(Req) ->
handler_utils:send_error(Req2, 404, <<"Event not found">>); handler_utils:send_error(Req2, 404, <<"Event not found">>);
{error, event_in_past} -> {error, event_in_past} ->
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>); handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, archived} ->
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
{error, {content_banned, _}} -> {error, {content_banned, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, invalid_specialist} -> {error, invalid_specialist} ->
@@ -215,6 +217,8 @@ delete_event(Req) ->
handler_utils:send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Event not found">>); handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, archived} ->
handler_utils:send_error(Req1, 409, <<"Month is archived">>);
{error, _} -> {error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
@@ -245,14 +249,7 @@ convert_field({<<"recurrence">>, Val}) ->
RuleJson = jsx:encode(Val), RuleJson = jsx:encode(Val),
{recurrence_rule, RuleJson}; {recurrence_rule, RuleJson};
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val}; convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
convert_field({<<"location">>, Val}) when is_map(Val) -> convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
Loc = #location{
address = maps:get(<<"address">>, Val, undefined),
lat = maps:get(<<"lat">>, Val, undefined),
lon = maps:get(<<"lon">>, Val, undefined)
},
{location, Loc};
convert_field({<<"location">>, Val}) -> {location, Val};
convert_field({<<"tags">>, Val}) -> {tags, Val}; convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val}; convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val}; convert_field({<<"online_link">>, Val}) -> {online_link, Val};
+128
View File
@@ -0,0 +1,128 @@
%%%-------------------------------------------------------------------
%%% @doc Waitlist for events (Back#72).
%%% POST/DELETE/GET /v1/events/:id/waitlist
%%% @end
%%%-------------------------------------------------------------------
-module(handler_event_waitlist).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> -> join(Req);
<<"DELETE">> -> leave(Req);
<<"GET">> -> get_status(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/events/:id/waitlist">>,
method => <<"POST">>,
description => <<"Join event waitlist (commercial, waitlist_enabled)">>,
tags => [<<"Waitlist">>],
responses => #{
200 => #{description => <<"Joined">>},
400 => #{description => <<"not_full / already_* / waitlist_disabled">>},
401 => #{description => <<"Unauthorized">>},
403 => #{description => <<"Forbidden">>},
404 => #{description => <<"Not found">>}
}
},
#{
path => <<"/v1/events/:id/waitlist">>,
method => <<"DELETE">>,
description => <<"Leave event waitlist">>,
tags => [<<"Waitlist">>],
responses => #{
200 => #{description => <<"Left">>},
401 => #{description => <<"Unauthorized">>},
404 => #{description => <<"Not on waitlist">>}
}
},
#{
path => <<"/v1/events/:id/waitlist">>,
method => <<"GET">>,
description => <<"Own waitlist status, or full list for owner/specialist">>,
tags => [<<"Waitlist">>],
responses => #{
200 => #{description => <<"Status">>},
401 => #{description => <<"Unauthorized">>},
404 => #{description => <<"Not found">>}
}
}
].
join(Req) ->
EventId = cowboy_req:binding(id, Req),
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case logic_waitlist:join(UserId, EventId) of
{ok, Body} ->
handler_utils:send_json(Req1, 200, Body);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, personal_calendar} ->
handler_utils:send_error(Req1, 403, <<"personal_calendar">>);
{error, subscription_inactive} ->
handler_utils:send_error(Req1, 403, <<"subscription_inactive">>);
{error, own_event} ->
handler_utils:send_error(Req1, 403, <<"own_event">>);
{error, waitlist_disabled} ->
handler_utils:send_error(Req1, 400, <<"waitlist_disabled">>);
{error, not_full} ->
handler_utils:send_error(Req1, 400, <<"not_full">>);
{error, already_booked} ->
handler_utils:send_error(Req1, 400, <<"already_booked">>);
{error, already_on_waitlist} ->
handler_utils:send_error(Req1, 400, <<"already_on_waitlist">>);
{error, event_not_active} ->
handler_utils:send_error(Req1, 400, <<"event_not_active">>);
{error, Reason} when is_atom(Reason) ->
handler_utils:send_error(Req1, 400, atom_to_binary(Reason, utf8));
{error, _} ->
handler_utils:send_error(Req1, 400, <<"Invalid request">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
leave(Req) ->
EventId = cowboy_req:binding(id, Req),
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case logic_waitlist:leave(UserId, EventId) of
ok ->
handler_utils:send_json(Req1, 200, #{ok => true});
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Not on waitlist">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
get_status(Req) ->
EventId = cowboy_req:binding(id, Req),
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case logic_waitlist:list_for_manager(UserId, EventId) of
{ok, Body} ->
handler_utils:send_json(Req1, 200, Body);
{error, access_denied} ->
case logic_waitlist:status_for_user(UserId, EventId) of
{ok, Body} ->
handler_utils:send_json(Req1, 200, Body);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} ->
handler_utils:send_error(Req1, 403, <<"Forbidden">>)
end;
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Event not found">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
+41 -19
View File
@@ -184,6 +184,8 @@ create_event(Req) ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>); handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, event_in_past} -> {error, event_in_past} ->
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>); handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, archived} ->
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
{error, {content_banned, _}} -> {error, {content_banned, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} -> {error, _} ->
@@ -201,6 +203,8 @@ create_event(Req) ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>); handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, event_in_past} -> {error, event_in_past} ->
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>); handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, archived} ->
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
{error, {content_banned, _}} -> {error, {content_banned, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} -> {error, _} ->
@@ -239,7 +243,10 @@ list_events(Req) ->
{FromStr, ToStr} -> {FromStr, ToStr} ->
FromDt = parse_datetime_binary(FromStr), FromDt = parse_datetime_binary(FromStr),
ToDt = parse_datetime_binary(ToStr), ToDt = parse_datetime_binary(ToStr),
expand_recurring_events(UserId, Events, FromDt, ToDt) HotJson = expand_recurring_events(UserId, Events, FromDt, ToDt),
SnapJson = logic_month_archive:events_json_in_range(
CalendarId, FromDt, ToDt),
merge_event_json(HotJson, SnapJson)
end, end,
handler_utils:send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, access_denied} -> {error, access_denied} ->
@@ -295,37 +302,42 @@ update_event_fields(UserId, EventId, Location, Decoded) ->
end. end.
parse_location(undefined) -> undefined; parse_location(undefined) -> undefined;
parse_location(LocationMap) when is_map(LocationMap) -> parse_location(LocationMap) -> handler_utils:parse_location(LocationMap).
case LocationMap of
#{<<"lat">> := Lat, <<"lon">> := Lon} ->
Address = maps:get(<<"address">>, LocationMap, <<"">>),
#location{address = Address, lat = Lat, lon = Lon};
_ -> undefined
end;
parse_location(_) -> undefined.
expand_recurring_events(UserId, Events, From, To) -> expand_recurring_events(UserId, Events, From, To) ->
lists:flatmap(fun(Event) -> lists:flatmap(fun(Event) ->
case Event#event.event_type of case Event#event.event_type of
single -> single ->
case is_in_range(Event#event.start_time, From, To) of case logic_month_archive:month_is_closed(Event#event.start_time) of
true -> [handler_utils:event_to_json(Event)]; true -> [];
false -> [] false ->
case is_in_range(Event#event.start_time, From, To) of
true -> [handler_utils:event_to_json(Event)];
false -> []
end
end; end;
recurring -> recurring ->
case logic_event:get_occurrences(UserId, Event#event.id, To) of case logic_event:get_occurrences(UserId, Event#event.id, To) of
{ok, Occurrences} -> {ok, Occurrences} ->
lists:filtermap( lists:filtermap(
fun({virtual, Occ}) -> fun({virtual, Occ}) ->
case is_in_range(Occ, From, To) of case logic_month_archive:month_is_closed(Occ) of
true -> {true, occurrence_to_json(Event, Occ)}; true -> false;
false -> false false ->
case is_in_range(Occ, From, To) of
true -> {true, occurrence_to_json(Event, Occ)};
false -> false
end
end; end;
({materialized, Instance}) -> ({materialized, Instance}) ->
case is_in_range(Instance#event.start_time, From, To) of case logic_month_archive:month_is_closed(Instance#event.start_time) of
true -> {true, handler_utils:event_to_json(Instance)}; true -> false;
false -> false false ->
end case is_in_range(Instance#event.start_time, From, To) of
true -> {true, handler_utils:event_to_json(Instance)};
false -> false
end
end
end, Occurrences); end, Occurrences);
_ -> [] _ -> []
end end
@@ -334,6 +346,16 @@ expand_recurring_events(UserId, Events, From, To) ->
is_in_range(Time, From, To) -> Time >= From andalso Time =< To. is_in_range(Time, From, To) -> Time >= From andalso Time =< To.
merge_event_json(Hot, Snap) ->
Ids = maps:from_list(
[{maps:get(<<"id">>, M, maps:get(id, M, undefined)), true} || M <- Hot]),
Extra = [M || M <- Snap,
begin
Id = maps:get(<<"id">>, M, maps:get(id, M, undefined)),
Id =:= undefined orelse not maps:is_key(Id, Ids)
end],
Hot ++ Extra.
parse_datetime_binary(Str) -> parse_datetime_binary(Str) ->
{ok, Dt} = handler_utils:parse_datetime(Str), {ok, Dt} = handler_utils:parse_datetime(Str),
Dt. Dt.
+162
View File
@@ -0,0 +1,162 @@
%%%-------------------------------------------------------------------
%%% @doc GET /v1/geo/suggest, POST /v1/geo/geocode, POST /v1/geo/reverse.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_geo).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
init(Req, Opts) ->
handle(Req, Opts).
trails() ->
[
#{
path => <<"/v1/geo/suggest">>,
method => <<"GET">>,
description => <<"Address typeahead (OpenCage). Public + IP rate-limit.">>,
tags => [<<"Geo">>],
parameters => [
#{name => <<"q">>, in => <<"query">>, required => true, schema => #{type => string}},
#{name => <<"lang">>, in => <<"query">>, schema => #{type => string}}
],
responses => #{
200 => #{description => <<"OK">>},
400 => #{description => <<"invalid_query">>},
429 => #{description => <<"rate limited">>},
503 => #{description => <<"geo_unavailable">>}
}
},
#{
path => <<"/v1/geo/geocode">>,
method => <<"POST">>,
description => <<"Forward geocode (OpenCage). Bearer required.">>,
tags => [<<"Geo">>],
responses => #{
200 => #{description => <<"OK">>},
401 => #{description => <<"Unauthorized">>},
404 => #{description => <<"not_found">>},
503 => #{description => <<"geo_unavailable">>}
}
},
#{
path => <<"/v1/geo/reverse">>,
method => <<"POST">>,
description => <<"Reverse geocode (OpenCage). Bearer required.">>,
tags => [<<"Geo">>],
responses => #{
200 => #{description => <<"OK">>},
401 => #{description => <<"Unauthorized">>},
503 => #{description => <<"geo_unavailable">>}
}
}
].
handle(Req, _Opts) ->
Path = cowboy_req:path(Req),
Method = cowboy_req:method(Req),
case {Path, Method} of
{<<"/v1/geo/suggest">>, <<"GET">>} -> suggest(Req);
{<<"/v1/geo/geocode">>, <<"POST">>} -> geocode(Req);
{<<"/v1/geo/reverse">>, <<"POST">>} -> reverse(Req);
{_, <<"GET">>} when Path =:= <<"/v1/geo/geocode">> orelse Path =:= <<"/v1/geo/reverse">> ->
handler_utils:send_error(Req, 405, <<"Method not allowed">>);
{<<"/v1/geo/suggest">>, _} ->
handler_utils:send_error(Req, 405, <<"Method not allowed">>);
_ ->
handler_utils:send_error(Req, 404, <<"Not found">>)
end.
suggest(Req) ->
Qs = cowboy_req:parse_qs(Req),
Q = proplists:get_value(<<"q">>, Qs, <<>>),
Lang = proplists:get_value(<<"lang">>, Qs, <<>>),
Key = <<"s:", (client_key(Req))/binary>>,
reply_suggest(Req, logic_geo:suggest(Q, Lang, Key)).
geocode(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
case decode_map(Body) of
{ok, Map} ->
Q = maps:get(<<"q">>, Map, <<>>),
Lang = maps:get(<<"lang">>, Map, <<>>),
Key = <<"g:", UserId/binary>>,
reply_hit(Req2, logic_geo:geocode(Q, Lang, Key), true);
error ->
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
reverse(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
case decode_map(Body) of
{ok, Map} ->
Lat = maps:get(<<"lat">>, Map, undefined),
Lon = maps:get(<<"lon">>, Map, undefined),
Lang = maps:get(<<"lang">>, Map, <<>>),
Key = <<"r:", UserId/binary>>,
reply_hit(Req2, logic_geo:reverse(Lat, Lon, Lang, Key), false);
error ->
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
reply_suggest(Req, {ok, Hits}) ->
handler_utils:send_json(Req, 200, #{<<"results">> => Hits});
reply_suggest(Req, {error, invalid_query}) ->
handler_utils:send_error(Req, 400, <<"invalid_query">>);
reply_suggest(Req, {error, rate_limited}) ->
handler_utils:send_error(Req, 429, <<"rate_limited">>);
reply_suggest(Req, {error, unavailable}) ->
handler_utils:send_error(Req, 503, <<"geo_unavailable">>).
reply_hit(Req, {ok, Hit}, _NotFound404) ->
handler_utils:send_json(Req, 200, Hit);
reply_hit(Req, {error, not_found}, true) ->
handler_utils:send_error(Req, 404, <<"not_found">>);
reply_hit(Req, {error, invalid_query}, _) ->
handler_utils:send_error(Req, 400, <<"invalid_query">>);
reply_hit(Req, {error, rate_limited}, _) ->
handler_utils:send_error(Req, 429, <<"rate_limited">>);
reply_hit(Req, {error, unavailable}, _) ->
handler_utils:send_error(Req, 503, <<"geo_unavailable">>).
decode_map(Body) when is_binary(Body) ->
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) -> {ok, Map};
_ -> error
catch
_:_ -> error
end.
client_key(Req) ->
case cowboy_req:header(<<"x-real-ip">>, Req) of
undefined ->
case cowboy_req:header(<<"x-forwarded-for">>, Req) of
undefined -> peer_ip(Req);
<<>> -> peer_ip(Req);
Fwd ->
case binary:split(Fwd, <<",">>) of
[First | _] -> string:trim(First);
_ -> peer_ip(Req)
end
end;
<<>> -> peer_ip(Req);
IP -> IP
end.
peer_ip(Req) ->
case cowboy_req:peer(Req) of
{{A, B, C, D}, _} ->
iolist_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
_ -> <<"unknown">>
end.
+30 -16
View File
@@ -32,8 +32,10 @@ trails() ->
type => object, type => object,
required => [<<"email">>, <<"password">>], required => [<<"email">>, <<"password">>],
properties => #{ properties => #{
email => #{type => string, format => <<"email">>}, email => #{type => string, format => <<"email">>},
password => #{type => string, format => <<"password">>} password => #{type => string, format => <<"password">>},
client_type => #{type => string, enum => [<<"web">>, <<"mobile">>]},
device_name => #{type => string}
} }
}}} }}}
}, },
@@ -69,23 +71,35 @@ login(Req) ->
handler_utils:send_error(Req1, 400, <<"Empty request body">>); handler_utils:send_error(Req1, 400, <<"Empty request body">>);
_ -> _ ->
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password} -> #{<<"email">> := Email, <<"password">> := Password} = Map ->
ClientType = maps:get(<<"client_type">>, Map, <<"web">>),
DeviceName = maps:get(<<"device_name">>, Map, <<>>),
UserAgent = case cowboy_req:header(<<"user-agent">>, Req1) of
undefined -> <<>>;
UA -> UA
end,
case eventhub_auth:authenticate_user_request(Req1, Email, Password) of case eventhub_auth:authenticate_user_request(Req1, Email, Password) of
{ok, _Token, User} -> {ok, _Token, User} ->
UserId = maps:get(id, User), UserId = maps:get(id, User),
Role = maps:get(role, User), Role = maps:get(role, User),
{ok, Token, RefreshToken} = logic_auth_session:issue_user_tokens(UserId, Role), case logic_auth_session:issue_user_tokens(
core_user:update_last_login(UserId), UserId, Role, ClientType, DeviceName, UserAgent) of
Response = #{ {ok, Token, RefreshToken, SessionId} ->
<<"token">> => Token, core_user:update_last_login(UserId),
<<"user">> => #{ Response = #{
<<"id">> => UserId, <<"token">> => Token,
<<"email">> => maps:get(email, User), <<"user">> => #{
<<"role">> => maps:get(role, User) <<"id">> => UserId,
}, <<"email">> => maps:get(email, User),
<<"refresh_token">> => RefreshToken <<"role">> => maps:get(role, User)
}, },
handler_utils:send_json(Req1, 200, Response); <<"refresh_token">> => RefreshToken,
<<"session_id">> => SessionId
},
handler_utils:send_json(Req1, 200, Response);
{error, invalid_client_type} ->
handler_utils:send_error(Req1, 400, <<"Invalid client_type">>)
end;
{error, not_verified} -> {error, not_verified} ->
handler_utils:send_error(Req1, 403, <<"Account not verified">>); handler_utils:send_error(Req1, 403, <<"Account not verified">>);
{error, frozen} -> {error, frozen} ->
@@ -103,4 +117,4 @@ login(Req) ->
end; end;
false -> false ->
handler_utils:send_error(Req, 400, <<"Missing request body">>) handler_utils:send_error(Req, 400, <<"Missing request body">>)
end. end.
+58
View File
@@ -0,0 +1,58 @@
%%%-------------------------------------------------------------------
%%% @doc POST /v1/logout отзыв user refresh-сессии (вариант A / Back#69).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_logout).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> -> logout(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/logout">>,
method => <<"POST">>,
description => <<"Revoke current user refresh session">>,
tags => [<<"Auth">>],
requestBody => #{
required => true,
content => #{
<<"application/json">> => #{
schema => #{
type => object,
required => [<<"refresh_token">>],
properties => #{refresh_token => #{type => string}}
}
}
}
},
responses => #{
200 => #{description => <<"Session revoked">>},
400 => #{description => <<"Missing refresh_token or invalid JSON">>},
401 => #{description => <<"Invalid refresh token">>}
}
}
].
logout(Req) ->
{ok, Body, Req1} = cowboy_req:read_body(Req),
try jsx:decode(Body, [return_maps]) of
#{<<"refresh_token">> := RefreshToken} ->
case logic_auth_session:logout_user(RefreshToken) of
ok ->
handler_utils:send_json(Req1, 200, #{ok => true});
{error, _} ->
handler_utils:send_error(Req1, 401, <<"Invalid refresh token">>)
end;
_ ->
handler_utils:send_error(Req1, 400, <<"Missing refresh_token field">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
end.
+69
View File
@@ -0,0 +1,69 @@
%%%-------------------------------------------------------------------
%%% @doc GET /v1/media/:kind/:owner/:file public static upload serve.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_media).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> get_media(Req);
<<"HEAD">> -> get_media(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/media/:kind/:owner/:file">>,
method => <<"GET">>,
description => <<"Serve uploaded avatar or cover image">>,
tags => [<<"Media">>],
responses => #{
200 => #{description => <<"Image bytes">>},
404 => #{description => <<"Not found">>}
}
}
].
get_media(Req) ->
KindB = cowboy_req:binding(kind, Req),
Owner = cowboy_req:binding(owner, Req),
File = cowboy_req:binding(file, Req),
Kind = case KindB of
<<"avatar">> -> avatar;
<<"cover">> -> cover;
_ -> invalid
end,
case Kind of
invalid ->
handler_utils:send_error(Req, 404, <<"Not found">>);
_ ->
case logic_upload:media_abs_path(Kind, Owner, File) of
{ok, Path} ->
Mime = mime_for(File),
case file:read_file(Path) of
{ok, Bin} ->
Headers = #{
<<"content-type">> => Mime,
<<"cache-control">> => <<"public, max-age=86400">>
},
Req1 = cowboy_req:reply(200, Headers, Bin, Req),
{ok, Bin, Req1};
{error, _} ->
handler_utils:send_error(Req, 404, <<"Not found">>)
end;
{error, _} ->
handler_utils:send_error(Req, 404, <<"Not found">>)
end
end.
mime_for(File) when is_binary(File) ->
case filename:extension(binary_to_list(File)) of
".png" -> <<"image/png">>;
".webp" -> <<"image/webp">>;
_ -> <<"image/jpeg">>
end.
@@ -0,0 +1,79 @@
%%%-------------------------------------------------------------------
%%% @doc GET/PUT /v1/notifications/prefs (Back#75).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_notification_prefs).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
init(Req, Opts) ->
Method = cowboy_req:method(Req),
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case Method of
<<"GET">> ->
case logic_notification_prefs:get_prefs(UserId) of
{ok, Prefs} ->
handler_utils:send_json(Req1, 200, Prefs);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"User not found">>)
end;
<<"PUT">> ->
put_prefs(Req1, UserId);
_ ->
cowboy_req:reply(405, #{}, <<>>, Req1)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end,
{ok, Req, Opts}.
trails() ->
[
#{
path => <<"/v1/notifications/prefs">>,
method => <<"GET">>,
description => <<"Get notification channel prefs (email/push)">>,
tags => [<<"Notifications">>],
responses => #{
200 => #{description => <<"Prefs">>},
401 => #{description => <<"Unauthorized">>}
}
},
#{
path => <<"/v1/notifications/prefs">>,
method => <<"PUT">>,
description => <<"Update notification channel prefs">>,
tags => [<<"Notifications">>],
responses => #{
200 => #{description => <<"Updated prefs">>},
400 => #{description => <<"Invalid body">>},
401 => #{description => <<"Unauthorized">>}
}
}
].
put_prefs(Req0, UserId) ->
{ok, Body, Req1} = cowboy_req:read_body(Req0),
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) ->
case {maps:get(<<"email">>, Map, undefined), maps:get(<<"push">>, Map, undefined)} of
{Email, Push} when is_boolean(Email), is_boolean(Push) ->
case logic_notification_prefs:put_prefs(UserId, #{email => Email, push => Push}) of
{ok, Prefs} ->
handler_utils:send_json(Req1, 200, Prefs);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"User not found">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Update failed">>)
end;
_ ->
handler_utils:send_error(Req1, 400, <<"email and push booleans required">>)
end;
_ ->
handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
catch
_:_ ->
handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
end.
+112
View File
@@ -0,0 +1,112 @@
%%%-------------------------------------------------------------------
%%% @doc Web Push VAPID key + subscriptions (Back#75).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_push).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-include("records.hrl").
init(Req, Opts) ->
Path = cowboy_req:path(Req),
Method = cowboy_req:method(Req),
case Path of
<<"/v1/push/vapid-public-key">> ->
case logic_web_push:vapid_public_key() of
{ok, Key} ->
handler_utils:send_json(Req, 200, #{publicKey => Key});
{error, missing} ->
handler_utils:send_error(Req, 503, <<"VAPID not configured">>)
end;
<<"/v1/push/subscriptions">> ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case Method of
<<"POST">> -> post_sub(Req1, UserId);
<<"DELETE">> -> delete_sub(Req1, UserId);
_ -> cowboy_req:reply(405, #{}, <<>>, Req1)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end;
_ ->
cowboy_req:reply(404, #{}, <<>>, Req)
end,
{ok, Req, Opts}.
trails() ->
[
#{
path => <<"/v1/push/vapid-public-key">>,
method => <<"GET">>,
description => <<"VAPID public key for Web Push subscribe">>,
tags => [<<"Notifications">>],
responses => #{200 => #{description => <<"OK">>}, 503 => #{description => <<"Not configured">>}}
},
#{
path => <<"/v1/push/subscriptions">>,
method => <<"POST">>,
description => <<"Register Web Push subscription">>,
tags => [<<"Notifications">>],
responses => #{200 => #{description => <<"OK">>}, 401 => #{description => <<"Unauthorized">>}}
},
#{
path => <<"/v1/push/subscriptions">>,
method => <<"DELETE">>,
description => <<"Remove Web Push subscription">>,
tags => [<<"Notifications">>],
responses => #{204 => #{description => <<"Deleted">>}, 401 => #{description => <<"Unauthorized">>}}
}
].
post_sub(Req0, UserId) ->
{ok, Body, Req1} = cowboy_req:read_body(Req0),
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) ->
Endpoint = maps:get(<<"endpoint">>, Map, undefined),
Keys = maps:get(<<"keys">>, Map, #{}),
P256 = maps:get(<<"p256dh">>, Keys, undefined),
Auth = maps:get(<<"auth">>, Keys, undefined),
case {Endpoint, P256, Auth} of
{E, P, A} when is_binary(E), E =/= <<>>,
is_binary(P), P =/= <<>>,
is_binary(A), A =/= <<>> ->
case core_push_subscription:upsert(UserId, E, P, A) of
{ok, Rec} ->
handler_utils:send_json(Req1, 200, #{
id => Rec#push_subscription.id,
endpoint => Rec#push_subscription.endpoint
});
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Save failed">>)
end;
_ ->
handler_utils:send_error(Req1, 400, <<"endpoint and keys.p256dh/auth required">>)
end;
_ ->
handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
catch
_:_ ->
handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
end.
delete_sub(Req0, UserId) ->
{ok, Body, Req1} = cowboy_req:read_body(Req0),
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) ->
Endpoint = maps:get(<<"endpoint">>, Map, <<>>),
case Endpoint of
<<>> ->
handler_utils:send_error(Req1, 400, <<"endpoint required">>);
Ep ->
_ = core_push_subscription:delete_by_endpoint(UserId, Ep),
cowboy_req:reply(204, #{}, <<>>, Req1)
end;
_ ->
handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
catch
_:_ ->
handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
end.
+5 -3
View File
@@ -57,7 +57,8 @@ token_pair_schema() ->
type => object, type => object,
properties => #{ properties => #{
token => #{type => string, description => <<"JWT access token">>}, token => #{type => string, description => <<"JWT access token">>},
refresh_token => #{type => string, description => <<"Refresh token">>} refresh_token => #{type => string, description => <<"Refresh token">>},
session_id => #{type => string, description => <<"auth_session id">>}
} }
}. }.
@@ -72,10 +73,11 @@ refresh(Req) ->
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"refresh_token">> := RefreshToken} -> #{<<"refresh_token">> := RefreshToken} ->
case logic_auth_session:refresh_user(RefreshToken) of case logic_auth_session:refresh_user(RefreshToken) of
{ok, NewToken, NewRefreshToken} -> {ok, NewToken, NewRefreshToken, SessionId} ->
handler_utils:send_json(Req1, 200, #{ handler_utils:send_json(Req1, 200, #{
token => NewToken, token => NewToken,
refresh_token => NewRefreshToken refresh_token => NewRefreshToken,
session_id => SessionId
}); });
{error, reuse_detected} -> {error, reuse_detected} ->
handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>); handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>);
+59 -21
View File
@@ -32,7 +32,7 @@ trails() ->
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>}, #{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>}, #{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>},
#{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>}, #{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>},
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>]}, description => <<"Field to sort by">>}, #{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>, <<"distance">>]}, description => <<"Field to sort by">>},
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>}, #{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>},
#{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>}, #{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>},
#{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>}, #{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>},
@@ -79,18 +79,22 @@ search(Req) ->
Qs = cowboy_req:parse_qs(Req1), Qs = cowboy_req:parse_qs(Req1),
Type = proplists:get_value(<<"type">>, Qs, undefined), Type = proplists:get_value(<<"type">>, Qs, undefined),
Query = proplists:get_value(<<"q">>, Qs, undefined), Query = proplists:get_value(<<"q">>, Qs, undefined),
Params = parse_params(Qs), case parse_params(Qs) of
case logic_search:search(Type, Query, UserId, Params) of {error, invalid_radius} ->
{ok, Total, Results} -> handler_utils:send_error(Req1, 400, <<"invalid_radius">>);
Response = #{ Params ->
total => Total, case logic_search:search(Type, Query, UserId, Params) of
limit => maps:get(limit, Params, 20), {ok, Total, Results} ->
offset => maps:get(offset, Params, 0), Response = #{
results => Results total => Total,
}, limit => maps:get(limit, Params, 20),
handler_utils:send_json(Req1, 200, Response); offset => maps:get(offset, Params, 0),
{error, _} -> results => Results
handler_utils:send_error(Req1, 500, <<"Search failed">>) },
handler_utils:send_json(Req1, 200, Response);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Search failed">>)
end
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
@@ -103,7 +107,8 @@ search(Req) ->
%% @private Собирает карту параметров для поискового движка. %% @private Собирает карту параметров для поискового движка.
%% Не кладёт sort/tags/geo/даты, если их нет в QS иначе %% Не кладёт sort/tags/geo/даты, если их нет в QS иначе
%% logic_search:is_discovery_request/2 никогда не сработает (Back#50). %% logic_search:is_discovery_request/2 никогда не сработает (Back#50).
-spec parse_params(cowboy_req:qs()) -> map(). %% radius без ключа при lat/lon без отсечения (GEO.md).
-spec parse_params(cowboy_req:qs()) -> map() | {error, invalid_radius}.
parse_params(Qs) -> parse_params(Qs) ->
Params0 = #{ Params0 = #{
limit => parse_int_param(Qs, <<"limit">>, 20), limit => parse_int_param(Qs, <<"limit">>, 20),
@@ -125,12 +130,22 @@ parse_params(Qs) ->
end, end,
Params1#{sort => Sort, order => Order} Params1#{sort => Sort, order => Order}
end, end,
Params3 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
{{ok, Lat}, {ok, Lon}} -> {{ok, Lat}, {ok, Lon}} ->
Radius = parse_int_param(Qs, <<"radius">>, 10), case parse_optional_radius(Qs) of
Params2#{lat => Lat, lon => Lon, radius => Radius}; {error, invalid_radius} = E -> E;
_ -> Params2 Radius ->
end, Params3 = case Radius of
undefined -> Params2#{lat => Lat, lon => Lon};
R -> Params2#{lat => Lat, lon => Lon, radius => R}
end,
with_dates(Qs, Params3)
end;
_ ->
with_dates(Qs, Params2)
end.
with_dates(Qs, Params3) ->
case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To}; {{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
{{ok, From}, error} -> Params3#{from => From}; {{ok, From}, error} -> Params3#{from => From};
@@ -138,6 +153,18 @@ parse_params(Qs) ->
_ -> Params3 _ -> Params3
end. end.
parse_optional_radius(Qs) ->
case proplists:get_value(<<"radius">>, Qs) of
undefined -> undefined;
<<>> -> undefined;
Val ->
N = handler_utils:parse_int_qs(Val, 0),
case N >= 1 andalso N =< 100 of
true -> N;
false -> {error, invalid_radius}
end
end.
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer(). -spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
parse_int_param(Qs, Key, Default) -> parse_int_param(Qs, Key, Default) ->
handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default). handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default).
@@ -146,7 +173,18 @@ parse_int_param(Qs, Key, Default) ->
parse_float_param(Qs, Key) -> parse_float_param(Qs, Key) ->
case proplists:get_value(Key, Qs) of case proplists:get_value(Key, Qs) of
undefined -> error; undefined -> error;
Val -> {ok, binary_to_float(Val)} <<>> -> error;
Val ->
try binary_to_float(Val) of
F -> {ok, F}
catch
_:_ ->
try binary_to_integer(Val) of
I -> {ok, float(I)}
catch
_:_ -> error
end
end
end. end.
-spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error. -spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error.
+131
View File
@@ -0,0 +1,131 @@
%%%-------------------------------------------------------------------
%%% @doc User session list / revoke (Back#74).
%%% GET /v1/sessions
%%% DELETE /v1/sessions/:id
%%% POST /v1/sessions/revoke-others
%%% @end
%%%-------------------------------------------------------------------
-module(handler_sessions).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) ->
Method = cowboy_req:method(Req),
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case Method of
<<"GET">> ->
list_sessions(Req1, UserId);
<<"POST">> ->
case cowboy_req:path(Req1) of
<<"/v1/sessions/revoke-others">> ->
revoke_others(Req1, UserId);
_ ->
handler_utils:send_error(Req1, 405, <<"Method not allowed">>)
end;
<<"DELETE">> ->
SessionId = cowboy_req:binding(id, Req1),
revoke_one(Req1, UserId, SessionId);
_ ->
handler_utils:send_error(Req1, 405, <<"Method not allowed">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end,
{ok, Req, Opts}.
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/sessions">>,
method => <<"GET">>,
description => <<"List active user sessions">>,
tags => [<<"Auth">>],
responses => #{
200 => #{description => <<"Session list">>},
401 => #{description => <<"Unauthorized">>}
}
},
#{
path => <<"/v1/sessions/:id">>,
method => <<"DELETE">>,
description => <<"Revoke one own session">>,
tags => [<<"Auth">>],
responses => #{
200 => #{description => <<"Revoked">>},
401 => #{description => <<"Unauthorized">>},
404 => #{description => <<"Not found">>}
}
},
#{
path => <<"/v1/sessions/revoke-others">>,
method => <<"POST">>,
description => <<"Revoke all sessions except current (from refresh_token)">>,
tags => [<<"Auth">>],
requestBody => #{
required => true,
content => #{
<<"application/json">> => #{
schema => #{
type => object,
required => [<<"refresh_token">>],
properties => #{refresh_token => #{type => string}}
}
}
}
},
responses => #{
200 => #{description => <<"Others revoked">>},
400 => #{description => <<"Bad request">>},
401 => #{description => <<"Unauthorized">>}
}
}
].
list_sessions(Req, UserId) ->
Items = [session_to_json(S) || S <- logic_auth_session:list_user_sessions(UserId)],
handler_utils:send_json(Req, 200, #{<<"sessions">> => Items}).
revoke_one(Req, UserId, SessionId) when is_binary(SessionId), SessionId =/= <<>> ->
case logic_auth_session:revoke_user_session(UserId, SessionId) of
ok ->
handler_utils:send_json(Req, 200, #{<<"ok">> => true});
{error, not_found} ->
handler_utils:send_error(Req, 404, <<"Session not found">>)
end;
revoke_one(Req, _UserId, _) ->
handler_utils:send_error(Req, 404, <<"Session not found">>).
revoke_others(Req0, UserId) ->
{ok, Body, Req} = cowboy_req:read_body(Req0),
try jsx:decode(Body, [return_maps]) of
#{<<"refresh_token">> := RefreshToken} ->
case logic_auth_session:revoke_other_user_sessions(UserId, RefreshToken) of
{ok, Count} ->
handler_utils:send_json(Req, 200, #{<<"ok">> => true, <<"revoked">> => Count});
{error, forbidden} ->
handler_utils:send_error(Req, 403, <<"Refresh token subject mismatch">>);
{error, _} ->
handler_utils:send_error(Req, 401, <<"Invalid refresh token">>)
end;
_ ->
handler_utils:send_error(Req, 400, <<"Missing refresh_token field">>)
catch
_:_ -> handler_utils:send_error(Req, 400, <<"Invalid JSON">>)
end.
session_to_json(#auth_session{} = S) ->
#{
<<"session_id">> => S#auth_session.session_id,
<<"client_type">> => S#auth_session.client_type,
<<"device_name">> => S#auth_session.device_name,
<<"user_agent">> => S#auth_session.user_agent,
<<"created_at">> => handler_utils:datetime_to_iso8601(S#auth_session.created_at),
<<"updated_at">> => handler_utils:datetime_to_iso8601(S#auth_session.updated_at),
<<"expires_at">> => handler_utils:datetime_to_iso8601(S#auth_session.expires_at)
}.
+156
View File
@@ -0,0 +1,156 @@
%%%-------------------------------------------------------------------
%%% @doc Invitee: входящие share invites + accept/decline.
%%%
%%% GET /v1/user/share-invites
%%% POST /v1/share-invites/:id/accept | decline
%%% POST /v1/share-invites/accept body: {token, mirror_to_default?}
%%% @end
%%%-------------------------------------------------------------------
-module(handler_share_invites).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-include("records.hrl").
init(Req, Opts) ->
handle(Req, Opts).
trails() ->
IdParam = #{name => <<"id">>, in => <<"path">>, required => true,
schema => #{type => string}},
[
#{path => <<"/v1/user/share-invites">>, method => <<"GET">>,
description => <<"Incoming share invites">>, tags => [<<"Users">>],
responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/share-invites/accept">>, method => <<"POST">>,
description => <<"Accept share invite by token">>, tags => [<<"Users">>],
responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/share-invites/:id/accept">>, method => <<"POST">>,
description => <<"Accept share invite">>, tags => [<<"Users">>],
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
#{path => <<"/v1/share-invites/:id/decline">>, method => <<"POST">>,
description => <<"Decline share invite">>, tags => [<<"Users">>],
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}}
].
handle(Req, _Opts) ->
Method = cowboy_req:method(Req),
Path = cowboy_req:path(Req),
case Method of
<<"GET">> ->
list_incoming(Req);
<<"POST">> ->
case Path of
<<"/v1/share-invites/accept">> ->
accept_token(Req);
_ ->
case {cowboy_req:binding(id, Req), path_action(Path)} of
{Id, accept} when is_binary(Id) -> accept_id(Req, Id);
{Id, decline} when is_binary(Id) -> decline_id(Req, Id);
_ -> handler_utils:send_error(Req, 404, <<"Not found">>)
end
end;
_ ->
handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
path_action(Path) ->
case binary:match(Path, <<"/accept">>) of
nomatch ->
case binary:match(Path, <<"/decline">>) of
nomatch -> unknown;
_ -> decline
end;
_ -> accept
end.
list_incoming(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
{ok, List} = logic_calendar_share_invite:list_incoming(UserId),
handler_utils:send_json(Req1, 200,
[logic_calendar_share_invite:to_json(I) || I <- List]);
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
accept_id(Req, InviteId) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
Opts = parse_accept_opts(Body),
reply_accept(Req2, logic_calendar_share_invite:accept(UserId, InviteId, Opts));
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
decline_id(Req, InviteId) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case logic_calendar_share_invite:decline(UserId, InviteId) of
{ok, Inv} ->
handler_utils:send_json(Req1, 200, logic_calendar_share_invite:to_json(Inv));
{error, Reason} ->
map_decide_error(Req1, Reason)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
accept_token(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"token">> := Token} = Map when is_binary(Token), Token =/= <<>> ->
Opts = accept_opts_from_map(Map),
reply_accept(Req2,
logic_calendar_share_invite:accept_by_token(UserId, Token, Opts));
_ ->
handler_utils:send_error(Req2, 400, <<"token required">>)
catch
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
parse_accept_opts(<<>>) -> #{};
parse_accept_opts(Body) ->
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) -> accept_opts_from_map(Map);
_ -> #{}
catch
_:_ -> #{}
end.
accept_opts_from_map(Map) ->
case maps:get(<<"mirror_to_default">>, Map, undefined) of
true -> #{mirror_to_default => true};
false -> #{mirror_to_default => false};
_ -> #{}
end.
reply_accept(Req, {ok, Inv, Share}) ->
handler_utils:send_json(Req, 200, #{
invite => logic_calendar_share_invite:to_json(Inv),
share => logic_calendar_share:to_json(Share)
});
reply_accept(Req, {error, Reason}) ->
map_decide_error(Req, Reason).
map_decide_error(Req, not_found) ->
handler_utils:send_error(Req, 404, <<"Not found">>);
map_decide_error(Req, access_denied) ->
handler_utils:send_error(Req, 403, <<"Access denied">>);
map_decide_error(Req, not_pending) ->
handler_utils:send_error(Req, 409, <<"Invite is not pending">>);
map_decide_error(Req, expired) ->
handler_utils:send_error(Req, 410, <<"Invite expired">>);
map_decide_error(Req, {no_exists, Table}) ->
handler_utils:report_and_send_error(Req, 503,
<<"Schema not ready">>, #{reason => no_exists, table => Table});
map_decide_error(Req, Reason) ->
handler_utils:report_and_send_error(Req, 500,
<<"Internal server error">>, #{reason => Reason}).
+103
View File
@@ -0,0 +1,103 @@
%%%-------------------------------------------------------------------
%%% @doc Multipart helpers for file uploads (Back#71, Front#76).
%%% Field name: file
%%% @end
%%%-------------------------------------------------------------------
-module(handler_upload_utils).
-export([read_image_part/1, read_file_part/2]).
-spec read_image_part(cowboy_req:req()) ->
{ok, binary(), cowboy_req:req()} |
{error, missing_file | too_large | unsupported_media | bad_multipart, cowboy_req:req()}.
read_image_part(Req) ->
Max = logic_upload:max_bytes(),
try read_parts(Req, Max)
catch
_:_ -> {error, bad_multipart, Req}
end.
read_parts(Req, Max) ->
case cowboy_req:read_part(Req) of
{ok, Headers, Req1} ->
case cow_multipart:form_data(Headers) of
{file, _Name, _Filename, _CType} ->
case read_limited(Req1, Max, <<>>) of
{ok, Bin, Req2} ->
case logic_upload:detect_image(Bin) of
{ok, _, _} -> {ok, Bin, Req2};
{error, unsupported_media} ->
{error, unsupported_media, Req2}
end;
{error, too_large, Req2} ->
{error, too_large, Req2}
end;
{data, _Name} ->
{ok, _Body, Req2} = cowboy_req:read_part_body(Req1),
read_parts(Req2, Max)
end;
{done, Req1} ->
{error, missing_file, Req1}
end.
%% Произвольный файл без проверки содержимого (аудио для STT):
%% возвращает байты и content-type части.
-spec read_file_part(cowboy_req:req(), non_neg_integer()) ->
{ok, binary(), binary(), cowboy_req:req()} |
{error, missing_file | too_large | bad_multipart, cowboy_req:req()}.
read_file_part(Req, Max) ->
try read_file_parts(Req, Max)
catch
_:_ -> {error, bad_multipart, Req}
end.
read_file_parts(Req, Max) ->
case cowboy_req:read_part(Req) of
{ok, Headers, Req1} ->
case cow_multipart:form_data(Headers) of
{file, _Name, _Filename, CType} ->
case read_limited(Req1, Max, <<>>) of
{ok, Bin, Req2} -> {ok, Bin, part_ctype(CType), Req2};
{error, too_large, Req2} -> {error, too_large, Req2}
end;
{data, _Name} ->
{ok, _Body, Req2} = cowboy_req:read_part_body(Req1),
read_file_parts(Req2, Max)
end;
{done, Req1} ->
{error, missing_file, Req1}
end.
part_ctype(undefined) -> <<"application/octet-stream">>;
part_ctype(B) when is_binary(B) -> B;
part_ctype(L) when is_list(L) -> list_to_binary(L).
read_limited(Req, Max, Acc) when byte_size(Acc) > Max ->
_ = drain(Req),
{error, too_large, Req};
read_limited(Req, Max, Acc) ->
Chunk = min(65536, Max - byte_size(Acc) + 1),
case cowboy_req:read_part_body(Req, #{length => Chunk}) of
{ok, Data, Req2} ->
Bin = <<Acc/binary, Data/binary>>,
case byte_size(Bin) > Max of
true -> {error, too_large, Req2};
false -> {ok, Bin, Req2}
end;
{more, Data, Req2} ->
Bin = <<Acc/binary, Data/binary>>,
case byte_size(Bin) > Max of
true ->
_ = drain(Req2),
{error, too_large, Req2};
false ->
read_limited(Req2, Max, Bin)
end
end.
drain(Req) ->
case cowboy_req:read_part_body(Req, #{length => 65536}) of
{ok, _, Req2} -> Req2;
{more, _, Req2} -> drain(Req2);
{done, Req2} -> Req2;
_ -> Req
end.
+85
View File
@@ -0,0 +1,85 @@
%%%-------------------------------------------------------------------
%%% @doc POST /v1/user/me/avatar multipart image upload (Back#71).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_user_avatar).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> -> post_avatar(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/user/me/avatar">>,
method => <<"POST">>,
description => <<"Upload current user avatar (multipart field file)">>,
tags => [<<"Users">>],
requestBody => #{
required => true,
content => #{
<<"multipart/form-data">> => #{
schema => #{
type => object,
required => [<<"file">>],
properties => #{file => #{type => string, format => binary}}
}
}
}
},
responses => #{
200 => #{description => <<"Updated user profile">>},
401 => #{description => <<"Unauthorized">>},
413 => #{description => <<"File too large">>},
415 => #{description => <<"Unsupported media type">>},
400 => #{description => <<"Missing file / bad multipart">>}
}
}
].
post_avatar(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case handler_upload_utils:read_image_part(Req1) of
{ok, Bin, Req2} ->
case logic_upload:save_avatar(UserId, Bin) of
{ok, Url} ->
Old = case core_user:get_by_id(UserId) of
{ok, #user{avatar_url = A}} -> A;
_ -> undefined
end,
case core_user:update(UserId, [{avatar_url, Url}]) of
{ok, User} ->
_ = logic_upload:maybe_delete_url(Old),
handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User));
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"User not found">>);
{error, _} ->
handler_utils:send_error(Req2, 400, <<"Update failed">>)
end;
{error, too_large} ->
handler_utils:send_error(Req2, 413, <<"File too large">>);
{error, unsupported_media} ->
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Upload failed">>)
end;
{error, missing_file, Req2} ->
handler_utils:send_error(Req2, 400, <<"Missing file field">>);
{error, too_large, Req2} ->
handler_utils:send_error(Req2, 413, <<"File too large">>);
{error, unsupported_media, Req2} ->
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
{error, bad_multipart, Req2} ->
handler_utils:send_error(Req2, 400, <<"Invalid multipart body">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
+64 -7
View File
@@ -33,7 +33,10 @@
is_superadmin/1, is_superadmin/1,
pagination_headers/2, pagination_headers/2,
maybe_report_internal_error/3, maybe_report_internal_error/3,
report_and_send_error/4 report_and_send_error/4,
parse_location/1,
location_to_json/1,
meaningful_coords/2
]). ]).
-export([admin_to_json/1, audit_to_json/1]). -export([admin_to_json/1, audit_to_json/1]).
@@ -317,11 +320,7 @@ audit_reason(_) -> <<>>.
%% (confirmed > pending > free; cancelled/expired и past-pending не занятость). %% (confirmed > pending > free; cancelled/expired и past-pending не занятость).
-spec event_to_json(#event{}) -> map(). -spec event_to_json(#event{}) -> map().
event_to_json(Event) -> event_to_json(Event) ->
LocationJson = case Event#event.location of LocationJson = location_to_json(Event#event.location),
undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon}
end,
RecurrenceJson = case Event#event.recurrence_rule of RecurrenceJson = case Event#event.recurrence_rule of
undefined -> null; undefined -> null;
Rule -> try jsx:decode(Rule, [return_maps]) of Rule -> try jsx:decode(Rule, [return_maps]) of
@@ -647,4 +646,62 @@ trails_for_crud(Path, _Resource, GetSchema, UpdateSchema) ->
200 => #{description => <<"Record deleted">>} 200 => #{description => <<"Record deleted">>}
} }
} }
]. ].
%%%===================================================================
%%% Location
%%%===================================================================
-spec parse_location(term()) -> #location{} | undefined.
parse_location(undefined) -> undefined;
parse_location(null) -> undefined;
parse_location(LocationMap) when is_map(LocationMap) ->
Addr0 = maps:get(<<"address">>, LocationMap, maps:get(address, LocationMap, <<>>)),
Addr = case Addr0 of
A when is_binary(A) -> A;
_ -> <<>>
end,
Lat = to_coord(maps:get(<<"lat">>, LocationMap,
maps:get(lat, LocationMap, undefined))),
Lon = to_coord(maps:get(<<"lon">>, LocationMap,
maps:get(lon, LocationMap, undefined))),
Coords = meaningful_coords(Lat, Lon),
HasAddr = Addr =/= <<>> andalso Addr =/= <<"undefined">>,
case {HasAddr, Coords, Lat =/= undefined orelse Lon =/= undefined} of
{false, false, _} -> undefined;
{_, true, _} ->
#location{address = Addr, lat = Lat, lon = Lon};
{true, false, false} ->
#location{address = Addr, lat = undefined, lon = undefined};
{true, false, true} ->
%% одна координата без пары храним только адрес
#location{address = Addr, lat = undefined, lon = undefined};
_ -> undefined
end;
parse_location(_) -> undefined.
-spec location_to_json(undefined | #location{}) -> null | map().
location_to_json(undefined) -> null;
location_to_json(#location{address = Addr, lat = Lat, lon = Lon}) ->
AddrJ = case Addr of
undefined -> null;
<<>> -> null;
A -> A
end,
case meaningful_coords(Lat, Lon) of
true -> #{address => AddrJ, lat => Lat, lon => Lon};
false -> #{address => AddrJ, lat => null, lon => null}
end.
-spec meaningful_coords(term(), term()) -> boolean().
meaningful_coords(Lat, Lon) ->
is_number(Lat) andalso is_number(Lon)
andalso not (Lat == 0 andalso Lon == 0)
andalso Lat >= -90 andalso Lat =< 90
andalso Lon >= -180 andalso Lon =< 180.
to_coord(undefined) -> undefined;
to_coord(null) -> undefined;
to_coord(N) when is_integer(N) -> float(N);
to_coord(N) when is_float(N) -> N;
to_coord(_) -> undefined.
+38 -6
View File
@@ -14,13 +14,15 @@
-define(TABLES, [ -define(TABLES, [
user, session, verification, password_reset, admin, admin_session, auth_session, user, session, verification, password_reset, admin, admin_session, auth_session,
calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite, calendar, calendar_share, calendar_share_invite, calendar_follow, calendar_specialist,
specialist_invite,
event, recurrence_exception, event, recurrence_exception,
booking, booking, waitlist_entry,
review, review_vote, report, banned_word, automod_settings, automod_hit, review, review_vote, report, banned_word, automod_settings, automod_hit,
ticket, subscription, ticket, subscription,
admin_audit, notification, admin_audit, notification, push_subscription,
stats_counter, stats_daily, node_metric, schema_migration stats_counter, stats_daily, node_metric, schema_migration,
month_snapshot
]). ]).
-define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]). -define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]).
@@ -210,6 +212,18 @@ ensure_schema_disc() ->
true -> ok true -> ok
end. end.
add_local_disc_copy(month_snapshot) ->
case lists:member(node(), mnesia:table_info(month_snapshot, disc_only_copies) ++
mnesia:table_info(month_snapshot, disc_copies)) of
true -> ok;
false ->
case mnesia:add_table_copy(month_snapshot, node(), disc_only_copies) of
{atomic, ok} -> ok;
{aborted, {already_exists, _}} -> ok;
{aborted, Reason} ->
io:format("Could not add disc_only copy for month_snapshot: ~p~n", [Reason])
end
end;
add_local_disc_copy(Tab) -> add_local_disc_copy(Tab) ->
case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of
false -> false ->
@@ -283,7 +297,9 @@ prune_dead_nodes() ->
lists:foreach(fun(Node) -> lists:foreach(fun(Node) ->
io:format("Removing dead node ~p from Mnesia schema...~n", [Node]), io:format("Removing dead node ~p from Mnesia schema...~n", [Node]),
lists:foreach(fun(Tab) -> lists:foreach(fun(Tab) ->
case lists:member(Node, mnesia:table_info(Tab, disc_copies)) of HasDisc = lists:member(Node, mnesia:table_info(Tab, disc_copies)),
HasOnly = lists:member(Node, mnesia:table_info(Tab, disc_only_copies)),
case HasDisc orelse HasOnly of
true -> catch mnesia:del_table_copy(Tab, Node); true -> catch mnesia:del_table_copy(Tab, Node);
false -> ok false -> ok
end end
@@ -317,7 +333,12 @@ create_table(Table) ->
table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, user)}]; table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, user)}];
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}]; table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}]; table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}]; table_opts(calendar_share) ->
[{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)},
{index, [calendar_id, user_id]}];
table_opts(calendar_share_invite) ->
[{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share_invite)},
{index, [calendar_id, invitee_user_id, invitee_email, token, status]}];
table_opts(calendar_follow) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}]; table_opts(calendar_follow) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}]; table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
table_opts(specialist_invite) -> [{disc_copies, [node()]}, {attributes, record_info(fields, specialist_invite)}]; table_opts(specialist_invite) -> [{disc_copies, [node()]}, {attributes, record_info(fields, specialist_invite)}];
@@ -326,6 +347,7 @@ table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
table_opts(recurrence_exception) -> table_opts(recurrence_exception) ->
[{disc_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}]; [{disc_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}]; table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
table_opts(waitlist_entry) -> [{disc_copies, [node()]}, {attributes, record_info(fields, waitlist_entry)}];
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}]; table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}]; table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}]; table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
@@ -336,9 +358,12 @@ table_opts(ticket) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}]; table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}];
table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}]; table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(fields, notification)}]; table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(fields, notification)}];
table_opts(push_subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, push_subscription)}];
table_opts(stats_counter) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_counter)}]; table_opts(stats_counter) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}]; table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}];
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}]; table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
table_opts(month_snapshot) ->
[{disc_only_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}];
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}]; table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}]; table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}]; table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}];
@@ -362,6 +387,9 @@ create_indices() ->
mnesia:add_table_index(booking, event_id), mnesia:add_table_index(booking, event_id),
mnesia:add_table_index(booking, user_id), mnesia:add_table_index(booking, user_id),
mnesia:add_table_index(booking, status), mnesia:add_table_index(booking, status),
mnesia:add_table_index(waitlist_entry, event_id),
mnesia:add_table_index(waitlist_entry, user_id),
mnesia:add_table_index(waitlist_entry, status),
mnesia:add_table_index(review_vote, review_id), mnesia:add_table_index(review_vote, review_id),
mnesia:add_table_index(review_vote, user_id), mnesia:add_table_index(review_vote, user_id),
mnesia:add_table_index(calendar, owner_id), mnesia:add_table_index(calendar, owner_id),
@@ -381,8 +409,12 @@ create_indices() ->
mnesia:add_table_index(password_reset, user_id), mnesia:add_table_index(password_reset, user_id),
mnesia:add_table_index(notification, user_id), mnesia:add_table_index(notification, user_id),
mnesia:add_table_index(notification, is_read), mnesia:add_table_index(notification, is_read),
mnesia:add_table_index(push_subscription, user_id),
mnesia:add_table_index(push_subscription, endpoint),
mnesia:add_table_index(auth_session, family_id), mnesia:add_table_index(auth_session, family_id),
mnesia:add_table_index(auth_session, subject_id), mnesia:add_table_index(auth_session, subject_id),
mnesia:add_table_index(report, resolved_by), mnesia:add_table_index(report, resolved_by),
mnesia:add_table_index(ticket, assigned_to), mnesia:add_table_index(ticket, assigned_to),
mnesia:add_table_index(month_snapshot, calendar_id),
mnesia:add_table_index(month_snapshot, year_month),
ok. ok.
+4 -4
View File
@@ -1,5 +1,5 @@
%% =================================================================== %% ===================================================================
%% EventHub инфраструктурный супервизор (с archive_manager) %% EventHub инфраструктурный супервизор
%% =================================================================== %% ===================================================================
-module(infra_sup). -module(infra_sup).
-behaviour(supervisor). -behaviour(supervisor).
@@ -39,12 +39,12 @@ init([]) ->
start => {node_monitor, start_link, []}, start => {node_monitor, start_link, []},
restart => permanent, restart => permanent,
type => worker}, type => worker},
#{id => archive_manager, #{id => month_archive_worker,
start => {archive_manager, start_link, []}, start => {month_archive_worker, start_link, []},
restart => permanent, restart => permanent,
shutdown => 5000, shutdown => 5000,
type => worker, type => worker,
modules => [archive_manager]}, modules => [month_archive_worker]},
#{id => migration_engine, #{id => migration_engine,
start => {migration_engine, start_link, []}, start => {migration_engine, start_link, []},
restart => permanent, restart => permanent,
+7 -1
View File
@@ -28,7 +28,13 @@
'20260720210000_calendar_follow', '20260720210000_calendar_follow',
'20260722150000_calendar_specialist_id', '20260722150000_calendar_specialist_id',
'20260722190000_specialist_invite', '20260722190000_specialist_invite',
'20260730120000_recurrence_exception_bag' '20260730120000_recurrence_exception_bag',
'20260814193000_waitlist_entry',
'20260815200000_push_subscription',
'20260815220000_calendar_share_invite',
'20260816180000_auth_session_device',
'20260817140000_month_snapshot',
'20260820180000_ai_hint_metric'
]). ]).
%% ------------------------------ %% ------------------------------
+47
View File
@@ -0,0 +1,47 @@
%% Periodic month snapshots (Back#76). Replaces extra-node archive_manager.
-module(month_archive_worker).
-behaviour(gen_server).
-export([start_link/0, run_once/0]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
-define(DEFAULT_INTERVAL_MS, 3600000).
-record(state, {interval_ms :: pos_integer(), timer_ref :: reference() | undefined}).
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
run_once() ->
logic_month_archive:run_once().
init([]) ->
Interval = application:get_env(eventhub, archive_interval_ms, ?DEFAULT_INTERVAL_MS),
Ref = erlang:send_after(15000, self(), run),
logger:info(#{what => month_archive_worker_started, interval_ms => Interval}),
{ok, #state{interval_ms = Interval, timer_ref = Ref}}.
handle_call(run_once, _From, State) ->
{reply, logic_month_archive:run_once(), State};
handle_call(_Msg, _From, State) ->
{reply, {error, unknown_call}, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(run, State) ->
_ = catch logic_month_archive:run_once(),
Ref = erlang:send_after(State#state.interval_ms, self(), run),
{noreply, State#state{timer_ref = Ref}};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, #state{timer_ref = Ref}) ->
case Ref of
undefined -> ok;
_ -> erlang:cancel_timer(Ref), ok
end.
code_change(_O, State, _E) ->
{ok, State}.
+1
View File
@@ -26,6 +26,7 @@ handle_cast(_Msg, State) ->
handle_info(tick, State) -> handle_info(tick, State) ->
_ = catch logic_subscription:handle_expired_subscriptions(), _ = catch logic_subscription:handle_expired_subscriptions(),
_ = catch logic_booking:process_timeout_bookings(), _ = catch logic_booking:process_timeout_bookings(),
_ = catch logic_booking:process_reminders(),
erlang:send_after(?INTERVAL_MS, self(), tick), erlang:send_after(?INTERVAL_MS, self(), tick),
{noreply, State}; {noreply, State};
handle_info(_Info, State) -> handle_info(_Info, State) ->
+201
View File
@@ -0,0 +1,201 @@
%%%-------------------------------------------------------------------
%%% @doc Пилотные метрики ИИ-подсказок (PRODUCT-AI, Spec#22).
%%%
%%% Front присылает счётчики shown/confirm/dismiss по паттерну подсказки;
%%% Back агрегирует их по дням (таблица ai_hint_metric) и отдаёт админам
%%% сводку для решения о старте L2 (гейты канона: hint CTR и доля
%%% активных с >=2 подтверждёнными ghost-записями за месяц).
%%%
%%% Данные привязаны к opt-in: клиент шлёт метрики только при включённых
%%% подсказках (preferences.ai_hints).
%%% @end
%%%-------------------------------------------------------------------
-module(logic_ai_metrics).
-export([record_events/2, summary/0]).
-include("records.hrl").
-define(TABLE, ai_hint_metric).
-define(MAX_BATCH, 20).
-define(MAX_PATTERN, 96).
-define(RETENTION_DAYS, 92).
-define(PRUNE_ROWS_PER_USER, 500).
-define(RL_TABLE, eventhub_ai_metrics_rl).
-define(RL_LIMIT, 30). % запросов на пользователя
-define(RL_WINDOW_MS, 60000).
%%%===================================================================
%%% Запись событий
%%%===================================================================
-spec record_events(binary(), [map()]) ->
{ok, non_neg_integer()} | {error, invalid_body | rate_limited}.
record_events(UserId, Events) when is_binary(UserId), is_list(Events),
Events =/= [], length(Events) =< ?MAX_BATCH ->
case allow(UserId) of
false ->
{error, rate_limited};
true ->
Day = today(),
case normalize_events(Events, []) of
{ok, Norm} ->
{atomic, Count} = mnesia:transaction(fun() ->
lists:foreach(fun({Kind, Pattern}) ->
increment(UserId, Day, Pattern, Kind)
end, Norm),
maybe_prune(UserId, Day),
length(Norm)
end),
{ok, Count};
error ->
{error, invalid_body}
end
end;
record_events(_UserId, _Events) ->
{error, invalid_body}.
increment(UserId, Day, Pattern, Kind) ->
Key = {UserId, Day, Pattern, Kind},
Row = case mnesia:read(?TABLE, Key, write) of
[Existing] -> Existing;
[] -> #ai_hint_metric{id = Key, user_id = UserId, day = Day,
pattern_key = Pattern, kind = Kind, count = 0}
end,
mnesia:write(Row#ai_hint_metric{
count = Row#ai_hint_metric.count + 1,
updated_at = calendar:universal_time()
}).
%% Ленивая чистка: если у пользователя накопилось много строк,
%% удаляем всё старше RETENTION_DAYS.
maybe_prune(UserId, Day) ->
Rows = mnesia:index_read(?TABLE, UserId, user_id),
case length(Rows) > ?PRUNE_ROWS_PER_USER of
true ->
Cutoff = shift_day(Day, -?RETENTION_DAYS),
lists:foreach(fun(#ai_hint_metric{day = D} = R) when D < Cutoff ->
mnesia:delete_object(R);
(_) -> ok
end, Rows);
false -> ok
end.
normalize_events([], Acc) ->
{ok, lists:reverse(Acc)};
normalize_events([E | Rest], Acc) when is_map(E) ->
Kind = maps:get(<<"kind">>, E, undefined),
Pattern = maps:get(<<"pattern">>, E, <<>>),
case valid_kind(Kind) andalso valid_pattern(Pattern) of
true -> normalize_events(Rest, [{Kind, Pattern} | Acc]);
false -> error
end;
normalize_events(_, _) ->
error.
valid_kind(<<"shown">>) -> true;
valid_kind(<<"confirm">>) -> true;
valid_kind(<<"dismiss">>) -> true;
valid_kind(_) -> false.
valid_pattern(P) when is_binary(P), byte_size(P) > 0, byte_size(P) =< ?MAX_PATTERN -> true;
valid_pattern(_) -> false.
%%%===================================================================
%%% Сводка для админов
%%%===================================================================
-spec summary() -> map().
summary() ->
Today = today(),
MonthAgo = shift_day(Today, -30),
Init = #{
by_kind => #{},
active_30d => sets:new(),
confirms_by_user_30d => #{},
confirms_by_pattern => #{}
},
{atomic, Acc} = mnesia:transaction(fun() ->
mnesia:foldl(fun(R, A) -> fold_row(R, MonthAgo, A) end, Init, ?TABLE)
end),
ByKind = maps:get(by_kind, Acc),
Active = sets:size(maps:get(active_30d, Acc)),
ConfirmsByUser = maps:get(confirms_by_user_30d, Acc),
UsersGe2 = maps:size(maps:filter(fun(_, N) -> N >= 2 end, ConfirmsByUser)),
TopPatterns = lists:sublist(
lists:reverse(lists:sort(maps:to_list(maps:get(confirms_by_pattern, Acc)))), 10),
#{
<<"by_kind">> => ByKind,
<<"active_users_30d">> => Active,
<<"users_with_confirms_ge2_30d">> => UsersGe2,
<<"gate_ratio_30d">> => ratio(UsersGe2, Active),
<<"top_patterns_by_confirm">> =>
[#{<<"pattern">> => P, <<"confirms">> => N} || {P, N} <- TopPatterns],
<<"retention_days">> => ?RETENTION_DAYS
}.
fold_row(#ai_hint_metric{user_id = U, day = D, pattern_key = P, kind = K, count = N},
MonthAgo, Acc) ->
ByKind = maps:get(by_kind, Acc),
Acc1 = Acc#{by_kind => maps:update_with(K, fun(V) -> V + N end, N, ByKind)},
case D >= MonthAgo of
false -> Acc1;
true ->
Active = sets:add_element(U, maps:get(active_30d, Acc1)),
Acc2 = Acc1#{active_30d => Active},
Acc3 = case K of
<<"confirm">> ->
Cbu = maps:get(confirms_by_user_30d, Acc2),
Acc2#{confirms_by_user_30d => maps:update_with(U, fun(V) -> V + N end, N, Cbu)};
_ -> Acc2
end,
case K of
<<"confirm">> ->
Cbp = maps:get(confirms_by_pattern, Acc3),
Acc3#{confirms_by_pattern => maps:update_with(P, fun(V) -> V + N end, N, Cbp)};
_ -> Acc3
end
end.
ratio(_, 0) -> 0.0;
ratio(Part, Total) -> Part / Total.
%%%===================================================================
%%% Даты и rate-limit
%%%===================================================================
today() ->
{{Y, M, D}, _} = calendar:universal_time(),
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y, M, D])).
shift_day(DayBin, Delta) ->
<<Y:4/binary, "-", M:2/binary, "-", D:2/binary>> = DayBin,
Date = {binary_to_integer(Y), binary_to_integer(M), binary_to_integer(D)},
Sec = calendar:datetime_to_gregorian_seconds({Date, {0, 0, 0}}) + Delta * 86400,
{{Y2, M2, D2}, _} = calendar:gregorian_seconds_to_datetime(Sec),
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y2, M2, D2])).
allow(UserId) ->
ensure_rl(),
Now = erlang:system_time(millisecond),
Key = {rl, UserId},
Result = case ets:lookup(?RL_TABLE, Key) of
[{_, WindowStart, Count}] when Now - WindowStart < ?RL_WINDOW_MS ->
Count < ?RL_LIMIT;
_ ->
true
end,
case Result of
true ->
ets:update_counter(?RL_TABLE, Key, {3, 1}, {Key, Now, 0}),
true;
false ->
false
end.
ensure_rl() ->
case ets:info(?RL_TABLE) of
undefined -> ets:new(?RL_TABLE, [named_table, public, set]);
_ -> ok
end,
ok.
+313
View File
@@ -0,0 +1,313 @@
%%%-------------------------------------------------------------------
%%% @doc Прокси транскрибации голоса (PRODUCT-AI, Front#76).
%%%
%%% Front шлёт аудио press-to-talk записи; Back проксирует его в
%%% продуктовый STT-провайдер (адаптеры: xai, openai-совместимый).
%%% Ключи провайдера не покидают Back; аудио и транскрипт не
%%% персистятся (приват-инвариант канона). Rate-limit в ETS.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_ai_transcribe).
-export([transcribe/3, max_bytes/0, ensure/0]).
%% Экспортировано для тестов и хэндлера.
-export([parse_provider_response/2, build_multipart/4, file_name/1]).
-define(RL_TABLE, eventhub_stt_rl).
-define(UP, eventhub_stt_upstream).
-define(RL_LIMIT, 10). % запросов на пользователя
-define(RL_WINDOW_MS, 60000).
-define(MAX_AUDIO_BYTES, 10 * 1024 * 1024).
-define(DEFAULT_XAI_URL, <<"https://api.x.ai/v1/stt">>).
-define(DEFAULT_OPENAI_URL, <<"http://localhost:8000/v1/audio/transcriptions">>).
-define(DEFAULT_TIMEOUT_MS, 15000).
-define(HTTP_PROFILE, eventhub_stt).
%%%===================================================================
%%% API
%%%===================================================================
-spec max_bytes() -> non_neg_integer().
max_bytes() -> ?MAX_AUDIO_BYTES.
-spec ensure() -> ok.
ensure() ->
case ets:info(?RL_TABLE) of
undefined ->
ets:new(?RL_TABLE, [named_table, public, set, {write_concurrency, true}]);
_ -> ok
end,
case ets:info(?UP) of
undefined ->
ets:new(?UP, [named_table, public, set]);
_ -> ok
end,
ok.
%% Транскрибация аудио пользователя. Пустой transcript в ответе
%% провайдера возвращаем как {ok, <<>>, Lang} клиент покажет
%% тост «не расслышал» без retry-цикла (degrade-матрица канона).
-spec transcribe(binary(), binary(), binary()) ->
{ok, binary(), binary()} |
{error, unavailable | rate_limited | upstream_error | invalid_audio}.
transcribe(UserId, Audio, ContentType)
when is_binary(UserId), is_binary(Audio), byte_size(Audio) > 0,
byte_size(Audio) =< ?MAX_AUDIO_BYTES ->
ensure(),
case api_key() of
<<>> -> {error, unavailable};
Key ->
case allow(UserId) of
false -> {error, rate_limited};
true ->
case throttle_allow() of
false -> {error, rate_limited};
true ->
Provider = provider(),
call_provider(Provider, stt_url(Provider), Key, Audio, ContentType)
end
end
end;
transcribe(_UserId, _Audio, _ContentType) ->
{error, invalid_audio}.
%%%===================================================================
%%% Конфигурация (env, паттерн logic_geo)
%%%===================================================================
provider() ->
case env_bin("STT_PROVIDER") of
<<>> -> xai;
<<"openai">> -> openai;
<<"xai">> -> xai;
_ -> xai
end.
stt_url(xai) ->
case env_bin("STT_URL") of
<<>> -> ?DEFAULT_XAI_URL;
Url -> Url
end;
stt_url(openai) ->
case env_bin("STT_URL") of
<<>> -> ?DEFAULT_OPENAI_URL;
Url -> Url
end.
api_key() -> env_bin("STT_API_KEY").
timeout_ms() -> env_int("STT_TIMEOUT_MS", ?DEFAULT_TIMEOUT_MS).
proxy_url() -> env_bin("STT_PROXY_URL").
env_bin(Name) ->
case os:getenv(Name) of
false -> <<>>;
"" -> <<>>;
V -> list_to_binary(string:trim(V))
end.
env_int(Name, Default) ->
case os:getenv(Name) of
false -> Default;
"" -> Default;
S ->
try list_to_integer(S) of
N when N > 0 -> N;
_ -> Default
catch
_:_ -> Default
end
end.
%%%===================================================================
%%% Вызов провайдера
%%%===================================================================
call_provider(xai, Url, Key, Audio, CType) ->
%% xAI: multipart без опциональных полей язык определяется авто
%% (format=true требует явный language и отключает автоопределение).
Body = build_multipart([], Audio, file_name(CType), CType),
post_and_parse(xai, Url, Key, Body);
call_provider(openai, Url, Key, Audio, CType) ->
Model = case env_bin("STT_MODEL") of
<<>> -> <<"whisper-1">>;
M -> M
end,
Body = build_multipart([{<<"model">>, Model}], Audio, file_name(CType), CType),
post_and_parse(openai, Url, Key, Body).
%% Апстрим определяет формат аудио по расширению имени файла в
%% multipart-части (не по её content-type); нейтральное voice.dat
%% даёт 400 «Could not detect audio format» на webm/mp4 из
%% MediaRecorder. Расширение выводим из content-type (параметры
%% вида ;codecs=opus отбрасываем); неизвестный тип .wav.
-spec file_name(binary()) -> binary().
file_name(ContentType) ->
Mime = case binary:split(ContentType, <<";">>) of
[M | _] -> string:trim(M);
[] -> <<>>
end,
case Mime of
<<"audio/webm">> -> <<"voice.webm">>;
<<"audio/mp4">> -> <<"voice.mp4">>;
<<"audio/aac">> -> <<"voice.aac">>;
<<"audio/mpeg">> -> <<"voice.mp3">>;
<<"audio/mp3">> -> <<"voice.mp3">>;
<<"audio/ogg">> -> <<"voice.ogg">>;
<<"audio/opus">> -> <<"voice.opus">>;
<<"audio/flac">> -> <<"voice.flac">>;
<<"audio/m4a">> -> <<"voice.m4a">>;
<<"audio/x-m4a">> -> <<"voice.m4a">>;
<<"audio/wav">> -> <<"voice.wav">>;
<<"audio/wave">> -> <<"voice.wav">>;
<<"audio/x-wav">> -> <<"voice.wav">>;
_ -> <<"voice.wav">>
end.
post_and_parse(Provider, Url, Key, Body) ->
Headers = [{"authorization", binary_to_list(<<"Bearer ", Key/binary>>)}],
CTypeHdr = "multipart/form-data; boundary=" ++ binary_to_list(boundary(Body)),
case http_post(Url, Headers, CTypeHdr, Body) of
{ok, RespBody} -> parse_provider_response(Provider, RespBody);
{error, _} -> {error, upstream_error}
end.
%% Хук для тестов: fun(Url :: binary(), Headers, ContentType, Body) ->
%% {ok, RespBinary} | {error, term()}.
http_post(Url, Headers, ContentType, Body) ->
Fun = case application:get_env(eventhub, stt_http_post) of
{ok, F} when is_function(F, 4) -> F;
_ -> fun default_http_post/4
end,
Fun(Url, Headers, ContentType, Body).
default_http_post(Url, Headers, ContentType, Body) ->
_ = application:ensure_all_started(inets),
ensure_profile(),
UrlS = binary_to_list(Url),
case httpc:request(post, {UrlS, Headers, ContentType, Body},
[{timeout, timeout_ms()}], [{body_format, binary}], ?HTTP_PROFILE) of
{ok, {{_, Code, _}, _, Resp}} when Code >= 200, Code < 300, is_binary(Resp) ->
{ok, Resp};
{ok, {{_, Code, _}, _, Resp}} when Code >= 200, Code < 300 ->
{ok, iolist_to_binary(Resp)};
{ok, _} -> {error, http_status};
{error, Reason} -> {error, Reason}
end.
%% Отдельный профиль httpc: прокси (STT_PROXY_URL) применяется только
%% к STT-апстриму, остальные httpc-вызовы не затрагиваются.
ensure_profile() ->
case inets:start(httpc, [{profile, ?HTTP_PROFILE}]) of
{ok, _Pid} -> apply_proxy();
{error, {already_started, _Pid}} -> apply_proxy();
_ -> ok
end.
apply_proxy() ->
case parse_proxy(proxy_url()) of
{ok, Host, Port} ->
Addr = {Host, Port},
httpc:set_options([{proxy, {Addr, []}}, {https_proxy, {Addr, []}}],
?HTTP_PROFILE);
error -> ok
end.
parse_proxy(<<>>) -> error;
parse_proxy(Url) ->
try uri_string:parse(binary_to_list(Url)) of
#{host := Host, port := Port} -> {ok, Host, Port};
#{host := Host} -> {ok, Host, 8080};
_ -> error
catch
_:_ -> error
end.
%%%===================================================================
%%% Multipart и парсинг ответа
%%%===================================================================
%% Multipart/form-data: Fields (name=value) идут первыми, файл
%% последним полем (требование xAI для стриминг-загрузки).
-spec build_multipart([{binary(), binary()}], binary(), binary(), binary()) -> binary().
build_multipart(Fields, FileBin, FileName, FileCType) ->
B = boundary_bin(),
FieldParts = [field_part(B, Name, Value) || {Name, Value} <- Fields],
FilePart = [
<<"--", B/binary, "\r\n">>,
<<"Content-Disposition: form-data; name=\"file\"; filename=\"">>,
FileName, <<"\"\r\n">>,
<<"Content-Type: ">>, FileCType, <<"\r\n\r\n">>,
FileBin, <<"\r\n">>
],
iolist_to_binary([FieldParts, FilePart, <<"--", B/binary, "--\r\n">>]).
field_part(B, Name, Value) ->
[
<<"--", B/binary, "\r\n">>,
<<"Content-Disposition: form-data; name=\"">>, Name, <<"\"\r\n\r\n">>,
Value, <<"\r\n">>
].
boundary(Body) ->
{Pos, _} = binary:match(Body, <<"\r\n">>),
<<"--", B/binary>> = binary:part(Body, 0, Pos),
B.
boundary_bin() ->
Hex = [io_lib:format("~2.16.0b", [rand:uniform(256) - 1]) || _ <- lists:seq(1, 12)],
<<"ehstt", (iolist_to_binary(Hex))/binary>>.
%% Маппинг ответа провайдера в {ok, Transcript, Lang}.
%% Оба адаптера читают `text`; язык из `language` (xAI определяет сам).
-spec parse_provider_response(xai | openai, binary()) ->
{ok, binary(), binary()} | {error, upstream_error}.
parse_provider_response(_Provider, RespBody) when is_binary(RespBody) ->
try jsx:decode(RespBody, [return_maps]) of
#{<<"text">> := Text} = M when is_binary(Text) ->
Lang = case maps:get(<<"language">>, M, null) of
L when is_binary(L) -> L;
_ -> <<>>
end,
{ok, Text, Lang};
_ -> {error, upstream_error}
catch
_:_ -> {error, upstream_error}
end;
parse_provider_response(_Provider, _) ->
{error, upstream_error}.
%%%===================================================================
%%% Rate-limit (паттерн logic_ai_metrics)
%%%===================================================================
allow(UserId) ->
ensure(),
Now = erlang:system_time(millisecond),
Key = {rl, UserId},
Result = case ets:lookup(?RL_TABLE, Key) of
[{_, WindowStart, Count}] when Now - WindowStart < ?RL_WINDOW_MS ->
Count < ?RL_LIMIT;
_ ->
true
end,
case Result of
true ->
ets:update_counter(?RL_TABLE, Key, {3, 1}, {Key, Now, 0}),
true;
false ->
false
end.
%% Глобальный троттлинг апстрима: не более 2 rps на весь узел.
throttle_allow() ->
ensure(),
NowMs = erlang:monotonic_time(millisecond),
MinGap = 500,
case ets:lookup(?UP, last) of
[{last, Prev}] when NowMs - Prev < MinGap -> false;
_ ->
ets:insert(?UP, {last, NowMs}),
true
end.
+121 -20
View File
@@ -5,53 +5,153 @@
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-module(logic_auth_session). -module(logic_auth_session).
-include("records.hrl"). -include("records.hrl").
-export([issue_admin_tokens/2, refresh_admin/1, -export([
issue_user_tokens/2, refresh_user/1]). issue_admin_tokens/2, refresh_admin/1, logout_admin/1,
issue_user_tokens/2, issue_user_tokens/5, refresh_user/1, logout_user/1,
list_user_sessions/1, revoke_user_session/2, revoke_other_user_sessions/2,
normalize_user_client_type/1, normalize_device_name/1
]).
-define(USER_CLIENT_WEB, <<"web">>). -define(USER_CLIENT_WEB, <<"web">>).
-define(USER_CLIENT_MOBILE, <<"mobile">>).
-define(DEVICE_NAME_MAX, 120).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% Admin (фаза 1) %%% Admin (фаза 1)
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec issue_admin_tokens(AdminId :: binary(), Role :: binary()) -> -spec issue_admin_tokens(AdminId :: binary(), Role :: binary()) ->
{ok, AccessToken :: binary(), RefreshToken :: binary()}. {ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()}.
issue_admin_tokens(AdminId, Role) -> issue_admin_tokens(AdminId, Role) ->
issue_tokens(AdminId, admin, <<"admin">>, Role, fun eventhub_auth:generate_admin_token/2, issue_tokens(AdminId, admin, <<"admin">>, <<>>, <<>>, Role,
fun eventhub_auth:generate_admin_token/2,
fun eventhub_auth:generate_admin_refresh_token/5). fun eventhub_auth:generate_admin_refresh_token/5).
-spec refresh_admin(RefreshToken :: binary()) -> -spec refresh_admin(RefreshToken :: binary()) ->
{ok, AccessToken :: binary(), NewRefreshToken :: binary()} | {ok, AccessToken :: binary(), NewRefreshToken :: binary(), SessionId :: binary()} |
{error, atom()}. {error, atom()}.
refresh_admin(RefreshToken) -> refresh_admin(RefreshToken) ->
refresh_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1, refresh_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1,
fun core_admin:get_by_id/1, admin, fun eventhub_auth:generate_admin_token/2, fun core_admin:get_by_id/1, admin, fun eventhub_auth:generate_admin_token/2,
fun eventhub_auth:generate_admin_refresh_token/5, <<"admin">>). fun eventhub_auth:generate_admin_refresh_token/5).
-spec logout_admin(RefreshToken :: binary()) -> ok | {error, atom()}.
logout_admin(RefreshToken) ->
logout_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% User (фаза 2) %%% User (фаза 2 / 3)
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec issue_user_tokens(UserId :: binary(), Role :: binary()) -> -spec issue_user_tokens(UserId :: binary(), Role :: binary()) ->
{ok, AccessToken :: binary(), RefreshToken :: binary()}. {ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()}.
issue_user_tokens(UserId, Role) -> issue_user_tokens(UserId, Role) ->
issue_tokens(UserId, user, ?USER_CLIENT_WEB, Role, fun eventhub_auth:generate_user_token/2, issue_user_tokens(UserId, Role, ?USER_CLIENT_WEB, <<>>, <<>>).
fun eventhub_auth:generate_user_refresh_token/5).
-spec issue_user_tokens(UserId :: binary(), Role :: binary(),
ClientType :: binary(), DeviceName :: binary(),
UserAgent :: binary()) ->
{ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()} |
{error, invalid_client_type}.
issue_user_tokens(UserId, Role, ClientType0, DeviceName0, UserAgent0) ->
case normalize_user_client_type(ClientType0) of
{ok, ClientType} ->
DeviceName = normalize_device_name(DeviceName0),
UserAgent = case UserAgent0 of
U when is_binary(U) -> U;
_ -> <<>>
end,
issue_tokens(UserId, user, ClientType, DeviceName, UserAgent, Role,
fun eventhub_auth:generate_user_token/2,
fun eventhub_auth:generate_user_refresh_token/5);
{error, _} = Err ->
Err
end.
-spec refresh_user(RefreshToken :: binary()) -> -spec refresh_user(RefreshToken :: binary()) ->
{ok, AccessToken :: binary(), NewRefreshToken :: binary()} | {ok, AccessToken :: binary(), NewRefreshToken :: binary(), SessionId :: binary()} |
{error, atom()}. {error, atom()}.
refresh_user(RefreshToken) -> refresh_user(RefreshToken) ->
refresh_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1, refresh_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1,
fun core_user:get_by_id/1, user, fun eventhub_auth:generate_user_token/2, fun core_user:get_by_id/1, user, fun eventhub_auth:generate_user_token/2,
fun eventhub_auth:generate_user_refresh_token/5, ?USER_CLIENT_WEB). fun eventhub_auth:generate_user_refresh_token/5).
-spec logout_user(RefreshToken :: binary()) -> ok | {error, atom()}.
logout_user(RefreshToken) ->
logout_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1).
-spec list_user_sessions(UserId :: binary()) -> [#auth_session{}].
list_user_sessions(UserId) ->
core_auth_session:list_active_for_subject(UserId, user).
-spec revoke_user_session(UserId :: binary(), SessionId :: binary()) ->
ok | {error, not_found}.
revoke_user_session(UserId, SessionId) ->
case core_auth_session:get(SessionId) of
{ok, #auth_session{subject_id = UserId, subject_type = user}} ->
core_auth_session:revoke(SessionId);
{ok, _} ->
{error, not_found};
{error, not_found} ->
{error, not_found}
end.
-spec revoke_other_user_sessions(UserId :: binary(), RefreshToken :: binary()) ->
{ok, non_neg_integer()} | {error, atom()}.
revoke_other_user_sessions(UserId, RefreshToken) ->
case eventhub_auth:verify_user_refresh_token(RefreshToken) of
{ok, Claims} ->
case maps:get(<<"sub">>, Claims) of
UserId ->
SessionId = maps:get(<<"sid">>, Claims),
core_auth_session:revoke_others_for_subject(UserId, user, SessionId);
_ ->
{error, forbidden}
end;
{error, _} = Err ->
Err
end.
-spec normalize_user_client_type(term()) -> {ok, binary()} | {error, invalid_client_type}.
normalize_user_client_type(undefined) -> {ok, ?USER_CLIENT_WEB};
normalize_user_client_type(null) -> {ok, ?USER_CLIENT_WEB};
normalize_user_client_type(<<>>) -> {ok, ?USER_CLIENT_WEB};
normalize_user_client_type(?USER_CLIENT_WEB) -> {ok, ?USER_CLIENT_WEB};
normalize_user_client_type(?USER_CLIENT_MOBILE) -> {ok, ?USER_CLIENT_MOBILE};
normalize_user_client_type(<<"WEB">>) -> {ok, ?USER_CLIENT_WEB};
normalize_user_client_type(<<"MOBILE">>) -> {ok, ?USER_CLIENT_MOBILE};
normalize_user_client_type(_) -> {error, invalid_client_type}.
-spec normalize_device_name(term()) -> binary().
normalize_device_name(Name) when is_binary(Name) ->
Trimmed = string:trim(Name),
case byte_size(Trimmed) > ?DEVICE_NAME_MAX of
true -> binary:part(Trimmed, 0, ?DEVICE_NAME_MAX);
false -> Trimmed
end;
normalize_device_name(_) ->
<<>>.
%%%=================================================================== %%%===================================================================
%%% Internal %%% Internal
%%%=================================================================== %%%===================================================================
issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) -> logout_subject(RefreshToken, VerifyFn) ->
{ok, Session} = core_auth_session:create(SubjectId, SubjectType, ClientType), case VerifyFn(RefreshToken) of
{ok, Claims} ->
SessionId = maps:get(<<"sid">>, Claims),
case core_auth_session:revoke(SessionId) of
ok -> ok;
{error, not_found} -> ok;
{error, _} = Err -> Err
end;
{error, _} = Err ->
Err
end.
issue_tokens(SubjectId, SubjectType, ClientType, DeviceName, UserAgent, Role, GenAccess, GenRefresh) ->
{ok, Session} = core_auth_session:create(
SubjectId, SubjectType, ClientType, DeviceName, UserAgent),
AccessToken = GenAccess(SubjectId, Role), AccessToken = GenAccess(SubjectId, Role),
{RefreshToken, _} = GenRefresh( {RefreshToken, _} = GenRefresh(
Session#auth_session.session_id, Session#auth_session.session_id,
@@ -60,28 +160,29 @@ issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) ->
SubjectId, SubjectId,
ClientType ClientType
), ),
{ok, AccessToken, RefreshToken}. {ok, AccessToken, RefreshToken, Session#auth_session.session_id}.
refresh_subject(RefreshToken, VerifyFn, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) -> refresh_subject(RefreshToken, VerifyFn, GetSubjectFn, SubjectType, GenAccess, GenRefresh) ->
case VerifyFn(RefreshToken) of case VerifyFn(RefreshToken) of
{ok, Claims} -> {ok, Claims} ->
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType); do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh);
Error -> Error ->
Error Error
end. end.
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) -> do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh) ->
SessionId = maps:get(<<"sid">>, Claims), SessionId = maps:get(<<"sid">>, Claims),
Jti = maps:get(<<"jti">>, Claims), Jti = maps:get(<<"jti">>, Claims),
SubjectId = maps:get(<<"sub">>, Claims), SubjectId = maps:get(<<"sub">>, Claims),
FamilyId = maps:get(<<"fid">>, Claims), FamilyId = maps:get(<<"fid">>, Claims),
case core_auth_session:rotate(SessionId, Jti) of case core_auth_session:rotate(SessionId, Jti) of
{ok, NewJti, _Session} -> {ok, NewJti, Session} ->
{ok, Subject} = GetSubjectFn(SubjectId), {ok, Subject} = GetSubjectFn(SubjectId),
Role = subject_role(Subject, SubjectType), Role = subject_role(Subject, SubjectType),
ClientType = Session#auth_session.client_type,
AccessToken = GenAccess(SubjectId, Role), AccessToken = GenAccess(SubjectId, Role),
{NewRefresh, _} = GenRefresh(SessionId, FamilyId, NewJti, SubjectId, ClientType), {NewRefresh, _} = GenRefresh(SessionId, FamilyId, NewJti, SubjectId, ClientType),
{ok, AccessToken, NewRefresh}; {ok, AccessToken, NewRefresh, SessionId};
{error, reuse_detected} -> {error, reuse_detected} ->
core_auth_session:revoke_family(FamilyId), core_auth_session:revoke_family(FamilyId),
{error, reuse_detected}; {error, reuse_detected};
+124 -6
View File
@@ -7,8 +7,8 @@
delete_booking/2, delete_booking/2,
list_bookings_admin/0, get_booking_admin/1, list_bookings_admin/0, get_booking_admin/1,
list_event_bookings/1, list_event_bookings/2, list_event_bookings/1, list_event_bookings/2,
process_timeout_bookings/0, cancel_pending_for_owner/1, process_timeout_bookings/0, process_reminders/0, cancel_pending_for_owner/1,
cancel_pending_for_calendar/1]). cancel_pending_for_calendar/1, can_manage_event_bookings/2]).
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
%%% @doc Создание бронирования с учётом commercial / confirmation / capacity. %%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
@@ -133,7 +133,13 @@ confirm_booking(UserId, BookingId, decline) ->
true -> true ->
case ensure_pending_actionable(Booking) of case ensure_pending_actionable(Booking) of
{ok, _} -> {ok, _} ->
core_booking:update(BookingId, [{status, cancelled}]); case core_booking:update(BookingId, [{status, cancelled}]) of
{ok, _} = Ok ->
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
Ok;
Err ->
Err
end;
{error, Reason} -> {error, Reason} ->
{error, Reason} {error, Reason}
end; end;
@@ -155,7 +161,14 @@ cancel_booking(BookingId, UserId) ->
{ok, Booking}; {ok, Booking};
_ -> _ ->
case Booking#booking.user_id =:= UserId of case Booking#booking.user_id =:= UserId of
true -> core_booking:update(BookingId, [{status, cancelled}]); true ->
case core_booking:update(BookingId, [{status, cancelled}]) of
{ok, _} = Ok ->
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
Ok;
Err ->
Err
end;
false -> {error, access_denied} false -> {error, access_denied}
end end
end; end;
@@ -367,6 +380,95 @@ process_timeout_bookings() ->
lists:foreach(fun(B) -> maybe_timeout(B, NowSec) end, Pending), lists:foreach(fun(B) -> maybe_timeout(B, NowSec) end, Pending),
ok. ok.
%%%-------------------------------------------------------------------
%%% @doc Email + in-app reminder before event start (Back#70).
%%% Confirmed bookings with reminder_sent=false whose event starts within
%%% REMINDER_LEAD_HOURS (default 24). Flag set before send (once).
%%% @end
%%%-------------------------------------------------------------------
-spec process_reminders() -> ok.
process_reminders() ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
LeadSec = reminder_lead_hours() * 3600,
Horizon = NowSec + LeadSec,
Candidates = mnesia:dirty_match_object(
#booking{status = confirmed, reminder_sent = false, _ = '_'}),
lists:foreach(fun(B) -> maybe_remind(B, NowSec, Horizon) end, Candidates),
ok.
maybe_remind(#booking{id = Id, event_id = EventId, user_id = UserId} = Booking,
NowSec, Horizon) ->
case core_event:get_by_id(EventId) of
{ok, #event{status = active, start_time = Start, title = Title,
calendar_id = CalId} = _Event} ->
StartSec = calendar:datetime_to_gregorian_seconds(Start),
case StartSec > NowSec andalso StartSec =< Horizon of
true ->
case core_booking:update(Id, [{reminder_sent, true}]) of
{ok, _} ->
send_reminder(UserId, Title, Start, CalId, EventId),
ok;
_ ->
ok
end;
false ->
ok
end;
_ ->
ok
end,
Booking.
send_reminder(UserId, Title, Start, CalId, EventId) ->
Path = <<"/c/", CalId/binary, "/e/", EventId/binary>>,
WhenText = format_when(Start),
case logic_notification_prefs:email_enabled(UserId) of
true ->
case core_user:get_by_id(UserId) of
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
_ = logic_email:send_booking_reminder(Email, Title, Start, Path);
_ ->
ok
end;
false ->
ok
end,
_ = logic_notification:notify_event_reminder(UserId, #{
event_id => EventId,
calendar_id => CalId,
title => Title,
start_time => Start,
when_text => WhenText
}),
_ = logic_web_push:send(
UserId,
<<"Напоминание о записи"/utf8>>,
iolist_to_binary([<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText]),
Path
),
ok.
format_when({{Y, Mo, D}, {H, Mi, _S}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
[Y, Mo, D, H, Mi])).
reminder_lead_hours() ->
case application:get_env(eventhub, reminder_lead_hours) of
{ok, N} when is_integer(N), N > 0 -> N;
_ ->
case os:getenv("REMINDER_LEAD_HOURS") of
false -> 24;
"" -> 24;
S ->
try list_to_integer(S) of
N when N > 0 -> N;
_ -> 24
catch
_:_ -> 24
end
end
end.
maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Booking, NowSec) -> maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Booking, NowSec) ->
case core_event:get_by_id(EventId) of case core_event:get_by_id(EventId) of
{ok, Event} -> {ok, Event} ->
@@ -384,7 +486,12 @@ maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Book
Now = calendar:universal_time(), Now = calendar:universal_time(),
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]); _ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
false -> false ->
_ = core_booking:update(Id, [{status, cancelled}]) case core_booking:update(Id, [{status, cancelled}]) of
{ok, _} ->
_ = logic_waitlist:maybe_promote(EventId);
_ ->
ok
end
end; end;
false -> false ->
ok ok
@@ -475,7 +582,18 @@ ensure_not_past_pending(B) ->
-spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}. -spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}.
mark_expired(BookingId) -> mark_expired(BookingId) ->
core_booking:update(BookingId, [{status, expired}]). case core_booking:get_by_id(BookingId) of
{ok, #booking{event_id = EventId}} ->
case core_booking:update(BookingId, [{status, expired}]) of
{ok, _} = Ok ->
_ = logic_waitlist:maybe_promote(EventId),
Ok;
Err ->
Err
end;
{error, _} = Err ->
Err
end.
-spec event_started(#event{}, non_neg_integer()) -> boolean(). -spec event_started(#event{}, non_neg_integer()) -> boolean().
event_started(#event{start_time = Start}, NowSec) -> event_started(#event{start_time = Start}, NowSec) ->
+39 -20
View File
@@ -162,7 +162,7 @@ calendar_has_content(CalendarId) ->
%% Получение календаря с проверкой доступа %% Получение календаря с проверкой доступа
get_calendar(UserId, CalendarId) -> get_calendar(UserId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of case core_calendar:get_by_id_or_short_name(CalendarId) of
{ok, Calendar} -> {ok, Calendar} ->
case can_access(UserId, Calendar) of case can_access(UserId, Calendar) of
true -> {ok, Calendar}; true -> {ok, Calendar};
@@ -172,9 +172,21 @@ get_calendar(UserId, CalendarId) ->
Error Error
end. end.
%% Список календарей пользователя %% Список календарей пользователя: owned shared grants
list_calendars(UserId) -> list_calendars(UserId) ->
core_calendar:list_by_owner(UserId). {ok, Owned} = core_calendar:list_by_owner(UserId),
Shared = shared_calendars(UserId),
OwnedIds = [C#calendar.id || C <- Owned],
SharedOnly = [C || C <- Shared, not lists:member(C#calendar.id, OwnedIds)],
{ok, Owned ++ SharedOnly}.
shared_calendars(UserId) ->
lists:filtermap(fun(#calendar_share{calendar_id = CalId}) ->
case core_calendar:get_by_id(CalId) of
{ok, #calendar{status = active} = Cal} -> {true, Cal};
_ -> false
end
end, core_calendar_share:list_by_user(UserId)).
%% Обновление календаря %% Обновление календаря
update_calendar(UserId, CalendarId, Updates) -> update_calendar(UserId, CalendarId, Updates) ->
@@ -227,16 +239,16 @@ apply_calendar_update(CalendarId, Calendar, ValidUpdates) ->
end end
end. end.
%% Единственный personal нельзя превратить в commercial (дневник пользователя). %% Смена type только owner. Единственный personal commercial запрещён.
gate_type_change(_UserId, #calendar{type = personal}, Updates) -> gate_type_change(UserId, #calendar{owner_id = OwnerId} = Calendar, Updates) ->
case lists:keyfind(type, 1, Updates) of case lists:keyfind(type, 1, Updates) of
{type, commercial} -> {type, _} when UserId =/= OwnerId ->
{error, access_denied};
{type, commercial} when Calendar#calendar.type =:= personal ->
{error, default_calendar}; {error, default_calendar};
_ -> _ ->
{ok, Updates} {ok, Updates}
end; end.
gate_type_change(_UserId, _Calendar, Updates) ->
{ok, Updates}.
maybe_cancel_pending_on_personal(#calendar{type = commercial}, maybe_cancel_pending_on_personal(#calendar{type = commercial},
#calendar{type = personal, id = Id}) -> #calendar{type = personal, id = Id}) ->
@@ -270,18 +282,15 @@ apply_text_results(Updates, [], []) -> Updates;
apply_text_results(Updates, [F | Fs], [T | Ts]) -> apply_text_results(Updates, [F | Fs], [T | Ts]) ->
apply_text_results(lists:keystore(F, 1, Updates, {F, T}), Fs, Ts). apply_text_results(lists:keystore(F, 1, Updates, {F, T}), Fs, Ts).
%% Удаление календаря (единственный personal удалять нельзя) %% Удаление календаря: только owner (share write/admin не удаляет)
delete_calendar(UserId, CalendarId) -> delete_calendar(UserId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of case core_calendar:get_by_id(CalendarId) of
{ok, #calendar{type = personal}} -> {ok, #calendar{type = personal}} ->
{error, default_calendar}; {error, default_calendar};
{ok, Calendar} -> {ok, #calendar{owner_id = UserId, status = active}} ->
case can_edit(UserId, Calendar) of core_calendar:delete(CalendarId);
true -> {ok, #calendar{}} ->
core_calendar:delete(CalendarId); {error, access_denied};
false ->
{error, access_denied}
end;
Error -> Error ->
Error Error
end. end.
@@ -289,16 +298,22 @@ delete_calendar(UserId, CalendarId) ->
%% Проверка прав доступа (просмотр) %% Проверка прав доступа (просмотр)
can_access(UserId, #calendar{owner_id = UserId, status = active}) -> can_access(UserId, #calendar{owner_id = UserId, status = active}) ->
true; true;
can_access(UserId, #calendar{id = CalId, type = personal, status = active}) ->
logic_calendar_share:get_rights(UserId, CalId) =/= none;
can_access(_UserId, #calendar{type = commercial, status = active}) -> can_access(_UserId, #calendar{type = commercial, status = active}) ->
true; true;
can_access(_UserId, _) -> can_access(_UserId, _) ->
false. false.
%% Проверка прав редактирования %% Проверка прав редактирования (owner или share write|admin)
can_edit(UserId, #calendar{owner_id = UserId, status = active}) -> can_edit(UserId, #calendar{owner_id = UserId, status = active}) ->
true; true;
can_edit(_UserId, #calendar{owner_id = _OwnerId}) -> can_edit(UserId, #calendar{id = CalId, status = active}) ->
false; case logic_calendar_share:get_rights(UserId, CalId) of
write -> true;
admin -> true;
_ -> false
end;
can_edit(_, _) -> can_edit(_, _) ->
false. false.
@@ -391,6 +406,10 @@ validate_settings_key(<<"default_duration_minutes">>, Val) ->
validate_default_duration(Val); validate_default_duration(Val);
validate_settings_key(<<"default_recurrence">>, Val) -> validate_settings_key(<<"default_recurrence">>, Val) ->
validate_default_recurrence(Val); validate_default_recurrence(Val);
validate_settings_key(<<"waitlist_enabled">>, Val) when is_boolean(Val) ->
ok;
validate_settings_key(<<"waitlist_enabled">>, _) ->
{error, {invalid_settings, <<"waitlist_enabled">>}};
validate_settings_key(_Unknown, _Val) -> validate_settings_key(_Unknown, _Val) ->
ok. ok.
+5 -6
View File
@@ -15,7 +15,7 @@
-spec follow(UserId :: binary(), CalendarId :: binary()) -> -spec follow(UserId :: binary(), CalendarId :: binary()) ->
{ok, #calendar_follow{}} | {error, term()}. {ok, #calendar_follow{}} | {error, term()}.
follow(UserId, CalendarId) -> follow(UserId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of case core_calendar:get_by_id_or_short_name(CalendarId) of
{error, not_found} -> {error, not_found} ->
{error, not_found}; {error, not_found};
{ok, #calendar{owner_id = UserId}} -> {ok, #calendar{owner_id = UserId}} ->
@@ -27,7 +27,7 @@ follow(UserId, CalendarId) ->
true -> true ->
case Calendar#calendar.status of case Calendar#calendar.status of
active -> active ->
core_calendar_follow:follow(CalendarId, UserId); core_calendar_follow:follow(Calendar#calendar.id, UserId);
_ -> _ ->
{error, not_found} {error, not_found}
end end
@@ -40,14 +40,13 @@ follow(UserId, CalendarId) ->
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
-spec unfollow(UserId :: binary(), CalendarId :: binary()) -> ok | {error, term()}. -spec unfollow(UserId :: binary(), CalendarId :: binary()) -> ok | {error, term()}.
unfollow(UserId, CalendarId) -> unfollow(UserId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of case core_calendar:get_by_id_or_short_name(CalendarId) of
{error, not_found} -> {error, not_found} ->
%% всё равно снимаем локальный follow, если был
core_calendar_follow:unfollow(CalendarId, UserId); core_calendar_follow:unfollow(CalendarId, UserId);
{ok, #calendar{owner_id = UserId}} -> {ok, #calendar{owner_id = UserId}} ->
{error, own_calendar}; {error, own_calendar};
{ok, _} -> {ok, Calendar} ->
core_calendar_follow:unfollow(CalendarId, UserId) core_calendar_follow:unfollow(Calendar#calendar.id, UserId)
end. end.
%%%------------------------------------------------------------------- %%%-------------------------------------------------------------------
+119
View File
@@ -0,0 +1,119 @@
%%%-------------------------------------------------------------------
%%% @doc Grants calendar_share: list / revoke / rights / mirror.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_calendar_share).
-include("records.hrl").
-export([
list/2,
revoke/3,
update_rights/4,
update_my_mirror/3,
get_rights/2,
can_manage_shares/2,
to_json/1
]).
-spec list(ActorId :: binary(), CalendarId :: binary()) ->
{ok, [#calendar_share{}]} | {error, not_found | access_denied}.
list(ActorId, CalendarId) ->
case require_can_view_shares(ActorId, CalendarId) of
{ok, _} -> {ok, core_calendar_share:list_by_calendar(CalendarId)};
Error -> Error
end.
-spec revoke(ActorId :: binary(), CalendarId :: binary(), TargetUserId :: binary()) ->
ok | {error, not_found | access_denied | term()}.
revoke(ActorId, CalendarId, TargetUserId) ->
case require_can_manage(ActorId, CalendarId) of
{ok, _} ->
case core_calendar_share:delete(CalendarId, TargetUserId) of
ok -> ok;
{error, _} = E -> E
end;
Error -> Error
end.
-spec update_rights(ActorId :: binary(), CalendarId :: binary(),
TargetUserId :: binary(), Rights :: read | write | admin) ->
{ok, #calendar_share{}} | {error, not_found | access_denied | bad_request | term()}.
update_rights(ActorId, CalendarId, TargetUserId, Rights) ->
case valid_rights(Rights) of
false -> {error, bad_request};
true ->
case require_can_manage(ActorId, CalendarId) of
{ok, _} -> core_calendar_share:update_rights(CalendarId, TargetUserId, Rights);
Error -> Error
end
end.
%% Invitee updates own mirror preference (personal shares only meaningful).
-spec update_my_mirror(UserId :: binary(), CalendarId :: binary(), Mirror :: boolean()) ->
{ok, #calendar_share{}} | {error, not_found | access_denied | term()}.
update_my_mirror(UserId, CalendarId, Mirror) when is_boolean(Mirror) ->
case core_calendar_share:get(CalendarId, UserId) of
{ok, _} -> core_calendar_share:update_mirror(CalendarId, UserId, Mirror);
{error, not_found} -> {error, not_found}
end;
update_my_mirror(_, _, _) ->
{error, bad_request}.
-spec get_rights(UserId :: binary(), CalendarId :: binary()) ->
none | read | write | admin.
get_rights(UserId, CalendarId) ->
case core_calendar_share:get(CalendarId, UserId) of
{ok, #calendar_share{rights = R}} -> R;
{error, not_found} -> none
end.
-spec can_manage_shares(UserId :: binary(), #calendar{}) -> boolean().
can_manage_shares(UserId, #calendar{owner_id = UserId, status = active}) ->
true;
can_manage_shares(UserId, #calendar{id = CalId, status = active}) ->
get_rights(UserId, CalId) =:= admin;
can_manage_shares(_, _) ->
false.
-spec to_json(#calendar_share{}) -> map().
to_json(S) ->
#{
id => S#calendar_share.id,
calendar_id => S#calendar_share.calendar_id,
user_id => S#calendar_share.user_id,
rights => S#calendar_share.rights,
mirror_to_default => S#calendar_share.mirror_to_default
}.
%%%===================================================================
%%% Internal
%%%===================================================================
require_can_view_shares(ActorId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of
{ok, #calendar{status = active} = Cal} ->
case can_manage_shares(ActorId, Cal)
orelse Cal#calendar.owner_id =:= ActorId
orelse get_rights(ActorId, CalendarId) =/= none of
true -> {ok, Cal};
false -> {error, access_denied}
end;
{ok, _} -> {error, not_found};
Error -> Error
end.
require_can_manage(ActorId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of
{ok, #calendar{status = active} = Cal} ->
case can_manage_shares(ActorId, Cal) of
true -> {ok, Cal};
false -> {error, access_denied}
end;
{ok, _} -> {error, not_found};
Error -> Error
end.
valid_rights(read) -> true;
valid_rights(write) -> true;
valid_rights(admin) -> true;
valid_rights(_) -> false.
+313
View File
@@ -0,0 +1,313 @@
%%%-------------------------------------------------------------------
%%% @doc Приглашения calendar_share (соредактор personal / заместитель commercial).
%%% @end
%%%-------------------------------------------------------------------
-module(logic_calendar_share_invite).
-include("records.hrl").
-export([
create/4,
list_outgoing/2,
cancel/3,
list_incoming/1,
accept/3,
decline/2,
accept_by_token/3,
to_json/1
]).
-define(INVITE_TTL_DAYS, 7).
-spec create(ActorId :: binary(), CalendarId :: binary(), Target :: map(),
Opts :: map()) ->
{ok, #calendar_share_invite{}} |
{error, not_found | access_denied | user_not_found | already_shared |
already_pending | bad_request | term()}.
create(ActorId, CalendarId, Target, Opts) ->
case require_can_invite(ActorId, CalendarId) of
{ok, _Cal} ->
case resolve_target(Target) of
{error, _} = E -> E;
{ok, UserId, Email} ->
case already_shared(CalendarId, UserId) of
true ->
{error, already_shared};
false ->
case core_calendar_share_invite:find_pending(CalendarId, UserId, Email) of
[_ | _] ->
{error, already_pending};
[] ->
do_create(ActorId, CalendarId, UserId, Email, Opts)
end
end
end;
Error -> Error
end.
-spec list_outgoing(ActorId :: binary(), CalendarId :: binary()) ->
{ok, [#calendar_share_invite{}]} | {error, not_found | access_denied}.
list_outgoing(ActorId, CalendarId) ->
case require_can_invite(ActorId, CalendarId) of
{ok, _} ->
List = [maybe_expire(I) || I <- core_calendar_share_invite:list_by_calendar(CalendarId)],
{ok, List};
Error -> Error
end.
-spec cancel(ActorId :: binary(), CalendarId :: binary(), InviteId :: binary()) ->
{ok, #calendar_share_invite{}} |
{error, not_found | access_denied | not_pending | term()}.
cancel(ActorId, CalendarId, InviteId) ->
case require_can_invite(ActorId, CalendarId) of
{ok, _} ->
case core_calendar_share_invite:get_by_id(InviteId) of
{ok, #calendar_share_invite{calendar_id = CalendarId, status = pending} = Inv} ->
Inv2 = maybe_expire(Inv),
case Inv2#calendar_share_invite.status of
pending -> core_calendar_share_invite:update_status(InviteId, cancelled);
_ -> {error, not_pending}
end;
{ok, #calendar_share_invite{calendar_id = CalendarId}} ->
{error, not_pending};
{ok, _} ->
{error, not_found};
{error, _} = E -> E
end;
Error -> Error
end.
-spec list_incoming(UserId :: binary()) -> {ok, [#calendar_share_invite{}]}.
list_incoming(UserId) ->
case core_user:get_by_id(UserId) of
{ok, #user{email = Email}} ->
ByUser = core_calendar_share_invite:list_by_invitee(UserId),
ByEmail = case Email of
<<>> -> [];
_ -> core_calendar_share_invite:list_by_email(Email)
end,
Merged = lists:ukeysort(1, [{I#calendar_share_invite.id, maybe_expire(I)}
|| I <- ByUser ++ ByEmail]),
{ok, [I || {_, I} <- Merged]};
{error, _} ->
{ok, [maybe_expire(I) || I <- core_calendar_share_invite:list_by_invitee(UserId)]}
end.
-spec accept(UserId :: binary(), InviteId :: binary(), Opts :: map()) ->
{ok, #calendar_share_invite{}, #calendar_share{}} |
{error, not_found | access_denied | not_pending | expired | term()}.
accept(UserId, InviteId, Opts) ->
case core_calendar_share_invite:get_by_id(InviteId) of
{ok, Inv0} ->
Inv = maybe_expire(Inv0),
case Inv#calendar_share_invite.status of
pending ->
case can_accept(UserId, Inv) of
true -> finalize_accept(UserId, Inv, Opts);
false -> {error, access_denied}
end;
expired -> {error, expired};
_ -> {error, not_pending}
end;
{error, _} = E -> E
end.
-spec decline(UserId :: binary(), InviteId :: binary()) ->
{ok, #calendar_share_invite{}} |
{error, not_found | access_denied | not_pending | expired | term()}.
decline(UserId, InviteId) ->
case core_calendar_share_invite:get_by_id(InviteId) of
{ok, Inv0} ->
Inv = maybe_expire(Inv0),
case Inv#calendar_share_invite.status of
pending ->
case can_accept(UserId, Inv) of
true -> core_calendar_share_invite:update_status(InviteId, declined);
false -> {error, access_denied}
end;
expired -> {error, expired};
_ -> {error, not_pending}
end;
{error, _} = E -> E
end.
-spec accept_by_token(UserId :: binary(), Token :: binary(), Opts :: map()) ->
{ok, #calendar_share_invite{}, #calendar_share{}} |
{error, not_found | access_denied | not_pending | expired | term()}.
accept_by_token(UserId, Token, Opts) ->
case core_calendar_share_invite:get_by_token(Token) of
{ok, #calendar_share_invite{id = Id}} ->
accept(UserId, Id, Opts);
{error, _} = E -> E
end.
-spec to_json(#calendar_share_invite{}) -> map().
to_json(I) ->
#{
id => I#calendar_share_invite.id,
calendar_id => I#calendar_share_invite.calendar_id,
inviter_id => I#calendar_share_invite.inviter_id,
invitee_user_id => null_if_empty(I#calendar_share_invite.invitee_user_id),
invitee_email => null_if_empty(I#calendar_share_invite.invitee_email),
rights => I#calendar_share_invite.rights,
status => I#calendar_share_invite.status,
created_at => handler_utils:datetime_to_iso8601(I#calendar_share_invite.created_at),
expires_at => handler_utils:datetime_to_iso8601(I#calendar_share_invite.expires_at)
}.
%%%===================================================================
%%% Internal
%%%===================================================================
do_create(ActorId, CalendarId, UserId, Email, Opts) ->
Now = calendar:universal_time(),
Expires = add_days(Now, ?INVITE_TTL_DAYS),
Rights = normalize_rights(maps:get(rights, Opts, write)),
case Rights of
bad -> {error, bad_request};
R ->
Rec = #calendar_share_invite{
id = infra_utils:generate_id(16),
calendar_id = CalendarId,
inviter_id = ActorId,
invitee_user_id = UserId,
invitee_email = Email,
rights = R,
status = pending,
token = infra_utils:generate_id(32),
created_at = Now,
expires_at = Expires
},
case core_calendar_share_invite:create(Rec) of
{ok, Created} ->
notify_invite(Created),
{ok, Created};
Error -> Error
end
end.
notify_invite(#calendar_share_invite{invitee_user_id = UserId, calendar_id = CalId} = Inv)
when UserId =/= <<>> ->
Title = <<"Приглашение к календарю"/utf8>>,
Body = <<"Вас пригласили в календарь "/utf8, CalId/binary>>,
_ = core_notification:create(UserId, calendar_share_invite, Title, Body),
logic_notification:notify_calendar_share_invite(UserId, Inv),
ok;
notify_invite(_) ->
ok.
finalize_accept(UserId, #calendar_share_invite{
id = InviteId, calendar_id = CalendarId, rights = Rights,
invitee_user_id = PrevUser}, Opts) ->
Mirror = resolve_mirror(CalendarId, Opts),
case core_calendar_share:upsert(CalendarId, UserId, Rights, Mirror) of
{ok, Share} ->
case core_calendar_share_invite:update_status(InviteId, accepted) of
{ok, Inv2} ->
Inv3 = case PrevUser of
<<>> ->
Filled = Inv2#calendar_share_invite{invitee_user_id = UserId},
ok = core_calendar_share_invite:write(Filled),
Filled;
_ -> Inv2
end,
{ok, Inv3, Share};
Error -> Error
end;
Error -> Error
end.
resolve_mirror(CalendarId, Opts) ->
Default = case core_calendar:get_by_id(CalendarId) of
{ok, #calendar{type = personal}} -> true;
_ -> false
end,
case maps:get(mirror_to_default, Opts, Default) of
true -> true;
false -> false;
_ -> Default
end.
can_accept(UserId, #calendar_share_invite{invitee_user_id = UserId})
when UserId =/= <<>> -> true;
can_accept(UserId, #calendar_share_invite{invitee_user_id = <<>>, invitee_email = Email})
when Email =/= <<>> ->
case core_user:get_by_id(UserId) of
{ok, #user{email = Email}} -> true;
_ -> false
end;
can_accept(_, _) -> false.
already_shared(_CalendarId, <<>>) -> false;
already_shared(CalendarId, UserId) ->
case core_calendar_share:get(CalendarId, UserId) of
{ok, _} -> true;
{error, not_found} -> false
end.
resolve_target(#{user_id := UserId}) when is_binary(UserId), UserId =/= <<>> ->
case core_user:get_by_id(UserId) of
{ok, #user{status = active, email = Email}} ->
{ok, UserId, Email};
{ok, #user{status = _}} ->
{error, user_not_found};
{error, _} ->
{error, user_not_found}
end;
resolve_target(#{email := Email0}) when is_binary(Email0), Email0 =/= <<>> ->
Email = string:trim(Email0),
case find_user_by_email(Email) of
{ok, #user{id = UserId, status = active}} ->
{ok, UserId, Email};
{ok, #user{status = _}} ->
{ok, <<>>, Email};
{error, not_found} ->
{ok, <<>>, Email}
end;
resolve_target(_) ->
{error, bad_request}.
find_user_by_email(Email) ->
case core_user:get_by_email(Email) of
{ok, _} = Ok -> Ok;
{error, not_found} ->
Lower = string:lowercase(Email),
case Lower =:= Email of
true -> {error, not_found};
false -> core_user:get_by_email(Lower)
end
end.
require_can_invite(ActorId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of
{ok, #calendar{status = active} = Cal} ->
case logic_calendar_share:can_manage_shares(ActorId, Cal) of
true -> {ok, Cal};
false -> {error, access_denied}
end;
{ok, _} -> {error, not_found};
Error -> Error
end.
normalize_rights(read) -> read;
normalize_rights(write) -> write;
normalize_rights(admin) -> admin;
normalize_rights(<<"read">>) -> read;
normalize_rights(<<"write">>) -> write;
normalize_rights(<<"admin">>) -> admin;
normalize_rights(_) -> bad.
maybe_expire(#calendar_share_invite{status = pending, expires_at = Exp, id = Id} = Inv) ->
case Exp < calendar:universal_time() of
true ->
_ = core_calendar_share_invite:update_status(Id, expired),
Inv#calendar_share_invite{status = expired};
false -> Inv
end;
maybe_expire(Inv) -> Inv.
add_days(DateTime, Days) ->
Sec = calendar:datetime_to_gregorian_seconds(DateTime) + Days * 86400,
calendar:gregorian_seconds_to_datetime(Sec).
null_if_empty(<<>>) -> null;
null_if_empty(V) -> V.
+2 -5
View File
@@ -10,12 +10,9 @@
-spec list(ActorId :: binary(), CalendarId :: binary()) -> -spec list(ActorId :: binary(), CalendarId :: binary()) ->
{ok, [#calendar_specialist{}]} | {error, not_found | access_denied}. {ok, [#calendar_specialist{}]} | {error, not_found | access_denied}.
list(ActorId, CalendarId) -> list(ActorId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of case logic_calendar:get_calendar(ActorId, CalendarId) of
{ok, Cal} -> {ok, Cal} ->
case logic_calendar:can_access(ActorId, Cal) of {ok, core_calendar_specialist:list_by_calendar(Cal#calendar.id)};
true -> {ok, core_calendar_specialist:list_by_calendar(CalendarId)};
false -> {error, access_denied}
end;
Error -> Error Error -> Error
end. end.
+353 -4
View File
@@ -1,11 +1,360 @@
-module(logic_email). -module(logic_email).
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2]). -export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2,
send_booking_reminder/4, send_waitlist_promoted/3]).
%% Email transport (Spec#8 / stage outbound :25 blocked):
%% EMAIL_TRANSPORT = smtp | http_api | log | auto (default auto)
%% auto: EMAIL_API_KEY set http_api; else SMTP_HOST set smtp; else log
%% SMTP: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_TLS
%% HTTP API: EMAIL_API_KEY, EMAIL_API_PROVIDER=resend|brevo (default resend),
%% optional EMAIL_API_URL; SMTP_FROM as sender
%% Tests: smtp_deliver / email_http_deliver app env mocks
-spec send_verification_email(binary(), binary()) -> ok.
send_verification_email(Email, Token) -> send_verification_email(Email, Token) ->
io:format("Sending verification email to ~s with token ~s~n", [Email, Token]). send_kind(verify, Email, abs_url(<<"/verify?token=">>, Token)).
-spec send_specialist_invite(binary(), binary()) -> ok.
send_specialist_invite(Email, Token) -> send_specialist_invite(Email, Token) ->
io:format("Sending specialist invite email to ~s with token ~s~n", [Email, Token]). send_kind(invite, Email, abs_url(<<"/invites?token=">>, Token)).
-spec send_password_reset(binary(), binary()) -> ok.
send_password_reset(Email, Token) -> send_password_reset(Email, Token) ->
io:format("Sending password reset email to ~s with token ~s~n", [Email, Token]). send_kind(reset, Email, abs_url(<<"/reset-password?token=">>, Token)).
-spec send_booking_reminder(binary(), binary(), calendar:datetime(), binary()) -> ok.
send_booking_reminder(Email, EventTitle, StartTime, EventPath) ->
Url = abs_path(EventPath),
WhenText = format_utc(StartTime),
{Subject, Plain, Html} =
logic_email_templates:render_reminder(Url, to_bin(EventTitle), WhenText),
send(to_bin(Email), Subject, Plain, Html).
-spec send_waitlist_promoted(binary(), binary(), binary()) -> ok.
send_waitlist_promoted(Email, EventTitle, EventPath) ->
Url = abs_path(EventPath),
{Subject, Plain, Html} =
logic_email_templates:render_waitlist_promoted(Url, to_bin(EventTitle)),
send(to_bin(Email), Subject, Plain, Html).
send_kind(Kind, To0, Url) ->
{Subject, Plain, Html} = logic_email_templates:render(Kind, Url),
send(to_bin(To0), Subject, Plain, Html).
send(To, Subject, Plain, Html) ->
case transport() of
log ->
logger:info(#{what => email_log_only, to => To, subject => Subject}),
ok;
smtp ->
send_smtp(To, Subject, Plain, Html);
http_api ->
send_http_api(To, Subject, Plain, Html)
end.
transport() ->
case env_bin("EMAIL_TRANSPORT", <<>>) of
<<"smtp">> -> smtp;
<<"http_api">> -> http_api;
<<"log">> -> log;
<<"auto">> -> transport_auto();
<<>> ->
case application:get_env(eventhub, email_transport) of
{ok, smtp} -> smtp;
{ok, http_api} -> http_api;
{ok, log} -> log;
_ -> transport_auto()
end;
_ ->
transport_auto()
end.
transport_auto() ->
case application:get_env(eventhub, email_transport) of
{ok, smtp} -> smtp;
{ok, http_api} -> http_api;
{ok, log} -> log;
_ ->
case api_key() of
<<>> ->
case smtp_relay() of
undefined -> log;
_ -> smtp
end;
_ ->
http_api
end
end.
%%%-------------------------------------------------------------------
%%% SMTP
%%%-------------------------------------------------------------------
send_smtp(To, Subject, Plain, Html) ->
case smtp_relay() of
undefined ->
logger:info(#{what => email_log_only, to => To, subject => Subject}),
ok;
Relay ->
From = from_addr(),
Raw = rfc822(From, To, Subject, Plain, Html),
Opts = smtp_opts(Relay),
safe_smtp_deliver(From, To, Raw, Opts)
end.
safe_smtp_deliver(From, To, Raw, Opts) ->
Fun = smtp_deliver_fun(),
try Fun(From, To, Raw, Opts) of
{ok, _} -> ok;
ok -> ok;
{error, Reason} ->
logger:warning(#{what => smtp_send_error, to => To, reason => Reason}),
ok;
Other ->
logger:warning(#{what => smtp_send_unexpected, to => To, result => Other}),
ok
catch
Class:Reason:Stack ->
logger:warning(#{what => smtp_send_crash, to => To,
class => Class, reason => Reason, stack => Stack}),
ok
end.
smtp_deliver_fun() ->
case application:get_env(eventhub, smtp_deliver) of
{ok, Fun} when is_function(Fun, 4) -> Fun;
_ -> fun default_smtp_deliver/4
end.
default_smtp_deliver(From, To, Raw, Opts) ->
gen_smtp_client:send({From, [To], Raw}, Opts).
smtp_relay() ->
case app_or_env(smtp_host, "SMTP_HOST") of
<<>> -> undefined;
Host -> binary_to_list(Host)
end.
smtp_opts(Relay) ->
Port = env_int("SMTP_PORT", 587),
Tls = tls_mode(env_bin("SMTP_TLS", <<"if_available">>)),
Base = [
{relay, Relay},
{port, Port},
{tls, Tls},
{hostname, "calentiq.com"}
],
User = env_bin("SMTP_USER", <<>>),
Pass = env_bin("SMTP_PASS", <<>>),
case User of
<<>> -> Base;
_ ->
[{username, binary_to_list(User)},
{password, binary_to_list(Pass)},
{auth, always} | Base]
end.
tls_mode(<<"always">>) -> always;
tls_mode(<<"never">>) -> never;
tls_mode(_) -> if_available.
%%%-------------------------------------------------------------------
%%% HTTP API (HTTPS transactional no outbound :25/:587)
%%% Providers: resend (default/canon), brevo (optional)
%%%-------------------------------------------------------------------
send_http_api(To, Subject, Plain, Html) ->
case api_key() of
<<>> ->
logger:warning(#{what => email_api_missing_key, to => To}),
ok;
Key ->
From = from_addr(),
safe_http_deliver(Key, From, To, Subject, Plain, Html)
end.
safe_http_deliver(Key, From, To, Subject, Plain, Html) ->
Fun = http_deliver_fun(),
try Fun(Key, From, To, Subject, Plain, Html) of
ok -> ok;
{ok, _} -> ok;
{error, Reason} ->
logger:warning(#{what => email_api_error, to => To, reason => Reason}),
ok;
Other ->
logger:warning(#{what => email_api_unexpected, to => To, result => Other}),
ok
catch
Class:Reason:Stack ->
logger:warning(#{what => email_api_crash, to => To,
class => Class, reason => Reason, stack => Stack}),
ok
end.
http_deliver_fun() ->
case application:get_env(eventhub, email_http_deliver) of
{ok, Fun} when is_function(Fun, 6) -> Fun;
_ -> fun default_http_deliver/6
end.
default_http_deliver(Key, From, To, Subject, Plain, Html) ->
_ = application:ensure_all_started(inets),
_ = application:ensure_all_started(ssl),
Provider = api_provider(),
{Url, Headers, Body} = http_api_request(Provider, Key, From, To, Subject, Plain, Html),
Request = {binary_to_list(Url), Headers, "application/json", Body},
HttpOpts = [{timeout, 15000}, {ssl, [{verify, verify_peer},
{cacerts, public_key:cacerts_get()}]}],
case httpc:request(post, Request, HttpOpts, [{body_format, binary}]) of
{ok, {{_, Code, _}, _, _Resp}} when Code >= 200, Code < 300 ->
ok;
{ok, {{_, Code, _}, _, Resp}} ->
{error, {http_status, Code, Resp}};
{error, Reason} ->
{error, Reason}
end.
http_api_request(resend, Key, From, To, Subject, Plain, Html) ->
Url = case env_bin("EMAIL_API_URL", <<>>) of
<<>> -> <<"https://api.resend.com/emails">>;
U -> U
end,
FromLine = <<"CalenTIQ <", From/binary, ">">>,
Body = jsx:encode(#{
<<"from">> => FromLine,
<<"to">> => [To],
<<"subject">> => Subject,
<<"html">> => Html,
<<"text">> => Plain
}),
Headers = [
{"authorization", "Bearer " ++ binary_to_list(Key)},
{"accept", "application/json"},
{"content-type", "application/json"}
],
{Url, Headers, Body};
http_api_request(brevo, Key, From, To, Subject, Plain, Html) ->
Url = case env_bin("EMAIL_API_URL", <<>>) of
<<>> -> <<"https://api.brevo.com/v3/smtp/email">>;
U -> U
end,
Body = jsx:encode(#{
<<"sender">> => #{
<<"name">> => <<"CalenTIQ">>,
<<"email">> => From
},
<<"to">> => [#{<<"email">> => To}],
<<"subject">> => Subject,
<<"htmlContent">> => Html,
<<"textContent">> => Plain
}),
Headers = [
{"api-key", binary_to_list(Key)},
{"accept", "application/json"},
{"content-type", "application/json"}
],
{Url, Headers, Body}.
api_provider() ->
case env_bin("EMAIL_API_PROVIDER", <<"resend">>) of
<<"resend">> -> resend;
<<"brevo">> -> brevo;
<<"sendinblue">> -> brevo;
Other ->
case application:get_env(eventhub, email_api_provider) of
{ok, resend} -> resend;
{ok, brevo} -> brevo;
_ when Other =:= <<>> -> resend;
_ ->
logger:warning(#{what => email_api_unknown_provider, provider => Other}),
resend
end
end.
api_key() ->
case application:get_env(eventhub, email_api_key) of
{ok, K} when is_binary(K), K =/= <<>> -> K;
{ok, K} when is_list(K), K =/= "" -> unicode:characters_to_binary(K);
_ -> env_bin("EMAIL_API_KEY", <<>>)
end.
%%%-------------------------------------------------------------------
%%% Shared
%%%-------------------------------------------------------------------
from_addr() ->
env_bin("SMTP_FROM", <<"noreply@calentiq.com">>).
abs_url(PathPrefix, Token) ->
Base = strip_slash(env_bin("PUBLIC_APP_URL", <<"https://stage.calentiq.com">>)),
TokenB = to_bin(Token),
<<Base/binary, PathPrefix/binary, TokenB/binary>>.
abs_path(Path0) ->
Base = strip_slash(env_bin("PUBLIC_APP_URL", <<"https://stage.calentiq.com">>)),
Path = case to_bin(Path0) of
<<$/, Rest/binary>> -> <<$/, Rest/binary>>;
Rest -> <<$/, Rest/binary>>
end,
<<Base/binary, Path/binary>>.
format_utc({{Y, Mo, D}, {H, Mi, _S}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
[Y, Mo, D, H, Mi])).
strip_slash(Url) ->
case binary:last(Url) of
$/ -> binary:part(Url, 0, byte_size(Url) - 1);
_ -> Url
end.
rfc822(From, To, Subject, Plain, Html) ->
Bound = <<"eh-alt-01">>,
iolist_to_binary([
<<"From: CalenTIQ <">>, From, <<">\r\n">>,
<<"To: ">>, To, <<"\r\n">>,
<<"Subject: ">>, encode_subject(Subject), <<"\r\n">>,
<<"MIME-Version: 1.0\r\n">>,
<<"Content-Type: multipart/alternative; boundary=\"">>, Bound, <<"\"\r\n">>,
<<"\r\n">>,
<<"--">>, Bound, <<"\r\n">>,
<<"Content-Type: text/plain; charset=utf-8\r\n">>,
<<"Content-Transfer-Encoding: 8bit\r\n\r\n">>,
Plain, <<"\r\n">>,
<<"--">>, Bound, <<"\r\n">>,
<<"Content-Type: text/html; charset=utf-8\r\n">>,
<<"Content-Transfer-Encoding: 8bit\r\n\r\n">>,
Html, <<"\r\n">>,
<<"--">>, Bound, <<"--\r\n">>
]).
app_or_env(AppKey, EnvName) ->
case application:get_env(eventhub, AppKey) of
{ok, Val} when Val =/= undefined, Val =/= <<>>, Val =/= "" -> to_bin(Val);
_ -> env_bin(EnvName, <<>>)
end.
env_bin(Name, Default) ->
case os:getenv(Name) of
false -> Default;
"" -> Default;
S -> unicode:characters_to_binary(S)
end.
env_int(Name, Default) ->
case os:getenv(Name) of
false -> Default;
"" -> Default;
S ->
try list_to_integer(S) of
N -> N
catch
_:_ -> Default
end
end.
to_bin(B) when is_binary(B) -> B;
to_bin(L) when is_list(L) -> unicode:characters_to_binary(L).
encode_subject(Bin) ->
<<"=?UTF-8?B?", (base64:encode(Bin))/binary, "?=">>.
+96
View File
@@ -0,0 +1,96 @@
-module(logic_email_templates).
-export([render/2, render_reminder/3, render_waitlist_promoted/2]).
%% Kind: verify | invite | reset
%% Non-ASCII binaries MUST use /utf8 (OTP truncates codepoints to 8 bits otherwise).
-spec render(verify | invite | reset, binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
render(Kind, Url) ->
{Subject, Heading, Lead, Cta, Foot, Pre} = copy(Kind),
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
%% Booking reminder (Back#70): title + when text in lead.
-spec render_reminder(binary(), binary(), binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
render_reminder(Url, Title, WhenText) ->
Subject = <<"CalenTIQ: напоминание о записи"/utf8>>,
Heading = <<"Напоминание о записи"/utf8>>,
Lead = iolist_to_binary([
<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText, <<" (UTC).">>
]),
Cta = <<"Открыть запись"/utf8>>,
Foot = <<"Вы получили письмо, потому что записаны на это событие в CalenTIQ."/utf8>>,
Pre = <<"Напоминание о записи в CalenTIQ"/utf8>>,
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
-spec render_waitlist_promoted(binary(), binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
render_waitlist_promoted(Url, Title) ->
Subject = <<"CalenTIQ: место в листе ожидания"/utf8>>,
Heading = <<"Для вас освободилось место"/utf8>>,
Lead = iolist_to_binary([
<<"Вы были в листе ожидания на «"/utf8>>, Title,
<<"». Мы записали вас — откройте событие в CalenTIQ."/utf8>>
]),
Cta = <<"Открыть событие"/utf8>>,
Foot = <<"Если запись вам не нужна, отмените её в приложении."/utf8>>,
Pre = <<"Место из листа ожидания CalenTIQ"/utf8>>,
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre) ->
Plain = iolist_to_binary([Heading, <<"\n\n">>, Lead, <<"\n\n">>, Url, <<"\n">>]),
Html = fill(layout(), [
{<<"{{preheader}}">>, Pre},
{<<"{{heading}}">>, Heading},
{<<"{{lead}}">>, Lead},
{<<"{{cta_url}}">>, Url},
{<<"{{cta_label}}">>, Cta},
{<<"{{footnote}}">>, Foot}
]),
{Subject, Plain, Html}.
copy(verify) ->
{<<"CalenTIQ: подтвердите email"/utf8>>,
<<"Подтвердите email"/utf8>>,
<<"Один календарь. Три режима для вашего времени. Нажмите кнопку, чтобы активировать аккаунт."/utf8>>,
<<"Подтвердить email"/utf8>>,
<<"Если вы не регистрировались в CalenTIQ, просто проигнорируйте письмо."/utf8>>,
<<"Подтвердите email в CalenTIQ"/utf8>>};
copy(invite) ->
{<<"CalenTIQ: приглашение специалиста"/utf8>>,
<<"Вас пригласили как специалиста"/utf8>>,
<<"Откройте приглашение, чтобы принять или отклонить его в CalenTIQ."/utf8>>,
<<"Открыть приглашение"/utf8>>,
<<"Письмо отправлено, потому что владелец календаря указал этот адрес."/utf8>>,
<<"Приглашение специалиста в CalenTIQ"/utf8>>};
copy(reset) ->
{<<"CalenTIQ: сброс пароля"/utf8>>,
<<"Сброс пароля"/utf8>>,
<<"Если это были вы — задайте новый пароль по кнопке. Ссылка одноразовая."/utf8>>,
<<"Сменить пароль"/utf8>>,
<<"Если вы не запрашивали сброс, проигнорируйте письмо — пароль не изменится."/utf8>>,
<<"Сброс пароля CalenTIQ"/utf8>>}.
layout() ->
case read_priv() of
{ok, Bin} -> Bin;
{error, _} -> builtin_layout()
end.
read_priv() ->
case code:priv_dir(eventhub) of
{error, _} = E -> E;
Dir ->
Path = filename:join([Dir, "email", "layout.html"]),
file:read_file(Path)
end.
fill(Html, []) -> Html;
fill(Html, [{Needle, Val} | Rest]) ->
fill(binary:replace(Html, Needle, Val, [global]), Rest).
builtin_layout() ->
<<"<!DOCTYPE html><html><body style=\"font-family:Arial,sans-serif;color:#324A5F\">"
"<p><b>CalenTIQ</b></p><h1>{{heading}}</h1><p>{{lead}}</p>"
"<p><a href=\"{{cta_url}}\" style=\"background:#FF6A3D;color:#fff\">{{cta_label}}</a></p>"
"<p>{{cta_url}}</p><p>{{footnote}}</p></body></html>">>.
+25 -5
View File
@@ -19,13 +19,16 @@ create_event(UserId, CalendarId, Title, StartTime, Duration, Description) ->
{ok, Calendar} -> {ok, Calendar} ->
case logic_calendar:can_edit(UserId, Calendar) of case logic_calendar:can_edit(UserId, Calendar) of
true -> true ->
case logic_month_archive:assert_writable(StartTime) of
{error, archived} -> {error, archived};
ok ->
case validate_event_time(StartTime, UserId) of case validate_event_time(StartTime, UserId) of
ok -> ok ->
case logic_automoderation:evaluate_texts([Title, Description]) of case logic_automoderation:evaluate_texts([Title, Description]) of
{reject, Words} -> {reject, Words} ->
{error, {content_banned, Words}}; {error, {content_banned, Words}};
{ok, Action, [Title2, Desc2], Words} -> {ok, Action, [Title2, Desc2], Words} ->
case core_event:create(CalendarId, Title2, StartTime, Duration) of case core_event:create(Calendar#calendar.id, Title2, StartTime, Duration) of
{ok, Event} -> {ok, Event} ->
case Desc2 of case Desc2 of
<<>> -> ok; <<>> -> ok;
@@ -39,6 +42,7 @@ create_event(UserId, CalendarId, Title, StartTime, Duration, Description) ->
end; end;
{error, _} = Error -> {error, _} = Error ->
Error Error
end
end; end;
false -> false ->
{error, access_denied} {error, access_denied}
@@ -56,6 +60,9 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De
{ok, Calendar} -> {ok, Calendar} ->
case logic_calendar:can_edit(UserId, Calendar) of case logic_calendar:can_edit(UserId, Calendar) of
true -> true ->
case logic_month_archive:assert_writable(StartTime) of
{error, archived} -> {error, archived};
ok ->
case validate_event_time(StartTime, UserId) of case validate_event_time(StartTime, UserId) of
ok -> ok ->
case logic_recurrence:validate_rrule(RRule) of case logic_recurrence:validate_rrule(RRule) of
@@ -64,7 +71,7 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De
{reject, Words} -> {reject, Words} ->
{error, {content_banned, Words}}; {error, {content_banned, Words}};
{ok, Action, [Title2, Desc2], Words} -> {ok, Action, [Title2, Desc2], Words} ->
case core_event:create_recurring(CalendarId, Title2, StartTime, Duration, RRule) of case core_event:create_recurring(Calendar#calendar.id, Title2, StartTime, Duration, RRule) of
{ok, Event} -> {ok, Event} ->
case Desc2 of case Desc2 of
<<>> -> ok; <<>> -> ok;
@@ -81,6 +88,7 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De
end; end;
{error, _} = Error -> {error, _} = Error ->
Error Error
end
end; end;
false -> false ->
{error, access_denied} {error, access_denied}
@@ -184,8 +192,8 @@ get_event(UserId, EventId) ->
%% Список событий календаря %% Список событий календаря
list_events(UserId, CalendarId) -> list_events(UserId, CalendarId) ->
case logic_calendar:get_calendar(UserId, CalendarId) of case logic_calendar:get_calendar(UserId, CalendarId) of
{ok, _} -> {ok, Calendar} ->
core_event:list_by_calendar(CalendarId); core_event:list_by_calendar(Calendar#calendar.id);
Error -> Error ->
Error Error
end. end.
@@ -202,6 +210,13 @@ update_event(UserId, EventId, Updates) ->
{error, _} = E -> {error, _} = E ->
E; E;
ok -> ok ->
NewStart = proplists:get_value(start_time, Updates, Event#event.start_time),
case logic_month_archive:assert_writable(Event#event.start_time) of
{error, archived} -> {error, archived};
ok ->
case logic_month_archive:assert_writable(NewStart) of
{error, archived} -> {error, archived};
ok ->
ValidUpdates = validate_updates(Updates, UserId), ValidUpdates = validate_updates(Updates, UserId),
Title = proplists:get_value(title, ValidUpdates, Event#event.title), Title = proplists:get_value(title, ValidUpdates, Event#event.title),
Desc = proplists:get_value(description, ValidUpdates, Event#event.description), Desc = proplists:get_value(description, ValidUpdates, Event#event.description),
@@ -231,6 +246,8 @@ update_event(UserId, EventId, Updates) ->
Error Error
end end
end end
end
end
end; end;
false -> false ->
{error, access_denied} {error, access_denied}
@@ -250,7 +267,10 @@ delete_event(UserId, EventId) ->
{ok, Calendar} -> {ok, Calendar} ->
case logic_calendar:can_edit(UserId, Calendar) of case logic_calendar:can_edit(UserId, Calendar) of
true -> true ->
core_event:delete(EventId); case logic_month_archive:assert_writable(Event#event.start_time) of
{error, archived} -> {error, archived};
ok -> core_event:delete(EventId)
end;
false -> false ->
{error, access_denied} {error, access_denied}
end; end;
+404
View File
@@ -0,0 +1,404 @@
%%%-------------------------------------------------------------------
%%% @doc Прокси к OpenCage API: suggest / geocode / reverse.
%%% Кэш и rate-limit в ETS. Без OPENCAGE_API_KEY {error, unavailable}.
%%% Глобальный троттлинг апстрима (free-тариф: 1 rps, 2500/сутки).
%%% @end
%%%-------------------------------------------------------------------
-module(logic_geo).
-export([suggest/3, geocode/3, reverse/4, ensure/0]).
-export([format_address/1]).
-define(CACHE, eventhub_geo_cache).
-define(RL, eventhub_geo_rl).
-define(UP, eventhub_geo_upstream).
-define(MIN_Q, 3).
-define(CACHE_TTL_SEC, 604800).
-define(RL_WINDOW_SEC, 60).
-define(RL_SUGGEST, 40).
-define(RL_WRITE, 20).
-define(DEFAULT_URL, <<"https://api.opencagedata.com/geocode/v1/json">>).
-spec ensure() -> ok.
ensure() ->
case ets:info(?CACHE) of
undefined ->
ets:new(?CACHE, [named_table, public, set, {read_concurrency, true}]);
_ -> ok
end,
case ets:info(?RL) of
undefined ->
ets:new(?RL, [named_table, public, set, {write_concurrency, true}]);
_ -> ok
end,
case ets:info(?UP) of
undefined ->
ets:new(?UP, [named_table, public, set]);
_ -> ok
end,
ok.
-spec suggest(binary(), binary(), binary()) ->
{ok, [map()]} | {error, invalid_query | rate_limited | unavailable}.
suggest(Q, Lang, RlKey) ->
ensure(),
case normalize_q(Q) of
{error, invalid_query} = E -> E;
{ok, Qn} ->
case allow(RlKey, ?RL_SUGGEST) of
false -> {error, rate_limited};
true ->
LangN = lang(Lang),
CacheKey = {suggest, Qn, LangN},
case cache_get(CacheKey) of
{ok, Hits} -> {ok, Hits};
miss ->
case opencage_get(forward_q(Qn, LangN, <<"8">>)) of
{ok, Body} ->
Hits = results_to_hits(Body),
cache_put(CacheKey, Hits),
{ok, Hits};
{error, rate_limited} -> {error, rate_limited};
{error, _} -> {error, unavailable}
end
end
end
end.
-spec geocode(binary(), binary(), binary()) ->
{ok, map()} | {error, invalid_query | not_found | rate_limited | unavailable}.
geocode(Q, Lang, RlKey) ->
ensure(),
case normalize_q(Q) of
{error, invalid_query} = E -> E;
{ok, Qn} ->
case allow(RlKey, ?RL_WRITE) of
false -> {error, rate_limited};
true ->
LangN = lang(Lang),
CacheKey = {geocode, Qn, LangN},
case cache_get(CacheKey) of
{ok, Hit} -> {ok, Hit};
miss ->
case opencage_get(forward_q(Qn, LangN, <<"1">>)) of
{ok, Body} ->
case results_to_hits(Body) of
[Hit | _] ->
Out = Hit#{<<"source">> => <<"opencage">>},
cache_put(CacheKey, Out),
{ok, Out};
[] -> {error, not_found}
end;
{error, rate_limited} -> {error, rate_limited};
{error, _} -> {error, unavailable}
end
end
end
end.
-spec reverse(number(), number(), binary(), binary()) ->
{ok, map()} | {error, invalid_query | rate_limited | unavailable}.
reverse(Lat, Lon, Lang, RlKey) when is_number(Lat), is_number(Lon) ->
ensure(),
case {valid_lat(Lat), valid_lon(Lon)} of
{true, true} ->
case allow(RlKey, ?RL_WRITE) of
false -> {error, rate_limited};
true ->
LangN = lang(Lang),
LatR = round_coord(Lat),
LonR = round_coord(Lon),
CacheKey = {reverse, LatR, LonR, LangN},
case cache_get(CacheKey) of
{ok, Hit} -> {ok, Hit};
miss ->
LatB = float_to_bin(LatR),
LonB = float_to_bin(LonR),
Qs = [{<<"q">>, <<LatB/binary, ",", LonB/binary>>},
{<<"language">>, LangN}],
case opencage_get(Qs) of
{ok, Body} ->
Hit = case results_to_hits(Body) of
[H | _] -> H#{<<"source">> => <<"opencage">>};
[] ->
#{<<"address">> => fallback_addr(LatR, LonR),
<<"lat">> => LatR,
<<"lon">> => LonR,
<<"source">> => <<"opencage">>}
end,
cache_put(CacheKey, Hit),
{ok, Hit};
{error, rate_limited} -> {error, rate_limited};
{error, _} -> {error, unavailable}
end
end
end;
_ -> {error, invalid_query}
end;
reverse(_, _, _, _) ->
{error, invalid_query}.
%% internals
normalize_q(Q) when is_binary(Q) ->
T = string:trim(Q),
case string:length(T) >= ?MIN_Q of
true -> {ok, T};
false -> {error, invalid_query}
end;
normalize_q(_) ->
{error, invalid_query}.
lang(<<>>) -> <<"ru">>;
lang(undefined) -> <<"ru">>;
lang(L) when is_binary(L), byte_size(L) >= 2 -> binary:part(L, 0, 2);
lang(_) -> <<"ru">>.
valid_lat(Lat) -> is_number(Lat) andalso Lat >= -90 andalso Lat =< 90.
valid_lon(Lon) -> is_number(Lon) andalso Lon >= -180 andalso Lon =< 180.
round_coord(N) when is_integer(N) -> float(N);
round_coord(N) when is_float(N) -> round(N * 100000) / 100000.
float_to_bin(N) when is_integer(N) -> integer_to_binary(N);
float_to_bin(N) when is_float(N) ->
iolist_to_binary(io_lib:format("~.6f", [N])).
fallback_addr(Lat, Lon) ->
iolist_to_binary(io_lib:format("~.5f, ~.5f", [float(Lat), float(Lon)])).
allow(Key, Limit) ->
Now = erlang:monotonic_time(second),
case ets:lookup(?RL, Key) of
[{Key, Count, Start}] when Now - Start < ?RL_WINDOW_SEC ->
case Count < Limit of
true ->
ets:update_counter(?RL, Key, {2, 1}),
true;
false -> false
end;
_ ->
ets:insert(?RL, {Key, 1, Now}),
true
end.
cache_get(Key) ->
Now = erlang:system_time(second),
case ets:lookup(?CACHE, Key) of
[{Key, Exp, Val}] when Exp > Now -> {ok, Val};
[_] -> ets:delete(?CACHE, Key), miss;
[] -> miss
end.
cache_put(Key, Val) ->
Exp = erlang:system_time(second) + ?CACHE_TTL_SEC,
ets:insert(?CACHE, {Key, Exp, Val}),
ok.
%% Forward-запрос: q + language + limit (+ countrycode, если задан).
forward_q(Q, LangN, Limit) ->
Qs = [{<<"q">>, Q}, {<<"language">>, LangN}, {<<"limit">>, Limit}],
case countrycode() of
<<>> -> Qs;
CC -> [{<<"countrycode">>, CC} | Qs]
end.
api_key() ->
case os:getenv("OPENCAGE_API_KEY") of
false -> <<>>;
"" -> <<>>;
K -> list_to_binary(string:trim(K))
end.
base_url() ->
case os:getenv("OPENCAGE_URL") of
false -> ?DEFAULT_URL;
"" -> ?DEFAULT_URL;
Url -> list_to_binary(string:trim(Url))
end.
countrycode() ->
case os:getenv("OPENCAGE_COUNTRYCODE") of
false -> <<>>;
"" -> <<>>;
CC -> list_to_binary(string:trim(CC))
end.
timeout_ms() ->
case os:getenv("OPENCAGE_TIMEOUT_MS") of
false -> 3000;
"" -> 3000;
S ->
try list_to_integer(S) of
N when N > 0 -> N;
_ -> 3000
catch
_:_ -> 3000
end
end.
%% 0 троттлинг выключен (платный тариф); по умолчанию 1 rps (free).
throttle_rps() ->
case os:getenv("OPENCAGE_THROTTLE_RPS") of
false -> 1;
"" -> 1;
S ->
try list_to_integer(S) of
N when N >= 0 -> N;
_ -> 1
catch
_:_ -> 1
end
end.
throttle_allow() ->
case throttle_rps() of
0 -> true;
Rps when Rps > 0 ->
NowMs = erlang:monotonic_time(millisecond),
MinGap = 1000 div Rps,
case ets:lookup(?UP, last) of
[{last, Prev}] when NowMs - Prev < MinGap -> false;
_ ->
ets:insert(?UP, {last, NowMs}),
true
end
end.
opencage_get(Qs) ->
case api_key() of
<<>> -> {error, unavailable};
Key ->
case throttle_allow() of
false -> {error, rate_limited};
true ->
Full = [{<<"key">>, Key}, {<<"no_annotations">>, <<"1">>} | Qs],
Query = uri_string:compose_query(Full),
Url = binary_to_list(<<(strip_slash(base_url()))/binary,
$?, Query/binary>>),
http_get(Url)
end
end.
strip_slash(B) ->
case binary:last(B) of
$/ -> binary:part(B, 0, byte_size(B) - 1);
_ -> B
end.
http_get(Url) ->
Fun = case application:get_env(eventhub, geo_http_get) of
{ok, F} when is_function(F, 1) -> F;
_ -> fun default_http_get/1
end,
Fun(Url).
default_http_get(Url) ->
_ = application:ensure_all_started(inets),
Headers = [{"user-agent", "EventHub/1.0 (geo; +https://calentiq.com)"}],
HttpOpts = [{timeout, timeout_ms()}],
case httpc:request(get, {Url, Headers}, HttpOpts, [{body_format, binary}]) of
{ok, {{_, Code, _}, _, Body}} when Code >= 200, Code < 300, is_binary(Body) ->
{ok, Body};
{ok, {{_, Code, _}, _, _}} ->
{error, {http_status, Code}};
{error, Reason} ->
{error, Reason}
end.
results_to_hits(Body) when is_binary(Body) ->
try jsx:decode(Body, [return_maps]) of
#{<<"results">> := Results} when is_list(Results) ->
lists:filtermap(fun result_to_hit/1, Results);
_ -> []
catch
_:_ -> []
end.
result_to_hit(#{<<"geometry">> := #{<<"lat">> := Lat, <<"lng">> := Lng}} = R) ->
{true, #{
<<"address">> => address_of(R),
<<"lat">> => to_num(Lat),
<<"lon">> => to_num(Lng)
}};
result_to_hit(_) ->
false.
address_of(R) ->
case bin(maps:get(<<"formatted">>, R, <<>>)) of
<<>> ->
C = maps:get(<<"components">>, R, #{}),
Name = bin(maps:get(<<"name">>, C, <<>>)),
House = bin(maps:get(<<"house_number">>, C, <<>>)),
Road = bin(maps:get(<<"road">>, C, <<>>)),
StreetLine = case {House, Road} of
{<<>>, Rd} -> Rd;
{H, <<>>} -> H;
{H, Rd} -> <<H/binary, " ", Rd/binary>>
end,
City = first_nonempty([
maps:get(<<"city">>, C, <<>>),
maps:get(<<"town">>, C, <<>>),
maps:get(<<"village">>, C, <<>>)
]),
Country = bin(maps:get(<<"country">>, C, <<>>)),
Parts = [P || P <- [Name, StreetLine, City, Country], P =/= <<>>],
case unique_keep(Parts) of
[] -> <<"">>;
Ps -> join_comma(Ps)
end;
Addr -> Addr
end.
to_num(N) when is_integer(N) -> float(N);
to_num(N) when is_float(N) -> N;
to_num(_) -> 0.0.
-spec format_address(map()) -> binary().
format_address(Props) when is_map(Props) ->
Name = bin(maps:get(<<"name">>, Props, <<>>)),
House = bin(maps:get(<<"housenumber">>, Props, <<>>)),
Street = bin(maps:get(<<"street">>, Props, <<>>)),
StreetLine = case {House, Street} of
{<<>>, S} -> S;
{H, <<>>} -> H;
{H, S} -> <<H/binary, " ", S/binary>>
end,
City = first_nonempty([
maps:get(<<"city">>, Props, <<>>),
maps:get(<<"town">>, Props, <<>>),
maps:get(<<"village">>, Props, <<>>),
maps:get(<<"locality">>, Props, <<>>)
]),
Country = bin(maps:get(<<"country">>, Props, <<>>)),
Parts0 = [Name, StreetLine, City, Country],
Parts = [P || P <- Parts0, P =/= <<>>],
Unique = unique_keep(Parts),
case Unique of
[] -> <<"">>;
_ -> join_comma(Unique)
end.
bin(B) when is_binary(B) -> string:trim(B);
bin(I) when is_integer(I) -> integer_to_binary(I);
bin(_) -> <<>>.
first_nonempty([]) -> <<>>;
first_nonempty([H | T]) ->
B = bin(H),
case B of
<<>> -> first_nonempty(T);
_ -> B
end.
unique_keep(List) ->
lists:reverse(lists:foldl(fun(X, Acc) ->
case lists:member(X, Acc) of
true -> Acc;
false -> [X | Acc]
end
end, [], List)).
join_comma([H]) -> H;
join_comma([H | T]) ->
Rest = join_comma(T),
<<H/binary, ", ", Rest/binary>>.
+254
View File
@@ -0,0 +1,254 @@
%%%-------------------------------------------------------------------
%%% Calendar month snapshots (Back#76). Same node, no extra BEAM.
%%% Bookings stay in hot (inbox). Events of closed months move to snapshot/file.
%%%-------------------------------------------------------------------
-module(logic_month_archive).
-include("records.hrl").
-export([
grace_days/0,
warm_months/0,
month_key/2,
month_is_closed/1,
month_is_closed/2,
assert_writable/1,
events_json_in_range/3,
snapshot_month/3,
run_once/0,
run_once/1
]).
-define(DEFAULT_GRACE, 7).
-define(DEFAULT_WARM, 3).
grace_days() -> env_int("ARCHIVE_GRACE_DAYS", ?DEFAULT_GRACE).
warm_months() -> env_int("ARCHIVE_WARM_MONTHS", ?DEFAULT_WARM).
-spec month_key(integer(), integer()) -> binary().
month_key(Year, Month) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B", [Year, Month])).
-spec month_is_closed(calendar:datetime()) -> boolean().
month_is_closed({{Y, M, _}, _}) -> month_is_closed(Y, M).
-spec month_is_closed(integer(), integer()) -> boolean().
month_is_closed(Year, Month) ->
{{CY, CM, CD}, _} = calendar:universal_time(),
{NY, NM} = next_month(Year, Month),
GraceEnd = add_days({NY, NM, 1}, grace_days()),
{CY, CM, CD} >= GraceEnd.
-spec assert_writable(calendar:datetime()) -> ok | {error, archived}.
assert_writable(StartTime) ->
case month_is_closed(StartTime) of
true -> {error, archived};
false -> ok
end.
%% Snapshot JSON maps overlapping [From, To] (inclusive). Missing snapshot [].
-spec events_json_in_range(binary(), calendar:datetime(), calendar:datetime()) -> [map()].
events_json_in_range(CalendarId, From, To) ->
Months = months_covering(From, To),
lists:flatmap(
fun({Y, M}) ->
case month_is_closed(Y, M) of
false -> [];
true ->
lists:filter(
fun(Map) -> json_in_range(Map, From, To) end,
load_events(CalendarId, month_key(Y, M)))
end
end, Months).
-spec snapshot_month(binary(), integer(), integer()) -> ok | {error, term()}.
snapshot_month(CalendarId, Year, Month) ->
Key = month_key(Year, Month),
Id = {CalendarId, Key},
case mnesia:dirty_read(month_snapshot, Id) of
[#month_snapshot{}] ->
delete_hot_for_month(CalendarId, Year, Month),
maybe_spill(CalendarId, Key),
ok;
[] ->
Events = hot_events_in_month(CalendarId, Year, Month),
Payload = encode_events(Events),
Rec = #month_snapshot{
id = Id,
calendar_id = CalendarId,
year_month = Key,
payload = Payload,
storage = mnesia,
file_rel = <<>>,
created_at = calendar:universal_time()
},
WriteDel = fun() ->
ok = mnesia:write(Rec),
lists:foreach(fun(E) -> mnesia:delete({event, E#event.id}) end,
deletable_events(Events)),
ok
end,
case mnesia:transaction(WriteDel) of
{atomic, ok} ->
maybe_spill(CalendarId, Key),
ok;
{aborted, Reason} ->
{error, Reason}
end
end.
-spec run_once() -> non_neg_integer().
run_once() ->
run_once(calendar:universal_time()).
-spec run_once(calendar:datetime()) -> non_neg_integer().
run_once(_Now) ->
Cals = mnesia:dirty_all_keys(calendar),
lists:foldl(fun(CalId, Acc) -> Acc + snapshot_due(CalId) end, 0, Cals).
%%%===================================================================
snapshot_due(CalendarId) ->
Events = case catch mnesia:dirty_index_read(event, CalendarId, #event.calendar_id) of
List when is_list(List) -> List;
_ -> []
end,
Months = lists:usort([ym(E#event.start_time) || E <- Events]),
lists:foldl(
fun({Y, M}, N) ->
case month_is_closed(Y, M) of
true ->
case snapshot_month(CalendarId, Y, M) of
ok -> N + 1;
_ -> N
end;
false -> N
end
end, 0, Months).
ym({{Y, M, _}, _}) -> {Y, M};
ym(_) -> {1970, 1}.
hot_events_in_month(CalendarId, Year, Month) ->
{Start, End} = month_bounds(Year, Month),
case mnesia:dirty_index_read(event, CalendarId, #event.calendar_id) of
List when is_list(List) ->
[E || E <- List, E#event.start_time >= Start, E#event.start_time =< End];
_ -> []
end.
deletable_events(Events) ->
[E || E <- Events, not keep_in_hot(E)].
delete_hot_for_month(CalendarId, Year, Month) ->
Events = deletable_events(hot_events_in_month(CalendarId, Year, Month)),
lists:foreach(fun(E) -> mnesia:dirty_delete({event, E#event.id}) end, Events).
keep_in_hot(#event{event_type = recurring, is_instance = false}) -> true;
keep_in_hot(_) -> false.
encode_events(Events) ->
Maps = [handler_utils:event_to_json(E) || E <- Events, not keep_in_hot(E)],
zlib:gzip(jsx:encode(#{<<"v">> => 1, <<"events">> => Maps})).
load_events(CalendarId, YearMonth) ->
Id = {CalendarId, YearMonth},
case mnesia:dirty_read(month_snapshot, Id) of
[#month_snapshot{storage = file, file_rel = Rel}] when Rel =/= <<>> ->
decode_file(Rel);
[#month_snapshot{payload = Bin}] when is_binary(Bin), Bin =/= <<>> ->
decode_bin(Bin);
_ -> []
end.
decode_file(Rel) ->
Path = filename:join(logic_upload:upload_dir(), binary_to_list(Rel)),
case file:read_file(Path) of
{ok, Bin} -> decode_bin(Bin);
_ -> []
end.
decode_bin(Bin) ->
try jsx:decode(zlib:gunzip(Bin), [return_maps]) of
#{<<"events">> := List} when is_list(List) -> List;
_ -> []
catch
_:_ -> []
end.
maybe_spill(CalendarId, YearMonth) ->
Warm = warm_months(),
Id = {CalendarId, YearMonth},
case mnesia:dirty_index_read(month_snapshot, CalendarId, #month_snapshot.calendar_id) of
List when is_list(List), length(List) > Warm ->
Sorted = lists:sort(
fun(#month_snapshot{year_month = A}, #month_snapshot{year_month = B}) ->
A =< B
end, List),
{Old, Keep} = lists:split(length(Sorted) - Warm, Sorted),
_ = Keep,
lists:foreach(fun(Rec) -> spill_one(Rec) end, Old),
ok;
_ ->
_ = Id,
ok
end.
spill_one(#month_snapshot{storage = file}) -> ok;
spill_one(#month_snapshot{id = Id, calendar_id = Cal, year_month = YM,
payload = Payload} = Rec) ->
Rel = filename:join(["archive", binary_to_list(Cal), binary_to_list(YM) ++ ".json.gz"]),
Abs = filename:join(logic_upload:upload_dir(), Rel),
ok = filelib:ensure_dir(Abs),
case file:write_file(Abs, Payload) of
ok ->
mnesia:dirty_write(Rec#month_snapshot{
payload = <<>>,
storage = file,
file_rel = list_to_binary(Rel)
}),
ok;
_ ->
_ = Id,
ok
end.
json_in_range(Map, From, To) ->
case maps:get(<<"start_time">>, Map, maps:get(start_time, Map, undefined)) of
undefined -> false;
Iso when is_binary(Iso) ->
case handler_utils:parse_datetime(Iso) of
{ok, Dt} -> Dt >= From andalso Dt =< To;
_ -> false
end;
_ -> false
end.
months_covering({{Y1, M1, _}, _}, {{Y2, M2, _}, _}) ->
months_covering1({Y1, M1}, {Y2, M2}, []).
months_covering1(Cur, End, Acc) when Cur > End -> lists:reverse(Acc);
months_covering1({Y, M} = Cur, End, Acc) ->
months_covering1(next_month(Y, M), End, [Cur | Acc]).
next_month(Y, 12) -> {Y + 1, 1};
next_month(Y, M) -> {Y, M + 1}.
month_bounds(Year, Month) ->
Last = calendar:last_day_of_the_month(Year, Month),
{{{Year, Month, 1}, {0, 0, 0}}, {{Year, Month, Last}, {23, 59, 59}}}.
add_days({Y, M, D}, Days) ->
Greg = calendar:date_to_gregorian_days(Y, M, D) + Days,
calendar:gregorian_days_to_date(Greg).
env_int(Name, Default) ->
case os:getenv(Name) of
false -> Default;
"" -> Default;
S ->
try list_to_integer(S) of
N when N > 0 -> N;
_ -> Default
catch
_:_ -> Default
end
end.
+26 -1
View File
@@ -5,7 +5,9 @@
-export([notify_calendar_update/1]). -export([notify_calendar_update/1]).
-export([notify_event_update/1]). -export([notify_event_update/1]).
-export([notify_admin/2]). -export([notify_admin/2]).
-export([notify_specialist_invite/2]). -export([notify_specialist_invite/2, notify_calendar_share_invite/2]).
-export([notify_event_reminder/2]).
-export([notify_waitlist_promoted/2]).
%% Уведомление о бронировании %% Уведомление о бронировании
notify_booking(UserId, Booking) -> notify_booking(UserId, Booking) ->
@@ -45,6 +47,29 @@ notify_specialist_invite(UserId, Invite) ->
}, },
broadcast_to_user(UserId, specialist_invite, Data). broadcast_to_user(UserId, specialist_invite, Data).
%% In-app / WS: приглашение calendar_share
notify_calendar_share_invite(UserId, Invite) ->
Data = #{
invite_id => Invite#calendar_share_invite.id,
calendar_id => Invite#calendar_share_invite.calendar_id,
rights => Invite#calendar_share_invite.rights,
status => Invite#calendar_share_invite.status
},
broadcast_to_user(UserId, calendar_share_invite, Data).
%% In-app / WS: напоминание о записи (Back#70)
notify_event_reminder(UserId, #{title := Title, when_text := WhenText} = Data) ->
NTitle = <<"Напоминание о записи"/utf8>>,
Body = iolist_to_binary([<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText, <<" (UTC)">>]),
_ = core_notification:create(UserId, event_reminder, NTitle, Body),
broadcast_to_user(UserId, event_reminder, Data#{user_id => UserId}).
notify_waitlist_promoted(UserId, #{title := Title} = Data) ->
NTitle = <<"Место из листа ожидания"/utf8>>,
Body = iolist_to_binary([<<"Вас записали на «"/utf8>>, Title, <<"»."/utf8>>]),
_ = core_notification:create(UserId, waitlist_promoted, NTitle, Body),
broadcast_to_user(UserId, waitlist_promoted, Data#{user_id => UserId}).
%% Уведомление для администраторов %% Уведомление для администраторов
notify_admin(Type, Data) -> notify_admin(Type, Data) ->
Message = {admin_notification, Type, Data}, Message = {admin_notification, Type, Data},
+78
View File
@@ -0,0 +1,78 @@
%%%-------------------------------------------------------------------
%%% @doc Notification channel prefs in user.preferences (Back#75).
%%% Keys: notify_email, notify_push (boolean; missing true).
%%% @end
%%%-------------------------------------------------------------------
-module(logic_notification_prefs).
-export([get_prefs/1, put_prefs/2, email_enabled/1, push_enabled/1]).
-include("records.hrl").
-spec get_prefs(binary()) -> {ok, #{email := boolean(), push := boolean()}} | {error, not_found}.
get_prefs(UserId) ->
case core_user:get_by_id(UserId) of
{ok, #user{preferences = Prefs}} ->
{ok, #{
email => pref_bool(Prefs, <<"notify_email">>, true),
push => pref_bool(Prefs, <<"notify_push">>, true)
}};
{error, _} = E ->
E
end.
-spec put_prefs(binary(), #{email := boolean(), push := boolean()}) ->
{ok, #{email := boolean(), push := boolean()}} | {error, term()}.
put_prefs(UserId, #{email := Email, push := Push})
when is_boolean(Email), is_boolean(Push) ->
case core_user:get_by_id(UserId) of
{ok, #user{preferences = Prefs0}} ->
Prefs1 = ensure_map(Prefs0),
Prefs2 = Prefs1#{
<<"notify_email">> => Email,
<<"notify_push">> => Push
},
case core_user:update(UserId, [{preferences, Prefs2}]) of
{ok, _} -> {ok, #{email => Email, push => Push}};
{error, _} = E -> E
end;
{error, _} = E ->
E
end.
-spec email_enabled(binary()) -> boolean().
email_enabled(UserId) ->
try
case get_prefs(UserId) of
{ok, #{email := V}} -> V;
_ -> true
end
catch
_:_ -> true
end.
-spec push_enabled(binary()) -> boolean().
push_enabled(UserId) ->
try
case get_prefs(UserId) of
{ok, #{push := V}} -> V;
_ -> true
end
catch
_:_ -> true
end.
ensure_map(M) when is_map(M) -> M;
ensure_map(_) -> #{}.
pref_bool(Prefs, Key, Default) when is_map(Prefs) ->
case maps:get(Key, Prefs, Default) of
true -> true;
false -> false;
<<"true">> -> true;
<<"false">> -> false;
1 -> true;
0 -> false;
_ -> Default
end;
pref_bool(_, _, Default) ->
Default.
+174 -44
View File
@@ -98,7 +98,7 @@ discovery_events(UserId, Params, Limit, Offset) ->
search_events(undefined, UserId, Params, Limit, Offset); search_events(undefined, UserId, Params, Limit, Offset);
Items -> Items ->
Total = length(Items), Total = length(Items),
{ok, Total, format_events(paginate(Items, Limit, Offset))} {ok, Total, format_events(paginate(Items, Limit, Offset), Params)}
end. end.
discovery_calendars(UserId, Params, Limit, Offset) -> discovery_calendars(UserId, Params, Limit, Offset) ->
@@ -110,7 +110,7 @@ discovery_calendars(UserId, Params, Limit, Offset) ->
search_calendars(undefined, UserId, Params, Limit, Offset); search_calendars(undefined, UserId, Params, Limit, Offset);
Items -> Items ->
Total = length(Items), Total = length(Items),
{ok, Total, format_calendars(paginate(Items, Limit, Offset))} {ok, Total, format_calendars(paginate(Items, Limit, Offset), Params)}
end. end.
%% ============ Поиск событий ============ %% ============ Поиск событий ============
@@ -131,7 +131,7 @@ search_events(Query, UserId, Params, Limit, Offset) ->
%% Pagination is applied BEFORE formatting so only the page slice is %% Pagination is applied BEFORE formatting so only the page slice is
%% enriched with calendar data (see format_events). %% enriched with calendar data (see format_events).
Paginated = paginate(Sorted, Limit, Offset), Paginated = paginate(Sorted, Limit, Offset),
{ok, length(Filtered), format_events(Paginated)}. {ok, length(Filtered), format_events(Paginated, Params)}.
%% ============ Поиск календарей ============ %% ============ Поиск календарей ============
@@ -145,8 +145,9 @@ search_calendars(Query, UserId, Params, Limit, Offset) ->
AllCalendars = get_all_calendars(), AllCalendars = get_all_calendars(),
AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId), AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId),
Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params), Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params),
Paginated = paginate(Filtered, Limit, Offset), Sorted = sort_calendars(Filtered, Params),
{ok, length(Filtered), format_calendars(Paginated)}. Paginated = paginate(Sorted, Limit, Offset),
{ok, length(Filtered), format_calendars(Paginated, Params)}.
%% ============ Получение данных ============ %% ============ Получение данных ============
@@ -191,10 +192,13 @@ filter_accessible_calendars(Calendars, UserId) ->
end, Calendars). end, Calendars).
%% Restricted commercial не показываем в search/discovery (deep-link остаётся). %% Restricted commercial не показываем в search/discovery (deep-link остаётся).
%% Personal всегда приватны только list/deep-link для owner|share.
calendar_discoverable(#calendar{type = commercial} = C) -> calendar_discoverable(#calendar{type = commercial} = C) ->
logic_calendar:booking_open(C); logic_calendar:booking_open(C);
calendar_discoverable(#calendar{type = personal}) ->
false;
calendar_discoverable(_) -> calendar_discoverable(_) ->
true. false.
%% @doc Builds a #{calendar_id => #calendar{}} map using dirty_read %% @doc Builds a #{calendar_id => #calendar{}} map using dirty_read
%% (key-based lookups). Used to batch-fetch calendars and avoid N+1 %% (key-based lookups). Used to batch-fetch calendars and avoid N+1
@@ -220,21 +224,41 @@ apply_event_filters(Events, Query, Params) ->
-spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}]. -spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}].
apply_calendar_filters(Calendars, Query, Params) -> apply_calendar_filters(Calendars, Query, Params) ->
Calendars1 = filter_by_text(Calendars, Query), Calendars1 = filter_by_text(Calendars, Query),
filter_by_tags(Calendars1, Params). Calendars2 = filter_by_tags(Calendars1, Params),
filter_calendars_by_location(Calendars2, Params).
%% --- Текстовый поиск --- %% --- Текстовый поиск ---
-spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}]. -spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}].
filter_by_text(Items, undefined) -> Items; filter_by_text(Items, undefined) -> Items;
filter_by_text(Items, <<>>) -> Items; filter_by_text(Items, <<>>) -> Items;
filter_by_text(Items, Query) -> filter_by_text(Items, Query) ->
QueryLower = string:lowercase(binary_to_list(Query)), QueryLower = unicode_lower(Query),
lists:filter(fun(Item) -> lists:filter(fun(Item) ->
Title = binary_to_list(get_title(Item)), contains_ci(get_title(Item), QueryLower) orelse
Description = binary_to_list(get_description(Item)), contains_ci(get_description(Item), QueryLower)
string:find(string:lowercase(Title), QueryLower) =/= nomatch orelse
string:find(string:lowercase(Description), QueryLower) =/= nomatch
end, Items). end, Items).
%% @doc Case-insensitive contains: бинарники UTF-8 приводим к юникод-
%% кодпоинтам. string:lowercase(binary_to_list/1) работал по байтам
%% (latin-1) и ломал кириллицу: «аврора» не находила «Аврора».
-spec contains_ci(binary() | undefined, string()) -> boolean().
contains_ci(undefined, _) -> false;
contains_ci(Bin, QueryLower) ->
case unicode:characters_to_list(Bin, utf8) of
Text when is_list(Text) ->
string:find(string:lowercase(Text), QueryLower) =/= nomatch;
_ ->
%% Битый UTF-8: фолбэк на побайтовое сравнение.
string:find(binary_to_list(Bin), QueryLower) =/= nomatch
end.
-spec unicode_lower(binary()) -> string().
unicode_lower(Query) ->
case unicode:characters_to_list(Query, utf8) of
Q when is_list(Q) -> string:lowercase(Q);
_ -> string:lowercase(binary_to_list(Query))
end.
%% --- Фильтр по тегам --- %% --- Фильтр по тегам ---
-spec filter_by_tags([#event{} | #calendar{}], map()) -> [#event{} | #calendar{}]. -spec filter_by_tags([#event{} | #calendar{}], map()) -> [#event{} | #calendar{}].
filter_by_tags(Items, Params) -> filter_by_tags(Items, Params) ->
@@ -266,21 +290,63 @@ filter_by_date_range(Events, Params) ->
%% --- Гео-фильтр --- %% --- Гео-фильтр ---
-spec filter_by_location([#event{}], map()) -> [#event{}]. -spec filter_by_location([#event{}], map()) -> [#event{}].
filter_by_location(Events, Params) -> filter_by_location(Events, Params) ->
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of case geo_origin(Params) of
{undefined, _} -> Events; none -> Events;
{_, undefined} -> Events;
{Lat, Lon} -> {Lat, Lon} ->
Radius = maps:get(radius, Params, 10), Radius = maps:get(radius, Params, undefined),
lists:filter(fun(Event) -> lists:filter(fun(Event) ->
case Event#event.location of within_radius(event_coords(Event), Lat, Lon, Radius)
#location{lat = EventLat, lon = EventLon} end, Events)
when is_number(EventLat), is_number(EventLon) ->
distance(Lat, Lon, EventLat, EventLon) =< Radius;
_ -> false
end
end, Events)
end. end.
-spec filter_calendars_by_location([#calendar{}], map()) -> [#calendar{}].
filter_calendars_by_location(Calendars, Params) ->
case geo_origin(Params) of
none -> Calendars;
{Lat, Lon} ->
Radius = maps:get(radius, Params, undefined),
lists:filter(fun(Cal) ->
within_radius(calendar_coords(Cal), Lat, Lon, Radius)
end, Calendars)
end.
geo_origin(Params) ->
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
{Lat, Lon} when is_number(Lat), is_number(Lon) -> {Lat, Lon};
_ -> none
end.
within_radius(none, _, _, _) -> false;
within_radius({ok, ELat, ELon}, Lat, Lon, Radius) ->
D = distance(Lat, Lon, ELat, ELon),
Radius =:= undefined orelse D =< Radius.
event_coords(#event{location = Loc}) -> loc_coords(Loc);
event_coords(_) -> none.
calendar_coords(#calendar{settings = Settings}) when is_map(Settings) ->
Loc = maps:get(<<"default_location">>, Settings,
maps:get(default_location, Settings, undefined)),
loc_coords(Loc);
calendar_coords(_) -> none.
loc_coords(#location{lat = Lat, lon = Lon}) ->
case handler_utils:meaningful_coords(Lat, Lon) of
true -> {ok, to_float(Lat), to_float(Lon)};
false -> none
end;
loc_coords(Loc) when is_map(Loc) ->
Lat = maps:get(<<"lat">>, Loc, maps:get(lat, Loc, undefined)),
Lon = maps:get(<<"lon">>, Loc, maps:get(lon, Loc, undefined)),
case handler_utils:meaningful_coords(Lat, Lon) of
true -> {ok, to_float(Lat), to_float(Lon)};
false -> none
end;
loc_coords(_) -> none.
to_float(N) when is_integer(N) -> float(N);
to_float(N) when is_float(N) -> N.
%% ============ Вспомогательные функции ============ %% ============ Вспомогательные функции ============
-spec get_title(#event{} | #calendar{}) -> binary(). -spec get_title(#event{} | #calendar{}) -> binary().
@@ -319,19 +385,66 @@ deg_to_rad(Deg) -> Deg * math:pi() / 180.
-spec sort_events([#event{}], map()) -> [#event{}]. -spec sort_events([#event{}], map()) -> [#event{}].
sort_events(Events, Params) -> sort_events(Events, Params) ->
SortBy = maps:get(sort, Params, <<"start_time">>), Default = case geo_origin(Params) of
none -> <<"start_time">>;
_ -> <<"distance">>
end,
SortBy = maps:get(sort, Params, Default),
Order = maps:get(order, Params, <<"asc">>), Order = maps:get(order, Params, <<"asc">>),
Sorted = case SortBy of Sorted = case SortBy of
<<"distance">> -> sort_by_distance_events(Events, Params);
<<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events); <<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events);
<<"rating">> -> lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events); <<"rating">> -> lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events);
<<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events); <<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
<<"title">> -> lists:sort(fun(A, B) -> A#event.title =< B#event.title end, Events);
_ -> Events _ -> Events
end, end,
case {SortBy, Order} of
{<<"distance">>, <<"desc">>} -> lists:reverse(Sorted);
{<<"rating">>, _} -> Sorted;
{_, <<"desc">>} -> lists:reverse(Sorted);
_ -> Sorted
end.
sort_calendars(Calendars, Params) ->
Default = case geo_origin(Params) of
none -> <<"title">>;
_ -> <<"distance">>
end,
SortBy = maps:get(sort, Params, Default),
Order = maps:get(order, Params, <<"asc">>),
Sorted = case SortBy of
<<"distance">> -> sort_by_distance_calendars(Calendars, Params);
<<"created_at">> -> lists:sort(fun(A, B) -> A#calendar.created_at =< B#calendar.created_at end, Calendars);
<<"title">> -> lists:sort(fun(A, B) -> A#calendar.title =< B#calendar.title end, Calendars);
_ -> Calendars
end,
case Order of case Order of
<<"desc">> -> lists:reverse(Sorted); <<"desc">> -> lists:reverse(Sorted);
_ -> Sorted _ -> Sorted
end. end.
sort_by_distance_events(Events, Params) ->
case geo_origin(Params) of
none -> Events;
{Lat, Lon} ->
lists:sort(fun(A, B) ->
dist_or_inf(event_coords(A), Lat, Lon) =< dist_or_inf(event_coords(B), Lat, Lon)
end, Events)
end.
sort_by_distance_calendars(Cals, Params) ->
case geo_origin(Params) of
none -> Cals;
{Lat, Lon} ->
lists:sort(fun(A, B) ->
dist_or_inf(calendar_coords(A), Lat, Lon) =< dist_or_inf(calendar_coords(B), Lat, Lon)
end, Cals)
end.
dist_or_inf({ok, ELat, ELon}, Lat, Lon) -> distance(Lat, Lon, ELat, ELon);
dist_or_inf(none, _, _) -> 1.0e12.
%% ============ Пагинация ============ %% ============ Пагинация ============
-spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()]. -spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()].
@@ -340,28 +453,24 @@ paginate(List, Limit, Offset) ->
%% ============ Форматирование ответа ============ %% ============ Форматирование ответа ============
-spec format_events([#event{}]) -> [map()]. -spec format_events([#event{}], map()) -> [map()].
format_events(Events) -> format_events(Events, Params) ->
%% Optimization: batch-fetch calendars for the (already paginated) %% Optimization: batch-fetch calendars for the (already paginated)
%% subset only, avoiding an N+1 query per event during formatting. %% subset only, avoiding an N+1 query per event during formatting.
CalendarMap = build_calendar_map( CalendarMap = build_calendar_map(
lists:usort([E#event.calendar_id || E <- Events])), lists:usort([E#event.calendar_id || E <- Events])),
lists:map(fun(E) -> format_event(E, CalendarMap) end, Events). lists:map(fun(E) -> format_event(E, CalendarMap, Params) end, Events).
-spec format_event(#event{}, #{binary() => #calendar{}}) -> map(). -spec format_event(#event{}, #{binary() => #calendar{}}, map()) -> map().
format_event(Event, CalendarMap) -> format_event(Event, CalendarMap, Params) ->
Location = case Event#event.location of Location = handler_utils:location_to_json(Event#event.location),
undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon}
end,
{CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of {CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of
{ok, #calendar{title = T, image_url = <<>>}} -> {T, null}; {ok, #calendar{title = T, image_url = <<>>}} -> {T, null};
{ok, #calendar{title = T, image_url = undefined}} -> {T, null}; {ok, #calendar{title = T, image_url = undefined}} -> {T, null};
{ok, #calendar{title = T, image_url = Url}} -> {T, Url}; {ok, #calendar{title = T, image_url = Url}} -> {T, Url};
error -> {null, null} error -> {null, null}
end, end,
#{ Base = #{
id => Event#event.id, id => Event#event.id,
calendar_id => Event#event.calendar_id, calendar_id => Event#event.calendar_id,
calendar_title => CalendarTitle, calendar_title => CalendarTitle,
@@ -377,15 +486,25 @@ format_event(Event, CalendarMap) ->
rating_count => Event#event.rating_count, rating_count => Event#event.rating_count,
status => Event#event.status, status => Event#event.status,
image_url => ImageUrl image_url => ImageUrl
}. },
maybe_distance(Base, event_coords(Event), Params).
-spec format_calendars([#calendar{}]) -> [map()]. -spec format_calendars([#calendar{}], map()) -> [map()].
format_calendars(Calendars) -> format_calendars(Calendars, Params) ->
lists:map(fun format_calendar/1, Calendars). lists:map(fun(C) -> format_calendar(C, Params) end, Calendars).
-spec format_calendar(#calendar{}) -> map(). -spec format_calendar(#calendar{}, map()) -> map().
format_calendar(Calendar) -> format_calendar(Calendar, Params) ->
#{ Settings = case Calendar#calendar.settings of
M when is_map(M) -> M;
_ -> #{}
end,
LocJson = case maps:get(<<"default_location">>, Settings,
maps:get(default_location, Settings, undefined)) of
undefined -> null;
Loc -> Loc
end,
Base = #{
id => Calendar#calendar.id, id => Calendar#calendar.id,
owner_id => Calendar#calendar.owner_id, owner_id => Calendar#calendar.owner_id,
title => Calendar#calendar.title, title => Calendar#calendar.title,
@@ -400,8 +519,19 @@ format_calendar(Calendar) ->
tags => Calendar#calendar.tags, tags => Calendar#calendar.tags,
rating_avg => Calendar#calendar.rating_avg, rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count, rating_count => Calendar#calendar.rating_count,
status => Calendar#calendar.status status => Calendar#calendar.status,
}. %% Всегда отдаём координаты (не только при гео-запросе) вид карты
%% на странице поиска строит маркеры и без lat/lon в запросе.
default_location => LocJson
},
maybe_distance(Base, calendar_coords(Calendar), Params).
maybe_distance(Map, Coords, Params) ->
case {geo_origin(Params), Coords} of
{{OLat, OLon}, {ok, Lat, Lon}} ->
Map#{distance_km => round(distance(OLat, OLon, Lat, Lon) * 10) / 10};
_ -> Map
end.
-spec datetime_to_iso8601(calendar:datetime()) -> binary(). -spec datetime_to_iso8601(calendar:datetime()) -> binary().
datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) -> datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) ->
+2 -2
View File
@@ -187,8 +187,8 @@ do_create(OwnerId, CalendarId, UserId, Email, Opts) ->
notify_invite(#specialist_invite{invitee_user_id = UserId, calendar_id = CalId, notify_invite(#specialist_invite{invitee_user_id = UserId, calendar_id = CalId,
token = Token} = Inv) when UserId =/= <<>> -> token = Token} = Inv) when UserId =/= <<>> ->
Title = <<"Приглашение стать специалистом">>, Title = <<"Приглашение стать специалистом"/utf8>>,
Body = <<"Вас пригласили в календарь ", CalId/binary>>, Body = <<"Вас пригласили в календарь "/utf8, CalId/binary>>,
_ = core_notification:create(UserId, specialist_invite, Title, Body), _ = core_notification:create(UserId, specialist_invite, Title, Body),
logic_notification:notify_specialist_invite(UserId, Inv), logic_notification:notify_specialist_invite(UserId, Inv),
maybe_email(Inv, Token); maybe_email(Inv, Token);
+175
View File
@@ -0,0 +1,175 @@
%%%-------------------------------------------------------------------
%%% @doc Local-disk uploads for avatar/cover (Back#71, variant A).
%%% Env: UPLOAD_DIR (default /app/data/uploads), UPLOAD_MAX_BYTES (default 2MiB).
%%% Public URLs: /v1/media/{avatar|cover}/:owner/:file
%%% @end
%%%-------------------------------------------------------------------
-module(logic_upload).
-export([max_bytes/0, upload_dir/0, ensure_dir/0,
save_avatar/2, save_cover/3,
media_abs_path/3, detect_image/1, maybe_delete_url/1]).
-define(DEFAULT_MAX, 2097152). %% 2 MiB
-define(DEFAULT_DIR, "/app/data/uploads").
-spec max_bytes() -> pos_integer().
max_bytes() ->
case application:get_env(eventhub, upload_max_bytes) of
{ok, N} when is_integer(N), N > 0 -> N;
_ ->
case os:getenv("UPLOAD_MAX_BYTES") of
false -> ?DEFAULT_MAX;
"" -> ?DEFAULT_MAX;
S ->
try list_to_integer(S) of
N when N > 0 -> N;
_ -> ?DEFAULT_MAX
catch
_:_ -> ?DEFAULT_MAX
end
end
end.
-spec upload_dir() -> string().
upload_dir() ->
case application:get_env(eventhub, upload_dir) of
{ok, Dir} when is_list(Dir), Dir =/= "" -> Dir;
{ok, Dir} when is_binary(Dir), Dir =/= <<>> -> binary_to_list(Dir);
_ ->
case os:getenv("UPLOAD_DIR") of
false -> ?DEFAULT_DIR;
"" -> ?DEFAULT_DIR;
S -> S
end
end.
-spec ensure_dir() -> ok | {error, term()}.
ensure_dir() ->
Dir = upload_dir(),
case filelib:is_dir(Dir) of
true -> ok;
false -> filelib:ensure_dir(filename:join(Dir, "dummy"))
end.
%% @doc Save avatar bytes; returns public URL path.
-spec save_avatar(UserId :: binary(), Bin :: binary()) ->
{ok, Url :: binary()} | {error, too_large | unsupported_media | term()}.
save_avatar(UserId, Bin) when is_binary(UserId), is_binary(Bin) ->
save(avatar, UserId, Bin).
%% @doc Save calendar cover; returns public URL path.
-spec save_cover(CalendarId :: binary(), OwnerUserId :: binary(), Bin :: binary()) ->
{ok, Url :: binary()} | {error, too_large | unsupported_media | term()}.
save_cover(CalendarId, _OwnerUserId, Bin)
when is_binary(CalendarId), is_binary(Bin) ->
save(cover, CalendarId, Bin).
-spec media_abs_path(Kind :: avatar | cover, OwnerId :: binary(), File :: binary()) ->
{ok, file:filename_all()} | {error, not_found | invalid}.
media_abs_path(Kind, OwnerId, File)
when (Kind =:= avatar orelse Kind =:= cover),
is_binary(OwnerId), is_binary(File) ->
case safe_segment(OwnerId) andalso safe_filename(File) of
true ->
KindS = atom_to_list(Kind),
Path = filename:join([upload_dir(), KindS,
binary_to_list(OwnerId), binary_to_list(File)]),
case filelib:is_regular(Path) of
true -> {ok, Path};
false -> {error, not_found}
end;
false ->
{error, invalid}
end.
-spec detect_image(binary()) ->
{ok, Ext :: binary(), Mime :: binary()} | {error, unsupported_media}.
detect_image(<<16#FF, 16#D8, 16#FF, _/binary>>) ->
{ok, <<"jpg">>, <<"image/jpeg">>};
detect_image(<<137, 80, 78, 71, 13, 10, 26, 10, _/binary>>) ->
{ok, <<"png">>, <<"image/png">>};
detect_image(<<"RIFF", _:4/binary, "WEBP", _/binary>>) ->
{ok, <<"webp">>, <<"image/webp">>};
detect_image(_) ->
{error, unsupported_media}.
-spec maybe_delete_url(binary() | default | undefined) -> ok.
maybe_delete_url(Url) when is_binary(Url) ->
case Url of
<<"/v1/media/", Rest/binary>> ->
case binary:split(Rest, <<"/">>, [global]) of
[KindB, OwnerId, File] ->
Kind = case KindB of
<<"avatar">> -> avatar;
<<"cover">> -> cover;
_ -> undefined
end,
case Kind of
undefined -> ok;
_ ->
case media_abs_path(Kind, OwnerId, File) of
{ok, Path} -> _ = file:delete(Path), ok;
_ -> ok
end
end;
_ -> ok
end;
_ -> ok
end;
maybe_delete_url(_) ->
ok.
%%%===================================================================
%%% INTERNAL
%%%===================================================================
save(Kind, OwnerId, Bin) ->
case byte_size(Bin) > max_bytes() of
true ->
{error, too_large};
false ->
case detect_image(Bin) of
{ok, Ext, _Mime} ->
case safe_segment(OwnerId) of
false ->
{error, invalid};
true ->
write_new(Kind, OwnerId, Ext, Bin)
end;
{error, _} = E ->
E
end
end.
write_new(Kind, OwnerId, Ext, Bin) ->
_ = ensure_dir(),
FileId = infra_utils:generate_id(16),
File = <<FileId/binary, ".", Ext/binary>>,
KindS = atom_to_list(Kind),
RelDir = filename:join([upload_dir(), KindS, binary_to_list(OwnerId)]),
AbsFile = filename:join(RelDir, binary_to_list(File)),
ok = filelib:ensure_dir(AbsFile),
case file:write_file(AbsFile, Bin) of
ok ->
Url = iolist_to_binary([<<"/v1/media/">>, KindS, <<"/">>,
OwnerId, <<"/">>, File]),
{ok, Url};
{error, Reason} ->
{error, Reason}
end.
safe_segment(Bin) when is_binary(Bin), byte_size(Bin) > 0, byte_size(Bin) < 128 ->
case binary:match(Bin, [<<"/">>, <<"..">>, <<"\\">>]) of
nomatch -> true;
_ -> false
end;
safe_segment(_) ->
false.
safe_filename(Bin) when is_binary(Bin), byte_size(Bin) > 0, byte_size(Bin) < 128 ->
case re:run(Bin, <<"^[A-Za-z0-9_-]+\\.(jpg|png|webp)$">>, [{capture, none}]) of
match -> true;
nomatch -> false
end;
safe_filename(_) ->
false.
+314
View File
@@ -0,0 +1,314 @@
%%%-------------------------------------------------------------------
%%% @doc Event waitlist (Back#72). Requires commercial calendar
%%% settings.waitlist_enabled = true. FIFO promote on free slot.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_waitlist).
-include("records.hrl").
-export([join/2, leave/2, status_for_user/2, list_for_manager/2,
maybe_promote/1, waitlist_enabled/1]).
-spec waitlist_enabled(#calendar{}) -> boolean().
waitlist_enabled(#calendar{type = commercial, settings = Settings}) when is_map(Settings) ->
case maps:get(<<"waitlist_enabled">>, Settings, maps:get(waitlist_enabled, Settings, false)) of
true -> true;
<<"true">> -> true;
_ -> false
end;
waitlist_enabled(_) ->
false.
-spec join(UserId :: binary(), EventId :: binary()) ->
{ok, map()} |
{error, not_found | access_denied | waitlist_disabled | not_full |
already_booked | already_on_waitlist | personal_calendar |
subscription_inactive | event_not_active | own_event}.
join(UserId, EventId) ->
case core_event:get_by_id(EventId) of
{ok, #event{status = active} = Event} ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Cal} ->
join_checked(UserId, Event, Cal);
{error, not_found} ->
{error, not_found}
end;
{ok, _} ->
{error, event_not_active};
{error, not_found} ->
{error, not_found}
end.
join_checked(UserId, Event, Cal) ->
case Cal#calendar.type of
personal ->
{error, personal_calendar};
commercial ->
case logic_calendar:booking_open(Cal) of
false ->
{error, subscription_inactive};
true ->
case waitlist_enabled(Cal) of
false ->
{error, waitlist_disabled};
true ->
join_ready(UserId, Event, Cal)
end
end
end.
join_ready(UserId, #event{id = EventId, capacity = Cap} = Event, _Cal) ->
case owner_of_event(UserId, Event) of
true ->
{error, own_event};
false ->
case logic_booking_occupied(EventId, UserId) of
true ->
{error, already_booked};
false ->
case core_waitlist:get_waiting(EventId, UserId) of
{ok, _} ->
{error, already_on_waitlist};
{error, not_found} ->
case is_full(EventId, Cap) of
false ->
{error, not_full};
true ->
case core_waitlist:create(EventId, UserId) of
{ok, Entry} ->
{ok, entry_public(Entry, EventId)};
{error, _} = E ->
E
end
end
end
end
end.
owner_of_event(UserId, Event) ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, #calendar{owner_id = UserId}} -> true;
_ -> false
end.
-spec leave(UserId :: binary(), EventId :: binary()) ->
ok | {error, not_found}.
leave(UserId, EventId) ->
case core_waitlist:get_waiting(EventId, UserId) of
{ok, #waitlist_entry{id = Id}} ->
_ = core_waitlist:update(Id, [{status, left}]),
ok;
{error, not_found} ->
{error, not_found}
end.
-spec status_for_user(UserId :: binary(), EventId :: binary()) ->
{ok, map()} | {error, not_found | access_denied}.
status_for_user(UserId, EventId) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Cal} ->
Enabled = waitlist_enabled(Cal),
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
case core_waitlist:get_waiting(EventId, UserId) of
{ok, Entry} ->
Pos = position_of(Entry#waitlist_entry.id, Waiting),
{ok, #{
enabled => Enabled,
joined => true,
position => Pos,
total => length(Waiting),
entry => entry_json(Entry)
}};
{error, not_found} ->
{ok, #{
enabled => Enabled,
joined => false,
position => null,
total => length(Waiting)
}}
end;
{error, not_found} ->
{error, not_found}
end;
{error, not_found} ->
{error, not_found}
end.
-spec list_for_manager(UserId :: binary(), EventId :: binary()) ->
{ok, map()} | {error, not_found | access_denied}.
list_for_manager(UserId, EventId) ->
case can_manage(UserId, EventId) of
true ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
{ok, Cal} = core_calendar:get_by_id(Event#event.calendar_id),
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
{ok, #{
enabled => waitlist_enabled(Cal),
total => length(Waiting),
items => [entry_json(E) || E <- Waiting]
}};
{error, not_found} ->
{error, not_found}
end;
false ->
{error, access_denied}
end.
%%%-------------------------------------------------------------------
%%% Promote first waiter when a booking slot frees.
%%%-------------------------------------------------------------------
-spec maybe_promote(EventId :: binary()) -> ok.
maybe_promote(EventId) ->
case core_event:get_by_id(EventId) of
{ok, #event{status = active, capacity = Cap, calendar_id = CalId} = Event} ->
case core_calendar:get_by_id(CalId) of
{ok, Cal} ->
case waitlist_enabled(Cal) andalso logic_calendar:booking_open(Cal)
andalso has_free_slot(EventId, Cap) of
true ->
promote_one(Event, Cal);
false ->
ok
end;
_ ->
ok
end;
_ ->
ok
end.
promote_one(Event, Cal) ->
case sort_waiting(core_waitlist:list_waiting_by_event(Event#event.id)) of
[] ->
ok;
[#waitlist_entry{id = Wid, user_id = UserId} = _Entry | _] ->
case logic_booking_occupied(Event#event.id, UserId) of
true ->
_ = core_waitlist:update(Wid, [{status, left}]),
maybe_promote(Event#event.id);
false ->
Status = initial_status(Cal#calendar.confirmation),
case core_booking:create(Event#event.id, UserId, Status) of
{ok, Booking} ->
_ = core_waitlist:update(Wid, [{status, promoted}]),
notify_promoted(UserId, Event, Booking),
ok;
{error, _} ->
ok
end
end
end.
initial_status(auto) -> confirmed;
initial_status(_) -> pending.
notify_promoted(UserId, Event, Booking) ->
Title = Event#event.title,
Path = <<"/c/", (Event#event.calendar_id)/binary, "/e/", (Event#event.id)/binary>>,
case logic_notification_prefs:email_enabled(UserId) of
true ->
case core_user:get_by_id(UserId) of
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
_ = logic_email:send_waitlist_promoted(Email, Title, Path);
_ ->
ok
end;
false ->
ok
end,
_ = logic_notification:notify_waitlist_promoted(UserId, #{
event_id => Event#event.id,
calendar_id => Event#event.calendar_id,
booking_id => Booking#booking.id,
title => Title,
status => Booking#booking.status
}),
_ = logic_web_push:send(
UserId,
<<"Место из листа ожидания"/utf8>>,
iolist_to_binary([<<"Вас записали на «"/utf8>>, Title, <<"»."/utf8>>]),
Path
),
ok.
%%%===================================================================
%%% INTERNAL
%%%===================================================================
is_full(_EventId, Cap) when Cap =:= undefined; Cap =:= 0 ->
false;
is_full(EventId, Cap) when is_integer(Cap), Cap > 0 ->
occupied(EventId) >= Cap;
is_full(_, _) ->
false.
has_free_slot(_EventId, Cap) when Cap =:= undefined; Cap =:= 0 ->
true;
has_free_slot(EventId, Cap) when is_integer(Cap), Cap > 0 ->
occupied(EventId) < Cap;
has_free_slot(_, _) ->
true.
occupied(EventId) ->
{ok, Bookings} = core_booking:list_by_event(EventId),
length([B || B <- Bookings,
B#booking.status =:= pending orelse B#booking.status =:= confirmed]).
logic_booking_occupied(EventId, UserId) ->
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'},
case [B || B <- mnesia:dirty_match_object(Match),
B#booking.status =:= pending orelse B#booking.status =:= confirmed] of
[_ | _] -> true;
[] -> false
end.
sort_waiting(List) ->
lists:sort(fun(A, B) -> A#waitlist_entry.created_at =< B#waitlist_entry.created_at end, List).
position_of(Id, Waiting) ->
position_of(Id, Waiting, 1).
position_of(_Id, [], _) -> null;
position_of(Id, [#waitlist_entry{id = Id} | _], N) -> N;
position_of(Id, [_ | Rest], N) -> position_of(Id, Rest, N + 1).
entry_public(Entry, EventId) ->
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
#{
entry => entry_json(Entry),
position => position_of(Entry#waitlist_entry.id, Waiting),
total => length(Waiting)
}.
entry_json(#waitlist_entry{} = E) ->
#{
id => E#waitlist_entry.id,
event_id => E#waitlist_entry.event_id,
user_id => E#waitlist_entry.user_id,
status => E#waitlist_entry.status,
created_at => handler_utils:datetime_to_iso8601(E#waitlist_entry.created_at),
updated_at => handler_utils:datetime_to_iso8601(E#waitlist_entry.updated_at)
}.
can_manage(UserId, EventId) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, #calendar{owner_id = UserId}} ->
true;
{ok, Calendar} ->
SpecOk = is_binary(Event#event.specialist_id)
andalso Event#event.specialist_id =/= <<>>
andalso Event#event.specialist_id =:= UserId
andalso core_calendar_specialist:is_active_specialist(
Calendar#calendar.id, UserId),
SpecOk orelse admin_utils:is_admin(UserId);
_ ->
admin_utils:is_admin(UserId)
end;
_ ->
false
end.
+253
View File
@@ -0,0 +1,253 @@
%%%-------------------------------------------------------------------
%%% @doc Web Push send (VAPID + aes128gcm). Back#75.
%%% Env: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY (base64url), VAPID_SUBJECT.
%%% Tests may set {eventhub, web_push_deliver} fun/4 mock.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_web_push).
-export([send/4, vapid_public_key/0]).
-include("records.hrl").
-spec vapid_public_key() -> {ok, binary()} | {error, missing}.
vapid_public_key() ->
case env_bin("VAPID_PUBLIC_KEY") of
<<>> -> {error, missing};
Key -> {ok, Key}
end.
-spec send(binary(), binary(), binary(), binary()) -> ok.
send(UserId, Title, Body, Url) ->
case logic_notification_prefs:push_enabled(UserId) of
false ->
ok;
true ->
case {env_bin("VAPID_PUBLIC_KEY"), env_bin("VAPID_PRIVATE_KEY")} of
{<<>>, _} ->
logger:info(#{what => web_push_skip_no_vapid, user_id => UserId}),
ok;
{_, <<>>} ->
logger:info(#{what => web_push_skip_no_vapid, user_id => UserId}),
ok;
{Pub, Priv} ->
Payload = jsx:encode(#{
<<"title">> => Title,
<<"body">> => Body,
<<"url">> => Url
}),
lists:foreach(
fun(Sub) -> safe_push(Sub, Pub, Priv, Payload) end,
core_push_subscription:list_by_user(UserId)
),
ok
end
end.
safe_push(#push_subscription{endpoint = Ep, p256dh = P256, auth = Auth} = Sub,
VapidPub, VapidPriv, Payload) ->
Fun = deliver_fun(),
try Fun(Ep, P256, Auth, Payload, VapidPub, VapidPriv) of
ok -> ok;
{ok, _} -> ok;
{error, gone} ->
_ = core_push_subscription:delete_by_id(Sub#push_subscription.id),
ok;
{error, Reason} ->
logger:warning(#{what => web_push_error, endpoint => Ep, reason => Reason}),
ok;
Other ->
logger:warning(#{what => web_push_unexpected, endpoint => Ep, result => Other}),
ok
catch
Class:Reason:Stack ->
logger:warning(#{what => web_push_crash, endpoint => Ep,
class => Class, reason => Reason, stack => Stack}),
ok
end.
deliver_fun() ->
case application:get_env(eventhub, web_push_deliver) of
{ok, Fun} when is_function(Fun, 6) -> Fun;
_ -> fun default_deliver/6
end.
default_deliver(Endpoint, P256dhB64, AuthB64, Payload, VapidPub, VapidPriv) ->
_ = application:ensure_all_started(inets),
_ = application:ensure_all_started(ssl),
case encrypt_aes128gcm(P256dhB64, AuthB64, Payload) of
{error, Reason} ->
{error, Reason};
{ok, Body, CryptoHeaders} ->
case vapid_auth_header(Endpoint, VapidPub, VapidPriv) of
{error, Reason} ->
{error, Reason};
{ok, Authz} ->
Headers = [
{"authorization", Authz},
{"ttl", "86400"},
{"content-encoding", "aes128gcm"},
{"content-type", "application/octet-stream"}
| CryptoHeaders
],
Request = {binary_to_list(Endpoint), Headers, "application/octet-stream", Body},
HttpOpts = [{timeout, 15000}, {ssl, [{verify, verify_peer},
{cacerts, public_key:cacerts_get()}]}],
case httpc:request(post, Request, HttpOpts, [{body_format, binary}]) of
{ok, {{_, Code, _}, _, _}} when Code >= 200, Code < 300 ->
ok;
{ok, {{_, Code, _}, _, _}} when Code =:= 404; Code =:= 410 ->
{error, gone};
{ok, {{_, Code, _}, _, Resp}} ->
{error, {http_status, Code, Resp}};
{error, Reason} ->
{error, Reason}
end
end
end.
%%%-------------------------------------------------------------------
%%% VAPID (RFC 8292) via jose ES256
%%%-------------------------------------------------------------------
vapid_auth_header(Endpoint, PubB64, PrivB64) ->
try
Audience = audience(Endpoint),
Sub = case env_bin("VAPID_SUBJECT") of
<<>> -> <<"mailto:admin@calentiq.com">>;
S -> S
end,
Exp = erlang:system_time(second) + 12 * 3600,
Claims = #{
<<"aud">> => Audience,
<<"exp">> => Exp,
<<"sub">> => Sub
},
JWK = vapid_jwk(PubB64, PrivB64),
{_, TokenBin} = jose_jwt:sign(JWK, #{<<"alg">> => <<"ES256">>}, Claims),
Compact = jose_jws:compact(TokenBin),
Token = iolist_to_binary(Compact),
H = <<"vapid t=", Token/binary, ", k=", PubB64/binary>>,
{ok, binary_to_list(H)}
catch
Class:Reason:Stack ->
{error, {vapid, Class, Reason, Stack}}
end.
audience(Endpoint) ->
%% scheme://host[:port]
case uri_string:parse(Endpoint) of
#{scheme := Scheme, host := Host} = U ->
Port = maps:get(port, U, undefined),
case Port of
undefined ->
iolist_to_binary([Scheme, <<"://">>, Host]);
P ->
iolist_to_binary([Scheme, <<"://">>, Host, <<":">>, integer_to_binary(P)])
end;
_ ->
Endpoint
end.
vapid_jwk(PubB64, PrivB64) ->
Pub = b64url_decode(PubB64),
Priv = b64url_decode(PrivB64),
<<16#04, X:32/binary, Y:32/binary>> = Pub,
jose_jwk:from_map(#{
<<"kty">> => <<"EC">>,
<<"crv">> => <<"P-256">>,
<<"x">> => b64url_encode(X),
<<"y">> => b64url_encode(Y),
<<"d">> => b64url_encode(Priv)
}).
%%%-------------------------------------------------------------------
%%% RFC 8291 aes128gcm (simplified single-record)
%%%-------------------------------------------------------------------
encrypt_aes128gcm(P256dhB64, AuthB64, Payload) ->
try
UaPublic = b64url_decode(P256dhB64),
AuthSecret = b64url_decode(AuthB64),
{AsPublic, AsPrivate} = crypto:generate_key(ecdh, secp256r1),
Shared = crypto:compute_key(ecdh, UaPublic, AsPrivate, secp256r1),
%% ikm
AuthInfo = <<"WebPush: info", 0, UaPublic/binary, AsPublic/binary>>,
Ikm = hkdf(AuthSecret, Shared, AuthInfo, 32),
Salt = crypto:strong_rand_bytes(16),
%% cek + nonce
KeyInfo = <<"Content-Encoding: aes128gcm", 0>>,
NonceInfo = <<"Content-Encoding: nonce", 0>>,
Cek = hkdf(Salt, Ikm, KeyInfo, 16),
Nonce = hkdf(Salt, Ikm, NonceInfo, 12),
%% pad: payload || 0x02 (delimiter) for aes128gcm
Plain = <<Payload/binary, 2>>,
{Cipher, Tag} = crypto:crypto_one_time_aead(aes_128_gcm, Cek, Nonce, Plain, <<>>, true),
Ciphertext = <<Cipher/binary, Tag/binary>>,
Rs = 4096,
IdLen = byte_size(AsPublic),
Body = <<Salt/binary, Rs:32, IdLen:8, AsPublic/binary, Ciphertext/binary>>,
{ok, Body, []}
catch
Class:Reason:Stack ->
{error, {encrypt, Class, Reason, Stack}}
end.
%% HKDF-Extract + Expand (SHA-256), RFC 5869
hkdf(Salt, Ikm, Info, Len) ->
Prk = crypto:mac(hmac, sha256, Salt, Ikm),
hkdf_expand(Prk, Info, Len).
hkdf_expand(Prk, Info, Len) ->
hkdf_expand(Prk, Info, Len, 1, <<>>, <<>>).
hkdf_expand(_Prk, _Info, Len, _I, Acc, _Prev) when byte_size(Acc) >= Len ->
binary:part(Acc, 0, Len);
hkdf_expand(Prk, Info, Len, I, Acc, Prev) ->
T = crypto:mac(hmac, sha256, Prk, <<Prev/binary, Info/binary, I:8>>),
hkdf_expand(Prk, Info, Len, I + 1, <<Acc/binary, T/binary>>, T).
%%%-------------------------------------------------------------------
%%% helpers
%%%-------------------------------------------------------------------
env_bin("VAPID_PUBLIC_KEY") ->
app_or_os(vapid_public_key, "VAPID_PUBLIC_KEY");
env_bin("VAPID_PRIVATE_KEY") ->
app_or_os(vapid_private_key, "VAPID_PRIVATE_KEY");
env_bin("VAPID_SUBJECT") ->
app_or_os(vapid_subject, "VAPID_SUBJECT");
env_bin(Name) ->
case os:getenv(Name) of
false -> <<>>;
"" -> <<>>;
S -> unicode:characters_to_binary(S)
end.
app_or_os(AppKey, EnvName) ->
case application:get_env(eventhub, AppKey) of
{ok, B} when is_binary(B), B =/= <<>> -> B;
{ok, L} when is_list(L), L =/= "" -> unicode:characters_to_binary(L);
_ ->
case os:getenv(EnvName) of
false -> <<>>;
"" -> <<>>;
S -> unicode:characters_to_binary(S)
end
end.
b64url_decode(Bin) when is_binary(Bin) ->
S0 = binary_to_list(Bin),
S1 = lists:map(fun($-) -> $+; ($_) -> $/; (C) -> C end, S0),
Pad = case length(S1) rem 4 of
0 -> "";
2 -> "==";
3 -> "=";
1 -> "="
end,
base64:decode(S1 ++ Pad).
b64url_encode(Bin) when is_binary(Bin) ->
S = base64:encode_to_string(Bin),
S2 = lists:filter(fun(C) -> C =/= $= end, S),
unicode:characters_to_binary(
lists:map(fun($+) -> $-; ($/) -> $_; (C) -> C end, S2)).
@@ -0,0 +1,38 @@
%% @doc Create waitlist_entry table and indexes (Back#72).
-module('20260814193000_waitlist_entry').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
ensure_table(waitlist_entry, record_info(fields, waitlist_entry)),
ensure_index(waitlist_entry, event_id),
ensure_index(waitlist_entry, user_id),
ensure_index(waitlist_entry, status),
ok.
down() ->
_ = mnesia:delete_table(waitlist_entry),
ok.
ensure_table(Table, Attrs) ->
case lists:member(Table, mnesia:system_info(tables)) of
true ->
ok;
false ->
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table}} -> ok;
{aborted, Reason} -> error({create_table_failed, Table, Reason})
end
end.
ensure_index(Table, Attr) ->
case mnesia:add_table_index(Table, Attr) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table, _Pos}} -> ok;
{aborted, {already_exists, Table, Attr}} -> ok;
{aborted, {already_exists, _}} -> ok;
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
end.
@@ -0,0 +1,37 @@
%% @doc Create push_subscription table (Back#75).
-module('20260815200000_push_subscription').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
ensure_table(push_subscription, record_info(fields, push_subscription)),
ensure_index(push_subscription, user_id),
ensure_index(push_subscription, endpoint),
ok.
down() ->
_ = mnesia:delete_table(push_subscription),
ok.
ensure_table(Table, Attrs) ->
case lists:member(Table, mnesia:system_info(tables)) of
true ->
ok;
false ->
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table}} -> ok;
{aborted, Reason} -> error({create_table_failed, Table, Reason})
end
end.
ensure_index(Table, Attr) ->
case mnesia:add_table_index(Table, Attr) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table, _Pos}} -> ok;
{aborted, {already_exists, Table, Attr}} -> ok;
{aborted, {already_exists, _}} -> ok;
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
end.
@@ -0,0 +1,104 @@
%% @doc calendar_share: id PK + mirror_to_default; create calendar_share_invite.
%% Old schema PK was calendar_id mnesia:transform_table cannot change the key,
%% so we rebuild the table (feature was unused until Back#73).
-module('20260815220000_calendar_share_invite').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
rebuild_calendar_share_if_needed(),
ensure_index(calendar_share, calendar_id),
ensure_index(calendar_share, user_id),
ensure_table(calendar_share_invite, record_info(fields, calendar_share_invite)),
ensure_index(calendar_share_invite, calendar_id),
ensure_index(calendar_share_invite, invitee_user_id),
ensure_index(calendar_share_invite, invitee_email),
ensure_index(calendar_share_invite, token),
ensure_index(calendar_share_invite, status),
ok.
down() ->
_ = mnesia:delete_table(calendar_share_invite),
ok.
rebuild_calendar_share_if_needed() ->
case lists:member(calendar_share, mnesia:system_info(tables)) of
false ->
ensure_table(calendar_share, record_info(fields, calendar_share));
true ->
Attrs = mnesia:table_info(calendar_share, attributes),
case Attrs =:= record_info(fields, calendar_share) of
true ->
ok;
false ->
Rows = dump_calendar_share_rows(),
case mnesia:delete_table(calendar_share) of
{atomic, ok} -> ok;
{aborted, Reason} -> error({delete_calendar_share_failed, Reason})
end,
ensure_table(calendar_share, record_info(fields, calendar_share)),
case mnesia:wait_for_tables([calendar_share], 30000) of
ok -> ok;
{timeout, _} -> error({wait_calendar_share_timeout})
end,
lists:foreach(fun(Rec) -> ok = mnesia:dirty_write(Rec) end, Rows),
ok
end
end.
dump_calendar_share_rows() ->
Raw = try mnesia:dirty_select(calendar_share, [{'_', [], ['$_']}])
catch _:_ -> []
end,
[transform_row(R) || R <- Raw].
transform_row({calendar_share, CalId, UserId, Rights})
when is_binary(CalId), is_binary(UserId) ->
#calendar_share{
id = infra_utils:generate_id(16),
calendar_id = CalId,
user_id = UserId,
rights = Rights,
mirror_to_default = false
};
transform_row({calendar_share, Id, CalId, UserId, Rights})
when is_binary(Id), is_binary(CalId), is_binary(UserId) ->
#calendar_share{
id = Id,
calendar_id = CalId,
user_id = UserId,
rights = Rights,
mirror_to_default = false
};
transform_row(#calendar_share{} = Rec) ->
Rec;
transform_row(Other) ->
error({unexpected_calendar_share_row, Other}).
ensure_table(Table, Attrs) ->
case lists:member(Table, mnesia:system_info(tables)) of
true ->
ok;
false ->
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table}} -> ok;
{aborted, Reason} -> error({create_table_failed, Table, Reason})
end
end.
ensure_index(Table, Attr) ->
case lists:member(Table, mnesia:system_info(tables)) of
false ->
ok;
true ->
case mnesia:add_table_index(Table, Attr) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table, _Pos}} -> ok;
{aborted, {already_exists, Table, Attr}} -> ok;
{aborted, {already_exists, _}} -> ok;
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
end
end.
@@ -0,0 +1,45 @@
%% @doc Add device_name and user_agent to auth_session (Back#74).
-module('20260816180000_auth_session_device').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
case table_exists(auth_session) of
false ->
ok;
true ->
ensure_device_fields(),
ok
end.
down() ->
ok.
table_exists(Table) ->
try lists:member(Table, mnesia:system_info(tables))
catch
_:_ -> false
end.
ensure_device_fields() ->
Attrs = mnesia:table_info(auth_session, attributes),
case lists:member(device_name, Attrs) andalso lists:member(user_agent, Attrs) of
true ->
ok;
false ->
Fun = fun(Rec) ->
case tuple_size(Rec) of
%% auth_session atom + 10 fields (pre-device)
11 ->
list_to_tuple(tuple_to_list(Rec) ++ [<<>>, <<>>]);
_ ->
Rec
end
end,
case mnesia:transform_table(auth_session, Fun, record_info(fields, auth_session)) of
{atomic, ok} -> ok;
{aborted, Reason} -> error({transform_auth_session_failed, Reason})
end
end.
@@ -0,0 +1,41 @@
%% @doc month_snapshot table (Back#76). disc_only blobs not in RAM.
-module('20260817140000_month_snapshot').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
ensure_table(),
ensure_index(month_snapshot, calendar_id),
ensure_index(month_snapshot, year_month),
ok.
down() ->
_ = mnesia:delete_table(month_snapshot),
ok.
ensure_table() ->
case lists:member(month_snapshot, mnesia:system_info(tables)) of
true ->
ok;
false ->
Attrs = record_info(fields, month_snapshot),
case mnesia:create_table(month_snapshot, [
{disc_only_copies, [node()]},
{attributes, Attrs}
]) of
{atomic, ok} -> ok;
{aborted, {already_exists, month_snapshot}} -> ok;
{aborted, Reason} -> error({create_table_failed, month_snapshot, Reason})
end
end.
ensure_index(Table, Attr) ->
case mnesia:add_table_index(Table, Attr) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table, _Pos}} -> ok;
{aborted, {already_exists, Table, Attr}} -> ok;
{aborted, {already_exists, _}} -> ok;
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
end.
@@ -0,0 +1,37 @@
%% @doc Create ai_hint_metric table пилотные метрики ИИ-подсказок (PRODUCT-AI, Spec#22).
-module('20260820180000_ai_hint_metric').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
ensure_table(ai_hint_metric, record_info(fields, ai_hint_metric)),
ensure_index(ai_hint_metric, user_id),
ensure_index(ai_hint_metric, day),
ok.
down() ->
_ = mnesia:delete_table(ai_hint_metric),
ok.
ensure_table(Table, Attrs) ->
case lists:member(Table, mnesia:system_info(tables)) of
true ->
ok;
false ->
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table}} -> ok;
{aborted, Reason} -> error({create_table_failed, Table, Reason})
end
end.
ensure_index(Table, Attr) ->
case mnesia:add_table_index(Table, Attr) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table, _Pos}} -> ok;
{aborted, {already_exists, Table, Attr}} -> ok;
{aborted, {already_exists, _}} -> ok;
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
end.
-50
View File
@@ -646,56 +646,6 @@
} }
} }
}, },
"/v1/calendars/:calendar_id/view": {
"get": {
"description": "Get calendar HTML view for a specific month",
"tags": [
"Calendars"
],
"parameters": [
{
"in": "path",
"name": "calendar_id",
"description": "Calendar ID",
"schema": {
"type": "string"
},
"required": true
},
{
"in": "query",
"name": "month",
"description": "Month in YYYY-MM format",
"schema": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}$"
},
"required": true
}
],
"responses": {
"200": {
"description": "HTML calendar page",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Missing or invalid 'month' parameter"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Access denied"
}
}
}
},
"/v1/calendars/:id": { "/v1/calendars/:id": {
"delete": { "delete": {
"description": "Delete calendar", "description": "Delete calendar",
+14 -1
View File
@@ -14,6 +14,7 @@ admin() ->
admin_handler_node_metrics, admin_handler_node_metrics,
admin_handler_login, admin_handler_login,
admin_handler_refresh, admin_handler_refresh,
admin_handler_logout,
% ================== ПОЛЬЗОВАТЕЛИ ================== % ================== ПОЛЬЗОВАТЕЛИ ==================
admin_handler_users, admin_handler_users,
admin_handler_user_by_id, admin_handler_user_by_id,
@@ -48,6 +49,8 @@ admin() ->
admin_handler_subscriptions, admin_handler_subscriptions,
admin_handler_subscription_stats, admin_handler_subscription_stats,
admin_handler_subscriptions_by_id, admin_handler_subscriptions_by_id,
% ================== ИИ-МЕТРИКИ ==================
admin_handler_ai_metrics,
% ================== МОДЕРАЦИЯ (общий маршрут) ================== % ================== МОДЕРАЦИЯ (общий маршрут) ==================
admin_handler_moderation, admin_handler_moderation,
% ================== Управление ролями (только для superadmin) ================== % ================== Управление ролями (только для superadmin) ==================
@@ -69,6 +72,8 @@ user() ->
handler_register, handler_register,
handler_login, handler_login,
handler_refresh, handler_refresh,
handler_logout,
handler_sessions,
handler_verify, handler_verify,
handler_forgot_password, handler_forgot_password,
handler_reset_password, handler_reset_password,
@@ -80,16 +85,19 @@ user() ->
handler_calendar_specialist_invites, handler_calendar_specialist_invites,
handler_specialist_invites, handler_specialist_invites,
handler_users_lookup, handler_users_lookup,
handler_calendar_view,
handler_calendars, handler_calendars,
handler_event_by_id, handler_event_by_id,
handler_event_occurrences, handler_event_occurrences,
handler_events, handler_events,
handler_event_waitlist,
handler_reports, handler_reports,
handler_review_by_id, handler_review_by_id,
handler_review_vote, handler_review_vote,
handler_reviews, handler_reviews,
handler_search, handler_search,
handler_geo,
handler_ai_metrics,
handler_ai_transcribe,
handler_subscription, handler_subscription,
handler_ticket_by_id, handler_ticket_by_id,
handler_tickets, handler_tickets,
@@ -98,6 +106,11 @@ user() ->
handler_user_studio_bookings, handler_user_studio_bookings,
handler_user_following, handler_user_following,
handler_user_me, handler_user_me,
handler_user_avatar,
handler_notification_prefs,
handler_push,
handler_calendar_cover,
handler_media,
handler_user_reviews handler_user_reviews
], ],
lists:flatmap(fun trails_from_module/1, Modules). lists:flatmap(fun trails_from_module/1, Modules).
+26 -2
View File
@@ -205,8 +205,7 @@ test_ws_connect_debug(Url, Token) ->
ct:pal(" Host: ~s", [Host]), ct:pal(" Host: ~s", [Host]),
ct:pal(" Port: ~p", [Port]), ct:pal(" Port: ~p", [Port]),
ct:pal(" Path: ~s", [Path]), ct:pal(" Path: ~s", [Path]),
{ok, ConnPid} = gun:open(Host, Port, Opts), ConnPid = ws_connect_up(Host, Port, Opts, 3),
{ok, http} = gun:await_up(ConnPid, 5000),
Headers = [{<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))}], Headers = [{<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))}],
StreamRef = gun:ws_upgrade(ConnPid, Path, Headers), StreamRef = gun:ws_upgrade(ConnPid, Path, Headers),
receive receive
@@ -243,6 +242,31 @@ test_ws_connect_debug(Url, Token) ->
{error, timeout} {error, timeout}
end. end.
%% @doc TCP/TLS-коннект с ретраями: сразу после deploy-stage traefik/бэкенд
%% могут быть нестабильны, и одиночный 5s-таймаут ронял сьюит
%% (badmatch {error,timeout}).
-spec ws_connect_up(string(), integer(), map(), non_neg_integer()) -> pid().
ws_connect_up(Host, Port, Opts, Retries) ->
Result =
case gun:open(Host, Port, Opts) of
{ok, ConnPid} ->
case gun:await_up(ConnPid, 5000) of
{ok, http} -> {ok, ConnPid};
{error, Reason} -> gun:close(ConnPid), {error, Reason}
end;
{error, Reason} -> {error, Reason}
end,
case Result of
{ok, Pid} -> Pid;
{error, Err} when Retries > 1 ->
ct:pal(" WS connect failed (~p), retry in 2s (~p attempts left)",
[Err, Retries - 1]),
timer:sleep(2000),
ws_connect_up(Host, Port, Opts, Retries - 1);
{error, Err} ->
error({ws_connect_failed, Err})
end.
test_ws_send(ConnPid, Data) -> test_ws_send(ConnPid, Data) ->
Msg = jsx:encode(Data), Msg = jsx:encode(Data),
ct:pal(" Sending: ~s", [Msg]), ct:pal(" Sending: ~s", [Msg]),
@@ -20,6 +20,7 @@ test() ->
test_get_calendar(Token, CalId), test_get_calendar(Token, CalId),
test_get_calendar_guest(Token, CalId), test_get_calendar_guest(Token, CalId),
test_get_calendar_guest_short_name(Token, CalId),
test_get_calendar_not_found(Token), test_get_calendar_not_found(Token),
test_update_calendar(Token, CalId), test_update_calendar(Token, CalId),
test_update_calendar_settings(Token, CalId), test_update_calendar_settings(Token, CalId),
@@ -49,6 +50,20 @@ test_get_calendar_guest(Token, CalId) ->
{ok, 403, _, _} = api_test_runner:client_request(get, PPath, <<>>), {ok, 403, _, _} = api_test_runner:client_request(get, PPath, <<>>),
ct:pal(" OK: guest commercial 200, personal 403"). ct:pal(" OK: guest commercial 200, personal 403").
test_get_calendar_guest_short_name(Token, CalId) ->
ct:pal(" TEST: Guest GET commercial by short_name"),
PathId = <<"/v1/calendars/", CalId/binary>>,
Slug2 = <<"sn", (integer_to_binary(erlang:unique_integer([positive])))/binary>>,
_ = api_test_runner:client_put(PathId, Token, #{short_name => Slug2}),
PathSlug = <<"/v1/calendars/", Slug2/binary>>,
{ok, 200, _, Body} = api_test_runner:client_request(get, PathSlug, <<>>),
Dec = jsx:decode(list_to_binary(Body), [return_maps]),
?assertEqual(CalId, maps:get(<<"id">>, Dec)),
?assertEqual(Slug2, maps:get(<<"short_name">>, Dec)),
EvPath = <<"/v1/calendars/", Slug2/binary, "/events">>,
{ok, 200, _, _} = api_test_runner:client_request(get, EvPath, <<>>),
ct:pal(" OK: guest GET by short_name").
test_get_calendar_not_found(Token) -> test_get_calendar_not_found(Token) ->
ct:pal(" TEST: Get non-existent calendar (404)"), ct:pal(" TEST: Get non-existent calendar (404)"),
Resp = api_test_runner:client_request(get, <<"/v1/calendars/fakeid">>, Token), Resp = api_test_runner:client_request(get, <<"/v1/calendars/fakeid">>, Token),
@@ -1,61 +0,0 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты клиентского API для HTML-представления календаря.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/calendars/:calendar_id/view
%%%
%%% Проверяет:
%%% - успешное получение HTML-страницы (200, text/html)
%%% - ошибку 401 без токена
%%% @end
%%%-------------------------------------------------------------------
-module(user_calendar_view_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== User Calendar View Tests ==="),
Token = api_test_runner:get_user_token(),
% Создаём календарь
CalId = api_test_runner:create_calendar(Token, #{title => <<"ViewCal">>}),
test_get_calendar_view(Token, CalId),
test_get_calendar_view_unauthorized(CalId),
ct:pal("=== All user calendar view tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc Успешный запрос HTML-представления: 200 OK, тип text/html.
-spec test_get_calendar_view(binary(), binary()) -> ok.
test_get_calendar_view(Token, CalId) ->
ct:pal(" TEST: Get calendar HTML view"),
Path = <<"/v1/calendars/", CalId/binary, "/view?month=2026-06">>,
Resp = api_test_runner:client_request(get, Path, Token),
{ok, 200, Headers, Body} = Resp,
?assert(lists:keymember("content-type", 1, Headers)),
{"content-type", CT} = lists:keyfind("content-type", 1, Headers),
?assert(string:str(CT, "text/html") > 0),
% Body может быть строкой или binary, приводим к binary и проверяем непустоту
BodyBin = iolist_to_binary(Body),
?assert(byte_size(BodyBin) > 0),
ct:pal(" OK: got HTML of ~p bytes", [byte_size(BodyBin)]).
%% @doc Запрос без токена: 401 Unauthorized.
-spec test_get_calendar_view_unauthorized(binary()) -> ok.
test_get_calendar_view_unauthorized(CalId) ->
ct:pal(" TEST: Get calendar view without token"),
Path = <<"/v1/calendars/", CalId/binary, "/view?month=2026-06">>,
Resp = api_test_runner:client_request(get, Path, <<>>),
?assertMatch({ok, 401, _, _}, Resp),
ct:pal(" OK: got 401").
+26 -2
View File
@@ -91,8 +91,7 @@ test_ws_connect_debug(Url, Token) ->
ct:pal(" Host: ~s", [Host]), ct:pal(" Host: ~s", [Host]),
ct:pal(" Port: ~p", [Port]), ct:pal(" Port: ~p", [Port]),
ct:pal(" Path: ~s", [Path]), ct:pal(" Path: ~s", [Path]),
{ok, ConnPid} = gun:open(Host, Port, Opts), ConnPid = ws_connect_up(Host, Port, Opts, 3),
{ok, http} = gun:await_up(ConnPid, 5000),
Headers = [{<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))}], Headers = [{<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))}],
StreamRef = gun:ws_upgrade(ConnPid, Path, Headers), StreamRef = gun:ws_upgrade(ConnPid, Path, Headers),
receive receive
@@ -129,6 +128,31 @@ test_ws_connect_debug(Url, Token) ->
{error, timeout} {error, timeout}
end. end.
%% @doc TCP/TLS-коннект с ретраями: сразу после deploy-stage traefik/бэкенд
%% могут быть нестабильны, и одиночный 5s-таймаут ронял сьюит
%% (badmatch {error,timeout}).
-spec ws_connect_up(string(), integer(), map(), non_neg_integer()) -> pid().
ws_connect_up(Host, Port, Opts, Retries) ->
Result =
case gun:open(Host, Port, Opts) of
{ok, ConnPid} ->
case gun:await_up(ConnPid, 5000) of
{ok, http} -> {ok, ConnPid};
{error, Reason} -> gun:close(ConnPid), {error, Reason}
end;
{error, Reason} -> {error, Reason}
end,
case Result of
{ok, Pid} -> Pid;
{error, Err} when Retries > 1 ->
ct:pal(" WS connect failed (~p), retry in 2s (~p attempts left)",
[Err, Retries - 1]),
timer:sleep(2000),
ws_connect_up(Host, Port, Opts, Retries - 1);
{error, Err} ->
error({ws_connect_failed, Err})
end.
test_ws_send(ConnPid, Data) -> test_ws_send(ConnPid, Data) ->
Msg = jsx:encode(Data), Msg = jsx:encode(Data),
ct:pal(" Sending: ~s", [Msg]), ct:pal(" Sending: ~s", [Msg]),
-4
View File
@@ -32,7 +32,6 @@ all() ->
user_test_user_me, user_test_user_me,
user_test_calendars, user_test_calendars,
user_test_calendar_by_id, user_test_calendar_by_id,
user_test_calendar_view,
user_test_event_by_id, user_test_event_by_id,
user_test_events, user_test_events,
user_test_occurrence_cancel, user_test_occurrence_cancel,
@@ -122,9 +121,6 @@ user_test_calendars(_Config) ->
user_test_calendar_by_id(_Config) -> user_test_calendar_by_id(_Config) ->
user_calendar_by_id_tests:test(). user_calendar_by_id_tests:test().
user_test_calendar_view(_Config) ->
user_calendar_view_tests:test().
user_test_events(_Config) -> user_test_events(_Config) ->
user_events_tests:test(). user_events_tests:test().
+5 -1
View File
@@ -224,7 +224,11 @@ def do_random_action(bot):
resp_cal = request("GET", f"{base}/v1/calendars", headers=headers) resp_cal = request("GET", f"{base}/v1/calendars", headers=headers)
if resp_cal and resp_cal.status_code == 200 and resp_cal.json(): if resp_cal and resp_cal.status_code == 200 and resp_cal.json():
cal = random.choice(resp_cal.json()) cal = random.choice(resp_cal.json())
request("GET", f"{base}/v1/calendars/{cal['id']}/view?month=2026-06", headers=headers) request(
"GET",
f"{base}/v1/calendars/{cal['id']}/events?from=2026-06-01T00:00:00Z&to=2026-06-30T23:59:59Z",
headers=headers,
)
elif action == 14: elif action == 14:
request("POST", f"{base}/v1/refresh", json={"refresh_token": "dummy"}, headers=headers) request("POST", f"{base}/v1/refresh", json={"refresh_token": "dummy"}, headers=headers)
except Exception as e: except Exception as e:
+2 -2
View File
@@ -55,9 +55,9 @@
</request> </request>
<thinktime min="500" max="2000" random="true"/> <thinktime min="500" max="2000" random="true"/>
<!-- 5. GET /v1/calendars/:id/view?month=2026-06 HTML-представление --> <!-- 5. GET /v1/calendars/:id/events месяц (hot + archive merge) -->
<request subst="true"> <request subst="true">
<http url="/v1/calendars/%%_calendar_id%%/view?month=2026-06" method="GET"> <http url="/v1/calendars/%%_calendar_id%%/events?from=2026-06-01T00:00:00Z&amp;to=2026-06-30T23:59:59Z" method="GET">
<http_header name="Authorization" value="Bearer %%_token%%"/> <http_header name="Authorization" value="Bearer %%_token%%"/>
</http> </http>
</request> </request>
+1 -1
View File
@@ -31,7 +31,7 @@ admin_refresh_test_() ->
]}. ]}.
test_refresh_ok() -> test_refresh_ok() ->
{ok, _Access, Refresh} = logic_auth_session:issue_admin_tokens(?ADMIN_ID, <<"admin">>), {ok, _Access, Refresh, _} = logic_auth_session:issue_admin_tokens(?ADMIN_ID, <<"admin">>),
{Status, _, Body} = eh_test_support:call(admin_handler_refresh, #{ {Status, _, Body} = eh_test_support:call(admin_handler_refresh, #{
method => <<"POST">>, method => <<"POST">>,
path => <<"/v1/admin/refresh">>, path => <<"/v1/admin/refresh">>,
+1 -1
View File
@@ -2,7 +2,7 @@
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-include("records.hrl"). -include("records.hrl").
-define(TABLES, [user, calendar, event, booking, admin, subscription]). -define(TABLES, [user, calendar, event, booking, admin, subscription, calendar_share]).
setup() -> setup() ->
eh_test_support:start_mnesia(), eh_test_support:start_mnesia(),
@@ -0,0 +1,50 @@
-module(calendar_share_migration_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
setup() ->
mnesia:stop(),
mnesia:delete_schema([node()]),
mnesia:create_schema([node()]),
mnesia:start(),
{atomic, ok} = mnesia:create_table(calendar_share, [
{ram_copies, [node()]},
{attributes, [calendar_id, user_id, rights]}
]),
ok.
cleanup(_) ->
catch mnesia:delete_table(calendar_share),
catch mnesia:delete_table(calendar_share_invite),
mnesia:stop(),
mnesia:delete_schema([node()]),
ok.
calendar_share_migration_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"transforms old calendar_share to id+mirror", fun test_transform_old_share/0},
{"idempotent when already new schema", fun test_idempotent_new_schema/0}
]}.
test_transform_old_share() ->
%% Pre-#73 row shape: PK = calendar_id (no id / mirror_to_default).
ok = mnesia:dirty_write({calendar_share, <<"cal1">>, <<"u1">>, write}),
?assertEqual([calendar_id, user_id, rights],
mnesia:table_info(calendar_share, attributes)),
ok = '20260815220000_calendar_share_invite':up(),
?assertEqual(record_info(fields, calendar_share),
mnesia:table_info(calendar_share, attributes)),
[Rec] = mnesia:dirty_match_object(#calendar_share{calendar_id = <<"cal1">>, _ = '_'}),
?assertMatch(#calendar_share{user_id = <<"u1">>, rights = write,
mirror_to_default = false}, Rec),
?assert(is_binary(Rec#calendar_share.id)),
?assert(lists:member(calendar_share_invite, mnesia:system_info(tables))),
{ok, Share} = core_calendar_share:upsert(<<"cal2">>, <<"u2">>, admin, true),
?assertEqual(admin, Share#calendar_share.rights),
?assertEqual(true, Share#calendar_share.mirror_to_default).
test_idempotent_new_schema() ->
ok = '20260815220000_calendar_share_invite':up(),
ok = '20260815220000_calendar_share_invite':up(),
?assertEqual(record_info(fields, calendar_share),
mnesia:table_info(calendar_share, attributes)).
+23 -1
View File
@@ -28,9 +28,11 @@ cleanup(_) ->
core_auth_session_test_() -> core_auth_session_test_() ->
{foreach, fun setup/0, fun cleanup/1, [ {foreach, fun setup/0, fun cleanup/1, [
{"Create session", fun test_create/0}, {"Create session", fun test_create/0},
{"Create with device", fun test_create_device/0},
{"Rotate session updates jti", fun test_rotate/0}, {"Rotate session updates jti", fun test_rotate/0},
{"Reuse detection", fun test_reuse/0}, {"Reuse detection", fun test_reuse/0},
{"Revoke family", fun test_revoke_family/0} {"Revoke family", fun test_revoke_family/0},
{"List active and revoke others", fun test_list_and_revoke_others/0}
]}. ]}.
test_create() -> test_create() ->
@@ -38,9 +40,16 @@ test_create() ->
?assertEqual(<<"admin1">>, Session#auth_session.subject_id), ?assertEqual(<<"admin1">>, Session#auth_session.subject_id),
?assertEqual(admin, Session#auth_session.subject_type), ?assertEqual(admin, Session#auth_session.subject_type),
?assertEqual(false, Session#auth_session.revoked), ?assertEqual(false, Session#auth_session.revoked),
?assertEqual(<<>>, Session#auth_session.device_name),
{ok, Loaded} = core_auth_session:get(Session#auth_session.session_id), {ok, Loaded} = core_auth_session:get(Session#auth_session.session_id),
?assertEqual(Session#auth_session.session_id, Loaded#auth_session.session_id). ?assertEqual(Session#auth_session.session_id, Loaded#auth_session.session_id).
test_create_device() ->
{ok, Session} = core_auth_session:create(
<<"u1">>, user, <<"web">>, <<"Chrome · Win">>, <<"Mozilla">>),
?assertEqual(<<"Chrome · Win">>, Session#auth_session.device_name),
?assertEqual(<<"Mozilla">>, Session#auth_session.user_agent).
test_rotate() -> test_rotate() ->
{ok, Session} = core_auth_session:create(<<"admin1">>, admin, <<"admin">>), {ok, Session} = core_auth_session:create(<<"admin1">>, admin, <<"admin">>),
OldJti = Session#auth_session.current_jti, OldJti = Session#auth_session.current_jti,
@@ -61,3 +70,16 @@ test_revoke_family() ->
ok = core_auth_session:revoke_family(S1#auth_session.family_id), ok = core_auth_session:revoke_family(S1#auth_session.family_id),
?assertMatch({error, revoked}, ?assertMatch({error, revoked},
core_auth_session:rotate(S1#auth_session.session_id, S1#auth_session.current_jti)). core_auth_session:rotate(S1#auth_session.session_id, S1#auth_session.current_jti)).
test_list_and_revoke_others() ->
{ok, Keep} = core_auth_session:create(<<"u1">>, user, <<"web">>, <<"A">>, <<"ua">>),
{ok, Other} = core_auth_session:create(<<"u1">>, user, <<"mobile">>, <<"B">>, <<"ub">>),
Active = core_auth_session:list_active_for_subject(<<"u1">>, user),
?assertEqual(2, length(Active)),
{ok, 1} = core_auth_session:revoke_others_for_subject(
<<"u1">>, user, Keep#auth_session.session_id),
Left = core_auth_session:list_active_for_subject(<<"u1">>, user),
?assertEqual(1, length(Left)),
?assertEqual(Keep#auth_session.session_id, (hd(Left))#auth_session.session_id),
{ok, Revoked} = core_auth_session:get(Other#auth_session.session_id),
?assertEqual(true, Revoked#auth_session.revoked).
+19 -3
View File
@@ -87,6 +87,7 @@ ensure_indexes(Tables) when is_list(Tables) ->
{event, [calendar_id, title, created_at, start_time, event_type, {event, [calendar_id, title, created_at, start_time, event_type,
master_id, specialist_id, status]}, master_id, specialist_id, status]},
{booking, [event_id, user_id, status]}, {booking, [event_id, user_id, status]},
{waitlist_entry, [event_id, user_id, status]},
{review_vote, [review_id, user_id]}, {review_vote, [review_id, user_id]},
{calendar, [owner_id, status, short_name, category]}, {calendar, [owner_id, status, short_name, category]},
{calendar_specialist, [calendar_id, user_id]}, {calendar_specialist, [calendar_id, user_id]},
@@ -95,9 +96,12 @@ ensure_indexes(Tables) when is_list(Tables) ->
{verification, [user_id]}, {verification, [user_id]},
{password_reset, [user_id]}, {password_reset, [user_id]},
{notification, [user_id, is_read]}, {notification, [user_id, is_read]},
{push_subscription, [user_id, endpoint]},
{auth_session, [family_id, subject_id]}, {auth_session, [family_id, subject_id]},
{report, [resolved_by]}, {report, [resolved_by]},
{ticket, [assigned_to]} {ticket, [assigned_to]},
{month_snapshot, [calendar_id, year_month]},
{ai_hint_metric, [user_id, day]}
]), ]),
ok. ok.
@@ -141,7 +145,11 @@ table_opts(admin) ->
table_opts(calendar) -> table_opts(calendar) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}];
table_opts(calendar_share) -> table_opts(calendar_share) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)},
{index, [calendar_id, user_id]}];
table_opts(calendar_share_invite) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share_invite)},
{index, [calendar_id, invitee_user_id, invitee_email, token, status]}];
table_opts(calendar_follow) -> table_opts(calendar_follow) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
table_opts(calendar_specialist) -> table_opts(calendar_specialist) ->
@@ -156,6 +164,8 @@ table_opts(recurrence_exception) ->
[{ram_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}]; [{ram_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
table_opts(booking) -> table_opts(booking) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, booking)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, booking)}];
table_opts(waitlist_entry) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, waitlist_entry)}];
table_opts(review) -> table_opts(review) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, review)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, review)}];
table_opts(review_vote) -> table_opts(review_vote) ->
@@ -176,6 +186,8 @@ table_opts(admin_audit) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, admin_audit)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
table_opts(notification) -> table_opts(notification) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, notification)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, notification)}];
table_opts(push_subscription) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, push_subscription)}];
table_opts(stats_counter) -> table_opts(stats_counter) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, stats_counter)}]; [{ram_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
table_opts(stats_daily) -> table_opts(stats_daily) ->
@@ -194,7 +206,11 @@ table_opts(node_metric) ->
[{ram_copies, [node()]}, {local_content, true}, [{ram_copies, [node()]}, {local_content, true},
{attributes, record_info(fields, node_metric)}]; {attributes, record_info(fields, node_metric)}];
table_opts(schema_migration) -> table_opts(schema_migration) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, schema_migration)}]. [{ram_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
table_opts(month_snapshot) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}];
table_opts(ai_hint_metric) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, ai_hint_metric)}].
%%%=================================================================== %%%===================================================================
%%% Domain seeds %%% Domain seeds

Some files were not shown because too many files have changed in this diff Show More