Compare commits
41 Commits
mvp-0.1
...
e3403f6ed6
| Author | SHA1 | Date | |
|---|---|---|---|
| e3403f6ed6 | |||
| 37011f66a1 | |||
| c89d08968d | |||
| 72bc440049 | |||
| 6c0775ee5e | |||
| 774f58f9ed | |||
| d88689f1a3 | |||
| e3467e7412 | |||
| 104892bdde | |||
| 2b8fc19822 | |||
| 4eb1f326ec | |||
| 0a5e86c4a0 | |||
| 0e4e886cd4 | |||
| ba1649f344 | |||
| 7d39c004a1 | |||
| 10464de3ec | |||
| bb37909ffb | |||
| edd396dff8 | |||
| 1fce1aa12d | |||
| 32d3e7378e | |||
| 4986baf006 | |||
| 4c480f1c43 | |||
| faf251b11d | |||
| 7e14dbcf8b | |||
| 76548e578b | |||
| bc85f5ab06 | |||
| 3c2c33f83b | |||
| 85a34dd7b0 | |||
| 7bd6e52d18 | |||
| 5ed361d8ff | |||
| 5169df3d40 | |||
| 8305bd1a86 | |||
| b3792a304b | |||
| 78d410c3e3 | |||
| 2bfce06dbf | |||
| cd619db4a2 | |||
| bd36f158b9 | |||
| 81728301e2 | |||
| edb7c20f70 | |||
| 1d9752bb69 | |||
| b25c0447cd |
@@ -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` + 1–2 файла |
|
||||
| Полный `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 в каждом чате.
|
||||
@@ -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 пользователя
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -17,6 +17,7 @@ erl_crash.dump
|
||||
.rebar
|
||||
logs
|
||||
data
|
||||
ci-out
|
||||
.idea
|
||||
*.iml
|
||||
rebar3.crashdump
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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}) ==="
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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, []},
|
||||
|
||||
@@ -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, _} ->
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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} ->
|
||||
|
||||
@@ -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} ->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}.
|
||||
@@ -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},
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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.
|
||||
@@ -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)}];
|
||||
|
||||
@@ -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}}.
|
||||
|
||||
@@ -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
@@ -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
@@ -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() ->
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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, <<"">>),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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">>, <<>>,
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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(_) ->
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}])).
|
||||
@@ -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 = #{
|
||||
|
||||
@@ -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, []),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(_) ->
|
||||
|
||||
@@ -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() ->
|
||||
|
||||
Reference in New Issue
Block a user