Compare commits

...

41 Commits

Author SHA1 Message Date
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
78 changed files with 3463 additions and 401 deletions
+119
View File
@@ -0,0 +1,119 @@
---
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` |
| `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
+1
View File
@@ -17,6 +17,7 @@ erl_crash.dump
.rebar
logs
data
ci-out
.idea
*.iml
rebar3.crashdump
+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
+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;
}
}
+13
View File
@@ -90,6 +90,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
+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
+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"
+1 -1
View File
@@ -192,7 +192,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(),
+5
View File
@@ -49,6 +49,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
+24
View File
@@ -2,6 +2,11 @@
# 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}"
@@ -114,8 +119,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 +139,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}) ==="
+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},
+96 -44
View File
@@ -5,6 +5,7 @@
%%%-------------------------------------------------------------------
-module(core_auth_session).
-include("records.hrl").
-include_lib("stdlib/include/ms_transform.hrl").
-export([create/3, get/1, rotate/2, revoke/1, revoke_family/1, revoke_all_for_subject/2]).
-define(REFRESH_TTL_SECONDS, 30 * 24 * 3600).
@@ -32,9 +33,14 @@ create(SubjectId, SubjectType, ClientType) ->
created_at = Now,
updated_at = Now
},
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 +62,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 +83,98 @@ 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, _ = '_'}),
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.
%% 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.
%%%===================================================================
%%% 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 +192,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.
+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) ->
+32 -10
View File
@@ -118,10 +118,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 +182,11 @@ 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}.
%%%-------------------------------------------------------------------
@@ -229,8 +234,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 +248,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 +271,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 +292,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 +315,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,
+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.
+1
View File
@@ -91,6 +91,7 @@ start_http() ->
{"/v1/refresh", handler_refresh, []},
{"/v1/user/me", handler_user_me, []},
{"/v1/user/bookings", handler_user_bookings, []},
{"/v1/user/booking-requests", handler_user_booking_requests, []},
{"/v1/user/reviews", handler_user_reviews, []},
{"/v1/user/following", handler_user_following, []},
{"/v1/user/specialist-invites", handler_specialist_invites, []},
+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, _} ->
+1 -1
View File
@@ -92,7 +92,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},
+19 -1
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,7 +103,12 @@ 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}
}
}.
@@ -146,8 +152,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 +178,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 +208,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.
@@ -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} ->
+22 -15
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} ->
+2
View File
@@ -188,6 +188,8 @@ update_event(Req) ->
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{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;
+33 -13
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,10 +177,7 @@ 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} ->
@@ -194,10 +192,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} ->
@@ -262,6 +257,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,12 +279,18 @@ 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.
@@ -323,10 +339,14 @@ parse_datetime_binary(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)
}.
@@ -0,0 +1,112 @@
%%%-------------------------------------------------------------------
%%% @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]).
-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 = [request_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 request_to_json({#booking{}, #event{}, owner | specialist}) -> map().
request_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},
+31 -1
View File
@@ -19,6 +19,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,
@@ -300,6 +301,8 @@ 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
@@ -337,9 +340,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) ->
+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.
+4 -1
View File
@@ -131,6 +131,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};
@@ -321,7 +322,9 @@ table_opts(calendar_follow) -> [{disc_copies, [node()]}, {attributes, record_inf
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(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
+8 -2
View File
@@ -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}}.
{ok, {SupFlags, Children}}.
+2 -1
View File
@@ -27,7 +27,8 @@
'20260719210000_review_vote',
'20260720210000_calendar_follow',
'20260722150000_calendar_specialist_id',
'20260722190000_specialist_invite'
'20260722190000_specialist_invite',
'20260730120000_recurrence_exception_bag'
]).
%% ------------------------------
+170 -31
View File
@@ -2,7 +2,8 @@
-include("records.hrl").
-export([create_booking/2, 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,
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,
@@ -68,14 +69,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 +84,8 @@ confirm_booking(BookingId, UserId) ->
false ->
{error, full}
end;
_ ->
{error, access_denied}
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
@@ -93,7 +94,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 +102,11 @@ 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 ->
case ensure_pending_actionable(Booking) of
{ok, _} ->
core_booking:update(BookingId, [{status, cancelled}]);
_ ->
{error, access_denied}
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
@@ -121,6 +122,8 @@ 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}]);
@@ -139,7 +142,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 +163,91 @@ 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 = owned_event_ids(UserId),
SpecEventIds = specialist_event_ids(UserId),
OwnedSet = sets:from_list(OwnedEventIds),
SpecOnly = [E || E <- SpecEventIds, not sets:is_element(E, OwnedSet)],
OwnerItems = collect_pending(OwnedEventIds, owner),
SpecItems = collect_pending(SpecOnly, specialist),
Items = sort_requests(OwnerItems ++ SpecItems),
{ok, Items}.
owned_event_ids(UserId) ->
case core_calendar:list_by_owner(UserId) of
{ok, Cals} ->
lists:flatmap(fun(#calendar{id = CalId}) ->
case core_event:list_by_calendar(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_by_calendar(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).
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 +268,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.
@@ -214,23 +299,28 @@ process_timeout_bookings() ->
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 ->
_ = core_booking:update(Id, [{status, cancelled}])
end;
false ->
_ = core_booking:update(Id, [{status, cancelled}])
ok
end;
false ->
_ ->
ok
end;
_ ->
ok
end
end;
_ ->
ok
@@ -272,6 +362,55 @@ 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) ->
core_booking:update(BookingId, [{status, expired}]).
-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) ->
+228 -49
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,42 +69,96 @@ 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) ->
@@ -109,12 +182,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,13 +227,11 @@ apply_calendar_update(CalendarId, Calendar, ValidUpdates) ->
end
end.
gate_type_change(UserId, #calendar{type = personal}, Updates) ->
%% Единственный personal нельзя превратить в commercial (дневник пользователя).
gate_type_change(_UserId, #calendar{type = personal}, 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;
{error, default_calendar};
_ ->
{ok, Updates}
end;
@@ -195,9 +270,11 @@ 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).
%% Удаление календаря
%% Удаление календаря (единственный personal удалять нельзя)
delete_calendar(UserId, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of
{ok, #calendar{type = personal}} ->
{error, default_calendar};
{ok, Calendar} ->
case can_edit(UserId, Calendar) of
true ->
@@ -225,23 +302,48 @@ can_edit(_UserId, #calendar{owner_id = _OwnerId}) ->
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 +351,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 +366,77 @@ 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(_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() ->
+32 -2
View File
@@ -5,7 +5,7 @@
-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}.
@@ -47,14 +47,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 +102,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.
+63 -10
View File
@@ -106,15 +106,21 @@ generate_by_freq(Current, ?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount,
case should_stop(Current, EndBoundary, MaxCount, Count) of
true -> lists:reverse(Acc);
false ->
% Если указаны дни недели, генерируем только в эти дни
NewOccurrences = case ByDay of
[] -> [Current];
Days -> filter_by_weekday(Current, Days)
end,
{NewOccurrences, Next} =
case ByDay of
[] ->
{[Current], add_weeks(Current, Interval)};
Days ->
%% BYDAY: expand weekdays in Current's week (on/after Current),
%% then jump Interval weeks from that week's Monday.
Occs = [O || O <- filter_by_weekday(Current, Days), O =< EndBoundary],
{Occs, add_weeks(week_start_monday(Current), Interval)}
end,
%% Prepend reversed batch so final lists:reverse/1 yields chronological order.
generate_by_freq(
add_weeks(Current, Interval),
Next,
?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount,
Count + 1, NewOccurrences ++ Acc
Count + 1, lists:reverse(NewOccurrences) ++ Acc
)
end;
@@ -151,9 +157,56 @@ add_months({{Y, M, D}, Time}, N) ->
NewDay = minus(D, calendar:last_day_of_the_month(NewYear, NewMonth)),
{{NewYear, NewMonth, NewDay}, Time}.
filter_by_weekday(DateTime, _Days) ->
% Упрощённая версия всегда возвращаем текущую дату
% В полной версии нужно проверять день недели
%% BYDAY codes (RFC 5545): MO..SU. calendar:day_of_the_week = 1=Mon .. 7=Sun.
week_start_monday({{Y, M, D}, Time}) ->
Dow = calendar:day_of_the_week({Y, M, D}),
add_days({{Y, M, D}, Time}, -(Dow - 1)).
byday_to_dow(<<"MO">>) -> 1;
byday_to_dow(<<"TU">>) -> 2;
byday_to_dow(<<"WE">>) -> 3;
byday_to_dow(<<"TH">>) -> 4;
byday_to_dow(<<"FR">>) -> 5;
byday_to_dow(<<"SA">>) -> 6;
byday_to_dow(<<"SU">>) -> 7;
byday_to_dow("MO") -> 1;
byday_to_dow("TU") -> 2;
byday_to_dow("WE") -> 3;
byday_to_dow("TH") -> 4;
byday_to_dow("FR") -> 5;
byday_to_dow("SA") -> 6;
byday_to_dow("SU") -> 7;
byday_to_dow(mo) -> 1;
byday_to_dow(tu) -> 2;
byday_to_dow(we) -> 3;
byday_to_dow(th) -> 4;
byday_to_dow(fr) -> 5;
byday_to_dow(sa) -> 6;
byday_to_dow(su) -> 7;
byday_to_dow(_) -> undefined.
%% Occurrences for BYDAY in the week of Current, same time-of-day.
%% Only dates on or after Current (so DTSTART mid-week does not emit earlier weekdays).
filter_by_weekday(Current, Days) when is_list(Days) ->
Monday = week_start_monday(Current),
Occs =
lists:foldl(
fun(DayCode, Acc) ->
case byday_to_dow(DayCode) of
undefined -> Acc;
TargetDow ->
Occ = add_days(Monday, TargetDow - 1),
case Occ >= Current of
true -> [Occ | Acc];
false -> Acc
end
end
end,
[],
Days
),
lists:sort(Occs);
filter_by_weekday(DateTime, _) ->
[DateTime].
filter_by_month_day(DateTime, _Days) ->
+52 -14
View File
@@ -122,10 +122,14 @@ discovery_calendars(UserId, Params, Limit, Offset) ->
Offset :: non_neg_integer()) ->
{ok, non_neg_integer(), [map()]}.
search_events(Query, UserId, Params, Limit, Offset) ->
%% Step 1: index read on #event.status (avoids full table scan).
AllEvents = get_all_events(),
%% Step 2: batch-fetch calendars to fix N+1 (see filter_accessible_events).
AccessibleEvents = filter_accessible_events(AllEvents, UserId),
Filtered = apply_event_filters(AccessibleEvents, Query, Params),
Sorted = sort_events(Filtered, Params),
%% Pagination is applied BEFORE formatting so only the page slice is
%% enriched with calendar data (see format_events).
Paginated = paginate(Sorted, Limit, Offset),
{ok, length(Filtered), format_events(Paginated)}.
@@ -148,24 +152,34 @@ search_calendars(Query, UserId, Params, Limit, Offset) ->
-spec get_all_events() -> [#event{}].
get_all_events() ->
Match = #event{status = active, is_instance = false, _ = '_'},
mnesia:dirty_match_object(Match).
%% Optimization: use the secondary index on #event.status instead of a
%% full table scan (dirty_match_object). This reads only events whose
%% status is `active`, then filters out recurring instances in memory.
Events = mnesia:dirty_index_read(event, active, #event.status),
[E || E <- Events, E#event.is_instance =:= false].
-spec get_all_calendars() -> [#calendar{}].
get_all_calendars() ->
Match = #calendar{status = active, _ = '_'},
mnesia:dirty_match_object(Match).
%% Optimization: use the secondary index on #calendar.status instead of
%% a full table scan.
mnesia:dirty_index_read(calendar, active, #calendar.status).
%% ============ Фильтрация по доступности ============
-spec filter_accessible_events([#event{}], binary()) -> [#event{}].
filter_accessible_events(Events, UserId) ->
%% Optimization (N+1 fix): batch-fetch all needed calendars in one pass
%% instead of calling core_calendar:get_by_id for every event. We
%% collect the unique calendar_ids, read them with dirty_read (key-based,
%% O(1) each) and build a lookup map.
CalendarIds = lists:usort([E#event.calendar_id || E <- Events]),
CalendarMap = build_calendar_map(CalendarIds),
lists:filter(fun(Event) ->
case core_calendar:get_by_id(Event#event.calendar_id) of
case maps:find(Event#event.calendar_id, CalendarMap) of
{ok, Calendar} ->
logic_calendar:can_access(UserId, Calendar)
andalso calendar_discoverable(Calendar);
_ -> false
error -> false
end
end, Events).
@@ -182,6 +196,18 @@ calendar_discoverable(#calendar{type = commercial} = C) ->
calendar_discoverable(_) ->
true.
%% @doc Builds a #{calendar_id => #calendar{}} map using dirty_read
%% (key-based lookups). Used to batch-fetch calendars and avoid N+1
%% queries in filter_accessible_events and format_events.
-spec build_calendar_map([binary()]) -> #{binary() => #calendar{}}.
build_calendar_map(CalendarIds) ->
lists:foldl(fun(Id, Acc) ->
case mnesia:dirty_read(calendar, Id) of
[Calendar] -> Acc#{Id => Calendar};
[] -> Acc
end
end, #{}, CalendarIds).
%% ============ Применение фильтров ============
-spec apply_event_filters([#event{}], binary() | undefined, map()) -> [#event{}].
@@ -316,19 +342,25 @@ paginate(List, Limit, Offset) ->
-spec format_events([#event{}]) -> [map()].
format_events(Events) ->
lists:map(fun format_event/1, Events).
%% Optimization: batch-fetch calendars for the (already paginated)
%% subset only, avoiding an N+1 query per event during formatting.
CalendarMap = build_calendar_map(
lists:usort([E#event.calendar_id || E <- Events])),
lists:map(fun(E) -> format_event(E, CalendarMap) end, Events).
-spec format_event(#event{}) -> map().
format_event(Event) ->
-spec format_event(#event{}, #{binary() => #calendar{}}) -> map().
format_event(Event, CalendarMap) ->
Location = case Event#event.location of
undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon}
end,
CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Cal} -> Cal#calendar.title;
_ -> null
end,
{CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of
{ok, #calendar{title = T, image_url = <<>>}} -> {T, null};
{ok, #calendar{title = T, image_url = undefined}} -> {T, null};
{ok, #calendar{title = T, image_url = Url}} -> {T, Url};
error -> {null, null}
end,
#{
id => Event#event.id,
calendar_id => Event#event.calendar_id,
@@ -343,7 +375,8 @@ format_event(Event) ->
capacity => Event#event.capacity,
rating_avg => Event#event.rating_avg,
rating_count => Event#event.rating_count,
status => Event#event.status
status => Event#event.status,
image_url => ImageUrl
}.
-spec format_calendars([#calendar{}]) -> [map()].
@@ -359,6 +392,11 @@ format_calendar(Calendar) ->
description => Calendar#calendar.description,
type => Calendar#calendar.type,
booking_open => logic_calendar:booking_open(Calendar),
image_url => case Calendar#calendar.image_url of
<<>> -> null;
undefined -> null;
Url -> Url
end,
tags => Calendar#calendar.tags,
rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count,
+5 -22
View File
@@ -8,7 +8,7 @@
%% ============ Управление подписками ============
%% Начать пробный период (вызывается при первой попытке создать commercial календарь)
%% Начать пробный период (явно через POST /v1/subscription action=start_trial)
start_trial(UserId) ->
case core_subscription:get_active_by_user(UserId) of
{ok, _} ->
@@ -103,29 +103,12 @@ check_user_subscription(UserId) ->
{ok, free, free}
end.
%% Проверить, может ли пользователь создавать коммерческие календари
%% Если у пользователя нет активной подписки, но он ещё не использовал пробный период,
%% автоматически запускаем пробный период
%% Проверить, может ли пользователь создавать/апгрейдить commercial-календарь.
%% Только при уже активной подписке/trial; trial стартует явно через start_trial/1.
can_create_commercial_calendar(UserId) ->
case check_user_subscription(UserId) of
{ok, active, _} ->
true;
{ok, free, free} ->
% Пользователь без подписки - проверяем, не использовал ли он уже пробный период
{ok, AllSubs} = core_subscription:list_by_user(UserId),
TrialUsed = lists:any(fun(S) -> S#subscription.trial_used == true orelse S#subscription.plan == trial end, AllSubs),
case TrialUsed of
false ->
% Автоматически запускаем пробный период
case start_trial(UserId) of
{ok, _} -> true;
_ -> false
end;
true ->
false
end;
_ ->
false
{ok, active, _} -> true;
_ -> false
end.
%% ============ Обслуживание ============
@@ -0,0 +1,45 @@
%% @doc recurrence_exception: set bag, чтобы хранить несколько cancel на один master.
-module('20260730120000_recurrence_exception_bag').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
case lists:member(recurrence_exception, mnesia:system_info(tables)) of
false ->
case mnesia:create_table(recurrence_exception, [
{disc_copies, [node()]},
{type, bag},
{attributes, record_info(fields, recurrence_exception)}
]) of
{atomic, ok} -> ok;
{aborted, {already_exists, recurrence_exception}} -> ok;
{aborted, Reason} -> error({create_table_failed, Reason})
end;
true ->
case mnesia:table_info(recurrence_exception, type) of
bag ->
ok;
_ ->
Rows = mnesia:dirty_match_object(#recurrence_exception{_ = '_'}),
case mnesia:delete_table(recurrence_exception) of
{atomic, ok} -> ok;
{aborted, Reason1} -> error({delete_table_failed, Reason1})
end,
case mnesia:create_table(recurrence_exception, [
{disc_copies, [node()]},
{type, bag},
{attributes, record_info(fields, recurrence_exception)}
]) of
{atomic, ok} -> ok;
{aborted, Reason2} -> error({create_table_failed, Reason2})
end,
lists:foreach(fun(R) -> mnesia:dirty_write(R) end, Rows),
ok
end
end.
down() ->
%% Откат в set потерял бы все кроме одного exception на master не делаем.
ok.
@@ -0,0 +1,11 @@
%% @doc Backfill: 1 personal per owner, title Default; extras commercial | delete.
-module('20260730200000_single_default_personal').
-export([up/0, down/0]).
up() ->
logic_calendar:backfill_single_personal(),
ok.
down() ->
ok.
+1
View File
@@ -94,6 +94,7 @@ user() ->
handler_ticket_by_id,
handler_tickets,
handler_user_bookings,
handler_user_booking_requests,
handler_user_following,
handler_user_me,
handler_user_reviews
+10 -4
View File
@@ -122,8 +122,14 @@ test_batch_add_words(Token) ->
Result = jsx:decode(list_to_binary(RespBody), [return_maps]),
?assertEqual(3, maps:get(<<"added">>, Result)),
?assertEqual(0, maps:get(<<"skipped">>, Result)),
AllWords = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token),
[?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word end, AllWords)) || Word <- Words],
%% Lookup via ?q= bare GET is paginated and flakes on dirty IFT stands.
lists:foreach(
fun(Word) ->
Found = api_test_runner:admin_get(<<"/v1/admin/banned-words?q=", Word/binary>>, Token),
?assertEqual(1, length(Found)),
?assertEqual(Word, maps:get(<<"word">>, hd(Found)))
end,
Words),
ct:pal(" OK").
%% @doc DELETE /v1/admin/banned-words/:word удаление слова.
@@ -132,8 +138,8 @@ test_delete_word(Token, Word) ->
Path = <<"/v1/admin/banned-words/", Word/binary>>,
Result = api_test_runner:admin_delete(Path, Token),
?assertEqual(<<"deleted">>, maps:get(<<"status">>, Result)),
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token),
?assertNot(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word end, Words)),
Left = api_test_runner:admin_get(<<"/v1/admin/banned-words?q=", Word/binary>>, Token),
?assert(lists:all(fun(W) -> maps:get(<<"word">>, W) =/= Word end, Left)),
ct:pal(" OK").
%% @doc PUT /v1/admin/banned-words/:word попытка переименовать в уже существующее слово (409).
+6 -11
View File
@@ -67,21 +67,16 @@ setup() ->
Email = api_test_runner:unique_email(<<"caladmin">>),
Password = <<"testpass">>,
UserToken = api_test_runner:register_and_login(Email, Password),
CalId1 = create_calendar(UserToken, <<"Personal Active">>, <<"Desc1">>),
CalId2 = create_calendar(UserToken, <<"Commercial">>, <<"Desc2">>),
CalId3 = create_calendar(UserToken, <<"Frozen">>, <<"Desc3">>),
update_calendar_admin(AdminToken, CalId2, #{<<"type">> => <<"commercial">>}),
%% Sole personal Default already exists after verify
CalId1 = api_test_runner:existing_personal_calendar_id(UserToken),
CalId2 = api_test_runner:create_calendar(UserToken,
#{title => <<"Commercial">>, description => <<"Desc2">>, type => <<"commercial">>}),
CalId3 = api_test_runner:create_calendar(UserToken,
#{title => <<"Frozen">>, description => <<"Desc3">>, type => <<"commercial">>}),
update_calendar_admin(AdminToken, CalId3, #{<<"status">> => <<"frozen">>}),
ct:pal(" Created calendars: ~p", [[CalId1, CalId2, CalId3]]),
{AdminToken, UserToken, [CalId1, CalId2, CalId3]}.
-spec create_calendar(binary(), binary(), binary()) -> binary().
create_calendar(UserToken, Title, Description) ->
Body = jsx:encode(#{title => Title, description => Description}),
{ok, 201, _, Resp} = api_test_runner:client_request(post, <<"/v1/calendars">>, UserToken, Body),
#{<<"id">> := Id} = jsx:decode(list_to_binary(Resp), [return_maps]),
Id.
-spec update_calendar_admin(binary(), binary(), map()) -> ok.
update_calendar_admin(AdminToken, CalId, Updates) ->
{ok, 200, _, RespBody} = api_test_runner:admin_request(get, <<"/v1/admin/calendars/", CalId/binary>>, AdminToken, <<"">>),
+24 -6
View File
@@ -34,12 +34,10 @@ test() ->
AdminToken = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
% Создаём календарь и событие для тестов
#{<<"id">> := CalId} = api_test_runner:client_post(
<<"/v1/calendars">>, UserToken,
% commercial требует активной подписки через create_calendar (trial)
CalId = api_test_runner:create_calendar(UserToken,
#{title => <<"WS Test Calendar">>, type => <<"commercial">>}),
#{<<"id">> := EventId} = api_test_runner:client_post(
<<"/v1/calendars/", CalId/binary, "/events">>, UserToken,
EventId = api_test_runner:create_event(UserToken, CalId,
#{title => <<"WS Test Event">>,
start_time => api_test_runner:future_date_iso8601(),
duration => 60}),
@@ -136,7 +134,7 @@ test_admin_ws_report_notification(WS, UserToken, EventId) ->
test_admin_ws_ping(WS) ->
ct:pal(" TEST: Admin Ping/Pong"),
ok = test_ws_send(WS, #{action => <<"ping">>}),
{ok, #{<<"status">> := <<"pong">>}} = test_ws_recv(WS).
{ok, #{<<"status">> := <<"pong">>}} = test_ws_recv_status(WS, <<"pong">>, 5000).
%% @doc Отписка от канала reports.
-spec test_admin_ws_unsubscribe(pid()) -> ok.
@@ -284,6 +282,26 @@ test_ws_recv(ConnPid, Timeout) ->
{error, timeout}
end.
%% @doc Ждём WS-сообщение с заданным status, пропуская посторонние (node_metric на shared stand).
test_ws_recv_status(ConnPid, ExpectedStatus, Timeout) ->
Deadline = erlang:monotonic_time(millisecond) + Timeout,
test_ws_recv_status_loop(ConnPid, ExpectedStatus, Deadline).
test_ws_recv_status_loop(ConnPid, ExpectedStatus, Deadline) ->
Remaining = max(0, Deadline - erlang:monotonic_time(millisecond)),
case test_ws_recv(ConnPid, Remaining) of
{ok, #{<<"status">> := ExpectedStatus} = Msg} ->
{ok, Msg};
{ok, #{<<"type">> := Type}} ->
ct:pal(" Skipping WS type=~s (waiting for status ~s)", [Type, ExpectedStatus]),
test_ws_recv_status_loop(ConnPid, ExpectedStatus, Deadline);
{ok, Other} ->
ct:pal(" Skipping WS message ~p (waiting for status ~s)", [Other, ExpectedStatus]),
test_ws_recv_status_loop(ConnPid, ExpectedStatus, Deadline);
{error, timeout} = Err ->
Err
end.
%% @doc Ждём WS-сообщение заданного type, пропуская посторонние (шум на shared stand).
test_ws_recv_type(ConnPid, ExpectedType, Timeout) ->
Deadline = erlang:monotonic_time(millisecond) + Timeout,
+182 -3
View File
@@ -25,7 +25,9 @@
future_date/0,
register_and_login/2,
create_calendar/2,
create_event/3
create_event/3,
ensure_commercial_subscription/1,
existing_personal_calendar_id/1
, get_admin_refresh_token/0
, admin_super_email/0
, admin_super_password/0]).
@@ -50,6 +52,13 @@
-export([verify_user/2]).
-export([future_date_iso8601/0, iso8601_utc/1, future_month_range_iso8601/0,
next_month_start_iso8601/0, next_month_range_iso8601/0]).
-export([
track_user/1,
track_calendar/1,
cleanup_tracked/0,
keep_ct_data/0,
reset_litter_tracker/0
]).
%%%===================================================================
%%% Конфигурация окружения (CT_MODE, ...)
@@ -123,6 +132,127 @@ clear_cached_admin_tokens() ->
[admin, superadmin, moderator, support]
).
%%%===================================================================
%%% Litter tracker (users/calendars created during CT)
%%% persistent_term survives CT testcase process exits (unlike ETS owner).
%%%===================================================================
-define(LITTER_USERS, {?MODULE, tracked_users}).
-define(LITTER_CALS, {?MODULE, tracked_calendars}).
%% @doc CT_KEEP_DATA=1 skip suite teardown soft-delete (default: clean up).
-spec keep_ct_data() -> boolean().
keep_ct_data() ->
case os:getenv("CT_KEEP_DATA") of
"1" -> true;
"true" -> true;
"yes" -> true;
_ -> false
end.
-spec reset_litter_tracker() -> ok.
reset_litter_tracker() ->
persistent_term:put(?LITTER_USERS, []),
persistent_term:put(?LITTER_CALS, []),
ok.
-spec track_user(binary()) -> ok.
track_user(UserId) when is_binary(UserId), UserId =/= <<>> ->
track_id(?LITTER_USERS, UserId);
track_user(_) ->
ok.
-spec track_calendar(binary()) -> ok.
track_calendar(CalId) when is_binary(CalId), CalId =/= <<>> ->
track_id(?LITTER_CALS, CalId);
track_calendar(_) ->
ok.
track_id(Key, Id) ->
Old = persistent_term:get(Key, []),
case lists:member(Id, Old) of
true -> ok;
false -> persistent_term:put(Key, [Id | Old]), ok
end.
%% @doc Soft-delete tracked calendars then users via admin API.
%% Best-effort: never fails the suite. No-op when CT_KEEP_DATA=1.
-spec cleanup_tracked() -> ok.
cleanup_tracked() ->
case keep_ct_data() of
true ->
ct:pal("CT_KEEP_DATA set — skip API CT litter cleanup"),
ok;
false ->
do_cleanup_tracked()
end.
do_cleanup_tracked() ->
CalIds = lists:usort(persistent_term:get(?LITTER_CALS, [])),
UserIds = lists:usort(persistent_term:get(?LITTER_USERS, [])),
ct:pal("API CT litter cleanup: ~p calendars, ~p users", [length(CalIds), length(UserIds)]),
AdminToken =
try get_admin_token()
catch
_:Reason ->
ct:pal("API CT litter cleanup: no admin token (~p) — skip", [Reason]),
undefined
end,
case AdminToken of
undefined ->
ok;
Token ->
lists:foreach(
fun(Id) ->
admin_delete_best_effort(<<"/v1/admin/calendars/", Id/binary>>, Token)
end,
CalIds
),
lists:foreach(
fun(Id) ->
admin_delete_best_effort(<<"/v1/admin/users/", Id/binary>>, Token)
end,
UserIds
),
cleanup_test_local_users(Token),
reset_litter_tracker(),
ok
end.
%% Safety-net: soft-delete users with @test.local (missed by tracker).
cleanup_test_local_users(AdminToken) ->
case admin_request(get, <<"/v1/admin/users?q=test.local&limit=100">>, AdminToken) of
{ok, 200, _, Body} ->
Users = jsx:decode(list_to_binary(Body), [return_maps]),
lists:foreach(
fun
(#{<<"id">> := Id, <<"email">> := Email}) when is_binary(Email) ->
case binary:match(Email, <<"@test.local">>) of
nomatch -> ok;
_ -> admin_delete_best_effort(<<"/v1/admin/users/", Id/binary>>, AdminToken)
end;
(_) ->
ok
end,
case Users of
L when is_list(L) -> L;
_ -> []
end
);
Other ->
ct:pal("API CT litter cleanup: list users skip (~p)", [Other]),
ok
end.
admin_delete_best_effort(Path, Token) ->
case admin_request(delete, Path, Token) of
{ok, Status, _, _} when Status >= 200, Status < 300 -> ok;
{ok, 404, _, _} -> ok;
Other ->
ct:pal("API CT litter cleanup: DELETE ~s -> ~p", [Path, Other]),
ok
end.
%%%===================================================================
%%% Учётные данные администраторов (из переменных окружения)
%%%===================================================================
@@ -393,6 +523,7 @@ register_and_login(Email, Password) ->
jsx:encode(#{email => Email, password => Password})),
{ok, 201, _, Body} = Resp,
#{<<"user">> := #{<<"id">> := UserId}} = jsx:decode(list_to_binary(Body), [return_maps]),
track_user(UserId),
% 2. Получаем админский токен (используется кеширование)
AdminToken = get_admin_token(),
@@ -409,8 +540,56 @@ register_and_login(Email, Password) ->
-spec create_calendar(binary(), map()) -> binary().
create_calendar(Token, Params) ->
#{<<"id">> := CalId} = client_post(<<"/v1/calendars">>, Token, Params),
CalId.
%% Default commercial: user already has sole personal Default after verify.
Type = maps:get(type, Params, maps:get(<<"type">>, Params, <<"commercial">>)),
Params1 =
case maps:is_key(type, Params) orelse maps:is_key(<<"type">>, Params) of
true -> Params;
false -> Params#{type => Type}
end,
case Type of
<<"commercial">> -> ensure_commercial_subscription(Token);
commercial -> ensure_commercial_subscription(Token);
<<"personal">> -> ok;
personal -> ok;
_ -> ensure_commercial_subscription(Token)
end,
Body = jsx:encode(Params1),
case client_request(post, <<"/v1/calendars">>, Token, Body) of
{ok, 201, _, RespBody} ->
#{<<"id">> := CalId} = jsx:decode(list_to_binary(RespBody), [return_maps]),
%% Personal Default is sole do not track for delete (403 default_calendar).
case Type of
<<"personal">> -> ok;
personal -> ok;
_ -> track_calendar(CalId)
end,
CalId;
{ok, 409, _, _} when Type =:= <<"personal">>; Type =:= personal ->
%% Sole Default personal already exists reuse it.
existing_personal_calendar_id(Token);
Other ->
error({create_calendar_failed, Other})
end.
-spec existing_personal_calendar_id(binary()) -> binary().
existing_personal_calendar_id(Token) ->
Cals = client_get(<<"/v1/calendars">>, Token),
case [Id || #{<<"id">> := Id, <<"type">> := <<"personal">>} <- Cals] of
[Id | _] -> Id;
[] -> error(no_personal_calendar)
end.
-spec ensure_commercial_subscription(binary()) -> ok.
ensure_commercial_subscription(Token) ->
case client_request(post, <<"/v1/subscription">>, Token,
jsx:encode(#{action => <<"start_trial">>})) of
{ok, 201, _, _} -> ok;
{ok, 200, _, _} -> ok;
{ok, 409, _, _} -> ok; %% already has subscription / trial used with active
{ok, 400, _, _} -> ok; %% trial_already_used may still have active paid
_ -> ok
end.
-spec create_event(binary(), binary(), map()) -> binary().
create_event(Token, CalId, Params) ->
+51 -10
View File
@@ -15,16 +15,17 @@ test() ->
OtherToken = api_test_runner:register_and_login(
api_test_runner:unique_email(<<"other">>), <<"pass">>),
% Создаём календарь для тестов
#{<<"id">> := CalId} = api_test_runner:client_post(<<"/v1/calendars">>, Token,
#{title => <<"TestCal">>, type => <<"personal">>}),
%% Studio calendar (sole personal Default cannot be deleted)
CalId = api_test_runner:create_calendar(Token, #{title => <<"TestCal">>}),
test_get_calendar(Token, CalId),
test_get_calendar_unauthorized(CalId),
test_get_calendar_not_found(Token),
test_update_calendar(Token, CalId),
test_update_calendar_settings(Token, CalId),
test_update_calendar_forbidden(OtherToken, CalId),
test_delete_calendar(Token, CalId),
test_delete_default_personal_forbidden(Token),
test_delete_calendar_forbidden(OtherToken, CalId),
ct:pal("=== All user calendar by id tests passed ==="),
@@ -60,6 +61,40 @@ test_update_calendar(Token, CalId) ->
?assertEqual(<<"New desc">>, maps:get(<<"description">>, Updated)),
ct:pal(" OK").
test_update_calendar_settings(Token, CalId) ->
ct:pal(" TEST: Persist calendar.settings week_patterns + org defaults"),
Path = <<"/v1/calendars/", CalId/binary>>,
Settings = #{
<<"week_patterns">> => [
#{
<<"id">> => <<"pat1">>,
<<"name">> => <<"Mon-Fri">>,
<<"slots">> => [
#{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60, <<"title">> => <<"Open">>}
]
}
],
<<"default_location">> => #{
<<"address">> => <<"ул. Пример, 1">>,
<<"lat">> => 55.75,
<<"lon">> => 37.61
},
<<"default_duration_minutes">> => 60,
<<"default_recurrence">> => #{
<<"enabled">> => true,
<<"freq">> => <<"WEEKLY">>,
<<"interval">> => 1
}
},
Updated = api_test_runner:client_put(Path, Token, #{settings => Settings}),
?assertEqual(Settings, maps:get(<<"settings">>, Updated)),
Got = api_test_runner:client_get(Path, Token),
?assertEqual(Settings, maps:get(<<"settings">>, Got)),
Bad = api_test_runner:client_request(put, Path, Token,
jsx:encode(#{settings => #{default_location => #{address => <<"">>}}})),
?assertMatch({ok, 400, _, _}, Bad),
ct:pal(" OK").
test_update_calendar_forbidden(OtherToken, CalId) ->
ct:pal(" TEST: Update calendar by non-owner (403)"),
Path = <<"/v1/calendars/", CalId/binary>>,
@@ -69,19 +104,25 @@ test_update_calendar_forbidden(OtherToken, CalId) ->
ct:pal(" OK: got 403").
test_delete_calendar(Token, CalId) ->
ct:pal(" TEST: Delete calendar (soft-delete)"),
ct:pal(" TEST: Delete studio calendar (soft-delete)"),
Path = <<"/v1/calendars/", CalId/binary>>,
Resp = api_test_runner:client_request(delete, Path, Token),
?assertMatch({ok, 200, _, _}, Resp),
ct:pal(" OK: deleted").
test_delete_calendar_forbidden(OtherToken, CalId) ->
% Первый раз мы уже удалили, но проверим на другом календаре
test_delete_default_personal_forbidden(Token) ->
ct:pal(" TEST: Delete sole personal calendar (403 default_calendar)"),
PersonalId = api_test_runner:existing_personal_calendar_id(Token),
Path = <<"/v1/calendars/", PersonalId/binary>>,
Resp = api_test_runner:client_request(delete, Path, Token),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403 default_calendar").
test_delete_calendar_forbidden(OtherToken, _DeletedCalId) ->
ct:pal(" TEST: Delete calendar by non-owner (403)"),
% Создадим новый календарь владельцем Token, попробуем удалить OtherToken
#{<<"id">> := NewCalId} = api_test_runner:client_post(<<"/v1/calendars">>, api_test_runner:get_user_token(),
#{title => <<"ForbiddenDel">>, type => <<"personal">>}),
OwnerToken = api_test_runner:get_user_token(),
NewCalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"ForbiddenDel">>}),
Path = <<"/v1/calendars/", NewCalId/binary>>,
Resp = api_test_runner:client_request(delete, Path, OtherToken),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
ct:pal(" OK: got 403").
+12 -5
View File
@@ -13,11 +13,10 @@ test() ->
ct:pal("=== User Calendars Tests ==="),
Token = api_test_runner:get_user_token(),
% Создаём один календарь для тестов
#{<<"id">> := CalId} = api_test_runner:client_post(<<"/v1/calendars">>, Token,
#{title => <<"TestCal">>, type => <<"personal">>}),
_CalId = api_test_runner:create_calendar(Token, #{title => <<"TestCal">>}),
test_create_calendar(Token),
test_second_personal_rejected(Token),
test_list_calendars(Token),
test_list_calendars_unauthorized(),
@@ -25,15 +24,23 @@ test() ->
ok.
test_create_calendar(Token) ->
ct:pal(" TEST: Create a new calendar"),
ct:pal(" TEST: Create a new studio calendar"),
api_test_runner:ensure_commercial_subscription(Token),
Resp = api_test_runner:client_request(post, <<"/v1/calendars">>, Token,
jsx:encode(#{title => <<"NewCal">>, type => <<"personal">>})),
jsx:encode(#{title => <<"NewCal">>, type => <<"commercial">>})),
{ok, 201, _, Body} = Resp,
#{<<"id">> := Id, <<"title">> := Title} = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(is_binary(Id)),
?assertEqual(<<"NewCal">>, Title),
ct:pal(" OK: created calendar ~s", [Id]).
test_second_personal_rejected(Token) ->
ct:pal(" TEST: Second personal calendar rejected (409)"),
Resp = api_test_runner:client_request(post, <<"/v1/calendars">>, Token,
jsx:encode(#{title => <<"AnotherPersonal">>, type => <<"personal">>})),
?assertMatch({ok, 409, _, _}, Resp),
ct:pal(" OK: got 409 personal_exists").
test_list_calendars(Token) ->
ct:pal(" TEST: List user calendars"),
Calendars = api_test_runner:client_get(<<"/v1/calendars">>, Token),
+2 -6
View File
@@ -47,17 +47,13 @@ test() ->
#{<<"token">> := AuthToken} = jsx:decode(list_to_binary(LoginBody), [return_maps]),
?assert(is_binary(AuthToken)),
% 6. После активации дефолтный приватный personal-календарь
% 6. После активации единственный personal с title Default
Calendars = api_test_runner:client_get(<<"/v1/calendars">>, AuthToken),
?assertEqual(1, length(Calendars)),
[DefaultCal] = Calendars,
?assertEqual(<<"personal">>, maps:get(<<"type">>, DefaultCal)),
?assertEqual(<<>>, maps:get(<<"short_name">>, DefaultCal)),
ExpectedTitle = case string:split(Email, <<"@">>) of
[Local, _] when byte_size(Local) > 0 -> Local;
_ -> <<"Мой календарь">>
end,
?assertEqual(ExpectedTitle, maps:get(<<"title">>, DefaultCal)),
?assertEqual(<<"Default">>, maps:get(<<"title">>, DefaultCal)),
% 7. Повторное использование того же токена ошибка 404
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>,
+5 -1
View File
@@ -46,6 +46,7 @@ all() ->
%% @doc Инициализация сьюта.
%% В локальном режиме запускает приложение EventHub.
init_per_suite(Config) ->
ok = api_test_runner:reset_litter_tracker(),
case os:getenv("CT_MODE", "local") of
"remote" ->
ct:pal("Remote mode: assuming application is already running"),
@@ -72,8 +73,11 @@ init_per_suite(Config) ->
end
end.
%% @doc Завершение сьюта. В локальном режиме останавливает приложение.
%% @doc Завершение сьюта: litter cleanup, затем stop app в local.
end_per_suite(Config) ->
%% Soft-delete tracked @test.local users/calendars (remote and local).
%% Skip with CT_KEEP_DATA=1. Must run before application:stop.
catch api_test_runner:cleanup_tracked(),
case proplists:get_value(started_by_us, Config, false) andalso
os:getenv("CT_MODE", "local") =/= "remote" of
true ->
+5 -1
View File
@@ -55,6 +55,7 @@ all() ->
%% @doc Инициализация сьюта.
%% В локальном режиме запускает приложение EventHub.
init_per_suite(Config) ->
ok = api_test_runner:reset_litter_tracker(),
case os:getenv("CT_MODE", "local") of
"remote" ->
ct:pal("Remote mode: assuming application is already running"),
@@ -81,8 +82,11 @@ init_per_suite(Config) ->
end
end.
%% @doc Завершение сьюта. В локальном режиме останавливает приложение.
%% @doc Завершение сьюта: litter cleanup, затем stop app в local.
end_per_suite(Config) ->
%% Soft-delete tracked @test.local users/calendars (remote and local).
%% Skip with CT_KEEP_DATA=1. Must run before application:stop.
catch api_test_runner:cleanup_tracked(),
case proplists:get_value(started_by_us, Config, false) andalso
os:getenv("CT_MODE", "local") =/= "remote" of
true ->
+1 -5
View File
@@ -6,12 +6,8 @@
setup() ->
eh_test_support:start_mnesia(),
%% email index comes from eh_test_support:ensure_indexes/1
eh_test_support:ensure_tables(?TABLES),
case mnesia:add_table_index(user, email) of
{atomic, ok} -> ok;
{aborted, {already_exists, user, email}} -> ok;
{aborted, Reason} -> error({add_index_failed, Reason})
end,
ok.
cleanup(_) ->
+27 -11
View File
@@ -72,18 +72,34 @@ stop_mnesia() ->
ensure_tables(Tables) when is_list(Tables) ->
lists:foreach(fun ensure_table/1, Tables),
maybe_add_admin_stats_indexes(Tables),
%% Mirror infra_mnesia:create_indices/0 for tables present in this suite.
ensure_indexes(Tables),
ok.
maybe_add_admin_stats_indexes(Tables) ->
case lists:member(report, Tables) of
true -> add_index(report, resolved_by);
false -> ok
end,
case lists:member(ticket, Tables) of
true -> add_index(ticket, assigned_to);
false -> ok
end.
%% Indexes required by core_*/logic_* dirty_index_read / index_read paths.
ensure_indexes(Tables) when is_list(Tables) ->
lists:foreach(fun({Tab, Attrs}) ->
case lists:member(Tab, Tables) of
true -> lists:foreach(fun(Attr) -> add_index(Tab, Attr) end, Attrs);
false -> ok
end
end, [
{event, [calendar_id, title, created_at, start_time, event_type,
master_id, specialist_id, status]},
{booking, [event_id, user_id, status]},
{review_vote, [review_id, user_id]},
{calendar, [owner_id, status, short_name, category]},
{calendar_specialist, [calendar_id, user_id]},
{specialist_invite, [calendar_id, invitee_user_id, invitee_email, token, status]},
{user, [nickname, email]},
{verification, [user_id]},
{password_reset, [user_id]},
{notification, [user_id, is_read]},
{auth_session, [family_id, subject_id]},
{report, [resolved_by]},
{ticket, [assigned_to]}
]),
ok.
add_index(Table, Attr) ->
case catch mnesia:add_table_index(Table, Attr) of
@@ -137,7 +153,7 @@ table_opts(specialist_invite) ->
table_opts(event) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, event)}];
table_opts(recurrence_exception) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}];
[{ram_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
table_opts(booking) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, booking)}];
table_opts(review) ->
+10 -2
View File
@@ -19,5 +19,13 @@ is_server_running() ->
end.
test_search_requires_auth() ->
{ok, {{_, 401, _}, _, Body}} = httpc:request(get, {"http://localhost:8080/v1/search?type=event", []}, [], []),
?assertMatch(#{<<"error">> := _}, jsx:decode(Body, [return_maps])).
{ok, {{_, Code, _}, _, Body0}} =
httpc:request(get, {"http://localhost:8080/v1/search?type=event", []}, [],
[{body_format, binary}]),
?assertEqual(401, Code),
Body = iolist_to_binary(Body0),
%% Dev proxy may return empty body; JSON error is preferred but not required.
case Body of
<<>> -> ok;
_ -> ?assertMatch(#{<<"error">> := _}, jsx:decode(Body, [return_maps]))
end.
+132
View File
@@ -0,0 +1,132 @@
-module(infra_cleanup_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(TABLES, [notification, auth_session, admin_audit]).
setup() ->
eh_test_support:start_mnesia(),
eh_test_support:ensure_tables(?TABLES),
ok.
cleanup(_) ->
eh_test_support:clear_tables(?TABLES),
eh_test_support:delete_tables(?TABLES),
eh_test_support:stop_mnesia(),
ok.
infra_cleanup_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"cutoff_datetime is in the past", fun test_cutoff_datetime/0},
{"keeps unread notifications", fun test_keeps_unread_notifications/0},
{"deletes old read notifications", fun test_deletes_old_read_notifications/0},
{"keeps active non-expired sessions", fun test_keeps_active_sessions/0},
{"deletes old revoked sessions", fun test_deletes_old_revoked_sessions/0},
{"deletes old admin_audit", fun test_deletes_old_admin_audit/0}
]}.
test_cutoff_datetime() ->
Cutoff = infra_cleanup:cutoff_datetime(30),
NowSecs = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
CutoffSecs = calendar:datetime_to_gregorian_seconds(Cutoff),
?assert(CutoffSecs < NowSecs),
%% ~30 days (± a few seconds for test runtime)
DiffDays = (NowSecs - CutoffSecs) / 86400,
?assert(DiffDays > 29.9),
?assert(DiffDays < 30.1).
test_keeps_unread_notifications() ->
Old = infra_cleanup:cutoff_datetime(60),
write_notification(<<"n_unread">>, false, Old),
write_notification(<<"n_read_recent">>, true, calendar:universal_time()),
#{notifications := N} = infra_cleanup:run_once(),
?assertEqual(0, N),
?assertMatch([_], mnesia:dirty_read(notification, <<"n_unread">>)),
?assertMatch([_], mnesia:dirty_read(notification, <<"n_read_recent">>)).
test_deletes_old_read_notifications() ->
Old = infra_cleanup:cutoff_datetime(60),
write_notification(<<"n_old_read">>, true, Old),
write_notification(<<"n_unread">>, false, Old),
#{notifications := N} = infra_cleanup:run_once(),
?assertEqual(1, N),
?assertEqual([], mnesia:dirty_read(notification, <<"n_old_read">>)),
?assertMatch([_], mnesia:dirty_read(notification, <<"n_unread">>)).
test_keeps_active_sessions() ->
Now = calendar:universal_time(),
Future = days_offset(Now, 7),
Recent = days_offset(Now, -1),
write_auth_session(<<"s_active">>, false, Future, Recent),
#{auth_sessions := N} = infra_cleanup:run_once(),
?assertEqual(0, N),
?assertMatch([_], mnesia:dirty_read(auth_session, <<"s_active">>)).
test_deletes_old_revoked_sessions() ->
Now = calendar:universal_time(),
Old = days_offset(Now, -14),
Future = days_offset(Now, 7),
write_auth_session(<<"s_revoked_old">>, true, Future, Old),
write_auth_session(<<"s_active">>, false, Future, Old),
#{auth_sessions := N} = infra_cleanup:run_once(),
?assert(N >= 1),
?assertEqual([], mnesia:dirty_read(auth_session, <<"s_revoked_old">>)),
%% Active but old created_at with future expiry must stay
?assertMatch([_], mnesia:dirty_read(auth_session, <<"s_active">>)).
test_deletes_old_admin_audit() ->
Now = calendar:universal_time(),
Old = days_offset(Now, -120),
write_admin_audit(<<"a_old">>, Old),
write_admin_audit(<<"a_new">>, Now),
#{admin_audit := N} = infra_cleanup:run_once(),
?assertEqual(1, N),
?assertEqual([], mnesia:dirty_read(admin_audit, <<"a_old">>)),
?assertMatch([_], mnesia:dirty_read(admin_audit, <<"a_new">>)).
%%%===================================================================
%%% Helpers
%%%===================================================================
write_notification(Id, IsRead, CreatedAt) ->
mnesia:dirty_write(#notification{
id = Id,
user_id = <<"u1">>,
type = custom,
title = <<"t">>,
body = <<"b">>,
is_read = IsRead,
created_at = CreatedAt
}).
write_auth_session(Id, Revoked, ExpiresAt, CreatedAt) ->
mnesia:dirty_write(#auth_session{
session_id = Id,
family_id = <<"fam">>,
subject_id = <<"subj">>,
subject_type = user,
client_type = <<"web">>,
current_jti = <<"jti">>,
expires_at = ExpiresAt,
revoked = Revoked,
created_at = CreatedAt,
updated_at = CreatedAt
}).
write_admin_audit(Id, Timestamp) ->
mnesia:dirty_write(#admin_audit{
id = Id,
admin_id = <<"adm">>,
email = <<"a@test.local">>,
role = admin,
action = <<"test">>,
entity_type = <<"user">>,
entity_id = <<"u1">>,
timestamp = Timestamp,
ip = <<"127.0.0.1">>,
reason = <<>>
}).
days_offset(Datetime, Days) ->
Secs = calendar:datetime_to_gregorian_seconds(Datetime),
calendar:gregorian_seconds_to_datetime(Secs + Days * 86400).
+123 -1
View File
@@ -39,7 +39,15 @@ logic_booking_test_() ->
{"List event bookings", fun test_list_event_bookings/0},
{"List event bookings as owner", fun test_list_event_bookings_owner/0},
{"List event bookings non-owner denied", fun test_list_event_bookings_non_owner/0},
{"List user bookings", fun test_list_user_bookings/0}
{"List user bookings", fun test_list_user_bookings/0},
{"List booking requests as owner", fun test_list_booking_requests_owner/0},
{"List booking requests as specialist", fun test_list_booking_requests_specialist/0},
{"List booking requests stranger empty", fun test_list_booking_requests_stranger/0},
{"Past pending expires on list", fun test_past_pending_expires_on_list/0},
{"Past pending excluded from booking requests", fun test_past_pending_excluded_from_requests/0},
{"Past pending confirm denied", fun test_past_pending_confirm_denied/0},
{"Future pending still confirmable", fun test_future_pending_still_confirmable/0},
{"Process timeout expires past pending", fun test_process_timeout_expires_past/0}
]}.
%% Вспомогательные функции
@@ -80,6 +88,14 @@ create_test_event(CalendarId) ->
{ok, Event} = core_event:create(CalendarId, <<"Test Event">>, StartTime, 60),
Event#event.id.
create_test_event_at(CalendarId, StartTime) ->
{ok, Event} = core_event:create(CalendarId, <<"Test Event">>, StartTime, 60),
Event#event.id.
past_start() ->
Sec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) - 3600,
calendar:gregorian_seconds_to_datetime(Sec).
create_test_event_with_capacity(CalendarId, Capacity) ->
StartTime = eh_test_support:future_start(),
{ok, Event} = core_event:create(CalendarId, <<"Test Event">>, StartTime, 60),
@@ -301,3 +317,109 @@ test_list_user_bookings() ->
{ok, Bookings} = logic_booking:list_user_bookings(ParticipantId),
?assertEqual(2, length(Bookings)).
test_list_booking_requests_owner() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event(CalendarId),
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
{ok, Items} = logic_booking:list_user_booking_requests(OwnerId),
?assertEqual(1, length(Items)),
[{B, Event, Role}] = Items,
?assertEqual(Booking#booking.id, B#booking.id),
?assertEqual(EventId, Event#event.id),
?assertEqual(owner, Role),
%% participant inbox is separate no manage requests for own booking
{ok, []} = logic_booking:list_user_booking_requests(ParticipantId).
test_list_booking_requests_specialist() ->
OwnerId = create_test_user(user),
SpecId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
{ok, _} = core_calendar_specialist:create(CalendarId, SpecId, <<"Spec">>, []),
SpecEventId = create_test_event(CalendarId),
OtherEventId = create_test_event(CalendarId),
{ok, _} = core_event:update(SpecEventId, [{specialist_id, SpecId}]),
{ok, SpecBooking} = logic_booking:create_booking(ParticipantId, SpecEventId),
OtherParticipant = create_test_user(user),
{ok, _} = logic_booking:create_booking(OtherParticipant, OtherEventId),
{ok, SpecItems} = logic_booking:list_user_booking_requests(SpecId),
?assertEqual(1, length(SpecItems)),
[{B, Event, Role}] = SpecItems,
?assertEqual(SpecBooking#booking.id, B#booking.id),
?assertEqual(SpecEventId, Event#event.id),
?assertEqual(specialist, Role),
%% owner sees both pending
{ok, OwnerItems} = logic_booking:list_user_booking_requests(OwnerId),
?assertEqual(2, length(OwnerItems)).
test_list_booking_requests_stranger() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
StrangerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event(CalendarId),
{ok, _} = logic_booking:create_booking(ParticipantId, EventId),
{ok, []} = logic_booking:list_user_booking_requests(StrangerId).
test_past_pending_expires_on_list() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event_at(CalendarId, past_start()),
{ok, Booking} = core_booking:create(EventId, ParticipantId, pending),
{ok, [Listed]} = logic_booking:list_user_bookings(ParticipantId),
?assertEqual(Booking#booking.id, Listed#booking.id),
?assertEqual(expired, Listed#booking.status),
{ok, Stored} = core_booking:get_by_id(Booking#booking.id),
?assertEqual(expired, Stored#booking.status).
test_past_pending_excluded_from_requests() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
PastId = create_test_event_at(CalendarId, past_start()),
FutureId = create_test_event(CalendarId),
{ok, _} = core_booking:create(PastId, ParticipantId, pending),
{ok, FutureBooking} = logic_booking:create_booking(ParticipantId, FutureId),
{ok, Items} = logic_booking:list_user_booking_requests(OwnerId),
?assertEqual(1, length(Items)),
[{B, Event, owner}] = Items,
?assertEqual(FutureBooking#booking.id, B#booking.id),
?assertEqual(FutureId, Event#event.id).
test_past_pending_confirm_denied() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event_at(CalendarId, past_start()),
{ok, Booking} = core_booking:create(EventId, ParticipantId, pending),
{error, expired} = logic_booking:confirm_booking(Booking#booking.id, OwnerId),
{ok, Stored} = core_booking:get_by_id(Booking#booking.id),
?assertEqual(expired, Stored#booking.status).
test_future_pending_still_confirmable() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event(CalendarId),
{ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
{ok, Confirmed} = logic_booking:confirm_booking(Booking#booking.id, OwnerId),
?assertEqual(confirmed, Confirmed#booking.status).
test_process_timeout_expires_past() ->
OwnerId = create_test_user(user),
ParticipantId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, manual),
EventId = create_test_event_at(CalendarId, past_start()),
{ok, Booking} = core_booking:create(EventId, ParticipantId, pending),
ok = logic_booking:process_timeout_bookings(),
{ok, Stored} = core_booking:get_by_id(Booking#booking.id),
?assertEqual(expired, Stored#booking.status).
+167 -11
View File
@@ -16,9 +16,14 @@ setup() ->
{attributes, record_info(fields, subscription)},
{ram_copies, [node()]}
]),
mnesia:create_table(calendar_specialist, [
{attributes, record_info(fields, calendar_specialist)},
{ram_copies, [node()]}
]),
ok.
cleanup(_) ->
mnesia:delete_table(calendar_specialist),
mnesia:delete_table(subscription),
mnesia:delete_table(calendar),
mnesia:delete_table(user),
@@ -31,12 +36,20 @@ logic_calendar_test_() ->
fun cleanup/1,
[
{"Create calendar test", fun test_create_calendar/0},
{"Create commercial requires subscription", fun test_create_commercial_gate/0},
{"Get calendar test", fun test_get_calendar/0},
{"List calendars test", fun test_list_calendars/0},
{"Update calendar test", fun test_update_calendar/0},
{"Persist calendar settings (week_patterns)", fun test_update_settings/0},
{"Reject non-map settings", fun test_update_settings_invalid/0},
{"Org defaults settings round-trip", fun test_update_settings_org_defaults/0},
{"Reject invalid default_location shape", fun test_update_settings_invalid_location/0},
{"Delete calendar test", fun test_delete_calendar/0},
{"Access control test", fun test_access_control/0},
{"Ensure default calendar test", fun test_ensure_default_calendar/0}
{"Ensure default calendar test", fun test_ensure_default_calendar/0},
{"Second personal rejected", fun test_personal_exists/0},
{"Personal type change forbidden", fun test_default_calendar_type_change/0},
{"Backfill extras to commercial or delete", fun test_backfill_extras/0}
]}.
create_test_user() ->
@@ -66,6 +79,16 @@ test_create_calendar() ->
?assertEqual(personal, Calendar#calendar.type),
?assertEqual(Confirmation, Calendar#calendar.confirmation).
test_create_commercial_gate() ->
UserId = create_test_user(),
?assertMatch({error, subscription_required},
logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial)),
{ok, Personal} = logic_calendar:create_calendar(UserId, <<"Personal">>, <<>>, manual, personal),
?assertEqual(personal, Personal#calendar.type),
{ok, _} = logic_subscription:start_trial(UserId),
{ok, Commercial} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial),
?assertEqual(commercial, Commercial#calendar.type).
test_get_calendar() ->
UserId = create_test_user(),
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual),
@@ -84,16 +107,19 @@ test_get_calendar() ->
test_list_calendars() ->
UserId = create_test_user(),
{ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 1">>, <<"">>, manual),
{ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 2">>, <<"">>, auto),
{ok, _} = logic_subscription:start_trial(UserId),
{ok, _} = logic_calendar:create_calendar(UserId, <<"Calendar 2">>, <<"">>, auto, commercial),
{ok, Calendars} = logic_calendar:list_calendars(UserId),
?assertEqual(2, length(Calendars)).
test_update_calendar() ->
UserId = create_test_user(),
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Original">>, <<"">>, manual),
{ok, _} = logic_subscription:start_trial(UserId),
{ok, Calendar} = logic_calendar:create_calendar(
UserId, <<"Original">>, <<"">>, manual, commercial),
Updates = [{title, <<"Updated">>}, {type, commercial}, {confirmation, auto}],
Updates = [{title, <<"Updated">>}, {confirmation, auto}],
{ok, Updated} = logic_calendar:update_calendar(UserId, Calendar#calendar.id, Updates),
?assertEqual(<<"Updated">>, Updated#calendar.title),
?assertEqual(commercial, Updated#calendar.type),
@@ -103,14 +129,112 @@ test_update_calendar() ->
?assertMatch({error, access_denied},
logic_calendar:update_calendar(OtherUserId, Calendar#calendar.id, Updates)).
test_update_settings() ->
UserId = create_test_user(),
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<"">>, manual),
Patterns = [
#{
<<"id">> => <<"p1">>,
<<"name">> => <<"Weekdays">>,
<<"updated_at">> => <<"2026-07-29T12:00:00Z">>,
<<"slots">> => [
#{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60, <<"title">> => <<"Open">>}
]
}
],
Settings = #{<<"week_patterns">> => Patterns},
{ok, Updated} = logic_calendar:update_calendar(
UserId, Calendar#calendar.id, [{settings, Settings}]),
?assertEqual(Settings, Updated#calendar.settings),
{ok, Reloaded} = logic_calendar:get_calendar(UserId, Calendar#calendar.id),
?assertEqual(Settings, Reloaded#calendar.settings),
%% binary key path (as from raw JSON list before convert_field)
Settings2 = Settings#{<<"foo">> => <<"bar">>},
{ok, Updated2} = logic_calendar:update_calendar(
UserId, Calendar#calendar.id, [{<<"settings">>, Settings2}]),
?assertEqual(Settings2, Updated2#calendar.settings).
test_update_settings_invalid() ->
UserId = create_test_user(),
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<"">>, manual),
%% non-map settings silently dropped by validate_updates calendar unchanged
{ok, Same} = logic_calendar:update_calendar(
UserId, Calendar#calendar.id, [{settings, [<<"not-a-map">>]}]),
?assertEqual(#{}, Same#calendar.settings).
test_update_settings_org_defaults() ->
UserId = create_test_user(),
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Salon">>, <<"">>, manual),
Patterns = [
#{
<<"id">> => <<"p1">>,
<<"name">> => <<"Weekdays">>,
<<"slots">> => [
#{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60}
]
}
],
Settings = #{
<<"week_patterns">> => Patterns,
<<"default_location">> => #{
<<"address">> => <<"ул. Пример, 1">>,
<<"lat">> => 55.75,
<<"lon">> => 37.61
},
<<"default_duration_minutes">> => 90,
<<"default_recurrence">> => #{
<<"enabled">> => true,
<<"freq">> => <<"WEEKLY">>,
<<"interval">> => 1
}
},
{ok, Updated} = logic_calendar:update_calendar(
UserId, Calendar#calendar.id, [{settings, Settings}]),
?assertEqual(Settings, Updated#calendar.settings),
{ok, Reloaded} = logic_calendar:get_calendar(UserId, Calendar#calendar.id),
?assertEqual(Settings, Reloaded#calendar.settings),
%% null clears recurrence default
Settings2 = Settings#{<<"default_recurrence">> => null},
{ok, Updated2} = logic_calendar:update_calendar(
UserId, Calendar#calendar.id, [{settings, Settings2}]),
?assertEqual(null, maps:get(<<"default_recurrence">>, Updated2#calendar.settings)).
test_update_settings_invalid_location() ->
UserId = create_test_user(),
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Salon">>, <<"">>, manual),
?assertMatch({error, {invalid_settings, <<"default_location">>}},
logic_calendar:update_calendar(UserId, Calendar#calendar.id, [
{settings, #{<<"default_location">> => #{<<"address">> => <<"">>}}}
])),
?assertMatch({error, {invalid_settings, <<"default_location">>}},
logic_calendar:update_calendar(UserId, Calendar#calendar.id, [
{settings, #{<<"default_location">> => #{<<"address">> => <<"X">>, <<"lat">> => 1}}}
])),
?assertMatch({error, {invalid_settings, <<"default_duration_minutes">>}},
logic_calendar:update_calendar(UserId, Calendar#calendar.id, [
{settings, #{<<"default_duration_minutes">> => 0}}
])),
?assertEqual(#{},
begin
{ok, Same} = logic_calendar:get_calendar(UserId, Calendar#calendar.id),
Same#calendar.settings
end),
?assertMatch({error, {invalid_settings, <<"default_location">>}},
logic_calendar:normalize_settings(#{<<"default_location">> => <<"street">>})).
test_delete_calendar() ->
UserId = create_test_user(),
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual),
{ok, Personal} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual),
?assertMatch({error, default_calendar},
logic_calendar:delete_calendar(UserId, Personal#calendar.id)),
{ok, Deleted} = logic_calendar:delete_calendar(UserId, Calendar#calendar.id),
{ok, _} = logic_subscription:start_trial(UserId),
{ok, Commercial} = logic_calendar:create_calendar(
UserId, <<"Studio">>, <<"">>, manual, commercial),
{ok, Deleted} = logic_calendar:delete_calendar(UserId, Commercial#calendar.id),
?assertEqual(deleted, Deleted#calendar.status),
?assertMatch({error, access_denied}, logic_calendar:get_calendar(UserId, Calendar#calendar.id)).
?assertMatch({error, access_denied},
logic_calendar:get_calendar(UserId, Commercial#calendar.id)).
test_access_control() ->
OwnerId = create_test_user(),
@@ -122,7 +246,9 @@ test_access_control() ->
?assertNot(logic_calendar:can_edit(OtherId, PersonalCalendar)),
?assertNot(logic_calendar:can_access(OtherId, PersonalCalendar)),
{ok, CommercialCalendar} = logic_calendar:update_calendar(OwnerId, PersonalCalendar#calendar.id, [{type, commercial}]),
{ok, _} = logic_subscription:start_trial(OwnerId),
{ok, CommercialCalendar} = logic_calendar:create_calendar(
OwnerId, <<"Studio">>, <<"">>, manual, commercial),
?assert(logic_calendar:can_access(OtherId, CommercialCalendar)),
?assertNot(logic_calendar:can_edit(OtherId, CommercialCalendar)),
@@ -137,9 +263,39 @@ test_ensure_default_calendar() ->
?assertEqual(1, length(Calendars)),
Cal = hd(Calendars),
?assertEqual(personal, Cal#calendar.type),
?assertEqual(<<"Default">>, Cal#calendar.title),
?assertEqual(<<>>, Cal#calendar.short_name),
?assertEqual(active, Cal#calendar.status),
?assert(byte_size(Cal#calendar.title) > 0),
ok = logic_calendar:ensure_default_calendar(UserId),
{ok, Calendars2} = logic_calendar:list_calendars(UserId),
?assertEqual(1, length(Calendars2)).
?assertEqual(1, length(Calendars2)),
?assertEqual(<<"Default">>, (hd(Calendars2))#calendar.title).
test_personal_exists() ->
UserId = create_test_user(),
{ok, _} = logic_calendar:create_calendar(UserId, <<"One">>, <<"">>, manual, personal),
?assertMatch({error, personal_exists},
logic_calendar:create_calendar(UserId, <<"Two">>, <<"">>, manual, personal)).
test_default_calendar_type_change() ->
UserId = create_test_user(),
{ok, Personal} = logic_calendar:create_calendar(UserId, <<"One">>, <<"">>, manual, personal),
{ok, _} = logic_subscription:start_trial(UserId),
?assertMatch({error, default_calendar},
logic_calendar:update_calendar(UserId, Personal#calendar.id, [{type, commercial}])).
test_backfill_extras() ->
UserId = create_test_user(),
{ok, Keep0} = core_calendar:create(UserId, <<"Old">>, <<"">>, manual, personal),
{ok, Extra0} = core_calendar:create(UserId, <<"Extra">>, <<"">>, manual, personal),
%% datetime resolution is 1s pin order explicitly
mnesia:dirty_write(Keep0#calendar{created_at = {{2020, 1, 1}, {0, 0, 0}}}),
mnesia:dirty_write(Extra0#calendar{created_at = {{2020, 1, 2}, {0, 0, 0}}}),
ok = logic_calendar:backfill_single_personal(),
{ok, List} = logic_calendar:list_calendars(UserId),
Personals = [C || C <- List, C#calendar.type =:= personal],
?assertEqual(1, length(Personals)),
?assertEqual(Keep0#calendar.id, (hd(Personals))#calendar.id),
?assertEqual(<<"Default">>, (hd(Personals))#calendar.title),
{ok, ExtraAfter} = core_calendar:get_by_id(Extra0#calendar.id),
?assertEqual(deleted, ExtraAfter#calendar.status).
+23
View File
@@ -24,6 +24,7 @@ logic_event_recurring_test_() ->
{"Get occurrences test", fun test_get_occurrences/0},
{"Cancel occurrence test", fun test_cancel_occurrence/0},
{"Get occurrences with cancelled test", fun test_occurrences_with_cancelled/0},
{"Cancel multiple occurrences keeps all exceptions", fun test_cancel_multiple_occurrences/0},
{"Materialize for booking test", fun test_materialize_for_booking/0}
]}.
@@ -117,6 +118,28 @@ test_occurrences_with_cancelled() ->
Starts = [O || {virtual, O} <- Occurrences],
?assertNot(lists:member(CancelTime, Starts)).
test_cancel_multiple_occurrences() ->
{UserId, CalendarId} = create_test_user_and_calendar(),
StartTime = eh_test_support:future_start(),
RRule = #{<<"freq">> => <<"WEEKLY">>, <<"interval">> => 1, <<"count">> => 4},
{ok, Event} = logic_event:create_recurring_event(
UserId, CalendarId, <<"WeeklyFill">>, StartTime, 60, RRule
),
C1 = StartTime,
C2 = add_days(StartTime, 7),
C3 = add_days(StartTime, 14),
{ok, cancelled} = logic_event:cancel_occurrence(UserId, Event#event.id, C1),
{ok, cancelled} = logic_event:cancel_occurrence(UserId, Event#event.id, C2),
{ok, cancelled} = logic_event:cancel_occurrence(UserId, Event#event.id, C3),
RangeEnd = add_days(StartTime, 28),
{ok, Occurrences} = logic_event:get_occurrences(UserId, Event#event.id, RangeEnd),
Starts = [O || {virtual, O} <- Occurrences],
?assertEqual(1, length(Starts)),
?assertEqual([add_days(StartTime, 21)], Starts).
test_materialize_for_booking() ->
{UserId, CalendarId} = create_test_user_and_calendar(),
StartTime = eh_test_support:future_start(),
+34 -2
View File
@@ -2,7 +2,7 @@
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(TABLES, [user, calendar, event]).
-define(TABLES, [user, calendar, event, calendar_specialist, subscription]).
setup() ->
eh_test_support:start_mnesia(),
@@ -24,7 +24,8 @@ logic_event_test_() ->
{"List events test", fun test_list_events/0},
{"Update event test", fun test_update_event/0},
{"Delete event test", fun test_delete_event/0},
{"Event time validation test", fun test_time_validation/0}
{"Event time validation test", fun test_time_validation/0},
{"Persist specialist_id on update", fun test_specialist_id_persist/0}
]}.
create_test_user_and_calendar() ->
@@ -43,6 +44,26 @@ create_test_user_and_calendar() ->
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test Calendar">>, <<"">>, manual),
{UserId, Calendar#calendar.id}.
create_commercial_with_specialist() ->
OwnerId = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}),
SpecId = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}),
Owner = #user{
id = OwnerId, email = <<"owner@ex.com">>, password_hash = <<"hash">>,
role = user, status = active,
created_at = calendar:universal_time(), updated_at = calendar:universal_time()
},
Spec = #user{
id = SpecId, email = <<"spec@ex.com">>, password_hash = <<"hash">>,
role = user, status = active,
created_at = calendar:universal_time(), updated_at = calendar:universal_time()
},
mnesia:dirty_write(Owner),
mnesia:dirty_write(Spec),
{ok, _} = logic_subscription:start_trial(OwnerId),
{ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial),
{ok, _} = core_calendar_specialist:create(Cal#calendar.id, SpecId, <<"Spec">>, []),
{OwnerId, SpecId, Cal#calendar.id}.
add_days(DateTime, Days) ->
Sec = calendar:datetime_to_gregorian_seconds(DateTime) + Days * 86400,
calendar:gregorian_seconds_to_datetime(Sec).
@@ -102,3 +123,14 @@ test_time_validation() ->
FutureTime = eh_test_support:future_start(),
?assertEqual(ok, logic_event:validate_event_time(FutureTime)).
test_specialist_id_persist() ->
{OwnerId, SpecId, CalendarId} = create_commercial_with_specialist(),
StartTime = eh_test_support:future_start(),
{ok, Event} = logic_event:create_event(OwnerId, CalendarId, <<"Slot">>, StartTime, 60),
?assertEqual(<<>>, Event#event.specialist_id),
{ok, Updated} = logic_event:update_event(OwnerId, Event#event.id, [{specialist_id, SpecId}]),
?assertEqual(SpecId, Updated#event.specialist_id),
BadSpec = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}),
?assertMatch({error, invalid_specialist},
logic_event:update_event(OwnerId, Event#event.id, [{specialist_id, BadSpec}])).
@@ -0,0 +1,82 @@
-module(logic_owner_specialist_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(TABLES, [user, calendar, calendar_specialist, subscription, event]).
setup() ->
eh_test_support:start_mnesia(),
eh_test_support:ensure_tables(?TABLES),
ok.
cleanup(_) ->
eh_test_support:delete_tables(?TABLES),
eh_test_support:stop_mnesia(),
ok.
logic_owner_specialist_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"create commercial ensures owner specialist active", fun test_ensure_on_create/0},
{"ensure is idempotent", fun test_ensure_idempotent/0},
{"DELETE owner specialist protected", fun test_remove_owner_forbidden/0},
{"owner can update name/status", fun test_owner_update/0},
{"specialist_id=owner ok when active, invalid when inactive", fun test_owner_specialist_id/0}
]}.
seed_owner() ->
Id = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}),
Owner = eh_test_support:make_user(#{
id => Id,
email => <<"owner-", Id/binary, "@ex.com">>,
nickname => <<"SoloNick">>,
status => active}),
mnesia:dirty_write(Owner),
{ok, _} = logic_subscription:start_trial(Id),
Id.
test_ensure_on_create() ->
OwnerId = seed_owner(),
{ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial),
?assert(core_calendar_specialist:is_active_specialist(Cal#calendar.id, OwnerId)),
{ok, Spec} = core_calendar_specialist:get_by_calendar_and_user(Cal#calendar.id, OwnerId),
?assertEqual(<<"SoloNick">>, Spec#calendar_specialist.name),
?assertEqual(active, Spec#calendar_specialist.status).
test_ensure_idempotent() ->
OwnerId = seed_owner(),
{ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial),
?assertEqual(ok, logic_calendar_specialist:ensure_owner_specialist(Cal)),
Specs = core_calendar_specialist:list_by_calendar(Cal#calendar.id),
OwnerSpecs = [S || S <- Specs, S#calendar_specialist.user_id =:= OwnerId],
?assertEqual(1, length(OwnerSpecs)).
test_remove_owner_forbidden() ->
OwnerId = seed_owner(),
{ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial),
?assertEqual({error, owner_specialist_protected},
logic_calendar_specialist:remove(OwnerId, Cal#calendar.id, OwnerId)),
?assert(core_calendar_specialist:is_active_specialist(Cal#calendar.id, OwnerId)).
test_owner_update() ->
OwnerId = seed_owner(),
{ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial),
{ok, Updated} = logic_calendar_specialist:update(OwnerId, Cal#calendar.id, OwnerId,
[{name, <<"Master">>}, {specialization, [<<"cut">>]}, {status, inactive}]),
?assertEqual(<<"Master">>, Updated#calendar_specialist.name),
?assertEqual([<<"cut">>], Updated#calendar_specialist.specialization),
?assertEqual(inactive, Updated#calendar_specialist.status),
?assertNot(core_calendar_specialist:is_active_specialist(Cal#calendar.id, OwnerId)),
{ok, ActiveAgain} = logic_calendar_specialist:update(OwnerId, Cal#calendar.id, OwnerId,
[{status, active}]),
?assertEqual(active, ActiveAgain#calendar_specialist.status).
test_owner_specialist_id() ->
OwnerId = seed_owner(),
{ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial),
Start = eh_test_support:future_start(),
{ok, Event} = logic_event:create_event(OwnerId, Cal#calendar.id, <<"Slot">>, Start, 60),
{ok, WithOwner} = logic_event:update_event(OwnerId, Event#event.id, [{specialist_id, OwnerId}]),
?assertEqual(OwnerId, WithOwner#event.specialist_id),
{ok, _} = logic_calendar_specialist:update(OwnerId, Cal#calendar.id, OwnerId, [{status, inactive}]),
?assertMatch({error, invalid_specialist},
logic_event:update_event(OwnerId, Event#event.id, [{specialist_id, OwnerId}])).
+25
View File
@@ -7,6 +7,7 @@ logic_recurrence_test_() ->
{"Validate RRULE test", fun test_validate_rrule/0},
{"Generate daily occurrences test", fun test_daily_occurrences/0},
{"Generate weekly occurrences test", fun test_weekly_occurrences/0},
{"Generate weekly BYDAY occurrences test", fun test_weekly_byday_occurrences/0},
{"Generate monthly occurrences test", fun test_monthly_occurrences/0},
{"Generate with count limit test", fun test_count_limit/0},
{"Generate with until limit test", fun test_until_limit/0}
@@ -71,6 +72,30 @@ test_weekly_occurrences() ->
?assertEqual({{2026, 4, 20}, {10, 0, 0}}, lists:nth(1, Occurrences)),
?assertEqual({{2026, 5, 11}, {10, 0, 0}}, lists:nth(4, Occurrences)).
test_weekly_byday_occurrences() ->
%% Monday start, MO/WE/FR for two weeks 6 slots
StartTime = {{2026, 4, 20}, {10, 0, 0}},
RRule = #{
freq => <<"WEEKLY">>,
interval => 1,
byday => [<<"MO">>, <<"WE">>, <<"FR">>]
},
RangeEnd = {{2026, 5, 1}, {10, 0, 0}},
Occurrences = logic_recurrence:generate_occurrences(StartTime, RRule, RangeEnd),
?assertEqual(6, length(Occurrences)),
?assertEqual({{2026, 4, 20}, {10, 0, 0}}, lists:nth(1, Occurrences)),
?assertEqual({{2026, 4, 22}, {10, 0, 0}}, lists:nth(2, Occurrences)),
?assertEqual({{2026, 4, 24}, {10, 0, 0}}, lists:nth(3, Occurrences)),
?assertEqual({{2026, 4, 27}, {10, 0, 0}}, lists:nth(4, Occurrences)),
?assertEqual({{2026, 4, 29}, {10, 0, 0}}, lists:nth(5, Occurrences)),
?assertEqual({{2026, 5, 1}, {10, 0, 0}}, lists:nth(6, Occurrences)),
%% Mid-week DTSTART must not emit earlier weekdays of the same week
WedStart = {{2026, 4, 22}, {10, 0, 0}},
MidWeek = logic_recurrence:generate_occurrences(WedStart, RRule, {{2026, 4, 24}, {10, 0, 0}}),
?assertEqual([{{2026, 4, 22}, {10, 0, 0}}, {{2026, 4, 24}, {10, 0, 0}}], MidWeek).
test_monthly_occurrences() ->
StartTime = {{2026, 4, 20}, {10, 0, 0}},
RRule = #{
+9
View File
@@ -25,6 +25,7 @@ logic_search_test_() ->
{"Search events by location", fun test_search_events_by_location/0},
{"Combined search", fun test_combined_search/0},
{"Search calendars", fun test_search_calendars/0},
{"Search calendars include image_url", fun test_search_calendars_image_url/0},
{"Search all", fun test_search_all/0},
{"Pagination", fun test_pagination/0},
{"Sorting", fun test_sorting/0},
@@ -175,6 +176,14 @@ test_search_calendars() ->
{Total2, _} = calendars_from(logic_search:search(<<"calendar">>, <<"Calendar">>, OwnerId, #{})),
?assertEqual(2, Total2).
test_search_calendars_image_url() ->
OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, commercial, [<<"cover">>]),
{ok, _} = core_calendar:update(CalendarId, [{image_url, <<"https://cdn.example/cal.jpg">>}]),
{_, Cals} = calendars_from(logic_search:search(<<"calendar">>, <<"Calendar">>, OwnerId, #{})),
[Hit | _] = [C || C <- Cals, maps:get(id, C) =:= CalendarId],
?assertEqual(<<"https://cdn.example/cal.jpg">>, maps:get(image_url, Hit)).
test_search_all() ->
OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId, personal, []),
+3 -3
View File
@@ -18,7 +18,7 @@ logic_subscription_test_() ->
{"Check subscription - free", fun test_check_free/0},
{"Check subscription - trial", fun test_check_trial/0},
{"Check subscription - paid", fun test_check_paid/0},
{"Can create commercial - free (auto-trial)", fun test_can_create_commercial_free/0},
{"Can create commercial - free (no auto-trial)", fun test_can_create_commercial_free/0},
{"Can create commercial - trial", fun test_can_create_commercial_trial/0},
{"Can create commercial - paid", fun test_can_create_commercial_paid/0},
{"Get user subscription - free", fun test_get_user_subscription_free/0},
@@ -127,9 +127,9 @@ test_check_paid() ->
{ok, active, monthly} = logic_subscription:check_user_subscription(UserId).
test_can_create_commercial_free() ->
% Новый пользователь автоматически получает пробный период при проверке
% Без активной подписки/trial create commercial запрещён (trial явно через start_trial)
UserId = create_test_user(),
?assert(logic_subscription:can_create_commercial_calendar(UserId)).
?assertNot(logic_subscription:can_create_commercial_calendar(UserId)).
test_can_create_commercial_trial() ->
UserId = create_test_user(),
+1 -5
View File
@@ -12,12 +12,8 @@
setup() ->
eh_test_support:start_mnesia(),
%% email index comes from eh_test_support:ensure_indexes/1
eh_test_support:ensure_tables(?TABLES),
case mnesia:add_table_index(user, email) of
{atomic, ok} -> ok;
{aborted, {already_exists, user, email}} -> ok;
{aborted, Reason} -> error({add_index_failed, Reason})
end,
ok.
cleanup(_) ->
+2 -1
View File
@@ -13,7 +13,8 @@
"20260719210000_review_vote",
"20260720210000_calendar_follow",
"20260722150000_calendar_specialist_id",
"20260722190000_specialist_invite"
"20260722190000_specialist_invite",
"20260730120000_recurrence_exception_bag"
]).
setup() ->