78 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
aleksey 2567585333 feat(booking): studio journal and instance inbox. Refs EventHub/EventHubFront#60
CI / test (push) Successful in 7m31s
CI / deploy-ift (push) Successful in 2m38s
CI / e2e-ift (push) Successful in 1m26s
CI / deploy-stage (push) Successful in 2m3s
CI / e2e-stage (push) Successful in 1m21s
2026-08-14 11:13:01 +03:00
aleksey 520af23cc6 feat(api): optional auth for public search and studio week. Refs EventHub/EventHubFront#58 2026-08-14 11:02:58 +03:00
aleksey 8a5f254c2a feat(booking): materialize occurrence on POST bookings. Refs EventHub/EventHubBack#67 2026-08-14 10:52:24 +03:00
aleksey e3403f6ed6 fix(ift): ObserverWeb mount at "" so CSS/JS paths are not //css-*
CI / test (push) Successful in 7m23s
CI / deploy-ift (push) Successful in 5m31s
CI / e2e-ift (push) Successful in 1m31s
CI / deploy-stage (push) Successful in 3m16s
CI / e2e-stage (push) Successful in 1m22s
observer_dashboard("/") makes asset href protocol-relative (//css-HASH). Empty path keeps assets at /css-HASH.
2026-08-13 11:27:41 +03:00
aleksey 37011f66a1 fix(ift): mount ObserverWeb dashboard at / on dedicated host
CI / test (push) Successful in 7m42s
CI / deploy-ift (push) Successful in 5m40s
CI / e2e-ift (push) Successful in 1m28s
CI / deploy-stage (push) Successful in 2m12s
CI / e2e-stage (push) Successful in 1m20s
observer.ift.calentiq.com already has its own subdomain; /observer path prefix is redundant.
2026-08-13 10:54:19 +03:00
aleksey c89d08968d fix(test): drop redundant user.email index setup
CI / test (push) Successful in 7m8s
CI / deploy-ift (push) Successful in 2m53s
CI / e2e-ift (push) Successful in 12m1s
CI / deploy-stage (push) Successful in 2m18s
CI / e2e-stage (push) Successful in 1m18s
ensure_tables already adds the email index; a second add_table_index
aborted with {already_exists,user,3} and failed the suite setup.
2026-08-04 10:46:32 +03:00
aleksey 72bc440049 fix(test): add mnesia secondary indexes in eh_test_support
CI / test (push) Failing after 9m25s
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
EUnit tables must mirror infra_mnesia:create_indices so dirty_index_read
paths in booking/event/search/user do not abort with badarg.
2026-08-04 10:27:58 +03:00
aleksey 6c0775ee5e feat(infra): add TTL cleanup worker for append-only tables
CI / test (push) Failing after 5m58s
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
Periodically prune old read notifications, revoked/expired auth sessions,
and admin_audit in chunked transactions; wire under eventhub config.
2026-08-03 22:57:41 +03:00
aleksey 774f58f9ed fix(user): transactional status updates and paginated list_users
Protect status/login/bot writes with transactions, fix abort Reason
shadowing, keep create_bot email_exists, and add list_users/2 pagination.
2026-08-03 22:56:16 +03:00
aleksey d88689f1a3 fix(auth): make auth_session mutations transactional
Wrap create/rotate/revoke paths in mnesia transactions so concurrent
refresh and family revoke stay atomic; align revoke_* specs with errors.
2026-08-03 22:55:52 +03:00
aleksey e3467e7412 perf(mnesia): use secondary indexes in booking, event, search, review
Replace hot-path dirty_match_object scans with dirty_index_read and batch
calendar lookup in search to cut N+1 enrichment cost.
2026-08-03 22:55:50 +03:00
aleksey 104892bdde fix(ci): remove orphan containers before mnesia volume wipe [skip ci]
After Swarm scale=0, shutdown containers can still hold eventhub-data-N;
force-rm holders and retry volume rm so wipe-mnesia-ift does not fail.
2026-08-01 18:50:07 +03:00
aleksey 2b8fc19822 ci: extract wipe-mnesia into standalone workflows [skip ci]
Move IFT/stage Mnesia wipe out of CI pipeline choice so Gitea Actions shows them as separate dispatch workflows.
2026-08-01 18:43:46 +03:00
aleksey 4eb1f326ec feat(ci): add manual wipe-mnesia jobs for IFT/stage [skip ci]
workflow_dispatch only: wipe-mnesia-ift / wipe-mnesia-stage and scripts/ci-full-mnesia-wipe.sh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 18:30:11 +03:00
aleksey 0a5e86c4a0 fix(recurrence): honor BYDAY for weekly RRULE expansion
CI / test (push) Successful in 8m33s
CI / deploy-ift (push) Successful in 3m26s
CI / e2e-ift (push) Successful in 3m44s
CI / deploy-stage (push) Successful in 2m14s
CI / e2e-stage (push) Successful in 1m21s
Expand weekdays in the DTSTART week and advance by Interval weeks from Monday;
add unit coverage for MO/WE/FR and mid-week starts.
2026-08-01 18:17:23 +03:00
aleksey 0e4e886cd4 feat(specialists): owner is non-deletable specialist on commercial create
CI / test (push) Successful in 8m35s
CI / deploy-ift (push) Successful in 21m2s
CI / e2e-ift (push) Successful in 2m3s
CI / deploy-stage (push) Successful in 2m9s
CI / e2e-stage (push) Successful in 1m16s
Bootstrap calendar_specialist for owner (active); DELETE owner → 403;
toggle via status. Fixes solo assign-to-slot. Refs EventHub/EventHubBack#66
2026-08-01 12:18:36 +03:00
aleksey ba1649f344 fix(ct): teardown litter users/calendars after API suites
CI / test (push) Successful in 7m3s
CI / deploy-ift (push) Successful in 3m53s
CI / e2e-ift (push) Successful in 3m34s
CI / deploy-stage (push) Successful in 2m19s
CI / e2e-stage (push) Successful in 1m19s
Track users/calendars created via register_and_login and create_calendar;
soft-delete via admin API in end_per_suite. Skip with CT_KEEP_DATA=1.
Safety-net soft-deletes remaining @test.local users.

Fixes EventHub/EventHubBack#65
2026-07-31 12:21:15 +03:00
aleksey 7d39c004a1 fix(ct): adapt API tests to sole Default personal
CI / test (push) Successful in 8m11s
CI / deploy-ift (push) Successful in 3m30s
CI / e2e-ift (push) Successful in 6m10s
CI / deploy-stage (push) Successful in 3m3s
CI / e2e-stage (push) Successful in 1m15s
Default create_calendar to commercial; reuse existing personal on 409;
assert verify title Default; cover personal_exists / default_calendar.

Refs EventHub/EventHubBack#64
2026-07-30 22:29:52 +03:00
aleksey 10464de3ec feat(calendar): single Default personal with guards and backfill
CI / test (push) Failing after 7m22s
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
Ensure title Default; reject second personal and delete/type-change of
the sole personal; migrate extras to commercial or soft-delete.

Refs EventHub/EventHubBack#64
2026-07-30 22:07:15 +03:00
aleksey bb37909ffb fix(events): guard booking_occupancy when booking table missing
CI / test (push) Successful in 6m45s
CI / deploy-ift (push) Successful in 4m5s
CI / e2e-ift (push) Successful in 9m0s
CI / deploy-stage (push) Successful in 2m36s
CI / e2e-stage (push) Successful in 1m18s
Admin eunit suites call event_to_json without Mnesia booking table;
catch list_by_event failures and treat as free.
2026-07-30 15:56:49 +03:00
aleksey edd396dff8 feat(events): add booking_occupancy on event JSON for studio grid
CI / test (push) Failing after 6m23s
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
Expose free|pending|confirmed so commercial calendar can aggregate
slots by occupancy without N GET /bookings calls.
2026-07-30 15:33:49 +03:00
aleksey 1fce1aa12d fix(ci): sync migration_engine_tests ALL_VERSIONS with bag migration
CI / test (push) Successful in 8m58s
CI / deploy-ift (push) Successful in 3m30s
CI / e2e-ift (push) Successful in 2m10s
CI / deploy-stage (push) Successful in 2m17s
CI / e2e-stage (push) Successful in 1m15s
test_join_wait timed out waiting for pending 20260730120000_recurrence_exception_bag.

Refs EventHub/EventHubFront#42
2026-07-30 13:27:57 +03:00
aleksey 32d3e7378e fix(event): store multiple recurrence cancel exceptions
CI / test (push) Failing after 10m4s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been cancelled
CI / deploy-stage (push) Has been cancelled
CI / e2e-stage (push) Has been cancelled
Use bag table for recurrence_exception so canceling several WEEKLY occurrences keeps all exceptions (set key was only master_id).

Refs EventHub/EventHubFront#42
2026-07-30 13:07:13 +03:00
aleksey 4986baf006 feat(calendar): validate org defaults in calendar.settings
CI / test (push) Successful in 7m30s
CI / deploy-ift (push) Successful in 3m46s
CI / e2e-ift (push) Successful in 1m28s
CI / deploy-stage (push) Successful in 2m11s
CI / e2e-stage (push) Successful in 1m15s
Documented keys default_location, default_duration_minutes,
default_recurrence; unknown keys (week_patterns) passthrough;
invalid known shape returns 400.

Refs EventHub/EventHubBack#63
2026-07-29 22:50:31 +03:00
aleksey 4c480f1c43 fix(test): lookup banned words via q after batch
CI / test (push) Successful in 6m38s
CI / deploy-ift (push) Successful in 6m53s
CI / e2e-ift (push) Successful in 2m16s
CI / deploy-stage (push) Successful in 2m15s
CI / e2e-stage (push) Successful in 1m17s
Bare GET /banned-words is paginated (limit 50); on dirty IFT the
batch words fall off page 1 and admin_test_banned_words flakes.
Assert via ?q= like the single-add case; same for delete check.
2026-07-29 15:58:41 +03:00
aleksey faf251b11d fix(calendar): persist settings on PUT/POST
CI / test (push) Successful in 7m4s
CI / deploy-ift (push) Successful in 3m21s
CI / e2e-ift (push) Failing after 52s
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
Accept calendar.settings (e.g. week_patterns) in validate_update
and convert_field; apply on create like tags. Unblocks Front#42
pattern save which previously got 200 with empty settings.
2026-07-29 15:00:55 +03:00
aleksey 7e14dbcf8b docs(rules): require run-wsl-sh for CRLF .sh scripts
CI / test (push) Successful in 6m58s
CI / deploy-ift (push) Successful in 2m47s
CI / e2e-ift (push) Failing after 1m26s
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
Cursor Write on Windows often saves CRLF; bare wsl -e bash script.sh
then fails with No such file. Canon is run-wsl-sh.sh (strip CR).
2026-07-29 12:55:01 +03:00
aleksey 76548e578b chore(make): add verification-token target for multi-stand email lookup
make verification-token EMAIL=… STAND=local|dev|ift|stage|calentiq
fetches admin verification token via Admin API. [skip ci]
2026-07-28 22:26:44 +03:00
aleksey bc85f5ab06 fix(ci-reports): chmod a+r on report.tar.gz for nginx read
mktemp leaves 600; nginx worker got 403 Forbidden on download.

[skip ci]
2026-07-28 19:42:14 +03:00
aleksey 3c2c33f83b feat(ci-reports): publish report.tar.gz archive for offline download
Pack HTML report after publish; link archive in run index and CI log.
Add fetch-ci-report.sh helper for agent curl/extract.

[skip ci]
2026-07-28 13:52:01 +03:00
aleksey 85a34dd7b0 docs(rules): git commit via -F file when message has parentheses
Document fix(ci): and special chars breaking wsl -lc one-liners.
Use .tmp-commit-msg.txt + GIT_EDITOR=true git commit -F in .sh scripts.

[skip ci]
2026-07-28 10:20:54 +03:00
aleksey 7bd6e52d18 fix(ci): auto-detect stand for ci-reports URL on IFT runner
Remove hardcoded dev CI_REPORTS_BASE_URL/RUNNER_STAND from workflow.
detect_stand() picks ift/stage/dev from host paths and swarm services.

[skip ci]
2026-07-28 10:16:20 +03:00
aleksey 5ed361d8ff fix(ci): publish reports to ci-reports.dev on runner host
CI / test (push) Successful in 6m44s
CI / deploy-ift (push) Successful in 3m3s
CI / e2e-ift (push) Failing after 2m23s
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
2026-07-28 00:39:10 +03:00
aleksey 5169df3d40 fix(ci): chown ci-reports dir for act runner publish
CI / test (push) Successful in 7m28s
CI / deploy-ift (push) Has been cancelled
CI / e2e-ift (push) Has been cancelled
CI / deploy-stage (push) Has been cancelled
CI / e2e-stage (push) Has been cancelled
2026-07-28 00:29:09 +03:00
aleksey 8305bd1a86 fix(ct): skip node_metric noise when waiting for admin WS pong [skip ci] 2026-07-27 23:19:27 +03:00
aleksey b3792a304b docs: forbid bare git push; use git-push.sh helpers.
[skip ci]
2026-07-27 23:10:45 +03:00
aleksey 78d410c3e3 chore: push without CI
[skip ci]
2026-07-27 22:57:20 +03:00
aleksey 2bfce06dbf Publish EUnit/CT HTML reports to ci-reports host.
Refs EventHub/EventHubBack#62
2026-07-27 22:54:52 +03:00
aleksey cd619db4a2 fix(ct): activate trial before commercial calendar in admin WS tests.
CI / test (push) Successful in 6m41s
CI / deploy-ift (push) Successful in 4m23s
CI / e2e-ift (push) Successful in 54s
CI / deploy-stage (push) Successful in 2m48s
CI / e2e-stage (push) Successful in 1m12s
admin_test_websocket bypassed create_calendar and hit 402 after Back#61
subscription gate. Align with user_websocket_tests helper. Refs EventHub/EventHubBack#61
2026-07-27 22:05:20 +03:00
aleksey bd36f158b9 docs: agent pitfalls + token economy rules for Cursor [skip ci] 2026-07-27 21:50:40 +03:00
aleksey 81728301e2 fix: persist specialist_id on event create; 402 commercial without active sub. Fixes EventHub/EventHubBack#61
CI / test (push) Failing after 6m52s
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-07-27 21:17:15 +03:00
aleksey edb7c20f70 Expire past-pending bookings to expired status. Fixes EventHub/EventHubBack#60
CI / test (push) Successful in 7m14s
CI / deploy-ift (push) Successful in 3m40s
CI / e2e-ift (push) Successful in 1m43s
CI / deploy-stage (push) Successful in 3m8s
CI / e2e-stage (push) Successful in 1m12s
2026-07-27 16:45:30 +03:00
aleksey 1d9752bb69 feat: GET /v1/user/booking-requests + search image_url. Fixes EventHub/EventHubBack#59
CI / test (push) Successful in 7m1s
CI / deploy-ift (push) Successful in 5m59s
CI / e2e-ift (push) Successful in 1m34s
CI / deploy-stage (push) Successful in 2m47s
CI / e2e-stage (push) Successful in 1m18s
2026-07-27 14:53:25 +03:00
aleksey b25c0447cd chore: bump fallback VERSION to 0.2. Refs EventHub/EventHubSpec#10
CI / test (push) Successful in 7m1s
CI / deploy-ift (push) Successful in 3m48s
CI / e2e-ift (push) Successful in 1m27s
CI / deploy-stage (push) Successful in 2m25s
CI / e2e-stage (push) Successful in 1m14s
2026-07-26 22:05:43 +03:00
166 changed files with 11787 additions and 1456 deletions
+120
View File
@@ -0,0 +1,120 @@
---
description: Типичные ошибки агентов EventHub — shell, git, docker, scope, токены (корень + субагенты)
alwaysApply: true
---
# EventHub: типичные ошибки агентов (избегать)
Канон. Субагентам: включать в промпт «следуй `agent-pitfalls.mdc` + `shell-wsl-not-powershell.mdc`».
## 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` |
| 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 → локально не дублировать |
| `unexpected EOF`, `ParserError`, пустой curl | PS ломает `"`, `$`, heredoc, `&&` | Файл `.sh` → `run-wsl-sh.sh` |
| `bash: syntax error near '('` | `grep -E ^(...)` в PS; **`fix(ci):` / `(…)` в `wsl -lc` one-liner** | Только внутри `.sh`; commit → `-F` файл (см. ниже) |
| `{{.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`.
Канон запуска скриптов: см. `shell-wsl-not-powershell.mdc` § CRLF.
## 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` |
| `Co-authored-by: Cursor <…>` / `<` ParserError | `git commit -F .tmp-msg.txt` из PS **или** `git-commit.sh` **из `.sh`**, не из PS one-liner |
| `editor` / not a terminal | Не `git commit` без `-F` в WSL; из Windows: `git commit -F file` |
| Сотни `M` после reset на `/mnt/c` | `git config core.filemode false` (локально); не «чинить» код |
| Удалились `.cursor/`, `.idea/` | **`git clean -fdx` только с backup** env + certs; IDE-директории **не трогать** |
| 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/*`.
**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`:
```bash
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
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): …"`.
## 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 |
| Собрали admin-ui/front без просьбы | «Только бэк» → eventhub (+ traefik если нужен HTTPS), **без** Front/Admin образов |
| Эмулятор не остановился | `docker service scale eventhub_bot-emulator-users=0` |
| `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 (не додумывать)
- Делать **ровно** запрос; не раздувать (полный `build-images.sh`, 3 ноды, admin-ui, spec commit).
- Перед scale/deploy — прочитать compose и STANDS.md.
- Секреты: `gitea.ps1` / `.sh` + env; не inline `source secrets` в PS one-liner.
## 5. Субагенты (Task)
В промпт субагента добавлять:
```text
Shell: только WSL + .sh (см. shell-wsl-not-powershell.mdc, agent-pitfalls.mdc).
Один репо. Итог на русском. Не push без явного OK.
```
## 6. Экономия токенов / лимитов моделей
### Контекст чата
- **Один чат — одна задача**; после «готово / CI зелёный» — новый чат на следующий шаг (см. `WORKFLOW.md` §5).
- Workspace: для Back-задачи достаточно **`EventHubBack`** (+ Spec при необходимости); не держать все репо без нужды.
- **`@` точечно** — файл/папка, не `@Codebase` без причины.
- Не просить «проанализируй все transcripts» — они огромные.
### Дешёвые vs дорогие действия
| Дорого | Дешевле |
|--------|---------|
| Task explore «very thorough» | `grep` + 12 файла |
| Полный `build-images.sh` | Только нужный образ |
| Весь лог CI (80k строк) | API job + 20 строк вокруг ошибки |
| Несколько subagents «на всякий» | 1 subagent, узкий промпт |
| Browser deep-walk stage | Smoke-скрипт / один URL |
### Меньше итераций
- Сразу **`.sh`**, не 5 попыток PS one-liner (каждая = новый turn).
- Git перед commit: **один** `status` + `diff`, не три раза подряд.
- Тесты: `rebar3 eunit --module=…` если трогали один модуль; не полный suite без нужды.
- Docker: не rebuild, если `docker images` уже есть нужный tag.
### Модели и режимы
- **Ask / Plan** — вопросы и варианты без tool calls.
- **Agent** — только когда нужны команды и правки.
- Bugbot / Security Review — не на каждый мелкий diff.
### Scope в запросе (шаблон)
```text
Репо: EventHubBack
Issue: #N
Scope: только …
Не: front, docker, push, spec
Done: eunit / lint / …
```
### Subagents
- Редко; промпт = issue + файлы + done, **без** всей переписки.
- `one-repo-per-agent` — меньше контекста и ошибок.
### Повторяемое — в скрипты
Токены verify, smoke creds, stage deploy — **`~/.cursor/eventhub/*.sh`**, не заново через SSH в каждом чате.
+27
View File
@@ -0,0 +1,27 @@
---
description: Ответы субагентов и итоги работы — на русском
alwaysApply: true
---
# EventHub: язык ответов агентов
## Правило
Все **вызванные / фоновые агенты** (Task subagents) пишут пользователю и parent-агенту **на русском**:
- итоги, вердикты, чеклисты, UX-оценки;
- комментарии в Gitea (как и раньше — только русский);
- краткие status-апдейты в конце задачи.
## Допустимо на английском
- Идентификаторы кода, API paths, commit subject если в репо уже принят EN-стиль сообщений (можно и RU — главное единообразие в рамках задачи);
- цитаты из логов CI / stack traces без перевода;
- имена файлов, SHA, URL.
## Родитель-координатор
Тоже отвечает пользователю по-русски.
## Промпт субагенту (обязательно)
- «Return summary in Russian»
- «Shell: WSL + `.sh` only — `shell-wsl-not-powershell.mdc`, `agent-pitfalls.mdc`»
- «One repo per agent — `one-repo-per-agent.mdc`»
- «Экономия токенов: §6 agent-pitfalls — узкий scope, без лишних subagents/explore»
- Не push / не `git clean -fdx` без явного OK пользователя
+29
View File
@@ -0,0 +1,29 @@
---
description: Один агент — один репозиторий; не смешивать Front и Back
alwaysApply: true
---
# EventHub: отдельные агенты по репозиториям
## Правило
При работе с **несколькими репозиториями** EventHub запускай **отдельного агента на каждый репо**. Не смешивай Front и Back (и Spec/FrontAdmin) в одном агенте.
| Репо | Зона |
|------|------|
| `EventHubFront` | клиентский SPA |
| `EventHubBack` | Erlang API |
| `EventHubSpec` | спеки, UI-PARITY, контракты |
| `EventHubFrontAdmin` | admin SPA |
## Запрещено
- Один агент правит и Front, и Back в одной сессии
- Коммиты/`Refs`/`Fixes` не в тот репо (Front-код → issue Back, и наоборот)
- Закрывать/комментировать issue чужого репо «заодно»
## Как делать
1. Спека/issue → агент **Spec** (или короткий foreground), если только доки.
2. API → отдельный агент **Back** (`Back#N`, eunit, push Back).
3. UI → отдельный агент **Front** (`Front#N`, lint/IFT, push Front).
4. Родитель-координатор только маршрутизирует; не пишет код сразу в два репо.
Кросс-репо фича (например Bookings inbox): **сначала Back**, потом **Front** (или параллельно двумя агентами), Spec — третьим или после стабилизации API.
+24
View File
@@ -0,0 +1,24 @@
---
description: EventHubBack — Erlang/rebar3 только в WSL (asdf OTP 28.2)
alwaysApply: true
---
# EventHubBack: OTP / rebar (WSL)
Сборка и тесты бэка — **только в WSL**, не через Windows `erl`/`rebar3`.
Windows npm для соседних Front-репо тоже не использовать — см. `EventHubFront/.cursor/rules/npm-wsl.mdc`.
| Tool | Version | Path |
|---|---|---|
| Erlang/OTP | 28.2 | `/home/alexc/.asdf/installs/erlang/28.2/bin` |
| rebar3 | 3.27.0 | `/home/alexc/.asdf/installs/rebar/3.27.0/bin` |
Перед командами:
```bash
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
source scripts/wsl-dev-env.sh
```
Или agent-скрипт: `bash ~/.cursor/eventhub/compile-back.sh`
Подробнее: `~/.cursor/eventhub/OTP-REBAR.md`, `EventHubSpec/WORKFLOW.md` §2.1.
+130
View File
@@ -0,0 +1,130 @@
---
description: EventHub — команды только через WSL/bash; PowerShell для сложного запрещён
alwaysApply: true
---
# EventHub: как запускать команды (обязательно)
## Проблема
PowerShell в Cursor **ломает** кавычки, `$`, JSON, heredoc и вложенный `bash -lc "..."`. Из‑за этого агенты снова и снова получают `unexpected EOF`, `Invalid JSON`, пустой curl.
## Правило
1. **Нельзя** гонять сложные one-liner’ы в PowerShell (npm, curl+JSON, python -c, git commit heredoc, вложенный bash с кавычками).
2. **Можно** в PowerShell только простые вещи: `cd`, `git status`, `ls`, одиночный `wsl …` без вложенных кавычек в аргументе.
3. Рабочий способ — **WSL bash** или **файл `.sh` + launcher** (см. ниже).
## CRLF / «No such file or directory» (обязательно)
Cursor `Write` на Windows часто сохраняет `.sh` с **CRLF**. Тогда:
```text
wsl -e bash /mnt/c/.../EventHubFront/.tmp-run.sh
# bash: .../.tmp-run.sh: No such file or directory
```
Файл **есть** (`ls` его видит) — ломается shebang (`#!/usr/bin/env bash\r`). Это **не** «файл не записался».
### Канон запуска любого `.sh` с `/mnt/c/...`
**Всегда** через хелпер (снимает `\r`, потом `bash`):
```text
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront/.tmp-run.sh
```
**Запрещено** после `Write` голый:
```text
wsl -e bash /mnt/c/.../repo/.tmp-something.sh
```
Альтернатива без хелпера (тот же смысл):
```text
wsl -e bash -lc 'sed -i "s/\r$//" /mnt/c/.../script.sh && bash /mnt/c/.../script.sh'
```
Предпочтительно класть долгоживущие скрипты в `/mnt/c/Users/alexc/.cursor/eventhub/` и тоже гонять через `run-wsl-sh.sh`.
## Канон (копируй)
### Вариант A — скрипт (предпочтительно)
1. Запиши команды в файл, например `EventHubBack/.tmp-run.sh` или `~/.cursor/eventhub/….sh` (`#!/usr/bin/env bash`).
2. Запусти **только** так (из PowerShell, без вложенных кавычек):
```text
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack/.tmp-run.sh
```
### Вариант B — один простой WSL вызов
Только если внутри **одинарные** кавычки bash и нет `"`/`$` конфликтов с PS (и **нет** нового `.sh` с диска C):
```text
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront && npm run lint'
```
В PowerShell для `-lc` используй **одинарные** кавычки снаружи (`'...'`), внутри — обычный bash.
### Запрещено
```text
wsl -e bash /mnt/c/.../.tmp-run.sh # без run-wsl-sh / sed — CRLF → No such file
wsl -e bash -lc ".... python -c \"import...\" ...." # ломается
curl ... -d "{\"email\":\"$x\"}" # в PS ломается
git commit -m "$(cat <<'EOF' ...)" # в PS не так
wsl -e bash -lc 'git-commit.sh … fix(ci): subject' # ( ) ломают bash
wsl -e bash -lc 'git commit -m "fix(ci): …"' # то же
npm / npx / node из Windows # сломан
```
### Git commit — канон (subject с `( )`, `#`, `$`)
1. Текст коммита — в файл репо, напр. `.tmp-commit-msg.txt` (без heredoc из PS).
2. Скрипт `.tmp-commit.sh`:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
git add path1 path2
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
```
3. Из PowerShell:
```text
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack/.tmp-commit.sh
```
**Не** передавать сообщение аргументом в `git-commit.sh` / `-m` через `wsl -lc` — скобки в `fix(ci):` парсятся как subshell.
## Стек → команда
| Задача | Как |
|-----|-----|
| Front lint/build/e2e | `.sh` + `run-wsl-sh.sh` или `-lc` с PATH-fix + `npm …` |
| Front перед push main | сначала `npm run test:e2e:ift` в WSL, потом `git-push-main.sh` |
| Back rebar/eunit | `source scripts/wsl-dev-env.sh` в WSL |
| Git commit/push | **commit:** `.tmp-commit-msg.txt` + `git commit -F` в `.sh` через `run-wsl-sh.sh`; **push:** `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh …` |
| JSON/API/python | скрипт `.sh`/`.py` + `run-wsl-sh.sh` |
## Git push (обязательно)
**Запрещено агентам:** `git push`, `git push origin`, `git push origin HEAD` / `master` напрямую — без токена зависают на HTTPS prompt.
**Канон:**
```text
bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
bash /mnt/c/Users/alexc/.cursor/eventhub/git-push-main.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront
```
Путь WSL: `/mnt/c/Users/alexc/.cursor/eventhub/` (не `~/.cursor/…` — home в WSL может отличаться).
Credential store в WSL уже настроен (`credential.helper=store` + `git.sabilin.com`); хелпер всё равно предпочтителен (явный URL с токеном из secrets).
## Перед push Front
1. WSL: `npm run lint && npm run build` (ловит синтаксис/TS)
2. WSL: `npm run test:e2e:ift`
3. Только потом push через `git-push-main.sh`. **Не пушить**, если lint/build/IFT красные.
## Частые сбои (подробнее)
См. **`agent-pitfalls.mdc`**: CRLF/`run-wsl-sh.sh`, git `-F`, `&&` в PS, docker dev scale, `git clean` vs `.cursor/`.
+34 -4
View File
@@ -114,12 +114,42 @@ jobs:
.
- name: Run unit tests (EUnit)
run: >
docker run --rm eventhub-tests:latest
rebar3 eunit --sname ci_eunit --verbose
id: eunit
run: |
set -euo pipefail
mkdir -p ci-out/eunit ci-out/eunit-html
set +e
docker run --rm \
-v "$PWD/ci-out/eunit:/app/logs/test/eunit" \
eventhub-tests:latest \
rebar3 eunit --sname ci_eunit --verbose
EUNIT_RC=$?
set -e
python3 scripts/junit-xml-to-html.py ci-out/eunit -o ci-out/eunit-html --title "EUnit CI" || true
exit "${EUNIT_RC}"
- name: Run API tests (local CT)
run: docker run --rm eventhub-tests:latest
id: ct
run: |
set -euo pipefail
mkdir -p ci-out/ct
docker run --rm \
-v "$PWD/ci-out/ct:/app/logs/test/ct" \
eventhub-tests:latest
- name: Publish HTML reports (ci-reports)
if: always()
run: |
set -euo pipefail
RUN_ID="${GITHUB_RUN_NUMBER:-local}"
mkdir -p /var/eventhub/ci-reports || sudo mkdir -p /var/eventhub/ci-reports || true
if [[ -d ci-out/eunit-html ]] && compgen -G 'ci-out/eunit-html/*' >/dev/null; then
bash scripts/publish-ci-report.sh EventHubBack "${RUN_ID}" eunit ci-out/eunit-html || true
fi
if [[ -d ci-out/ct ]] && compgen -G 'ci-out/ct/*' >/dev/null; then
bash scripts/publish-ci-report.sh EventHubBack "${RUN_ID}" ct ci-out/ct || true
fi
bash scripts/prune-ci-reports.sh || true
- name: Push tested eventhub image
if: github.event_name == 'push'
+22
View File
@@ -0,0 +1,22 @@
name: Wipe Mnesia IFT
# Manual only: full Mnesia wipe on IFT (NOT prod).
on:
workflow_dispatch:
concurrency:
group: wipe-mnesia-ift
cancel-in-progress: false
jobs:
wipe-mnesia-ift:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Full Mnesia wipe IFT
env:
SSH_HOST: ${{ secrets.IFT_SSH_HOST }}
SSH_USER: ${{ secrets.IFT_SSH_USER }}
SSH_KEY: ${{ secrets.IFT_SSH_PRIVATE_KEY }}
run: bash scripts/ci-full-mnesia-wipe.sh ift
+22
View File
@@ -0,0 +1,22 @@
name: Wipe Mnesia stage
# Manual only: full Mnesia wipe on stage (NOT prod).
on:
workflow_dispatch:
concurrency:
group: wipe-mnesia-stage
cancel-in-progress: false
jobs:
wipe-mnesia-stage:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- name: Full Mnesia wipe stage
env:
SSH_HOST: ${{ secrets.STAGE_SSH_HOST }}
SSH_USER: ${{ secrets.STAGE_SSH_USER }}
SSH_KEY: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
run: bash scripts/ci-full-mnesia-wipe.sh stage
+7 -4
View File
@@ -17,6 +17,7 @@ erl_crash.dump
.rebar
logs
data
ci-out
.idea
*.iml
rebar3.crashdump
@@ -35,7 +36,9 @@ docker/.env.stage
# Пул пользователей для stress hot-path (см. test/tsung/ift-users.csv.example)
/test/tsung/ift-users.csv
# Traefik TLS private keys (generated locally for dev/staging).
# Никогда не коммитим приватный ключ в репозиторий.
docker/traefik/certs/*.key
docker/traefik/certs/*.pem
# Traefik TLS certs (generated locally for dev/staging) — не коммитить.
docker/traefik/certs/*
!docker/traefik/certs/make-traefik-certs.sh
# Temporary/scratch files
.tmp-*
+7 -1
View File
@@ -1,5 +1,5 @@
.PHONY: help clean compile shell test eunit test-api test-all \
dialyzer xref cover docs release run stop logs
dialyzer xref cover docs release run stop logs verification-token
# Цвета для вывода (только для команд, где нужны)
GREEN := \033[0;32m
@@ -352,6 +352,12 @@ docker-swarm-shell:
# ============================================================================
# UTILITIES
# ============================================================================
# STAND=local|dev|ift|stage|calentiq (default local). Overrides: ADMIN_API_HOST, ENV_FILE, ADMIN_*/SMOKE_ADMIN_*
verification-token: ## Token: make verification-token EMAIL=u@x STAND=ift|stage|dev|local|calentiq
@test -n "$(EMAIL)" || (echo "Usage: make verification-token EMAIL=user@x STAND=ift" >&2; exit 1)
@STAND="$(STAND)" ADMIN_API_HOST="$(ADMIN_API_HOST)" ENV_FILE="$(ENV_FILE)" \
bash scripts/get-verification-token.sh "$(EMAIL)"
status: ## Проверить статус сервера
@echo "Проверка статуса сервера..."
@if curl -s http://localhost:8080/health > /dev/null 2>&1; then \
+1 -1
View File
@@ -1 +1 @@
0.1
0.2
+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
RELEASE_COOKIE=ваш-очень-длинный-секретный-куки-минимум-32-символа
GRAFANA_ADMIN_PASSWORD=сложный-уникальный-пароль
RELEASE_COOKIE=胁邪褕-芯褔械薪褜-写谢懈薪薪褘泄-褋械泻褉械褌薪褘泄-泻褍泻懈-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪
GRAFANA_ADMIN_PASSWORD=褋谢芯卸薪褘泄-褍薪懈泻邪谢褜薪褘泄-锌邪褉芯谢褜
# JWT (минимум 32 символа, не из denylist — см. infra_secrets)
JWT_SECRET=замените-на-случайную-строку-минимум-32-символа-для-user-jwt
ADMIN_JWT_SECRET=замените-на-другую-случайную-строку-минимум-32-символа
# JWT (屑懈薪懈屑褍屑 32 褋懈屑胁芯谢邪, 薪械 懈蟹 denylist 褋屑. infra_secrets)
JWT_SECRET=蟹邪屑械薪懈褌械-薪邪-褋谢褍褔邪泄薪褍褞-褋褌褉芯泻褍-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪-写谢褟-user-jwt
ADMIN_JWT_SECRET=蟹邪屑械薪懈褌械-薪邪-写褉褍谐褍褞-褋谢褍褔邪泄薪褍褞-褋褌褉芯泻褍-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪
# Seed админов (обязательны в stage/prod при пустой таблице admin; в dev — опционально)
# Seed 邪写屑懈薪芯胁 (芯斜褟蟹邪褌械谢褜薪褘 胁 stage/prod 锌褉懈 锌褍褋褌芯泄 褌邪斜谢懈褑械 admin; dev 芯锌褑懈芯薪邪谢褜薪芯)
ADMIN_SUPER_EMAIL=superadmin@eventhub.local
ADMIN_SUPER_PASSWORD=замените-сильный-пароль-суперадмина
ADMIN_SUPER_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-褋褍锌械褉邪写屑懈薪邪
ADMIN_EMAIL=admin@eventhub.local
ADMIN_PASSWORD=замените-сильный-пароль-админа
ADMIN_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-邪写屑懈薪邪
ADMIN_MODER_EMAIL=moderator@eventhub.local
ADMIN_MODER_PASSWORD=замените-сильный-пароль-модератора
ADMIN_MODER_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-屑芯写械褉邪褌芯褉邪
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 src/ src/
COPY priv/ priv/
COPY src/config/sys.config ./config/sys.config
COPY src/config/vm.args ./config/vm.args
+22
View File
@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
autoindex on;
charset utf-8;
# Archives for curl/agent offline analysis (before Allure SPA fallback)
location ~* \.(tar\.gz|tgz|zip)$ {
default_type application/gzip;
add_header Content-Disposition 'attachment';
try_files $uri =404;
}
location / {
try_files $uri $uri/ =404;
}
location ~* /allure/ {
try_files $uri $uri/ /index.html =404;
}
}
+17
View File
@@ -41,6 +41,23 @@ services:
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
- CLUSTER_MODE=true
- 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:
eventhub-net:
aliases:
+15
View File
@@ -70,6 +70,8 @@ services:
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
- CLUSTER_MODE=true
- DNS_NAME=eventhub-node
- OPENCAGE_API_KEY=${OPENCAGE_API_KEY:-}
- OPENCAGE_TIMEOUT_MS=${OPENCAGE_TIMEOUT_MS:-3000}
networks:
eventhub-net:
aliases:
@@ -90,6 +92,19 @@ services:
labels:
- "traefik.enable=true"
# ================== CI HTML reports (local runner) ==================
ci-reports:
image: nginx:1.27-alpine
volumes:
- "${CI_REPORTS_HOST_DIR:-/var/eventhub/ci-reports}:/usr/share/nginx/html:ro"
- "./ci-reports/nginx.conf:/etc/nginx/conf.d/default.conf:ro"
networks:
- eventhub-net
deploy:
replicas: 1
restart_policy:
condition: any
# ================== Admin UI ==================
admin-ui:
image: eventhub-admin-ui:latest
+2 -2
View File
@@ -7,7 +7,7 @@ FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache \
openssl libstdc++ libgcc ncurses-libs libsodium \
file \
&& mkdir -p /app/data \
&& chmod 777 /app/data
&& mkdir -p /app/data /app/data/uploads \
&& chmod 777 /app/data /app/data/uploads
WORKDIR /app
+3 -1
View File
@@ -12,7 +12,9 @@ defmodule WebDev.Router do
scope "/" do
pipe_through(:browser)
observer_dashboard("/observer")
# Dedicated host: mount at "" not "/" — library builds "#{prefix}/css-HASH",
# and prefix="/" yields broken protocol-relative "//css-…".
observer_dashboard("")
end
end
-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-----
+16
View File
@@ -116,6 +116,17 @@ http:
tls: true
service: "client-ui-service"
ci-reports:
rule: "Host(`ci-reports.dev.eventhub.local`)"
entryPoints: ["web"]
middlewares: ["redirect-to-https"]
service: "ci-reports"
ci-reports-secure:
rule: "Host(`ci-reports.dev.eventhub.local`)"
entryPoints: ["websecure"]
tls: true
service: "ci-reports"
services:
api:
failover:
@@ -172,3 +183,8 @@ http:
loadbalancer:
servers:
- url: "http://client-ui:80"
ci-reports:
loadbalancer:
servers:
- url: "http://ci-reports:80"
+68 -6
View File
@@ -78,7 +78,9 @@
expires_at :: calendar:datetime(),
revoked :: boolean(),
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()
}).
%% PK = id. Уникальность пары calendar_id+user_id — в logic.
-record(calendar_share, {
calendar_id :: binary(),
user_id :: binary(),
rights :: read | write | admin
id :: binary(),
calendar_id :: binary(),
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)
@@ -192,7 +211,7 @@
id :: binary(),
event_id :: binary(), % ссылка на конкретный экземпляр события
user_id :: binary(),
status :: pending | confirmed | cancelled,
status :: pending | confirmed | cancelled | expired,
notes :: binary(),
reminder_sent :: boolean(),
confirmed_at :: calendar:datetime(),
@@ -200,6 +219,16 @@
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, {
id :: binary(),
@@ -319,13 +348,24 @@
id :: binary(),
user_id :: binary(),
type :: booking_confirmed | event_reminder | event_cancelled |
specialist_invite | custom,
specialist_invite | waitlist_promoted | custom,
title :: binary(),
body :: binary(),
is_read :: boolean(),
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-счётчики админ-статистики (абсолютные значения).
%% key: atom() | {atom(), atom()} | {atom(), atom(), atom()}
%% users_total-подобные totals не храним (table_info),
@@ -364,7 +404,29 @@
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, {
version :: string(),
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>
+8 -2
View File
@@ -10,7 +10,8 @@
{meck, "0.9.2"},
{gun, "2.2.0"},
{prometheus_cowboy, "0.2.0"},
{cowboy_swagger, "2.8.0"}
{cowboy_swagger, "2.8.0"},
{gen_smtp, "1.3.0"}
]}.
{shell, [
@@ -27,7 +28,7 @@
{profiles, [
{prod, [
{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},
{extended_start_script, true},
{sys_config, "./src/config/sys.config"}
@@ -49,6 +50,11 @@
{verbose, true} % Print more info to console
]}.
{eunit_opts, [
verbose,
{report, {eunit_surefire, [{dir, "logs/test/eunit"}]}}
]}.
{ct_compile_opts, [
{i, "include"}, % Include directory
{d, 'DEBUG'} % Define macros
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Full Mnesia wipe for IFT/stage from CI runner (NOT prod).
# Order: scale 0 -> wipe eventhub-data-1..N -> scale 1 -> mnesia-safe-redeploy up -> ensure-smoke-user
# Env: SSH_HOST SSH_USER SSH_KEY; arg: ift|stage
set -euo pipefail
STAND="${1:?usage: ci-full-mnesia-wipe.sh ift|stage}"
case "${STAND}" in
ift)
MAX_REPLICAS=2
SVC="eventhub-ift-core_eventhub"
SCRIPTS="/opt/eventhub-ift/devops/scripts"
;;
stage)
MAX_REPLICAS=1
SVC="eventhub-stage-core_eventhub"
SCRIPTS="/opt/eventhub-stage/devops/scripts"
;;
*)
echo "Unknown stand: ${STAND} (ift|stage only; not prod)" >&2
exit 1
;;
esac
SSH_HOST="${SSH_HOST:?SSH_HOST required}"
SSH_USER="${SSH_USER:?SSH_USER required}"
SSH_KEY="${SSH_KEY:?SSH_KEY required}"
KEY="$(mktemp)"
printf '%s\n' "${SSH_KEY}" > "${KEY}"
chmod 600 "${KEY}"
cleanup() { rm -f "${KEY}"; }
trap cleanup EXIT
ssh_cmd() {
ssh -i "${KEY}" -o BatchMode=yes -o ConnectTimeout=45 -o StrictHostKeyChecking=accept-new \
"${SSH_USER}@${SSH_HOST}" "$@"
}
echo "### FULL MNESIA WIPE ${STAND} slots=1..${MAX_REPLICAS}"
ssh_cmd "docker volume ls --format '{{.Name}}' | grep eventhub-data || true"
ssh_cmd "docker service scale ${SVC}=0"
for ((i = 1; i <= 40; i++)); do
n="$(ssh_cmd "docker service ps ${SVC} --filter desired-state=running -q 2>/dev/null | wc -l | tr -d ' '")"
echo "running=${n} try=${i}"
[[ "${n}" == "0" ]] && break
sleep 3
done
[[ "${n}" == "0" ]] || { echo "scale-down timeout"; exit 1; }
# After Swarm scale=0, shutdown/orphan task containers may still hold the volume.
# Find holders via Mounts / --filter volume=, docker rm -f, then retry volume rm.
echo "### wipe volumes (remove holders + retry volume rm)"
ssh_cmd "bash -s" <<EOF
set -euo pipefail
MAX=${MAX_REPLICAS}
rm_volume_holders() {
local vol="\$1"
local cids=""
local cid
cids="\$(docker ps -aq --filter "volume=\${vol}" 2>/dev/null || true)"
if [[ -z "\${cids// }" ]]; then
for cid in \$(docker ps -aq 2>/dev/null || true); do
if docker inspect "\$cid" --format '{{range .Mounts}}{{println .Name}}{{end}}' 2>/dev/null | grep -qx "\$vol"; then
cids="\${cids} \$cid"
fi
done
fi
for cid in \$cids; do
[[ -z "\$cid" ]] && continue
echo "removing container holding \$vol:"
docker inspect "\$cid" --format ' id={{.Id}} name={{.Name}} status={{.State.Status}}' 2>/dev/null || echo " id=\$cid"
docker rm -f "\$cid" || true
done
}
for n in \$(seq 1 \$MAX); do
vol=eventhub-data-\$n
echo "### wipe \$vol"
if ! docker volume inspect "\$vol" >/dev/null 2>&1; then
echo "volume \$vol absent, create fresh"
docker volume create "\$vol"
echo "wiped \$vol"
continue
fi
rm_volume_holders "\$vol"
removed=0
for attempt in \$(seq 1 12); do
if docker volume rm -f "\$vol" >/tmp/volrm.out 2>/tmp/volrm.err; then
echo "volume rm ok \$vol attempt=\$attempt"
removed=1
break
fi
err="\$(tr -d '\\r' </tmp/volrm.err 2>/dev/null || true)"
echo "volume rm failed \$vol attempt=\$attempt: \$err"
rm_volume_holders "\$vol"
sleep 3
done
if [[ "\$removed" != "1" ]]; then
if docker volume inspect "\$vol" >/dev/null 2>&1; then
echo "ERROR: could not remove \$vol after retries" >&2
exit 1
fi
echo "volume \$vol already absent after retries"
fi
docker volume create "\$vol"
echo "wiped \$vol"
done
EOF
ssh_cmd "docker service scale ${SVC}=1"
for ((i = 1; i <= 60; i++)); do
n="$(ssh_cmd "docker service ps ${SVC} --filter desired-state=running --format '{{.CurrentState}}' 2>/dev/null | grep -c '^Running' || true")"
echo "Running=${n}/1 try=${i}"
[[ "${n}" -eq 1 ]] && break
sleep 3
done
[[ "${n}" -eq 1 ]] || { echo "scale-up timeout"; exit 1; }
ssh_cmd "bash ${SCRIPTS}/mnesia-safe-redeploy.sh up ${STAND} ${MAX_REPLICAS}"
ssh_cmd "bash ${SCRIPTS}/ensure-smoke-user.sh ${STAND}"
if ssh_cmd "test -x ${SCRIPTS}/ensure-smoke-admin.sh"; then
ssh_cmd "bash ${SCRIPTS}/ensure-smoke-admin.sh ${STAND}" || true
fi
ssh_cmd "docker volume ls --format '{{.Name}}' | grep eventhub-data || true"
echo "OK full mnesia wipe ${STAND}"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Download CI report archive from ci-reports server and extract for agent/offline analysis.
# Usage: fetch-ci-report.sh <report.tar.gz URL> [out_dir]
# Example:
# bash scripts/fetch-ci-report.sh \
# 'https://ci-reports.ift.eventhub.local/EventHubFront/40/allure-ift/report.tar.gz' \
# ./.tmp-ci-report
set -euo pipefail
URL="${1:?report.tar.gz URL}"
OUT="${2:-./ci-report-archive}"
CURL=(curl -fsSL)
if [[ "${CI_REPORTS_INSECURE:-}" == "1" ]]; then
CURL+=( -k )
fi
mkdir -p "${OUT}"
ARCHIVE="${OUT}/report.tar.gz"
"${CURL[@]}" "${URL}" -o "${ARCHIVE}"
rm -rf "${OUT}/contents"
mkdir -p "${OUT}/contents"
tar -xzf "${ARCHIVE}" -C "${OUT}/contents"
echo "Archive: ${ARCHIVE}"
echo "Extracted: ${OUT}/contents"
echo "Allure index (if present): ${OUT}/contents/index.html"
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# Get email verification token via Admin API (multi-stand).
# Usage: get-verification-token.sh <email>
# Env: STAND=local|dev|ift|stage|calentiq [ADMIN_API_HOST] [ENV_FILE] [ADMIN_*|SMOKE_ADMIN_*]
# stdout: token only; stderr: stand / user_id / expires_at
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET_EMAIL="${1:-}"
STAND="${STAND:-local}"
if [[ -z "${TARGET_EMAIL}" ]]; then
echo "Usage: STAND=ift get-verification-token.sh user@example.com" >&2
exit 1
fi
# Make may pass empty ADMIN_API_HOST= / ENV_FILE= — treat as unset.
[[ -z "${ADMIN_API_HOST:-}" ]] && unset ADMIN_API_HOST || true
[[ -z "${ENV_FILE:-}" ]] && unset ENV_FILE || true
# Preserve explicit creds so ENV_FILE does not override CLI/make env.
_PRE_SMOKE_ADMIN_EMAIL="${SMOKE_ADMIN_EMAIL:-}"
_PRE_SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-}"
_PRE_ADMIN_SUPER_EMAIL="${ADMIN_SUPER_EMAIL:-}"
_PRE_ADMIN_SUPER_PASSWORD="${ADMIN_SUPER_PASSWORD:-}"
_PRE_ADMIN_EMAIL="${ADMIN_EMAIL:-}"
_PRE_ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
case "${STAND}" in
local)
DEFAULT_ADMIN_API="http://localhost:8445"
ENV_CANDIDATES=(
"${ROOT}/docker/.env"
"${ROOT}/.env.development"
)
ALLOW_FALLBACK_CREDS=1
;;
dev)
DEFAULT_ADMIN_API="https://admin-api.dev.eventhub.local"
ENV_CANDIDATES=(
"${ROOT}/docker/.env"
)
ALLOW_FALLBACK_CREDS=1
;;
ift)
DEFAULT_ADMIN_API="https://admin-api.ift.eventhub.local"
ENV_CANDIDATES=(
"${ROOT}/docker/.env.ift"
)
ALLOW_FALLBACK_CREDS=0
;;
stage)
DEFAULT_ADMIN_API="https://admin-api.stage.eventhub.local"
ENV_CANDIDATES=(
"${ROOT}/docker/.env.stage"
"${HOME}/.cursor/secrets/eventhub-stage.env"
"/mnt/c/Users/alexc/.cursor/secrets/eventhub-stage.env"
)
ALLOW_FALLBACK_CREDS=0
;;
calentiq)
DEFAULT_ADMIN_API="https://admin-api.stage.calentiq.com"
ENV_CANDIDATES=(
"${ROOT}/docker/.env.stage"
"${HOME}/.cursor/secrets/eventhub-stage.env"
"/mnt/c/Users/alexc/.cursor/secrets/eventhub-stage.env"
)
ALLOW_FALLBACK_CREDS=0
;;
*)
echo "Unknown STAND=${STAND} (expected: local|dev|ift|stage|calentiq)" >&2
exit 1
;;
esac
ADMIN_API="${ADMIN_API_HOST:-$DEFAULT_ADMIN_API}"
ADMIN_API="${ADMIN_API%/}"
resolve_env_file() {
if [[ -n "${ENV_FILE:-}" ]]; then
if [[ ! -f "${ENV_FILE}" ]]; then
echo "ENV_FILE not found: ${ENV_FILE}" >&2
exit 1
fi
printf '%s\n' "${ENV_FILE}"
return
fi
local f
for f in "${ENV_CANDIDATES[@]}"; do
if [[ -f "${f}" ]]; then
printf '%s\n' "${f}"
return
fi
done
printf '\n'
}
load_admin_creds_from_file() {
local file="$1"
[[ -n "${file}" ]] || return 0
set -a
# shellcheck disable=SC1091
source <(sed $'s/\xEF\xBB\xBF//' "${file}" | tr -d '\r' | grep -E '^(ADMIN_SUPER_|ADMIN_EMAIL=|ADMIN_PASSWORD=|SMOKE_ADMIN_)' || true)
set +a
}
ENV_RESOLVED="$(resolve_env_file)"
if [[ -n "${ENV_RESOLVED}" ]]; then
load_admin_creds_from_file "${ENV_RESOLVED}"
fi
# Restore CLI/make overrides
[[ -n "${_PRE_SMOKE_ADMIN_EMAIL}" ]] && SMOKE_ADMIN_EMAIL="${_PRE_SMOKE_ADMIN_EMAIL}"
[[ -n "${_PRE_SMOKE_ADMIN_PASSWORD}" ]] && SMOKE_ADMIN_PASSWORD="${_PRE_SMOKE_ADMIN_PASSWORD}"
[[ -n "${_PRE_ADMIN_SUPER_EMAIL}" ]] && ADMIN_SUPER_EMAIL="${_PRE_ADMIN_SUPER_EMAIL}"
[[ -n "${_PRE_ADMIN_SUPER_PASSWORD}" ]] && ADMIN_SUPER_PASSWORD="${_PRE_ADMIN_SUPER_PASSWORD}"
[[ -n "${_PRE_ADMIN_EMAIL}" ]] && ADMIN_EMAIL="${_PRE_ADMIN_EMAIL}"
[[ -n "${_PRE_ADMIN_PASSWORD}" ]] && ADMIN_PASSWORD="${_PRE_ADMIN_PASSWORD}"
ADMIN_LOGIN_EMAIL="${SMOKE_ADMIN_EMAIL:-${ADMIN_SUPER_EMAIL:-${ADMIN_EMAIL:-}}}"
ADMIN_LOGIN_PASS="${SMOKE_ADMIN_PASSWORD:-${ADMIN_SUPER_PASSWORD:-${ADMIN_PASSWORD:-}}}"
if [[ -z "${ADMIN_LOGIN_EMAIL}" || -z "${ADMIN_LOGIN_PASS}" ]]; then
if [[ "${ALLOW_FALLBACK_CREDS}" -eq 1 ]]; then
ADMIN_LOGIN_EMAIL="${ADMIN_LOGIN_EMAIL:-superadmin@eventhub.local}"
ADMIN_LOGIN_PASS="${ADMIN_LOGIN_PASS:-123456}"
else
echo "No admin credentials for STAND=${STAND}." >&2
echo "Set ADMIN_* / SMOKE_ADMIN_* or provide ENV_FILE (tried: ${ENV_CANDIDATES[*]})." >&2
exit 1
fi
fi
echo "stand=${STAND} admin_api=${ADMIN_API} env_file=${ENV_RESOLVED:-none}" >&2
LOGIN=$(curl -sk --connect-timeout 15 -X POST "${ADMIN_API}/v1/admin/login" \
-H 'Content-Type: application/json' \
-d "{\"email\":\"${ADMIN_LOGIN_EMAIL}\",\"password\":\"${ADMIN_LOGIN_PASS}\"}") || {
echo "admin login request failed: ${ADMIN_API}/v1/admin/login" >&2
exit 1
}
ADMIN_TOKEN=$(printf '%s' "${LOGIN}" | python3 -c '
import json,sys
try:
d=json.load(sys.stdin)
except Exception as e:
print("", end="")
sys.exit(0)
print(d.get("token") or d.get("access_token") or "", end="")
') || true
if [[ -z "${ADMIN_TOKEN}" ]]; then
echo "admin login failed: ${LOGIN}" >&2
exit 1
fi
ENC=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "${TARGET_EMAIL}")
USERS_JSON=$(curl -sk --connect-timeout 15 \
"${ADMIN_API}/v1/admin/users?q=${ENC}&limit=50" \
-H "Authorization: Bearer ${ADMIN_TOKEN}")
USER_ID=$(printf '%s' "${USERS_JSON}" | python3 -c '
import json,sys
raw=sys.stdin.read()
try:
items=json.loads(raw)
except Exception:
print("")
raise SystemExit
if isinstance(items, dict):
items=items.get("users") or items.get("items") or items.get("data") or []
target=sys.argv[1].lower()
for u in items:
if str(u.get("email","")).lower()==target:
print(u.get("id") or "")
raise SystemExit
print("")
' "${TARGET_EMAIL}")
if [[ -z "${USER_ID}" ]]; then
echo "USER_NOT_FOUND=${TARGET_EMAIL}" >&2
exit 2
fi
VT=$(curl -sk --connect-timeout 15 \
"${ADMIN_API}/v1/admin/users/${USER_ID}/verification-token" \
-H "Authorization: Bearer ${ADMIN_TOKEN}")
TOKEN=$(printf '%s' "${VT}" | python3 -c '
import json,sys
try:
d=json.load(sys.stdin)
except Exception:
print("")
raise SystemExit
print(d.get("token") or "", end="")
')
EXPIRES=$(printf '%s' "${VT}" | python3 -c '
import json,sys
try:
d=json.load(sys.stdin)
except Exception:
print("")
raise SystemExit
print(d.get("expires_at") or "", end="")
')
if [[ -z "${TOKEN}" ]]; then
echo "verification-token failed for user_id=${USER_ID}: ${VT}" >&2
exit 1
fi
echo "user_id=${USER_ID} expires_at=${EXPIRES}" >&2
printf '%s\n' "${TOKEN}"
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Convert Surefire/JUnit XML (eunit_surefire) into a simple HTML report."""
from __future__ import annotations
import argparse
import html
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
def load_suites(paths: list[Path]) -> list[ET.Element]:
suites: list[ET.Element] = []
for path in paths:
try:
root = ET.parse(path).getroot()
except ET.ParseError as exc:
print(f"skip {path}: {exc}", file=sys.stderr)
continue
if root.tag == "testsuite":
suites.append(root)
elif root.tag == "testsuites":
suites.extend(list(root.findall("testsuite")))
return suites
def render(suites: list[ET.Element], title: str) -> str:
total = failures = errors = skipped = 0
rows: list[str] = []
for suite in suites:
s_name = suite.attrib.get("name", "suite")
total += int(suite.attrib.get("tests", "0") or 0)
failures += int(suite.attrib.get("failures", "0") or 0)
errors += int(suite.attrib.get("errors", "0") or 0)
skipped += int(suite.attrib.get("skipped", "0") or 0)
for case in suite.findall("testcase"):
name = case.attrib.get("name", "?")
classname = case.attrib.get("classname", s_name)
time_s = case.attrib.get("time", "")
fail = case.find("failure")
err = case.find("error")
skip = case.find("skipped")
if fail is not None:
status, detail = "FAIL", fail.attrib.get("message") or (fail.text or "")
elif err is not None:
status, detail = "ERROR", err.attrib.get("message") or (err.text or "")
elif skip is not None:
status, detail = "SKIP", skip.attrib.get("message") or ""
else:
status, detail = "PASS", ""
cls = status.lower()
rows.append(
"<tr class=\"{cls}\"><td>{status}</td><td>{suite}</td><td>{case}</td>"
"<td>{time}</td><td><pre>{detail}</pre></td></tr>".format(
cls=html.escape(cls),
status=html.escape(status),
suite=html.escape(classname),
case=html.escape(name),
time=html.escape(time_s),
detail=html.escape(detail.strip()[:4000]),
)
)
ok = total - failures - errors - skipped
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<title>{html.escape(title)}</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 1.5rem; }}
.summary span {{ margin-right: 1rem; }}
.pass {{ color: #0a7; }}
.fail, .error {{ color: #c22; }}
.skip {{ color: #a80; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 1rem; }}
th, td {{ border: 1px solid #ddd; padding: .4rem .6rem; vertical-align: top; text-align: left; }}
th {{ background: #f4f4f4; }}
pre {{ white-space: pre-wrap; margin: 0; font-size: 12px; }}
tr.fail, tr.error {{ background: #fff5f5; }}
tr.pass {{ background: #f5fff8; }}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
<p class="summary">
<span>tests: <b>{total}</b></span>
<span class="pass">pass: <b>{ok}</b></span>
<span class="fail">fail: <b>{failures}</b></span>
<span class="error">error: <b>{errors}</b></span>
<span class="skip">skip: <b>{skipped}</b></span>
</p>
<table>
<thead><tr><th>Status</th><th>Suite</th><th>Case</th><th>Time</th><th>Detail</th></tr></thead>
<tbody>
{''.join(rows) if rows else '<tr><td colspan="5">No testcases</td></tr>'}
</tbody>
</table>
</body>
</html>
"""
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("xml_dir", type=Path, help="Directory with TEST-*.xml")
ap.add_argument("-o", "--out", type=Path, required=True, help="Output HTML file or dir")
ap.add_argument("--title", default="EUnit report")
args = ap.parse_args()
xml_files = sorted(args.xml_dir.glob("TEST-*.xml")) + sorted(args.xml_dir.glob("*.xml"))
# de-dupe
seen = set()
unique: list[Path] = []
for p in xml_files:
if p.resolve() in seen:
continue
seen.add(p.resolve())
unique.append(p)
suites = load_suites(unique)
out = args.out
if out.suffix.lower() != ".html":
out.mkdir(parents=True, exist_ok=True)
out = out / "index.html"
else:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(render(suites, args.title), encoding="utf-8")
print(f"wrote {out} ({len(suites)} suites, {len(unique)} xml files)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Удаляет каталоги отчётов старше CI_REPORTS_KEEP_DAYS (default 14).
# Usage: prune-ci-reports.sh [/var/eventhub/ci-reports]
set -euo pipefail
ROOT="${1:-${CI_REPORTS_DIR:-/var/eventhub/ci-reports}}"
KEEP_DAYS="${CI_REPORTS_KEEP_DAYS:-14}"
if [[ ! -d "${ROOT}" ]]; then
echo "ci-reports: ${ROOT} отсутствует — пропуск"
exit 0
fi
echo "ci-reports prune: root=${ROOT} keep_days=${KEEP_DAYS}"
find "${ROOT}" -mindepth 2 -maxdepth 2 -type d -mtime "+${KEEP_DAYS}" -print -exec rm -rf {} + 2>/dev/null || true
echo "ci-reports prune: done"
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# Publish HTML CI report tree to local runner host and print HTTPS URL.
# Usage: publish-ci-report.sh <repo> <run_id> <kind> <src_dir>
# repo: EventHubBack | EventHubFront | …
# run_id: usually GITHUB_RUN_NUMBER or sha
# kind: eunit | ct | allure | e2e-ift | e2e-stage | …
# src_dir: directory with HTML to copy
set -euo pipefail
REPO="${1:?repo}"
RUN_ID="${2:?run_id}"
KIND="${3:?kind}"
SRC="${4:?src_dir}"
REPORTS_DIR="${CI_REPORTS_DIR:-/var/eventhub/ci-reports}"
STAND="${RUNNER_STAND:-}"
detect_stand() {
if [[ -n "${RUNNER_STAND:-}" ]]; then
echo "${RUNNER_STAND}"
return 0
fi
if [[ -f /opt/eventhub-ift/.env ]] || [[ -d /opt/eventhub-ift ]]; then
echo "ift"
return 0
fi
if [[ -f /opt/eventhub-stage/.env ]] || [[ -d /opt/eventhub-stage ]]; then
echo "stage"
return 0
fi
if command -v docker >/dev/null 2>&1 \
&& docker service ls --format '{{.Name}}' 2>/dev/null | grep -qx 'eventhub-ift-core_ci-reports'; then
echo "ift"
return 0
fi
if command -v docker >/dev/null 2>&1 \
&& docker service ls --format '{{.Name}}' 2>/dev/null | grep -qx 'eventhub_ci-reports'; then
echo "dev"
return 0
fi
echo "dev"
}
if [[ -n "${CI_REPORTS_BASE_URL:-}" ]]; then
BASE_URL="${CI_REPORTS_BASE_URL%/}"
STAND="${RUNNER_STAND:-custom}"
else
STAND="$(detect_stand)"
BASE_URL="https://ci-reports.${STAND}.eventhub.local"
fi
if [[ ! -d "${SRC}" ]]; then
echo "publish-ci-report: src missing: ${SRC}" >&2
exit 1
fi
# act runner: workflow may create REPORTS_DIR via sudo (root-owned); ensure CI user can write.
ensure_ci_reports_dir() {
if [[ -d "${REPORTS_DIR}" ]] && [[ -w "${REPORTS_DIR}" ]]; then
return 0
fi
if mkdir -p "${REPORTS_DIR}" 2>/dev/null && [[ -w "${REPORTS_DIR}" ]]; then
return 0
fi
if command -v sudo >/dev/null 2>&1; then
sudo mkdir -p "${REPORTS_DIR}"
sudo chown -R "$(id -u):$(id -g)" "${REPORTS_DIR}"
return 0
fi
mkdir -p "${REPORTS_DIR}"
}
ensure_ci_reports_dir
ensure_ci_reports_nginx() {
command -v docker >/dev/null 2>&1 || return 0
local svc replicas
for svc in eventhub-ift-core_ci-reports eventhub_ci-reports; do
docker service ls --format '{{.Name}}' 2>/dev/null | grep -qx "${svc}" || continue
replicas=$(docker service ls --format '{{.Name}} {{.Replicas}}' 2>/dev/null | awk -v s="${svc}" '$1==s{print $2; exit}')
if [[ "${replicas}" == 0/* ]] || [[ -z "${replicas}" ]]; then
echo "Starting ${svc}=1 (ci-reports nginx)..."
docker service scale "${svc}=1" 2>/dev/null || true
fi
return 0
done
}
ensure_ci_reports_nginx
DEST="${REPORTS_DIR}/${REPO}/${RUN_ID}/${KIND}"
mkdir -p "${REPORTS_DIR}/${REPO}/${RUN_ID}"
rm -rf "${DEST}"
mkdir -p "${DEST}"
cp -a "${SRC}/." "${DEST}/"
create_report_archive() {
local dest="$1"
local archive="${dest}/report.tar.gz"
local tmp
tmp="$(mktemp)"
tar -czf "${tmp}" -C "${dest}" .
mv -f "${tmp}" "${archive}"
chmod a+r "${archive}"
}
create_report_archive "${DEST}"
# Convenience: CT puts index under ct_run.*; expose DEST/index.html
if [[ ! -f "${DEST}/index.html" ]]; then
CT_INDEX="$(find "${DEST}" -type f -name index.html 2>/dev/null | head -n 1 || true)"
if [[ -n "${CT_INDEX}" ]]; then
REL="${CT_INDEX#"${DEST}/"}"
cat > "${DEST}/index.html" <<EOF
<!DOCTYPE html>
<html lang="ru"><head>
<meta charset="utf-8"/>
<meta http-equiv="refresh" content="0; url=${REL}"/>
<title>CT report</title>
</head><body>
<p><a href="${REL}">Open Common Test report</a></p>
</body></html>
EOF
fi
fi
# Refresh run index
INDEX="${REPORTS_DIR}/${REPO}/${RUN_ID}/index.html"
{
echo "<!DOCTYPE html>"
echo "<html lang=\"ru\"><head><meta charset=\"utf-8\"/><title>${REPO} #${RUN_ID}</title>"
echo "<style>body{font-family:system-ui,sans-serif;margin:2rem}a{display:block;margin:.4rem 0}</style>"
echo "</head><body>"
echo "<h1>${REPO} — run ${RUN_ID}</h1>"
echo "<ul>"
for d in "${REPORTS_DIR}/${REPO}/${RUN_ID}"/*/; do
[[ -d "${d}" ]] || continue
name="$(basename "${d}")"
if [[ -f "${d}index.html" ]]; then
if [[ -f "${d}report.tar.gz" ]]; then
echo "<li><a href=\"./${name}/index.html\">${name}</a> — <a href=\"./${name}/report.tar.gz\">report.tar.gz</a></li>"
else
echo "<li><a href=\"./${name}/index.html\">${name}</a></li>"
fi
elif [[ -f "${d}report.tar.gz" ]]; then
echo "<li><a href=\"./${name}/\">${name}/</a> — <a href=\"./${name}/report.tar.gz\">report.tar.gz</a></li>"
else
echo "<li><a href=\"./${name}/\">${name}/</a></li>"
fi
done
echo "</ul></body></html>"
} > "${INDEX}"
URL="${BASE_URL}/${REPO}/${RUN_ID}/${KIND}/"
ARCHIVE_URL="${BASE_URL}/${REPO}/${RUN_ID}/${KIND}/report.tar.gz"
INDEX_URL="${BASE_URL}/${REPO}/${RUN_ID}/"
echo "Report stand: ${STAND}"
echo "Report: ${URL}"
echo "Report archive: ${ARCHIVE_URL}"
echo "Report index: ${INDEX_URL}"
echo "Report path: ${DEST}"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
{
echo "### Test report"
echo "- [${KIND}](${URL})"
echo "- [${KIND} archive](${ARCHIVE_URL})"
echo "- [index](${INDEX_URL})"
} >> "${GITHUB_STEP_SUMMARY}"
fi
+55 -8
View File
@@ -2,22 +2,29 @@
# Full API CT suite against live stand (remote) + smoke-core on stand host.
# Usage: run-stand-api-tests.sh <ift|stage>
# Requires: ADMIN_* from load-stand-api-env.sh; SSH_* for smoke-core on stand.
#
# Litter cleanup: after each CT suite, api_test_runner:cleanup_tracked/0
# soft-deletes tracked calendars/users via admin API (and @test.local safety-net).
# Skip teardown: CT_KEEP_DATA=1 (data remains on the stand).
# Soft-delete ≠ hard wipe; full clean slate = ops Mnesia volume wipe.
set -euo pipefail
STAND="${1:?stand: ift|stage}"
case "${STAND}" in
ift)
API_HOST="${API_HOST:-https://api.ift.eventhub.local}"
ADMIN_API_HOST="${ADMIN_API_HOST:-https://admin-api.ift.eventhub.local}"
WS_HOST="${WS_HOST:-wss://ws.ift.eventhub.local}"
ADMIN_WS_HOST="${ADMIN_WS_HOST:-wss://admin-ws.ift.eventhub.local}"
[[ -n "${API_HOST:-}" ]] || API_HOST="https://api.ift.eventhub.local"
[[ -n "${ADMIN_API_HOST:-}" ]] || ADMIN_API_HOST="https://admin-api.ift.eventhub.local"
[[ -n "${WS_HOST:-}" ]] || WS_HOST="wss://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"
;;
stage)
API_HOST="${API_HOST:-https://api.stage.eventhub.local}"
ADMIN_API_HOST="${ADMIN_API_HOST:-https://admin-api.stage.eventhub.local}"
WS_HOST="${WS_HOST:-wss://ws.stage.eventhub.local}"
ADMIN_WS_HOST="${ADMIN_WS_HOST:-wss://admin-ws.stage.eventhub.local}"
# Публичный stage (канон STAGE-BROWSER.md). Пустые E2E_STAGE_* из CI
# не должны оставлять API_HOST="" (иначе CT бьёт в *.eventhub.local).
[[ -n "${API_HOST:-}" ]] || API_HOST="https://api.stage.calentiq.com"
[[ -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"
;;
*)
@@ -44,6 +51,27 @@ echo " ADMIN_API_HOST=${ADMIN_API_HOST}"
echo " WS_HOST=${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 стенда,
# из‑за чего CT remote не резолвит *.ift.eventhub.local (health timeout 30s).
docker_add_hosts=()
@@ -114,8 +142,13 @@ if [[ "${have_image}" -ne 1 ]]; then
.
fi
CT_OUT="${CT_REPORT_DIR:-$(pwd)/ci-out/ct-${STAND}}"
mkdir -p "${CT_OUT}"
set +e
docker run --rm --network=host \
${docker_add_hosts[@]+"${docker_add_hosts[@]}"} \
-v "${CT_OUT}:/app/logs/test/ct" \
-e CT_MODE=remote \
-e "API_HOST=${API_HOST}" \
-e "ADMIN_API_HOST=${ADMIN_API_HOST}" \
@@ -129,6 +162,20 @@ docker run --rm --network=host \
-e "ADMIN_MODER_PASSWORD=${ADMIN_MODER_PASSWORD:-}" \
-e "ADMIN_SUPPORT_EMAIL=${ADMIN_SUPPORT_EMAIL:-}" \
-e "ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD:-}" \
-e "CT_KEEP_DATA=${CT_KEEP_DATA:-}" \
"${IMAGE}"
CT_RC=$?
set -e
if [[ -d "${CT_OUT}" ]] && compgen -G "${CT_OUT}/*" >/dev/null; then
RUN_ID="${GITHUB_RUN_NUMBER:-local}"
mkdir -p /var/eventhub/ci-reports 2>/dev/null || true
bash scripts/publish-ci-report.sh EventHubBack "${RUN_ID}" "e2e-${STAND}" "${CT_OUT}" || true
fi
if [[ "${CT_RC}" -ne 0 ]]; then
echo "=== API CT FAILED (${STAND}) rc=${CT_RC} ===" >&2
exit "${CT_RC}"
fi
echo "=== API CT passed (${STAND}) ==="
-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.
+3 -1
View File
@@ -5,7 +5,9 @@
{admin_http_port, "${ADMIN_HTTP_PORT:-8445}"},
{admin_ws_port, "${ADMIN_WS_PORT:-8446}"},
{jwt_secret, <<"${JWT_SECRET:-change_me_in_production}">>},
{admin_jwt_secret, <<"${ADMIN_JWT_SECRET:-change_me_in_production}">>}
{admin_jwt_secret, <<"${ADMIN_JWT_SECRET:-change_me_in_production}">>},
%% Interval between TTL cleanup runs (infra_cleanup), default 1 hour
{cleanup_interval_ms, 3600000}
]},
{kernel, [
{logger_level, info},
+163 -46
View File
@@ -5,17 +5,31 @@
%%%-------------------------------------------------------------------
-module(core_auth_session).
-include("records.hrl").
-export([create/3, get/1, rotate/2, revoke/1, revoke_family/1, revoke_all_for_subject/2]).
-include_lib("stdlib/include/ms_transform.hrl").
-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).
%%%-------------------------------------------------------------------
%%% @doc Создать новую сессию устройства/клиента.
%%% @doc Создать новую сессию устройства/клиента (без device meta).
%%% @end
%%%-------------------------------------------------------------------
-spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary()) ->
{ok, #auth_session{}}.
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(),
ExpiresAt = calendar:gregorian_seconds_to_datetime(
calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS
@@ -30,11 +44,18 @@ create(SubjectId, SubjectType, ClientType) ->
expires_at = ExpiresAt,
revoked = false,
created_at = Now,
updated_at = Now
updated_at = Now,
device_name = DeviceName,
user_agent = UserAgent
},
mnesia:dirty_write(Session),
inc_counter(SubjectType),
{ok, Session}.
%% Transaction ensures atomic write of the new session record.
case mnesia:transaction(fun() -> mnesia:write(Session) end) of
{atomic, ok} ->
inc_counter(SubjectType),
{ok, Session};
{aborted, Reason} ->
{error, Reason}
end.
%%%-------------------------------------------------------------------
%%% @doc Получить сессию по идентификатору.
@@ -56,11 +77,19 @@ get(SessionId) ->
{ok, NewJti :: binary(), #auth_session{}} |
{error, not_found | expired | revoked | reuse_detected}.
rotate(SessionId, PresentedJti) ->
case mnesia:dirty_read({auth_session, SessionId}) of
[Session] ->
rotate_session(Session, PresentedJti);
[] ->
{error, not_found}
%% Transaction prevents concurrent rotate from racing on the same session
%% (e.g. two clients presenting the same jti simultaneously).
F = fun() ->
case mnesia:read(auth_session, SessionId, write) of
[Session] ->
rotate_session(Session, PresentedJti);
[] ->
{error, not_found}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, _Reason} -> {error, transaction_failed}
end.
%%%-------------------------------------------------------------------
@@ -69,60 +98,148 @@ rotate(SessionId, PresentedJti) ->
%%%-------------------------------------------------------------------
-spec revoke(SessionId :: binary()) -> ok | {error, not_found}.
revoke(SessionId) ->
case mnesia:dirty_read({auth_session, SessionId}) of
[Session] when Session#auth_session.revoked =:= true ->
%% Transaction ensures the read-then-write revocation is atomic.
F = fun() ->
case mnesia:read(auth_session, SessionId, write) of
[Session] when Session#auth_session.revoked =:= true ->
ok;
[Session] ->
Now = calendar:universal_time(),
mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
{revoked, Session#auth_session.subject_type};
[] ->
{error, not_found}
end
end,
case mnesia:transaction(F) of
{atomic, ok} -> ok;
{atomic, {revoked, SubjectType}} ->
dec_counter(SubjectType),
ok;
[Session] ->
Now = calendar:universal_time(),
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}),
dec_counter(Session#auth_session.subject_type),
ok;
[] ->
{error, not_found}
{atomic, {error, _} = Err} -> Err;
{aborted, _Reason} -> {error, transaction_failed}
end.
%%%-------------------------------------------------------------------
%%% @doc Отозвать все сессии семейства (reuse attack / принудительный logout).
%%% @end
%%%-------------------------------------------------------------------
-spec revoke_family(FamilyId :: binary()) -> ok.
-spec revoke_family(FamilyId :: binary()) -> ok | {error, transaction_failed}.
revoke_family(FamilyId) ->
Sessions = mnesia:dirty_index_read(auth_session, FamilyId, #auth_session.family_id),
Now = calendar:universal_time(),
lists:foreach(fun(Session) ->
case Session#auth_session.revoked of
true -> ok;
false ->
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}),
dec_counter(Session#auth_session.subject_type)
end
end, Sessions),
ok.
%% Transaction ensures all sessions in the family are revoked atomically,
%% preventing partial revocation under concurrent access.
F = fun() ->
Sessions = mnesia:index_read(auth_session, FamilyId, #auth_session.family_id),
Now = calendar:universal_time(),
RevokedTypes = lists:filtermap(fun(Session) ->
case Session#auth_session.revoked of
true -> false;
false ->
mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
{true, Session#auth_session.subject_type}
end
end, Sessions),
RevokedTypes
end,
case mnesia:transaction(F) of
{atomic, Types} ->
%% Decrement counters outside the transaction
lists:foreach(fun dec_counter/1, Types),
ok;
{aborted, _Reason} ->
{error, transaction_failed}
end.
%%%-------------------------------------------------------------------
%%% @doc Отозвать все сессии субъекта (например после сброса пароля).
%%% @end
%%%-------------------------------------------------------------------
-spec revoke_all_for_subject(SubjectId :: binary(), SubjectType :: user | admin) -> ok.
-spec revoke_all_for_subject(SubjectId :: binary(), SubjectType :: user | admin) ->
ok | {error, transaction_failed}.
revoke_all_for_subject(SubjectId, SubjectType) ->
Sessions = mnesia:dirty_match_object(#auth_session{subject_id = SubjectId, _ = '_'}),
%% Transaction ensures all matching sessions are revoked atomically.
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(),
Count = lists:foldl(fun(Session, Acc) ->
case Session#auth_session.subject_type =:= SubjectType andalso
Session#auth_session.revoked =:= false of
true ->
mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
Acc + 1;
false ->
Acc
end
end, 0, Sessions),
Count
end,
case mnesia:transaction(F) of
{atomic, Count} ->
%% Decrement counters outside the transaction
lists:foreach(fun(_) -> dec_counter(SubjectType) end, lists:seq(1, Count)),
ok;
{aborted, _Reason} ->
{error, transaction_failed}
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(),
lists:foreach(fun(Session) ->
case Session#auth_session.subject_type =:= SubjectType andalso
Session#auth_session.revoked =:= false of
true ->
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}),
dec_counter(Session#auth_session.subject_type);
false ->
ok
end
end, Sessions),
ok.
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
%%%===================================================================
%% NOTE: Called inside a mnesia:transaction/1 from rotate/2.
%% Uses mnesia:read/3 and mnesia:write/1 (not dirty_ variants).
rotate_session(Session, PresentedJti) ->
case Session#auth_session.revoked of
true ->
@@ -140,7 +257,7 @@ rotate_session(Session, PresentedJti) ->
current_jti = NewJti,
updated_at = Now
},
mnesia:dirty_write(Updated),
mnesia:write(Updated),
{ok, NewJti, Updated};
false ->
{error, reuse_detected}
+9 -6
View File
@@ -54,8 +54,8 @@ get_by_id(Id) ->
%%%-------------------------------------------------------------------
-spec list_by_event(EventId :: binary()) -> {ok, [#booking{}]}.
list_by_event(EventId) ->
Match = #booking{event_id = EventId, _ = '_'},
Bookings = mnesia:dirty_match_object(Match),
%% Optimized: use event_id index instead of full table scan.
Bookings = mnesia:dirty_index_read(booking, EventId, #booking.event_id),
{ok, Bookings}.
%%%-------------------------------------------------------------------
@@ -64,8 +64,8 @@ list_by_event(EventId) ->
%%%-------------------------------------------------------------------
-spec list_by_user(UserId :: binary()) -> {ok, [#booking{}]}.
list_by_user(UserId) ->
Match = #booking{user_id = UserId, _ = '_'},
Bookings = mnesia:dirty_match_object(Match),
%% Optimized: use user_id index instead of full table scan.
Bookings = mnesia:dirty_index_read(booking, UserId, #booking.user_id),
{ok, Bookings}.
%%%-------------------------------------------------------------------
@@ -74,6 +74,7 @@ list_by_user(UserId) ->
%%%-------------------------------------------------------------------
-spec list_all() -> [#booking{}].
list_all() ->
%% Genuinely lists all bookings (admin view); no index can help here.
mnesia:dirty_match_object(#booking{_ = '_'}).
%%%-------------------------------------------------------------------
@@ -123,8 +124,10 @@ count_bookings() ->
-spec get_by_event_and_user(EventId :: binary(), UserId :: binary()) ->
{ok, #booking{}} | {error, not_found}.
get_by_event_and_user(EventId, UserId) ->
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'},
case mnesia:dirty_match_object(Match) of
%% Optimized: use event_id index, then filter by user_id
%% instead of a full table scan.
Candidates = mnesia:dirty_index_read(booking, EventId, #booking.event_id),
case [B || B <- Candidates, B#booking.user_id =:= UserId] of
[] -> {error, not_found};
[Booking] -> {ok, Booking}
end.
+24 -1
View File
@@ -1,6 +1,7 @@
-module(core_calendar).
-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([freeze/2, unfreeze/2]).
-export([count_calendars_by_date/2]).
@@ -78,6 +79,28 @@ get_by_id(Id) ->
[Calendar] -> {ok, Calendar}
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 Список активных календарей владельца.
%%% @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.
+5 -1
View File
@@ -6,7 +6,7 @@
-include("records.hrl").
-export([create/4, get_by_calendar_and_user/2, list_by_calendar/1,
update/3, delete/2, is_active_specialist/2]).
list_by_user/1, update/3, delete/2, is_active_specialist/2]).
-spec create(CalendarId :: binary(), UserId :: binary(), Name :: binary(),
Specs :: [binary()]) ->
@@ -51,6 +51,10 @@ get_by_calendar_and_user(CalendarId, UserId) ->
list_by_calendar(CalendarId) ->
mnesia:dirty_match_object(#calendar_specialist{calendar_id = CalendarId, _ = '_'}).
-spec list_by_user(UserId :: binary()) -> [#calendar_specialist{}].
list_by_user(UserId) ->
mnesia:dirty_match_object(#calendar_specialist{user_id = UserId, _ = '_'}).
-spec update(CalendarId :: binary(), UserId :: binary(), Updates :: [{atom(), term()}]) ->
{ok, #calendar_specialist{}} | {error, not_found | term()}.
update(CalendarId, UserId, Updates) ->
+44 -11
View File
@@ -1,6 +1,7 @@
-module(core_event).
-include("records.hrl").
-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1, update/2, delete/1,
-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1,
list_active_including_instances/1, update/2, delete/1,
materialize_occurrence/3]).
-export([count_events/0, count_events_by_date/2]).
-export([freeze/2, unfreeze/2]).
@@ -118,10 +119,12 @@ materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) ->
[] ->
{error, master_not_found};
[Master] when Master#event.event_type =:= recurring ->
Existing = mnesia:dirty_match_object(
#event{master_id = MasterId, start_time = OccurrenceStart,
is_instance = true, _ = '_'}
),
%% Optimized: use master_id index instead of full table scan,
%% then filter by start_time and is_instance.
Candidates = mnesia:dirty_index_read(event, MasterId, #event.master_id),
Existing = [E || E <- Candidates,
E#event.start_time =:= OccurrenceStart andalso
E#event.is_instance =:= true],
case Existing of
[] ->
InstanceId = infra_utils:generate_id(16),
@@ -180,8 +183,21 @@ get_by_id(Id) ->
%%%-------------------------------------------------------------------
-spec list_by_calendar(CalendarId :: binary()) -> {ok, [#event{}]}.
list_by_calendar(CalendarId) ->
Match = #event{calendar_id = CalendarId, status = active, is_instance = false, _ = '_'},
Events = mnesia:dirty_match_object(Match),
%% Optimized: use calendar_id index instead of full table scan,
%% then filter by status and is_instance.
Candidates = mnesia:dirty_index_read(event, CalendarId, #event.calendar_id),
Events = [E || E <- Candidates,
E#event.status =:= active andalso E#event.is_instance =:= false],
{ok, Events}.
%%%-------------------------------------------------------------------
%%% @doc Active events including materialized occurrences (for booking inbox).
%%% @end
%%%-------------------------------------------------------------------
-spec list_active_including_instances(CalendarId :: binary()) -> {ok, [#event{}]}.
list_active_including_instances(CalendarId) ->
Candidates = mnesia:dirty_index_read(event, CalendarId, #event.calendar_id),
Events = [E || E <- Candidates, E#event.status =:= active],
{ok, Events}.
%%%-------------------------------------------------------------------
@@ -229,8 +245,10 @@ count_events() ->
%%%-------------------------------------------------------------------
-spec list_all() -> [#event{}].
list_all() ->
Match = #event{status = active, is_instance = false, _ = '_'},
mnesia:dirty_match_object(Match).
%% Optimized: use status index to read only active events,
%% then filter by is_instance instead of a full table scan.
Active = mnesia:dirty_index_read(event, active, #event.status),
[E || E <- Active, E#event.is_instance =:= false].
%%%-------------------------------------------------------------------
%%% @doc Подсчёт событий, созданных в заданном временном диапазоне.
@@ -241,6 +259,9 @@ list_all() ->
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
count_events_by_date(From, To) ->
core_stats:with_daily(events_created, From, To, fun() ->
%% TODO: created_at has a hash index (no range-scan support in Mnesia);
%% a full scan is still required here. Pre-aggregate in stats_collector
%% to avoid this fallback path.
All = mnesia:dirty_match_object(#event{_ = '_'}),
Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso
E#event.created_at =< To end, All),
@@ -261,7 +282,12 @@ count_events_by_date(From, To) ->
-spec count_events_by_type() -> [{atom(), non_neg_integer()}].
count_events_by_type() ->
core_stats:with_dim(event, type, fun() ->
Events = mnesia:dirty_match_object(#event{_ = '_'}),
%% Optimized: use event_type index reads for known values instead of
%% a full table scan. event_type :: single | recurring.
KnownTypes = [single, recurring],
Events = lists:flatmap(
fun(Type) -> mnesia:dirty_index_read(event, Type, #event.event_type) end,
KnownTypes),
lists:foldl(fun(#event{event_type = Type}, Acc) ->
case lists:keyfind(Type, 1, Acc) of
false -> [{Type, 1} | Acc];
@@ -277,7 +303,12 @@ count_events_by_type() ->
-spec count_events_by_status() -> [{atom(), non_neg_integer()}].
count_events_by_status() ->
core_stats:with_dim(event, status, fun() ->
Events = mnesia:dirty_match_object(#event{_ = '_'}),
%% Optimized: use status index reads for known values instead of
%% a full table scan. status :: active | cancelled | completed | frozen | deleted.
KnownStatuses = [active, cancelled, completed, frozen, deleted],
Events = lists:flatmap(
fun(Status) -> mnesia:dirty_index_read(event, Status, #event.status) end,
KnownStatuses),
lists:foldl(fun(#event{status = Status}, Acc) ->
case lists:keyfind(Status, 1, Acc) of
false -> [{Status, 1} | Acc];
@@ -295,6 +326,8 @@ get_top_events_by_rating(N) ->
case stats_tops:get_top_events_by_rating(N) of
{ok, Events} -> Events;
{error, _} ->
%% TODO: no index on rating_avg; full scan is required for this fallback.
%% The primary path uses stats_tops; this is only a fallback.
Events = mnesia:dirty_match_object(#event{_ = '_'}),
Sorted = lists:reverse(lists:sort(
fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end,
+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.
+2 -1
View File
@@ -114,7 +114,8 @@ delete(Id) ->
[] ->
{error, not_found};
[Review] ->
Votes = mnesia:match_object(#review_vote{review_id = Id, _ = '_'}),
%% Use review_vote.review_id index instead of full-table match.
Votes = mnesia:index_read(review_vote, Id, #review_vote.review_id),
lists:foreach(fun(#review_vote{id = VoteId}) ->
mnesia:delete({review_vote, VoteId})
end, Votes),
+141 -77
View File
@@ -1,9 +1,10 @@
-module(core_user).
-include("records.hrl").
-include_lib("stdlib/include/ms_transform.hrl").
-export([create/2, get_by_id/1, get_by_email/1, update/2, update_status/3,
delete/1, update_last_login/1]).
-export([email_exists/1]).
-export([list_users/0]).
-export([list_users/0, list_users/2]).
-export([block/2, unblock/2]).
-export([count_users/0, count_users_by_date/2, count_pending_users/0, count_pending_users_by_date/2, list_all/0]).
-export([count_users_by_role/0, count_users_by_status/0]).
@@ -80,10 +81,10 @@ get_by_id(Id) ->
%%%-------------------------------------------------------------------
-spec get_by_email(Email :: binary()) -> {ok, #user{}} | {error, not_found}.
get_by_email(Email) ->
Match = #user{email = Email, _ = '_'},
case mnesia:dirty_match_object(Match) of
%% Use the index on #user.email instead of a full table scan.
case mnesia:dirty_index_read(user, Email, #user.email) of
[] -> {error, not_found};
[User] -> {ok, User}
[User | _] -> {ok, User}
end.
%%%-------------------------------------------------------------------
@@ -125,14 +126,21 @@ update(Id, Updates) ->
%%% Устанавливает `last_login` в текущее UTC-время.
%%% @end
%%%-------------------------------------------------------------------
-spec update_last_login(Id :: binary()) -> {ok, #user{}} | {error, not_found}.
-spec update_last_login(Id :: binary()) -> {ok, #user{}} | {error, not_found | term()}.
update_last_login(Id) ->
case get_by_id(Id) of
{ok, User} ->
Updated = User#user{last_login = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
%% Transaction prevents lost updates when concurrent logins happen.
F = fun() ->
case mnesia:read(user, Id, write) of
[] -> {error, not_found};
[User] ->
Updated = User#user{last_login = calendar:universal_time()},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
%%%-------------------------------------------------------------------
@@ -140,15 +148,23 @@ update_last_login(Id) ->
%%% @end
%%%-------------------------------------------------------------------
-spec update_status(Id :: binary(), Status :: atom(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
{ok, #user{}} | {error, not_found | term()}.
update_status(Id, Status, Reason) ->
case get_by_id(Id) of
{ok, User} ->
Updated = User#user{status = Status, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
%% Transaction ensures atomic status change — prevents lost updates
%% when two admins change the same user's status simultaneously.
F = fun() ->
case mnesia:read(user, Id, write) of
[] -> {error, not_found};
[User] ->
Updated = User#user{status = Status, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, AbortReason} -> {error, AbortReason}
end.
%%%-------------------------------------------------------------------
@@ -167,9 +183,32 @@ delete(Id) ->
%%%-------------------------------------------------------------------
-spec list_users() -> {ok, [map()]}.
list_users() ->
Users = mnesia:dirty_match_object(#user{_ = '_'}),
ActiveUsers = [U || U <- Users, U#user.status =/= deleted],
{ok, [user_to_map(U) || U <- ActiveUsers]}.
%% Default pagination: first 100 non-deleted users.
list_users(100, 0).
%%%-------------------------------------------------------------------
%%% @doc Получить список активных пользователей с пагинацией.
%%% `Limit` — максимальное количество записей, `Offset` — смещение.
%%% Фильтрация status =/= deleted выполняется в match-спецификации.
%%% @end
%%%-------------------------------------------------------------------
-spec list_users(Limit :: pos_integer(), Offset :: non_neg_integer()) -> {ok, [map()]}.
list_users(Limit, Offset) ->
%% Use select with a match spec to filter out deleted users at DB level
%% and apply limit for pagination.
MS = ets:fun2ms(fun(#user{status = S} = U) when S =/= deleted -> U end),
case mnesia:dirty_select(user, MS) of
[] ->
{ok, []};
All ->
%% Apply offset/limit at the list level (Mnesia dirty_select
%% doesn't support offset natively, but we filtered at DB level).
Paged = case length(All) > Offset of
true -> lists:sublist(lists:nthtail(Offset, All), Limit);
false -> []
end,
{ok, [user_to_map(U) || U <- Paged]}
end.
%%%-------------------------------------------------------------------
%%% @doc Преобразование записи пользователя в map.
@@ -203,15 +242,22 @@ user_to_map(User) ->
%%% @end
%%%-------------------------------------------------------------------
-spec block(Id :: binary(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
{ok, #user{}} | {error, not_found | term()}.
block(Id, Reason) ->
case get_by_id(Id) of
{ok, User} ->
Updated = User#user{status = blocked, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
%% Transaction prevents concurrent status changes from racing.
F = fun() ->
case mnesia:read(user, Id, write) of
[] -> {error, not_found};
[User] ->
Updated = User#user{status = blocked, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, AbortReason} -> {error, AbortReason}
end.
%%%-------------------------------------------------------------------
@@ -219,15 +265,22 @@ block(Id, Reason) ->
%%% @end
%%%-------------------------------------------------------------------
-spec unblock(Id :: binary(), Reason :: binary()) ->
{ok, #user{}} | {error, not_found}.
{ok, #user{}} | {error, not_found | term()}.
unblock(Id, Reason) ->
case get_by_id(Id) of
{ok, User} ->
Updated = User#user{status = active, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
%% Transaction prevents concurrent status changes from racing.
F = fun() ->
case mnesia:read(user, Id, write) of
[] -> {error, not_found};
[User] ->
Updated = User#user{status = active, reason = Reason,
updated_at = calendar:universal_time()},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, AbortReason} -> {error, AbortReason}
end.
%%%-------------------------------------------------------------------
@@ -393,50 +446,61 @@ set_field(_, _, U) -> U.
-spec create_bot(Email :: binary(), Password :: binary()) ->
{ok, #user{}} | {error, email_exists | invalid_email}.
create_bot(Email, Password) ->
case email_exists(Email) of
true ->
{error, email_exists};
false ->
case mnesia:dirty_index_read(user, Email, email) of
[] ->
{ok, PasswordHash} = logic_auth:hash_password(Password),
Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
User = #user{
id = Id,
email = Email,
password_hash = PasswordHash,
role = bot,
status = active,
reason = ?DEFAULT_REASON,
nickname = extract_nickname(Email),
avatar_url = ?DEFAULT_AVATAR_URL,
timezone = ?DEFAULT_TIMEZONE,
language = ?DEFAULT_LANGUAGE,
social_links = ?DEFAULT_SOCIAL_LINKS,
phone = ?DEFAULT_PHONE,
preferences = ?DEFAULT_PREFERENCES,
last_login = ?DEFAULT_LAST_LOGIN,
created_at = Now,
updated_at = Now
},
ok = mnesia:dirty_write(User),
{ok, User};
_ ->
{error, duplicate_email}
end
%% Hash password outside the transaction to minimize lock time.
{ok, PasswordHash} = logic_auth:hash_password(Password),
%% Transaction ensures the email-uniqueness check and write are atomic,
%% preventing duplicate bots under concurrent creation.
F = fun() ->
case mnesia:index_read(user, Email, #user.email) of
[] ->
Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
User = #user{
id = Id,
email = Email,
password_hash = PasswordHash,
role = bot,
status = active,
reason = ?DEFAULT_REASON,
nickname = extract_nickname(Email),
avatar_url = ?DEFAULT_AVATAR_URL,
timezone = ?DEFAULT_TIMEZONE,
language = ?DEFAULT_LANGUAGE,
social_links = ?DEFAULT_SOCIAL_LINKS,
phone = ?DEFAULT_PHONE,
preferences = ?DEFAULT_PREFERENCES,
last_login = ?DEFAULT_LAST_LOGIN,
created_at = Now,
updated_at = Now
},
ok = mnesia:write(User),
{ok, User};
_ ->
{error, email_exists}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, AbortReason} -> {error, AbortReason}
end.
%%%-------------------------------------------------------------------
%%% @doc Удаление бота (физическое удаление записи, не мягкое).
%%% @end
%%%-------------------------------------------------------------------
-spec delete_bot(Id :: binary()) -> ok | {error, not_found}.
-spec delete_bot(Id :: binary()) -> ok | {error, not_found | term()}.
delete_bot(Id) ->
case mnesia:dirty_read({user, Id}) of
[#user{role = bot}] ->
mnesia:dirty_delete({user, Id}),
ok;
[] ->
{error, not_found}
%% Transaction ensures the read-then-delete is atomic.
F = fun() ->
case mnesia:read(user, Id, write) of
[#user{role = bot}] ->
mnesia:delete({user, Id}),
ok;
_ ->
{error, not_found}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{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,
jsx,
trails,
cowboy_swagger
cowboy_swagger,
ssl,
inets,
gen_smtp
]},
{env, [
{http_port, 8080},
+30 -2
View File
@@ -57,7 +57,6 @@ start_application() ->
ok = migration_engine:ensure_applied(),
%% После wait + migrations: иначе backfill ловит {no_exists, user} на ещё не загруженных таблицах.
ok = stats_collector:subscribe(),
calendar_html_renderer:init_cache(),
application:ensure_all_started(cowboy),
start_http(), % Пользовательский API (8080)
start_admin_http(), % Административный API (8445)
@@ -83,35 +82,61 @@ start_http() ->
{'_', [
{"/metrics/[:registry]", prometheus_cowboy2_handler, []},
{"/health", handler_health, []},
{"/v1/media/:kind/:owner/:file", handler_media, []},
{"/v1/register", handler_register, []},
{"/v1/verify", handler_verify, []},
{"/v1/forgot-password", handler_forgot_password, []},
{"/v1/reset-password", handler_reset_password, []},
{"/v1/login", handler_login, []},
{"/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/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/booking-requests", handler_user_booking_requests, []},
{"/v1/user/studio-bookings", handler_user_studio_bookings, []},
{"/v1/user/reviews", handler_user_reviews, []},
{"/v1/user/following", handler_user_following, []},
{"/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/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/:id", handler_calendar_by_id, []},
{"/v1/calendars/:id/cover", handler_calendar_cover, []},
{"/v1/calendars/:id/follow", handler_calendar_follow, []},
{"/v1/calendars/:id/specialists", 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/: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/:id/accept", 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/events/:id", handler_event_by_id, []},
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
{"/v1/events/:id/bookings", handler_bookings, []},
{"/v1/events/:id/waitlist", handler_event_waitlist, []},
{"/v1/bookings/:id", handler_booking_by_id, []},
{"/v1/reviews", handler_reviews, []},
{"/v1/reviews/:id", handler_review_by_id, []},
@@ -145,6 +170,7 @@ start_admin_http() ->
{"/v1/admin/nodes/metrics", admin_handler_node_metrics, []},
{"/v1/admin/login", admin_handler_login, []},
{"/v1/admin/refresh", admin_handler_refresh, []},
{"/v1/admin/logout", admin_handler_logout, []},
% ================== ПОЛЬЗОВАТЕЛИ ==================
{"/v1/admin/users", admin_handler_users, []},
{"/v1/admin/users/stats", admin_handler_user_stats, []},
@@ -183,6 +209,8 @@ start_admin_http() ->
{"/v1/admin/subscriptions", admin_handler_subscriptions, []},
{"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []},
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
% ================== ИИ-МЕТРИКИ ==================
{"/v1/admin/ai-metrics/stats", admin_handler_ai_metrics, []},
% ================== Управление ролями (только для superadmin) ==================
{"/v1/admin/me", admin_handler_me, []},
{"/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({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
convert_field({<<"location">>, Val}) when is_map(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({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
+3 -2
View File
@@ -22,7 +22,7 @@ init(Req0, _State) ->
{ok, _Token, User} ->
UserId = maps:get(id, 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),
% Аудит успешного входа
admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, <<>>, Req1),
@@ -33,7 +33,8 @@ init(Req0, _State) ->
<<"email">> => maps:get(email, User),
<<"role">> => maps:get(role, User)
},
<<"refresh_token">> => RefreshToken
<<"refresh_token">> => RefreshToken,
<<"session_id">> => SessionId
},
handler_utils:send_json(Req1, 200, Resp);
{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
#{<<"refresh_token">> := RefreshToken} ->
case logic_auth_session:refresh_admin(RefreshToken) of
{ok, NewToken, NewRefreshToken} ->
{ok, NewToken, NewRefreshToken, SessionId} ->
handler_utils:send_json(Req1, 200, #{
token => NewToken,
refresh_token => NewRefreshToken
refresh_token => NewRefreshToken,
session_id => SessionId
});
{error, 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.
+5 -1
View File
@@ -88,7 +88,7 @@ booking_schema() ->
id => #{type => string},
event_id => #{type => string},
user_id => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>, <<"expired">>]},
notes => #{type => string, nullable => true},
reminder_sent => #{type => boolean},
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
@@ -143,6 +143,10 @@ update_booking(Req) ->
handler_utils:send_json(Req2, 200, booking_to_json(Booking));
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, expired} ->
handler_utils:send_error(Req2, 409, <<"Booking expired">>);
{error, full} ->
handler_utils:send_error(Req2, 409, <<"Event is full">>);
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Booking not found">>);
{error, _} ->
+76 -25
View File
@@ -45,12 +45,29 @@ trails() ->
schema => #{type => string}
}
],
requestBody => #{
required => false,
content => #{
<<"application/json">> => #{
schema => #{
type => object,
properties => #{
occurrence_start => #{
type => string,
format => <<"date-time">>,
description => <<"Required for recurring masters">>
}
}
}
}
}
},
responses => #{
201 => #{
description => <<"Booking created">>,
content => #{<<"application/json">> => #{schema => BookingSchema}}
},
400 => #{description => <<"Event is full or not active">>},
400 => #{description => <<"Event is full, not active, or occurrence_start missing/invalid">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Event not found">>},
409 => #{description => <<"Already booked">>}
@@ -92,7 +109,7 @@ booking_schema() ->
id => #{type => string},
event_id => #{type => string},
user_id => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>, <<"expired">>]},
notes => #{type => string, nullable => true},
reminder_sent => #{type => boolean},
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
@@ -111,29 +128,39 @@ create_booking(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
EventId = cowboy_req:binding(id, Req1),
case logic_booking:create_booking(UserId, EventId) of
{ok, Booking} ->
handler_utils:send_json(Req1, 201, booking_to_json(Booking));
{error, already_booked} ->
handler_utils:send_error(Req1, 409, <<"Already booked">>);
{error, full} ->
handler_utils:send_error(Req1, 400, <<"Event is full">>);
{error, event_full} ->
handler_utils:send_error(Req1, 400, <<"Event is full">>);
{error, event_not_active} ->
handler_utils:send_error(Req1, 400, <<"Event is not active">>);
{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, <<"Cannot book own event">>);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
{ok, Body, Req2} = cowboy_req:read_body(Req1),
case parse_occurrence_start(Body) of
{error, invalid_occurrence} ->
handler_utils:send_error(Req2, 400, <<"Invalid occurrence_start">>);
{ok, OccurrenceStart} ->
case logic_booking:create_booking(UserId, EventId, OccurrenceStart) of
{ok, Booking} ->
handler_utils:send_json(Req2, 201, booking_to_json(Booking));
{error, already_booked} ->
handler_utils:send_error(Req2, 409, <<"Already booked">>);
{error, full} ->
handler_utils:send_error(Req2, 400, <<"Event is full">>);
{error, event_full} ->
handler_utils:send_error(Req2, 400, <<"Event is full">>);
{error, event_not_active} ->
handler_utils:send_error(Req2, 400, <<"Event is not active">>);
{error, occurrence_start_required} ->
handler_utils:send_error(Req2, 400, <<"occurrence_start required">>);
{error, invalid_occurrence} ->
handler_utils:send_error(Req2, 400, <<"Invalid occurrence_start">>);
{error, personal_calendar} ->
handler_utils:send_error(Req2, 403, <<"personal_calendar">>);
{error, subscription_inactive} ->
handler_utils:send_error(Req2, 403, <<"subscription_inactive">>);
{error, own_event} ->
handler_utils:send_error(Req2, 403, <<"Cannot book own event">>);
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Event not found">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
@@ -168,3 +195,27 @@ list_bookings(Req) ->
-spec booking_to_json(#booking{}) -> map().
booking_to_json(Booking) ->
handler_utils:booking_to_json(Booking).
-spec parse_occurrence_start(binary()) ->
{ok, calendar:datetime() | undefined} | {error, invalid_occurrence}.
parse_occurrence_start(<<>>) ->
{ok, undefined};
parse_occurrence_start(Body) ->
try jsx:decode(Body, [return_maps]) of
Map when is_map(Map) ->
case maps:get(<<"occurrence_start">>, Map, undefined) of
undefined ->
{ok, undefined};
Iso when is_binary(Iso) ->
case handler_utils:parse_datetime(Iso) of
{ok, Dt} -> {ok, Dt};
{error, _} -> {error, invalid_occurrence}
end;
_ ->
{error, invalid_occurrence}
end;
_ ->
{ok, undefined}
catch
_:_ -> {error, invalid_occurrence}
end.
+22 -3
View File
@@ -85,6 +85,7 @@ calendar_schema() ->
type => #{type => string, enum => [<<"personal">>, <<"commercial">>]},
confirmation => #{type => string, enum => [<<"auto">>, <<"manual">>]},
tags => #{type => array, items => #{type => string}},
settings => #{type => object},
rating_avg => #{type => number, format => float},
rating_count => #{type => integer},
status => #{type => string},
@@ -102,20 +103,26 @@ calendar_update_schema() ->
description => #{type => string},
type => #{type => string},
confirmation => #{type => string},
tags => #{type => array, items => #{type => string}}
tags => #{type => array, items => #{type => string}},
short_name => #{type => string},
category => #{type => string},
color => #{type => string},
image_url => #{type => string},
settings => #{type => object, nullable => true}
}
}.
%%% Internal functions
get_calendar(Req) ->
case handler_utils:auth_user(Req) of
case handler_utils:auth_user_optional(Req) of
{ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
case logic_calendar:get_calendar(UserId, CalendarId) of
{ok, Calendar} ->
Json0 = handler_utils:calendar_to_json(Calendar),
Following = 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});
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
@@ -146,8 +153,13 @@ update_calendar(Req) ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, subscription_required} ->
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>);
{error, default_calendar} ->
handler_utils:send_error(Req2, 403, <<"default_calendar">>);
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, {invalid_settings, Key}} when is_binary(Key) ->
handler_utils:send_error(Req2, 400,
<<"Invalid settings.", Key/binary>>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;
@@ -167,6 +179,8 @@ delete_calendar(Req) ->
case logic_calendar:delete_calendar(UserId, CalendarId) of
{ok, _} ->
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, default_calendar} ->
handler_utils:send_error(Req1, 403, <<"default_calendar">>);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} ->
@@ -195,4 +209,9 @@ convert_field({<<"confirmation">>, #{<<"timeout">> := N}}) when is_integer(N), N
{confirmation, {timeout, N}};
convert_field({<<"confirmation">>, Val}) -> {confirmation, Val};
convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"short_name">>, Val}) -> {short_name, Val};
convert_field({<<"category">>, Val}) -> {category, Val};
convert_field({<<"color">>, Val}) -> {color, Val};
convert_field({<<"image_url">>, Val}) -> {image_url, Val};
convert_field({<<"settings">>, Val}) when is_map(Val) -> {settings, Val};
convert_field(Other) -> Other.
+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">>).
@@ -94,7 +94,7 @@ has_user_binding(Req) ->
cowboy_req:binding(user_id, Req) =/= undefined.
list_specialists(Req) ->
case handler_utils:auth_user(Req) of
case handler_utils:auth_user_optional(Req) of
{ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1),
case logic_calendar_specialist:list(UserId, CalendarId) of
@@ -187,6 +187,8 @@ remove_specialist(Req) ->
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Not found">>);
{error, owner_specialist_protected} ->
handler_utils:send_error(Req1, 403, <<"Owner specialist cannot be removed">>);
{error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_commercial} ->
-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.
+38 -16
View File
@@ -117,23 +117,31 @@ create_calendar(Req) ->
Confirmation = parse_confirmation(maps:get(<<"confirmation">>, Decoded, <<"manual">>)),
Tags = maps:get(<<"tags">>, Decoded, []),
Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)),
case Type of
commercial ->
case logic_subscription:can_create_commercial_calendar(UserId) of
true -> ok;
false ->
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>),
throw(stop)
end;
personal -> ok
end,
case logic_calendar:create_calendar(UserId, Title, Description, Confirmation) of
Settings = maps:get(<<"settings">>, Decoded, undefined),
case logic_calendar:create_calendar(UserId, Title, Description, Confirmation, Type) of
{ok, Calendar} ->
Updates = [{tags, Tags}, {type, Type}],
core_calendar:update(Calendar#calendar.id, Updates),
{ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id),
Extra0 = case Tags of
[] -> [];
_ -> [{tags, Tags}]
end,
Extra = case Settings of
S when is_map(S) -> [{settings, S} | Extra0];
_ -> Extra0
end,
Updated = case Extra of
[] -> Calendar;
_ ->
case logic_calendar:update_calendar(UserId, Calendar#calendar.id, Extra) of
{ok, C2} -> C2;
_ -> Calendar
end
end,
Response = calendar_to_json(Updated),
handler_utils:send_json(Req2, 201, Response);
{error, subscription_required} ->
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>);
{error, personal_exists} ->
handler_utils:send_error(Req2, 409, <<"personal_exists">>);
{error, user_inactive} ->
handler_utils:send_error(Req2, 403, <<"User account is not active">>);
{error, {content_banned, _Words}} ->
@@ -147,7 +155,6 @@ create_calendar(Req) ->
_ ->
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch
throw:stop -> ok;
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
end;
{error, Code, Message, Req1} ->
@@ -161,7 +168,7 @@ list_calendars(Req) ->
{ok, UserId, Req1} ->
case logic_calendar:list_calendars(UserId) of
{ok, Calendars} ->
Response = [calendar_to_json(C) || C <- Calendars],
Response = [calendar_list_json(UserId, C) || C <- Calendars],
handler_utils:send_json(Req1, 200, Response);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
@@ -174,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().
calendar_to_json(Calendar) ->
Base = handler_utils:calendar_to_json(Calendar),
+7 -8
View File
@@ -186,8 +186,12 @@ update_event(Req) ->
handler_utils:send_error(Req2, 404, <<"Event not found">>);
{error, event_in_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, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, invalid_specialist} ->
handler_utils:send_error(Req2, 400, <<"Invalid specialist_id for this calendar">>);
{error, _} ->
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end;
@@ -213,6 +217,8 @@ delete_event(Req) ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, archived} ->
handler_utils:send_error(Req1, 409, <<"Month is archived">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
@@ -243,14 +249,7 @@ convert_field({<<"recurrence">>, Val}) ->
RuleJson = jsx:encode(Val),
{recurrence_rule, RuleJson};
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
convert_field({<<"location">>, Val}) when is_map(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({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, 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.
+74 -32
View File
@@ -140,6 +140,7 @@ event_create_schema() ->
lon => #{type => number, format => float}
}
},
specialist_id => #{type => string, nullable => true},
recurrence => #{type => object, description => <<"Recurrence rule (RFC 5545)">>}
}
}.
@@ -176,16 +177,15 @@ create_event(Req) ->
undefined ->
case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration, Description) of
{ok, Event} ->
update_event_fields(UserId, Event#event.id, Location, Decoded),
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
Response = handler_utils:event_to_json(UpdatedEvent),
handler_utils:send_json(Req2, 201, Response);
finish_create_event(Req2, UserId, Event, Location, Decoded);
{error, access_denied} ->
handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_found} ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, event_in_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, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} ->
@@ -194,10 +194,7 @@ create_event(Req) ->
RRule ->
case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, Description) of
{ok, Event} ->
update_event_fields(UserId, Event#event.id, Location, Decoded),
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
Response = handler_utils:event_to_json(UpdatedEvent),
handler_utils:send_json(Req2, 201, Response);
finish_create_event(Req2, UserId, Event, Location, Decoded);
{error, invalid_rrule} ->
handler_utils:send_error(Req2, 400, <<"Invalid recurrence rule">>);
{error, access_denied} ->
@@ -206,6 +203,8 @@ create_event(Req) ->
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, event_in_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, _}} ->
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
{error, _} ->
@@ -230,7 +229,7 @@ create_event(Req) ->
%% @doc GET /v1/calendars/:calendar_id/events список событий.
-spec list_events(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_events(Req) ->
case handler_utils:auth_user(Req) of
case handler_utils:auth_user_optional(Req) of
{ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(calendar_id, Req1),
Qs = cowboy_req:parse_qs(Req1),
@@ -244,7 +243,10 @@ list_events(Req) ->
{FromStr, ToStr} ->
FromDt = parse_datetime_binary(FromStr),
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,
handler_utils:send_json(Req1, 200, Response);
{error, access_denied} ->
@@ -262,6 +264,21 @@ list_events(Req) ->
%%% Вспомогательные функции
%%%===================================================================
finish_create_event(Req, UserId, Event, Location, Decoded) ->
case update_event_fields(UserId, Event#event.id, Location, Decoded) of
ok ->
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
Response = handler_utils:event_to_json(UpdatedEvent),
handler_utils:send_json(Req, 201, Response);
{error, invalid_specialist} ->
_ = logic_event:delete_event(UserId, Event#event.id),
handler_utils:send_error(Req, 400, <<"Invalid specialist_id for this calendar">>);
{error, _} ->
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
Response = handler_utils:event_to_json(UpdatedEvent),
handler_utils:send_json(Req, 201, Response)
end.
update_event_fields(UserId, EventId, Location, Decoded) ->
Updates = [],
Updates1 = case Location of undefined -> Updates; _ -> [{location, Location} | Updates] end,
@@ -269,47 +286,58 @@ update_event_fields(UserId, EventId, Location, Decoded) ->
Updates3 = case maps:get(<<"tags">>, Decoded, undefined) of undefined -> Updates2; Tags -> [{tags, Tags} | Updates2] end,
%% description already applied in create_event/6 when present
Updates4 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates3; Link -> [{online_link, Link} | Updates3] end,
case Updates4 of
Updates5 = case maps:get(<<"specialist_id">>, Decoded, undefined) of
undefined -> Updates4;
null -> [{specialist_id, <<>>} | Updates4];
SpecId when is_binary(SpecId) -> [{specialist_id, SpecId} | Updates4];
_ -> Updates4
end,
case Updates5 of
[] -> ok;
_ ->
case logic_event:update_event(UserId, EventId, Updates4) of
case logic_event:update_event(UserId, EventId, Updates5) of
{ok, _} -> ok;
_ -> ok
{error, _} = Err -> Err
end
end.
parse_location(undefined) -> undefined;
parse_location(LocationMap) when is_map(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.
parse_location(LocationMap) -> handler_utils:parse_location(LocationMap).
expand_recurring_events(UserId, Events, From, To) ->
lists:flatmap(fun(Event) ->
case Event#event.event_type of
single ->
case is_in_range(Event#event.start_time, From, To) of
true -> [handler_utils:event_to_json(Event)];
false -> []
case logic_month_archive:month_is_closed(Event#event.start_time) of
true -> [];
false ->
case is_in_range(Event#event.start_time, From, To) of
true -> [handler_utils:event_to_json(Event)];
false -> []
end
end;
recurring ->
case logic_event:get_occurrences(UserId, Event#event.id, To) of
{ok, Occurrences} ->
lists:filtermap(
fun({virtual, Occ}) ->
case is_in_range(Occ, From, To) of
true -> {true, occurrence_to_json(Event, Occ)};
false -> false
case logic_month_archive:month_is_closed(Occ) of
true -> false;
false ->
case is_in_range(Occ, From, To) of
true -> {true, occurrence_to_json(Event, Occ)};
false -> false
end
end;
({materialized, Instance}) ->
case is_in_range(Instance#event.start_time, From, To) of
true -> {true, handler_utils:event_to_json(Instance)};
false -> false
end
case logic_month_archive:month_is_closed(Instance#event.start_time) of
true -> false;
false ->
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
@@ -318,15 +346,29 @@ expand_recurring_events(UserId, Events, From, 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) ->
{ok, Dt} = handler_utils:parse_datetime(Str),
Dt.
occurrence_to_json(Master, Occurrence) ->
%% Virtual row: occupancy by master event id (materialized instances use event_to_json).
#{
master_id => Master#event.id,
start_time => handler_utils:datetime_to_iso8601(Occurrence),
duration => Master#event.duration,
title => Master#event.title,
is_virtual => true
is_virtual => true,
specialist_id => Master#event.specialist_id,
calendar_id => Master#event.calendar_id,
booking_occupancy => handler_utils:booking_occupancy(Master)
}.
+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.
+29 -15
View File
@@ -32,8 +32,10 @@ trails() ->
type => object,
required => [<<"email">>, <<"password">>],
properties => #{
email => #{type => string, format => <<"email">>},
password => #{type => string, format => <<"password">>}
email => #{type => string, format => <<"email">>},
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">>);
_ ->
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
{ok, _Token, User} ->
UserId = maps:get(id, User),
Role = maps:get(role, User),
{ok, Token, RefreshToken} = logic_auth_session:issue_user_tokens(UserId, Role),
core_user:update_last_login(UserId),
Response = #{
<<"token">> => Token,
<<"user">> => #{
<<"id">> => UserId,
<<"email">> => maps:get(email, User),
<<"role">> => maps:get(role, User)
},
<<"refresh_token">> => RefreshToken
},
handler_utils:send_json(Req1, 200, Response);
case logic_auth_session:issue_user_tokens(
UserId, Role, ClientType, DeviceName, UserAgent) of
{ok, Token, RefreshToken, SessionId} ->
core_user:update_last_login(UserId),
Response = #{
<<"token">> => Token,
<<"user">> => #{
<<"id">> => UserId,
<<"email">> => maps:get(email, User),
<<"role">> => maps:get(role, User)
},
<<"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} ->
handler_utils:send_error(Req1, 403, <<"Account not verified">>);
{error, frozen} ->
+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,
properties => #{
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
#{<<"refresh_token">> := RefreshToken} ->
case logic_auth_session:refresh_user(RefreshToken) of
{ok, NewToken, NewRefreshToken} ->
{ok, NewToken, NewRefreshToken, SessionId} ->
handler_utils:send_json(Req1, 200, #{
token => NewToken,
refresh_token => NewRefreshToken
refresh_token => NewRefreshToken,
session_id => SessionId
});
{error, reuse_detected} ->
handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>);
+60 -22
View File
@@ -32,7 +32,7 @@ trails() ->
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>},
#{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 => <<"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">>},
@@ -74,23 +74,27 @@ handle(Req, _Opts) ->
%% @doc GET /v1/search полнотекстовый поиск с фильтрами.
-spec search(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
search(Req) ->
case handler_utils:auth_user(Req) of
case handler_utils:auth_user_optional(Req) of
{ok, UserId, Req1} ->
Qs = cowboy_req:parse_qs(Req1),
Type = proplists:get_value(<<"type">>, Qs, undefined),
Query = proplists:get_value(<<"q">>, Qs, undefined),
Params = parse_params(Qs),
case logic_search:search(Type, Query, UserId, Params) of
{ok, Total, Results} ->
Response = #{
total => Total,
limit => maps:get(limit, Params, 20),
offset => maps:get(offset, Params, 0),
results => Results
},
handler_utils:send_json(Req1, 200, Response);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Search failed">>)
case parse_params(Qs) of
{error, invalid_radius} ->
handler_utils:send_error(Req1, 400, <<"invalid_radius">>);
Params ->
case logic_search:search(Type, Query, UserId, Params) of
{ok, Total, Results} ->
Response = #{
total => Total,
limit => maps:get(limit, Params, 20),
offset => maps:get(offset, Params, 0),
results => Results
},
handler_utils:send_json(Req1, 200, Response);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Search failed">>)
end
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
@@ -103,7 +107,8 @@ search(Req) ->
%% @private Собирает карту параметров для поискового движка.
%% Не кладёт sort/tags/geo/даты, если их нет в QS иначе
%% 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) ->
Params0 = #{
limit => parse_int_param(Qs, <<"limit">>, 20),
@@ -125,12 +130,22 @@ parse_params(Qs) ->
end,
Params1#{sort => Sort, order => Order}
end,
Params3 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
{{ok, Lat}, {ok, Lon}} ->
Radius = parse_int_param(Qs, <<"radius">>, 10),
Params2#{lat => Lat, lon => Lon, radius => Radius};
_ -> Params2
end,
case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
{{ok, Lat}, {ok, Lon}} ->
case parse_optional_radius(Qs) of
{error, invalid_radius} = E -> E;
Radius ->
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
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
{{ok, From}, error} -> Params3#{from => From};
@@ -138,6 +153,18 @@ parse_params(Qs) ->
_ -> Params3
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().
parse_int_param(Qs, Key, 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) ->
case proplists:get_value(Key, Qs) of
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.
-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.
@@ -0,0 +1,113 @@
%%%-------------------------------------------------------------------
%%% @doc Pending booking requests for the current user (owner / specialist inbox).
%%% GET /v1/user/booking-requests
%%% @end
%%%-------------------------------------------------------------------
-module(handler_user_booking_requests).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-export([item_to_json/1]).
-include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) ->
handle(Req, Opts).
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/user/booking-requests">>,
method => <<"GET">>,
description => <<"List pending booking requests the user can confirm/decline">>,
tags => [<<"Bookings">>],
responses => #{
200 => #{
description => <<"Array of enriched booking requests">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => request_schema()
}}}
},
401 => #{description => <<"Unauthorized">>}
}
}
].
request_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
event_id => #{type => string},
user_id => #{type => string},
status => #{type => string, enum => [<<"pending">>]},
role => #{type => string, enum => [<<"owner">>, <<"specialist">>]},
user_nickname => #{type => string, nullable => true},
user_email => #{type => string, nullable => true},
created_at => #{type => string, format => <<"date-time">>},
event => #{
type => object,
properties => #{
id => #{type => string},
calendar_id => #{type => string},
calendar_title => #{type => string, nullable => true},
title => #{type => string},
start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer},
specialist_id => #{type => string, nullable => true}
}
}
}
}.
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_requests(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec list_requests(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
list_requests(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case logic_booking:list_user_booking_requests(UserId) of
{ok, Items} ->
Response = [item_to_json(I) || I <- Items],
handler_utils:send_json(Req1, 200, Response);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
-spec item_to_json({#booking{}, #event{}, owner | specialist}) -> map().
item_to_json({Booking, Event, Role}) ->
CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Cal} -> Cal#calendar.title;
_ -> null
end,
SpecialistId = case Event#event.specialist_id of
<<>> -> null;
undefined -> null;
Sid -> Sid
end,
Base = handler_utils:booking_to_json(Booking),
EventMap = #{
id => Event#event.id,
calendar_id => Event#event.calendar_id,
calendar_title => CalendarTitle,
title => Event#event.title,
start_time => handler_utils:datetime_to_iso8601(Event#event.start_time),
duration => Event#event.duration,
specialist_id => SpecialistId
},
Base#{
role => Role,
event => EventMap
}.
+1 -1
View File
@@ -45,7 +45,7 @@ booking_schema() ->
id => #{type => string},
event_id => #{type => string},
user_id => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>, <<"expired">>]},
notes => #{type => string, nullable => true},
reminder_sent => #{type => boolean},
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
@@ -0,0 +1,59 @@
%%%-------------------------------------------------------------------
%%% @doc Confirmed + pending bookings on calendars the user owns or staffs.
%%% GET /v1/user/studio-bookings
%%% @end
%%%-------------------------------------------------------------------
-module(handler_user_studio_bookings).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) ->
handle(Req, Opts).
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/user/studio-bookings">>,
method => <<"GET">>,
description => <<"Pending and confirmed bookings on owned/staffed calendars">>,
tags => [<<"Bookings">>],
responses => #{
200 => #{
description => <<"Array of enriched studio bookings">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => #{type => object}
}}}
},
401 => #{description => <<"Unauthorized">>}
}
}
].
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_studio(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec list_studio(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
list_studio(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case logic_booking:list_user_studio_bookings(UserId) of
{ok, Items} ->
Response = [handler_user_booking_requests:item_to_json(I) || I <- Items],
handler_utils:send_json(Req1, 200, Response);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
+106 -7
View File
@@ -10,6 +10,7 @@
-export([
auth_admin/1,
auth_user/1,
auth_user_optional/1,
send_json/3,
send_json/4,
send_error/3,
@@ -19,6 +20,7 @@
parse_datetime/1,
datetime_to_iso8601/1,
event_to_json/1,
booking_occupancy/1,
user_to_json/1,
review_to_json/1,
review_to_json/2,
@@ -31,7 +33,10 @@
is_superadmin/1,
pagination_headers/2,
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]).
@@ -74,6 +79,17 @@ is_superadmin(Req) ->
auth_user(Req) ->
handler_auth:authenticate(Req).
%% @doc Как auth_user/1, но без заголовка Authorization гость (`<<>>`).
%% Невалидный Bearer по-прежнему 401.
-spec auth_user_optional(cowboy_req:req()) ->
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
auth_user_optional(Req) ->
case cowboy_req:header(<<"authorization">>, Req) of
undefined -> {ok, <<>>, Req};
<<>> -> {ok, <<>>, Req};
_ -> auth_user(Req)
end.
%%%===================================================================
%%% HTTPответы
%%%===================================================================
@@ -300,13 +316,11 @@ audit_reason(#{<<"reason">> := R}) when is_binary(R) -> R;
audit_reason(_) -> <<>>.
%% @doc Преобразует #event{} в JSON-карту.
%% Добавляет booking_occupancy: free | pending | confirmed
%% (confirmed > pending > free; cancelled/expired и past-pending не занятость).
-spec event_to_json(#event{}) -> map().
event_to_json(Event) ->
LocationJson = case Event#event.location of
undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon}
end,
LocationJson = location_to_json(Event#event.location),
RecurrenceJson = case Event#event.recurrence_rule of
undefined -> null;
Rule -> try jsx:decode(Rule, [return_maps]) of
@@ -337,9 +351,36 @@ event_to_json(Event) ->
attachments => Event#event.attachments,
edit_history => Event#event.edit_history,
created_at => datetime_to_iso8601(Event#event.created_at),
updated_at => datetime_to_iso8601(Event#event.updated_at)
updated_at => datetime_to_iso8601(Event#event.updated_at),
booking_occupancy => booking_occupancy(Event)
}.
%% @doc Активная занятость слота для сетки commercial (Studio aggregate).
%% Safe if Mnesia booking table is not ready (admin/unit tests without full schema).
-spec booking_occupancy(#event{}) -> free | pending | confirmed.
booking_occupancy(#event{id = EventId, start_time = Start}) ->
Bookings =
try
{ok, List} = core_booking:list_by_event(EventId),
List
catch
_:_ -> []
end,
Now = calendar:universal_time(),
lists:foldl(
fun(#booking{status = confirmed}, _Acc) ->
confirmed;
(#booking{status = pending}, Acc) ->
case Start > Now of
true when Acc =/= confirmed -> pending;
_ -> Acc
end;
(_, Acc) ->
Acc
end,
free,
Bookings).
%% @doc Преобразует #user{} в JSON-карту.
-spec user_to_json(#user{}) -> map().
user_to_json(User) ->
@@ -606,3 +647,61 @@ trails_for_crud(Path, _Resource, GetSchema, UpdateSchema) ->
}
}
].
%%%===================================================================
%%% 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.
+204
View File
@@ -0,0 +1,204 @@
%% ===================================================================
%% EventHub периодическая очистка append-only таблиц (TTL cleanup)
%% ===================================================================
%% gen_server, который раз в N миллисекунд (по умолчанию 1 час) запускает
%% фоновый процесс очистки устаревших записей:
%% notification прочитанные старше 30 дней
%% auth_session отозванные (revoked) или истёкшие старше 7 дней
%% admin_audit записи старше 90 дней
%%
%% Deletes run in bounded chunks so a single tick does not hold long
%% Mnesia locks on large tables.
%% ===================================================================
-module(infra_cleanup).
-behaviour(gen_server).
-include("records.hrl").
-include_lib("stdlib/include/ms_transform.hrl").
%% API
-export([start_link/0, run_once/0, cutoff_datetime/1]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
%% TTL thresholds in days
-define(NOTIFICATION_TTL_DAYS, 30).
-define(AUTH_SESSION_TTL_DAYS, 7).
-define(ADMIN_AUDIT_TTL_DAYS, 90).
%% Default cleanup interval: 1 hour
-define(DEFAULT_INTERVAL_MS, 3600000).
%% Chunked delete limits
-define(CHUNK_SIZE, 500).
-define(MAX_CHUNKS_PER_TICK, 20).
-record(state, {
interval_ms :: pos_integer(),
timer_ref :: reference() | undefined
}).
%% ===================================================================
%% API
%% ===================================================================
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
%% @doc Run one cleanup pass (used by tests and manual ops).
-spec run_once() ->
#{notifications := non_neg_integer(),
auth_sessions := non_neg_integer(),
admin_audit := non_neg_integer()}.
run_once() ->
cleanup_worker().
%% @doc Returns a calendar:datetime() that is `Days` days before now.
-spec cutoff_datetime(non_neg_integer()) -> calendar:datetime().
cutoff_datetime(Days) ->
Now = calendar:universal_time(),
Secs = calendar:datetime_to_gregorian_seconds(Now),
calendar:gregorian_seconds_to_datetime(Secs - Days * 86400).
%% ===================================================================
%% gen_server callbacks
%% ===================================================================
init([]) ->
process_flag(trap_exit, true),
IntervalMs = application:get_env(eventhub, cleanup_interval_ms, ?DEFAULT_INTERVAL_MS),
%% Schedule first run after a short delay to let the system stabilise
Ref = erlang:send_after(5000, self(), run_cleanup),
logger:info(#{what => infra_cleanup_started, interval_ms => IntervalMs}),
{ok, #state{interval_ms = IntervalMs, timer_ref = Ref}}.
handle_call(run_once, _From, State) ->
{reply, cleanup_worker(), State};
handle_call(_Msg, _From, State) ->
{reply, {error, unknown_call}, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(run_cleanup, #state{interval_ms = IntervalMs} = State) ->
spawn_link(fun cleanup_worker/0),
Ref = erlang:send_after(IntervalMs, self(), run_cleanup),
{noreply, State#state{timer_ref = Ref}};
handle_info({'EXIT', _Pid, normal}, State) ->
{noreply, State};
handle_info({'EXIT', _Pid, Reason}, State) ->
logger:warning(#{what => infra_cleanup_worker_exit, reason => Reason}),
{noreply, State};
handle_info(_Msg, State) ->
{noreply, State}.
terminate(_Reason, #state{timer_ref = Ref}) ->
case Ref of
undefined -> ok;
_ -> erlang:cancel_timer(Ref)
end,
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%% ===================================================================
%% Cleanup worker (runs in a spawned process or via run_once/0)
%% ===================================================================
cleanup_worker() ->
CutoffNotif = cutoff_datetime(?NOTIFICATION_TTL_DAYS),
CutoffAuth = cutoff_datetime(?AUTH_SESSION_TTL_DAYS),
CutoffAudit = cutoff_datetime(?ADMIN_AUDIT_TTL_DAYS),
N1 = cleanup_notifications(CutoffNotif),
N2 = cleanup_auth_sessions(CutoffAuth),
N3 = cleanup_admin_audit(CutoffAudit),
logger:info(#{what => infra_cleanup_done,
notifications => N1,
auth_sessions => N2,
admin_audit => N3}),
#{notifications => N1, auth_sessions => N2, admin_audit => N3}.
%% ===================================================================
%% Cleanup: notifications
%% ===================================================================
cleanup_notifications(Cutoff) ->
Ms = ets:fun2ms(fun(#notification{id = Id, is_read = true, created_at = CreatedAt})
when CreatedAt < Cutoff ->
Id
end),
delete_matching(notification, Ms).
%% ===================================================================
%% Cleanup: auth_sessions
%% ===================================================================
cleanup_auth_sessions(Cutoff) ->
MsRevoked = ets:fun2ms(fun(#auth_session{session_id = Id,
revoked = true,
created_at = CreatedAt})
when CreatedAt < Cutoff ->
Id
end),
NRevoked = delete_matching(auth_session, MsRevoked),
Now = calendar:universal_time(),
MsExpired = ets:fun2ms(fun(#auth_session{session_id = Id,
expires_at = ExpiresAt,
created_at = CreatedAt})
when CreatedAt < Cutoff,
ExpiresAt < Now ->
Id
end),
NExpired = delete_matching(auth_session, MsExpired),
NRevoked + NExpired.
%% ===================================================================
%% Cleanup: admin_audit
%% ===================================================================
cleanup_admin_audit(Cutoff) ->
Ms = ets:fun2ms(fun(#admin_audit{id = Id, timestamp = Ts})
when Ts < Cutoff ->
Id
end),
delete_matching(admin_audit, Ms).
%% ===================================================================
%% Chunked delete helpers
%% ===================================================================
%% @doc Delete matching keys in separate transactions of ?CHUNK_SIZE,
%% up to ?MAX_CHUNKS_PER_TICK per call. Re-selects after each chunk
%% (continuation is not valid across transactions).
delete_matching(Table, Ms) ->
delete_matching_loop(Table, Ms, 0, ?MAX_CHUNKS_PER_TICK).
delete_matching_loop(_Table, _Ms, Acc, 0) ->
Acc;
delete_matching_loop(Table, Ms, Acc, ChunksLeft) ->
case mnesia:transaction(fun() ->
case mnesia:select(Table, Ms, ?CHUNK_SIZE, write) of
'$end_of_table' ->
[];
{Ids, _Cont} ->
lists:foreach(fun(Id) -> mnesia:delete({Table, Id}) end, Ids),
Ids
end
end) of
{atomic, []} ->
Acc;
{atomic, Ids} when is_list(Ids) ->
delete_matching_loop(Table, Ms, Acc + length(Ids), ChunksLeft - 1);
{aborted, Reason} ->
logger:warning(#{what => infra_cleanup_tx_aborted,
table => Table,
reason => Reason}),
Acc
end.
+42 -7
View File
@@ -14,13 +14,15 @@
-define(TABLES, [
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,
booking,
booking, waitlist_entry,
review, review_vote, report, banned_word, automod_settings, automod_hit,
ticket, subscription,
admin_audit, notification,
stats_counter, stats_daily, node_metric, schema_migration
admin_audit, notification, push_subscription,
stats_counter, stats_daily, node_metric, schema_migration,
month_snapshot
]).
-define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]).
@@ -131,6 +133,7 @@ handle_call(init_tables, _From, State) ->
end,
ok = create_indices(),
%% stats_collector:subscribe после wait_for_tables + migrations (eventhub_app)
%% Table fragmentation: use infra_mnesia_fragmentation:fragment_table/2 manually.
ok = start_cleanup_timer(),
{reply, ok, State};
@@ -209,6 +212,18 @@ ensure_schema_disc() ->
true -> ok
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) ->
case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of
false ->
@@ -282,7 +297,9 @@ prune_dead_nodes() ->
lists:foreach(fun(Node) ->
io:format("Removing dead node ~p from Mnesia schema...~n", [Node]),
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);
false -> ok
end
@@ -316,13 +333,21 @@ create_table(Table) ->
table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, user)}];
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_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_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(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, event)}];
table_opts(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}];
%% bag: несколько cancel/reschedule на один master_id (ключ set = только master_id).
table_opts(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(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_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
@@ -333,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(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
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_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(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(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}];
@@ -359,6 +387,9 @@ create_indices() ->
mnesia:add_table_index(booking, event_id),
mnesia:add_table_index(booking, user_id),
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, user_id),
mnesia:add_table_index(calendar, owner_id),
@@ -378,8 +409,12 @@ create_indices() ->
mnesia:add_table_index(password_reset, user_id),
mnesia:add_table_index(notification, user_id),
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, subject_id),
mnesia:add_table_index(report, resolved_by),
mnesia:add_table_index(ticket, assigned_to),
mnesia:add_table_index(month_snapshot, calendar_id),
mnesia:add_table_index(month_snapshot, year_month),
ok.
+11 -5
View File
@@ -1,5 +1,5 @@
%% ===================================================================
%% EventHub инфраструктурный супервизор (с archive_manager)
%% EventHub инфраструктурный супервизор
%% ===================================================================
-module(infra_sup).
-behaviour(supervisor).
@@ -39,12 +39,12 @@ init([]) ->
start => {node_monitor, start_link, []},
restart => permanent,
type => worker},
#{id => archive_manager,
start => {archive_manager, start_link, []},
#{id => month_archive_worker,
start => {month_archive_worker, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [archive_manager]},
modules => [month_archive_worker]},
#{id => migration_engine,
start => {migration_engine, start_link, []},
restart => permanent,
@@ -56,6 +56,12 @@ init([]) ->
restart => permanent,
shutdown => 5000,
type => worker,
modules => [subscription_worker]}
modules => [subscription_worker]},
#{id => infra_cleanup,
start => {infra_cleanup, start_link, []},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [infra_cleanup]}
],
{ok, {SupFlags, Children}}.
+8 -1
View File
@@ -27,7 +27,14 @@
'20260719210000_review_vote',
'20260720210000_calendar_follow',
'20260722150000_calendar_specialist_id',
'20260722190000_specialist_invite'
'20260722190000_specialist_invite',
'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) ->
_ = catch logic_subscription:handle_expired_subscriptions(),
_ = catch logic_booking:process_timeout_bookings(),
_ = catch logic_booking:process_reminders(),
erlang:send_after(?INTERVAL_MS, self(), tick),
{noreply, 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).
-include("records.hrl").
-export([issue_admin_tokens/2, refresh_admin/1,
issue_user_tokens/2, refresh_user/1]).
-export([
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_MOBILE, <<"mobile">>).
-define(DEVICE_NAME_MAX, 120).
%%%-------------------------------------------------------------------
%%% Admin (фаза 1)
%%%-------------------------------------------------------------------
-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_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).
-spec refresh_admin(RefreshToken :: binary()) ->
{ok, AccessToken :: binary(), NewRefreshToken :: binary()} |
{ok, AccessToken :: binary(), NewRefreshToken :: binary(), SessionId :: binary()} |
{error, atom()}.
refresh_admin(RefreshToken) ->
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 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()) ->
{ok, AccessToken :: binary(), RefreshToken :: binary()}.
{ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()}.
issue_user_tokens(UserId, Role) ->
issue_tokens(UserId, user, ?USER_CLIENT_WEB, Role, fun eventhub_auth:generate_user_token/2,
fun eventhub_auth:generate_user_refresh_token/5).
issue_user_tokens(UserId, Role, ?USER_CLIENT_WEB, <<>>, <<>>).
-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()) ->
{ok, AccessToken :: binary(), NewRefreshToken :: binary()} |
{ok, AccessToken :: binary(), NewRefreshToken :: binary(), SessionId :: binary()} |
{error, atom()}.
refresh_user(RefreshToken) ->
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 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
%%%===================================================================
issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) ->
{ok, Session} = core_auth_session:create(SubjectId, SubjectType, ClientType),
logout_subject(RefreshToken, VerifyFn) ->
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),
{RefreshToken, _} = GenRefresh(
Session#auth_session.session_id,
@@ -60,28 +160,29 @@ issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) ->
SubjectId,
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
{ok, Claims} ->
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType);
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh);
Error ->
Error
end.
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) ->
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh) ->
SessionId = maps:get(<<"sid">>, Claims),
Jti = maps:get(<<"jti">>, Claims),
SubjectId = maps:get(<<"sub">>, Claims),
FamilyId = maps:get(<<"fid">>, Claims),
case core_auth_session:rotate(SessionId, Jti) of
{ok, NewJti, _Session} ->
{ok, NewJti, Session} ->
{ok, Subject} = GetSubjectFn(SubjectId),
Role = subject_role(Subject, SubjectType),
ClientType = Session#auth_session.client_type,
AccessToken = GenAccess(SubjectId, Role),
{NewRefresh, _} = GenRefresh(SessionId, FamilyId, NewJti, SubjectId, ClientType),
{ok, AccessToken, NewRefresh};
{ok, AccessToken, NewRefresh, SessionId};
{error, reuse_detected} ->
core_auth_session:revoke_family(FamilyId),
{error, reuse_detected};
+370 -42
View File
@@ -1,12 +1,14 @@
-module(logic_booking).
-include("records.hrl").
-export([create_booking/2, confirm_booking/2, confirm_booking/3,
-export([create_booking/2, create_booking/3, confirm_booking/2, confirm_booking/3,
cancel_booking/2, cancel_booking/3, get_booking/2,
list_bookings/2, list_user_bookings/1, delete_booking/2,
list_bookings/2, list_user_bookings/1, list_user_booking_requests/1,
list_user_studio_bookings/1,
delete_booking/2,
list_bookings_admin/0, get_booking_admin/1,
list_event_bookings/1, list_event_bookings/2,
process_timeout_bookings/0, cancel_pending_for_owner/1,
cancel_pending_for_calendar/1]).
process_timeout_bookings/0, process_reminders/0, cancel_pending_for_owner/1,
cancel_pending_for_calendar/1, can_manage_event_bookings/2]).
%%%-------------------------------------------------------------------
%%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
@@ -15,15 +17,30 @@
-spec create_booking(UserId :: binary(), EventId :: binary()) ->
{ok, #booking{}} |
{error, full | already_booked | not_found | personal_calendar |
subscription_inactive | own_event | event_not_active | access_denied}.
subscription_inactive | own_event | event_not_active | access_denied |
occurrence_start_required | invalid_occurrence}.
create_booking(UserId, EventId) ->
create_booking(UserId, EventId, undefined).
-spec create_booking(UserId :: binary(), EventId :: binary(),
OccurrenceStart :: calendar:datetime() | undefined) ->
{ok, #booking{}} |
{error, full | already_booked | not_found | personal_calendar |
subscription_inactive | own_event | event_not_active | access_denied |
occurrence_start_required | invalid_occurrence}.
create_booking(UserId, EventId, OccurrenceStart) ->
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, Calendar} ->
create_on_calendar(UserId, Event, Calendar);
{error, not_found} ->
{error, not_found}
case resolve_bookable_event(Event, OccurrenceStart) of
{ok, BookEvent} ->
case core_calendar:get_by_id(BookEvent#event.calendar_id) of
{ok, Calendar} ->
create_on_calendar(UserId, BookEvent, Calendar);
{error, not_found} ->
{error, not_found}
end;
{error, _} = Err ->
Err
end;
{ok, _} ->
{error, event_not_active};
@@ -31,6 +48,19 @@ create_booking(UserId, EventId) ->
{error, not_found}
end.
resolve_bookable_event(#event{event_type = recurring}, undefined) ->
{error, occurrence_start_required};
resolve_bookable_event(#event{event_type = recurring} = Master, OccurrenceStart) ->
case logic_event:validate_occurrence(Master, OccurrenceStart) of
ok ->
logic_event:materialize_for_booking(
Master#event.id, OccurrenceStart, Master#event.specialist_id);
{error, _} = Err ->
Err
end;
resolve_bookable_event(Event, _OccurrenceStart) ->
{ok, Event}.
create_on_calendar(UserId, _Event, #calendar{owner_id = UserId}) ->
{error, own_event};
create_on_calendar(_UserId, _Event, #calendar{type = personal}) ->
@@ -68,14 +98,14 @@ initial_status(_) -> pending.
%%%-------------------------------------------------------------------
-spec confirm_booking(BookingId :: binary(), UserId :: binary()) ->
{ok, #booking{}} | {error, not_found | access_denied | full}.
{ok, #booking{}} | {error, not_found | access_denied | full | expired}.
confirm_booking(BookingId, UserId) ->
case core_booking:get_by_id(BookingId) of
{ok, Booking} ->
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
true ->
case Booking#booking.status of
pending ->
case ensure_pending_actionable(Booking) of
{ok, _} ->
case event_capacity_ok(Booking#booking.event_id) of
true ->
Now = calendar:universal_time(),
@@ -83,8 +113,8 @@ confirm_booking(BookingId, UserId) ->
false ->
{error, full}
end;
_ ->
{error, access_denied}
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
@@ -93,7 +123,7 @@ confirm_booking(BookingId, UserId) ->
end.
-spec confirm_booking(UserId :: binary(), BookingId :: binary(), confirm | decline) ->
{ok, #booking{}} | {error, not_found | access_denied | full}.
{ok, #booking{}} | {error, not_found | access_denied | full | expired}.
confirm_booking(UserId, BookingId, confirm) ->
confirm_booking(BookingId, UserId);
confirm_booking(UserId, BookingId, decline) ->
@@ -101,11 +131,17 @@ confirm_booking(UserId, BookingId, decline) ->
{ok, Booking} ->
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
true ->
case Booking#booking.status of
pending ->
core_booking:update(BookingId, [{status, cancelled}]);
_ ->
{error, access_denied}
case ensure_pending_actionable(Booking) of
{ok, _} ->
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}
end;
{error, Reason} ->
{error, Reason}
@@ -121,9 +157,18 @@ cancel_booking(BookingId, UserId) ->
case Booking#booking.status of
cancelled ->
{ok, Booking};
expired ->
{ok, Booking};
_ ->
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}
end
end;
@@ -139,7 +184,8 @@ cancel_booking(UserId, BookingId, cancel) ->
{ok, #booking{}} | {error, not_found | access_denied}.
get_booking(BookingId, UserId) ->
case core_booking:get_by_id(BookingId) of
{ok, Booking} ->
{ok, Booking0} ->
Booking = ensure_not_past_pending(Booking0),
case Booking#booking.user_id =:= UserId of
true -> {ok, Booking};
false ->
@@ -159,11 +205,133 @@ list_bookings(EventId, UserId) ->
true -> Bookings;
false -> [B || B <- Bookings, B#booking.user_id =:= UserId]
end,
{ok, Filtered}.
{ok, [ensure_not_past_pending(B) || B <- Filtered]}.
-spec list_user_bookings(UserId :: binary()) -> {ok, [#booking{}]}.
list_user_bookings(UserId) ->
core_booking:list_by_user(UserId).
{ok, Bookings} = core_booking:list_by_user(UserId),
{ok, [ensure_not_past_pending(B) || B <- Bookings]}.
%%%-------------------------------------------------------------------
%%% @doc Pending bookings the user can confirm/decline as owner or specialist.
%%% Returns `{ok, [{Booking, Event, Role}]}` where Role is `owner` | `specialist`.
%%% @end
%%%-------------------------------------------------------------------
-spec list_user_booking_requests(UserId :: binary()) ->
{ok, [{#booking{}, #event{}, owner | specialist}]}.
list_user_booking_requests(UserId) ->
{OwnedEventIds, SpecOnly} = managed_event_ids(UserId),
OwnerItems = collect_pending(OwnedEventIds, owner),
SpecItems = collect_pending(SpecOnly, specialist),
{ok, sort_requests(OwnerItems ++ SpecItems)}.
-spec list_user_studio_bookings(UserId :: binary()) ->
{ok, [{#booking{}, #event{}, owner | specialist}]}.
list_user_studio_bookings(UserId) ->
{OwnedEventIds, SpecOnly} = managed_event_ids(UserId),
OwnerItems = collect_studio(OwnedEventIds, owner),
SpecItems = collect_studio(SpecOnly, specialist),
{ok, sort_requests(OwnerItems ++ SpecItems)}.
managed_event_ids(UserId) ->
OwnedEventIds = owned_event_ids(UserId),
SpecEventIds = specialist_event_ids(UserId),
OwnedSet = sets:from_list(OwnedEventIds),
SpecOnly = [E || E <- SpecEventIds, not sets:is_element(E, OwnedSet)],
{OwnedEventIds, SpecOnly}.
owned_event_ids(UserId) ->
case core_calendar:list_by_owner(UserId) of
{ok, Cals} ->
lists:flatmap(fun(#calendar{id = CalId}) ->
case core_event:list_active_including_instances(CalId) of
{ok, Events} -> [E#event.id || E <- Events];
_ -> []
end
end, Cals);
_ ->
[]
end.
specialist_event_ids(UserId) ->
Specs = [S || S <- core_calendar_specialist:list_by_user(UserId),
S#calendar_specialist.status =:= active],
lists:flatmap(fun(#calendar_specialist{calendar_id = CalId}) ->
case core_event:list_active_including_instances(CalId) of
{ok, Events} ->
[E#event.id || E <- Events,
is_binary(E#event.specialist_id),
E#event.specialist_id =/= <<>>,
E#event.specialist_id =:= UserId];
_ ->
[]
end
end, Specs).
collect_pending(EventIds, Role) ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
lists:flatmap(fun(EventId) ->
case core_booking:list_by_event(EventId) of
{ok, Bookings} ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
lists:filtermap(fun(B) ->
case B#booking.status of
pending ->
case event_started(Event, NowSec) of
true ->
_ = mark_expired(B#booking.id),
false;
false ->
{true, {B, Event, Role}}
end;
_ ->
false
end
end, Bookings);
_ ->
[]
end;
_ ->
[]
end
end, EventIds).
collect_studio(EventIds, Role) ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
lists:flatmap(fun(EventId) ->
case core_booking:list_by_event(EventId) of
{ok, Bookings} ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
lists:filtermap(fun(B) ->
case B#booking.status of
pending ->
case event_started(Event, NowSec) of
true ->
_ = mark_expired(B#booking.id),
false;
false ->
{true, {B, Event, Role}}
end;
confirmed ->
{true, {B, Event, Role}};
_ ->
false
end
end, Bookings);
_ ->
[]
end;
_ ->
[]
end
end, EventIds).
sort_requests(Items) ->
lists:sort(fun({A, _, _}, {B, _, _}) ->
A#booking.created_at >= B#booking.created_at
end, Items).
-spec delete_booking(BookingId :: binary(), UserId :: binary()) ->
ok | {error, not_found | access_denied}.
@@ -184,24 +352,25 @@ get_booking_admin(BookingId) ->
-spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}.
list_event_bookings(EventId) ->
core_booking:list_by_event(EventId).
{ok, Bookings} = core_booking:list_by_event(EventId),
{ok, [ensure_not_past_pending(B) || B <- Bookings]}.
-spec list_event_bookings(UserId :: binary(), EventId :: binary()) ->
{ok, [#booking{}]} | {error, not_found | access_denied}.
list_event_bookings(UserId, EventId) ->
case can_manage_event_bookings(UserId, EventId) of
true ->
core_booking:list_by_event(EventId);
list_event_bookings(EventId);
{error, Reason} ->
{error, Reason}
end.
-spec list_bookings_admin() -> {ok, [#booking{}]}.
list_bookings_admin() ->
{ok, core_booking:list_all()}.
{ok, [ensure_not_past_pending(B) || B <- core_booking:list_all()]}.
%%%-------------------------------------------------------------------
%%% @doc Авто-confirm/cancel по политике {timeout, N}.
%%% @doc Авто-confirm/cancel по политике {timeout, N}; past-pending expired.
%%% @end
%%%-------------------------------------------------------------------
-spec process_timeout_bookings() -> ok.
@@ -211,26 +380,125 @@ process_timeout_bookings() ->
lists:foreach(fun(B) -> maybe_timeout(B, NowSec) end, Pending),
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) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, #calendar{confirmation = {timeout, N}}} when is_integer(N), N > 0 ->
CreatedSec = calendar:datetime_to_gregorian_seconds(Created),
case NowSec - CreatedSec >= N of
true ->
case event_capacity_ok(EventId) of
case event_started(Event, NowSec) of
true ->
_ = mark_expired(Id);
false ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, #calendar{confirmation = {timeout, N}}} when is_integer(N), N > 0 ->
CreatedSec = calendar:datetime_to_gregorian_seconds(Created),
case NowSec - CreatedSec >= N of
true ->
Now = calendar:universal_time(),
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
case event_capacity_ok(EventId) of
true ->
Now = calendar:universal_time(),
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
false ->
case core_booking:update(Id, [{status, cancelled}]) of
{ok, _} ->
_ = logic_waitlist:maybe_promote(EventId);
_ ->
ok
end
end;
false ->
_ = core_booking:update(Id, [{status, cancelled}])
ok
end;
false ->
_ ->
ok
end;
_ ->
ok
end
end;
_ ->
ok
@@ -272,6 +540,66 @@ cancel_pending_for_calendar(CalendarId) ->
%%% INTERNAL
%%%===================================================================
%% @doc Pending after event start is no longer actionable expired.
-spec ensure_pending_actionable(#booking{}) ->
{ok, #booking{}} | {error, access_denied | expired | not_found}.
ensure_pending_actionable(#booking{status = pending} = Booking) ->
case core_event:get_by_id(Booking#booking.event_id) of
{ok, Event} ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
case event_started(Event, NowSec) of
true ->
_ = mark_expired(Booking#booking.id),
{error, expired};
false ->
{ok, Booking}
end;
{error, not_found} ->
{error, not_found}
end;
ensure_pending_actionable(_) ->
{error, access_denied}.
-spec ensure_not_past_pending(#booking{}) -> #booking{}.
ensure_not_past_pending(#booking{status = pending, id = Id, event_id = EventId} = B) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
case event_started(Event, NowSec) of
true ->
case mark_expired(Id) of
{ok, Updated} -> Updated;
_ -> B#booking{status = expired}
end;
false ->
B
end;
_ ->
B
end;
ensure_not_past_pending(B) ->
B.
-spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}.
mark_expired(BookingId) ->
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().
event_started(#event{start_time = Start}, NowSec) ->
StartSec = calendar:datetime_to_gregorian_seconds(Start),
NowSec >= StartSec.
-spec can_manage_event_bookings(UserId :: binary(), EventId :: binary()) ->
true | {error, not_found | access_denied}.
can_manage_event_bookings(UserId, EventId) ->
+264 -66
View File
@@ -1,11 +1,14 @@
-module(logic_calendar).
-include("records.hrl").
-export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/1,
update_calendar/3, delete_calendar/2, ensure_default_calendar/1]).
-export([create_calendar/3, create_calendar/4, create_calendar/5, get_calendar/2, list_calendars/1,
update_calendar/3, delete_calendar/2, ensure_default_calendar/1, backfill_single_personal/0]).
-export([can_access/2, can_edit/2, booking_open/1]).
-export([normalize_settings/1]).
-export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]).
-define(DEFAULT_PERSONAL_TITLE, <<"Default">>).
%% Создание календаря с политикой по умолчанию (manual)
create_calendar(UserId, Title, Description) ->
create_calendar(UserId, Title, Description, manual).
@@ -33,12 +36,28 @@ create_calendar(UserId, Title, Description, Confirmation, Type) ->
{error, subscription_required}
end;
personal ->
core_calendar:create(UserId, Title2, Desc2, Confirmation, Type)
case has_active_personal_calendar(UserId) of
true ->
{error, personal_exists};
false ->
core_calendar:create(UserId, Title2, Desc2, Confirmation, Type)
end
end,
case Result of
{ok, Cal} ->
logic_automoderation:apply_after_save(calendar, Cal#calendar.id, Action, Words),
core_calendar:get_by_id(Cal#calendar.id);
case core_calendar:get_by_id(Cal#calendar.id) of
{ok, Cal2} = Ok ->
case Cal2#calendar.type of
commercial ->
_ = logic_calendar_specialist:ensure_owner_specialist(Cal2);
_ ->
ok
end,
Ok;
Error ->
Error
end;
Error ->
Error
end
@@ -50,46 +69,100 @@ create_calendar(UserId, Title, Description, Confirmation, Type) ->
{error, user_not_found}
end.
%% @doc Создаёт дефолтный personal-календарь после активации пользователя.
%% Идемпотентно: если у владельца уже есть active personal ok.
%% @doc Единственный personal владельца: title Default; лишние commercial|delete.
%% Вызывается после verify; идемпотентно.
-spec ensure_default_calendar(UserId :: binary()) -> ok | {error, term()}.
ensure_default_calendar(UserId) ->
case has_active_personal_calendar(UserId) of
true ->
ok;
false ->
normalize_owner_personals(UserId).
%% @doc Backfill всех владельцев: 1 personal с title Default.
-spec backfill_single_personal() -> ok.
backfill_single_personal() ->
Owners = lists:usort([
C#calendar.owner_id
|| C <- core_calendar:list_all(),
C#calendar.status =:= active,
C#calendar.type =:= personal
]),
lists:foreach(fun(OwnerId) -> _ = normalize_owner_personals(OwnerId) end, Owners),
ok.
-spec normalize_owner_personals(UserId :: binary()) -> ok | {error, term()}.
normalize_owner_personals(UserId) ->
Personals = active_personals(UserId),
case Personals of
[] ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Title = default_calendar_title(User),
case create_calendar(UserId, Title, <<>>, manual, personal) of
{ok, #user{status = active}} ->
case create_calendar(UserId, ?DEFAULT_PERSONAL_TITLE, <<>>, manual, personal) of
{ok, _} -> ok;
Error -> Error
end;
{ok, _} ->
{error, user_inactive};
Error ->
Error
end
end;
[Keep | Rest] ->
_ = core_calendar:update(Keep#calendar.id, [{title, ?DEFAULT_PERSONAL_TITLE}]),
lists:foreach(fun convert_or_delete_extra_personal/1, Rest),
ok
end.
active_personals(UserId) ->
case core_calendar:list_by_owner(UserId) of
{ok, Calendars} ->
lists:sort(
fun(#calendar{created_at = A, id = IdA}, #calendar{created_at = B, id = IdB}) ->
{A, IdA} =< {B, IdB}
end,
[C || C <- Calendars, C#calendar.type =:= personal]
);
_ ->
[]
end.
has_active_personal_calendar(UserId) ->
case core_calendar:list_by_owner(UserId) of
{ok, Calendars} ->
lists:any(
fun(#calendar{type = personal}) -> true;
(_) -> false
end,
Calendars);
_ ->
false
end.
active_personals(UserId) =/= [].
default_calendar_title(#user{nickname = Nick}) when is_binary(Nick), byte_size(Nick) > 0 ->
Nick;
default_calendar_title(_) ->
<<"Мой календарь">>.
convert_or_delete_extra_personal(#calendar{id = Id} = Cal) ->
case calendar_has_content(Id) of
true ->
%% System backfill: bypass subscription gate.
case core_calendar:update(Id, [{type, commercial}]) of
{ok, Updated} ->
_ = logic_calendar_specialist:ensure_owner_specialist(Updated);
_ ->
ok
end,
ok;
false ->
_ = core_calendar:delete(Id),
ok
end,
Cal.
calendar_has_content(CalendarId) ->
Events =
try
case core_event:list_by_calendar(CalendarId) of
{ok, Es} -> Es;
_ -> []
end
catch
_:_ -> []
end,
Specs =
try
core_calendar_specialist:list_by_calendar(CalendarId)
catch
_:_ -> []
end,
Events =/= [] orelse Specs =/= [].
%% Получение календаря с проверкой доступа
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} ->
case can_access(UserId, Calendar) of
true -> {ok, Calendar};
@@ -99,9 +172,21 @@ get_calendar(UserId, CalendarId) ->
Error
end.
%% Список календарей пользователя
%% Список календарей пользователя: owned shared grants
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) ->
@@ -109,12 +194,16 @@ update_calendar(UserId, CalendarId, Updates) ->
{ok, Calendar} ->
case can_edit(UserId, Calendar) of
true ->
ValidUpdates = validate_updates(Updates),
case gate_type_change(UserId, Calendar, ValidUpdates) of
case validate_updates(Updates) of
{error, _} = Err ->
Err;
{ok, ValidUpdates2} ->
apply_calendar_update(CalendarId, Calendar, ValidUpdates2)
{ok, ValidUpdates} ->
case gate_type_change(UserId, Calendar, ValidUpdates) of
{error, _} = Err ->
Err;
{ok, ValidUpdates2} ->
apply_calendar_update(CalendarId, Calendar, ValidUpdates2)
end
end;
false ->
{error, access_denied}
@@ -150,18 +239,16 @@ apply_calendar_update(CalendarId, Calendar, ValidUpdates) ->
end
end.
gate_type_change(UserId, #calendar{type = personal}, Updates) ->
%% Смена type только owner. Единственный personal commercial запрещён.
gate_type_change(UserId, #calendar{owner_id = OwnerId} = Calendar, Updates) ->
case lists:keyfind(type, 1, Updates) of
{type, commercial} ->
case logic_subscription:can_create_commercial_calendar(UserId) of
true -> {ok, Updates};
false -> {error, subscription_required}
end;
{type, _} when UserId =/= OwnerId ->
{error, access_denied};
{type, commercial} when Calendar#calendar.type =:= personal ->
{error, default_calendar};
_ ->
{ok, Updates}
end;
gate_type_change(_UserId, _Calendar, Updates) ->
{ok, Updates}.
end.
maybe_cancel_pending_on_personal(#calendar{type = commercial},
#calendar{type = personal, id = Id}) ->
@@ -195,16 +282,15 @@ apply_text_results(Updates, [], []) -> Updates;
apply_text_results(Updates, [F | Fs], [T | Ts]) ->
apply_text_results(lists:keystore(F, 1, Updates, {F, T}), Fs, Ts).
%% Удаление календаря
%% Удаление календаря: только owner (share write/admin не удаляет)
delete_calendar(UserId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of
{ok, Calendar} ->
case can_edit(UserId, Calendar) of
true ->
core_calendar:delete(CalendarId);
false ->
{error, access_denied}
end;
{ok, #calendar{type = personal}} ->
{error, default_calendar};
{ok, #calendar{owner_id = UserId, status = active}} ->
core_calendar:delete(CalendarId);
{ok, #calendar{}} ->
{error, access_denied};
Error ->
Error
end.
@@ -212,36 +298,67 @@ delete_calendar(UserId, CalendarId) ->
%% Проверка прав доступа (просмотр)
can_access(UserId, #calendar{owner_id = UserId, status = active}) ->
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}) ->
true;
can_access(_UserId, _) ->
false.
%% Проверка прав редактирования
%% Проверка прав редактирования (owner или share write|admin)
can_edit(UserId, #calendar{owner_id = UserId, status = active}) ->
true;
can_edit(_UserId, #calendar{owner_id = _OwnerId}) ->
false;
can_edit(UserId, #calendar{id = CalId, status = active}) ->
case logic_calendar_share:get_rights(UserId, CalId) of
write -> true;
admin -> true;
_ -> false
end;
can_edit(_, _) ->
false.
%% Валидация полей обновления
%% Валидация полей обновления.
%% Known settings keys with invalid shape {error, {invalid_settings, Key}}.
%% Unknown settings keys (e.g. week_patterns) are preserved as-is.
validate_updates(Updates) ->
lists:filtermap(fun(U) ->
case normalize_update(U) of
false -> false;
Norm ->
case validate_update(Norm) of
true -> {true, Norm};
false -> false
end
end
end, Updates).
validate_updates(Updates, []).
validate_updates([], Acc) ->
{ok, lists:reverse(Acc)};
validate_updates([U | Rest], Acc) ->
case normalize_update(U) of
false ->
validate_updates(Rest, Acc);
{error, _} = Err ->
Err;
Norm ->
case validate_update(Norm) of
true -> validate_updates(Rest, [Norm | Acc]);
false -> validate_updates(Rest, Acc)
end
end.
normalize_update({type, <<"personal">>}) -> {type, personal};
normalize_update({type, <<"commercial">>}) -> {type, commercial};
normalize_update({type, personal}) -> {type, personal};
normalize_update({type, commercial}) -> {type, commercial};
normalize_update({<<"settings">>, Value}) when is_map(Value) ->
case normalize_settings(Value) of
{ok, Map} -> {settings, Map};
{error, _} = Err -> Err
end;
normalize_update({settings, Value}) when is_map(Value) ->
case normalize_settings(Value) of
{ok, Map} -> {settings, Map};
{error, _} = Err -> Err
end;
normalize_update({<<"title">>, Value}) when is_binary(Value) -> {title, Value};
normalize_update({<<"description">>, Value}) when is_binary(Value) -> {description, Value};
normalize_update({<<"tags">>, Value}) when is_list(Value) -> {tags, Value};
normalize_update({<<"short_name">>, Value}) when is_binary(Value) -> {short_name, Value};
normalize_update({<<"category">>, Value}) when is_binary(Value) -> {category, Value};
normalize_update({<<"color">>, Value}) when is_binary(Value) -> {color, Value};
normalize_update({<<"image_url">>, Value}) when is_binary(Value) -> {image_url, Value};
normalize_update(Other) -> Other.
validate_update({title, Value}) when is_binary(Value) -> true;
@@ -249,6 +366,12 @@ validate_update({description, Value}) when is_binary(Value) -> true;
validate_update({tags, Value}) when is_list(Value) -> true;
validate_update({type, personal}) -> true;
validate_update({type, commercial}) -> true;
validate_update({short_name, Value}) when is_binary(Value) -> true;
validate_update({category, Value}) when is_binary(Value) -> true;
validate_update({color, Value}) when is_binary(Value) -> true;
validate_update({image_url, Value}) when is_binary(Value) -> true;
%% Documented org-default keys validated in normalize_settings/1; unknown keys passthrough.
validate_update({settings, Value}) when is_map(Value) -> true;
validate_update({confirmation, Value}) ->
case Value of
auto -> true;
@@ -258,6 +381,81 @@ validate_update({confirmation, Value}) ->
end;
validate_update(_) -> false.
%%%-------------------------------------------------------------------
%%% @doc Validate documented calendar.settings keys for org defaults.
%%% Unknown keys are kept (forward-compat, e.g. week_patterns).
%%% @end
%%%-------------------------------------------------------------------
-spec normalize_settings(map()) -> {ok, map()} | {error, {invalid_settings, binary()}}.
normalize_settings(Map) when is_map(Map) ->
normalize_settings_keys(maps:keys(Map), Map).
normalize_settings_keys([], Map) ->
{ok, Map};
normalize_settings_keys([Key | Rest], Map) ->
case validate_settings_key(Key, maps:get(Key, Map)) of
ok ->
normalize_settings_keys(Rest, Map);
{error, _} = Err ->
Err
end.
validate_settings_key(<<"default_location">>, Val) ->
validate_default_location(Val);
validate_settings_key(<<"default_duration_minutes">>, Val) ->
validate_default_duration(Val);
validate_settings_key(<<"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) ->
ok.
validate_default_location(Loc) when is_map(Loc) ->
case maps:get(<<"address">>, Loc, undefined) of
Address when is_binary(Address), byte_size(Address) > 0 ->
HasLat = maps:is_key(<<"lat">>, Loc),
HasLon = maps:is_key(<<"lon">>, Loc),
case {HasLat, HasLon} of
{false, false} ->
ok;
{true, true} ->
Lat = maps:get(<<"lat">>, Loc),
Lon = maps:get(<<"lon">>, Loc),
case is_number(Lat) andalso is_number(Lon) of
true -> ok;
false -> {error, {invalid_settings, <<"default_location">>}}
end;
_ ->
{error, {invalid_settings, <<"default_location">>}}
end;
_ ->
{error, {invalid_settings, <<"default_location">>}}
end;
validate_default_location(_) ->
{error, {invalid_settings, <<"default_location">>}}.
validate_default_duration(N) when is_integer(N), N >= 1, N =< 1440 ->
ok;
validate_default_duration(_) ->
{error, {invalid_settings, <<"default_duration_minutes">>}}.
validate_default_recurrence(null) ->
ok;
validate_default_recurrence(Rec) when is_map(Rec) ->
Enabled = maps:get(<<"enabled">>, Rec, undefined),
Freq = maps:get(<<"freq">>, Rec, undefined),
Interval = maps:get(<<"interval">>, Rec, undefined),
ValidFreq = Freq =:= <<"DAILY">> orelse Freq =:= <<"WEEKLY">> orelse Freq =:= <<"MONTHLY">>,
case is_boolean(Enabled) andalso ValidFreq andalso is_integer(Interval) andalso Interval >= 1 of
true -> ok;
false -> {error, {invalid_settings, <<"default_recurrence">>}}
end;
validate_default_recurrence(_) ->
{error, {invalid_settings, <<"default_recurrence">>}}.
%% Административные функции
-spec admin_list_all() -> {ok, [map()]} | {error, term()}.
admin_list_all() ->
+5 -6
View File
@@ -15,7 +15,7 @@
-spec follow(UserId :: binary(), CalendarId :: binary()) ->
{ok, #calendar_follow{}} | {error, term()}.
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};
{ok, #calendar{owner_id = UserId}} ->
@@ -27,7 +27,7 @@ follow(UserId, CalendarId) ->
true ->
case Calendar#calendar.status of
active ->
core_calendar_follow:follow(CalendarId, UserId);
core_calendar_follow:follow(Calendar#calendar.id, UserId);
_ ->
{error, not_found}
end
@@ -40,14 +40,13 @@ follow(UserId, CalendarId) ->
%%%-------------------------------------------------------------------
-spec unfollow(UserId :: binary(), CalendarId :: binary()) -> ok | {error, term()}.
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} ->
%% всё равно снимаем локальный follow, если был
core_calendar_follow:unfollow(CalendarId, UserId);
{ok, #calendar{owner_id = UserId}} ->
{error, own_calendar};
{ok, _} ->
core_calendar_follow:unfollow(CalendarId, UserId)
{ok, Calendar} ->
core_calendar_follow:unfollow(Calendar#calendar.id, UserId)
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.
+34 -7
View File
@@ -5,17 +5,14 @@
-module(logic_calendar_specialist).
-include("records.hrl").
-export([list/2, add/5, update/4, remove/3, to_json/1]).
-export([list/2, add/5, update/4, remove/3, to_json/1, ensure_owner_specialist/1]).
-spec list(ActorId :: binary(), CalendarId :: binary()) ->
{ok, [#calendar_specialist{}]} | {error, not_found | access_denied}.
list(ActorId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of
case logic_calendar:get_calendar(ActorId, CalendarId) of
{ok, Cal} ->
case logic_calendar:can_access(ActorId, Cal) of
true -> {ok, core_calendar_specialist:list_by_calendar(CalendarId)};
false -> {error, access_denied}
end;
{ok, core_calendar_specialist:list_by_calendar(Cal#calendar.id)};
Error -> Error
end.
@@ -47,14 +44,34 @@ update(OwnerId, CalendarId, UserId, Updates) ->
end.
-spec remove(OwnerId :: binary(), CalendarId :: binary(), UserId :: binary()) ->
ok | {error, not_found | access_denied | not_commercial | term()}.
ok | {error, not_found | access_denied | not_commercial |
owner_specialist_protected | term()}.
remove(OwnerId, CalendarId, UserId) ->
case require_owner_commercial(OwnerId, CalendarId) of
{ok, #calendar{owner_id = CalOwnerId}} when UserId =:= CalOwnerId ->
{error, owner_specialist_protected};
{ok, _} ->
core_calendar_specialist:delete(CalendarId, UserId);
Error -> Error
end.
%% @doc Idempotent: commercial calendar always has a specialist row for owner.
%% Default status=active; name from nickname (else email). No-op if already present.
-spec ensure_owner_specialist(#calendar{}) -> ok | {error, term()}.
ensure_owner_specialist(#calendar{id = CalId, owner_id = OwnerId, type = commercial}) ->
case core_calendar_specialist:get_by_calendar_and_user(CalId, OwnerId) of
{ok, _} ->
ok;
{error, not_found} ->
case core_calendar_specialist:create(CalId, OwnerId, owner_display_name(OwnerId), []) of
{ok, _} -> ok;
{error, already_exists} -> ok;
{error, _} = Err -> Err
end
end;
ensure_owner_specialist(_) ->
ok.
-spec to_json(#calendar_specialist{}) -> map().
to_json(S) ->
#{
@@ -82,3 +99,13 @@ require_owner_commercial(OwnerId, CalendarId) ->
{error, access_denied};
Error -> Error
end.
owner_display_name(OwnerId) ->
case core_user:get_by_id(OwnerId) of
{ok, #user{nickname = Nick}} when is_binary(Nick), Nick =/= <<>> ->
Nick;
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
Email;
_ ->
<<"Owner">>
end.
+353 -4
View File
@@ -1,11 +1,360 @@
-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) ->
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) ->
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) ->
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, "?=">>.

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