Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0565f09a72 | |||
| a061674408 | |||
| 9348c36e52 | |||
| e0e3455281 | |||
| 61f7a5f719 | |||
| b122cf3339 | |||
| 4486dc87db | |||
| d46eb6e528 | |||
| 1ef9020c40 | |||
| f4d89a262c | |||
| cc08b3b18a | |||
| 73d43e5fdc | |||
| 6b70e1336d | |||
| 501334e241 | |||
| e3f5a7ca13 | |||
| ef31e7fb40 | |||
| 0690020e81 | |||
| 1c701a5254 | |||
| fb927b7f71 | |||
| c1b4897a8b | |||
| fcb737eb47 | |||
| 331000a464 | |||
| 00bf72e4d1 | |||
| 12274f9ed0 | |||
| e86d603f30 | |||
| 0a1e55e036 | |||
| fb8869fa86 | |||
| 29111cd514 | |||
| 15ae8d4426 | |||
| 347a645933 | |||
| 302e1e5e9e | |||
| 69ee037d45 | |||
| 3d7ae0d372 | |||
| 658cbda4fe | |||
| 2567585333 | |||
| 520af23cc6 | |||
| 8a5f254c2a | |||
| 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,120 @@
|
||||
---
|
||||
description: Типичные ошибки агентов EventHub — shell, git, docker, scope, токены (корень + субагенты)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# EventHub: типичные ошибки агентов (избегать)
|
||||
|
||||
Канон. Субагентам: включать в промпт «следуй `agent-pitfalls.mdc` + `shell-wsl-not-powershell.mdc`».
|
||||
|
||||
## 1. Shell / PowerShell
|
||||
|
||||
| Симптом | Причина | Как правильно |
|
||||
|---------|---------|---------------|
|
||||
| `bash: /mnt/c/.../.tmp-*.sh: No such file or directory` при том что `ls` файл видит | **CRLF** от Cursor Write: shebang = `bash\r` | **Всегда** `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../script.sh` (или `sed -i 's/\r$//' && bash`). **Не** голый `wsl -e bash /mnt/c/.../script.sh` |
|
||||
| IFT e2e: **N skipped**, exit 0 | Нет `SMOKE_USER_*` (голый `npm run test:e2e:ift`) | `run-front-ift.sh` / source `eventhub-ift.env` **до** npm. Не путать со «стенд недоступен». CI уже крутит e2e-ift → локально не дублировать |
|
||||
| `unexpected EOF`, `ParserError`, пустой curl | PS ломает `"`, `$`, heredoc, `&&` | Файл `.sh` → `run-wsl-sh.sh` |
|
||||
| `bash: syntax error near '('` | `grep -E ^(...)` в PS; **`fix(ci):` / `(…)` в `wsl -lc` one-liner** | Только внутри `.sh`; commit → `-F` файл (см. ниже) |
|
||||
| `{{.Names}}: command not found` | `docker --format "{{…}}"` в PS | Формат в `.sh` или простой `docker ps` |
|
||||
| `&&` is not valid | PowerShell не bash | Отдельные команды или `.sh` |
|
||||
|
||||
**Запрещено в PS:** голый `wsl -e bash /mnt/c/.../.tmp-*.sh` (без `run-wsl-sh.sh`), `wsl -e bash -lc "…\"…"`, `curl -d "{\"x\":…}"`, heredoc commit, `npm`/`rebar3`.
|
||||
|
||||
Канон запуска скриптов: см. `shell-wsl-not-powershell.mdc` § CRLF.
|
||||
|
||||
## 2. Git
|
||||
|
||||
| Симптом | Как правильно |
|
||||
|---------|---------------|
|
||||
| `#` в `-m` обрезает сообщение | `-F` файл; в тексте `Refs EventHub/EventHubBack#N` |
|
||||
| `syntax error near '('` на commit | Сообщение `fix(ci): …` inline в `wsl -lc '… git-commit.sh … fix(ci): …'` | **Только** `.tmp-commit-msg.txt` + `git commit -F`; или `.sh` без inline `-m` |
|
||||
| `Co-authored-by: Cursor <…>` / `<` ParserError | `git commit -F .tmp-msg.txt` из PS **или** `git-commit.sh` **из `.sh`**, не из PS one-liner |
|
||||
| `editor` / not a terminal | Не `git commit` без `-F` в WSL; из Windows: `git commit -F file` |
|
||||
| Сотни `M` после reset на `/mnt/c` | `git config core.filemode false` (локально); не «чинить» код |
|
||||
| Удалились `.cursor/`, `.idea/` | **`git clean -fdx` только с backup** env + certs; IDE-директории **не трогать** |
|
||||
| Push «висит» / ждёт login | **Не** `git push origin` — только `~/.cursor/eventhub/git-push.sh` / `git-push-main.sh` |
|
||||
| Push заблокирован Auto-review | Push в `master` — только по явной просьбе; `request_smart_mode_approval` |
|
||||
|
||||
**`git clean` / reset к origin:** сначала сохранить `docker/.env*`, `.env.development`, `docker/traefik/certs/*`.
|
||||
|
||||
**Push:** запрещены голые `git push` / `git push origin …`. Канон — `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh <repo-dir>` (токен из secrets).
|
||||
|
||||
**Commit (сообщение со скобками, `#`, `$`, пробелами):** не передавать subject/body аргументом через PowerShell или `wsl -e bash -lc '…'`. Пиши `.tmp-commit-msg.txt` в репо, затем в `.sh`:
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
|
||||
git add path/to/file
|
||||
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
|
||||
```
|
||||
|
||||
Запуск из PS: `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../EventHubBack/.tmp-commit.sh` — **без** голого `wsl -e bash …/.tmp-commit.sh`, **без** `-lc` и **без** `-m "fix(ci): …"`.
|
||||
|
||||
## 3. Docker dev (Swarm локально)
|
||||
|
||||
| Ошибка | Правильно |
|
||||
|--------|-----------|
|
||||
| «Балансировка» → 3× eventhub | **dev:** `traefik=1` + **`eventhub=1`** (см. `EventHubDevOps/docs/STANDS.md`) |
|
||||
| `host-mode port already in use` | У eventhub опубликованы 8080/8445 — **нельзя** scale >1; LB = traefik |
|
||||
| Собрали admin-ui/front без просьбы | «Только бэк» → eventhub (+ traefik если нужен HTTPS), **без** Front/Admin образов |
|
||||
| Эмулятор не остановился | `docker service scale eventhub_bot-emulator-users=0` |
|
||||
| `No such image: eventhub:latest` | Сначала `docker build -t eventhub:latest …` или `docker/build-images.sh` |
|
||||
| Admin API dev не отвечает на :443 | Traefik без ports → **`http://127.0.0.1:8445`**; stage → SSH + `--resolve …:443:127.0.0.1` |
|
||||
|
||||
## 4. Scope (не додумывать)
|
||||
|
||||
- Делать **ровно** запрос; не раздувать (полный `build-images.sh`, 3 ноды, admin-ui, spec commit).
|
||||
- Перед scale/deploy — прочитать compose и STANDS.md.
|
||||
- Секреты: `gitea.ps1` / `.sh` + env; не inline `source secrets` в PS one-liner.
|
||||
|
||||
## 5. Субагенты (Task)
|
||||
|
||||
В промпт субагента добавлять:
|
||||
|
||||
```text
|
||||
Shell: только WSL + .sh (см. shell-wsl-not-powershell.mdc, agent-pitfalls.mdc).
|
||||
Один репо. Итог на русском. Не push без явного OK.
|
||||
```
|
||||
|
||||
## 6. Экономия токенов / лимитов моделей
|
||||
|
||||
### Контекст чата
|
||||
- **Один чат — одна задача**; после «готово / CI зелёный» — новый чат на следующий шаг (см. `WORKFLOW.md` §5).
|
||||
- Workspace: для Back-задачи достаточно **`EventHubBack`** (+ Spec при необходимости); не держать все репо без нужды.
|
||||
- **`@` точечно** — файл/папка, не `@Codebase` без причины.
|
||||
- Не просить «проанализируй все transcripts» — они огромные.
|
||||
|
||||
### Дешёвые vs дорогие действия
|
||||
| Дорого | Дешевле |
|
||||
|--------|---------|
|
||||
| Task explore «very thorough» | `grep` + 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/`.
|
||||
+33
-3
@@ -114,12 +114,42 @@ jobs:
|
||||
.
|
||||
|
||||
- name: Run unit tests (EUnit)
|
||||
run: >
|
||||
docker run --rm eventhub-tests:latest
|
||||
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
|
||||
+7
-4
@@ -17,6 +17,7 @@ erl_crash.dump
|
||||
.rebar
|
||||
logs
|
||||
data
|
||||
ci-out
|
||||
.idea
|
||||
*.iml
|
||||
rebar3.crashdump
|
||||
@@ -35,7 +36,9 @@ docker/.env.stage
|
||||
# Пул пользователей для stress hot-path (см. test/tsung/ift-users.csv.example)
|
||||
/test/tsung/ift-users.csv
|
||||
|
||||
# Traefik TLS private keys (generated locally for dev/staging).
|
||||
# Никогда не коммитим приватный ключ в репозиторий.
|
||||
docker/traefik/certs/*.key
|
||||
docker/traefik/certs/*.pem
|
||||
# Traefik TLS certs (generated locally for dev/staging) — не коммитить.
|
||||
docker/traefik/certs/*
|
||||
!docker/traefik/certs/make-traefik-certs.sh
|
||||
|
||||
# Temporary/scratch files
|
||||
.tmp-*
|
||||
|
||||
@@ -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 \
|
||||
|
||||
+52
-12
@@ -1,21 +1,61 @@
|
||||
# Скопируйте в .env (файл .env в .gitignore). Для локальной разработки: EVENTHUB_ENV=dev
|
||||
# 小泻芯锌懈褉褍泄褌械 胁 .env (褎邪泄谢 .env 胁 .gitignore). 袛谢褟 谢芯泻邪谢褜薪芯泄 褉邪蟹褉邪斜芯褌泻懈: EVENTHUB_ENV=dev
|
||||
|
||||
# Режим: dev | stage | prod (stage/prod — обязательные сильные секреты)
|
||||
# 袪械卸懈屑: dev | stage | prod (stage/prod 鈥� 芯斜褟蟹邪褌械谢褜薪褘械 褋懈谢褜薪褘械 褋械泻褉械褌褘)
|
||||
EVENTHUB_ENV=dev
|
||||
|
||||
RELEASE_COOKIE=ваш-очень-длинный-секретный-куки-минимум-32-символа
|
||||
GRAFANA_ADMIN_PASSWORD=сложный-уникальный-пароль
|
||||
RELEASE_COOKIE=胁邪褕-芯褔械薪褜-写谢懈薪薪褘泄-褋械泻褉械褌薪褘泄-泻褍泻懈-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪
|
||||
GRAFANA_ADMIN_PASSWORD=褋谢芯卸薪褘泄-褍薪懈泻邪谢褜薪褘泄-锌邪褉芯谢褜
|
||||
|
||||
# JWT (минимум 32 символа, не из denylist — см. infra_secrets)
|
||||
JWT_SECRET=замените-на-случайную-строку-минимум-32-символа-для-user-jwt
|
||||
ADMIN_JWT_SECRET=замените-на-другую-случайную-строку-минимум-32-символа
|
||||
# JWT (屑懈薪懈屑褍屑 32 褋懈屑胁芯谢邪, 薪械 懈蟹 denylist 鈥� 褋屑. infra_secrets)
|
||||
JWT_SECRET=蟹邪屑械薪懈褌械-薪邪-褋谢褍褔邪泄薪褍褞-褋褌褉芯泻褍-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪-写谢褟-user-jwt
|
||||
ADMIN_JWT_SECRET=蟹邪屑械薪懈褌械-薪邪-写褉褍谐褍褞-褋谢褍褔邪泄薪褍褞-褋褌褉芯泻褍-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪
|
||||
|
||||
# Seed админов (обязательны в stage/prod при пустой таблице admin; в dev — опционально)
|
||||
# Seed 邪写屑懈薪芯胁 (芯斜褟蟹邪褌械谢褜薪褘 胁 stage/prod 锌褉懈 锌褍褋褌芯泄 褌邪斜谢懈褑械 admin; 胁 dev 鈥� 芯锌褑懈芯薪邪谢褜薪芯)
|
||||
ADMIN_SUPER_EMAIL=superadmin@eventhub.local
|
||||
ADMIN_SUPER_PASSWORD=замените-сильный-пароль-суперадмина
|
||||
ADMIN_SUPER_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-褋褍锌械褉邪写屑懈薪邪
|
||||
ADMIN_EMAIL=admin@eventhub.local
|
||||
ADMIN_PASSWORD=замените-сильный-пароль-админа
|
||||
ADMIN_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-邪写屑懈薪邪
|
||||
ADMIN_MODER_EMAIL=moderator@eventhub.local
|
||||
ADMIN_MODER_PASSWORD=замените-сильный-пароль-модератора
|
||||
ADMIN_MODER_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-屑芯写械褉邪褌芯褉邪
|
||||
ADMIN_SUPPORT_EMAIL=support@eventhub.local
|
||||
ADMIN_SUPPORT_PASSWORD=замените-сильный-пароль-поддержки
|
||||
ADMIN_SUPPORT_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-锌芯写写械褉卸泻懈
|
||||
|
||||
# Email: EMAIL_TRANSPORT=auto|smtp|http_api|log (auto: API key 鈫� http_api, else SMTP_HOST 鈫� smtp, else log)
|
||||
EMAIL_TRANSPORT=auto
|
||||
# HTTP API (HTTPS 鈥� when outbound SMTP ports blocked). Canon: Resend (Brevo optional).
|
||||
EMAIL_API_KEY=
|
||||
EMAIL_API_PROVIDER=resend
|
||||
# EMAIL_API_URL=https://api.resend.com/emails
|
||||
# Web Push (VAPID). Generate: npx web-push generate-vapid-keys
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@calentiq.com
|
||||
# SMTP (锌褍褋褌芯 SMTP_HOST + 薪械褌 API key = 褌芯谢褜泻芯 谢芯谐; 锌邪褉芯谢褜 薪械 泻芯屑屑懈褌懈褌褜)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=noreply@calentiq.com
|
||||
SMTP_TLS=if_available
|
||||
PUBLIC_APP_URL=https://stage.calentiq.com
|
||||
# 袨泻薪芯 薪邪锌芯屑懈薪邪薪懈泄 芯 booking (褔邪褋褘 写芯 褋褌邪褉褌邪; Back#70)
|
||||
REMINDER_LEAD_HOURS=24
|
||||
# Uploads (avatar/cover) 鈥� 泻邪褌邪谢芯谐 薪邪 volume /app/data (Back#71)
|
||||
UPLOAD_DIR=/app/data/uploads
|
||||
UPLOAD_MAX_BYTES=2097152
|
||||
# OpenCage (DevOps#16). 象耱铋 觌� = /v1/geo -> 503 (degrade). Free: 2500/耋蜿�, 1 rps.
|
||||
OPENCAGE_API_KEY=
|
||||
OPENCAGE_TIMEOUT_MS=3000
|
||||
# STT-прокси голосового центра (Back#79, Front#76). Без STT_API_KEY
|
||||
# /v1/ai/transcribe отвечает 503 unavailable, клиент деградирует в палитру.
|
||||
# Провайдеры: xai (POST {STT_URL}, Bearer) | openai (OpenAI-совместимый).
|
||||
STT_PROVIDER=xai
|
||||
STT_URL=https://api.x.ai/v1/stt
|
||||
STT_API_KEY=
|
||||
# Опционально: модель для openai-провайдера и таймаут апстрима.
|
||||
STT_MODEL=
|
||||
STT_TIMEOUT_MS=15000
|
||||
# Опционально: HTTP-эгресс (сервис stt-egress в стеке, vless-образ);
|
||||
# гео-блокированные STT-провайдеры вызываются через него.
|
||||
STT_PROXY_URL=
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ RUN rebar3 get-deps
|
||||
|
||||
COPY include/ include/
|
||||
COPY src/ src/
|
||||
COPY priv/ priv/
|
||||
|
||||
COPY src/config/sys.config ./config/sys.config
|
||||
COPY src/config/vm.args ./config/vm.args
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,23 @@ services:
|
||||
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
|
||||
- CLUSTER_MODE=true
|
||||
- DNS_NAME=eventhub-node
|
||||
- EMAIL_TRANSPORT=${EMAIL_TRANSPORT:-auto}
|
||||
- EMAIL_API_KEY=${EMAIL_API_KEY:-}
|
||||
- EMAIL_API_PROVIDER=${EMAIL_API_PROVIDER:-resend}
|
||||
- EMAIL_API_URL=${EMAIL_API_URL:-}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@calentiq.com}
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-noreply@calentiq.com}
|
||||
- SMTP_TLS=${SMTP_TLS:-if_available}
|
||||
- PUBLIC_APP_URL=${PUBLIC_APP_URL:-https://stage.calentiq.com}
|
||||
- REMINDER_LEAD_HOURS=${REMINDER_LEAD_HOURS:-24}
|
||||
- UPLOAD_DIR=${UPLOAD_DIR:-/app/data/uploads}
|
||||
- UPLOAD_MAX_BYTES=${UPLOAD_MAX_BYTES:-2097152}
|
||||
networks:
|
||||
eventhub-net:
|
||||
aliases:
|
||||
|
||||
@@ -70,6 +70,8 @@ services:
|
||||
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
|
||||
- CLUSTER_MODE=true
|
||||
- DNS_NAME=eventhub-node
|
||||
- OPENCAGE_API_KEY=${OPENCAGE_API_KEY:-}
|
||||
- OPENCAGE_TIMEOUT_MS=${OPENCAGE_TIMEOUT_MS:-3000}
|
||||
networks:
|
||||
eventhub-net:
|
||||
aliases:
|
||||
@@ -90,6 +92,19 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
||||
# ================== CI HTML reports (local runner) ==================
|
||||
ci-reports:
|
||||
image: nginx:1.27-alpine
|
||||
volumes:
|
||||
- "${CI_REPORTS_HOST_DIR:-/var/eventhub/ci-reports}:/usr/share/nginx/html:ro"
|
||||
- "./ci-reports/nginx.conf:/etc/nginx/conf.d/default.conf:ro"
|
||||
networks:
|
||||
- eventhub-net
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: any
|
||||
|
||||
# ================== Admin UI ==================
|
||||
admin-ui:
|
||||
image: eventhub-admin-ui:latest
|
||||
|
||||
@@ -7,7 +7,7 @@ FROM alpine:${ALPINE_VERSION}
|
||||
RUN apk add --no-cache \
|
||||
openssl libstdc++ libgcc ncurses-libs libsodium \
|
||||
file \
|
||||
&& mkdir -p /app/data \
|
||||
&& chmod 777 /app/data
|
||||
&& mkdir -p /app/data /app/data/uploads \
|
||||
&& chmod 777 /app/data /app/data/uploads
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFFzCCAv+gAwIBAgIUUX5W2zeEC7Z8wSBPoFSDNLpV6U0wDQYJKoZIhvcNAQEL
|
||||
BQAwGzEZMBcGA1UEAwwQKi5ldmVudGh1Yi5sb2NhbDAeFw0yNjA0MjUwNzAzNTZa
|
||||
Fw0zNjA0MjIwNzAzNTZaMBsxGTAXBgNVBAMMECouZXZlbnRodWIubG9jYWwwggIi
|
||||
MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDAKnFa4rgzqERaAHGh1sKLy++A
|
||||
AqFKBIJAtK+8YPz45jtHElT0BJfE2kOo3wZ+iucbE+dg4yfrKkD4bfa6zbwCYP2h
|
||||
hEHT6w1tI1BMZCrgY+eqEjvgTYLb5oq9J9zs9GawmaW+XCnUVAOWdOFU3MQlBcbn
|
||||
FJrDAwz4BjmaRFGVMm95Nk2eGD3cObuqQUVMR95YgZxHyX5M7F8jIAdUO6DoB+fT
|
||||
MQEEudkU/po/Xp2Xx3GJavRDMPoZ4HEGpQlPmU5JzeMAHVVx6Zj8Ls45eGX4qwf7
|
||||
4OXbAmBRntpjDIz8qQVAl11QakM2ZW2ALEkIqRU6tIiiyk/PO3fuq11IMGNlx1oL
|
||||
9L8ClSsgsA3UX7YmjpFmvDJX0anBHiUKonHXzroPu4opm9RpUhYkUTiNLoQxOWwz
|
||||
FeBmfg5vzJ/fd/w/796CU56vCRctG5UpXWD9dQxjXkWVZIoRvuIeGIIW2qy+NkJA
|
||||
OuF0aTAphm4Hns9yT4EvfJIvLSZKQyFu6oIfvR4e1RTlZmzgVUQ+O50Kk7z6Jwnt
|
||||
DkFburi/HnJ8I5YS6zeLrNykKhTPOZu8SfFm5UA71SJZorzftE3R6jz9iPj2Ev48
|
||||
F7rCWhMqfGtiIp70ASx9xv+5ZTQPLEQkvH+R4JYOqWUwmJhtiso6z8a8zB+3MbGX
|
||||
f2VTurQ1nB6+C9SaIQIDAQABo1MwUTAdBgNVHQ4EFgQU/EItSTyuc3hQdo4uZdh9
|
||||
L8o6kkMwHwYDVR0jBBgwFoAU/EItSTyuc3hQdo4uZdh9L8o6kkMwDwYDVR0TAQH/
|
||||
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAY0UTqk6ICLlyFKNoTyybd1Mf+Hsz
|
||||
Wn39zJnfdfTbw7DTZgAxU+rPkwWVtpAQpzKS9WugoYa7AEUZDphDHQBlzhuT8xd7
|
||||
0Gy47kZEklY29FaNDKaiStZ+GEU323irC22y2od0AVHVssKGG/tLWL4hUViMYTWx
|
||||
Yj6zpzovM2BrIzzAvjIQ0DAAFcVEr5lyvCduz83NiqQH6M/onTGXM01nXhUoIZnZ
|
||||
QV0O4Rsnsi5VcnP8pUI3aKkxZ7rkVb/AktNTe7rX7xRztdTt0BGOOVR0u7Q97cQ6
|
||||
kjXbrwIf6cura9J1FyyUeGA7B9z1huYm5XslpkhJ3ILd8DHBvKv5n7wDNuNbocGo
|
||||
FrVza3sruJDwQzJSDmUyC+xoRv112X2R51sjOe9j17zxfSFqjVckWXTvI+ZvoLoX
|
||||
g3pbAMQ06bmXpDYcg6er9NG/eycZvpYbSCCRS+TRDLSMshg/F6JoGVcHJtCNkTl9
|
||||
Sl70z3+/ytjdOl/+XYQ6sEUzd4qtBrslCBX78y4elcO8qQIBWBkvYr+BI09MJyW0
|
||||
C+Zae0G3mifyhWCfqm6Eq5lKhVhO9bF5rL6E+n4QSYK4PQ6GqAkNZ8/yVo8YcI0Z
|
||||
fYNOIjQTEz9qqTlwDS8PTO0fFZLeiB64P86jf6m6WNhuQ+CC3BP6HP5ACImhy26N
|
||||
ctzWkY+lExmP/1o=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,52 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDAKnFa4rgzqERa
|
||||
AHGh1sKLy++AAqFKBIJAtK+8YPz45jtHElT0BJfE2kOo3wZ+iucbE+dg4yfrKkD4
|
||||
bfa6zbwCYP2hhEHT6w1tI1BMZCrgY+eqEjvgTYLb5oq9J9zs9GawmaW+XCnUVAOW
|
||||
dOFU3MQlBcbnFJrDAwz4BjmaRFGVMm95Nk2eGD3cObuqQUVMR95YgZxHyX5M7F8j
|
||||
IAdUO6DoB+fTMQEEudkU/po/Xp2Xx3GJavRDMPoZ4HEGpQlPmU5JzeMAHVVx6Zj8
|
||||
Ls45eGX4qwf74OXbAmBRntpjDIz8qQVAl11QakM2ZW2ALEkIqRU6tIiiyk/PO3fu
|
||||
q11IMGNlx1oL9L8ClSsgsA3UX7YmjpFmvDJX0anBHiUKonHXzroPu4opm9RpUhYk
|
||||
UTiNLoQxOWwzFeBmfg5vzJ/fd/w/796CU56vCRctG5UpXWD9dQxjXkWVZIoRvuIe
|
||||
GIIW2qy+NkJAOuF0aTAphm4Hns9yT4EvfJIvLSZKQyFu6oIfvR4e1RTlZmzgVUQ+
|
||||
O50Kk7z6JwntDkFburi/HnJ8I5YS6zeLrNykKhTPOZu8SfFm5UA71SJZorzftE3R
|
||||
6jz9iPj2Ev48F7rCWhMqfGtiIp70ASx9xv+5ZTQPLEQkvH+R4JYOqWUwmJhtiso6
|
||||
z8a8zB+3MbGXf2VTurQ1nB6+C9SaIQIDAQABAoICAAUfLHnGaK/xyG6Yq+6rFXDZ
|
||||
7OZ9y3Q4Vgto9XUhJaTxp/tPNQXy+GOWCK8f0KG/TIUXfPeDXm1BaU1lyul0MdJj
|
||||
aOcs6f8+uNMjmZCLq+vipcpN5ZwnHLmFNcXwYc5L/k+vLLB77t56pcTVFStone65
|
||||
rnE6xpwzgN08dz0SHJWMLFXv+VYhIcb0n/ib+D+d+8Jio/py16OvuiJqyF85nqKI
|
||||
kWh62iLY3D0Xwx80w6EHvxIxUFTZIZVeesy5Sdv+A0zqyKXYYk5gskPocHUuLROZ
|
||||
ZWNUfXPeppgOUpZIv5o9XAc6cZGC34s73VUI+SJatVbk+G5UU5e7p0jsdtZFeeSw
|
||||
94t5Cw9O4E5Rbu0I7iQxuC215m20DRQyh3sHXB4ZtiYgYle7DPsCU5yzQxoo6+Z3
|
||||
3sOIwO79A4epKcHU8JeZChZYi4CAC/OGhjH5WFRA0LoVdKnJmyfIKJMCvwdD05hA
|
||||
L1lHSK43+gFQRz07qRxSLNX6E1E8pmMfnv6iah9DPmQz8uQUTPuPa9dHvD8lpm+m
|
||||
rSGUeNCi7cnlPMszXrS1Q5gDL7nuFA66p1nA5LcMWFrllyj79Snbp7JB+2Yry0G1
|
||||
9pfVsMB2DjKa282iXWjzqiDQAefRSqBKbrqo0m6Qzd+VC4AFeDXieo7/RvecQbzM
|
||||
gbRw5hdX3FxIWiJJHK5FAoIBAQDtX7Bwz5LPqfgq/RmXcKODTqbhVIbMFX/6kWYD
|
||||
zfI2PeRyb85do7uqZLMi81qBS8sLd18RuVPO+FtWjC8IrBWQW1Df8RN3cZsX/azP
|
||||
bi+FbauJG1RO12xVVJ2utjaSPCztGKc4jsTip/Ox4rIpYAv6qdEwIR9TtzGTL7MC
|
||||
GeFWuq+Q7HjCIDC46UNnbOn/ZwZpzO2etvdbjgPeRl+HOGom0b8dDbuIsyHssuUh
|
||||
MnJRh+tWlkdLDjNgDOMGZdERc6b+VEAUKrsi/alhR6NSgQ/J2hUqcumgPjhNRWlL
|
||||
2EUPQcPFT/0nTsPGfrnmG1cgHlhewMyrVWaWbai2o+avi7gVAoIBAQDPPqAqM1CQ
|
||||
Za/UuG7i61LrjLK5+wDEGYMvIPP3sHhTPIzQowqddV5b3kjusXFA3lsAYSYDRyub
|
||||
Qvlt/dpSTkqYt1kTjvhxX98baWTz6Ifu9qGHdAKvVeQz+GQLQsUCAKjEcJLe723O
|
||||
01oZMuvOICa4VMNTndOCSYA6rj5IzgCSetGFJH/1l+1cw4Ty0I1XLBoxtd7ZHUOL
|
||||
LpFUzyqeprT497rvCS9fitCNo8nlhGCeNonhNXtMHoYapf9jZWweNExfsZkxPUYn
|
||||
RORtaKDtWTzWCEpT00uCNiEq1fnBnQVWC9952lkmJJGX+DBd28cZncayn8DiRgtE
|
||||
MgLhiPbC1/DdAoIBAQDWqfmDG4SV/gzqu3bgSEHm5kLudaVtfGf/LmxGNoG7Yd9t
|
||||
SgUjE4YMQwLpMZAAa42UsgAspB/RhTH8o8uJGS5rHNmXm4lSNE/gs00LTYb2MEa3
|
||||
OBrXLePPizUKIchaGMc8J1ZaRGpy2xX8IZmi5zQOHHKMBvgwmfPgOnu1A7iP2UYa
|
||||
Vm+ihr/pCTImnw1CJmZbWbLuHGK3zkdbROLxAyikMHyXhqq4pd6hEus5SUiuyK8f
|
||||
38lba900GJNaXsth7+fuCjzkv/v6WdFKoyFk5Ehl763CdKcoffwmjQX7S/qwIHYt
|
||||
wveaC5XqtQHiwYjaB/c8wTiWHSYoFcVEXhapXD49AoIBAGO7LXRhcVmrtavWfXYx
|
||||
7Jtx/+hEBCBabqjVYNJHWAAmWVVlwIv5mUj4dXx4Y5nl50ENfy+Apll3J3Vo/hEx
|
||||
n/BR+0nQOXrFuiYE2BIQSqoSgbUffNNHsCPaWrqYjRnjm1O9INCrw9oOF4Oc36E4
|
||||
E6oNEFpCzzAZkt2qUErU562V8RHzeKiFTjKf3HDFL99NV+WoMe+lh4WJCWMYttfW
|
||||
Pay5+nlhoCyILsiak2QiCiTY9g0soYMGBV75lycL/bWjUUWTv7bUV9g57PtBqH/P
|
||||
u9j78Fjai7HDvr5Qbnpfx9uapxaKnvAeFPpe81eXlf6b0KzQ+A9ryawtrPk/4g/t
|
||||
vvkCggEBAL46J1suxrPtZFgfhnAQRhmm01nnFhPBp3mVVT1lOu2UMwyAbkaJcyEh
|
||||
OAGel159ed+cg1SSpQLD8lK4L0aUUd8+hiN5bLDXNXV3leD3ZXpl9byUxvNGS/UA
|
||||
Wnm2JTCt20R4cuhp9LjcCWgCiqupblJcbjwga2fKRvM7VquVUBcoyoJcV2IT33iQ
|
||||
qa96afyCoGYExoQ+9SWettHV+FTQEKo+mfvnHdGRgpH49prIFYTKusMxOi1EdqH4
|
||||
MKKj4mvxi2WBMT31OPTNlpn7RkOzo7+RTIz3fC+sqV6X+joT64z5GkWw2kJlvuJS
|
||||
OcJYMreefpqPopJhaO1krD+TWml5ciw=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -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"
|
||||
|
||||
+66
-4
@@ -78,7 +78,9 @@
|
||||
expires_at :: calendar:datetime(),
|
||||
revoked :: boolean(),
|
||||
created_at :: calendar:datetime(),
|
||||
updated_at :: calendar:datetime()
|
||||
updated_at :: calendar:datetime(),
|
||||
device_name :: binary(),
|
||||
user_agent :: binary()
|
||||
}).
|
||||
|
||||
%% ------------------- Календари ---------------------------------------
|
||||
@@ -103,10 +105,27 @@
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% PK = id. Уникальность пары calendar_id+user_id — в logic.
|
||||
-record(calendar_share, {
|
||||
id :: binary(),
|
||||
calendar_id :: binary(),
|
||||
user_id :: binary(),
|
||||
rights :: read | write | admin
|
||||
rights :: read | write | admin,
|
||||
mirror_to_default :: boolean()
|
||||
}).
|
||||
|
||||
%% Приглашения к calendar_share (соредактор / заместитель).
|
||||
-record(calendar_share_invite, {
|
||||
id :: binary(),
|
||||
calendar_id :: binary(),
|
||||
inviter_id :: binary(),
|
||||
invitee_user_id :: binary(), % <<>> если ещё неизвестен
|
||||
invitee_email :: binary(), % <<>> если только user_id
|
||||
rights :: read | write | admin,
|
||||
status :: pending | accepted | declined | expired | cancelled,
|
||||
token :: binary(),
|
||||
created_at :: calendar:datetime(),
|
||||
expires_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% Follow чужого календаря (не путать с платной subscription / calendar_share)
|
||||
@@ -192,7 +211,7 @@
|
||||
id :: binary(),
|
||||
event_id :: binary(), % ссылка на конкретный экземпляр события
|
||||
user_id :: binary(),
|
||||
status :: pending | confirmed | cancelled,
|
||||
status :: pending | confirmed | cancelled | expired,
|
||||
notes :: binary(),
|
||||
reminder_sent :: boolean(),
|
||||
confirmed_at :: calendar:datetime(),
|
||||
@@ -200,6 +219,16 @@
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% ------------------- Лист ожидания (commercial, settings.waitlist_enabled) ----
|
||||
-record(waitlist_entry, {
|
||||
id :: binary(),
|
||||
event_id :: binary(),
|
||||
user_id :: binary(),
|
||||
status :: waiting | promoted | left,
|
||||
created_at :: calendar:datetime(),
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% ------------------- Отзывы ------------------------------------------
|
||||
-record(review, {
|
||||
id :: binary(),
|
||||
@@ -319,13 +348,24 @@
|
||||
id :: binary(),
|
||||
user_id :: binary(),
|
||||
type :: booking_confirmed | event_reminder | event_cancelled |
|
||||
specialist_invite | custom,
|
||||
specialist_invite | waitlist_promoted | custom,
|
||||
title :: binary(),
|
||||
body :: binary(),
|
||||
is_read :: boolean(),
|
||||
created_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% Web Push subscription (Back#75). Prefs live in user.preferences:
|
||||
%% notify_email / notify_push (boolean, default true).
|
||||
-record(push_subscription, {
|
||||
id :: binary(),
|
||||
user_id :: binary(),
|
||||
endpoint :: binary(),
|
||||
p256dh :: binary(),
|
||||
auth :: binary(),
|
||||
created_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% Upsert-счётчики админ-статистики (абсолютные значения).
|
||||
%% key: atom() | {atom(), atom()} | {atom(), atom(), atom()}
|
||||
%% users_total-подобные totals не храним (table_info),
|
||||
@@ -364,7 +404,29 @@
|
||||
cpu_utilization :: float()
|
||||
}).
|
||||
|
||||
-record(month_snapshot, {
|
||||
id :: {binary(), binary()},
|
||||
calendar_id :: binary(),
|
||||
year_month :: binary(),
|
||||
payload :: binary(),
|
||||
storage :: mnesia | file,
|
||||
file_rel :: binary(),
|
||||
created_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
-record(schema_migration, {
|
||||
version :: string(),
|
||||
applied_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% Пилотные метрики ИИ-подсказок (PRODUCT-AI, Spec#22): дневные счётчики
|
||||
%% shown/confirm/dismiss по паттерну. Id = {user_id, day, pattern_key, kind}.
|
||||
-record(ai_hint_metric, {
|
||||
id :: {binary(), binary(), binary(), binary()},
|
||||
user_id :: binary(),
|
||||
day :: binary(),
|
||||
pattern_key :: binary(),
|
||||
kind :: binary(),
|
||||
count :: non_neg_integer(),
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{{heading}}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#F4F1EA;">
|
||||
<span style="display:none!important;visibility:hidden;opacity:0;height:0;width:0;overflow:hidden;">{{preheader}}</span>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#F4F1EA;padding:32px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background:#FFFFFF;border-radius:16px;overflow:hidden;border:1px solid #E4E0D6;">
|
||||
<tr>
|
||||
<td style="background:#324A5F;padding:22px 28px;">
|
||||
<p style="margin:0;font-family:Georgia,'Times New Roman',serif;font-size:22px;letter-spacing:0.04em;color:#FFFFFF;">CalenTIQ</p>
|
||||
<p style="margin:6px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#ABC5B5;">One calendar. Three modes for your time.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height:4px;background:#2F7A68;font-size:0;line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px 28px 8px;font-family:Arial,Helvetica,sans-serif;color:#1A1A1A;">
|
||||
<h1 style="margin:0 0 12px;font-size:22px;line-height:1.3;font-weight:700;color:#324A5F;">{{heading}}</h1>
|
||||
<p style="margin:0 0 20px;font-size:15px;line-height:1.55;color:#3A3A3A;">{{lead}}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="border-radius:10px;background:#FF6A3D;">
|
||||
<a href="{{cta_url}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:700;color:#FFFFFF;text-decoration:none;">{{cta_label}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:20px 0 0;font-size:12px;line-height:1.5;color:#6B6B6B;">Если кнопка не открывается, скопируйте ссылку:<br/>
|
||||
<a href="{{cta_url}}" style="color:#2F7A68;word-break:break-all;">{{cta_url}}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 28px 28px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:1.5;color:#8A8A8A;">
|
||||
{{footnote}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
+8
-2
@@ -10,7 +10,8 @@
|
||||
{meck, "0.9.2"},
|
||||
{gun, "2.2.0"},
|
||||
{prometheus_cowboy, "0.2.0"},
|
||||
{cowboy_swagger, "2.8.0"}
|
||||
{cowboy_swagger, "2.8.0"},
|
||||
{gen_smtp, "1.3.0"}
|
||||
]}.
|
||||
|
||||
{shell, [
|
||||
@@ -27,7 +28,7 @@
|
||||
{profiles, [
|
||||
{prod, [
|
||||
{relx, [
|
||||
{release, {eventhub, "0.0.1"}, [eventhub, sasl,cowboy, jose, jsx, argon2, runtime_tools, os_mon, prometheus_cowboy]},
|
||||
{release, {eventhub, "0.0.1"}, [eventhub, sasl, cowboy, jose, jsx, argon2, gen_smtp, ssl, runtime_tools, os_mon, prometheus_cowboy]},
|
||||
{include_erts, true},
|
||||
{extended_start_script, true},
|
||||
{sys_config, "./src/config/sys.config"}
|
||||
@@ -49,6 +50,11 @@
|
||||
{verbose, true} % Print more info to console
|
||||
]}.
|
||||
|
||||
{eunit_opts, [
|
||||
verbose,
|
||||
{report, {eunit_surefire, [{dir, "logs/test/eunit"}]}}
|
||||
]}.
|
||||
|
||||
{ct_compile_opts, [
|
||||
{i, "include"}, % Include directory
|
||||
{d, 'DEBUG'} % Define macros
|
||||
|
||||
@@ -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,22 +2,29 @@
|
||||
# Full API CT suite against live stand (remote) + smoke-core on stand host.
|
||||
# Usage: run-stand-api-tests.sh <ift|stage>
|
||||
# Requires: ADMIN_* from load-stand-api-env.sh; SSH_* for smoke-core on stand.
|
||||
#
|
||||
# Litter cleanup: after each CT suite, api_test_runner:cleanup_tracked/0
|
||||
# soft-deletes tracked calendars/users via admin API (and @test.local safety-net).
|
||||
# Skip teardown: CT_KEEP_DATA=1 (data remains on the stand).
|
||||
# Soft-delete ≠ hard wipe; full clean slate = ops Mnesia volume wipe.
|
||||
set -euo pipefail
|
||||
|
||||
STAND="${1:?stand: ift|stage}"
|
||||
case "${STAND}" in
|
||||
ift)
|
||||
API_HOST="${API_HOST:-https://api.ift.eventhub.local}"
|
||||
ADMIN_API_HOST="${ADMIN_API_HOST:-https://admin-api.ift.eventhub.local}"
|
||||
WS_HOST="${WS_HOST:-wss://ws.ift.eventhub.local}"
|
||||
ADMIN_WS_HOST="${ADMIN_WS_HOST:-wss://admin-ws.ift.eventhub.local}"
|
||||
[[ -n "${API_HOST:-}" ]] || API_HOST="https://api.ift.eventhub.local"
|
||||
[[ -n "${ADMIN_API_HOST:-}" ]] || ADMIN_API_HOST="https://admin-api.ift.eventhub.local"
|
||||
[[ -n "${WS_HOST:-}" ]] || WS_HOST="wss://ws.ift.eventhub.local"
|
||||
[[ -n "${ADMIN_WS_HOST:-}" ]] || ADMIN_WS_HOST="wss://admin-ws.ift.eventhub.local"
|
||||
DEVOPS_SMOKE="/opt/eventhub-ift/devops/scripts/smoke-core.sh"
|
||||
;;
|
||||
stage)
|
||||
API_HOST="${API_HOST:-https://api.stage.eventhub.local}"
|
||||
ADMIN_API_HOST="${ADMIN_API_HOST:-https://admin-api.stage.eventhub.local}"
|
||||
WS_HOST="${WS_HOST:-wss://ws.stage.eventhub.local}"
|
||||
ADMIN_WS_HOST="${ADMIN_WS_HOST:-wss://admin-ws.stage.eventhub.local}"
|
||||
# Публичный stage (канон STAGE-BROWSER.md). Пустые E2E_STAGE_* из CI
|
||||
# не должны оставлять API_HOST="" (иначе CT бьёт в *.eventhub.local).
|
||||
[[ -n "${API_HOST:-}" ]] || API_HOST="https://api.stage.calentiq.com"
|
||||
[[ -n "${ADMIN_API_HOST:-}" ]] || ADMIN_API_HOST="https://admin-api.stage.calentiq.com"
|
||||
[[ -n "${WS_HOST:-}" ]] || WS_HOST="wss://ws.stage.calentiq.com"
|
||||
[[ -n "${ADMIN_WS_HOST:-}" ]] || ADMIN_WS_HOST="wss://admin-ws.stage.calentiq.com"
|
||||
DEVOPS_SMOKE="/opt/eventhub-stage/devops/scripts/smoke-core.sh"
|
||||
;;
|
||||
*)
|
||||
@@ -44,6 +51,27 @@ echo " ADMIN_API_HOST=${ADMIN_API_HOST}"
|
||||
echo " WS_HOST=${WS_HOST}"
|
||||
echo " ADMIN_WS_HOST=${ADMIN_WS_HOST}"
|
||||
|
||||
# После deploy-stage контейнер стенда может ещё стартовать; traefik в
|
||||
# это время отвечает 502/503, и первые сьюиты падают с {badmatch,{ok,502}}.
|
||||
# Ждём, пока HTTP-фронт приложений начнёт отвечать (любой код, кроме 5xx).
|
||||
for probe in "${API_HOST}/v1/calendars" "${ADMIN_API_HOST}/v1/admin/health"; do
|
||||
ready=0
|
||||
for i in $(seq 1 30); do
|
||||
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "${probe}" 2>/dev/null || echo 000)
|
||||
case "${code}" in
|
||||
5*) : ;;
|
||||
000) : ;;
|
||||
*) ready=1; break ;;
|
||||
esac
|
||||
sleep 5
|
||||
done
|
||||
if [[ "${ready}" -eq 1 ]]; then
|
||||
echo " readiness OK: ${probe} (HTTP ${code})"
|
||||
else
|
||||
echo "::warning::${probe} не отвечает после 150s — стартуем сьюиты как есть"
|
||||
fi
|
||||
done
|
||||
|
||||
# Docker Desktop/WSL: --network=host часто НЕ наследует /etc/hosts стенда,
|
||||
# из‑за чего CT remote не резолвит *.ift.eventhub.local (health timeout 30s).
|
||||
docker_add_hosts=()
|
||||
@@ -114,8 +142,13 @@ if [[ "${have_image}" -ne 1 ]]; then
|
||||
.
|
||||
fi
|
||||
|
||||
CT_OUT="${CT_REPORT_DIR:-$(pwd)/ci-out/ct-${STAND}}"
|
||||
mkdir -p "${CT_OUT}"
|
||||
|
||||
set +e
|
||||
docker run --rm --network=host \
|
||||
${docker_add_hosts[@]+"${docker_add_hosts[@]}"} \
|
||||
-v "${CT_OUT}:/app/logs/test/ct" \
|
||||
-e CT_MODE=remote \
|
||||
-e "API_HOST=${API_HOST}" \
|
||||
-e "ADMIN_API_HOST=${ADMIN_API_HOST}" \
|
||||
@@ -129,6 +162,20 @@ docker run --rm --network=host \
|
||||
-e "ADMIN_MODER_PASSWORD=${ADMIN_MODER_PASSWORD:-}" \
|
||||
-e "ADMIN_SUPPORT_EMAIL=${ADMIN_SUPPORT_EMAIL:-}" \
|
||||
-e "ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD:-}" \
|
||||
-e "CT_KEEP_DATA=${CT_KEEP_DATA:-}" \
|
||||
"${IMAGE}"
|
||||
CT_RC=$?
|
||||
set -e
|
||||
|
||||
if [[ -d "${CT_OUT}" ]] && compgen -G "${CT_OUT}/*" >/dev/null; then
|
||||
RUN_ID="${GITHUB_RUN_NUMBER:-local}"
|
||||
mkdir -p /var/eventhub/ci-reports 2>/dev/null || true
|
||||
bash scripts/publish-ci-report.sh EventHubBack "${RUN_ID}" "e2e-${STAND}" "${CT_OUT}" || true
|
||||
fi
|
||||
|
||||
if [[ "${CT_RC}" -ne 0 ]]; then
|
||||
echo "=== API CT FAILED (${STAND}) rc=${CT_RC} ===" >&2
|
||||
exit "${CT_RC}"
|
||||
fi
|
||||
|
||||
echo "=== API CT passed (${STAND}) ==="
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
-module(archive_controller).
|
||||
-compile([{nowarn_deprecated_function, [{slave, start, 3}, {slave, stop, 1}]}]).
|
||||
-include("records.hrl").
|
||||
-export([archive_day/1]).
|
||||
|
||||
archive_day(Day) ->
|
||||
ArchiveNode = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
|
||||
case start_archive_node(ArchiveNode) of
|
||||
{ok, PeerOrSlave} ->
|
||||
try
|
||||
rpc:call(ArchiveNode, mnesia, create_schema, [[ArchiveNode]]),
|
||||
rpc:call(ArchiveNode, mnesia, start, []),
|
||||
rpc:call(ArchiveNode, code, ensure_loaded, [archive_fetcher]),
|
||||
create_archive_table(ArchiveNode, event,
|
||||
[id, calendar_id, start_time, end_time, event_type,
|
||||
master_id, specialist_id, title, description,
|
||||
attachments, edit_history, status, created_at, updated_at],
|
||||
[calendar_id, start_time, event_type, master_id,
|
||||
specialist_id, status]),
|
||||
create_archive_table(ArchiveNode, booking,
|
||||
[id, event_id, user_id, status, confirmed_at,
|
||||
created_at, updated_at, notes, reminder_sent],
|
||||
[event_id, user_id, status]),
|
||||
create_archive_table(ArchiveNode, review,
|
||||
[id, user_id, target_type, target_id, rating, comment,
|
||||
status, reason, created_at, updated_at, likes,
|
||||
dislikes, edited_at], []),
|
||||
create_archive_table(ArchiveNode, report,
|
||||
[id, reporter_id, target_type, target_id, reason,
|
||||
status, created_at, resolved_at, resolved_by], []),
|
||||
ok = transfer_data(ArchiveNode, Day),
|
||||
io:format("Archived day ~s successfully.~n", [Day])
|
||||
after
|
||||
stop_archive_node(PeerOrSlave, ArchiveNode)
|
||||
end;
|
||||
{error, Reason} ->
|
||||
io:format("Failed to start archive node: ~p~n", [Reason]),
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
start_archive_node(Node) ->
|
||||
case os:getenv("CLUSTER_MODE") of
|
||||
"true" ->
|
||||
peer:start_link(#{name => Node, host => host()});
|
||||
_ ->
|
||||
CookieStr = atom_to_list(erlang:get_cookie()),
|
||||
case slave:start(host(), Node, "-setcookie " ++ CookieStr) of
|
||||
{ok, Slave} -> {ok, Slave};
|
||||
Error -> Error
|
||||
end
|
||||
end.
|
||||
|
||||
stop_archive_node(PeerOrSlave, Node) ->
|
||||
case os:getenv("CLUSTER_MODE") of
|
||||
"true" -> peer:stop(PeerOrSlave);
|
||||
_ -> slave:stop(Node)
|
||||
end.
|
||||
|
||||
create_archive_table(Node, Tab, Attributes, Indices) ->
|
||||
Opts = [{disc_only_copies, [Node]},
|
||||
{attributes, Attributes},
|
||||
{type, set}] ++ case Indices of
|
||||
[] -> [];
|
||||
_ -> [{index, Indices}]
|
||||
end,
|
||||
rpc:call(Node, mnesia, create_table, [Tab, Opts]).
|
||||
|
||||
transfer_data(ArchiveNode, Day) ->
|
||||
Tables = [event, booking, review, report],
|
||||
lists:foreach(fun(Tab) ->
|
||||
Records = fetch_records(Tab, Day),
|
||||
rpc:call(ArchiveNode, mnesia, transaction, [
|
||||
fun() -> [mnesia:write(Rec) || Rec <- Records] end
|
||||
]),
|
||||
mnesia:transaction(fun() ->
|
||||
[mnesia:delete({Tab, element(2, Rec)}) || Rec <- Records]
|
||||
end)
|
||||
end, Tables).
|
||||
|
||||
fetch_records(event, Day) ->
|
||||
Start = list_to_binary(Day ++ " 00:00:00"),
|
||||
End = list_to_binary(Day ++ " 23:59:59"),
|
||||
mnesia:dirty_select(event, [{#event{start_time = '$1', _ = '_'},
|
||||
[{'>=','$1', Start},{'=<','$1', End}],
|
||||
['$_']}]);
|
||||
fetch_records(booking, Day) ->
|
||||
mnesia:dirty_select(booking, [{#booking{created_at = '$1', _ = '_'},
|
||||
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
|
||||
['$_']}]);
|
||||
fetch_records(review, Day) ->
|
||||
mnesia:dirty_select(review, [{#review{created_at = '$1', _ = '_'},
|
||||
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
|
||||
['$_']}]);
|
||||
fetch_records(report, Day) ->
|
||||
mnesia:dirty_select(report, [{#report{created_at = '$1', _ = '_'},
|
||||
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
|
||||
['$_']}]).
|
||||
|
||||
host() ->
|
||||
{ok, Name} = inet:gethostname(),
|
||||
Name.
|
||||
@@ -1,12 +0,0 @@
|
||||
-module(archive_fetcher).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([fetch/3]).
|
||||
|
||||
fetch(CalendarId, Year, Month) ->
|
||||
Start = {{Year, Month, 1}, {0, 0, 0}},
|
||||
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}},
|
||||
mnesia:dirty_select(event, [{#event{calendar_id = CalendarId,
|
||||
start_time = '$1', _ = '_'},
|
||||
[{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}],
|
||||
['$_']}]).
|
||||
@@ -1,190 +0,0 @@
|
||||
-module(archive_manager).
|
||||
-behaviour(gen_server).
|
||||
-compile([{nowarn_deprecated_function, [{slave, start, 3}]}]).
|
||||
|
||||
%% Peer start must not run inside handle_call: peer:start_it timeout
|
||||
%% exits the gen_server and cascades via infra_sup (seen under IFT load).
|
||||
%% Starts are async single-flight; callers wait or get {error, _}.
|
||||
|
||||
-export([start_link/0, get_archive_node/1]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
||||
terminate/2, code_change/3]).
|
||||
|
||||
-define(IDLE_MS, 30000).
|
||||
-define(PEER_CONN_MS, 15000).
|
||||
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
get_archive_node(Day) ->
|
||||
gen_server:call(?MODULE, {get_node, Day}).
|
||||
|
||||
init([]) ->
|
||||
process_flag(trap_exit, true),
|
||||
{ok, #{nodes => #{}, starting => #{}}}.
|
||||
|
||||
handle_call({get_node, Day}, From, State) ->
|
||||
Node = archive_node_name(Day),
|
||||
Nodes = maps:get(nodes, State),
|
||||
Starting = maps:get(starting, State),
|
||||
case maps:find(Node, Nodes) of
|
||||
{ok, _} ->
|
||||
{reply, {ok, Node}, State#{nodes := touch_node(Nodes, Node)}};
|
||||
error ->
|
||||
case is_node_alive(Node) of
|
||||
true ->
|
||||
{reply, {ok, Node}, State#{nodes := register_node(Nodes, Node)}};
|
||||
false ->
|
||||
case maps:find(Day, Starting) of
|
||||
{ok, Waiters} ->
|
||||
{noreply, State#{starting := Starting#{Day => [From | Waiters]}}};
|
||||
error ->
|
||||
spawn_starter(Day),
|
||||
{noreply, State#{starting := Starting#{Day => [From]}}}
|
||||
end
|
||||
end
|
||||
end;
|
||||
handle_call(_Req, _From, State) ->
|
||||
{reply, {error, unknown_call}, State}.
|
||||
|
||||
handle_cast(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info({start_done, Day, Result}, State) ->
|
||||
Starting = maps:get(starting, State),
|
||||
Waiters = maps:get(Day, Starting, []),
|
||||
NewStarting = maps:remove(Day, Starting),
|
||||
Nodes = maps:get(nodes, State),
|
||||
case Result of
|
||||
{ok, Node} ->
|
||||
reply_all(Waiters, {ok, Node}),
|
||||
{noreply, State#{
|
||||
nodes := register_node(Nodes, Node),
|
||||
starting := NewStarting
|
||||
}};
|
||||
{error, Reason} ->
|
||||
reply_all(Waiters, {error, Reason}),
|
||||
{noreply, State#{starting := NewStarting}}
|
||||
end;
|
||||
handle_info({release, Node}, State) ->
|
||||
Nodes = maps:get(nodes, State),
|
||||
case maps:find(Node, Nodes) of
|
||||
{ok, #{last_access := Last, timer := _Old}} ->
|
||||
Idle = erlang:convert_time_unit(
|
||||
erlang:monotonic_time() - Last, native, millisecond),
|
||||
if Idle >= ?IDLE_MS ->
|
||||
stop_archive_node(Node),
|
||||
{noreply, State#{nodes := maps:remove(Node, Nodes)}};
|
||||
true ->
|
||||
{noreply, State#{nodes := touch_node(Nodes, Node)}}
|
||||
end;
|
||||
error ->
|
||||
{noreply, State}
|
||||
end;
|
||||
handle_info({'EXIT', _Pid, _Reason}, State) ->
|
||||
{noreply, State};
|
||||
handle_info(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Internal
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
spawn_starter(Day) ->
|
||||
Parent = self(),
|
||||
spawn(fun() ->
|
||||
Result =
|
||||
try start_archive_node(Day) of
|
||||
Ok -> Ok
|
||||
catch
|
||||
exit:{timeout, _} -> {error, peer_timeout};
|
||||
exit:Reason -> {error, {exit, Reason}};
|
||||
error:Reason -> {error, {error, Reason}};
|
||||
throw:Reason -> {error, {throw, Reason}}
|
||||
end,
|
||||
Parent ! {start_done, Day, Result}
|
||||
end).
|
||||
|
||||
reply_all(Waiters, Reply) ->
|
||||
lists:foreach(fun(From) -> gen_server:reply(From, Reply) end, Waiters).
|
||||
|
||||
register_node(Nodes, Node) ->
|
||||
case maps:find(Node, Nodes) of
|
||||
{ok, #{timer := OldRef}} ->
|
||||
_ = erlang:cancel_timer(OldRef),
|
||||
ok;
|
||||
error ->
|
||||
ok
|
||||
end,
|
||||
Ref = erlang:send_after(?IDLE_MS, self(), {release, Node}),
|
||||
Nodes#{Node => #{timer => Ref, last_access => erlang:monotonic_time()}}.
|
||||
|
||||
touch_node(Nodes, Node) ->
|
||||
register_node(Nodes, Node).
|
||||
|
||||
is_node_alive(Node) ->
|
||||
case net_adm:ping(Node) of
|
||||
pong -> true;
|
||||
pang -> false
|
||||
end.
|
||||
|
||||
start_archive_node(Day) ->
|
||||
Node = archive_node_name(Day),
|
||||
case start_archive_peer(Node) of
|
||||
{ok, _} ->
|
||||
case ensure_archive_node_ready(Node) of
|
||||
ok -> {ok, Node};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
start_archive_peer(Node) ->
|
||||
case os:getenv("CLUSTER_MODE") of
|
||||
"true" ->
|
||||
case peer:start_link(#{
|
||||
name => Node,
|
||||
host => host(),
|
||||
connection_timeout => ?PEER_CONN_MS
|
||||
}) of
|
||||
{ok, _} -> {ok, Node};
|
||||
{error, {already_started, _}} -> {ok, Node};
|
||||
Error -> Error
|
||||
end;
|
||||
_ ->
|
||||
CookieStr = atom_to_list(erlang:get_cookie()),
|
||||
case slave:start(host(), Node, "-setcookie " ++ CookieStr) of
|
||||
{ok, _} -> {ok, Node};
|
||||
{error, {already_running, _}} -> {ok, Node};
|
||||
Error -> Error
|
||||
end
|
||||
end.
|
||||
|
||||
ensure_archive_node_ready(Node) ->
|
||||
case rpc:call(Node, mnesia, start, [], 5000) of
|
||||
ok ->
|
||||
case rpc:call(Node, code, ensure_loaded, [archive_fetcher], 5000) of
|
||||
{module, archive_fetcher} -> ok;
|
||||
{error, Reason} -> {error, Reason};
|
||||
{badrpc, Reason} -> {error, Reason}
|
||||
end;
|
||||
{badrpc, Reason} -> {error, Reason};
|
||||
Other -> {error, Other}
|
||||
end.
|
||||
|
||||
archive_node_name(Day) ->
|
||||
list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()).
|
||||
|
||||
stop_archive_node(Node) ->
|
||||
rpc:cast(Node, init, stop, []).
|
||||
|
||||
host() ->
|
||||
{ok, Name} = inet:gethostname(),
|
||||
Name.
|
||||
@@ -1,57 +0,0 @@
|
||||
-module(calendar_html_renderer).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([render_month/3, init_cache/0]).
|
||||
|
||||
init_cache() ->
|
||||
case ets:info(archive_html_cache) of
|
||||
undefined ->
|
||||
ets:new(archive_html_cache, [set, public, named_table, {keypos, 1}]);
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
render_month(Year, Month, Events) ->
|
||||
Key = {Year, Month},
|
||||
try ets:lookup(archive_html_cache, Key) of
|
||||
[{Key, Html}] -> Html;
|
||||
[] ->
|
||||
Html = generate_html(Year, Month, Events),
|
||||
ets:insert(archive_html_cache, {Key, Html}),
|
||||
Html
|
||||
catch
|
||||
_:_ ->
|
||||
generate_html(Year, Month, Events)
|
||||
end.
|
||||
|
||||
generate_html(Year, Month, Events) ->
|
||||
DaysInMonth = calendar:last_day_of_the_month(Year, Month),
|
||||
EventsByDay = group_events_by_day(Events),
|
||||
DayList = lists:seq(1, DaysInMonth),
|
||||
DayCells = lists:map(fun(D) ->
|
||||
DayEvents = maps:get(D, EventsByDay, []),
|
||||
["<td>", integer_to_list(D), format_events(DayEvents), "</td>"]
|
||||
end, DayList),
|
||||
["<html><body><table>",
|
||||
"<tr>", DayCells, "</tr>",
|
||||
"</table></body></html>"].
|
||||
|
||||
group_events_by_day(Events) ->
|
||||
lists:foldl(fun(Evt, Acc) ->
|
||||
case Evt of
|
||||
#event{start_time = {{_, _, Day}, {_, _, _}}} ->
|
||||
maps:update_with(Day, fun(List) -> [Evt | List] end, [Evt], Acc);
|
||||
_ -> Acc
|
||||
end
|
||||
end, #{}, Events).
|
||||
|
||||
format_events(Events) ->
|
||||
case Events of
|
||||
[] -> [];
|
||||
_ ->
|
||||
["<ul>",
|
||||
lists:map(fun(#event{title = Title, start_time = {{_, _, _}, {H, M, _}}}) ->
|
||||
["<li>", Title, " (", integer_to_list(H), ":",
|
||||
io_lib:format("~2..0B", [M]), ")</li>"]
|
||||
end, Events),
|
||||
"</ul>"]
|
||||
end.
|
||||
@@ -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},
|
||||
|
||||
+143
-26
@@ -5,17 +5,31 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_auth_session).
|
||||
-include("records.hrl").
|
||||
-export([create/3, get/1, rotate/2, revoke/1, revoke_family/1, revoke_all_for_subject/2]).
|
||||
-include_lib("stdlib/include/ms_transform.hrl").
|
||||
-export([
|
||||
create/3, create/5, get/1, rotate/2, revoke/1, revoke_family/1,
|
||||
revoke_all_for_subject/2, list_active_for_subject/2, revoke_others_for_subject/3
|
||||
]).
|
||||
|
||||
-define(REFRESH_TTL_SECONDS, 30 * 24 * 3600).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Создать новую сессию устройства/клиента.
|
||||
%%% @doc Создать новую сессию устройства/клиента (без device meta).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary()) ->
|
||||
{ok, #auth_session{}}.
|
||||
create(SubjectId, SubjectType, ClientType) ->
|
||||
create(SubjectId, SubjectType, ClientType, <<>>, <<>>).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Создать сессию с device_name и user_agent.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary(),
|
||||
DeviceName :: binary(), UserAgent :: binary()) ->
|
||||
{ok, #auth_session{}}.
|
||||
create(SubjectId, SubjectType, ClientType, DeviceName, UserAgent) ->
|
||||
Now = calendar:universal_time(),
|
||||
ExpiresAt = calendar:gregorian_seconds_to_datetime(
|
||||
calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS
|
||||
@@ -30,11 +44,18 @@ create(SubjectId, SubjectType, ClientType) ->
|
||||
expires_at = ExpiresAt,
|
||||
revoked = false,
|
||||
created_at = Now,
|
||||
updated_at = Now
|
||||
updated_at = Now,
|
||||
device_name = DeviceName,
|
||||
user_agent = UserAgent
|
||||
},
|
||||
mnesia:dirty_write(Session),
|
||||
%% 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}.
|
||||
{ok, Session};
|
||||
{aborted, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Получить сессию по идентификатору.
|
||||
@@ -56,11 +77,19 @@ get(SessionId) ->
|
||||
{ok, NewJti :: binary(), #auth_session{}} |
|
||||
{error, not_found | expired | revoked | reuse_detected}.
|
||||
rotate(SessionId, PresentedJti) ->
|
||||
case mnesia:dirty_read({auth_session, SessionId}) of
|
||||
%% Transaction prevents concurrent rotate from racing on the same session
|
||||
%% (e.g. two clients presenting the same jti simultaneously).
|
||||
F = fun() ->
|
||||
case mnesia:read(auth_session, SessionId, write) of
|
||||
[Session] ->
|
||||
rotate_session(Session, PresentedJti);
|
||||
[] ->
|
||||
{error, not_found}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, _Reason} -> {error, transaction_failed}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
@@ -69,60 +98,148 @@ rotate(SessionId, PresentedJti) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec revoke(SessionId :: binary()) -> ok | {error, not_found}.
|
||||
revoke(SessionId) ->
|
||||
case mnesia:dirty_read({auth_session, SessionId}) of
|
||||
%% 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:dirty_write(Session#auth_session{revoked = true, updated_at = Now}),
|
||||
dec_counter(Session#auth_session.subject_type),
|
||||
ok;
|
||||
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;
|
||||
{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),
|
||||
%% 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(),
|
||||
lists:foreach(fun(Session) ->
|
||||
RevokedTypes = lists:filtermap(fun(Session) ->
|
||||
case Session#auth_session.revoked of
|
||||
true -> ok;
|
||||
true -> false;
|
||||
false ->
|
||||
mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}),
|
||||
dec_counter(Session#auth_session.subject_type)
|
||||
mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
|
||||
{true, Session#auth_session.subject_type}
|
||||
end
|
||||
end, Sessions),
|
||||
ok.
|
||||
RevokedTypes
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Types} ->
|
||||
%% Decrement counters outside the transaction
|
||||
lists:foreach(fun dec_counter/1, Types),
|
||||
ok;
|
||||
{aborted, _Reason} ->
|
||||
{error, transaction_failed}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Отозвать все сессии субъекта (например после сброса пароля).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec revoke_all_for_subject(SubjectId :: binary(), SubjectType :: user | admin) -> ok.
|
||||
-spec revoke_all_for_subject(SubjectId :: binary(), SubjectType :: user | admin) ->
|
||||
ok | {error, transaction_failed}.
|
||||
revoke_all_for_subject(SubjectId, SubjectType) ->
|
||||
Sessions = mnesia:dirty_match_object(#auth_session{subject_id = SubjectId, _ = '_'}),
|
||||
%% Transaction ensures all matching sessions are revoked atomically.
|
||||
F = fun() ->
|
||||
MS = ets:fun2ms(fun(#auth_session{subject_id = Sid} = S)
|
||||
when Sid =:= SubjectId -> S end),
|
||||
Sessions = mnesia:select(auth_session, MS, write),
|
||||
Now = calendar:universal_time(),
|
||||
lists:foreach(fun(Session) ->
|
||||
Count = lists:foldl(fun(Session, Acc) ->
|
||||
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);
|
||||
mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
|
||||
Acc + 1;
|
||||
false ->
|
||||
ok
|
||||
Acc
|
||||
end
|
||||
end, Sessions),
|
||||
ok.
|
||||
end, 0, Sessions),
|
||||
Count
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Count} ->
|
||||
%% Decrement counters outside the transaction
|
||||
lists:foreach(fun(_) -> dec_counter(SubjectType) end, lists:seq(1, Count)),
|
||||
ok;
|
||||
{aborted, _Reason} ->
|
||||
{error, transaction_failed}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Активные (не revoked, не expired) сессии субъекта.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_active_for_subject(SubjectId :: binary(), SubjectType :: user | admin) ->
|
||||
[#auth_session{}].
|
||||
list_active_for_subject(SubjectId, SubjectType) ->
|
||||
Now = calendar:universal_time(),
|
||||
MS = ets:fun2ms(fun(#auth_session{subject_id = Sid} = S)
|
||||
when Sid =:= SubjectId -> S end),
|
||||
Sessions = mnesia:dirty_select(auth_session, MS),
|
||||
lists:filter(fun(S) ->
|
||||
S#auth_session.subject_type =:= SubjectType andalso
|
||||
S#auth_session.revoked =:= false andalso
|
||||
S#auth_session.expires_at > Now
|
||||
end, Sessions).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Отозвать все активные сессии субъекта, кроме KeepSessionId.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec revoke_others_for_subject(SubjectId :: binary(), SubjectType :: user | admin,
|
||||
KeepSessionId :: binary()) ->
|
||||
{ok, non_neg_integer()} | {error, transaction_failed}.
|
||||
revoke_others_for_subject(SubjectId, SubjectType, KeepSessionId) ->
|
||||
F = fun() ->
|
||||
MS = ets:fun2ms(fun(#auth_session{subject_id = Sid} = S)
|
||||
when Sid =:= SubjectId -> S end),
|
||||
Sessions = mnesia:select(auth_session, MS, write),
|
||||
Now = calendar:universal_time(),
|
||||
lists:foldl(fun(Session, Acc) ->
|
||||
case Session#auth_session.subject_type =:= SubjectType andalso
|
||||
Session#auth_session.revoked =:= false andalso
|
||||
Session#auth_session.session_id =/= KeepSessionId of
|
||||
true ->
|
||||
mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
|
||||
Acc + 1;
|
||||
false ->
|
||||
Acc
|
||||
end
|
||||
end, 0, Sessions)
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Count} ->
|
||||
lists:foreach(fun(_) -> dec_counter(SubjectType) end, lists:seq(1, Count)),
|
||||
{ok, Count};
|
||||
{aborted, _Reason} ->
|
||||
{error, transaction_failed}
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
%% NOTE: Called inside a mnesia:transaction/1 from rotate/2.
|
||||
%% Uses mnesia:read/3 and mnesia:write/1 (not dirty_ variants).
|
||||
rotate_session(Session, PresentedJti) ->
|
||||
case Session#auth_session.revoked of
|
||||
true ->
|
||||
@@ -140,7 +257,7 @@ rotate_session(Session, PresentedJti) ->
|
||||
current_jti = NewJti,
|
||||
updated_at = Now
|
||||
},
|
||||
mnesia:dirty_write(Updated),
|
||||
mnesia:write(Updated),
|
||||
{ok, NewJti, Updated};
|
||||
false ->
|
||||
{error, reuse_detected}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-module(core_calendar).
|
||||
-include("records.hrl").
|
||||
-export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]).
|
||||
-export([create/4, create/5, get_by_id/1, get_by_short_name/1, get_by_id_or_short_name/1,
|
||||
list_by_owner/1, update/2, delete/1]).
|
||||
-export([count_calendars/0, list_all/0]).
|
||||
-export([freeze/2, unfreeze/2]).
|
||||
-export([count_calendars_by_date/2]).
|
||||
@@ -78,6 +79,28 @@ get_by_id(Id) ->
|
||||
[Calendar] -> {ok, Calendar}
|
||||
end.
|
||||
|
||||
%% Unique public slug. Empty name is not a slug (many Default calendars).
|
||||
-spec get_by_short_name(Name :: binary()) -> {ok, #calendar{}} | {error, not_found}.
|
||||
get_by_short_name(<<>>) ->
|
||||
{error, not_found};
|
||||
get_by_short_name(Name) when is_binary(Name) ->
|
||||
case mnesia:dirty_index_read(calendar, Name, #calendar.short_name) of
|
||||
[] ->
|
||||
{error, not_found};
|
||||
Rows ->
|
||||
case [C || C <- Rows, C#calendar.status =:= active] of
|
||||
[C | _] -> {ok, C};
|
||||
[] -> {error, not_found}
|
||||
end
|
||||
end.
|
||||
|
||||
-spec get_by_id_or_short_name(IdOrSlug :: binary()) -> {ok, #calendar{}} | {error, not_found}.
|
||||
get_by_id_or_short_name(IdOrSlug) ->
|
||||
case get_by_id(IdOrSlug) of
|
||||
{ok, C} -> {ok, C};
|
||||
{error, not_found} -> get_by_short_name(IdOrSlug)
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Список активных календарей владельца.
|
||||
%%% @end
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Хранение grants calendar_share (соредактор / заместитель).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_calendar_share).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([upsert/4, get/2, list_by_calendar/1, list_by_user/1,
|
||||
delete/2, update_rights/3, update_mirror/3]).
|
||||
|
||||
-spec upsert(CalendarId :: binary(), UserId :: binary(),
|
||||
Rights :: read | write | admin, Mirror :: boolean()) ->
|
||||
{ok, #calendar_share{}} | {error, term()}.
|
||||
upsert(CalendarId, UserId, Rights, Mirror) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[#calendar_share{} = Existing] ->
|
||||
Updated = Existing#calendar_share{
|
||||
rights = Rights,
|
||||
mirror_to_default = Mirror
|
||||
},
|
||||
mnesia:write(Updated),
|
||||
{ok, Updated};
|
||||
[] ->
|
||||
Rec = #calendar_share{
|
||||
id = infra_utils:generate_id(16),
|
||||
calendar_id = CalendarId,
|
||||
user_id = UserId,
|
||||
rights = Rights,
|
||||
mirror_to_default = Mirror
|
||||
},
|
||||
mnesia:write(Rec),
|
||||
{ok, Rec}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec get(CalendarId :: binary(), UserId :: binary()) ->
|
||||
{ok, #calendar_share{}} | {error, not_found}.
|
||||
get(CalendarId, UserId) ->
|
||||
try find_dirty(CalendarId, UserId) of
|
||||
[Rec] -> {ok, Rec};
|
||||
[] -> {error, not_found}
|
||||
catch
|
||||
exit:{aborted, {no_exists, _}} -> {error, not_found};
|
||||
error:_ -> {error, not_found}
|
||||
end.
|
||||
|
||||
-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_share{}].
|
||||
list_by_calendar(CalendarId) ->
|
||||
try
|
||||
mnesia:dirty_match_object(#calendar_share{calendar_id = CalendarId, _ = '_'})
|
||||
catch
|
||||
exit:{aborted, {no_exists, _}} -> [];
|
||||
error:_ -> []
|
||||
end.
|
||||
|
||||
-spec list_by_user(UserId :: binary()) -> [#calendar_share{}].
|
||||
list_by_user(UserId) ->
|
||||
try
|
||||
mnesia:dirty_match_object(#calendar_share{user_id = UserId, _ = '_'})
|
||||
catch
|
||||
exit:{aborted, {no_exists, _}} -> [];
|
||||
error:_ -> []
|
||||
end.
|
||||
|
||||
-spec delete(CalendarId :: binary(), UserId :: binary()) -> ok | {error, not_found | term()}.
|
||||
delete(CalendarId, UserId) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[] -> {error, not_found};
|
||||
[#calendar_share{id = Id}] ->
|
||||
mnesia:delete({calendar_share, Id}),
|
||||
ok
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec update_rights(CalendarId :: binary(), UserId :: binary(),
|
||||
Rights :: read | write | admin) ->
|
||||
{ok, #calendar_share{}} | {error, not_found | term()}.
|
||||
update_rights(CalendarId, UserId, Rights) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[] -> {error, not_found};
|
||||
[#calendar_share{} = Rec] ->
|
||||
Updated = Rec#calendar_share{rights = Rights},
|
||||
mnesia:write(Updated),
|
||||
{ok, Updated}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec update_mirror(CalendarId :: binary(), UserId :: binary(), Mirror :: boolean()) ->
|
||||
{ok, #calendar_share{}} | {error, not_found | term()}.
|
||||
update_mirror(CalendarId, UserId, Mirror) ->
|
||||
F = fun() ->
|
||||
case find(CalendarId, UserId) of
|
||||
[] -> {error, not_found};
|
||||
[#calendar_share{} = Rec] ->
|
||||
Updated = Rec#calendar_share{mirror_to_default = Mirror},
|
||||
mnesia:write(Updated),
|
||||
{ok, Updated}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
find(CalendarId, UserId) ->
|
||||
mnesia:match_object(#calendar_share{calendar_id = CalendarId, user_id = UserId, _ = '_'}).
|
||||
|
||||
find_dirty(CalendarId, UserId) ->
|
||||
mnesia:dirty_match_object(
|
||||
#calendar_share{calendar_id = CalendarId, user_id = UserId, _ = '_'}).
|
||||
@@ -0,0 +1,90 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Хранение приглашений calendar_share.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_calendar_share_invite).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([create/1, get_by_id/1, get_by_token/1, list_by_calendar/1,
|
||||
list_by_invitee/1, list_by_email/1, update_status/2, find_pending/3,
|
||||
write/1]).
|
||||
|
||||
-spec create(#calendar_share_invite{}) ->
|
||||
{ok, #calendar_share_invite{}} | {error, term()}.
|
||||
create(Rec) ->
|
||||
F = fun() ->
|
||||
mnesia:write(Rec),
|
||||
{ok, Rec}
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec write(#calendar_share_invite{}) -> ok.
|
||||
write(Rec) ->
|
||||
mnesia:dirty_write(Rec).
|
||||
|
||||
-spec get_by_id(Id :: binary()) ->
|
||||
{ok, #calendar_share_invite{}} | {error, not_found}.
|
||||
get_by_id(Id) ->
|
||||
case mnesia:dirty_read(calendar_share_invite, Id) of
|
||||
[Rec] -> {ok, Rec};
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
|
||||
-spec get_by_token(Token :: binary()) ->
|
||||
{ok, #calendar_share_invite{}} | {error, not_found}.
|
||||
get_by_token(Token) ->
|
||||
case mnesia:dirty_index_read(calendar_share_invite, Token,
|
||||
#calendar_share_invite.token) of
|
||||
[Rec] -> {ok, Rec};
|
||||
[] -> {error, not_found};
|
||||
[Rec | _] -> {ok, Rec}
|
||||
end.
|
||||
|
||||
-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_share_invite{}].
|
||||
list_by_calendar(CalendarId) ->
|
||||
mnesia:dirty_match_object(
|
||||
#calendar_share_invite{calendar_id = CalendarId, _ = '_'}).
|
||||
|
||||
-spec list_by_invitee(UserId :: binary()) -> [#calendar_share_invite{}].
|
||||
list_by_invitee(UserId) ->
|
||||
mnesia:dirty_match_object(
|
||||
#calendar_share_invite{invitee_user_id = UserId, _ = '_'}).
|
||||
|
||||
-spec list_by_email(Email :: binary()) -> [#calendar_share_invite{}].
|
||||
list_by_email(Email) ->
|
||||
mnesia:dirty_match_object(
|
||||
#calendar_share_invite{invitee_email = Email, _ = '_'}).
|
||||
|
||||
-spec update_status(Id :: binary(), Status :: atom()) ->
|
||||
{ok, #calendar_share_invite{}} | {error, not_found | term()}.
|
||||
update_status(Id, Status) ->
|
||||
F = fun() ->
|
||||
case mnesia:read(calendar_share_invite, Id) of
|
||||
[] ->
|
||||
{error, not_found};
|
||||
[Rec] ->
|
||||
Updated = Rec#calendar_share_invite{status = Status},
|
||||
mnesia:write(Updated),
|
||||
{ok, Updated}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec find_pending(CalendarId :: binary(), UserId :: binary(), Email :: binary()) ->
|
||||
[#calendar_share_invite{}].
|
||||
find_pending(CalendarId, UserId, Email) ->
|
||||
All = list_by_calendar(CalendarId),
|
||||
[I || I <- All, I#calendar_share_invite.status =:= pending,
|
||||
matches_pending(I, UserId, Email)].
|
||||
|
||||
matches_pending(#calendar_share_invite{invitee_user_id = U}, UserId, _)
|
||||
when UserId =/= <<>>, U =:= UserId -> true;
|
||||
matches_pending(#calendar_share_invite{invitee_email = E}, _, Email)
|
||||
when Email =/= <<>>, E =/= <<>>, E =:= Email -> true;
|
||||
matches_pending(_, _, _) -> false.
|
||||
@@ -6,7 +6,7 @@
|
||||
-include("records.hrl").
|
||||
|
||||
-export([create/4, get_by_calendar_and_user/2, list_by_calendar/1,
|
||||
update/3, delete/2, is_active_specialist/2]).
|
||||
list_by_user/1, update/3, delete/2, is_active_specialist/2]).
|
||||
|
||||
-spec create(CalendarId :: binary(), UserId :: binary(), Name :: binary(),
|
||||
Specs :: [binary()]) ->
|
||||
@@ -51,6 +51,10 @@ get_by_calendar_and_user(CalendarId, UserId) ->
|
||||
list_by_calendar(CalendarId) ->
|
||||
mnesia:dirty_match_object(#calendar_specialist{calendar_id = CalendarId, _ = '_'}).
|
||||
|
||||
-spec list_by_user(UserId :: binary()) -> [#calendar_specialist{}].
|
||||
list_by_user(UserId) ->
|
||||
mnesia:dirty_match_object(#calendar_specialist{user_id = UserId, _ = '_'}).
|
||||
|
||||
-spec update(CalendarId :: binary(), UserId :: binary(), Updates :: [{atom(), term()}]) ->
|
||||
{ok, #calendar_specialist{}} | {error, not_found | term()}.
|
||||
update(CalendarId, UserId, Updates) ->
|
||||
|
||||
+44
-11
@@ -1,6 +1,7 @@
|
||||
-module(core_event).
|
||||
-include("records.hrl").
|
||||
-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1, update/2, delete/1,
|
||||
-export([create/4, create_recurring/5, get_by_id/1, list_by_calendar/1,
|
||||
list_active_including_instances/1, update/2, delete/1,
|
||||
materialize_occurrence/3]).
|
||||
-export([count_events/0, count_events_by_date/2]).
|
||||
-export([freeze/2, unfreeze/2]).
|
||||
@@ -118,10 +119,12 @@ materialize_occurrence(MasterId, OccurrenceStart, SpecialistId) ->
|
||||
[] ->
|
||||
{error, master_not_found};
|
||||
[Master] when Master#event.event_type =:= recurring ->
|
||||
Existing = mnesia:dirty_match_object(
|
||||
#event{master_id = MasterId, start_time = OccurrenceStart,
|
||||
is_instance = true, _ = '_'}
|
||||
),
|
||||
%% Optimized: use master_id index instead of full table scan,
|
||||
%% then filter by start_time and is_instance.
|
||||
Candidates = mnesia:dirty_index_read(event, MasterId, #event.master_id),
|
||||
Existing = [E || E <- Candidates,
|
||||
E#event.start_time =:= OccurrenceStart andalso
|
||||
E#event.is_instance =:= true],
|
||||
case Existing of
|
||||
[] ->
|
||||
InstanceId = infra_utils:generate_id(16),
|
||||
@@ -180,8 +183,21 @@ get_by_id(Id) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_by_calendar(CalendarId :: binary()) -> {ok, [#event{}]}.
|
||||
list_by_calendar(CalendarId) ->
|
||||
Match = #event{calendar_id = CalendarId, status = active, is_instance = false, _ = '_'},
|
||||
Events = mnesia:dirty_match_object(Match),
|
||||
%% Optimized: use calendar_id index instead of full table scan,
|
||||
%% then filter by status and is_instance.
|
||||
Candidates = mnesia:dirty_index_read(event, CalendarId, #event.calendar_id),
|
||||
Events = [E || E <- Candidates,
|
||||
E#event.status =:= active andalso E#event.is_instance =:= false],
|
||||
{ok, Events}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Active events including materialized occurrences (for booking inbox).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_active_including_instances(CalendarId :: binary()) -> {ok, [#event{}]}.
|
||||
list_active_including_instances(CalendarId) ->
|
||||
Candidates = mnesia:dirty_index_read(event, CalendarId, #event.calendar_id),
|
||||
Events = [E || E <- Candidates, E#event.status =:= active],
|
||||
{ok, Events}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
@@ -229,8 +245,10 @@ count_events() ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_all() -> [#event{}].
|
||||
list_all() ->
|
||||
Match = #event{status = active, is_instance = false, _ = '_'},
|
||||
mnesia:dirty_match_object(Match).
|
||||
%% Optimized: use status index to read only active events,
|
||||
%% then filter by is_instance instead of a full table scan.
|
||||
Active = mnesia:dirty_index_read(event, active, #event.status),
|
||||
[E || E <- Active, E#event.is_instance =:= false].
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Подсчёт событий, созданных в заданном временном диапазоне.
|
||||
@@ -241,6 +259,9 @@ list_all() ->
|
||||
[{{pos_integer(), pos_integer(), pos_integer()}, non_neg_integer()}].
|
||||
count_events_by_date(From, To) ->
|
||||
core_stats:with_daily(events_created, From, To, fun() ->
|
||||
%% TODO: created_at has a hash index (no range-scan support in Mnesia);
|
||||
%% a full scan is still required here. Pre-aggregate in stats_collector
|
||||
%% to avoid this fallback path.
|
||||
All = mnesia:dirty_match_object(#event{_ = '_'}),
|
||||
Filtered = lists:filter(fun(E) -> E#event.created_at >= From andalso
|
||||
E#event.created_at =< To end, All),
|
||||
@@ -261,7 +282,12 @@ count_events_by_date(From, To) ->
|
||||
-spec count_events_by_type() -> [{atom(), non_neg_integer()}].
|
||||
count_events_by_type() ->
|
||||
core_stats:with_dim(event, type, fun() ->
|
||||
Events = mnesia:dirty_match_object(#event{_ = '_'}),
|
||||
%% Optimized: use event_type index reads for known values instead of
|
||||
%% a full table scan. event_type :: single | recurring.
|
||||
KnownTypes = [single, recurring],
|
||||
Events = lists:flatmap(
|
||||
fun(Type) -> mnesia:dirty_index_read(event, Type, #event.event_type) end,
|
||||
KnownTypes),
|
||||
lists:foldl(fun(#event{event_type = Type}, Acc) ->
|
||||
case lists:keyfind(Type, 1, Acc) of
|
||||
false -> [{Type, 1} | Acc];
|
||||
@@ -277,7 +303,12 @@ count_events_by_type() ->
|
||||
-spec count_events_by_status() -> [{atom(), non_neg_integer()}].
|
||||
count_events_by_status() ->
|
||||
core_stats:with_dim(event, status, fun() ->
|
||||
Events = mnesia:dirty_match_object(#event{_ = '_'}),
|
||||
%% Optimized: use status index reads for known values instead of
|
||||
%% a full table scan. status :: active | cancelled | completed | frozen | deleted.
|
||||
KnownStatuses = [active, cancelled, completed, frozen, deleted],
|
||||
Events = lists:flatmap(
|
||||
fun(Status) -> mnesia:dirty_index_read(event, Status, #event.status) end,
|
||||
KnownStatuses),
|
||||
lists:foldl(fun(#event{status = Status}, Acc) ->
|
||||
case lists:keyfind(Status, 1, Acc) of
|
||||
false -> [{Status, 1} | Acc];
|
||||
@@ -295,6 +326,8 @@ get_top_events_by_rating(N) ->
|
||||
case stats_tops:get_top_events_by_rating(N) of
|
||||
{ok, Events} -> Events;
|
||||
{error, _} ->
|
||||
%% TODO: no index on rating_avg; full scan is required for this fallback.
|
||||
%% The primary path uses stats_tops; this is only a fallback.
|
||||
Events = mnesia:dirty_match_object(#event{_ = '_'}),
|
||||
Sorted = lists:reverse(lists:sort(
|
||||
fun(A, B) -> A#event.rating_avg =< B#event.rating_avg end,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Web Push subscriptions (Back#75).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(core_push_subscription).
|
||||
-export([upsert/4, list_by_user/1, delete_by_endpoint/2, delete_by_id/1,
|
||||
get_by_endpoint/1]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-spec upsert(binary(), binary(), binary(), binary()) ->
|
||||
{ok, #push_subscription{}} | {error, term()}.
|
||||
upsert(UserId, Endpoint, P256dh, Auth) ->
|
||||
Now = calendar:universal_time(),
|
||||
case get_by_endpoint(Endpoint) of
|
||||
{ok, #push_subscription{} = Existing} ->
|
||||
Rec = Existing#push_subscription{
|
||||
user_id = UserId,
|
||||
p256dh = P256dh,
|
||||
auth = Auth,
|
||||
created_at = Now
|
||||
},
|
||||
case mnesia:transaction(fun() -> mnesia:write(Rec) end) of
|
||||
{atomic, ok} -> {ok, Rec};
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end;
|
||||
{error, not_found} ->
|
||||
Rec = #push_subscription{
|
||||
id = infra_utils:generate_id(16),
|
||||
user_id = UserId,
|
||||
endpoint = Endpoint,
|
||||
p256dh = P256dh,
|
||||
auth = Auth,
|
||||
created_at = Now
|
||||
},
|
||||
case mnesia:transaction(fun() -> mnesia:write(Rec) end) of
|
||||
{atomic, ok} -> {ok, Rec};
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end
|
||||
end.
|
||||
|
||||
-spec list_by_user(binary()) -> [#push_subscription{}].
|
||||
list_by_user(UserId) ->
|
||||
mnesia:dirty_match_object(#push_subscription{user_id = UserId, _ = '_'}).
|
||||
|
||||
-spec get_by_endpoint(binary()) -> {ok, #push_subscription{}} | {error, not_found}.
|
||||
get_by_endpoint(Endpoint) ->
|
||||
case mnesia:dirty_index_read(push_subscription, Endpoint, #push_subscription.endpoint) of
|
||||
[Rec | _] -> {ok, Rec};
|
||||
[] ->
|
||||
case mnesia:dirty_match_object(#push_subscription{endpoint = Endpoint, _ = '_'}) of
|
||||
[Rec | _] -> {ok, Rec};
|
||||
[] -> {error, not_found}
|
||||
end
|
||||
end.
|
||||
|
||||
-spec delete_by_endpoint(binary(), binary()) -> ok | {error, not_found | forbidden}.
|
||||
delete_by_endpoint(UserId, Endpoint) ->
|
||||
case get_by_endpoint(Endpoint) of
|
||||
{ok, #push_subscription{id = Id, user_id = UserId}} ->
|
||||
delete_by_id(Id);
|
||||
{ok, #push_subscription{}} ->
|
||||
{error, forbidden};
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end.
|
||||
|
||||
-spec delete_by_id(binary()) -> ok | {error, term()}.
|
||||
delete_by_id(Id) ->
|
||||
case mnesia:transaction(fun() -> mnesia:delete({push_subscription, Id}) end) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
@@ -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),
|
||||
|
||||
+107
-43
@@ -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} ->
|
||||
%% 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:dirty_write(Updated),
|
||||
{ok, Updated};
|
||||
Error -> Error
|
||||
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} ->
|
||||
%% 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:dirty_write(Updated),
|
||||
{ok, Updated};
|
||||
Error -> Error
|
||||
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} ->
|
||||
%% 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:dirty_write(Updated),
|
||||
{ok, Updated};
|
||||
Error -> Error
|
||||
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} ->
|
||||
%% 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:dirty_write(Updated),
|
||||
{ok, Updated};
|
||||
Error -> Error
|
||||
mnesia:write(Updated),
|
||||
{ok, Updated}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, AbortReason} -> {error, AbortReason}
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
@@ -393,13 +446,13 @@ 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
|
||||
[] ->
|
||||
%% 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{
|
||||
@@ -420,23 +473,34 @@ create_bot(Email, Password) ->
|
||||
created_at = Now,
|
||||
updated_at = Now
|
||||
},
|
||||
ok = mnesia:dirty_write(User),
|
||||
ok = mnesia:write(User),
|
||||
{ok, User};
|
||||
_ ->
|
||||
{error, duplicate_email}
|
||||
{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
|
||||
%% Transaction ensures the read-then-delete is atomic.
|
||||
F = fun() ->
|
||||
case mnesia:read(user, Id, write) of
|
||||
[#user{role = bot}] ->
|
||||
mnesia:dirty_delete({user, Id}),
|
||||
mnesia:delete({user, Id}),
|
||||
ok;
|
||||
[] ->
|
||||
_ ->
|
||||
{error, not_found}
|
||||
end
|
||||
end,
|
||||
case mnesia:transaction(F) of
|
||||
{atomic, Result} -> Result;
|
||||
{aborted, Reason} -> {error, Reason}
|
||||
end.
|
||||
@@ -0,0 +1,66 @@
|
||||
-module(core_waitlist).
|
||||
-include("records.hrl").
|
||||
-export([create/2, get_by_id/1, update/2,
|
||||
list_by_event/1, list_waiting_by_event/1,
|
||||
get_waiting/2]).
|
||||
|
||||
-spec create(EventId :: binary(), UserId :: binary()) ->
|
||||
{ok, #waitlist_entry{}} | {error, term()}.
|
||||
create(EventId, UserId) ->
|
||||
Id = infra_utils:generate_id(16),
|
||||
Now = calendar:universal_time(),
|
||||
Entry = #waitlist_entry{
|
||||
id = Id,
|
||||
event_id = EventId,
|
||||
user_id = UserId,
|
||||
status = waiting,
|
||||
created_at = Now,
|
||||
updated_at = Now
|
||||
},
|
||||
case mnesia:dirty_write(Entry) of
|
||||
ok -> {ok, Entry};
|
||||
Error -> {error, Error}
|
||||
end.
|
||||
|
||||
-spec get_by_id(binary()) -> {ok, #waitlist_entry{}} | {error, not_found}.
|
||||
get_by_id(Id) ->
|
||||
case mnesia:dirty_read(waitlist_entry, Id) of
|
||||
[E] -> {ok, E};
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
|
||||
-spec update(binary(), [{atom(), term()}]) ->
|
||||
{ok, #waitlist_entry{}} | {error, not_found | term()}.
|
||||
update(Id, Updates) ->
|
||||
case get_by_id(Id) of
|
||||
{ok, E0} ->
|
||||
E1 = lists:foldl(fun apply_upd/2, E0, Updates),
|
||||
E2 = E1#waitlist_entry{updated_at = calendar:universal_time()},
|
||||
case mnesia:dirty_write(E2) of
|
||||
ok -> {ok, E2};
|
||||
Err -> {error, Err}
|
||||
end;
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end.
|
||||
|
||||
apply_upd({status, V}, E) -> E#waitlist_entry{status = V};
|
||||
apply_upd(_, E) -> E.
|
||||
|
||||
-spec list_by_event(binary()) -> [#waitlist_entry{}].
|
||||
list_by_event(EventId) ->
|
||||
mnesia:dirty_index_read(waitlist_entry, EventId, #waitlist_entry.event_id).
|
||||
|
||||
-spec list_waiting_by_event(binary()) -> [#waitlist_entry{}].
|
||||
list_waiting_by_event(EventId) ->
|
||||
[E || E <- list_by_event(EventId), E#waitlist_entry.status =:= waiting].
|
||||
|
||||
-spec get_waiting(EventId :: binary(), UserId :: binary()) ->
|
||||
{ok, #waitlist_entry{}} | {error, not_found}.
|
||||
get_waiting(EventId, UserId) ->
|
||||
case [E || E <- list_by_event(EventId),
|
||||
E#waitlist_entry.user_id =:= UserId,
|
||||
E#waitlist_entry.status =:= waiting] of
|
||||
[E | _] -> {ok, E};
|
||||
[] -> {error, not_found}
|
||||
end.
|
||||
@@ -11,7 +11,10 @@
|
||||
cowboy,
|
||||
jsx,
|
||||
trails,
|
||||
cowboy_swagger
|
||||
cowboy_swagger,
|
||||
ssl,
|
||||
inets,
|
||||
gen_smtp
|
||||
]},
|
||||
{env, [
|
||||
{http_port, 8080},
|
||||
|
||||
+30
-2
@@ -57,7 +57,6 @@ start_application() ->
|
||||
ok = migration_engine:ensure_applied(),
|
||||
%% После wait + migrations: иначе backfill ловит {no_exists, user} на ещё не загруженных таблицах.
|
||||
ok = stats_collector:subscribe(),
|
||||
calendar_html_renderer:init_cache(),
|
||||
application:ensure_all_started(cowboy),
|
||||
start_http(), % Пользовательский API (8080)
|
||||
start_admin_http(), % Административный API (8445)
|
||||
@@ -83,35 +82,61 @@ start_http() ->
|
||||
{'_', [
|
||||
{"/metrics/[:registry]", prometheus_cowboy2_handler, []},
|
||||
{"/health", handler_health, []},
|
||||
{"/v1/media/:kind/:owner/:file", handler_media, []},
|
||||
{"/v1/register", handler_register, []},
|
||||
{"/v1/verify", handler_verify, []},
|
||||
{"/v1/forgot-password", handler_forgot_password, []},
|
||||
{"/v1/reset-password", handler_reset_password, []},
|
||||
{"/v1/login", handler_login, []},
|
||||
{"/v1/refresh", handler_refresh, []},
|
||||
{"/v1/logout", handler_logout, []},
|
||||
{"/v1/sessions", handler_sessions, []},
|
||||
{"/v1/sessions/revoke-others", handler_sessions, []},
|
||||
{"/v1/sessions/:id", handler_sessions, []},
|
||||
{"/v1/user/me", handler_user_me, []},
|
||||
{"/v1/user/me/avatar", handler_user_avatar, []},
|
||||
{"/v1/notifications/prefs", handler_notification_prefs, []},
|
||||
{"/v1/push/vapid-public-key", handler_push, []},
|
||||
{"/v1/push/subscriptions", handler_push, []},
|
||||
{"/v1/user/bookings", handler_user_bookings, []},
|
||||
{"/v1/user/booking-requests", handler_user_booking_requests, []},
|
||||
{"/v1/user/studio-bookings", handler_user_studio_bookings, []},
|
||||
{"/v1/user/reviews", handler_user_reviews, []},
|
||||
{"/v1/user/following", handler_user_following, []},
|
||||
{"/v1/user/specialist-invites", handler_specialist_invites, []},
|
||||
{"/v1/user/share-invites", handler_share_invites, []},
|
||||
{"/v1/user/shares/:calendar_id", handler_calendar_shares, []},
|
||||
{"/v1/users/lookup", handler_users_lookup, []},
|
||||
{"/v1/search", handler_search, []},
|
||||
{"/v1/geo/suggest", handler_geo, []},
|
||||
{"/v1/geo/geocode", handler_geo, []},
|
||||
{"/v1/geo/reverse", handler_geo, []},
|
||||
{"/v1/ai/hint-metrics", handler_ai_metrics, []},
|
||||
{"/v1/ai/transcribe", handler_ai_transcribe, []},
|
||||
{"/v1/calendars", handler_calendars, []},
|
||||
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
||||
{"/v1/calendars/:id/cover", handler_calendar_cover, []},
|
||||
{"/v1/calendars/:id/follow", handler_calendar_follow, []},
|
||||
{"/v1/calendars/:id/specialists", handler_calendar_specialists, []},
|
||||
{"/v1/calendars/:id/specialists/:user_id", handler_calendar_specialists, []},
|
||||
{"/v1/calendars/:id/specialist-invites", handler_calendar_specialist_invites, []},
|
||||
{"/v1/calendars/:id/specialist-invites/:invite_id", handler_calendar_specialist_invites, []},
|
||||
{"/v1/calendars/:id/share-invites", handler_calendar_share_invites, []},
|
||||
{"/v1/calendars/:id/share-invites/:invite_id", handler_calendar_share_invites, []},
|
||||
{"/v1/calendars/:id/shares", handler_calendar_shares, []},
|
||||
{"/v1/calendars/:id/shares/:user_id", handler_calendar_shares, []},
|
||||
{"/v1/specialist-invites/accept", handler_specialist_invites, []},
|
||||
{"/v1/specialist-invites/:id/accept", handler_specialist_invites, []},
|
||||
{"/v1/specialist-invites/:id/decline", handler_specialist_invites, []},
|
||||
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
||||
{"/v1/share-invites/accept", handler_share_invites, []},
|
||||
{"/v1/share-invites/:id/accept", handler_share_invites, []},
|
||||
{"/v1/share-invites/:id/decline", handler_share_invites, []},
|
||||
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
||||
{"/v1/events/:id", handler_event_by_id, []},
|
||||
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
||||
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
|
||||
{"/v1/events/:id/bookings", handler_bookings, []},
|
||||
{"/v1/events/:id/waitlist", handler_event_waitlist, []},
|
||||
{"/v1/bookings/:id", handler_booking_by_id, []},
|
||||
{"/v1/reviews", handler_reviews, []},
|
||||
{"/v1/reviews/:id", handler_review_by_id, []},
|
||||
@@ -145,6 +170,7 @@ start_admin_http() ->
|
||||
{"/v1/admin/nodes/metrics", admin_handler_node_metrics, []},
|
||||
{"/v1/admin/login", admin_handler_login, []},
|
||||
{"/v1/admin/refresh", admin_handler_refresh, []},
|
||||
{"/v1/admin/logout", admin_handler_logout, []},
|
||||
% ================== ПОЛЬЗОВАТЕЛИ ==================
|
||||
{"/v1/admin/users", admin_handler_users, []},
|
||||
{"/v1/admin/users/stats", admin_handler_user_stats, []},
|
||||
@@ -183,6 +209,8 @@ start_admin_http() ->
|
||||
{"/v1/admin/subscriptions", admin_handler_subscriptions, []},
|
||||
{"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []},
|
||||
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
|
||||
% ================== ИИ-МЕТРИКИ ==================
|
||||
{"/v1/admin/ai-metrics/stats", admin_handler_ai_metrics, []},
|
||||
% ================== Управление ролями (только для superadmin) ==================
|
||||
{"/v1/admin/me", admin_handler_me, []},
|
||||
{"/v1/admin/admins", admin_handler_admins, []},
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc GET /v1/admin/ai-metrics/stats — сводка пилота ИИ-подсказок
|
||||
%%% (PRODUCT-AI, Spec#22): CTR-счётчики, гейт-доля активных с >=2
|
||||
%%% confirm за 30 дней, топ паттернов.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_ai_metrics).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_stats(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[ #{
|
||||
path => <<"/v1/admin/ai-metrics/stats">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"AI hint pilot summary (PRODUCT-AI gates)">>,
|
||||
tags => [<<"Statistics">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"AI hint pilot statistics">>},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
} ].
|
||||
|
||||
get_stats(Req) ->
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, _AdminId, Req1} ->
|
||||
handler_utils:send_json(Req1, 200, logic_ai_metrics:summary());
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -206,14 +206,7 @@ convert_field({<<"start_time">>, Val}) ->
|
||||
convert_field({<<"duration">>, Val}) -> {duration, Val};
|
||||
convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
|
||||
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
||||
convert_field({<<"location">>, Val}) when is_map(Val) ->
|
||||
Loc = #location{
|
||||
address = maps:get(<<"address">>, Val, undefined),
|
||||
lat = maps:get(<<"lat">>, Val, undefined),
|
||||
lon = maps:get(<<"lon">>, Val, undefined)
|
||||
},
|
||||
{location, Loc};
|
||||
convert_field({<<"location">>, Val}) -> {location, Val};
|
||||
convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
|
||||
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
||||
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
||||
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
||||
|
||||
@@ -22,7 +22,7 @@ init(Req0, _State) ->
|
||||
{ok, _Token, User} ->
|
||||
UserId = maps:get(id, User),
|
||||
Role = maps:get(role, User),
|
||||
{ok, Token, RefreshToken} = logic_auth_session:issue_admin_tokens(UserId, Role),
|
||||
{ok, Token, RefreshToken, SessionId} = logic_auth_session:issue_admin_tokens(UserId, Role),
|
||||
core_admin:update_last_login(UserId),
|
||||
% Аудит успешного входа
|
||||
admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, <<>>, Req1),
|
||||
@@ -33,7 +33,8 @@ init(Req0, _State) ->
|
||||
<<"email">> => maps:get(email, User),
|
||||
<<"role">> => maps:get(role, User)
|
||||
},
|
||||
<<"refresh_token">> => RefreshToken
|
||||
<<"refresh_token">> => RefreshToken,
|
||||
<<"session_id">> => SessionId
|
||||
},
|
||||
handler_utils:send_json(Req1, 200, Resp);
|
||||
{error, insufficient_permissions} ->
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/admin/logout — отзыв admin refresh-сессии (вариант A / Back#69).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(admin_handler_logout).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> logout(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/admin/logout">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Revoke current admin refresh session">>,
|
||||
tags => [<<"Admin Auth">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{
|
||||
<<"application/json">> => #{
|
||||
schema => #{
|
||||
type => object,
|
||||
required => [<<"refresh_token">>],
|
||||
properties => #{refresh_token => #{type => string}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Session revoked">>},
|
||||
400 => #{description => <<"Missing refresh_token or invalid JSON">>},
|
||||
401 => #{description => <<"Invalid refresh token">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
logout(Req) ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"refresh_token">> := RefreshToken} ->
|
||||
case logic_auth_session:logout_admin(RefreshToken) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req1, 200, #{ok => true});
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 401, <<"Invalid refresh token">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Missing refresh_token field">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
end.
|
||||
@@ -71,10 +71,11 @@ refresh(Req) ->
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"refresh_token">> := RefreshToken} ->
|
||||
case logic_auth_session:refresh_admin(RefreshToken) of
|
||||
{ok, NewToken, NewRefreshToken} ->
|
||||
{ok, NewToken, NewRefreshToken, SessionId} ->
|
||||
handler_utils:send_json(Req1, 200, #{
|
||||
token => NewToken,
|
||||
refresh_token => NewRefreshToken
|
||||
refresh_token => NewRefreshToken,
|
||||
session_id => SessionId
|
||||
});
|
||||
{error, reuse_detected} ->
|
||||
handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/ai/hint-metrics — пилотные метрики ИИ-подсказок
|
||||
%%% (PRODUCT-AI, Spec#22). Bearer required.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_ai_metrics).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/ai/hint-metrics">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"AI hint pilot metrics (shown/confirm/dismiss counters). Bearer required.">>,
|
||||
tags => [<<"AI">>],
|
||||
responses => #{
|
||||
202 => #{description => <<"accepted">>},
|
||||
400 => #{description => <<"invalid_body">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
429 => #{description => <<"rate_limited">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> post(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
post(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
case decode_body(Body) of
|
||||
{ok, Events} ->
|
||||
case logic_ai_metrics:record_events(UserId, Events) of
|
||||
{ok, Count} ->
|
||||
handler_utils:send_json(Req2, 202, #{<<"accepted">> => Count});
|
||||
{error, invalid_body} ->
|
||||
handler_utils:send_error(Req2, 400, <<"invalid_body">>);
|
||||
{error, rate_limited} ->
|
||||
handler_utils:send_error(Req2, 429, <<"rate_limited">>)
|
||||
end;
|
||||
error ->
|
||||
handler_utils:send_error(Req2, 400, <<"invalid_body">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
decode_body(Body) when is_binary(Body) ->
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"events">> := Events} when is_list(Events) -> {ok, Events};
|
||||
_ -> error
|
||||
catch
|
||||
_:_ -> error
|
||||
end.
|
||||
@@ -0,0 +1,68 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/ai/transcribe — прокси транскрибации голоса
|
||||
%%% (PRODUCT-AI, Front#76). Bearer required; multipart-поле file.
|
||||
%%% Аудио и транскрипт не персистятся (приват-инвариант канона).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_ai_transcribe).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/ai/transcribe">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Transcribe press-to-talk audio via STT provider proxy. "
|
||||
"Bearer required; multipart field `file` (audio).">>,
|
||||
tags => [<<"AI">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"{transcript, lang}; empty transcript = unheard">>},
|
||||
400 => #{description => <<"missing_file / bad_multipart / too_large">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
429 => #{description => <<"rate_limited">>},
|
||||
502 => #{description => <<"upstream_error">>},
|
||||
503 => #{description => <<"unavailable">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> post(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
post(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
Max = logic_ai_transcribe:max_bytes(),
|
||||
case handler_upload_utils:read_file_part(Req1, Max) of
|
||||
{ok, Audio, CType, Req2} ->
|
||||
case logic_ai_transcribe:transcribe(UserId, Audio, CType) of
|
||||
{ok, Transcript, Lang} ->
|
||||
handler_utils:send_json(Req2, 200,
|
||||
#{<<"transcript">> => Transcript, <<"lang">> => Lang});
|
||||
{error, rate_limited} ->
|
||||
handler_utils:send_error(Req2, 429, <<"rate_limited">>);
|
||||
{error, unavailable} ->
|
||||
handler_utils:send_error(Req2, 503, <<"unavailable">>);
|
||||
{error, upstream_error} ->
|
||||
handler_utils:send_error(Req2, 502, <<"upstream_error">>);
|
||||
{error, invalid_audio} ->
|
||||
handler_utils:send_error(Req2, 400, <<"invalid_audio">>)
|
||||
end;
|
||||
{error, too_large, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"too_large">>);
|
||||
{error, missing_file, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"missing_file">>);
|
||||
{error, bad_multipart, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"bad_multipart">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -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, _} ->
|
||||
|
||||
@@ -45,12 +45,29 @@ trails() ->
|
||||
schema => #{type => string}
|
||||
}
|
||||
],
|
||||
requestBody => #{
|
||||
required => false,
|
||||
content => #{
|
||||
<<"application/json">> => #{
|
||||
schema => #{
|
||||
type => object,
|
||||
properties => #{
|
||||
occurrence_start => #{
|
||||
type => string,
|
||||
format => <<"date-time">>,
|
||||
description => <<"Required for recurring masters">>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
201 => #{
|
||||
description => <<"Booking created">>,
|
||||
content => #{<<"application/json">> => #{schema => BookingSchema}}
|
||||
},
|
||||
400 => #{description => <<"Event is full or not active">>},
|
||||
400 => #{description => <<"Event is full, not active, or occurrence_start missing/invalid">>},
|
||||
403 => #{description => <<"Access denied">>},
|
||||
404 => #{description => <<"Event not found">>},
|
||||
409 => #{description => <<"Already booked">>}
|
||||
@@ -92,7 +109,7 @@ booking_schema() ->
|
||||
id => #{type => string},
|
||||
event_id => #{type => string},
|
||||
user_id => #{type => string},
|
||||
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
|
||||
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>, <<"expired">>]},
|
||||
notes => #{type => string, nullable => true},
|
||||
reminder_sent => #{type => boolean},
|
||||
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
|
||||
@@ -111,29 +128,39 @@ create_booking(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
case logic_booking:create_booking(UserId, EventId) of
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
case parse_occurrence_start(Body) of
|
||||
{error, invalid_occurrence} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid occurrence_start">>);
|
||||
{ok, OccurrenceStart} ->
|
||||
case logic_booking:create_booking(UserId, EventId, OccurrenceStart) of
|
||||
{ok, Booking} ->
|
||||
handler_utils:send_json(Req1, 201, booking_to_json(Booking));
|
||||
handler_utils:send_json(Req2, 201, booking_to_json(Booking));
|
||||
{error, already_booked} ->
|
||||
handler_utils:send_error(Req1, 409, <<"Already booked">>);
|
||||
handler_utils:send_error(Req2, 409, <<"Already booked">>);
|
||||
{error, full} ->
|
||||
handler_utils:send_error(Req1, 400, <<"Event is full">>);
|
||||
handler_utils:send_error(Req2, 400, <<"Event is full">>);
|
||||
{error, event_full} ->
|
||||
handler_utils:send_error(Req1, 400, <<"Event is full">>);
|
||||
handler_utils:send_error(Req2, 400, <<"Event is full">>);
|
||||
{error, event_not_active} ->
|
||||
handler_utils:send_error(Req1, 400, <<"Event is not active">>);
|
||||
handler_utils:send_error(Req2, 400, <<"Event is not active">>);
|
||||
{error, occurrence_start_required} ->
|
||||
handler_utils:send_error(Req2, 400, <<"occurrence_start required">>);
|
||||
{error, invalid_occurrence} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid occurrence_start">>);
|
||||
{error, personal_calendar} ->
|
||||
handler_utils:send_error(Req1, 403, <<"personal_calendar">>);
|
||||
handler_utils:send_error(Req2, 403, <<"personal_calendar">>);
|
||||
{error, subscription_inactive} ->
|
||||
handler_utils:send_error(Req1, 403, <<"subscription_inactive">>);
|
||||
handler_utils:send_error(Req2, 403, <<"subscription_inactive">>);
|
||||
{error, own_event} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Cannot book own event">>);
|
||||
handler_utils:send_error(Req2, 403, <<"Cannot book own event">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
@@ -168,3 +195,27 @@ list_bookings(Req) ->
|
||||
-spec booking_to_json(#booking{}) -> map().
|
||||
booking_to_json(Booking) ->
|
||||
handler_utils:booking_to_json(Booking).
|
||||
|
||||
-spec parse_occurrence_start(binary()) ->
|
||||
{ok, calendar:datetime() | undefined} | {error, invalid_occurrence}.
|
||||
parse_occurrence_start(<<>>) ->
|
||||
{ok, undefined};
|
||||
parse_occurrence_start(Body) ->
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
case maps:get(<<"occurrence_start">>, Map, undefined) of
|
||||
undefined ->
|
||||
{ok, undefined};
|
||||
Iso when is_binary(Iso) ->
|
||||
case handler_utils:parse_datetime(Iso) of
|
||||
{ok, Dt} -> {ok, Dt};
|
||||
{error, _} -> {error, invalid_occurrence}
|
||||
end;
|
||||
_ ->
|
||||
{error, invalid_occurrence}
|
||||
end;
|
||||
_ ->
|
||||
{ok, undefined}
|
||||
catch
|
||||
_:_ -> {error, invalid_occurrence}
|
||||
end.
|
||||
@@ -85,6 +85,7 @@ calendar_schema() ->
|
||||
type => #{type => string, enum => [<<"personal">>, <<"commercial">>]},
|
||||
confirmation => #{type => string, enum => [<<"auto">>, <<"manual">>]},
|
||||
tags => #{type => array, items => #{type => string}},
|
||||
settings => #{type => object},
|
||||
rating_avg => #{type => number, format => float},
|
||||
rating_count => #{type => integer},
|
||||
status => #{type => string},
|
||||
@@ -102,20 +103,26 @@ calendar_update_schema() ->
|
||||
description => #{type => string},
|
||||
type => #{type => string},
|
||||
confirmation => #{type => string},
|
||||
tags => #{type => array, items => #{type => string}}
|
||||
tags => #{type => array, items => #{type => string}},
|
||||
short_name => #{type => string},
|
||||
category => #{type => string},
|
||||
color => #{type => string},
|
||||
image_url => #{type => string},
|
||||
settings => #{type => object, nullable => true}
|
||||
}
|
||||
}.
|
||||
|
||||
%%% Internal functions
|
||||
|
||||
get_calendar(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
case handler_utils:auth_user_optional(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar:get_calendar(UserId, CalendarId) of
|
||||
{ok, Calendar} ->
|
||||
Json0 = handler_utils:calendar_to_json(Calendar),
|
||||
Following = logic_calendar_follow:is_following(UserId, CalendarId),
|
||||
Following = UserId =/= <<>> andalso
|
||||
logic_calendar_follow:is_following(UserId, Calendar#calendar.id),
|
||||
handler_utils:send_json(Req1, 200, Json0#{following => Following});
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
@@ -146,8 +153,13 @@ update_calendar(Req) ->
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, subscription_required} ->
|
||||
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>);
|
||||
{error, default_calendar} ->
|
||||
handler_utils:send_error(Req2, 403, <<"default_calendar">>);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, {invalid_settings, Key}} when is_binary(Key) ->
|
||||
handler_utils:send_error(Req2, 400,
|
||||
<<"Invalid settings.", Key/binary>>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
@@ -167,6 +179,8 @@ delete_calendar(Req) ->
|
||||
case logic_calendar:delete_calendar(UserId, CalendarId) of
|
||||
{ok, _} ->
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, default_calendar} ->
|
||||
handler_utils:send_error(Req1, 403, <<"default_calendar">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
@@ -195,4 +209,9 @@ convert_field({<<"confirmation">>, #{<<"timeout">> := N}}) when is_integer(N), N
|
||||
{confirmation, {timeout, N}};
|
||||
convert_field({<<"confirmation">>, Val}) -> {confirmation, Val};
|
||||
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
||||
convert_field({<<"short_name">>, Val}) -> {short_name, Val};
|
||||
convert_field({<<"category">>, Val}) -> {category, Val};
|
||||
convert_field({<<"color">>, Val}) -> {color, Val};
|
||||
convert_field({<<"image_url">>, Val}) -> {image_url, Val};
|
||||
convert_field({<<"settings">>, Val}) when is_map(Val) -> {settings, Val};
|
||||
convert_field(Other) -> Other.
|
||||
@@ -0,0 +1,99 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/calendars/:id/cover — multipart cover upload (Back#71).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_cover).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
-include("records.hrl").
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> post_cover(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/calendars/:id/cover">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Upload calendar cover image (owner; multipart field file)">>,
|
||||
tags => [<<"Calendars">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{
|
||||
<<"multipart/form-data">> => #{
|
||||
schema => #{
|
||||
type => object,
|
||||
required => [<<"file">>],
|
||||
properties => #{file => #{type => string, format => binary}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated calendar">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
403 => #{description => <<"Forbidden">>},
|
||||
404 => #{description => <<"Not found">>},
|
||||
413 => #{description => <<"File too large">>},
|
||||
415 => #{description => <<"Unsupported media type">>},
|
||||
400 => #{description => <<"Missing file / bad multipart">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
post_cover(Req) ->
|
||||
CalendarId = cowboy_req:binding(id, Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, Cal} ->
|
||||
case logic_calendar:can_edit(UserId, Cal) of
|
||||
true ->
|
||||
do_upload(UserId, CalendarId, Cal, Req1);
|
||||
false ->
|
||||
handler_utils:send_error(Req1, 403, <<"Forbidden">>)
|
||||
end;
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
do_upload(UserId, CalendarId, #calendar{image_url = Old}, Req) ->
|
||||
case handler_upload_utils:read_image_part(Req) of
|
||||
{ok, Bin, Req2} ->
|
||||
case logic_upload:save_cover(CalendarId, UserId, Bin) of
|
||||
{ok, Url} ->
|
||||
case logic_calendar:update_calendar(UserId, CalendarId, [{<<"image_url">>, Url}]) of
|
||||
{ok, Updated} ->
|
||||
_ = logic_upload:maybe_delete_url(Old),
|
||||
handler_utils:send_json(Req2, 200, handler_utils:calendar_to_json(Updated));
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req2, 403, <<"Forbidden">>);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Update failed">>)
|
||||
end;
|
||||
{error, too_large} ->
|
||||
handler_utils:send_error(Req2, 413, <<"File too large">>);
|
||||
{error, unsupported_media} ->
|
||||
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Upload failed">>)
|
||||
end;
|
||||
{error, missing_file, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Missing file field">>);
|
||||
{error, too_large, Req2} ->
|
||||
handler_utils:send_error(Req2, 413, <<"File too large">>);
|
||||
{error, unsupported_media, Req2} ->
|
||||
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
|
||||
{error, bad_multipart, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid multipart body">>)
|
||||
end.
|
||||
@@ -0,0 +1,134 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Owner/admin: исходящие share invites.
|
||||
%%%
|
||||
%%% GET/POST /v1/calendars/:id/share-invites
|
||||
%%% DELETE /v1/calendars/:id/share-invites/:invite_id
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_share_invites).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
trails() ->
|
||||
IdParam = #{name => <<"id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
InviteParam = #{name => <<"invite_id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
[
|
||||
#{path => <<"/v1/calendars/:id/share-invites">>, method => <<"GET">>,
|
||||
description => <<"List outgoing share invites">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/calendars/:id/share-invites">>, method => <<"POST">>,
|
||||
description => <<"Create share invite">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam], responses => #{201 => #{description => <<"Created">>}}},
|
||||
#{path => <<"/v1/calendars/:id/share-invites/:invite_id">>, method => <<"DELETE">>,
|
||||
description => <<"Cancel pending share invite">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam, InviteParam], responses => #{200 => #{description => <<"OK">>}}}
|
||||
].
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
Method = cowboy_req:method(Req),
|
||||
InviteId = cowboy_req:binding(invite_id, Req),
|
||||
case {Method, InviteId} of
|
||||
{<<"GET">>, undefined} -> list_invites(Req);
|
||||
{<<"POST">>, undefined} -> create_invite(Req);
|
||||
{<<"DELETE">>, Id} when is_binary(Id) -> cancel_invite(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
list_invites(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar_share_invite:list_outgoing(ActorId, CalendarId) of
|
||||
{ok, List} ->
|
||||
handler_utils:send_json(Req1, 200,
|
||||
[logic_calendar_share_invite:to_json(I) || I <- List]);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
create_invite(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
case parse_target(Map) of
|
||||
{error, bad_request} ->
|
||||
handler_utils:send_error(Req2, 400, <<"user_id or email required">>);
|
||||
Target ->
|
||||
Opts = #{rights => maps:get(<<"rights">>, Map, <<"write">>)},
|
||||
case logic_calendar_share_invite:create(ActorId, CalendarId, Target, Opts) of
|
||||
{ok, Inv} ->
|
||||
handler_utils:send_json(Req2, 201,
|
||||
logic_calendar_share_invite:to_json(Inv));
|
||||
{error, Reason} ->
|
||||
map_create_error(Req2, Reason)
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
cancel_invite(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
InviteId = cowboy_req:binding(invite_id, Req1),
|
||||
case logic_calendar_share_invite:cancel(ActorId, CalendarId, InviteId) of
|
||||
{ok, Inv} ->
|
||||
handler_utils:send_json(Req1, 200, logic_calendar_share_invite:to_json(Inv));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_pending} ->
|
||||
handler_utils:send_error(Req1, 409, <<"Invite is not pending">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
parse_target(#{<<"user_id">> := UserId}) when is_binary(UserId), UserId =/= <<>> ->
|
||||
#{user_id => UserId};
|
||||
parse_target(#{<<"email">> := Email}) when is_binary(Email), Email =/= <<>> ->
|
||||
#{email => Email};
|
||||
parse_target(_) ->
|
||||
{error, bad_request}.
|
||||
|
||||
map_create_error(Req, not_found) ->
|
||||
handler_utils:send_error(Req, 404, <<"Calendar not found">>);
|
||||
map_create_error(Req, access_denied) ->
|
||||
handler_utils:send_error(Req, 403, <<"Access denied">>);
|
||||
map_create_error(Req, user_not_found) ->
|
||||
handler_utils:send_error(Req, 404, <<"User not found">>);
|
||||
map_create_error(Req, already_shared) ->
|
||||
handler_utils:send_error(Req, 409, <<"Already shared">>);
|
||||
map_create_error(Req, already_pending) ->
|
||||
handler_utils:send_error(Req, 409, <<"Invite already pending">>);
|
||||
map_create_error(Req, bad_request) ->
|
||||
handler_utils:send_error(Req, 400, <<"Bad request">>);
|
||||
map_create_error(Req, _) ->
|
||||
handler_utils:send_error(Req, 500, <<"Internal server error">>).
|
||||
@@ -0,0 +1,161 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Accepted shares on a calendar + revoke / rights / self-mirror.
|
||||
%%%
|
||||
%%% GET/DELETE/PUT /v1/calendars/:id/shares[/:user_id]
|
||||
%%% PUT /v1/user/shares/:calendar_id body: {mirror_to_default}
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_shares).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
trails() ->
|
||||
IdParam = #{name => <<"id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
UserParam = #{name => <<"user_id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
CalParam = #{name => <<"calendar_id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
[
|
||||
#{path => <<"/v1/calendars/:id/shares">>, method => <<"GET">>,
|
||||
description => <<"List calendar shares">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/calendars/:id/shares/:user_id">>, method => <<"DELETE">>,
|
||||
description => <<"Revoke share grant">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam, UserParam], responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/calendars/:id/shares/:user_id">>, method => <<"PUT">>,
|
||||
description => <<"Update share rights">>, tags => [<<"Calendars">>],
|
||||
parameters => [IdParam, UserParam], responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/user/shares/:calendar_id">>, method => <<"PUT">>,
|
||||
description => <<"Update own mirror_to_default">>, tags => [<<"Users">>],
|
||||
parameters => [CalParam], responses => #{200 => #{description => <<"OK">>}}}
|
||||
].
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
Method = cowboy_req:method(Req),
|
||||
Path = cowboy_req:path(Req),
|
||||
case Path of
|
||||
<<"/v1/user/shares/", _/binary>> ->
|
||||
case Method of
|
||||
<<"PUT">> -> update_my_mirror(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end;
|
||||
_ ->
|
||||
UserId = cowboy_req:binding(user_id, Req),
|
||||
case {Method, UserId} of
|
||||
{<<"GET">>, undefined} -> list_shares(Req);
|
||||
{<<"DELETE">>, Id} when is_binary(Id) -> revoke_share(Req);
|
||||
{<<"PUT">>, Id} when is_binary(Id) -> update_rights(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end
|
||||
end.
|
||||
|
||||
list_shares(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar_share:list(ActorId, CalendarId) of
|
||||
{ok, List} ->
|
||||
handler_utils:send_json(Req1, 200,
|
||||
[logic_calendar_share:to_json(S) || S <- List]);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
revoke_share(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
TargetUserId = cowboy_req:binding(user_id, Req1),
|
||||
case logic_calendar_share:revoke(ActorId, CalendarId, TargetUserId) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req1, 200, #{ok => true});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Not found">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
update_rights(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, ActorId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
TargetUserId = cowboy_req:binding(user_id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"rights">> := RightsBin} when is_binary(RightsBin) ->
|
||||
case binary_to_existing_atom_safe(RightsBin) of
|
||||
invalid ->
|
||||
handler_utils:send_error(Req2, 400, <<"Bad request">>);
|
||||
Rights ->
|
||||
case logic_calendar_share:update_rights(ActorId, CalendarId, TargetUserId, Rights) of
|
||||
{ok, Share} ->
|
||||
handler_utils:send_json(Req2, 200, logic_calendar_share:to_json(Share));
|
||||
{error, Reason} ->
|
||||
map_share_error(Req2, Reason)
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"rights required">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
update_my_mirror(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(calendar_id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"mirror_to_default">> := Mirror} when is_boolean(Mirror) ->
|
||||
case logic_calendar_share:update_my_mirror(UserId, CalendarId, Mirror) of
|
||||
{ok, Share} ->
|
||||
handler_utils:send_json(Req2, 200, logic_calendar_share:to_json(Share));
|
||||
{error, Reason} ->
|
||||
map_share_error(Req2, Reason)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"mirror_to_default required">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
binary_to_existing_atom_safe(<<"read">>) -> read;
|
||||
binary_to_existing_atom_safe(<<"write">>) -> write;
|
||||
binary_to_existing_atom_safe(<<"admin">>) -> admin;
|
||||
binary_to_existing_atom_safe(_) -> invalid.
|
||||
|
||||
map_share_error(Req, not_found) ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>);
|
||||
map_share_error(Req, access_denied) ->
|
||||
handler_utils:send_error(Req, 403, <<"Access denied">>);
|
||||
map_share_error(Req, bad_request) ->
|
||||
handler_utils:send_error(Req, 400, <<"Bad request">>);
|
||||
map_share_error(Req, invalid) ->
|
||||
handler_utils:send_error(Req, 400, <<"Bad request">>);
|
||||
map_share_error(Req, _) ->
|
||||
handler_utils:send_error(Req, 500, <<"Internal server error">>).
|
||||
@@ -94,7 +94,7 @@ has_user_binding(Req) ->
|
||||
cowboy_req:binding(user_id, Req) =/= undefined.
|
||||
|
||||
list_specialists(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
case handler_utils:auth_user_optional(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar_specialist:list(UserId, CalendarId) of
|
||||
@@ -187,6 +187,8 @@ remove_specialist(Req) ->
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Not found">>);
|
||||
{error, owner_specialist_protected} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Owner specialist cannot be removed">>);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_commercial} ->
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик календарного представления (HTML-календарь).
|
||||
%%%
|
||||
%%% GET – возвращает HTML-страницу с календарём на указанный месяц.
|
||||
%%% Требует параметр `month` в формате YYYY-MM.
|
||||
%%% Доступно только владельцу календаря.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_view).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-define(ARCHIVE_CALL_TIMEOUT, 8000).
|
||||
|
||||
%%% cowboy_handler callback
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
%%% Swagger metadata
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/calendars/:calendar_id/view">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get calendar HTML view for a specific month">>,
|
||||
tags => [<<"Calendars">>],
|
||||
parameters => [
|
||||
#{
|
||||
name => <<"calendar_id">>,
|
||||
in => <<"path">>,
|
||||
description => <<"Calendar ID">>,
|
||||
required => true,
|
||||
schema => #{type => string}
|
||||
},
|
||||
#{
|
||||
name => <<"month">>,
|
||||
in => <<"query">>,
|
||||
description => <<"Month in YYYY-MM format">>,
|
||||
required => true,
|
||||
schema => #{type => string, pattern => <<"^\\d{4}-\\d{2}$">>}
|
||||
}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"HTML calendar page">>,
|
||||
content => #{<<"text/html">> => #{schema => #{type => string}}}
|
||||
},
|
||||
400 => #{description => <<"Missing or invalid 'month' parameter">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
403 => #{description => <<"Access denied">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
%% @private Основной обработчик запроса.
|
||||
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
handle(Req, _Opts) ->
|
||||
CalendarId = cowboy_req:binding(calendar_id, Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case is_owner(UserId, CalendarId) of
|
||||
true -> process_view(Req1, CalendarId);
|
||||
false -> handler_utils:send_error(Req1, 403, <<"Access denied">>)
|
||||
end;
|
||||
{error, _Code, _Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, 401, <<"Unauthorized">>)
|
||||
end.
|
||||
|
||||
%% @private Проверяет, является ли пользователь владельцем календаря.
|
||||
-spec is_owner(binary(), binary()) -> boolean().
|
||||
is_owner(UserId, CalendarId) ->
|
||||
case mnesia:dirty_read({calendar, CalendarId}) of
|
||||
[#calendar{owner_id = UserId}] -> true;
|
||||
_ -> false
|
||||
end.
|
||||
|
||||
%% @private Обрабатывает запрос на отображение календаря.
|
||||
-spec process_view(cowboy_req:req(), binary()) -> {ok, cowboy_req:req(), any()}.
|
||||
process_view(Req, CalendarId) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
case lists:keyfind(<<"month">>, 1, Qs) of
|
||||
{<<"month">>, MonthBin} ->
|
||||
case binary:split(MonthBin, <<"-">>) of
|
||||
[YearStr, MonthStr] ->
|
||||
Year = binary_to_integer(YearStr),
|
||||
Month = binary_to_integer(MonthStr),
|
||||
Events = fetch_events(CalendarId, Year, Month),
|
||||
Html = calendar_html_renderer:render_month(Year, Month, Events),
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"text/html">>,
|
||||
<<"cache-control">> => <<"public, max-age=86400">>
|
||||
},
|
||||
cowboy_req:reply(200, Headers, Html, Req),
|
||||
{ok, Req, undefined};
|
||||
_ ->
|
||||
handler_utils:send_error(Req, 400, <<"Invalid 'month' format. Use YYYY-MM">>)
|
||||
end;
|
||||
false ->
|
||||
handler_utils:send_error(Req, 400, <<"Missing 'month' parameter">>)
|
||||
end.
|
||||
|
||||
%% @private Извлекает события для указанного месяца календаря.
|
||||
-spec fetch_events(binary(), integer(), integer()) -> list(#event{}).
|
||||
fetch_events(CalendarId, Year, Month) ->
|
||||
case is_hot(Year, Month) of
|
||||
true -> fetch_hot_events(CalendarId, Year, Month);
|
||||
false -> fetch_archive_events(CalendarId, Year, Month)
|
||||
end.
|
||||
|
||||
%% @private Определяет, является ли месяц "горячим" (в пределах 30 дней от текущей даты).
|
||||
-spec is_hot(integer(), integer()) -> boolean().
|
||||
is_hot(Year, Month) ->
|
||||
Current = calendar:local_time(),
|
||||
Target = {{Year, Month, 1}, {0, 0, 0}},
|
||||
calendar:datetime_to_gregorian_seconds(Current) -
|
||||
calendar:datetime_to_gregorian_seconds(Target) < 30 * 86400.
|
||||
|
||||
%% @private Извлекает "горячие" события из Mnesia.
|
||||
-spec fetch_hot_events(binary(), integer(), integer()) -> list(#event{}).
|
||||
fetch_hot_events(CalendarId, Year, Month) ->
|
||||
Start = {{Year, Month, 1}, {0, 0, 0}},
|
||||
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}},
|
||||
mnesia:dirty_select(event, [
|
||||
{#event{calendar_id = CalendarId, start_time = '$1', _ = '_'},
|
||||
[{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}],
|
||||
['$_']}
|
||||
]).
|
||||
|
||||
%% @private Извлекает архивные события через RPC на архивный узел.
|
||||
%% При недоступности архива возвращает события из основной Mnesia.
|
||||
-spec fetch_archive_events(binary(), integer(), integer()) -> list(#event{}).
|
||||
fetch_archive_events(CalendarId, Year, Month) ->
|
||||
DayStr = lists:flatten(io_lib:format("~4..0B~2..0B", [Year, Month])),
|
||||
case safe_get_archive_node(DayStr) of
|
||||
{ok, Node} ->
|
||||
case rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month],
|
||||
?ARCHIVE_CALL_TIMEOUT) of
|
||||
Events when is_list(Events) -> Events;
|
||||
_ -> fetch_hot_events(CalendarId, Year, Month)
|
||||
end;
|
||||
{error, _} ->
|
||||
fetch_hot_events(CalendarId, Year, Month)
|
||||
end.
|
||||
|
||||
-spec safe_get_archive_node(string()) -> {ok, node()} | {error, term()}.
|
||||
safe_get_archive_node(Day) ->
|
||||
try gen_server:call(archive_manager, {get_node, Day}, ?ARCHIVE_CALL_TIMEOUT) of
|
||||
{ok, Node} when is_atom(Node) -> {ok, Node};
|
||||
{error, Reason} -> {error, Reason};
|
||||
Other -> {error, Other}
|
||||
catch
|
||||
exit:{timeout, _} -> {error, timeout};
|
||||
exit:{noproc, _} -> {error, noproc}
|
||||
end.
|
||||
@@ -117,23 +117,31 @@ create_calendar(Req) ->
|
||||
Confirmation = parse_confirmation(maps:get(<<"confirmation">>, Decoded, <<"manual">>)),
|
||||
Tags = maps:get(<<"tags">>, Decoded, []),
|
||||
Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)),
|
||||
case Type of
|
||||
commercial ->
|
||||
case logic_subscription:can_create_commercial_calendar(UserId) of
|
||||
true -> ok;
|
||||
false ->
|
||||
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>),
|
||||
throw(stop)
|
||||
end;
|
||||
personal -> ok
|
||||
end,
|
||||
case logic_calendar:create_calendar(UserId, Title, Description, Confirmation) of
|
||||
Settings = maps:get(<<"settings">>, Decoded, undefined),
|
||||
case logic_calendar:create_calendar(UserId, Title, Description, Confirmation, Type) of
|
||||
{ok, Calendar} ->
|
||||
Updates = [{tags, Tags}, {type, Type}],
|
||||
core_calendar:update(Calendar#calendar.id, Updates),
|
||||
{ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id),
|
||||
Extra0 = case Tags of
|
||||
[] -> [];
|
||||
_ -> [{tags, Tags}]
|
||||
end,
|
||||
Extra = case Settings of
|
||||
S when is_map(S) -> [{settings, S} | Extra0];
|
||||
_ -> Extra0
|
||||
end,
|
||||
Updated = case Extra of
|
||||
[] -> Calendar;
|
||||
_ ->
|
||||
case logic_calendar:update_calendar(UserId, Calendar#calendar.id, Extra) of
|
||||
{ok, C2} -> C2;
|
||||
_ -> Calendar
|
||||
end
|
||||
end,
|
||||
Response = calendar_to_json(Updated),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, subscription_required} ->
|
||||
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>);
|
||||
{error, personal_exists} ->
|
||||
handler_utils:send_error(Req2, 409, <<"personal_exists">>);
|
||||
{error, user_inactive} ->
|
||||
handler_utils:send_error(Req2, 403, <<"User account is not active">>);
|
||||
{error, {content_banned, _Words}} ->
|
||||
@@ -147,7 +155,6 @@ create_calendar(Req) ->
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
throw:stop -> ok;
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
@@ -161,7 +168,7 @@ list_calendars(Req) ->
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_calendar:list_calendars(UserId) of
|
||||
{ok, Calendars} ->
|
||||
Response = [calendar_to_json(C) || C <- Calendars],
|
||||
Response = [calendar_list_json(UserId, C) || C <- Calendars],
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
@@ -174,6 +181,21 @@ list_calendars(Req) ->
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
-spec calendar_list_json(binary(), #calendar{}) -> map().
|
||||
calendar_list_json(UserId, #calendar{owner_id = UserId} = Calendar) ->
|
||||
(calendar_to_json(Calendar))#{role => <<"owner">>};
|
||||
calendar_list_json(UserId, Calendar) ->
|
||||
Base = (calendar_to_json(Calendar))#{role => <<"shared">>},
|
||||
case core_calendar_share:get(Calendar#calendar.id, UserId) of
|
||||
{ok, Share} ->
|
||||
Base#{
|
||||
share_rights => Share#calendar_share.rights,
|
||||
mirror_to_default => Share#calendar_share.mirror_to_default
|
||||
};
|
||||
{error, not_found} ->
|
||||
Base
|
||||
end.
|
||||
|
||||
-spec calendar_to_json(#calendar{}) -> map().
|
||||
calendar_to_json(Calendar) ->
|
||||
Base = handler_utils:calendar_to_json(Calendar),
|
||||
|
||||
@@ -186,8 +186,12 @@ update_event(Req) ->
|
||||
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
||||
{error, event_in_past} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||
{error, archived} ->
|
||||
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
|
||||
{error, {content_banned, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, invalid_specialist} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid specialist_id for this calendar">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
@@ -213,6 +217,8 @@ delete_event(Req) ->
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, archived} ->
|
||||
handler_utils:send_error(Req1, 409, <<"Month is archived">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
@@ -243,14 +249,7 @@ convert_field({<<"recurrence">>, Val}) ->
|
||||
RuleJson = jsx:encode(Val),
|
||||
{recurrence_rule, RuleJson};
|
||||
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
||||
convert_field({<<"location">>, Val}) when is_map(Val) ->
|
||||
Loc = #location{
|
||||
address = maps:get(<<"address">>, Val, undefined),
|
||||
lat = maps:get(<<"lat">>, Val, undefined),
|
||||
lon = maps:get(<<"lon">>, Val, undefined)
|
||||
},
|
||||
{location, Loc};
|
||||
convert_field({<<"location">>, Val}) -> {location, Val};
|
||||
convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
|
||||
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
||||
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
||||
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Waitlist for events (Back#72).
|
||||
%%% POST/DELETE/GET /v1/events/:id/waitlist
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_event_waitlist).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> join(Req);
|
||||
<<"DELETE">> -> leave(Req);
|
||||
<<"GET">> -> get_status(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/events/:id/waitlist">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Join event waitlist (commercial, waitlist_enabled)">>,
|
||||
tags => [<<"Waitlist">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Joined">>},
|
||||
400 => #{description => <<"not_full / already_* / waitlist_disabled">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
403 => #{description => <<"Forbidden">>},
|
||||
404 => #{description => <<"Not found">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/events/:id/waitlist">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Leave event waitlist">>,
|
||||
tags => [<<"Waitlist">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Left">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
404 => #{description => <<"Not on waitlist">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/events/:id/waitlist">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Own waitlist status, or full list for owner/specialist">>,
|
||||
tags => [<<"Waitlist">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Status">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
404 => #{description => <<"Not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
join(Req) ->
|
||||
EventId = cowboy_req:binding(id, Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_waitlist:join(UserId, EventId) of
|
||||
{ok, Body} ->
|
||||
handler_utils:send_json(Req1, 200, Body);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, personal_calendar} ->
|
||||
handler_utils:send_error(Req1, 403, <<"personal_calendar">>);
|
||||
{error, subscription_inactive} ->
|
||||
handler_utils:send_error(Req1, 403, <<"subscription_inactive">>);
|
||||
{error, own_event} ->
|
||||
handler_utils:send_error(Req1, 403, <<"own_event">>);
|
||||
{error, waitlist_disabled} ->
|
||||
handler_utils:send_error(Req1, 400, <<"waitlist_disabled">>);
|
||||
{error, not_full} ->
|
||||
handler_utils:send_error(Req1, 400, <<"not_full">>);
|
||||
{error, already_booked} ->
|
||||
handler_utils:send_error(Req1, 400, <<"already_booked">>);
|
||||
{error, already_on_waitlist} ->
|
||||
handler_utils:send_error(Req1, 400, <<"already_on_waitlist">>);
|
||||
{error, event_not_active} ->
|
||||
handler_utils:send_error(Req1, 400, <<"event_not_active">>);
|
||||
{error, Reason} when is_atom(Reason) ->
|
||||
handler_utils:send_error(Req1, 400, atom_to_binary(Reason, utf8));
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid request">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
leave(Req) ->
|
||||
EventId = cowboy_req:binding(id, Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_waitlist:leave(UserId, EventId) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req1, 200, #{ok => true});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Not on waitlist">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
get_status(Req) ->
|
||||
EventId = cowboy_req:binding(id, Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_waitlist:list_for_manager(UserId, EventId) of
|
||||
{ok, Body} ->
|
||||
handler_utils:send_json(Req1, 200, Body);
|
||||
{error, access_denied} ->
|
||||
case logic_waitlist:status_for_user(UserId, EventId) of
|
||||
{ok, Body} ->
|
||||
handler_utils:send_json(Req1, 200, Body);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Forbidden">>)
|
||||
end;
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -140,6 +140,7 @@ event_create_schema() ->
|
||||
lon => #{type => number, format => float}
|
||||
}
|
||||
},
|
||||
specialist_id => #{type => string, nullable => true},
|
||||
recurrence => #{type => object, description => <<"Recurrence rule (RFC 5545)">>}
|
||||
}
|
||||
}.
|
||||
@@ -176,16 +177,15 @@ create_event(Req) ->
|
||||
undefined ->
|
||||
case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration, Description) of
|
||||
{ok, Event} ->
|
||||
update_event_fields(UserId, Event#event.id, Location, Decoded),
|
||||
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
|
||||
Response = handler_utils:event_to_json(UpdatedEvent),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
finish_create_event(Req2, UserId, Event, Location, Decoded);
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, event_in_past} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||
{error, archived} ->
|
||||
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
|
||||
{error, {content_banned, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, _} ->
|
||||
@@ -194,10 +194,7 @@ create_event(Req) ->
|
||||
RRule ->
|
||||
case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, Description) of
|
||||
{ok, Event} ->
|
||||
update_event_fields(UserId, Event#event.id, Location, Decoded),
|
||||
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
|
||||
Response = handler_utils:event_to_json(UpdatedEvent),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
finish_create_event(Req2, UserId, Event, Location, Decoded);
|
||||
{error, invalid_rrule} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid recurrence rule">>);
|
||||
{error, access_denied} ->
|
||||
@@ -206,6 +203,8 @@ create_event(Req) ->
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, event_in_past} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||
{error, archived} ->
|
||||
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
|
||||
{error, {content_banned, _}} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||
{error, _} ->
|
||||
@@ -230,7 +229,7 @@ create_event(Req) ->
|
||||
%% @doc GET /v1/calendars/:calendar_id/events — список событий.
|
||||
-spec list_events(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
list_events(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
case handler_utils:auth_user_optional(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(calendar_id, Req1),
|
||||
Qs = cowboy_req:parse_qs(Req1),
|
||||
@@ -244,7 +243,10 @@ list_events(Req) ->
|
||||
{FromStr, ToStr} ->
|
||||
FromDt = parse_datetime_binary(FromStr),
|
||||
ToDt = parse_datetime_binary(ToStr),
|
||||
expand_recurring_events(UserId, Events, FromDt, ToDt)
|
||||
HotJson = expand_recurring_events(UserId, Events, FromDt, ToDt),
|
||||
SnapJson = logic_month_archive:events_json_in_range(
|
||||
CalendarId, FromDt, ToDt),
|
||||
merge_event_json(HotJson, SnapJson)
|
||||
end,
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, access_denied} ->
|
||||
@@ -262,6 +264,21 @@ list_events(Req) ->
|
||||
%%% Вспомогательные функции
|
||||
%%%===================================================================
|
||||
|
||||
finish_create_event(Req, UserId, Event, Location, Decoded) ->
|
||||
case update_event_fields(UserId, Event#event.id, Location, Decoded) of
|
||||
ok ->
|
||||
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
|
||||
Response = handler_utils:event_to_json(UpdatedEvent),
|
||||
handler_utils:send_json(Req, 201, Response);
|
||||
{error, invalid_specialist} ->
|
||||
_ = logic_event:delete_event(UserId, Event#event.id),
|
||||
handler_utils:send_error(Req, 400, <<"Invalid specialist_id for this calendar">>);
|
||||
{error, _} ->
|
||||
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
|
||||
Response = handler_utils:event_to_json(UpdatedEvent),
|
||||
handler_utils:send_json(Req, 201, Response)
|
||||
end.
|
||||
|
||||
update_event_fields(UserId, EventId, Location, Decoded) ->
|
||||
Updates = [],
|
||||
Updates1 = case Location of undefined -> Updates; _ -> [{location, Location} | Updates] end,
|
||||
@@ -269,47 +286,58 @@ update_event_fields(UserId, EventId, Location, Decoded) ->
|
||||
Updates3 = case maps:get(<<"tags">>, Decoded, undefined) of undefined -> Updates2; Tags -> [{tags, Tags} | Updates2] end,
|
||||
%% description already applied in create_event/6 when present
|
||||
Updates4 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates3; Link -> [{online_link, Link} | Updates3] end,
|
||||
case Updates4 of
|
||||
Updates5 = case maps:get(<<"specialist_id">>, Decoded, undefined) of
|
||||
undefined -> Updates4;
|
||||
null -> [{specialist_id, <<>>} | Updates4];
|
||||
SpecId when is_binary(SpecId) -> [{specialist_id, SpecId} | Updates4];
|
||||
_ -> Updates4
|
||||
end,
|
||||
case Updates5 of
|
||||
[] -> ok;
|
||||
_ ->
|
||||
case logic_event:update_event(UserId, EventId, Updates4) of
|
||||
case logic_event:update_event(UserId, EventId, Updates5) of
|
||||
{ok, _} -> ok;
|
||||
_ -> ok
|
||||
{error, _} = Err -> Err
|
||||
end
|
||||
end.
|
||||
|
||||
parse_location(undefined) -> undefined;
|
||||
parse_location(LocationMap) when is_map(LocationMap) ->
|
||||
case LocationMap of
|
||||
#{<<"lat">> := Lat, <<"lon">> := Lon} ->
|
||||
Address = maps:get(<<"address">>, LocationMap, <<"">>),
|
||||
#location{address = Address, lat = Lat, lon = Lon};
|
||||
_ -> undefined
|
||||
end;
|
||||
parse_location(_) -> undefined.
|
||||
parse_location(LocationMap) -> handler_utils:parse_location(LocationMap).
|
||||
|
||||
expand_recurring_events(UserId, Events, From, To) ->
|
||||
lists:flatmap(fun(Event) ->
|
||||
case Event#event.event_type of
|
||||
single ->
|
||||
case logic_month_archive:month_is_closed(Event#event.start_time) of
|
||||
true -> [];
|
||||
false ->
|
||||
case is_in_range(Event#event.start_time, From, To) of
|
||||
true -> [handler_utils:event_to_json(Event)];
|
||||
false -> []
|
||||
end
|
||||
end;
|
||||
recurring ->
|
||||
case logic_event:get_occurrences(UserId, Event#event.id, To) of
|
||||
{ok, Occurrences} ->
|
||||
lists:filtermap(
|
||||
fun({virtual, Occ}) ->
|
||||
case logic_month_archive:month_is_closed(Occ) of
|
||||
true -> false;
|
||||
false ->
|
||||
case is_in_range(Occ, From, To) of
|
||||
true -> {true, occurrence_to_json(Event, Occ)};
|
||||
false -> false
|
||||
end
|
||||
end;
|
||||
({materialized, Instance}) ->
|
||||
case logic_month_archive:month_is_closed(Instance#event.start_time) of
|
||||
true -> false;
|
||||
false ->
|
||||
case is_in_range(Instance#event.start_time, From, To) of
|
||||
true -> {true, handler_utils:event_to_json(Instance)};
|
||||
false -> false
|
||||
end
|
||||
end
|
||||
end, Occurrences);
|
||||
_ -> []
|
||||
end
|
||||
@@ -318,15 +346,29 @@ expand_recurring_events(UserId, Events, From, To) ->
|
||||
|
||||
is_in_range(Time, From, To) -> Time >= From andalso Time =< To.
|
||||
|
||||
merge_event_json(Hot, Snap) ->
|
||||
Ids = maps:from_list(
|
||||
[{maps:get(<<"id">>, M, maps:get(id, M, undefined)), true} || M <- Hot]),
|
||||
Extra = [M || M <- Snap,
|
||||
begin
|
||||
Id = maps:get(<<"id">>, M, maps:get(id, M, undefined)),
|
||||
Id =:= undefined orelse not maps:is_key(Id, Ids)
|
||||
end],
|
||||
Hot ++ Extra.
|
||||
|
||||
parse_datetime_binary(Str) ->
|
||||
{ok, Dt} = handler_utils:parse_datetime(Str),
|
||||
Dt.
|
||||
|
||||
occurrence_to_json(Master, Occurrence) ->
|
||||
%% Virtual row: occupancy by master event id (materialized instances use event_to_json).
|
||||
#{
|
||||
master_id => Master#event.id,
|
||||
start_time => handler_utils:datetime_to_iso8601(Occurrence),
|
||||
duration => Master#event.duration,
|
||||
title => Master#event.title,
|
||||
is_virtual => true
|
||||
is_virtual => true,
|
||||
specialist_id => Master#event.specialist_id,
|
||||
calendar_id => Master#event.calendar_id,
|
||||
booking_occupancy => handler_utils:booking_occupancy(Master)
|
||||
}.
|
||||
@@ -0,0 +1,162 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc GET /v1/geo/suggest, POST /v1/geo/geocode, POST /v1/geo/reverse.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_geo).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/geo/suggest">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Address typeahead (OpenCage). Public + IP rate-limit.">>,
|
||||
tags => [<<"Geo">>],
|
||||
parameters => [
|
||||
#{name => <<"q">>, in => <<"query">>, required => true, schema => #{type => string}},
|
||||
#{name => <<"lang">>, in => <<"query">>, schema => #{type => string}}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{description => <<"OK">>},
|
||||
400 => #{description => <<"invalid_query">>},
|
||||
429 => #{description => <<"rate limited">>},
|
||||
503 => #{description => <<"geo_unavailable">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/geo/geocode">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Forward geocode (OpenCage). Bearer required.">>,
|
||||
tags => [<<"Geo">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"OK">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
404 => #{description => <<"not_found">>},
|
||||
503 => #{description => <<"geo_unavailable">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/geo/reverse">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Reverse geocode (OpenCage). Bearer required.">>,
|
||||
tags => [<<"Geo">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"OK">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
503 => #{description => <<"geo_unavailable">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
Path = cowboy_req:path(Req),
|
||||
Method = cowboy_req:method(Req),
|
||||
case {Path, Method} of
|
||||
{<<"/v1/geo/suggest">>, <<"GET">>} -> suggest(Req);
|
||||
{<<"/v1/geo/geocode">>, <<"POST">>} -> geocode(Req);
|
||||
{<<"/v1/geo/reverse">>, <<"POST">>} -> reverse(Req);
|
||||
{_, <<"GET">>} when Path =:= <<"/v1/geo/geocode">> orelse Path =:= <<"/v1/geo/reverse">> ->
|
||||
handler_utils:send_error(Req, 405, <<"Method not allowed">>);
|
||||
{<<"/v1/geo/suggest">>, _} ->
|
||||
handler_utils:send_error(Req, 405, <<"Method not allowed">>);
|
||||
_ ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>)
|
||||
end.
|
||||
|
||||
suggest(Req) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
Q = proplists:get_value(<<"q">>, Qs, <<>>),
|
||||
Lang = proplists:get_value(<<"lang">>, Qs, <<>>),
|
||||
Key = <<"s:", (client_key(Req))/binary>>,
|
||||
reply_suggest(Req, logic_geo:suggest(Q, Lang, Key)).
|
||||
|
||||
geocode(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
case decode_map(Body) of
|
||||
{ok, Map} ->
|
||||
Q = maps:get(<<"q">>, Map, <<>>),
|
||||
Lang = maps:get(<<"lang">>, Map, <<>>),
|
||||
Key = <<"g:", UserId/binary>>,
|
||||
reply_hit(Req2, logic_geo:geocode(Q, Lang, Key), true);
|
||||
error ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
reverse(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
case decode_map(Body) of
|
||||
{ok, Map} ->
|
||||
Lat = maps:get(<<"lat">>, Map, undefined),
|
||||
Lon = maps:get(<<"lon">>, Map, undefined),
|
||||
Lang = maps:get(<<"lang">>, Map, <<>>),
|
||||
Key = <<"r:", UserId/binary>>,
|
||||
reply_hit(Req2, logic_geo:reverse(Lat, Lon, Lang, Key), false);
|
||||
error ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
reply_suggest(Req, {ok, Hits}) ->
|
||||
handler_utils:send_json(Req, 200, #{<<"results">> => Hits});
|
||||
reply_suggest(Req, {error, invalid_query}) ->
|
||||
handler_utils:send_error(Req, 400, <<"invalid_query">>);
|
||||
reply_suggest(Req, {error, rate_limited}) ->
|
||||
handler_utils:send_error(Req, 429, <<"rate_limited">>);
|
||||
reply_suggest(Req, {error, unavailable}) ->
|
||||
handler_utils:send_error(Req, 503, <<"geo_unavailable">>).
|
||||
|
||||
reply_hit(Req, {ok, Hit}, _NotFound404) ->
|
||||
handler_utils:send_json(Req, 200, Hit);
|
||||
reply_hit(Req, {error, not_found}, true) ->
|
||||
handler_utils:send_error(Req, 404, <<"not_found">>);
|
||||
reply_hit(Req, {error, invalid_query}, _) ->
|
||||
handler_utils:send_error(Req, 400, <<"invalid_query">>);
|
||||
reply_hit(Req, {error, rate_limited}, _) ->
|
||||
handler_utils:send_error(Req, 429, <<"rate_limited">>);
|
||||
reply_hit(Req, {error, unavailable}, _) ->
|
||||
handler_utils:send_error(Req, 503, <<"geo_unavailable">>).
|
||||
|
||||
decode_map(Body) when is_binary(Body) ->
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) -> {ok, Map};
|
||||
_ -> error
|
||||
catch
|
||||
_:_ -> error
|
||||
end.
|
||||
|
||||
client_key(Req) ->
|
||||
case cowboy_req:header(<<"x-real-ip">>, Req) of
|
||||
undefined ->
|
||||
case cowboy_req:header(<<"x-forwarded-for">>, Req) of
|
||||
undefined -> peer_ip(Req);
|
||||
<<>> -> peer_ip(Req);
|
||||
Fwd ->
|
||||
case binary:split(Fwd, <<",">>) of
|
||||
[First | _] -> string:trim(First);
|
||||
_ -> peer_ip(Req)
|
||||
end
|
||||
end;
|
||||
<<>> -> peer_ip(Req);
|
||||
IP -> IP
|
||||
end.
|
||||
|
||||
peer_ip(Req) ->
|
||||
case cowboy_req:peer(Req) of
|
||||
{{A, B, C, D}, _} ->
|
||||
iolist_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
|
||||
_ -> <<"unknown">>
|
||||
end.
|
||||
@@ -33,7 +33,9 @@ trails() ->
|
||||
required => [<<"email">>, <<"password">>],
|
||||
properties => #{
|
||||
email => #{type => string, format => <<"email">>},
|
||||
password => #{type => string, format => <<"password">>}
|
||||
password => #{type => string, format => <<"password">>},
|
||||
client_type => #{type => string, enum => [<<"web">>, <<"mobile">>]},
|
||||
device_name => #{type => string}
|
||||
}
|
||||
}}}
|
||||
},
|
||||
@@ -69,12 +71,20 @@ login(Req) ->
|
||||
handler_utils:send_error(Req1, 400, <<"Empty request body">>);
|
||||
_ ->
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"email">> := Email, <<"password">> := Password} ->
|
||||
#{<<"email">> := Email, <<"password">> := Password} = Map ->
|
||||
ClientType = maps:get(<<"client_type">>, Map, <<"web">>),
|
||||
DeviceName = maps:get(<<"device_name">>, Map, <<>>),
|
||||
UserAgent = case cowboy_req:header(<<"user-agent">>, Req1) of
|
||||
undefined -> <<>>;
|
||||
UA -> UA
|
||||
end,
|
||||
case eventhub_auth:authenticate_user_request(Req1, Email, Password) of
|
||||
{ok, _Token, User} ->
|
||||
UserId = maps:get(id, User),
|
||||
Role = maps:get(role, User),
|
||||
{ok, Token, RefreshToken} = logic_auth_session:issue_user_tokens(UserId, Role),
|
||||
case logic_auth_session:issue_user_tokens(
|
||||
UserId, Role, ClientType, DeviceName, UserAgent) of
|
||||
{ok, Token, RefreshToken, SessionId} ->
|
||||
core_user:update_last_login(UserId),
|
||||
Response = #{
|
||||
<<"token">> => Token,
|
||||
@@ -83,9 +93,13 @@ login(Req) ->
|
||||
<<"email">> => maps:get(email, User),
|
||||
<<"role">> => maps:get(role, User)
|
||||
},
|
||||
<<"refresh_token">> => RefreshToken
|
||||
<<"refresh_token">> => RefreshToken,
|
||||
<<"session_id">> => SessionId
|
||||
},
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, invalid_client_type} ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid client_type">>)
|
||||
end;
|
||||
{error, not_verified} ->
|
||||
handler_utils:send_error(Req1, 403, <<"Account not verified">>);
|
||||
{error, frozen} ->
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/logout — отзыв user refresh-сессии (вариант A / Back#69).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_logout).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> logout(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/logout">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Revoke current user refresh session">>,
|
||||
tags => [<<"Auth">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{
|
||||
<<"application/json">> => #{
|
||||
schema => #{
|
||||
type => object,
|
||||
required => [<<"refresh_token">>],
|
||||
properties => #{refresh_token => #{type => string}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Session revoked">>},
|
||||
400 => #{description => <<"Missing refresh_token or invalid JSON">>},
|
||||
401 => #{description => <<"Invalid refresh token">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
logout(Req) ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"refresh_token">> := RefreshToken} ->
|
||||
case logic_auth_session:logout_user(RefreshToken) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req1, 200, #{ok => true});
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 401, <<"Invalid refresh token">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Missing refresh_token field">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
end.
|
||||
@@ -0,0 +1,69 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc GET /v1/media/:kind/:owner/:file — public static upload serve.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_media).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_media(Req);
|
||||
<<"HEAD">> -> get_media(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/media/:kind/:owner/:file">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Serve uploaded avatar or cover image">>,
|
||||
tags => [<<"Media">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Image bytes">>},
|
||||
404 => #{description => <<"Not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
get_media(Req) ->
|
||||
KindB = cowboy_req:binding(kind, Req),
|
||||
Owner = cowboy_req:binding(owner, Req),
|
||||
File = cowboy_req:binding(file, Req),
|
||||
Kind = case KindB of
|
||||
<<"avatar">> -> avatar;
|
||||
<<"cover">> -> cover;
|
||||
_ -> invalid
|
||||
end,
|
||||
case Kind of
|
||||
invalid ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>);
|
||||
_ ->
|
||||
case logic_upload:media_abs_path(Kind, Owner, File) of
|
||||
{ok, Path} ->
|
||||
Mime = mime_for(File),
|
||||
case file:read_file(Path) of
|
||||
{ok, Bin} ->
|
||||
Headers = #{
|
||||
<<"content-type">> => Mime,
|
||||
<<"cache-control">> => <<"public, max-age=86400">>
|
||||
},
|
||||
Req1 = cowboy_req:reply(200, Headers, Bin, Req),
|
||||
{ok, Bin, Req1};
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>)
|
||||
end;
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>)
|
||||
end
|
||||
end.
|
||||
|
||||
mime_for(File) when is_binary(File) ->
|
||||
case filename:extension(binary_to_list(File)) of
|
||||
".png" -> <<"image/png">>;
|
||||
".webp" -> <<"image/webp">>;
|
||||
_ -> <<"image/jpeg">>
|
||||
end.
|
||||
@@ -0,0 +1,79 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc GET/PUT /v1/notifications/prefs (Back#75).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_notification_prefs).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
Method = cowboy_req:method(Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case Method of
|
||||
<<"GET">> ->
|
||||
case logic_notification_prefs:get_prefs(UserId) of
|
||||
{ok, Prefs} ->
|
||||
handler_utils:send_json(Req1, 200, Prefs);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"User not found">>)
|
||||
end;
|
||||
<<"PUT">> ->
|
||||
put_prefs(Req1, UserId);
|
||||
_ ->
|
||||
cowboy_req:reply(405, #{}, <<>>, Req1)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end,
|
||||
{ok, Req, Opts}.
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/notifications/prefs">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get notification channel prefs (email/push)">>,
|
||||
tags => [<<"Notifications">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Prefs">>},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/notifications/prefs">>,
|
||||
method => <<"PUT">>,
|
||||
description => <<"Update notification channel prefs">>,
|
||||
tags => [<<"Notifications">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated prefs">>},
|
||||
400 => #{description => <<"Invalid body">>},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
put_prefs(Req0, UserId) ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req0),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
case {maps:get(<<"email">>, Map, undefined), maps:get(<<"push">>, Map, undefined)} of
|
||||
{Email, Push} when is_boolean(Email), is_boolean(Push) ->
|
||||
case logic_notification_prefs:put_prefs(UserId, #{email => Email, push => Push}) of
|
||||
{ok, Prefs} ->
|
||||
handler_utils:send_json(Req1, 200, Prefs);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"User not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Update failed">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"email and push booleans required">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end.
|
||||
@@ -0,0 +1,112 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Web Push VAPID key + subscriptions (Back#75).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_push).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, Opts) ->
|
||||
Path = cowboy_req:path(Req),
|
||||
Method = cowboy_req:method(Req),
|
||||
case Path of
|
||||
<<"/v1/push/vapid-public-key">> ->
|
||||
case logic_web_push:vapid_public_key() of
|
||||
{ok, Key} ->
|
||||
handler_utils:send_json(Req, 200, #{publicKey => Key});
|
||||
{error, missing} ->
|
||||
handler_utils:send_error(Req, 503, <<"VAPID not configured">>)
|
||||
end;
|
||||
<<"/v1/push/subscriptions">> ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case Method of
|
||||
<<"POST">> -> post_sub(Req1, UserId);
|
||||
<<"DELETE">> -> delete_sub(Req1, UserId);
|
||||
_ -> cowboy_req:reply(405, #{}, <<>>, Req1)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end;
|
||||
_ ->
|
||||
cowboy_req:reply(404, #{}, <<>>, Req)
|
||||
end,
|
||||
{ok, Req, Opts}.
|
||||
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/push/vapid-public-key">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"VAPID public key for Web Push subscribe">>,
|
||||
tags => [<<"Notifications">>],
|
||||
responses => #{200 => #{description => <<"OK">>}, 503 => #{description => <<"Not configured">>}}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/push/subscriptions">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Register Web Push subscription">>,
|
||||
tags => [<<"Notifications">>],
|
||||
responses => #{200 => #{description => <<"OK">>}, 401 => #{description => <<"Unauthorized">>}}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/push/subscriptions">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Remove Web Push subscription">>,
|
||||
tags => [<<"Notifications">>],
|
||||
responses => #{204 => #{description => <<"Deleted">>}, 401 => #{description => <<"Unauthorized">>}}
|
||||
}
|
||||
].
|
||||
|
||||
post_sub(Req0, UserId) ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req0),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
Endpoint = maps:get(<<"endpoint">>, Map, undefined),
|
||||
Keys = maps:get(<<"keys">>, Map, #{}),
|
||||
P256 = maps:get(<<"p256dh">>, Keys, undefined),
|
||||
Auth = maps:get(<<"auth">>, Keys, undefined),
|
||||
case {Endpoint, P256, Auth} of
|
||||
{E, P, A} when is_binary(E), E =/= <<>>,
|
||||
is_binary(P), P =/= <<>>,
|
||||
is_binary(A), A =/= <<>> ->
|
||||
case core_push_subscription:upsert(UserId, E, P, A) of
|
||||
{ok, Rec} ->
|
||||
handler_utils:send_json(Req1, 200, #{
|
||||
id => Rec#push_subscription.id,
|
||||
endpoint => Rec#push_subscription.endpoint
|
||||
});
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Save failed">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"endpoint and keys.p256dh/auth required">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end.
|
||||
|
||||
delete_sub(Req0, UserId) ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req0),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) ->
|
||||
Endpoint = maps:get(<<"endpoint">>, Map, <<>>),
|
||||
case Endpoint of
|
||||
<<>> ->
|
||||
handler_utils:send_error(Req1, 400, <<"endpoint required">>);
|
||||
Ep ->
|
||||
_ = core_push_subscription:delete_by_endpoint(UserId, Ep),
|
||||
cowboy_req:reply(204, #{}, <<>>, Req1)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid JSON format">>)
|
||||
end.
|
||||
@@ -57,7 +57,8 @@ token_pair_schema() ->
|
||||
type => object,
|
||||
properties => #{
|
||||
token => #{type => string, description => <<"JWT access token">>},
|
||||
refresh_token => #{type => string, description => <<"Refresh token">>}
|
||||
refresh_token => #{type => string, description => <<"Refresh token">>},
|
||||
session_id => #{type => string, description => <<"auth_session id">>}
|
||||
}
|
||||
}.
|
||||
|
||||
@@ -72,10 +73,11 @@ refresh(Req) ->
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"refresh_token">> := RefreshToken} ->
|
||||
case logic_auth_session:refresh_user(RefreshToken) of
|
||||
{ok, NewToken, NewRefreshToken} ->
|
||||
{ok, NewToken, NewRefreshToken, SessionId} ->
|
||||
handler_utils:send_json(Req1, 200, #{
|
||||
token => NewToken,
|
||||
refresh_token => NewRefreshToken
|
||||
refresh_token => NewRefreshToken,
|
||||
session_id => SessionId
|
||||
});
|
||||
{error, reuse_detected} ->
|
||||
handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>);
|
||||
|
||||
@@ -32,7 +32,7 @@ trails() ->
|
||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>},
|
||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>},
|
||||
#{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>},
|
||||
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>]}, description => <<"Field to sort by">>},
|
||||
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>, <<"distance">>]}, description => <<"Field to sort by">>},
|
||||
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>},
|
||||
#{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>},
|
||||
#{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>},
|
||||
@@ -74,12 +74,15 @@ handle(Req, _Opts) ->
|
||||
%% @doc GET /v1/search — полнотекстовый поиск с фильтрами.
|
||||
-spec search(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
search(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
case handler_utils:auth_user_optional(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
Qs = cowboy_req:parse_qs(Req1),
|
||||
Type = proplists:get_value(<<"type">>, Qs, undefined),
|
||||
Query = proplists:get_value(<<"q">>, Qs, undefined),
|
||||
Params = parse_params(Qs),
|
||||
case parse_params(Qs) of
|
||||
{error, invalid_radius} ->
|
||||
handler_utils:send_error(Req1, 400, <<"invalid_radius">>);
|
||||
Params ->
|
||||
case logic_search:search(Type, Query, UserId, Params) of
|
||||
{ok, Total, Results} ->
|
||||
Response = #{
|
||||
@@ -91,6 +94,7 @@ search(Req) ->
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Search failed">>)
|
||||
end
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
@@ -103,7 +107,8 @@ search(Req) ->
|
||||
%% @private Собирает карту параметров для поискового движка.
|
||||
%% Не кладёт sort/tags/geo/даты, если их нет в QS — иначе
|
||||
%% logic_search:is_discovery_request/2 никогда не сработает (Back#50).
|
||||
-spec parse_params(cowboy_req:qs()) -> map().
|
||||
%% radius без ключа при lat/lon — без отсечения (GEO.md).
|
||||
-spec parse_params(cowboy_req:qs()) -> map() | {error, invalid_radius}.
|
||||
parse_params(Qs) ->
|
||||
Params0 = #{
|
||||
limit => parse_int_param(Qs, <<"limit">>, 20),
|
||||
@@ -125,12 +130,22 @@ parse_params(Qs) ->
|
||||
end,
|
||||
Params1#{sort => Sort, order => Order}
|
||||
end,
|
||||
Params3 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
||||
case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
||||
{{ok, Lat}, {ok, Lon}} ->
|
||||
Radius = parse_int_param(Qs, <<"radius">>, 10),
|
||||
Params2#{lat => Lat, lon => Lon, radius => Radius};
|
||||
_ -> Params2
|
||||
case parse_optional_radius(Qs) of
|
||||
{error, invalid_radius} = E -> E;
|
||||
Radius ->
|
||||
Params3 = case Radius of
|
||||
undefined -> Params2#{lat => Lat, lon => Lon};
|
||||
R -> Params2#{lat => Lat, lon => Lon, radius => R}
|
||||
end,
|
||||
with_dates(Qs, Params3)
|
||||
end;
|
||||
_ ->
|
||||
with_dates(Qs, Params2)
|
||||
end.
|
||||
|
||||
with_dates(Qs, Params3) ->
|
||||
case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
||||
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
|
||||
{{ok, From}, error} -> Params3#{from => From};
|
||||
@@ -138,6 +153,18 @@ parse_params(Qs) ->
|
||||
_ -> Params3
|
||||
end.
|
||||
|
||||
parse_optional_radius(Qs) ->
|
||||
case proplists:get_value(<<"radius">>, Qs) of
|
||||
undefined -> undefined;
|
||||
<<>> -> undefined;
|
||||
Val ->
|
||||
N = handler_utils:parse_int_qs(Val, 0),
|
||||
case N >= 1 andalso N =< 100 of
|
||||
true -> N;
|
||||
false -> {error, invalid_radius}
|
||||
end
|
||||
end.
|
||||
|
||||
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
|
||||
parse_int_param(Qs, Key, Default) ->
|
||||
handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default).
|
||||
@@ -146,7 +173,18 @@ parse_int_param(Qs, Key, Default) ->
|
||||
parse_float_param(Qs, Key) ->
|
||||
case proplists:get_value(Key, Qs) of
|
||||
undefined -> error;
|
||||
Val -> {ok, binary_to_float(Val)}
|
||||
<<>> -> error;
|
||||
Val ->
|
||||
try binary_to_float(Val) of
|
||||
F -> {ok, F}
|
||||
catch
|
||||
_:_ ->
|
||||
try binary_to_integer(Val) of
|
||||
I -> {ok, float(I)}
|
||||
catch
|
||||
_:_ -> error
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
-spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc User session list / revoke (Back#74).
|
||||
%%% GET /v1/sessions
|
||||
%%% DELETE /v1/sessions/:id
|
||||
%%% POST /v1/sessions/revoke-others
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_sessions).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, Opts) ->
|
||||
Method = cowboy_req:method(Req),
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case Method of
|
||||
<<"GET">> ->
|
||||
list_sessions(Req1, UserId);
|
||||
<<"POST">> ->
|
||||
case cowboy_req:path(Req1) of
|
||||
<<"/v1/sessions/revoke-others">> ->
|
||||
revoke_others(Req1, UserId);
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 405, <<"Method not allowed">>)
|
||||
end;
|
||||
<<"DELETE">> ->
|
||||
SessionId = cowboy_req:binding(id, Req1),
|
||||
revoke_one(Req1, UserId, SessionId);
|
||||
_ ->
|
||||
handler_utils:send_error(Req1, 405, <<"Method not allowed">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end,
|
||||
{ok, Req, Opts}.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/sessions">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"List active user sessions">>,
|
||||
tags => [<<"Auth">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Session list">>},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/sessions/:id">>,
|
||||
method => <<"DELETE">>,
|
||||
description => <<"Revoke one own session">>,
|
||||
tags => [<<"Auth">>],
|
||||
responses => #{
|
||||
200 => #{description => <<"Revoked">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
404 => #{description => <<"Not found">>}
|
||||
}
|
||||
},
|
||||
#{
|
||||
path => <<"/v1/sessions/revoke-others">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Revoke all sessions except current (from refresh_token)">>,
|
||||
tags => [<<"Auth">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{
|
||||
<<"application/json">> => #{
|
||||
schema => #{
|
||||
type => object,
|
||||
required => [<<"refresh_token">>],
|
||||
properties => #{refresh_token => #{type => string}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Others revoked">>},
|
||||
400 => #{description => <<"Bad request">>},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
list_sessions(Req, UserId) ->
|
||||
Items = [session_to_json(S) || S <- logic_auth_session:list_user_sessions(UserId)],
|
||||
handler_utils:send_json(Req, 200, #{<<"sessions">> => Items}).
|
||||
|
||||
revoke_one(Req, UserId, SessionId) when is_binary(SessionId), SessionId =/= <<>> ->
|
||||
case logic_auth_session:revoke_user_session(UserId, SessionId) of
|
||||
ok ->
|
||||
handler_utils:send_json(Req, 200, #{<<"ok">> => true});
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req, 404, <<"Session not found">>)
|
||||
end;
|
||||
revoke_one(Req, _UserId, _) ->
|
||||
handler_utils:send_error(Req, 404, <<"Session not found">>).
|
||||
|
||||
revoke_others(Req0, UserId) ->
|
||||
{ok, Body, Req} = cowboy_req:read_body(Req0),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"refresh_token">> := RefreshToken} ->
|
||||
case logic_auth_session:revoke_other_user_sessions(UserId, RefreshToken) of
|
||||
{ok, Count} ->
|
||||
handler_utils:send_json(Req, 200, #{<<"ok">> => true, <<"revoked">> => Count});
|
||||
{error, forbidden} ->
|
||||
handler_utils:send_error(Req, 403, <<"Refresh token subject mismatch">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req, 401, <<"Invalid refresh token">>)
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req, 400, <<"Missing refresh_token field">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req, 400, <<"Invalid JSON">>)
|
||||
end.
|
||||
|
||||
session_to_json(#auth_session{} = S) ->
|
||||
#{
|
||||
<<"session_id">> => S#auth_session.session_id,
|
||||
<<"client_type">> => S#auth_session.client_type,
|
||||
<<"device_name">> => S#auth_session.device_name,
|
||||
<<"user_agent">> => S#auth_session.user_agent,
|
||||
<<"created_at">> => handler_utils:datetime_to_iso8601(S#auth_session.created_at),
|
||||
<<"updated_at">> => handler_utils:datetime_to_iso8601(S#auth_session.updated_at),
|
||||
<<"expires_at">> => handler_utils:datetime_to_iso8601(S#auth_session.expires_at)
|
||||
}.
|
||||
@@ -0,0 +1,156 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Invitee: входящие share invites + accept/decline.
|
||||
%%%
|
||||
%%% GET /v1/user/share-invites
|
||||
%%% POST /v1/share-invites/:id/accept | decline
|
||||
%%% POST /v1/share-invites/accept body: {token, mirror_to_default?}
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_share_invites).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2, trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
trails() ->
|
||||
IdParam = #{name => <<"id">>, in => <<"path">>, required => true,
|
||||
schema => #{type => string}},
|
||||
[
|
||||
#{path => <<"/v1/user/share-invites">>, method => <<"GET">>,
|
||||
description => <<"Incoming share invites">>, tags => [<<"Users">>],
|
||||
responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/share-invites/accept">>, method => <<"POST">>,
|
||||
description => <<"Accept share invite by token">>, tags => [<<"Users">>],
|
||||
responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/share-invites/:id/accept">>, method => <<"POST">>,
|
||||
description => <<"Accept share invite">>, tags => [<<"Users">>],
|
||||
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}},
|
||||
#{path => <<"/v1/share-invites/:id/decline">>, method => <<"POST">>,
|
||||
description => <<"Decline share invite">>, tags => [<<"Users">>],
|
||||
parameters => [IdParam], responses => #{200 => #{description => <<"OK">>}}}
|
||||
].
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
Method = cowboy_req:method(Req),
|
||||
Path = cowboy_req:path(Req),
|
||||
case Method of
|
||||
<<"GET">> ->
|
||||
list_incoming(Req);
|
||||
<<"POST">> ->
|
||||
case Path of
|
||||
<<"/v1/share-invites/accept">> ->
|
||||
accept_token(Req);
|
||||
_ ->
|
||||
case {cowboy_req:binding(id, Req), path_action(Path)} of
|
||||
{Id, accept} when is_binary(Id) -> accept_id(Req, Id);
|
||||
{Id, decline} when is_binary(Id) -> decline_id(Req, Id);
|
||||
_ -> handler_utils:send_error(Req, 404, <<"Not found">>)
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
path_action(Path) ->
|
||||
case binary:match(Path, <<"/accept">>) of
|
||||
nomatch ->
|
||||
case binary:match(Path, <<"/decline">>) of
|
||||
nomatch -> unknown;
|
||||
_ -> decline
|
||||
end;
|
||||
_ -> accept
|
||||
end.
|
||||
|
||||
list_incoming(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, List} = logic_calendar_share_invite:list_incoming(UserId),
|
||||
handler_utils:send_json(Req1, 200,
|
||||
[logic_calendar_share_invite:to_json(I) || I <- List]);
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
accept_id(Req, InviteId) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
Opts = parse_accept_opts(Body),
|
||||
reply_accept(Req2, logic_calendar_share_invite:accept(UserId, InviteId, Opts));
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
decline_id(Req, InviteId) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_calendar_share_invite:decline(UserId, InviteId) of
|
||||
{ok, Inv} ->
|
||||
handler_utils:send_json(Req1, 200, logic_calendar_share_invite:to_json(Inv));
|
||||
{error, Reason} ->
|
||||
map_decide_error(Req1, Reason)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
accept_token(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"token">> := Token} = Map when is_binary(Token), Token =/= <<>> ->
|
||||
Opts = accept_opts_from_map(Map),
|
||||
reply_accept(Req2,
|
||||
logic_calendar_share_invite:accept_by_token(UserId, Token, Opts));
|
||||
_ ->
|
||||
handler_utils:send_error(Req2, 400, <<"token required">>)
|
||||
catch
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
parse_accept_opts(<<>>) -> #{};
|
||||
parse_accept_opts(Body) ->
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Map when is_map(Map) -> accept_opts_from_map(Map);
|
||||
_ -> #{}
|
||||
catch
|
||||
_:_ -> #{}
|
||||
end.
|
||||
|
||||
accept_opts_from_map(Map) ->
|
||||
case maps:get(<<"mirror_to_default">>, Map, undefined) of
|
||||
true -> #{mirror_to_default => true};
|
||||
false -> #{mirror_to_default => false};
|
||||
_ -> #{}
|
||||
end.
|
||||
|
||||
reply_accept(Req, {ok, Inv, Share}) ->
|
||||
handler_utils:send_json(Req, 200, #{
|
||||
invite => logic_calendar_share_invite:to_json(Inv),
|
||||
share => logic_calendar_share:to_json(Share)
|
||||
});
|
||||
reply_accept(Req, {error, Reason}) ->
|
||||
map_decide_error(Req, Reason).
|
||||
|
||||
map_decide_error(Req, not_found) ->
|
||||
handler_utils:send_error(Req, 404, <<"Not found">>);
|
||||
map_decide_error(Req, access_denied) ->
|
||||
handler_utils:send_error(Req, 403, <<"Access denied">>);
|
||||
map_decide_error(Req, not_pending) ->
|
||||
handler_utils:send_error(Req, 409, <<"Invite is not pending">>);
|
||||
map_decide_error(Req, expired) ->
|
||||
handler_utils:send_error(Req, 410, <<"Invite expired">>);
|
||||
map_decide_error(Req, {no_exists, Table}) ->
|
||||
handler_utils:report_and_send_error(Req, 503,
|
||||
<<"Schema not ready">>, #{reason => no_exists, table => Table});
|
||||
map_decide_error(Req, Reason) ->
|
||||
handler_utils:report_and_send_error(Req, 500,
|
||||
<<"Internal server error">>, #{reason => Reason}).
|
||||
@@ -0,0 +1,103 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Multipart helpers for file uploads (Back#71, Front#76).
|
||||
%%% Field name: file
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_upload_utils).
|
||||
-export([read_image_part/1, read_file_part/2]).
|
||||
|
||||
-spec read_image_part(cowboy_req:req()) ->
|
||||
{ok, binary(), cowboy_req:req()} |
|
||||
{error, missing_file | too_large | unsupported_media | bad_multipart, cowboy_req:req()}.
|
||||
read_image_part(Req) ->
|
||||
Max = logic_upload:max_bytes(),
|
||||
try read_parts(Req, Max)
|
||||
catch
|
||||
_:_ -> {error, bad_multipart, Req}
|
||||
end.
|
||||
|
||||
read_parts(Req, Max) ->
|
||||
case cowboy_req:read_part(Req) of
|
||||
{ok, Headers, Req1} ->
|
||||
case cow_multipart:form_data(Headers) of
|
||||
{file, _Name, _Filename, _CType} ->
|
||||
case read_limited(Req1, Max, <<>>) of
|
||||
{ok, Bin, Req2} ->
|
||||
case logic_upload:detect_image(Bin) of
|
||||
{ok, _, _} -> {ok, Bin, Req2};
|
||||
{error, unsupported_media} ->
|
||||
{error, unsupported_media, Req2}
|
||||
end;
|
||||
{error, too_large, Req2} ->
|
||||
{error, too_large, Req2}
|
||||
end;
|
||||
{data, _Name} ->
|
||||
{ok, _Body, Req2} = cowboy_req:read_part_body(Req1),
|
||||
read_parts(Req2, Max)
|
||||
end;
|
||||
{done, Req1} ->
|
||||
{error, missing_file, Req1}
|
||||
end.
|
||||
|
||||
%% Произвольный файл без проверки содержимого (аудио для STT):
|
||||
%% возвращает байты и content-type части.
|
||||
-spec read_file_part(cowboy_req:req(), non_neg_integer()) ->
|
||||
{ok, binary(), binary(), cowboy_req:req()} |
|
||||
{error, missing_file | too_large | bad_multipart, cowboy_req:req()}.
|
||||
read_file_part(Req, Max) ->
|
||||
try read_file_parts(Req, Max)
|
||||
catch
|
||||
_:_ -> {error, bad_multipart, Req}
|
||||
end.
|
||||
|
||||
read_file_parts(Req, Max) ->
|
||||
case cowboy_req:read_part(Req) of
|
||||
{ok, Headers, Req1} ->
|
||||
case cow_multipart:form_data(Headers) of
|
||||
{file, _Name, _Filename, CType} ->
|
||||
case read_limited(Req1, Max, <<>>) of
|
||||
{ok, Bin, Req2} -> {ok, Bin, part_ctype(CType), Req2};
|
||||
{error, too_large, Req2} -> {error, too_large, Req2}
|
||||
end;
|
||||
{data, _Name} ->
|
||||
{ok, _Body, Req2} = cowboy_req:read_part_body(Req1),
|
||||
read_file_parts(Req2, Max)
|
||||
end;
|
||||
{done, Req1} ->
|
||||
{error, missing_file, Req1}
|
||||
end.
|
||||
|
||||
part_ctype(undefined) -> <<"application/octet-stream">>;
|
||||
part_ctype(B) when is_binary(B) -> B;
|
||||
part_ctype(L) when is_list(L) -> list_to_binary(L).
|
||||
|
||||
read_limited(Req, Max, Acc) when byte_size(Acc) > Max ->
|
||||
_ = drain(Req),
|
||||
{error, too_large, Req};
|
||||
read_limited(Req, Max, Acc) ->
|
||||
Chunk = min(65536, Max - byte_size(Acc) + 1),
|
||||
case cowboy_req:read_part_body(Req, #{length => Chunk}) of
|
||||
{ok, Data, Req2} ->
|
||||
Bin = <<Acc/binary, Data/binary>>,
|
||||
case byte_size(Bin) > Max of
|
||||
true -> {error, too_large, Req2};
|
||||
false -> {ok, Bin, Req2}
|
||||
end;
|
||||
{more, Data, Req2} ->
|
||||
Bin = <<Acc/binary, Data/binary>>,
|
||||
case byte_size(Bin) > Max of
|
||||
true ->
|
||||
_ = drain(Req2),
|
||||
{error, too_large, Req2};
|
||||
false ->
|
||||
read_limited(Req2, Max, Bin)
|
||||
end
|
||||
end.
|
||||
|
||||
drain(Req) ->
|
||||
case cowboy_req:read_part_body(Req, #{length => 65536}) of
|
||||
{ok, _, Req2} -> Req2;
|
||||
{more, _, Req2} -> drain(Req2);
|
||||
{done, Req2} -> Req2;
|
||||
_ -> Req
|
||||
end.
|
||||
@@ -0,0 +1,85 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc POST /v1/user/me/avatar — multipart image upload (Back#71).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_user_avatar).
|
||||
-behaviour(cowboy_handler).
|
||||
-export([init/2, trails/0]).
|
||||
-include("records.hrl").
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> post_avatar(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/user/me/avatar">>,
|
||||
method => <<"POST">>,
|
||||
description => <<"Upload current user avatar (multipart field file)">>,
|
||||
tags => [<<"Users">>],
|
||||
requestBody => #{
|
||||
required => true,
|
||||
content => #{
|
||||
<<"multipart/form-data">> => #{
|
||||
schema => #{
|
||||
type => object,
|
||||
required => [<<"file">>],
|
||||
properties => #{file => #{type => string, format => binary}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses => #{
|
||||
200 => #{description => <<"Updated user profile">>},
|
||||
401 => #{description => <<"Unauthorized">>},
|
||||
413 => #{description => <<"File too large">>},
|
||||
415 => #{description => <<"Unsupported media type">>},
|
||||
400 => #{description => <<"Missing file / bad multipart">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
post_avatar(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case handler_upload_utils:read_image_part(Req1) of
|
||||
{ok, Bin, Req2} ->
|
||||
case logic_upload:save_avatar(UserId, Bin) of
|
||||
{ok, Url} ->
|
||||
Old = case core_user:get_by_id(UserId) of
|
||||
{ok, #user{avatar_url = A}} -> A;
|
||||
_ -> undefined
|
||||
end,
|
||||
case core_user:update(UserId, [{avatar_url, Url}]) of
|
||||
{ok, User} ->
|
||||
_ = logic_upload:maybe_delete_url(Old),
|
||||
handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"User not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Update failed">>)
|
||||
end;
|
||||
{error, too_large} ->
|
||||
handler_utils:send_error(Req2, 413, <<"File too large">>);
|
||||
{error, unsupported_media} ->
|
||||
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Upload failed">>)
|
||||
end;
|
||||
{error, missing_file, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Missing file field">>);
|
||||
{error, too_large, Req2} ->
|
||||
handler_utils:send_error(Req2, 413, <<"File too large">>);
|
||||
{error, unsupported_media, Req2} ->
|
||||
handler_utils:send_error(Req2, 415, <<"Unsupported media type">>);
|
||||
{error, bad_multipart, Req2} ->
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid multipart body">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -0,0 +1,113 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Pending booking requests for the current user (owner / specialist inbox).
|
||||
%%% GET /v1/user/booking-requests
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_user_booking_requests).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
-export([item_to_json/1]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/user/booking-requests">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"List pending booking requests the user can confirm/decline">>,
|
||||
tags => [<<"Bookings">>],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Array of enriched booking requests">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => request_schema()
|
||||
}}}
|
||||
},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
request_schema() ->
|
||||
#{
|
||||
type => object,
|
||||
properties => #{
|
||||
id => #{type => string},
|
||||
event_id => #{type => string},
|
||||
user_id => #{type => string},
|
||||
status => #{type => string, enum => [<<"pending">>]},
|
||||
role => #{type => string, enum => [<<"owner">>, <<"specialist">>]},
|
||||
user_nickname => #{type => string, nullable => true},
|
||||
user_email => #{type => string, nullable => true},
|
||||
created_at => #{type => string, format => <<"date-time">>},
|
||||
event => #{
|
||||
type => object,
|
||||
properties => #{
|
||||
id => #{type => string},
|
||||
calendar_id => #{type => string},
|
||||
calendar_title => #{type => string, nullable => true},
|
||||
title => #{type => string},
|
||||
start_time => #{type => string, format => <<"date-time">>},
|
||||
duration => #{type => integer},
|
||||
specialist_id => #{type => string, nullable => true}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.
|
||||
|
||||
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_requests(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec list_requests(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||
list_requests(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_booking:list_user_booking_requests(UserId) of
|
||||
{ok, Items} ->
|
||||
Response = [item_to_json(I) || I <- Items],
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
-spec item_to_json({#booking{}, #event{}, owner | specialist}) -> map().
|
||||
item_to_json({Booking, Event, Role}) ->
|
||||
CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
{ok, Cal} -> Cal#calendar.title;
|
||||
_ -> null
|
||||
end,
|
||||
SpecialistId = case Event#event.specialist_id of
|
||||
<<>> -> null;
|
||||
undefined -> null;
|
||||
Sid -> Sid
|
||||
end,
|
||||
Base = handler_utils:booking_to_json(Booking),
|
||||
EventMap = #{
|
||||
id => Event#event.id,
|
||||
calendar_id => Event#event.calendar_id,
|
||||
calendar_title => CalendarTitle,
|
||||
title => Event#event.title,
|
||||
start_time => handler_utils:datetime_to_iso8601(Event#event.start_time),
|
||||
duration => Event#event.duration,
|
||||
specialist_id => SpecialistId
|
||||
},
|
||||
Base#{
|
||||
role => Role,
|
||||
event => EventMap
|
||||
}.
|
||||
@@ -45,7 +45,7 @@ booking_schema() ->
|
||||
id => #{type => string},
|
||||
event_id => #{type => string},
|
||||
user_id => #{type => string},
|
||||
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
|
||||
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>, <<"expired">>]},
|
||||
notes => #{type => string, nullable => true},
|
||||
reminder_sent => #{type => boolean},
|
||||
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Confirmed + pending bookings on calendars the user owns or staffs.
|
||||
%%% GET /v1/user/studio-bookings
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_user_studio_bookings).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/user/studio-bookings">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Pending and confirmed bookings on owned/staffed calendars">>,
|
||||
tags => [<<"Bookings">>],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Array of enriched studio bookings">>,
|
||||
content => #{<<"application/json">> => #{schema => #{
|
||||
type => array,
|
||||
items => #{type => object}
|
||||
}}}
|
||||
},
|
||||
401 => #{description => <<"Unauthorized">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_studio(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
-spec list_studio(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||
list_studio(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_booking:list_user_studio_bookings(UserId) of
|
||||
{ok, Items} ->
|
||||
Response = [handler_user_booking_requests:item_to_json(I) || I <- Items],
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -10,6 +10,7 @@
|
||||
-export([
|
||||
auth_admin/1,
|
||||
auth_user/1,
|
||||
auth_user_optional/1,
|
||||
send_json/3,
|
||||
send_json/4,
|
||||
send_error/3,
|
||||
@@ -19,6 +20,7 @@
|
||||
parse_datetime/1,
|
||||
datetime_to_iso8601/1,
|
||||
event_to_json/1,
|
||||
booking_occupancy/1,
|
||||
user_to_json/1,
|
||||
review_to_json/1,
|
||||
review_to_json/2,
|
||||
@@ -31,7 +33,10 @@
|
||||
is_superadmin/1,
|
||||
pagination_headers/2,
|
||||
maybe_report_internal_error/3,
|
||||
report_and_send_error/4
|
||||
report_and_send_error/4,
|
||||
parse_location/1,
|
||||
location_to_json/1,
|
||||
meaningful_coords/2
|
||||
]).
|
||||
-export([admin_to_json/1, audit_to_json/1]).
|
||||
|
||||
@@ -74,6 +79,17 @@ is_superadmin(Req) ->
|
||||
auth_user(Req) ->
|
||||
handler_auth:authenticate(Req).
|
||||
|
||||
%% @doc Как auth_user/1, но без заголовка Authorization — гость (`<<>>`).
|
||||
%% Невалидный Bearer по-прежнему 401.
|
||||
-spec auth_user_optional(cowboy_req:req()) ->
|
||||
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
|
||||
auth_user_optional(Req) ->
|
||||
case cowboy_req:header(<<"authorization">>, Req) of
|
||||
undefined -> {ok, <<>>, Req};
|
||||
<<>> -> {ok, <<>>, Req};
|
||||
_ -> auth_user(Req)
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% HTTP‑ответы
|
||||
%%%===================================================================
|
||||
@@ -300,13 +316,11 @@ audit_reason(#{<<"reason">> := R}) when is_binary(R) -> R;
|
||||
audit_reason(_) -> <<>>.
|
||||
|
||||
%% @doc Преобразует #event{} в JSON-карту.
|
||||
%% Добавляет booking_occupancy: free | pending | confirmed
|
||||
%% (confirmed > pending > free; cancelled/expired и past-pending → не занятость).
|
||||
-spec event_to_json(#event{}) -> map().
|
||||
event_to_json(Event) ->
|
||||
LocationJson = case Event#event.location of
|
||||
undefined -> null;
|
||||
#location{address = Addr, lat = Lat, lon = Lon} ->
|
||||
#{address => Addr, lat => Lat, lon => Lon}
|
||||
end,
|
||||
LocationJson = location_to_json(Event#event.location),
|
||||
RecurrenceJson = case Event#event.recurrence_rule of
|
||||
undefined -> null;
|
||||
Rule -> try jsx:decode(Rule, [return_maps]) of
|
||||
@@ -337,9 +351,36 @@ event_to_json(Event) ->
|
||||
attachments => Event#event.attachments,
|
||||
edit_history => Event#event.edit_history,
|
||||
created_at => datetime_to_iso8601(Event#event.created_at),
|
||||
updated_at => datetime_to_iso8601(Event#event.updated_at)
|
||||
updated_at => datetime_to_iso8601(Event#event.updated_at),
|
||||
booking_occupancy => booking_occupancy(Event)
|
||||
}.
|
||||
|
||||
%% @doc Активная занятость слота для сетки commercial (Studio aggregate).
|
||||
%% Safe if Mnesia booking table is not ready (admin/unit tests without full schema).
|
||||
-spec booking_occupancy(#event{}) -> free | pending | confirmed.
|
||||
booking_occupancy(#event{id = EventId, start_time = Start}) ->
|
||||
Bookings =
|
||||
try
|
||||
{ok, List} = core_booking:list_by_event(EventId),
|
||||
List
|
||||
catch
|
||||
_:_ -> []
|
||||
end,
|
||||
Now = calendar:universal_time(),
|
||||
lists:foldl(
|
||||
fun(#booking{status = confirmed}, _Acc) ->
|
||||
confirmed;
|
||||
(#booking{status = pending}, Acc) ->
|
||||
case Start > Now of
|
||||
true when Acc =/= confirmed -> pending;
|
||||
_ -> Acc
|
||||
end;
|
||||
(_, Acc) ->
|
||||
Acc
|
||||
end,
|
||||
free,
|
||||
Bookings).
|
||||
|
||||
%% @doc Преобразует #user{} в JSON-карту.
|
||||
-spec user_to_json(#user{}) -> map().
|
||||
user_to_json(User) ->
|
||||
@@ -606,3 +647,61 @@ trails_for_crud(Path, _Resource, GetSchema, UpdateSchema) ->
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
%%%===================================================================
|
||||
%%% Location
|
||||
%%%===================================================================
|
||||
|
||||
-spec parse_location(term()) -> #location{} | undefined.
|
||||
parse_location(undefined) -> undefined;
|
||||
parse_location(null) -> undefined;
|
||||
parse_location(LocationMap) when is_map(LocationMap) ->
|
||||
Addr0 = maps:get(<<"address">>, LocationMap, maps:get(address, LocationMap, <<>>)),
|
||||
Addr = case Addr0 of
|
||||
A when is_binary(A) -> A;
|
||||
_ -> <<>>
|
||||
end,
|
||||
Lat = to_coord(maps:get(<<"lat">>, LocationMap,
|
||||
maps:get(lat, LocationMap, undefined))),
|
||||
Lon = to_coord(maps:get(<<"lon">>, LocationMap,
|
||||
maps:get(lon, LocationMap, undefined))),
|
||||
Coords = meaningful_coords(Lat, Lon),
|
||||
HasAddr = Addr =/= <<>> andalso Addr =/= <<"undefined">>,
|
||||
case {HasAddr, Coords, Lat =/= undefined orelse Lon =/= undefined} of
|
||||
{false, false, _} -> undefined;
|
||||
{_, true, _} ->
|
||||
#location{address = Addr, lat = Lat, lon = Lon};
|
||||
{true, false, false} ->
|
||||
#location{address = Addr, lat = undefined, lon = undefined};
|
||||
{true, false, true} ->
|
||||
%% одна координата без пары — храним только адрес
|
||||
#location{address = Addr, lat = undefined, lon = undefined};
|
||||
_ -> undefined
|
||||
end;
|
||||
parse_location(_) -> undefined.
|
||||
|
||||
-spec location_to_json(undefined | #location{}) -> null | map().
|
||||
location_to_json(undefined) -> null;
|
||||
location_to_json(#location{address = Addr, lat = Lat, lon = Lon}) ->
|
||||
AddrJ = case Addr of
|
||||
undefined -> null;
|
||||
<<>> -> null;
|
||||
A -> A
|
||||
end,
|
||||
case meaningful_coords(Lat, Lon) of
|
||||
true -> #{address => AddrJ, lat => Lat, lon => Lon};
|
||||
false -> #{address => AddrJ, lat => null, lon => null}
|
||||
end.
|
||||
|
||||
-spec meaningful_coords(term(), term()) -> boolean().
|
||||
meaningful_coords(Lat, Lon) ->
|
||||
is_number(Lat) andalso is_number(Lon)
|
||||
andalso not (Lat == 0 andalso Lon == 0)
|
||||
andalso Lat >= -90 andalso Lat =< 90
|
||||
andalso Lon >= -180 andalso Lon =< 180.
|
||||
|
||||
to_coord(undefined) -> undefined;
|
||||
to_coord(null) -> undefined;
|
||||
to_coord(N) when is_integer(N) -> float(N);
|
||||
to_coord(N) when is_float(N) -> N;
|
||||
to_coord(_) -> undefined.
|
||||
@@ -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.
|
||||
@@ -14,13 +14,15 @@
|
||||
|
||||
-define(TABLES, [
|
||||
user, session, verification, password_reset, admin, admin_session, auth_session,
|
||||
calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite,
|
||||
calendar, calendar_share, calendar_share_invite, calendar_follow, calendar_specialist,
|
||||
specialist_invite,
|
||||
event, recurrence_exception,
|
||||
booking,
|
||||
booking, waitlist_entry,
|
||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||
ticket, subscription,
|
||||
admin_audit, notification,
|
||||
stats_counter, stats_daily, node_metric, schema_migration
|
||||
admin_audit, notification, push_subscription,
|
||||
stats_counter, stats_daily, node_metric, schema_migration,
|
||||
month_snapshot
|
||||
]).
|
||||
|
||||
-define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]).
|
||||
@@ -131,6 +133,7 @@ handle_call(init_tables, _From, State) ->
|
||||
end,
|
||||
ok = create_indices(),
|
||||
%% stats_collector:subscribe — после wait_for_tables + migrations (eventhub_app)
|
||||
%% Table fragmentation: use infra_mnesia_fragmentation:fragment_table/2 manually.
|
||||
ok = start_cleanup_timer(),
|
||||
{reply, ok, State};
|
||||
|
||||
@@ -209,6 +212,18 @@ ensure_schema_disc() ->
|
||||
true -> ok
|
||||
end.
|
||||
|
||||
add_local_disc_copy(month_snapshot) ->
|
||||
case lists:member(node(), mnesia:table_info(month_snapshot, disc_only_copies) ++
|
||||
mnesia:table_info(month_snapshot, disc_copies)) of
|
||||
true -> ok;
|
||||
false ->
|
||||
case mnesia:add_table_copy(month_snapshot, node(), disc_only_copies) of
|
||||
{atomic, ok} -> ok;
|
||||
{aborted, {already_exists, _}} -> ok;
|
||||
{aborted, Reason} ->
|
||||
io:format("Could not add disc_only copy for month_snapshot: ~p~n", [Reason])
|
||||
end
|
||||
end;
|
||||
add_local_disc_copy(Tab) ->
|
||||
case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of
|
||||
false ->
|
||||
@@ -282,7 +297,9 @@ prune_dead_nodes() ->
|
||||
lists:foreach(fun(Node) ->
|
||||
io:format("Removing dead node ~p from Mnesia schema...~n", [Node]),
|
||||
lists:foreach(fun(Tab) ->
|
||||
case lists:member(Node, mnesia:table_info(Tab, disc_copies)) of
|
||||
HasDisc = lists:member(Node, mnesia:table_info(Tab, disc_copies)),
|
||||
HasOnly = lists:member(Node, mnesia:table_info(Tab, disc_only_copies)),
|
||||
case HasDisc orelse HasOnly of
|
||||
true -> catch mnesia:del_table_copy(Tab, Node);
|
||||
false -> ok
|
||||
end
|
||||
@@ -316,13 +333,21 @@ create_table(Table) ->
|
||||
table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, user)}];
|
||||
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
|
||||
table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
||||
table_opts(calendar_share) ->
|
||||
[{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)},
|
||||
{index, [calendar_id, user_id]}];
|
||||
table_opts(calendar_share_invite) ->
|
||||
[{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share_invite)},
|
||||
{index, [calendar_id, invitee_user_id, invitee_email, token, status]}];
|
||||
table_opts(calendar_follow) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
|
||||
table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
||||
table_opts(specialist_invite) -> [{disc_copies, [node()]}, {attributes, record_info(fields, specialist_invite)}];
|
||||
table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, event)}];
|
||||
table_opts(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}];
|
||||
%% bag: несколько cancel/reschedule на один master_id (ключ set = только master_id).
|
||||
table_opts(recurrence_exception) ->
|
||||
[{disc_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
|
||||
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
||||
table_opts(waitlist_entry) -> [{disc_copies, [node()]}, {attributes, record_info(fields, waitlist_entry)}];
|
||||
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
|
||||
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
|
||||
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
|
||||
@@ -333,9 +358,12 @@ table_opts(ticket) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
|
||||
table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}];
|
||||
table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
|
||||
table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(fields, notification)}];
|
||||
table_opts(push_subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, push_subscription)}];
|
||||
table_opts(stats_counter) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
|
||||
table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}];
|
||||
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
||||
table_opts(month_snapshot) ->
|
||||
[{disc_only_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}];
|
||||
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
||||
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
||||
table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}];
|
||||
@@ -359,6 +387,9 @@ create_indices() ->
|
||||
mnesia:add_table_index(booking, event_id),
|
||||
mnesia:add_table_index(booking, user_id),
|
||||
mnesia:add_table_index(booking, status),
|
||||
mnesia:add_table_index(waitlist_entry, event_id),
|
||||
mnesia:add_table_index(waitlist_entry, user_id),
|
||||
mnesia:add_table_index(waitlist_entry, status),
|
||||
mnesia:add_table_index(review_vote, review_id),
|
||||
mnesia:add_table_index(review_vote, user_id),
|
||||
mnesia:add_table_index(calendar, owner_id),
|
||||
@@ -378,8 +409,12 @@ create_indices() ->
|
||||
mnesia:add_table_index(password_reset, user_id),
|
||||
mnesia:add_table_index(notification, user_id),
|
||||
mnesia:add_table_index(notification, is_read),
|
||||
mnesia:add_table_index(push_subscription, user_id),
|
||||
mnesia:add_table_index(push_subscription, endpoint),
|
||||
mnesia:add_table_index(auth_session, family_id),
|
||||
mnesia:add_table_index(auth_session, subject_id),
|
||||
mnesia:add_table_index(report, resolved_by),
|
||||
mnesia:add_table_index(ticket, assigned_to),
|
||||
mnesia:add_table_index(month_snapshot, calendar_id),
|
||||
mnesia:add_table_index(month_snapshot, year_month),
|
||||
ok.
|
||||
+11
-5
@@ -1,5 +1,5 @@
|
||||
%% ===================================================================
|
||||
%% EventHub – инфраструктурный супервизор (с archive_manager)
|
||||
%% EventHub – инфраструктурный супервизор
|
||||
%% ===================================================================
|
||||
-module(infra_sup).
|
||||
-behaviour(supervisor).
|
||||
@@ -39,12 +39,12 @@ init([]) ->
|
||||
start => {node_monitor, start_link, []},
|
||||
restart => permanent,
|
||||
type => worker},
|
||||
#{id => archive_manager,
|
||||
start => {archive_manager, start_link, []},
|
||||
#{id => month_archive_worker,
|
||||
start => {month_archive_worker, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker,
|
||||
modules => [archive_manager]},
|
||||
modules => [month_archive_worker]},
|
||||
#{id => migration_engine,
|
||||
start => {migration_engine, start_link, []},
|
||||
restart => permanent,
|
||||
@@ -56,6 +56,12 @@ init([]) ->
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker,
|
||||
modules => [subscription_worker]}
|
||||
modules => [subscription_worker]},
|
||||
#{id => infra_cleanup,
|
||||
start => {infra_cleanup, start_link, []},
|
||||
restart => permanent,
|
||||
shutdown => 5000,
|
||||
type => worker,
|
||||
modules => [infra_cleanup]}
|
||||
],
|
||||
{ok, {SupFlags, Children}}.
|
||||
@@ -27,7 +27,14 @@
|
||||
'20260719210000_review_vote',
|
||||
'20260720210000_calendar_follow',
|
||||
'20260722150000_calendar_specialist_id',
|
||||
'20260722190000_specialist_invite'
|
||||
'20260722190000_specialist_invite',
|
||||
'20260730120000_recurrence_exception_bag',
|
||||
'20260814193000_waitlist_entry',
|
||||
'20260815200000_push_subscription',
|
||||
'20260815220000_calendar_share_invite',
|
||||
'20260816180000_auth_session_device',
|
||||
'20260817140000_month_snapshot',
|
||||
'20260820180000_ai_hint_metric'
|
||||
]).
|
||||
|
||||
%% ------------------------------
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
%% Periodic month snapshots (Back#76). Replaces extra-node archive_manager.
|
||||
-module(month_archive_worker).
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([start_link/0, run_once/0]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
||||
terminate/2, code_change/3]).
|
||||
|
||||
-define(DEFAULT_INTERVAL_MS, 3600000).
|
||||
|
||||
-record(state, {interval_ms :: pos_integer(), timer_ref :: reference() | undefined}).
|
||||
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
run_once() ->
|
||||
logic_month_archive:run_once().
|
||||
|
||||
init([]) ->
|
||||
Interval = application:get_env(eventhub, archive_interval_ms, ?DEFAULT_INTERVAL_MS),
|
||||
Ref = erlang:send_after(15000, self(), run),
|
||||
logger:info(#{what => month_archive_worker_started, interval_ms => Interval}),
|
||||
{ok, #state{interval_ms = Interval, timer_ref = Ref}}.
|
||||
|
||||
handle_call(run_once, _From, State) ->
|
||||
{reply, logic_month_archive:run_once(), State};
|
||||
handle_call(_Msg, _From, State) ->
|
||||
{reply, {error, unknown_call}, State}.
|
||||
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
handle_info(run, State) ->
|
||||
_ = catch logic_month_archive:run_once(),
|
||||
Ref = erlang:send_after(State#state.interval_ms, self(), run),
|
||||
{noreply, State#state{timer_ref = Ref}};
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
terminate(_Reason, #state{timer_ref = Ref}) ->
|
||||
case Ref of
|
||||
undefined -> ok;
|
||||
_ -> erlang:cancel_timer(Ref), ok
|
||||
end.
|
||||
|
||||
code_change(_O, State, _E) ->
|
||||
{ok, State}.
|
||||
@@ -26,6 +26,7 @@ handle_cast(_Msg, State) ->
|
||||
handle_info(tick, State) ->
|
||||
_ = catch logic_subscription:handle_expired_subscriptions(),
|
||||
_ = catch logic_booking:process_timeout_bookings(),
|
||||
_ = catch logic_booking:process_reminders(),
|
||||
erlang:send_after(?INTERVAL_MS, self(), tick),
|
||||
{noreply, State};
|
||||
handle_info(_Info, State) ->
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Пилотные метрики ИИ-подсказок (PRODUCT-AI, Spec#22).
|
||||
%%%
|
||||
%%% Front присылает счётчики shown/confirm/dismiss по паттерну подсказки;
|
||||
%%% Back агрегирует их по дням (таблица ai_hint_metric) и отдаёт админам
|
||||
%%% сводку для решения о старте L2 (гейты канона: hint CTR и доля
|
||||
%%% активных с >=2 подтверждёнными ghost-записями за месяц).
|
||||
%%%
|
||||
%%% Данные привязаны к opt-in: клиент шлёт метрики только при включённых
|
||||
%%% подсказках (preferences.ai_hints).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_ai_metrics).
|
||||
|
||||
-export([record_events/2, summary/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
-define(TABLE, ai_hint_metric).
|
||||
-define(MAX_BATCH, 20).
|
||||
-define(MAX_PATTERN, 96).
|
||||
-define(RETENTION_DAYS, 92).
|
||||
-define(PRUNE_ROWS_PER_USER, 500).
|
||||
-define(RL_TABLE, eventhub_ai_metrics_rl).
|
||||
-define(RL_LIMIT, 30). % запросов на пользователя
|
||||
-define(RL_WINDOW_MS, 60000).
|
||||
|
||||
%%%===================================================================
|
||||
%%% Запись событий
|
||||
%%%===================================================================
|
||||
|
||||
-spec record_events(binary(), [map()]) ->
|
||||
{ok, non_neg_integer()} | {error, invalid_body | rate_limited}.
|
||||
record_events(UserId, Events) when is_binary(UserId), is_list(Events),
|
||||
Events =/= [], length(Events) =< ?MAX_BATCH ->
|
||||
case allow(UserId) of
|
||||
false ->
|
||||
{error, rate_limited};
|
||||
true ->
|
||||
Day = today(),
|
||||
case normalize_events(Events, []) of
|
||||
{ok, Norm} ->
|
||||
{atomic, Count} = mnesia:transaction(fun() ->
|
||||
lists:foreach(fun({Kind, Pattern}) ->
|
||||
increment(UserId, Day, Pattern, Kind)
|
||||
end, Norm),
|
||||
maybe_prune(UserId, Day),
|
||||
length(Norm)
|
||||
end),
|
||||
{ok, Count};
|
||||
error ->
|
||||
{error, invalid_body}
|
||||
end
|
||||
end;
|
||||
record_events(_UserId, _Events) ->
|
||||
{error, invalid_body}.
|
||||
|
||||
increment(UserId, Day, Pattern, Kind) ->
|
||||
Key = {UserId, Day, Pattern, Kind},
|
||||
Row = case mnesia:read(?TABLE, Key, write) of
|
||||
[Existing] -> Existing;
|
||||
[] -> #ai_hint_metric{id = Key, user_id = UserId, day = Day,
|
||||
pattern_key = Pattern, kind = Kind, count = 0}
|
||||
end,
|
||||
mnesia:write(Row#ai_hint_metric{
|
||||
count = Row#ai_hint_metric.count + 1,
|
||||
updated_at = calendar:universal_time()
|
||||
}).
|
||||
|
||||
%% Ленивая чистка: если у пользователя накопилось много строк,
|
||||
%% удаляем всё старше RETENTION_DAYS.
|
||||
maybe_prune(UserId, Day) ->
|
||||
Rows = mnesia:index_read(?TABLE, UserId, user_id),
|
||||
case length(Rows) > ?PRUNE_ROWS_PER_USER of
|
||||
true ->
|
||||
Cutoff = shift_day(Day, -?RETENTION_DAYS),
|
||||
lists:foreach(fun(#ai_hint_metric{day = D} = R) when D < Cutoff ->
|
||||
mnesia:delete_object(R);
|
||||
(_) -> ok
|
||||
end, Rows);
|
||||
false -> ok
|
||||
end.
|
||||
|
||||
normalize_events([], Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
normalize_events([E | Rest], Acc) when is_map(E) ->
|
||||
Kind = maps:get(<<"kind">>, E, undefined),
|
||||
Pattern = maps:get(<<"pattern">>, E, <<>>),
|
||||
case valid_kind(Kind) andalso valid_pattern(Pattern) of
|
||||
true -> normalize_events(Rest, [{Kind, Pattern} | Acc]);
|
||||
false -> error
|
||||
end;
|
||||
normalize_events(_, _) ->
|
||||
error.
|
||||
|
||||
valid_kind(<<"shown">>) -> true;
|
||||
valid_kind(<<"confirm">>) -> true;
|
||||
valid_kind(<<"dismiss">>) -> true;
|
||||
valid_kind(_) -> false.
|
||||
|
||||
valid_pattern(P) when is_binary(P), byte_size(P) > 0, byte_size(P) =< ?MAX_PATTERN -> true;
|
||||
valid_pattern(_) -> false.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Сводка для админов
|
||||
%%%===================================================================
|
||||
|
||||
-spec summary() -> map().
|
||||
summary() ->
|
||||
Today = today(),
|
||||
MonthAgo = shift_day(Today, -30),
|
||||
Init = #{
|
||||
by_kind => #{},
|
||||
active_30d => sets:new(),
|
||||
confirms_by_user_30d => #{},
|
||||
confirms_by_pattern => #{}
|
||||
},
|
||||
{atomic, Acc} = mnesia:transaction(fun() ->
|
||||
mnesia:foldl(fun(R, A) -> fold_row(R, MonthAgo, A) end, Init, ?TABLE)
|
||||
end),
|
||||
ByKind = maps:get(by_kind, Acc),
|
||||
Active = sets:size(maps:get(active_30d, Acc)),
|
||||
ConfirmsByUser = maps:get(confirms_by_user_30d, Acc),
|
||||
UsersGe2 = maps:size(maps:filter(fun(_, N) -> N >= 2 end, ConfirmsByUser)),
|
||||
TopPatterns = lists:sublist(
|
||||
lists:reverse(lists:sort(maps:to_list(maps:get(confirms_by_pattern, Acc)))), 10),
|
||||
#{
|
||||
<<"by_kind">> => ByKind,
|
||||
<<"active_users_30d">> => Active,
|
||||
<<"users_with_confirms_ge2_30d">> => UsersGe2,
|
||||
<<"gate_ratio_30d">> => ratio(UsersGe2, Active),
|
||||
<<"top_patterns_by_confirm">> =>
|
||||
[#{<<"pattern">> => P, <<"confirms">> => N} || {P, N} <- TopPatterns],
|
||||
<<"retention_days">> => ?RETENTION_DAYS
|
||||
}.
|
||||
|
||||
fold_row(#ai_hint_metric{user_id = U, day = D, pattern_key = P, kind = K, count = N},
|
||||
MonthAgo, Acc) ->
|
||||
ByKind = maps:get(by_kind, Acc),
|
||||
Acc1 = Acc#{by_kind => maps:update_with(K, fun(V) -> V + N end, N, ByKind)},
|
||||
case D >= MonthAgo of
|
||||
false -> Acc1;
|
||||
true ->
|
||||
Active = sets:add_element(U, maps:get(active_30d, Acc1)),
|
||||
Acc2 = Acc1#{active_30d => Active},
|
||||
Acc3 = case K of
|
||||
<<"confirm">> ->
|
||||
Cbu = maps:get(confirms_by_user_30d, Acc2),
|
||||
Acc2#{confirms_by_user_30d => maps:update_with(U, fun(V) -> V + N end, N, Cbu)};
|
||||
_ -> Acc2
|
||||
end,
|
||||
case K of
|
||||
<<"confirm">> ->
|
||||
Cbp = maps:get(confirms_by_pattern, Acc3),
|
||||
Acc3#{confirms_by_pattern => maps:update_with(P, fun(V) -> V + N end, N, Cbp)};
|
||||
_ -> Acc3
|
||||
end
|
||||
end.
|
||||
|
||||
ratio(_, 0) -> 0.0;
|
||||
ratio(Part, Total) -> Part / Total.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Даты и rate-limit
|
||||
%%%===================================================================
|
||||
|
||||
today() ->
|
||||
{{Y, M, D}, _} = calendar:universal_time(),
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y, M, D])).
|
||||
|
||||
shift_day(DayBin, Delta) ->
|
||||
<<Y:4/binary, "-", M:2/binary, "-", D:2/binary>> = DayBin,
|
||||
Date = {binary_to_integer(Y), binary_to_integer(M), binary_to_integer(D)},
|
||||
Sec = calendar:datetime_to_gregorian_seconds({Date, {0, 0, 0}}) + Delta * 86400,
|
||||
{{Y2, M2, D2}, _} = calendar:gregorian_seconds_to_datetime(Sec),
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Y2, M2, D2])).
|
||||
|
||||
allow(UserId) ->
|
||||
ensure_rl(),
|
||||
Now = erlang:system_time(millisecond),
|
||||
Key = {rl, UserId},
|
||||
Result = case ets:lookup(?RL_TABLE, Key) of
|
||||
[{_, WindowStart, Count}] when Now - WindowStart < ?RL_WINDOW_MS ->
|
||||
Count < ?RL_LIMIT;
|
||||
_ ->
|
||||
true
|
||||
end,
|
||||
case Result of
|
||||
true ->
|
||||
ets:update_counter(?RL_TABLE, Key, {3, 1}, {Key, Now, 0}),
|
||||
true;
|
||||
false ->
|
||||
false
|
||||
end.
|
||||
|
||||
ensure_rl() ->
|
||||
case ets:info(?RL_TABLE) of
|
||||
undefined -> ets:new(?RL_TABLE, [named_table, public, set]);
|
||||
_ -> ok
|
||||
end,
|
||||
ok.
|
||||
@@ -0,0 +1,313 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Прокси транскрибации голоса (PRODUCT-AI, Front#76).
|
||||
%%%
|
||||
%%% Front шлёт аудио press-to-talk записи; Back проксирует его в
|
||||
%%% продуктовый STT-провайдер (адаптеры: xai, openai-совместимый).
|
||||
%%% Ключи провайдера не покидают Back; аудио и транскрипт не
|
||||
%%% персистятся (приват-инвариант канона). Rate-limit в ETS.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_ai_transcribe).
|
||||
|
||||
-export([transcribe/3, max_bytes/0, ensure/0]).
|
||||
%% Экспортировано для тестов и хэндлера.
|
||||
-export([parse_provider_response/2, build_multipart/4, file_name/1]).
|
||||
|
||||
-define(RL_TABLE, eventhub_stt_rl).
|
||||
-define(UP, eventhub_stt_upstream).
|
||||
-define(RL_LIMIT, 10). % запросов на пользователя
|
||||
-define(RL_WINDOW_MS, 60000).
|
||||
-define(MAX_AUDIO_BYTES, 10 * 1024 * 1024).
|
||||
-define(DEFAULT_XAI_URL, <<"https://api.x.ai/v1/stt">>).
|
||||
-define(DEFAULT_OPENAI_URL, <<"http://localhost:8000/v1/audio/transcriptions">>).
|
||||
-define(DEFAULT_TIMEOUT_MS, 15000).
|
||||
-define(HTTP_PROFILE, eventhub_stt).
|
||||
|
||||
%%%===================================================================
|
||||
%%% API
|
||||
%%%===================================================================
|
||||
|
||||
-spec max_bytes() -> non_neg_integer().
|
||||
max_bytes() -> ?MAX_AUDIO_BYTES.
|
||||
|
||||
-spec ensure() -> ok.
|
||||
ensure() ->
|
||||
case ets:info(?RL_TABLE) of
|
||||
undefined ->
|
||||
ets:new(?RL_TABLE, [named_table, public, set, {write_concurrency, true}]);
|
||||
_ -> ok
|
||||
end,
|
||||
case ets:info(?UP) of
|
||||
undefined ->
|
||||
ets:new(?UP, [named_table, public, set]);
|
||||
_ -> ok
|
||||
end,
|
||||
ok.
|
||||
|
||||
%% Транскрибация аудио пользователя. Пустой transcript в ответе
|
||||
%% провайдера возвращаем как {ok, <<>>, Lang} — клиент покажет
|
||||
%% тост «не расслышал» без retry-цикла (degrade-матрица канона).
|
||||
-spec transcribe(binary(), binary(), binary()) ->
|
||||
{ok, binary(), binary()} |
|
||||
{error, unavailable | rate_limited | upstream_error | invalid_audio}.
|
||||
transcribe(UserId, Audio, ContentType)
|
||||
when is_binary(UserId), is_binary(Audio), byte_size(Audio) > 0,
|
||||
byte_size(Audio) =< ?MAX_AUDIO_BYTES ->
|
||||
ensure(),
|
||||
case api_key() of
|
||||
<<>> -> {error, unavailable};
|
||||
Key ->
|
||||
case allow(UserId) of
|
||||
false -> {error, rate_limited};
|
||||
true ->
|
||||
case throttle_allow() of
|
||||
false -> {error, rate_limited};
|
||||
true ->
|
||||
Provider = provider(),
|
||||
call_provider(Provider, stt_url(Provider), Key, Audio, ContentType)
|
||||
end
|
||||
end
|
||||
end;
|
||||
transcribe(_UserId, _Audio, _ContentType) ->
|
||||
{error, invalid_audio}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Конфигурация (env, паттерн logic_geo)
|
||||
%%%===================================================================
|
||||
|
||||
provider() ->
|
||||
case env_bin("STT_PROVIDER") of
|
||||
<<>> -> xai;
|
||||
<<"openai">> -> openai;
|
||||
<<"xai">> -> xai;
|
||||
_ -> xai
|
||||
end.
|
||||
|
||||
stt_url(xai) ->
|
||||
case env_bin("STT_URL") of
|
||||
<<>> -> ?DEFAULT_XAI_URL;
|
||||
Url -> Url
|
||||
end;
|
||||
stt_url(openai) ->
|
||||
case env_bin("STT_URL") of
|
||||
<<>> -> ?DEFAULT_OPENAI_URL;
|
||||
Url -> Url
|
||||
end.
|
||||
|
||||
api_key() -> env_bin("STT_API_KEY").
|
||||
|
||||
timeout_ms() -> env_int("STT_TIMEOUT_MS", ?DEFAULT_TIMEOUT_MS).
|
||||
|
||||
proxy_url() -> env_bin("STT_PROXY_URL").
|
||||
|
||||
env_bin(Name) ->
|
||||
case os:getenv(Name) of
|
||||
false -> <<>>;
|
||||
"" -> <<>>;
|
||||
V -> list_to_binary(string:trim(V))
|
||||
end.
|
||||
|
||||
env_int(Name, Default) ->
|
||||
case os:getenv(Name) of
|
||||
false -> Default;
|
||||
"" -> Default;
|
||||
S ->
|
||||
try list_to_integer(S) of
|
||||
N when N > 0 -> N;
|
||||
_ -> Default
|
||||
catch
|
||||
_:_ -> Default
|
||||
end
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Вызов провайдера
|
||||
%%%===================================================================
|
||||
|
||||
call_provider(xai, Url, Key, Audio, CType) ->
|
||||
%% xAI: multipart без опциональных полей — язык определяется авто
|
||||
%% (format=true требует явный language и отключает автоопределение).
|
||||
Body = build_multipart([], Audio, file_name(CType), CType),
|
||||
post_and_parse(xai, Url, Key, Body);
|
||||
call_provider(openai, Url, Key, Audio, CType) ->
|
||||
Model = case env_bin("STT_MODEL") of
|
||||
<<>> -> <<"whisper-1">>;
|
||||
M -> M
|
||||
end,
|
||||
Body = build_multipart([{<<"model">>, Model}], Audio, file_name(CType), CType),
|
||||
post_and_parse(openai, Url, Key, Body).
|
||||
|
||||
%% Апстрим определяет формат аудио по расширению имени файла в
|
||||
%% multipart-части (не по её content-type); нейтральное voice.dat
|
||||
%% даёт 400 «Could not detect audio format» на webm/mp4 из
|
||||
%% MediaRecorder. Расширение выводим из content-type (параметры
|
||||
%% вида ;codecs=opus отбрасываем); неизвестный тип — .wav.
|
||||
-spec file_name(binary()) -> binary().
|
||||
file_name(ContentType) ->
|
||||
Mime = case binary:split(ContentType, <<";">>) of
|
||||
[M | _] -> string:trim(M);
|
||||
[] -> <<>>
|
||||
end,
|
||||
case Mime of
|
||||
<<"audio/webm">> -> <<"voice.webm">>;
|
||||
<<"audio/mp4">> -> <<"voice.mp4">>;
|
||||
<<"audio/aac">> -> <<"voice.aac">>;
|
||||
<<"audio/mpeg">> -> <<"voice.mp3">>;
|
||||
<<"audio/mp3">> -> <<"voice.mp3">>;
|
||||
<<"audio/ogg">> -> <<"voice.ogg">>;
|
||||
<<"audio/opus">> -> <<"voice.opus">>;
|
||||
<<"audio/flac">> -> <<"voice.flac">>;
|
||||
<<"audio/m4a">> -> <<"voice.m4a">>;
|
||||
<<"audio/x-m4a">> -> <<"voice.m4a">>;
|
||||
<<"audio/wav">> -> <<"voice.wav">>;
|
||||
<<"audio/wave">> -> <<"voice.wav">>;
|
||||
<<"audio/x-wav">> -> <<"voice.wav">>;
|
||||
_ -> <<"voice.wav">>
|
||||
end.
|
||||
|
||||
post_and_parse(Provider, Url, Key, Body) ->
|
||||
Headers = [{"authorization", binary_to_list(<<"Bearer ", Key/binary>>)}],
|
||||
CTypeHdr = "multipart/form-data; boundary=" ++ binary_to_list(boundary(Body)),
|
||||
case http_post(Url, Headers, CTypeHdr, Body) of
|
||||
{ok, RespBody} -> parse_provider_response(Provider, RespBody);
|
||||
{error, _} -> {error, upstream_error}
|
||||
end.
|
||||
|
||||
%% Хук для тестов: fun(Url :: binary(), Headers, ContentType, Body) ->
|
||||
%% {ok, RespBinary} | {error, term()}.
|
||||
http_post(Url, Headers, ContentType, Body) ->
|
||||
Fun = case application:get_env(eventhub, stt_http_post) of
|
||||
{ok, F} when is_function(F, 4) -> F;
|
||||
_ -> fun default_http_post/4
|
||||
end,
|
||||
Fun(Url, Headers, ContentType, Body).
|
||||
|
||||
default_http_post(Url, Headers, ContentType, Body) ->
|
||||
_ = application:ensure_all_started(inets),
|
||||
ensure_profile(),
|
||||
UrlS = binary_to_list(Url),
|
||||
case httpc:request(post, {UrlS, Headers, ContentType, Body},
|
||||
[{timeout, timeout_ms()}], [{body_format, binary}], ?HTTP_PROFILE) of
|
||||
{ok, {{_, Code, _}, _, Resp}} when Code >= 200, Code < 300, is_binary(Resp) ->
|
||||
{ok, Resp};
|
||||
{ok, {{_, Code, _}, _, Resp}} when Code >= 200, Code < 300 ->
|
||||
{ok, iolist_to_binary(Resp)};
|
||||
{ok, _} -> {error, http_status};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
%% Отдельный профиль httpc: прокси (STT_PROXY_URL) применяется только
|
||||
%% к STT-апстриму, остальные httpc-вызовы не затрагиваются.
|
||||
ensure_profile() ->
|
||||
case inets:start(httpc, [{profile, ?HTTP_PROFILE}]) of
|
||||
{ok, _Pid} -> apply_proxy();
|
||||
{error, {already_started, _Pid}} -> apply_proxy();
|
||||
_ -> ok
|
||||
end.
|
||||
|
||||
apply_proxy() ->
|
||||
case parse_proxy(proxy_url()) of
|
||||
{ok, Host, Port} ->
|
||||
Addr = {Host, Port},
|
||||
httpc:set_options([{proxy, {Addr, []}}, {https_proxy, {Addr, []}}],
|
||||
?HTTP_PROFILE);
|
||||
error -> ok
|
||||
end.
|
||||
|
||||
parse_proxy(<<>>) -> error;
|
||||
parse_proxy(Url) ->
|
||||
try uri_string:parse(binary_to_list(Url)) of
|
||||
#{host := Host, port := Port} -> {ok, Host, Port};
|
||||
#{host := Host} -> {ok, Host, 8080};
|
||||
_ -> error
|
||||
catch
|
||||
_:_ -> error
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Multipart и парсинг ответа
|
||||
%%%===================================================================
|
||||
|
||||
%% Multipart/form-data: Fields (name=value) идут первыми, файл —
|
||||
%% последним полем (требование xAI для стриминг-загрузки).
|
||||
-spec build_multipart([{binary(), binary()}], binary(), binary(), binary()) -> binary().
|
||||
build_multipart(Fields, FileBin, FileName, FileCType) ->
|
||||
B = boundary_bin(),
|
||||
FieldParts = [field_part(B, Name, Value) || {Name, Value} <- Fields],
|
||||
FilePart = [
|
||||
<<"--", B/binary, "\r\n">>,
|
||||
<<"Content-Disposition: form-data; name=\"file\"; filename=\"">>,
|
||||
FileName, <<"\"\r\n">>,
|
||||
<<"Content-Type: ">>, FileCType, <<"\r\n\r\n">>,
|
||||
FileBin, <<"\r\n">>
|
||||
],
|
||||
iolist_to_binary([FieldParts, FilePart, <<"--", B/binary, "--\r\n">>]).
|
||||
|
||||
field_part(B, Name, Value) ->
|
||||
[
|
||||
<<"--", B/binary, "\r\n">>,
|
||||
<<"Content-Disposition: form-data; name=\"">>, Name, <<"\"\r\n\r\n">>,
|
||||
Value, <<"\r\n">>
|
||||
].
|
||||
|
||||
boundary(Body) ->
|
||||
{Pos, _} = binary:match(Body, <<"\r\n">>),
|
||||
<<"--", B/binary>> = binary:part(Body, 0, Pos),
|
||||
B.
|
||||
|
||||
boundary_bin() ->
|
||||
Hex = [io_lib:format("~2.16.0b", [rand:uniform(256) - 1]) || _ <- lists:seq(1, 12)],
|
||||
<<"ehstt", (iolist_to_binary(Hex))/binary>>.
|
||||
|
||||
%% Маппинг ответа провайдера в {ok, Transcript, Lang}.
|
||||
%% Оба адаптера читают `text`; язык — из `language` (xAI определяет сам).
|
||||
-spec parse_provider_response(xai | openai, binary()) ->
|
||||
{ok, binary(), binary()} | {error, upstream_error}.
|
||||
parse_provider_response(_Provider, RespBody) when is_binary(RespBody) ->
|
||||
try jsx:decode(RespBody, [return_maps]) of
|
||||
#{<<"text">> := Text} = M when is_binary(Text) ->
|
||||
Lang = case maps:get(<<"language">>, M, null) of
|
||||
L when is_binary(L) -> L;
|
||||
_ -> <<>>
|
||||
end,
|
||||
{ok, Text, Lang};
|
||||
_ -> {error, upstream_error}
|
||||
catch
|
||||
_:_ -> {error, upstream_error}
|
||||
end;
|
||||
parse_provider_response(_Provider, _) ->
|
||||
{error, upstream_error}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Rate-limit (паттерн logic_ai_metrics)
|
||||
%%%===================================================================
|
||||
|
||||
allow(UserId) ->
|
||||
ensure(),
|
||||
Now = erlang:system_time(millisecond),
|
||||
Key = {rl, UserId},
|
||||
Result = case ets:lookup(?RL_TABLE, Key) of
|
||||
[{_, WindowStart, Count}] when Now - WindowStart < ?RL_WINDOW_MS ->
|
||||
Count < ?RL_LIMIT;
|
||||
_ ->
|
||||
true
|
||||
end,
|
||||
case Result of
|
||||
true ->
|
||||
ets:update_counter(?RL_TABLE, Key, {3, 1}, {Key, Now, 0}),
|
||||
true;
|
||||
false ->
|
||||
false
|
||||
end.
|
||||
|
||||
%% Глобальный троттлинг апстрима: не более 2 rps на весь узел.
|
||||
throttle_allow() ->
|
||||
ensure(),
|
||||
NowMs = erlang:monotonic_time(millisecond),
|
||||
MinGap = 500,
|
||||
case ets:lookup(?UP, last) of
|
||||
[{last, Prev}] when NowMs - Prev < MinGap -> false;
|
||||
_ ->
|
||||
ets:insert(?UP, {last, NowMs}),
|
||||
true
|
||||
end.
|
||||
@@ -5,53 +5,153 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_auth_session).
|
||||
-include("records.hrl").
|
||||
-export([issue_admin_tokens/2, refresh_admin/1,
|
||||
issue_user_tokens/2, refresh_user/1]).
|
||||
-export([
|
||||
issue_admin_tokens/2, refresh_admin/1, logout_admin/1,
|
||||
issue_user_tokens/2, issue_user_tokens/5, refresh_user/1, logout_user/1,
|
||||
list_user_sessions/1, revoke_user_session/2, revoke_other_user_sessions/2,
|
||||
normalize_user_client_type/1, normalize_device_name/1
|
||||
]).
|
||||
|
||||
-define(USER_CLIENT_WEB, <<"web">>).
|
||||
-define(USER_CLIENT_MOBILE, <<"mobile">>).
|
||||
-define(DEVICE_NAME_MAX, 120).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Admin (фаза 1)
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
-spec issue_admin_tokens(AdminId :: binary(), Role :: binary()) ->
|
||||
{ok, AccessToken :: binary(), RefreshToken :: binary()}.
|
||||
{ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()}.
|
||||
issue_admin_tokens(AdminId, Role) ->
|
||||
issue_tokens(AdminId, admin, <<"admin">>, Role, fun eventhub_auth:generate_admin_token/2,
|
||||
issue_tokens(AdminId, admin, <<"admin">>, <<>>, <<>>, Role,
|
||||
fun eventhub_auth:generate_admin_token/2,
|
||||
fun eventhub_auth:generate_admin_refresh_token/5).
|
||||
|
||||
-spec refresh_admin(RefreshToken :: binary()) ->
|
||||
{ok, AccessToken :: binary(), NewRefreshToken :: binary()} |
|
||||
{ok, AccessToken :: binary(), NewRefreshToken :: binary(), SessionId :: binary()} |
|
||||
{error, atom()}.
|
||||
refresh_admin(RefreshToken) ->
|
||||
refresh_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1,
|
||||
fun core_admin:get_by_id/1, admin, fun eventhub_auth:generate_admin_token/2,
|
||||
fun eventhub_auth:generate_admin_refresh_token/5, <<"admin">>).
|
||||
fun eventhub_auth:generate_admin_refresh_token/5).
|
||||
|
||||
-spec logout_admin(RefreshToken :: binary()) -> ok | {error, atom()}.
|
||||
logout_admin(RefreshToken) ->
|
||||
logout_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% User (фаза 2)
|
||||
%%% User (фаза 2 / 3)
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
-spec issue_user_tokens(UserId :: binary(), Role :: binary()) ->
|
||||
{ok, AccessToken :: binary(), RefreshToken :: binary()}.
|
||||
{ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()}.
|
||||
issue_user_tokens(UserId, Role) ->
|
||||
issue_tokens(UserId, user, ?USER_CLIENT_WEB, Role, fun eventhub_auth:generate_user_token/2,
|
||||
fun eventhub_auth:generate_user_refresh_token/5).
|
||||
issue_user_tokens(UserId, Role, ?USER_CLIENT_WEB, <<>>, <<>>).
|
||||
|
||||
-spec issue_user_tokens(UserId :: binary(), Role :: binary(),
|
||||
ClientType :: binary(), DeviceName :: binary(),
|
||||
UserAgent :: binary()) ->
|
||||
{ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()} |
|
||||
{error, invalid_client_type}.
|
||||
issue_user_tokens(UserId, Role, ClientType0, DeviceName0, UserAgent0) ->
|
||||
case normalize_user_client_type(ClientType0) of
|
||||
{ok, ClientType} ->
|
||||
DeviceName = normalize_device_name(DeviceName0),
|
||||
UserAgent = case UserAgent0 of
|
||||
U when is_binary(U) -> U;
|
||||
_ -> <<>>
|
||||
end,
|
||||
issue_tokens(UserId, user, ClientType, DeviceName, UserAgent, Role,
|
||||
fun eventhub_auth:generate_user_token/2,
|
||||
fun eventhub_auth:generate_user_refresh_token/5);
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end.
|
||||
|
||||
-spec refresh_user(RefreshToken :: binary()) ->
|
||||
{ok, AccessToken :: binary(), NewRefreshToken :: binary()} |
|
||||
{ok, AccessToken :: binary(), NewRefreshToken :: binary(), SessionId :: binary()} |
|
||||
{error, atom()}.
|
||||
refresh_user(RefreshToken) ->
|
||||
refresh_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1,
|
||||
fun core_user:get_by_id/1, user, fun eventhub_auth:generate_user_token/2,
|
||||
fun eventhub_auth:generate_user_refresh_token/5, ?USER_CLIENT_WEB).
|
||||
fun eventhub_auth:generate_user_refresh_token/5).
|
||||
|
||||
-spec logout_user(RefreshToken :: binary()) -> ok | {error, atom()}.
|
||||
logout_user(RefreshToken) ->
|
||||
logout_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1).
|
||||
|
||||
-spec list_user_sessions(UserId :: binary()) -> [#auth_session{}].
|
||||
list_user_sessions(UserId) ->
|
||||
core_auth_session:list_active_for_subject(UserId, user).
|
||||
|
||||
-spec revoke_user_session(UserId :: binary(), SessionId :: binary()) ->
|
||||
ok | {error, not_found}.
|
||||
revoke_user_session(UserId, SessionId) ->
|
||||
case core_auth_session:get(SessionId) of
|
||||
{ok, #auth_session{subject_id = UserId, subject_type = user}} ->
|
||||
core_auth_session:revoke(SessionId);
|
||||
{ok, _} ->
|
||||
{error, not_found};
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end.
|
||||
|
||||
-spec revoke_other_user_sessions(UserId :: binary(), RefreshToken :: binary()) ->
|
||||
{ok, non_neg_integer()} | {error, atom()}.
|
||||
revoke_other_user_sessions(UserId, RefreshToken) ->
|
||||
case eventhub_auth:verify_user_refresh_token(RefreshToken) of
|
||||
{ok, Claims} ->
|
||||
case maps:get(<<"sub">>, Claims) of
|
||||
UserId ->
|
||||
SessionId = maps:get(<<"sid">>, Claims),
|
||||
core_auth_session:revoke_others_for_subject(UserId, user, SessionId);
|
||||
_ ->
|
||||
{error, forbidden}
|
||||
end;
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end.
|
||||
|
||||
-spec normalize_user_client_type(term()) -> {ok, binary()} | {error, invalid_client_type}.
|
||||
normalize_user_client_type(undefined) -> {ok, ?USER_CLIENT_WEB};
|
||||
normalize_user_client_type(null) -> {ok, ?USER_CLIENT_WEB};
|
||||
normalize_user_client_type(<<>>) -> {ok, ?USER_CLIENT_WEB};
|
||||
normalize_user_client_type(?USER_CLIENT_WEB) -> {ok, ?USER_CLIENT_WEB};
|
||||
normalize_user_client_type(?USER_CLIENT_MOBILE) -> {ok, ?USER_CLIENT_MOBILE};
|
||||
normalize_user_client_type(<<"WEB">>) -> {ok, ?USER_CLIENT_WEB};
|
||||
normalize_user_client_type(<<"MOBILE">>) -> {ok, ?USER_CLIENT_MOBILE};
|
||||
normalize_user_client_type(_) -> {error, invalid_client_type}.
|
||||
|
||||
-spec normalize_device_name(term()) -> binary().
|
||||
normalize_device_name(Name) when is_binary(Name) ->
|
||||
Trimmed = string:trim(Name),
|
||||
case byte_size(Trimmed) > ?DEVICE_NAME_MAX of
|
||||
true -> binary:part(Trimmed, 0, ?DEVICE_NAME_MAX);
|
||||
false -> Trimmed
|
||||
end;
|
||||
normalize_device_name(_) ->
|
||||
<<>>.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) ->
|
||||
{ok, Session} = core_auth_session:create(SubjectId, SubjectType, ClientType),
|
||||
logout_subject(RefreshToken, VerifyFn) ->
|
||||
case VerifyFn(RefreshToken) of
|
||||
{ok, Claims} ->
|
||||
SessionId = maps:get(<<"sid">>, Claims),
|
||||
case core_auth_session:revoke(SessionId) of
|
||||
ok -> ok;
|
||||
{error, not_found} -> ok;
|
||||
{error, _} = Err -> Err
|
||||
end;
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end.
|
||||
|
||||
issue_tokens(SubjectId, SubjectType, ClientType, DeviceName, UserAgent, Role, GenAccess, GenRefresh) ->
|
||||
{ok, Session} = core_auth_session:create(
|
||||
SubjectId, SubjectType, ClientType, DeviceName, UserAgent),
|
||||
AccessToken = GenAccess(SubjectId, Role),
|
||||
{RefreshToken, _} = GenRefresh(
|
||||
Session#auth_session.session_id,
|
||||
@@ -60,28 +160,29 @@ issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) ->
|
||||
SubjectId,
|
||||
ClientType
|
||||
),
|
||||
{ok, AccessToken, RefreshToken}.
|
||||
{ok, AccessToken, RefreshToken, Session#auth_session.session_id}.
|
||||
|
||||
refresh_subject(RefreshToken, VerifyFn, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) ->
|
||||
refresh_subject(RefreshToken, VerifyFn, GetSubjectFn, SubjectType, GenAccess, GenRefresh) ->
|
||||
case VerifyFn(RefreshToken) of
|
||||
{ok, Claims} ->
|
||||
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType);
|
||||
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh);
|
||||
Error ->
|
||||
Error
|
||||
end.
|
||||
|
||||
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) ->
|
||||
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh) ->
|
||||
SessionId = maps:get(<<"sid">>, Claims),
|
||||
Jti = maps:get(<<"jti">>, Claims),
|
||||
SubjectId = maps:get(<<"sub">>, Claims),
|
||||
FamilyId = maps:get(<<"fid">>, Claims),
|
||||
case core_auth_session:rotate(SessionId, Jti) of
|
||||
{ok, NewJti, _Session} ->
|
||||
{ok, NewJti, Session} ->
|
||||
{ok, Subject} = GetSubjectFn(SubjectId),
|
||||
Role = subject_role(Subject, SubjectType),
|
||||
ClientType = Session#auth_session.client_type,
|
||||
AccessToken = GenAccess(SubjectId, Role),
|
||||
{NewRefresh, _} = GenRefresh(SessionId, FamilyId, NewJti, SubjectId, ClientType),
|
||||
{ok, AccessToken, NewRefresh};
|
||||
{ok, AccessToken, NewRefresh, SessionId};
|
||||
{error, reuse_detected} ->
|
||||
core_auth_session:revoke_family(FamilyId),
|
||||
{error, reuse_detected};
|
||||
|
||||
+355
-27
@@ -1,12 +1,14 @@
|
||||
-module(logic_booking).
|
||||
-include("records.hrl").
|
||||
-export([create_booking/2, confirm_booking/2, confirm_booking/3,
|
||||
-export([create_booking/2, create_booking/3, confirm_booking/2, confirm_booking/3,
|
||||
cancel_booking/2, cancel_booking/3, get_booking/2,
|
||||
list_bookings/2, list_user_bookings/1, delete_booking/2,
|
||||
list_bookings/2, list_user_bookings/1, list_user_booking_requests/1,
|
||||
list_user_studio_bookings/1,
|
||||
delete_booking/2,
|
||||
list_bookings_admin/0, get_booking_admin/1,
|
||||
list_event_bookings/1, list_event_bookings/2,
|
||||
process_timeout_bookings/0, cancel_pending_for_owner/1,
|
||||
cancel_pending_for_calendar/1]).
|
||||
process_timeout_bookings/0, process_reminders/0, cancel_pending_for_owner/1,
|
||||
cancel_pending_for_calendar/1, can_manage_event_bookings/2]).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
|
||||
@@ -15,22 +17,50 @@
|
||||
-spec create_booking(UserId :: binary(), EventId :: binary()) ->
|
||||
{ok, #booking{}} |
|
||||
{error, full | already_booked | not_found | personal_calendar |
|
||||
subscription_inactive | own_event | event_not_active | access_denied}.
|
||||
subscription_inactive | own_event | event_not_active | access_denied |
|
||||
occurrence_start_required | invalid_occurrence}.
|
||||
create_booking(UserId, EventId) ->
|
||||
create_booking(UserId, EventId, undefined).
|
||||
|
||||
-spec create_booking(UserId :: binary(), EventId :: binary(),
|
||||
OccurrenceStart :: calendar:datetime() | undefined) ->
|
||||
{ok, #booking{}} |
|
||||
{error, full | already_booked | not_found | personal_calendar |
|
||||
subscription_inactive | own_event | event_not_active | access_denied |
|
||||
occurrence_start_required | invalid_occurrence}.
|
||||
create_booking(UserId, EventId, OccurrenceStart) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, #event{status = active} = Event} ->
|
||||
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||
case resolve_bookable_event(Event, OccurrenceStart) of
|
||||
{ok, BookEvent} ->
|
||||
case core_calendar:get_by_id(BookEvent#event.calendar_id) of
|
||||
{ok, Calendar} ->
|
||||
create_on_calendar(UserId, Event, Calendar);
|
||||
create_on_calendar(UserId, BookEvent, Calendar);
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end;
|
||||
{ok, _} ->
|
||||
{error, event_not_active};
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end.
|
||||
|
||||
resolve_bookable_event(#event{event_type = recurring}, undefined) ->
|
||||
{error, occurrence_start_required};
|
||||
resolve_bookable_event(#event{event_type = recurring} = Master, OccurrenceStart) ->
|
||||
case logic_event:validate_occurrence(Master, OccurrenceStart) of
|
||||
ok ->
|
||||
logic_event:materialize_for_booking(
|
||||
Master#event.id, OccurrenceStart, Master#event.specialist_id);
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end;
|
||||
resolve_bookable_event(Event, _OccurrenceStart) ->
|
||||
{ok, Event}.
|
||||
|
||||
create_on_calendar(UserId, _Event, #calendar{owner_id = UserId}) ->
|
||||
{error, own_event};
|
||||
create_on_calendar(_UserId, _Event, #calendar{type = personal}) ->
|
||||
@@ -68,14 +98,14 @@ initial_status(_) -> pending.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec confirm_booking(BookingId :: binary(), UserId :: binary()) ->
|
||||
{ok, #booking{}} | {error, not_found | access_denied | full}.
|
||||
{ok, #booking{}} | {error, not_found | access_denied | full | expired}.
|
||||
confirm_booking(BookingId, UserId) ->
|
||||
case core_booking:get_by_id(BookingId) of
|
||||
{ok, Booking} ->
|
||||
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
|
||||
true ->
|
||||
case Booking#booking.status of
|
||||
pending ->
|
||||
case ensure_pending_actionable(Booking) of
|
||||
{ok, _} ->
|
||||
case event_capacity_ok(Booking#booking.event_id) of
|
||||
true ->
|
||||
Now = calendar:universal_time(),
|
||||
@@ -83,8 +113,8 @@ confirm_booking(BookingId, UserId) ->
|
||||
false ->
|
||||
{error, full}
|
||||
end;
|
||||
_ ->
|
||||
{error, access_denied}
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
@@ -93,7 +123,7 @@ confirm_booking(BookingId, UserId) ->
|
||||
end.
|
||||
|
||||
-spec confirm_booking(UserId :: binary(), BookingId :: binary(), confirm | decline) ->
|
||||
{ok, #booking{}} | {error, not_found | access_denied | full}.
|
||||
{ok, #booking{}} | {error, not_found | access_denied | full | expired}.
|
||||
confirm_booking(UserId, BookingId, confirm) ->
|
||||
confirm_booking(BookingId, UserId);
|
||||
confirm_booking(UserId, BookingId, decline) ->
|
||||
@@ -101,11 +131,17 @@ confirm_booking(UserId, BookingId, decline) ->
|
||||
{ok, Booking} ->
|
||||
case can_manage_event_bookings(UserId, Booking#booking.event_id) of
|
||||
true ->
|
||||
case Booking#booking.status of
|
||||
pending ->
|
||||
core_booking:update(BookingId, [{status, cancelled}]);
|
||||
_ ->
|
||||
{error, access_denied}
|
||||
case ensure_pending_actionable(Booking) of
|
||||
{ok, _} ->
|
||||
case core_booking:update(BookingId, [{status, cancelled}]) of
|
||||
{ok, _} = Ok ->
|
||||
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
|
||||
Ok;
|
||||
Err ->
|
||||
Err
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
@@ -121,9 +157,18 @@ cancel_booking(BookingId, UserId) ->
|
||||
case Booking#booking.status of
|
||||
cancelled ->
|
||||
{ok, Booking};
|
||||
expired ->
|
||||
{ok, Booking};
|
||||
_ ->
|
||||
case Booking#booking.user_id =:= UserId of
|
||||
true -> core_booking:update(BookingId, [{status, cancelled}]);
|
||||
true ->
|
||||
case core_booking:update(BookingId, [{status, cancelled}]) of
|
||||
{ok, _} = Ok ->
|
||||
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
|
||||
Ok;
|
||||
Err ->
|
||||
Err
|
||||
end;
|
||||
false -> {error, access_denied}
|
||||
end
|
||||
end;
|
||||
@@ -139,7 +184,8 @@ cancel_booking(UserId, BookingId, cancel) ->
|
||||
{ok, #booking{}} | {error, not_found | access_denied}.
|
||||
get_booking(BookingId, UserId) ->
|
||||
case core_booking:get_by_id(BookingId) of
|
||||
{ok, Booking} ->
|
||||
{ok, Booking0} ->
|
||||
Booking = ensure_not_past_pending(Booking0),
|
||||
case Booking#booking.user_id =:= UserId of
|
||||
true -> {ok, Booking};
|
||||
false ->
|
||||
@@ -159,11 +205,133 @@ list_bookings(EventId, UserId) ->
|
||||
true -> Bookings;
|
||||
false -> [B || B <- Bookings, B#booking.user_id =:= UserId]
|
||||
end,
|
||||
{ok, Filtered}.
|
||||
{ok, [ensure_not_past_pending(B) || B <- Filtered]}.
|
||||
|
||||
-spec list_user_bookings(UserId :: binary()) -> {ok, [#booking{}]}.
|
||||
list_user_bookings(UserId) ->
|
||||
core_booking:list_by_user(UserId).
|
||||
{ok, Bookings} = core_booking:list_by_user(UserId),
|
||||
{ok, [ensure_not_past_pending(B) || B <- Bookings]}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Pending bookings the user can confirm/decline as owner or specialist.
|
||||
%%% Returns `{ok, [{Booking, Event, Role}]}` where Role is `owner` | `specialist`.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec list_user_booking_requests(UserId :: binary()) ->
|
||||
{ok, [{#booking{}, #event{}, owner | specialist}]}.
|
||||
list_user_booking_requests(UserId) ->
|
||||
{OwnedEventIds, SpecOnly} = managed_event_ids(UserId),
|
||||
OwnerItems = collect_pending(OwnedEventIds, owner),
|
||||
SpecItems = collect_pending(SpecOnly, specialist),
|
||||
{ok, sort_requests(OwnerItems ++ SpecItems)}.
|
||||
|
||||
-spec list_user_studio_bookings(UserId :: binary()) ->
|
||||
{ok, [{#booking{}, #event{}, owner | specialist}]}.
|
||||
list_user_studio_bookings(UserId) ->
|
||||
{OwnedEventIds, SpecOnly} = managed_event_ids(UserId),
|
||||
OwnerItems = collect_studio(OwnedEventIds, owner),
|
||||
SpecItems = collect_studio(SpecOnly, specialist),
|
||||
{ok, sort_requests(OwnerItems ++ SpecItems)}.
|
||||
|
||||
managed_event_ids(UserId) ->
|
||||
OwnedEventIds = owned_event_ids(UserId),
|
||||
SpecEventIds = specialist_event_ids(UserId),
|
||||
OwnedSet = sets:from_list(OwnedEventIds),
|
||||
SpecOnly = [E || E <- SpecEventIds, not sets:is_element(E, OwnedSet)],
|
||||
{OwnedEventIds, SpecOnly}.
|
||||
|
||||
owned_event_ids(UserId) ->
|
||||
case core_calendar:list_by_owner(UserId) of
|
||||
{ok, Cals} ->
|
||||
lists:flatmap(fun(#calendar{id = CalId}) ->
|
||||
case core_event:list_active_including_instances(CalId) of
|
||||
{ok, Events} -> [E#event.id || E <- Events];
|
||||
_ -> []
|
||||
end
|
||||
end, Cals);
|
||||
_ ->
|
||||
[]
|
||||
end.
|
||||
|
||||
specialist_event_ids(UserId) ->
|
||||
Specs = [S || S <- core_calendar_specialist:list_by_user(UserId),
|
||||
S#calendar_specialist.status =:= active],
|
||||
lists:flatmap(fun(#calendar_specialist{calendar_id = CalId}) ->
|
||||
case core_event:list_active_including_instances(CalId) of
|
||||
{ok, Events} ->
|
||||
[E#event.id || E <- Events,
|
||||
is_binary(E#event.specialist_id),
|
||||
E#event.specialist_id =/= <<>>,
|
||||
E#event.specialist_id =:= UserId];
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
end, Specs).
|
||||
|
||||
collect_pending(EventIds, Role) ->
|
||||
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
|
||||
lists:flatmap(fun(EventId) ->
|
||||
case core_booking:list_by_event(EventId) of
|
||||
{ok, Bookings} ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
lists:filtermap(fun(B) ->
|
||||
case B#booking.status of
|
||||
pending ->
|
||||
case event_started(Event, NowSec) of
|
||||
true ->
|
||||
_ = mark_expired(B#booking.id),
|
||||
false;
|
||||
false ->
|
||||
{true, {B, Event, Role}}
|
||||
end;
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end, Bookings);
|
||||
_ ->
|
||||
[]
|
||||
end;
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
end, EventIds).
|
||||
|
||||
collect_studio(EventIds, Role) ->
|
||||
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
|
||||
lists:flatmap(fun(EventId) ->
|
||||
case core_booking:list_by_event(EventId) of
|
||||
{ok, Bookings} ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
lists:filtermap(fun(B) ->
|
||||
case B#booking.status of
|
||||
pending ->
|
||||
case event_started(Event, NowSec) of
|
||||
true ->
|
||||
_ = mark_expired(B#booking.id),
|
||||
false;
|
||||
false ->
|
||||
{true, {B, Event, Role}}
|
||||
end;
|
||||
confirmed ->
|
||||
{true, {B, Event, Role}};
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end, Bookings);
|
||||
_ ->
|
||||
[]
|
||||
end;
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
end, EventIds).
|
||||
|
||||
sort_requests(Items) ->
|
||||
lists:sort(fun({A, _, _}, {B, _, _}) ->
|
||||
A#booking.created_at >= B#booking.created_at
|
||||
end, Items).
|
||||
|
||||
-spec delete_booking(BookingId :: binary(), UserId :: binary()) ->
|
||||
ok | {error, not_found | access_denied}.
|
||||
@@ -184,24 +352,25 @@ get_booking_admin(BookingId) ->
|
||||
|
||||
-spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}.
|
||||
list_event_bookings(EventId) ->
|
||||
core_booking:list_by_event(EventId).
|
||||
{ok, Bookings} = core_booking:list_by_event(EventId),
|
||||
{ok, [ensure_not_past_pending(B) || B <- Bookings]}.
|
||||
|
||||
-spec list_event_bookings(UserId :: binary(), EventId :: binary()) ->
|
||||
{ok, [#booking{}]} | {error, not_found | access_denied}.
|
||||
list_event_bookings(UserId, EventId) ->
|
||||
case can_manage_event_bookings(UserId, EventId) of
|
||||
true ->
|
||||
core_booking:list_by_event(EventId);
|
||||
list_event_bookings(EventId);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec list_bookings_admin() -> {ok, [#booking{}]}.
|
||||
list_bookings_admin() ->
|
||||
{ok, core_booking:list_all()}.
|
||||
{ok, [ensure_not_past_pending(B) || B <- core_booking:list_all()]}.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Авто-confirm/cancel по политике {timeout, N}.
|
||||
%%% @doc Авто-confirm/cancel по политике {timeout, N}; past-pending → expired.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec process_timeout_bookings() -> ok.
|
||||
@@ -211,9 +380,102 @@ process_timeout_bookings() ->
|
||||
lists:foreach(fun(B) -> maybe_timeout(B, NowSec) end, Pending),
|
||||
ok.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Email + in-app reminder before event start (Back#70).
|
||||
%%% Confirmed bookings with reminder_sent=false whose event starts within
|
||||
%%% REMINDER_LEAD_HOURS (default 24). Flag set before send (once).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec process_reminders() -> ok.
|
||||
process_reminders() ->
|
||||
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
|
||||
LeadSec = reminder_lead_hours() * 3600,
|
||||
Horizon = NowSec + LeadSec,
|
||||
Candidates = mnesia:dirty_match_object(
|
||||
#booking{status = confirmed, reminder_sent = false, _ = '_'}),
|
||||
lists:foreach(fun(B) -> maybe_remind(B, NowSec, Horizon) end, Candidates),
|
||||
ok.
|
||||
|
||||
maybe_remind(#booking{id = Id, event_id = EventId, user_id = UserId} = Booking,
|
||||
NowSec, Horizon) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, #event{status = active, start_time = Start, title = Title,
|
||||
calendar_id = CalId} = _Event} ->
|
||||
StartSec = calendar:datetime_to_gregorian_seconds(Start),
|
||||
case StartSec > NowSec andalso StartSec =< Horizon of
|
||||
true ->
|
||||
case core_booking:update(Id, [{reminder_sent, true}]) of
|
||||
{ok, _} ->
|
||||
send_reminder(UserId, Title, Start, CalId, EventId),
|
||||
ok;
|
||||
_ ->
|
||||
ok
|
||||
end;
|
||||
false ->
|
||||
ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
Booking.
|
||||
|
||||
send_reminder(UserId, Title, Start, CalId, EventId) ->
|
||||
Path = <<"/c/", CalId/binary, "/e/", EventId/binary>>,
|
||||
WhenText = format_when(Start),
|
||||
case logic_notification_prefs:email_enabled(UserId) of
|
||||
true ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
|
||||
_ = logic_email:send_booking_reminder(Email, Title, Start, Path);
|
||||
_ ->
|
||||
ok
|
||||
end;
|
||||
false ->
|
||||
ok
|
||||
end,
|
||||
_ = logic_notification:notify_event_reminder(UserId, #{
|
||||
event_id => EventId,
|
||||
calendar_id => CalId,
|
||||
title => Title,
|
||||
start_time => Start,
|
||||
when_text => WhenText
|
||||
}),
|
||||
_ = logic_web_push:send(
|
||||
UserId,
|
||||
<<"Напоминание о записи"/utf8>>,
|
||||
iolist_to_binary([<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText]),
|
||||
Path
|
||||
),
|
||||
ok.
|
||||
|
||||
format_when({{Y, Mo, D}, {H, Mi, _S}}) ->
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
|
||||
[Y, Mo, D, H, Mi])).
|
||||
|
||||
reminder_lead_hours() ->
|
||||
case application:get_env(eventhub, reminder_lead_hours) of
|
||||
{ok, N} when is_integer(N), N > 0 -> N;
|
||||
_ ->
|
||||
case os:getenv("REMINDER_LEAD_HOURS") of
|
||||
false -> 24;
|
||||
"" -> 24;
|
||||
S ->
|
||||
try list_to_integer(S) of
|
||||
N when N > 0 -> N;
|
||||
_ -> 24
|
||||
catch
|
||||
_:_ -> 24
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Booking, NowSec) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
case 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),
|
||||
@@ -224,13 +486,19 @@ maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Book
|
||||
Now = calendar:universal_time(),
|
||||
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
|
||||
false ->
|
||||
_ = core_booking:update(Id, [{status, cancelled}])
|
||||
case core_booking:update(Id, [{status, cancelled}]) of
|
||||
{ok, _} ->
|
||||
_ = logic_waitlist:maybe_promote(EventId);
|
||||
_ ->
|
||||
ok
|
||||
end
|
||||
end;
|
||||
false ->
|
||||
ok
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
end
|
||||
end;
|
||||
_ ->
|
||||
ok
|
||||
@@ -272,6 +540,66 @@ cancel_pending_for_calendar(CalendarId) ->
|
||||
%%% INTERNAL
|
||||
%%%===================================================================
|
||||
|
||||
%% @doc Pending after event start is no longer actionable → expired.
|
||||
-spec ensure_pending_actionable(#booking{}) ->
|
||||
{ok, #booking{}} | {error, access_denied | expired | not_found}.
|
||||
ensure_pending_actionable(#booking{status = pending} = Booking) ->
|
||||
case core_event:get_by_id(Booking#booking.event_id) of
|
||||
{ok, Event} ->
|
||||
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
|
||||
case event_started(Event, NowSec) of
|
||||
true ->
|
||||
_ = mark_expired(Booking#booking.id),
|
||||
{error, expired};
|
||||
false ->
|
||||
{ok, Booking}
|
||||
end;
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
ensure_pending_actionable(_) ->
|
||||
{error, access_denied}.
|
||||
|
||||
-spec ensure_not_past_pending(#booking{}) -> #booking{}.
|
||||
ensure_not_past_pending(#booking{status = pending, id = Id, event_id = EventId} = B) ->
|
||||
case core_event:get_by_id(EventId) of
|
||||
{ok, Event} ->
|
||||
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
|
||||
case event_started(Event, NowSec) of
|
||||
true ->
|
||||
case mark_expired(Id) of
|
||||
{ok, Updated} -> Updated;
|
||||
_ -> B#booking{status = expired}
|
||||
end;
|
||||
false ->
|
||||
B
|
||||
end;
|
||||
_ ->
|
||||
B
|
||||
end;
|
||||
ensure_not_past_pending(B) ->
|
||||
B.
|
||||
|
||||
-spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}.
|
||||
mark_expired(BookingId) ->
|
||||
case core_booking:get_by_id(BookingId) of
|
||||
{ok, #booking{event_id = EventId}} ->
|
||||
case core_booking:update(BookingId, [{status, expired}]) of
|
||||
{ok, _} = Ok ->
|
||||
_ = logic_waitlist:maybe_promote(EventId),
|
||||
Ok;
|
||||
Err ->
|
||||
Err
|
||||
end;
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end.
|
||||
|
||||
-spec event_started(#event{}, non_neg_integer()) -> boolean().
|
||||
event_started(#event{start_time = Start}, NowSec) ->
|
||||
StartSec = calendar:datetime_to_gregorian_seconds(Start),
|
||||
NowSec >= StartSec.
|
||||
|
||||
-spec can_manage_event_bookings(UserId :: binary(), EventId :: binary()) ->
|
||||
true | {error, not_found | access_denied}.
|
||||
can_manage_event_bookings(UserId, EventId) ->
|
||||
|
||||
+255
-57
@@ -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 ->
|
||||
case has_active_personal_calendar(UserId) of
|
||||
true ->
|
||||
{error, personal_exists};
|
||||
false ->
|
||||
core_calendar:create(UserId, Title2, Desc2, Confirmation, Type)
|
||||
end
|
||||
end,
|
||||
case Result of
|
||||
{ok, Cal} ->
|
||||
logic_automoderation:apply_after_save(calendar, Cal#calendar.id, Action, Words),
|
||||
core_calendar:get_by_id(Cal#calendar.id);
|
||||
case core_calendar:get_by_id(Cal#calendar.id) of
|
||||
{ok, Cal2} = Ok ->
|
||||
case Cal2#calendar.type of
|
||||
commercial ->
|
||||
_ = logic_calendar_specialist:ensure_owner_specialist(Cal2);
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
Ok;
|
||||
Error ->
|
||||
Error
|
||||
end;
|
||||
Error ->
|
||||
Error
|
||||
end
|
||||
@@ -50,46 +69,100 @@ create_calendar(UserId, Title, Description, Confirmation, Type) ->
|
||||
{error, user_not_found}
|
||||
end.
|
||||
|
||||
%% @doc Создаёт дефолтный personal-календарь после активации пользователя.
|
||||
%% Идемпотентно: если у владельца уже есть active personal — ok.
|
||||
%% @doc Единственный personal владельца: title Default; лишние → commercial|delete.
|
||||
%% Вызывается после verify; идемпотентно.
|
||||
-spec ensure_default_calendar(UserId :: binary()) -> ok | {error, term()}.
|
||||
ensure_default_calendar(UserId) ->
|
||||
case has_active_personal_calendar(UserId) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
normalize_owner_personals(UserId).
|
||||
|
||||
%% @doc Backfill всех владельцев: ≤1 personal с title Default.
|
||||
-spec backfill_single_personal() -> ok.
|
||||
backfill_single_personal() ->
|
||||
Owners = lists:usort([
|
||||
C#calendar.owner_id
|
||||
|| C <- core_calendar:list_all(),
|
||||
C#calendar.status =:= active,
|
||||
C#calendar.type =:= personal
|
||||
]),
|
||||
lists:foreach(fun(OwnerId) -> _ = normalize_owner_personals(OwnerId) end, Owners),
|
||||
ok.
|
||||
|
||||
-spec normalize_owner_personals(UserId :: binary()) -> ok | {error, term()}.
|
||||
normalize_owner_personals(UserId) ->
|
||||
Personals = active_personals(UserId),
|
||||
case Personals of
|
||||
[] ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, User} ->
|
||||
Title = default_calendar_title(User),
|
||||
case create_calendar(UserId, Title, <<>>, manual, personal) of
|
||||
{ok, #user{status = active}} ->
|
||||
case create_calendar(UserId, ?DEFAULT_PERSONAL_TITLE, <<>>, manual, personal) of
|
||||
{ok, _} -> ok;
|
||||
Error -> Error
|
||||
end;
|
||||
{ok, _} ->
|
||||
{error, user_inactive};
|
||||
Error ->
|
||||
Error
|
||||
end
|
||||
end;
|
||||
[Keep | Rest] ->
|
||||
_ = core_calendar:update(Keep#calendar.id, [{title, ?DEFAULT_PERSONAL_TITLE}]),
|
||||
lists:foreach(fun convert_or_delete_extra_personal/1, Rest),
|
||||
ok
|
||||
end.
|
||||
|
||||
active_personals(UserId) ->
|
||||
case core_calendar:list_by_owner(UserId) of
|
||||
{ok, Calendars} ->
|
||||
lists:sort(
|
||||
fun(#calendar{created_at = A, id = IdA}, #calendar{created_at = B, id = IdB}) ->
|
||||
{A, IdA} =< {B, IdB}
|
||||
end,
|
||||
[C || C <- Calendars, C#calendar.type =:= personal]
|
||||
);
|
||||
_ ->
|
||||
[]
|
||||
end.
|
||||
|
||||
has_active_personal_calendar(UserId) ->
|
||||
case core_calendar:list_by_owner(UserId) of
|
||||
{ok, Calendars} ->
|
||||
lists:any(
|
||||
fun(#calendar{type = personal}) -> true;
|
||||
(_) -> false
|
||||
end,
|
||||
Calendars);
|
||||
_ ->
|
||||
false
|
||||
end.
|
||||
active_personals(UserId) =/= [].
|
||||
|
||||
default_calendar_title(#user{nickname = Nick}) when is_binary(Nick), byte_size(Nick) > 0 ->
|
||||
Nick;
|
||||
default_calendar_title(_) ->
|
||||
<<"Мой календарь">>.
|
||||
convert_or_delete_extra_personal(#calendar{id = Id} = Cal) ->
|
||||
case calendar_has_content(Id) of
|
||||
true ->
|
||||
%% System backfill: bypass subscription gate.
|
||||
case core_calendar:update(Id, [{type, commercial}]) of
|
||||
{ok, Updated} ->
|
||||
_ = logic_calendar_specialist:ensure_owner_specialist(Updated);
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
ok;
|
||||
false ->
|
||||
_ = core_calendar:delete(Id),
|
||||
ok
|
||||
end,
|
||||
Cal.
|
||||
|
||||
calendar_has_content(CalendarId) ->
|
||||
Events =
|
||||
try
|
||||
case core_event:list_by_calendar(CalendarId) of
|
||||
{ok, Es} -> Es;
|
||||
_ -> []
|
||||
end
|
||||
catch
|
||||
_:_ -> []
|
||||
end,
|
||||
Specs =
|
||||
try
|
||||
core_calendar_specialist:list_by_calendar(CalendarId)
|
||||
catch
|
||||
_:_ -> []
|
||||
end,
|
||||
Events =/= [] orelse Specs =/= [].
|
||||
|
||||
%% Получение календаря с проверкой доступа
|
||||
get_calendar(UserId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
case core_calendar:get_by_id_or_short_name(CalendarId) of
|
||||
{ok, Calendar} ->
|
||||
case can_access(UserId, Calendar) of
|
||||
true -> {ok, Calendar};
|
||||
@@ -99,9 +172,21 @@ get_calendar(UserId, CalendarId) ->
|
||||
Error
|
||||
end.
|
||||
|
||||
%% Список календарей пользователя
|
||||
%% Список календарей пользователя: owned ∪ shared grants
|
||||
list_calendars(UserId) ->
|
||||
core_calendar:list_by_owner(UserId).
|
||||
{ok, Owned} = core_calendar:list_by_owner(UserId),
|
||||
Shared = shared_calendars(UserId),
|
||||
OwnedIds = [C#calendar.id || C <- Owned],
|
||||
SharedOnly = [C || C <- Shared, not lists:member(C#calendar.id, OwnedIds)],
|
||||
{ok, Owned ++ SharedOnly}.
|
||||
|
||||
shared_calendars(UserId) ->
|
||||
lists:filtermap(fun(#calendar_share{calendar_id = CalId}) ->
|
||||
case core_calendar:get_by_id(CalId) of
|
||||
{ok, #calendar{status = active} = Cal} -> {true, Cal};
|
||||
_ -> false
|
||||
end
|
||||
end, core_calendar_share:list_by_user(UserId)).
|
||||
|
||||
%% Обновление календаря
|
||||
update_calendar(UserId, CalendarId, Updates) ->
|
||||
@@ -109,12 +194,16 @@ update_calendar(UserId, CalendarId, Updates) ->
|
||||
{ok, Calendar} ->
|
||||
case can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
ValidUpdates = validate_updates(Updates),
|
||||
case validate_updates(Updates) of
|
||||
{error, _} = Err ->
|
||||
Err;
|
||||
{ok, ValidUpdates} ->
|
||||
case gate_type_change(UserId, Calendar, ValidUpdates) of
|
||||
{error, _} = Err ->
|
||||
Err;
|
||||
{ok, ValidUpdates2} ->
|
||||
apply_calendar_update(CalendarId, Calendar, ValidUpdates2)
|
||||
end
|
||||
end;
|
||||
false ->
|
||||
{error, access_denied}
|
||||
@@ -150,18 +239,16 @@ apply_calendar_update(CalendarId, Calendar, ValidUpdates) ->
|
||||
end
|
||||
end.
|
||||
|
||||
gate_type_change(UserId, #calendar{type = personal}, Updates) ->
|
||||
%% Смена type — только owner. Единственный personal → commercial запрещён.
|
||||
gate_type_change(UserId, #calendar{owner_id = OwnerId} = Calendar, Updates) ->
|
||||
case lists:keyfind(type, 1, Updates) of
|
||||
{type, commercial} ->
|
||||
case logic_subscription:can_create_commercial_calendar(UserId) of
|
||||
true -> {ok, Updates};
|
||||
false -> {error, subscription_required}
|
||||
end;
|
||||
{type, _} when UserId =/= OwnerId ->
|
||||
{error, access_denied};
|
||||
{type, commercial} when Calendar#calendar.type =:= personal ->
|
||||
{error, default_calendar};
|
||||
_ ->
|
||||
{ok, Updates}
|
||||
end;
|
||||
gate_type_change(_UserId, _Calendar, Updates) ->
|
||||
{ok, Updates}.
|
||||
end.
|
||||
|
||||
maybe_cancel_pending_on_personal(#calendar{type = commercial},
|
||||
#calendar{type = personal, id = Id}) ->
|
||||
@@ -195,16 +282,15 @@ apply_text_results(Updates, [], []) -> Updates;
|
||||
apply_text_results(Updates, [F | Fs], [T | Ts]) ->
|
||||
apply_text_results(lists:keystore(F, 1, Updates, {F, T}), Fs, Ts).
|
||||
|
||||
%% Удаление календаря
|
||||
%% Удаление календаря: только owner (share write/admin не удаляет)
|
||||
delete_calendar(UserId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, Calendar} ->
|
||||
case can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
{ok, #calendar{type = personal}} ->
|
||||
{error, default_calendar};
|
||||
{ok, #calendar{owner_id = UserId, status = active}} ->
|
||||
core_calendar:delete(CalendarId);
|
||||
false ->
|
||||
{error, access_denied}
|
||||
end;
|
||||
{ok, #calendar{}} ->
|
||||
{error, access_denied};
|
||||
Error ->
|
||||
Error
|
||||
end.
|
||||
@@ -212,36 +298,67 @@ delete_calendar(UserId, CalendarId) ->
|
||||
%% Проверка прав доступа (просмотр)
|
||||
can_access(UserId, #calendar{owner_id = UserId, status = active}) ->
|
||||
true;
|
||||
can_access(UserId, #calendar{id = CalId, type = personal, status = active}) ->
|
||||
logic_calendar_share:get_rights(UserId, CalId) =/= none;
|
||||
can_access(_UserId, #calendar{type = commercial, status = active}) ->
|
||||
true;
|
||||
can_access(_UserId, _) ->
|
||||
false.
|
||||
|
||||
%% Проверка прав редактирования
|
||||
%% Проверка прав редактирования (owner или share write|admin)
|
||||
can_edit(UserId, #calendar{owner_id = UserId, status = active}) ->
|
||||
true;
|
||||
can_edit(_UserId, #calendar{owner_id = _OwnerId}) ->
|
||||
false;
|
||||
can_edit(UserId, #calendar{id = CalId, status = active}) ->
|
||||
case logic_calendar_share:get_rights(UserId, CalId) of
|
||||
write -> true;
|
||||
admin -> true;
|
||||
_ -> false
|
||||
end;
|
||||
can_edit(_, _) ->
|
||||
false.
|
||||
|
||||
%% Валидация полей обновления
|
||||
%% Валидация полей обновления.
|
||||
%% Known settings keys with invalid shape → {error, {invalid_settings, Key}}.
|
||||
%% Unknown settings keys (e.g. week_patterns) are preserved as-is.
|
||||
validate_updates(Updates) ->
|
||||
lists:filtermap(fun(U) ->
|
||||
validate_updates(Updates, []).
|
||||
|
||||
validate_updates([], Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
validate_updates([U | Rest], Acc) ->
|
||||
case normalize_update(U) of
|
||||
false -> false;
|
||||
false ->
|
||||
validate_updates(Rest, Acc);
|
||||
{error, _} = Err ->
|
||||
Err;
|
||||
Norm ->
|
||||
case validate_update(Norm) of
|
||||
true -> {true, Norm};
|
||||
false -> false
|
||||
true -> validate_updates(Rest, [Norm | Acc]);
|
||||
false -> validate_updates(Rest, Acc)
|
||||
end
|
||||
end
|
||||
end, Updates).
|
||||
end.
|
||||
|
||||
normalize_update({type, <<"personal">>}) -> {type, personal};
|
||||
normalize_update({type, <<"commercial">>}) -> {type, commercial};
|
||||
normalize_update({type, personal}) -> {type, personal};
|
||||
normalize_update({type, commercial}) -> {type, commercial};
|
||||
normalize_update({<<"settings">>, Value}) when is_map(Value) ->
|
||||
case normalize_settings(Value) of
|
||||
{ok, Map} -> {settings, Map};
|
||||
{error, _} = Err -> Err
|
||||
end;
|
||||
normalize_update({settings, Value}) when is_map(Value) ->
|
||||
case normalize_settings(Value) of
|
||||
{ok, Map} -> {settings, Map};
|
||||
{error, _} = Err -> Err
|
||||
end;
|
||||
normalize_update({<<"title">>, Value}) when is_binary(Value) -> {title, Value};
|
||||
normalize_update({<<"description">>, Value}) when is_binary(Value) -> {description, Value};
|
||||
normalize_update({<<"tags">>, Value}) when is_list(Value) -> {tags, Value};
|
||||
normalize_update({<<"short_name">>, Value}) when is_binary(Value) -> {short_name, Value};
|
||||
normalize_update({<<"category">>, Value}) when is_binary(Value) -> {category, Value};
|
||||
normalize_update({<<"color">>, Value}) when is_binary(Value) -> {color, Value};
|
||||
normalize_update({<<"image_url">>, Value}) when is_binary(Value) -> {image_url, Value};
|
||||
normalize_update(Other) -> Other.
|
||||
|
||||
validate_update({title, Value}) when is_binary(Value) -> true;
|
||||
@@ -249,6 +366,12 @@ validate_update({description, Value}) when is_binary(Value) -> true;
|
||||
validate_update({tags, Value}) when is_list(Value) -> true;
|
||||
validate_update({type, personal}) -> true;
|
||||
validate_update({type, commercial}) -> true;
|
||||
validate_update({short_name, Value}) when is_binary(Value) -> true;
|
||||
validate_update({category, Value}) when is_binary(Value) -> true;
|
||||
validate_update({color, Value}) when is_binary(Value) -> true;
|
||||
validate_update({image_url, Value}) when is_binary(Value) -> true;
|
||||
%% Documented org-default keys validated in normalize_settings/1; unknown keys passthrough.
|
||||
validate_update({settings, Value}) when is_map(Value) -> true;
|
||||
validate_update({confirmation, Value}) ->
|
||||
case Value of
|
||||
auto -> true;
|
||||
@@ -258,6 +381,81 @@ validate_update({confirmation, Value}) ->
|
||||
end;
|
||||
validate_update(_) -> false.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Validate documented calendar.settings keys for org defaults.
|
||||
%%% Unknown keys are kept (forward-compat, e.g. week_patterns).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec normalize_settings(map()) -> {ok, map()} | {error, {invalid_settings, binary()}}.
|
||||
normalize_settings(Map) when is_map(Map) ->
|
||||
normalize_settings_keys(maps:keys(Map), Map).
|
||||
|
||||
normalize_settings_keys([], Map) ->
|
||||
{ok, Map};
|
||||
normalize_settings_keys([Key | Rest], Map) ->
|
||||
case validate_settings_key(Key, maps:get(Key, Map)) of
|
||||
ok ->
|
||||
normalize_settings_keys(Rest, Map);
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end.
|
||||
|
||||
validate_settings_key(<<"default_location">>, Val) ->
|
||||
validate_default_location(Val);
|
||||
validate_settings_key(<<"default_duration_minutes">>, Val) ->
|
||||
validate_default_duration(Val);
|
||||
validate_settings_key(<<"default_recurrence">>, Val) ->
|
||||
validate_default_recurrence(Val);
|
||||
validate_settings_key(<<"waitlist_enabled">>, Val) when is_boolean(Val) ->
|
||||
ok;
|
||||
validate_settings_key(<<"waitlist_enabled">>, _) ->
|
||||
{error, {invalid_settings, <<"waitlist_enabled">>}};
|
||||
validate_settings_key(_Unknown, _Val) ->
|
||||
ok.
|
||||
|
||||
validate_default_location(Loc) when is_map(Loc) ->
|
||||
case maps:get(<<"address">>, Loc, undefined) of
|
||||
Address when is_binary(Address), byte_size(Address) > 0 ->
|
||||
HasLat = maps:is_key(<<"lat">>, Loc),
|
||||
HasLon = maps:is_key(<<"lon">>, Loc),
|
||||
case {HasLat, HasLon} of
|
||||
{false, false} ->
|
||||
ok;
|
||||
{true, true} ->
|
||||
Lat = maps:get(<<"lat">>, Loc),
|
||||
Lon = maps:get(<<"lon">>, Loc),
|
||||
case is_number(Lat) andalso is_number(Lon) of
|
||||
true -> ok;
|
||||
false -> {error, {invalid_settings, <<"default_location">>}}
|
||||
end;
|
||||
_ ->
|
||||
{error, {invalid_settings, <<"default_location">>}}
|
||||
end;
|
||||
_ ->
|
||||
{error, {invalid_settings, <<"default_location">>}}
|
||||
end;
|
||||
validate_default_location(_) ->
|
||||
{error, {invalid_settings, <<"default_location">>}}.
|
||||
|
||||
validate_default_duration(N) when is_integer(N), N >= 1, N =< 1440 ->
|
||||
ok;
|
||||
validate_default_duration(_) ->
|
||||
{error, {invalid_settings, <<"default_duration_minutes">>}}.
|
||||
|
||||
validate_default_recurrence(null) ->
|
||||
ok;
|
||||
validate_default_recurrence(Rec) when is_map(Rec) ->
|
||||
Enabled = maps:get(<<"enabled">>, Rec, undefined),
|
||||
Freq = maps:get(<<"freq">>, Rec, undefined),
|
||||
Interval = maps:get(<<"interval">>, Rec, undefined),
|
||||
ValidFreq = Freq =:= <<"DAILY">> orelse Freq =:= <<"WEEKLY">> orelse Freq =:= <<"MONTHLY">>,
|
||||
case is_boolean(Enabled) andalso ValidFreq andalso is_integer(Interval) andalso Interval >= 1 of
|
||||
true -> ok;
|
||||
false -> {error, {invalid_settings, <<"default_recurrence">>}}
|
||||
end;
|
||||
validate_default_recurrence(_) ->
|
||||
{error, {invalid_settings, <<"default_recurrence">>}}.
|
||||
|
||||
%% ─── Административные функции ────────────────────────────────────────
|
||||
-spec admin_list_all() -> {ok, [map()]} | {error, term()}.
|
||||
admin_list_all() ->
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
-spec follow(UserId :: binary(), CalendarId :: binary()) ->
|
||||
{ok, #calendar_follow{}} | {error, term()}.
|
||||
follow(UserId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
case core_calendar:get_by_id_or_short_name(CalendarId) of
|
||||
{error, not_found} ->
|
||||
{error, not_found};
|
||||
{ok, #calendar{owner_id = UserId}} ->
|
||||
@@ -27,7 +27,7 @@ follow(UserId, CalendarId) ->
|
||||
true ->
|
||||
case Calendar#calendar.status of
|
||||
active ->
|
||||
core_calendar_follow:follow(CalendarId, UserId);
|
||||
core_calendar_follow:follow(Calendar#calendar.id, UserId);
|
||||
_ ->
|
||||
{error, not_found}
|
||||
end
|
||||
@@ -40,14 +40,13 @@ follow(UserId, CalendarId) ->
|
||||
%%%-------------------------------------------------------------------
|
||||
-spec unfollow(UserId :: binary(), CalendarId :: binary()) -> ok | {error, term()}.
|
||||
unfollow(UserId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
case core_calendar:get_by_id_or_short_name(CalendarId) of
|
||||
{error, not_found} ->
|
||||
%% всё равно снимаем локальный follow, если был
|
||||
core_calendar_follow:unfollow(CalendarId, UserId);
|
||||
{ok, #calendar{owner_id = UserId}} ->
|
||||
{error, own_calendar};
|
||||
{ok, _} ->
|
||||
core_calendar_follow:unfollow(CalendarId, UserId)
|
||||
{ok, Calendar} ->
|
||||
core_calendar_follow:unfollow(Calendar#calendar.id, UserId)
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Grants calendar_share: list / revoke / rights / mirror.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_calendar_share).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([
|
||||
list/2,
|
||||
revoke/3,
|
||||
update_rights/4,
|
||||
update_my_mirror/3,
|
||||
get_rights/2,
|
||||
can_manage_shares/2,
|
||||
to_json/1
|
||||
]).
|
||||
|
||||
-spec list(ActorId :: binary(), CalendarId :: binary()) ->
|
||||
{ok, [#calendar_share{}]} | {error, not_found | access_denied}.
|
||||
list(ActorId, CalendarId) ->
|
||||
case require_can_view_shares(ActorId, CalendarId) of
|
||||
{ok, _} -> {ok, core_calendar_share:list_by_calendar(CalendarId)};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec revoke(ActorId :: binary(), CalendarId :: binary(), TargetUserId :: binary()) ->
|
||||
ok | {error, not_found | access_denied | term()}.
|
||||
revoke(ActorId, CalendarId, TargetUserId) ->
|
||||
case require_can_manage(ActorId, CalendarId) of
|
||||
{ok, _} ->
|
||||
case core_calendar_share:delete(CalendarId, TargetUserId) of
|
||||
ok -> ok;
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec update_rights(ActorId :: binary(), CalendarId :: binary(),
|
||||
TargetUserId :: binary(), Rights :: read | write | admin) ->
|
||||
{ok, #calendar_share{}} | {error, not_found | access_denied | bad_request | term()}.
|
||||
update_rights(ActorId, CalendarId, TargetUserId, Rights) ->
|
||||
case valid_rights(Rights) of
|
||||
false -> {error, bad_request};
|
||||
true ->
|
||||
case require_can_manage(ActorId, CalendarId) of
|
||||
{ok, _} -> core_calendar_share:update_rights(CalendarId, TargetUserId, Rights);
|
||||
Error -> Error
|
||||
end
|
||||
end.
|
||||
|
||||
%% Invitee updates own mirror preference (personal shares only meaningful).
|
||||
-spec update_my_mirror(UserId :: binary(), CalendarId :: binary(), Mirror :: boolean()) ->
|
||||
{ok, #calendar_share{}} | {error, not_found | access_denied | term()}.
|
||||
update_my_mirror(UserId, CalendarId, Mirror) when is_boolean(Mirror) ->
|
||||
case core_calendar_share:get(CalendarId, UserId) of
|
||||
{ok, _} -> core_calendar_share:update_mirror(CalendarId, UserId, Mirror);
|
||||
{error, not_found} -> {error, not_found}
|
||||
end;
|
||||
update_my_mirror(_, _, _) ->
|
||||
{error, bad_request}.
|
||||
|
||||
-spec get_rights(UserId :: binary(), CalendarId :: binary()) ->
|
||||
none | read | write | admin.
|
||||
get_rights(UserId, CalendarId) ->
|
||||
case core_calendar_share:get(CalendarId, UserId) of
|
||||
{ok, #calendar_share{rights = R}} -> R;
|
||||
{error, not_found} -> none
|
||||
end.
|
||||
|
||||
-spec can_manage_shares(UserId :: binary(), #calendar{}) -> boolean().
|
||||
can_manage_shares(UserId, #calendar{owner_id = UserId, status = active}) ->
|
||||
true;
|
||||
can_manage_shares(UserId, #calendar{id = CalId, status = active}) ->
|
||||
get_rights(UserId, CalId) =:= admin;
|
||||
can_manage_shares(_, _) ->
|
||||
false.
|
||||
|
||||
-spec to_json(#calendar_share{}) -> map().
|
||||
to_json(S) ->
|
||||
#{
|
||||
id => S#calendar_share.id,
|
||||
calendar_id => S#calendar_share.calendar_id,
|
||||
user_id => S#calendar_share.user_id,
|
||||
rights => S#calendar_share.rights,
|
||||
mirror_to_default => S#calendar_share.mirror_to_default
|
||||
}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
require_can_view_shares(ActorId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, #calendar{status = active} = Cal} ->
|
||||
case can_manage_shares(ActorId, Cal)
|
||||
orelse Cal#calendar.owner_id =:= ActorId
|
||||
orelse get_rights(ActorId, CalendarId) =/= none of
|
||||
true -> {ok, Cal};
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
{ok, _} -> {error, not_found};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
require_can_manage(ActorId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, #calendar{status = active} = Cal} ->
|
||||
case can_manage_shares(ActorId, Cal) of
|
||||
true -> {ok, Cal};
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
{ok, _} -> {error, not_found};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
valid_rights(read) -> true;
|
||||
valid_rights(write) -> true;
|
||||
valid_rights(admin) -> true;
|
||||
valid_rights(_) -> false.
|
||||
@@ -0,0 +1,313 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Приглашения calendar_share (соредактор personal / заместитель commercial).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(logic_calendar_share_invite).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([
|
||||
create/4,
|
||||
list_outgoing/2,
|
||||
cancel/3,
|
||||
list_incoming/1,
|
||||
accept/3,
|
||||
decline/2,
|
||||
accept_by_token/3,
|
||||
to_json/1
|
||||
]).
|
||||
|
||||
-define(INVITE_TTL_DAYS, 7).
|
||||
|
||||
-spec create(ActorId :: binary(), CalendarId :: binary(), Target :: map(),
|
||||
Opts :: map()) ->
|
||||
{ok, #calendar_share_invite{}} |
|
||||
{error, not_found | access_denied | user_not_found | already_shared |
|
||||
already_pending | bad_request | term()}.
|
||||
create(ActorId, CalendarId, Target, Opts) ->
|
||||
case require_can_invite(ActorId, CalendarId) of
|
||||
{ok, _Cal} ->
|
||||
case resolve_target(Target) of
|
||||
{error, _} = E -> E;
|
||||
{ok, UserId, Email} ->
|
||||
case already_shared(CalendarId, UserId) of
|
||||
true ->
|
||||
{error, already_shared};
|
||||
false ->
|
||||
case core_calendar_share_invite:find_pending(CalendarId, UserId, Email) of
|
||||
[_ | _] ->
|
||||
{error, already_pending};
|
||||
[] ->
|
||||
do_create(ActorId, CalendarId, UserId, Email, Opts)
|
||||
end
|
||||
end
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec list_outgoing(ActorId :: binary(), CalendarId :: binary()) ->
|
||||
{ok, [#calendar_share_invite{}]} | {error, not_found | access_denied}.
|
||||
list_outgoing(ActorId, CalendarId) ->
|
||||
case require_can_invite(ActorId, CalendarId) of
|
||||
{ok, _} ->
|
||||
List = [maybe_expire(I) || I <- core_calendar_share_invite:list_by_calendar(CalendarId)],
|
||||
{ok, List};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec cancel(ActorId :: binary(), CalendarId :: binary(), InviteId :: binary()) ->
|
||||
{ok, #calendar_share_invite{}} |
|
||||
{error, not_found | access_denied | not_pending | term()}.
|
||||
cancel(ActorId, CalendarId, InviteId) ->
|
||||
case require_can_invite(ActorId, CalendarId) of
|
||||
{ok, _} ->
|
||||
case core_calendar_share_invite:get_by_id(InviteId) of
|
||||
{ok, #calendar_share_invite{calendar_id = CalendarId, status = pending} = Inv} ->
|
||||
Inv2 = maybe_expire(Inv),
|
||||
case Inv2#calendar_share_invite.status of
|
||||
pending -> core_calendar_share_invite:update_status(InviteId, cancelled);
|
||||
_ -> {error, not_pending}
|
||||
end;
|
||||
{ok, #calendar_share_invite{calendar_id = CalendarId}} ->
|
||||
{error, not_pending};
|
||||
{ok, _} ->
|
||||
{error, not_found};
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
-spec list_incoming(UserId :: binary()) -> {ok, [#calendar_share_invite{}]}.
|
||||
list_incoming(UserId) ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} ->
|
||||
ByUser = core_calendar_share_invite:list_by_invitee(UserId),
|
||||
ByEmail = case Email of
|
||||
<<>> -> [];
|
||||
_ -> core_calendar_share_invite:list_by_email(Email)
|
||||
end,
|
||||
Merged = lists:ukeysort(1, [{I#calendar_share_invite.id, maybe_expire(I)}
|
||||
|| I <- ByUser ++ ByEmail]),
|
||||
{ok, [I || {_, I} <- Merged]};
|
||||
{error, _} ->
|
||||
{ok, [maybe_expire(I) || I <- core_calendar_share_invite:list_by_invitee(UserId)]}
|
||||
end.
|
||||
|
||||
-spec accept(UserId :: binary(), InviteId :: binary(), Opts :: map()) ->
|
||||
{ok, #calendar_share_invite{}, #calendar_share{}} |
|
||||
{error, not_found | access_denied | not_pending | expired | term()}.
|
||||
accept(UserId, InviteId, Opts) ->
|
||||
case core_calendar_share_invite:get_by_id(InviteId) of
|
||||
{ok, Inv0} ->
|
||||
Inv = maybe_expire(Inv0),
|
||||
case Inv#calendar_share_invite.status of
|
||||
pending ->
|
||||
case can_accept(UserId, Inv) of
|
||||
true -> finalize_accept(UserId, Inv, Opts);
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
expired -> {error, expired};
|
||||
_ -> {error, not_pending}
|
||||
end;
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
-spec decline(UserId :: binary(), InviteId :: binary()) ->
|
||||
{ok, #calendar_share_invite{}} |
|
||||
{error, not_found | access_denied | not_pending | expired | term()}.
|
||||
decline(UserId, InviteId) ->
|
||||
case core_calendar_share_invite:get_by_id(InviteId) of
|
||||
{ok, Inv0} ->
|
||||
Inv = maybe_expire(Inv0),
|
||||
case Inv#calendar_share_invite.status of
|
||||
pending ->
|
||||
case can_accept(UserId, Inv) of
|
||||
true -> core_calendar_share_invite:update_status(InviteId, declined);
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
expired -> {error, expired};
|
||||
_ -> {error, not_pending}
|
||||
end;
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
-spec accept_by_token(UserId :: binary(), Token :: binary(), Opts :: map()) ->
|
||||
{ok, #calendar_share_invite{}, #calendar_share{}} |
|
||||
{error, not_found | access_denied | not_pending | expired | term()}.
|
||||
accept_by_token(UserId, Token, Opts) ->
|
||||
case core_calendar_share_invite:get_by_token(Token) of
|
||||
{ok, #calendar_share_invite{id = Id}} ->
|
||||
accept(UserId, Id, Opts);
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
-spec to_json(#calendar_share_invite{}) -> map().
|
||||
to_json(I) ->
|
||||
#{
|
||||
id => I#calendar_share_invite.id,
|
||||
calendar_id => I#calendar_share_invite.calendar_id,
|
||||
inviter_id => I#calendar_share_invite.inviter_id,
|
||||
invitee_user_id => null_if_empty(I#calendar_share_invite.invitee_user_id),
|
||||
invitee_email => null_if_empty(I#calendar_share_invite.invitee_email),
|
||||
rights => I#calendar_share_invite.rights,
|
||||
status => I#calendar_share_invite.status,
|
||||
created_at => handler_utils:datetime_to_iso8601(I#calendar_share_invite.created_at),
|
||||
expires_at => handler_utils:datetime_to_iso8601(I#calendar_share_invite.expires_at)
|
||||
}.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Internal
|
||||
%%%===================================================================
|
||||
|
||||
do_create(ActorId, CalendarId, UserId, Email, Opts) ->
|
||||
Now = calendar:universal_time(),
|
||||
Expires = add_days(Now, ?INVITE_TTL_DAYS),
|
||||
Rights = normalize_rights(maps:get(rights, Opts, write)),
|
||||
case Rights of
|
||||
bad -> {error, bad_request};
|
||||
R ->
|
||||
Rec = #calendar_share_invite{
|
||||
id = infra_utils:generate_id(16),
|
||||
calendar_id = CalendarId,
|
||||
inviter_id = ActorId,
|
||||
invitee_user_id = UserId,
|
||||
invitee_email = Email,
|
||||
rights = R,
|
||||
status = pending,
|
||||
token = infra_utils:generate_id(32),
|
||||
created_at = Now,
|
||||
expires_at = Expires
|
||||
},
|
||||
case core_calendar_share_invite:create(Rec) of
|
||||
{ok, Created} ->
|
||||
notify_invite(Created),
|
||||
{ok, Created};
|
||||
Error -> Error
|
||||
end
|
||||
end.
|
||||
|
||||
notify_invite(#calendar_share_invite{invitee_user_id = UserId, calendar_id = CalId} = Inv)
|
||||
when UserId =/= <<>> ->
|
||||
Title = <<"Приглашение к календарю"/utf8>>,
|
||||
Body = <<"Вас пригласили в календарь "/utf8, CalId/binary>>,
|
||||
_ = core_notification:create(UserId, calendar_share_invite, Title, Body),
|
||||
logic_notification:notify_calendar_share_invite(UserId, Inv),
|
||||
ok;
|
||||
notify_invite(_) ->
|
||||
ok.
|
||||
|
||||
finalize_accept(UserId, #calendar_share_invite{
|
||||
id = InviteId, calendar_id = CalendarId, rights = Rights,
|
||||
invitee_user_id = PrevUser}, Opts) ->
|
||||
Mirror = resolve_mirror(CalendarId, Opts),
|
||||
case core_calendar_share:upsert(CalendarId, UserId, Rights, Mirror) of
|
||||
{ok, Share} ->
|
||||
case core_calendar_share_invite:update_status(InviteId, accepted) of
|
||||
{ok, Inv2} ->
|
||||
Inv3 = case PrevUser of
|
||||
<<>> ->
|
||||
Filled = Inv2#calendar_share_invite{invitee_user_id = UserId},
|
||||
ok = core_calendar_share_invite:write(Filled),
|
||||
Filled;
|
||||
_ -> Inv2
|
||||
end,
|
||||
{ok, Inv3, Share};
|
||||
Error -> Error
|
||||
end;
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
resolve_mirror(CalendarId, Opts) ->
|
||||
Default = case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, #calendar{type = personal}} -> true;
|
||||
_ -> false
|
||||
end,
|
||||
case maps:get(mirror_to_default, Opts, Default) of
|
||||
true -> true;
|
||||
false -> false;
|
||||
_ -> Default
|
||||
end.
|
||||
|
||||
can_accept(UserId, #calendar_share_invite{invitee_user_id = UserId})
|
||||
when UserId =/= <<>> -> true;
|
||||
can_accept(UserId, #calendar_share_invite{invitee_user_id = <<>>, invitee_email = Email})
|
||||
when Email =/= <<>> ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{email = Email}} -> true;
|
||||
_ -> false
|
||||
end;
|
||||
can_accept(_, _) -> false.
|
||||
|
||||
already_shared(_CalendarId, <<>>) -> false;
|
||||
already_shared(CalendarId, UserId) ->
|
||||
case core_calendar_share:get(CalendarId, UserId) of
|
||||
{ok, _} -> true;
|
||||
{error, not_found} -> false
|
||||
end.
|
||||
|
||||
resolve_target(#{user_id := UserId}) when is_binary(UserId), UserId =/= <<>> ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, #user{status = active, email = Email}} ->
|
||||
{ok, UserId, Email};
|
||||
{ok, #user{status = _}} ->
|
||||
{error, user_not_found};
|
||||
{error, _} ->
|
||||
{error, user_not_found}
|
||||
end;
|
||||
resolve_target(#{email := Email0}) when is_binary(Email0), Email0 =/= <<>> ->
|
||||
Email = string:trim(Email0),
|
||||
case find_user_by_email(Email) of
|
||||
{ok, #user{id = UserId, status = active}} ->
|
||||
{ok, UserId, Email};
|
||||
{ok, #user{status = _}} ->
|
||||
{ok, <<>>, Email};
|
||||
{error, not_found} ->
|
||||
{ok, <<>>, Email}
|
||||
end;
|
||||
resolve_target(_) ->
|
||||
{error, bad_request}.
|
||||
|
||||
find_user_by_email(Email) ->
|
||||
case core_user:get_by_email(Email) of
|
||||
{ok, _} = Ok -> Ok;
|
||||
{error, not_found} ->
|
||||
Lower = string:lowercase(Email),
|
||||
case Lower =:= Email of
|
||||
true -> {error, not_found};
|
||||
false -> core_user:get_by_email(Lower)
|
||||
end
|
||||
end.
|
||||
|
||||
require_can_invite(ActorId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
{ok, #calendar{status = active} = Cal} ->
|
||||
case logic_calendar_share:can_manage_shares(ActorId, Cal) of
|
||||
true -> {ok, Cal};
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
{ok, _} -> {error, not_found};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
normalize_rights(read) -> read;
|
||||
normalize_rights(write) -> write;
|
||||
normalize_rights(admin) -> admin;
|
||||
normalize_rights(<<"read">>) -> read;
|
||||
normalize_rights(<<"write">>) -> write;
|
||||
normalize_rights(<<"admin">>) -> admin;
|
||||
normalize_rights(_) -> bad.
|
||||
|
||||
maybe_expire(#calendar_share_invite{status = pending, expires_at = Exp, id = Id} = Inv) ->
|
||||
case Exp < calendar:universal_time() of
|
||||
true ->
|
||||
_ = core_calendar_share_invite:update_status(Id, expired),
|
||||
Inv#calendar_share_invite{status = expired};
|
||||
false -> Inv
|
||||
end;
|
||||
maybe_expire(Inv) -> Inv.
|
||||
|
||||
add_days(DateTime, Days) ->
|
||||
Sec = calendar:datetime_to_gregorian_seconds(DateTime) + Days * 86400,
|
||||
calendar:gregorian_seconds_to_datetime(Sec).
|
||||
|
||||
null_if_empty(<<>>) -> null;
|
||||
null_if_empty(V) -> V.
|
||||
@@ -5,17 +5,14 @@
|
||||
-module(logic_calendar_specialist).
|
||||
-include("records.hrl").
|
||||
|
||||
-export([list/2, add/5, update/4, remove/3, to_json/1]).
|
||||
-export([list/2, add/5, update/4, remove/3, to_json/1, ensure_owner_specialist/1]).
|
||||
|
||||
-spec list(ActorId :: binary(), CalendarId :: binary()) ->
|
||||
{ok, [#calendar_specialist{}]} | {error, not_found | access_denied}.
|
||||
list(ActorId, CalendarId) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
case logic_calendar:get_calendar(ActorId, CalendarId) of
|
||||
{ok, Cal} ->
|
||||
case logic_calendar:can_access(ActorId, Cal) of
|
||||
true -> {ok, core_calendar_specialist:list_by_calendar(CalendarId)};
|
||||
false -> {error, access_denied}
|
||||
end;
|
||||
{ok, core_calendar_specialist:list_by_calendar(Cal#calendar.id)};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
@@ -47,14 +44,34 @@ update(OwnerId, CalendarId, UserId, Updates) ->
|
||||
end.
|
||||
|
||||
-spec remove(OwnerId :: binary(), CalendarId :: binary(), UserId :: binary()) ->
|
||||
ok | {error, not_found | access_denied | not_commercial | term()}.
|
||||
ok | {error, not_found | access_denied | not_commercial |
|
||||
owner_specialist_protected | term()}.
|
||||
remove(OwnerId, CalendarId, UserId) ->
|
||||
case require_owner_commercial(OwnerId, CalendarId) of
|
||||
{ok, #calendar{owner_id = CalOwnerId}} when UserId =:= CalOwnerId ->
|
||||
{error, owner_specialist_protected};
|
||||
{ok, _} ->
|
||||
core_calendar_specialist:delete(CalendarId, UserId);
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
%% @doc Idempotent: commercial calendar always has a specialist row for owner.
|
||||
%% Default status=active; name from nickname (else email). No-op if already present.
|
||||
-spec ensure_owner_specialist(#calendar{}) -> ok | {error, term()}.
|
||||
ensure_owner_specialist(#calendar{id = CalId, owner_id = OwnerId, type = commercial}) ->
|
||||
case core_calendar_specialist:get_by_calendar_and_user(CalId, OwnerId) of
|
||||
{ok, _} ->
|
||||
ok;
|
||||
{error, not_found} ->
|
||||
case core_calendar_specialist:create(CalId, OwnerId, owner_display_name(OwnerId), []) of
|
||||
{ok, _} -> ok;
|
||||
{error, already_exists} -> ok;
|
||||
{error, _} = Err -> Err
|
||||
end
|
||||
end;
|
||||
ensure_owner_specialist(_) ->
|
||||
ok.
|
||||
|
||||
-spec to_json(#calendar_specialist{}) -> map().
|
||||
to_json(S) ->
|
||||
#{
|
||||
@@ -82,3 +99,13 @@ require_owner_commercial(OwnerId, CalendarId) ->
|
||||
{error, access_denied};
|
||||
Error -> Error
|
||||
end.
|
||||
|
||||
owner_display_name(OwnerId) ->
|
||||
case core_user:get_by_id(OwnerId) of
|
||||
{ok, #user{nickname = Nick}} when is_binary(Nick), Nick =/= <<>> ->
|
||||
Nick;
|
||||
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
|
||||
Email;
|
||||
_ ->
|
||||
<<"Owner">>
|
||||
end.
|
||||
|
||||
+353
-4
@@ -1,11 +1,360 @@
|
||||
-module(logic_email).
|
||||
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2]).
|
||||
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2,
|
||||
send_booking_reminder/4, send_waitlist_promoted/3]).
|
||||
|
||||
%% Email transport (Spec#8 / stage outbound :25 blocked):
|
||||
%% EMAIL_TRANSPORT = smtp | http_api | log | auto (default auto)
|
||||
%% auto: EMAIL_API_KEY set → http_api; else SMTP_HOST set → smtp; else log
|
||||
%% SMTP: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_TLS
|
||||
%% HTTP API: EMAIL_API_KEY, EMAIL_API_PROVIDER=resend|brevo (default resend),
|
||||
%% optional EMAIL_API_URL; SMTP_FROM as sender
|
||||
%% Tests: smtp_deliver / email_http_deliver app env mocks
|
||||
|
||||
-spec send_verification_email(binary(), binary()) -> ok.
|
||||
send_verification_email(Email, Token) ->
|
||||
io:format("Sending verification email to ~s with token ~s~n", [Email, Token]).
|
||||
send_kind(verify, Email, abs_url(<<"/verify?token=">>, Token)).
|
||||
|
||||
-spec send_specialist_invite(binary(), binary()) -> ok.
|
||||
send_specialist_invite(Email, Token) ->
|
||||
io:format("Sending specialist invite email to ~s with token ~s~n", [Email, Token]).
|
||||
send_kind(invite, Email, abs_url(<<"/invites?token=">>, Token)).
|
||||
|
||||
-spec send_password_reset(binary(), binary()) -> ok.
|
||||
send_password_reset(Email, Token) ->
|
||||
io:format("Sending password reset email to ~s with token ~s~n", [Email, Token]).
|
||||
send_kind(reset, Email, abs_url(<<"/reset-password?token=">>, Token)).
|
||||
|
||||
-spec send_booking_reminder(binary(), binary(), calendar:datetime(), binary()) -> ok.
|
||||
send_booking_reminder(Email, EventTitle, StartTime, EventPath) ->
|
||||
Url = abs_path(EventPath),
|
||||
WhenText = format_utc(StartTime),
|
||||
{Subject, Plain, Html} =
|
||||
logic_email_templates:render_reminder(Url, to_bin(EventTitle), WhenText),
|
||||
send(to_bin(Email), Subject, Plain, Html).
|
||||
|
||||
-spec send_waitlist_promoted(binary(), binary(), binary()) -> ok.
|
||||
send_waitlist_promoted(Email, EventTitle, EventPath) ->
|
||||
Url = abs_path(EventPath),
|
||||
{Subject, Plain, Html} =
|
||||
logic_email_templates:render_waitlist_promoted(Url, to_bin(EventTitle)),
|
||||
send(to_bin(Email), Subject, Plain, Html).
|
||||
|
||||
send_kind(Kind, To0, Url) ->
|
||||
{Subject, Plain, Html} = logic_email_templates:render(Kind, Url),
|
||||
send(to_bin(To0), Subject, Plain, Html).
|
||||
|
||||
send(To, Subject, Plain, Html) ->
|
||||
case transport() of
|
||||
log ->
|
||||
logger:info(#{what => email_log_only, to => To, subject => Subject}),
|
||||
ok;
|
||||
smtp ->
|
||||
send_smtp(To, Subject, Plain, Html);
|
||||
http_api ->
|
||||
send_http_api(To, Subject, Plain, Html)
|
||||
end.
|
||||
|
||||
transport() ->
|
||||
case env_bin("EMAIL_TRANSPORT", <<>>) of
|
||||
<<"smtp">> -> smtp;
|
||||
<<"http_api">> -> http_api;
|
||||
<<"log">> -> log;
|
||||
<<"auto">> -> transport_auto();
|
||||
<<>> ->
|
||||
case application:get_env(eventhub, email_transport) of
|
||||
{ok, smtp} -> smtp;
|
||||
{ok, http_api} -> http_api;
|
||||
{ok, log} -> log;
|
||||
_ -> transport_auto()
|
||||
end;
|
||||
_ ->
|
||||
transport_auto()
|
||||
end.
|
||||
|
||||
transport_auto() ->
|
||||
case application:get_env(eventhub, email_transport) of
|
||||
{ok, smtp} -> smtp;
|
||||
{ok, http_api} -> http_api;
|
||||
{ok, log} -> log;
|
||||
_ ->
|
||||
case api_key() of
|
||||
<<>> ->
|
||||
case smtp_relay() of
|
||||
undefined -> log;
|
||||
_ -> smtp
|
||||
end;
|
||||
_ ->
|
||||
http_api
|
||||
end
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% SMTP
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
send_smtp(To, Subject, Plain, Html) ->
|
||||
case smtp_relay() of
|
||||
undefined ->
|
||||
logger:info(#{what => email_log_only, to => To, subject => Subject}),
|
||||
ok;
|
||||
Relay ->
|
||||
From = from_addr(),
|
||||
Raw = rfc822(From, To, Subject, Plain, Html),
|
||||
Opts = smtp_opts(Relay),
|
||||
safe_smtp_deliver(From, To, Raw, Opts)
|
||||
end.
|
||||
|
||||
safe_smtp_deliver(From, To, Raw, Opts) ->
|
||||
Fun = smtp_deliver_fun(),
|
||||
try Fun(From, To, Raw, Opts) of
|
||||
{ok, _} -> ok;
|
||||
ok -> ok;
|
||||
{error, Reason} ->
|
||||
logger:warning(#{what => smtp_send_error, to => To, reason => Reason}),
|
||||
ok;
|
||||
Other ->
|
||||
logger:warning(#{what => smtp_send_unexpected, to => To, result => Other}),
|
||||
ok
|
||||
catch
|
||||
Class:Reason:Stack ->
|
||||
logger:warning(#{what => smtp_send_crash, to => To,
|
||||
class => Class, reason => Reason, stack => Stack}),
|
||||
ok
|
||||
end.
|
||||
|
||||
smtp_deliver_fun() ->
|
||||
case application:get_env(eventhub, smtp_deliver) of
|
||||
{ok, Fun} when is_function(Fun, 4) -> Fun;
|
||||
_ -> fun default_smtp_deliver/4
|
||||
end.
|
||||
|
||||
default_smtp_deliver(From, To, Raw, Opts) ->
|
||||
gen_smtp_client:send({From, [To], Raw}, Opts).
|
||||
|
||||
smtp_relay() ->
|
||||
case app_or_env(smtp_host, "SMTP_HOST") of
|
||||
<<>> -> undefined;
|
||||
Host -> binary_to_list(Host)
|
||||
end.
|
||||
|
||||
smtp_opts(Relay) ->
|
||||
Port = env_int("SMTP_PORT", 587),
|
||||
Tls = tls_mode(env_bin("SMTP_TLS", <<"if_available">>)),
|
||||
Base = [
|
||||
{relay, Relay},
|
||||
{port, Port},
|
||||
{tls, Tls},
|
||||
{hostname, "calentiq.com"}
|
||||
],
|
||||
User = env_bin("SMTP_USER", <<>>),
|
||||
Pass = env_bin("SMTP_PASS", <<>>),
|
||||
case User of
|
||||
<<>> -> Base;
|
||||
_ ->
|
||||
[{username, binary_to_list(User)},
|
||||
{password, binary_to_list(Pass)},
|
||||
{auth, always} | Base]
|
||||
end.
|
||||
|
||||
tls_mode(<<"always">>) -> always;
|
||||
tls_mode(<<"never">>) -> never;
|
||||
tls_mode(_) -> if_available.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% HTTP API (HTTPS transactional — no outbound :25/:587)
|
||||
%%% Providers: resend (default/canon), brevo (optional)
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
send_http_api(To, Subject, Plain, Html) ->
|
||||
case api_key() of
|
||||
<<>> ->
|
||||
logger:warning(#{what => email_api_missing_key, to => To}),
|
||||
ok;
|
||||
Key ->
|
||||
From = from_addr(),
|
||||
safe_http_deliver(Key, From, To, Subject, Plain, Html)
|
||||
end.
|
||||
|
||||
safe_http_deliver(Key, From, To, Subject, Plain, Html) ->
|
||||
Fun = http_deliver_fun(),
|
||||
try Fun(Key, From, To, Subject, Plain, Html) of
|
||||
ok -> ok;
|
||||
{ok, _} -> ok;
|
||||
{error, Reason} ->
|
||||
logger:warning(#{what => email_api_error, to => To, reason => Reason}),
|
||||
ok;
|
||||
Other ->
|
||||
logger:warning(#{what => email_api_unexpected, to => To, result => Other}),
|
||||
ok
|
||||
catch
|
||||
Class:Reason:Stack ->
|
||||
logger:warning(#{what => email_api_crash, to => To,
|
||||
class => Class, reason => Reason, stack => Stack}),
|
||||
ok
|
||||
end.
|
||||
|
||||
http_deliver_fun() ->
|
||||
case application:get_env(eventhub, email_http_deliver) of
|
||||
{ok, Fun} when is_function(Fun, 6) -> Fun;
|
||||
_ -> fun default_http_deliver/6
|
||||
end.
|
||||
|
||||
default_http_deliver(Key, From, To, Subject, Plain, Html) ->
|
||||
_ = application:ensure_all_started(inets),
|
||||
_ = application:ensure_all_started(ssl),
|
||||
Provider = api_provider(),
|
||||
{Url, Headers, Body} = http_api_request(Provider, Key, From, To, Subject, Plain, Html),
|
||||
Request = {binary_to_list(Url), Headers, "application/json", Body},
|
||||
HttpOpts = [{timeout, 15000}, {ssl, [{verify, verify_peer},
|
||||
{cacerts, public_key:cacerts_get()}]}],
|
||||
case httpc:request(post, Request, HttpOpts, [{body_format, binary}]) of
|
||||
{ok, {{_, Code, _}, _, _Resp}} when Code >= 200, Code < 300 ->
|
||||
ok;
|
||||
{ok, {{_, Code, _}, _, Resp}} ->
|
||||
{error, {http_status, Code, Resp}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
http_api_request(resend, Key, From, To, Subject, Plain, Html) ->
|
||||
Url = case env_bin("EMAIL_API_URL", <<>>) of
|
||||
<<>> -> <<"https://api.resend.com/emails">>;
|
||||
U -> U
|
||||
end,
|
||||
FromLine = <<"CalenTIQ <", From/binary, ">">>,
|
||||
Body = jsx:encode(#{
|
||||
<<"from">> => FromLine,
|
||||
<<"to">> => [To],
|
||||
<<"subject">> => Subject,
|
||||
<<"html">> => Html,
|
||||
<<"text">> => Plain
|
||||
}),
|
||||
Headers = [
|
||||
{"authorization", "Bearer " ++ binary_to_list(Key)},
|
||||
{"accept", "application/json"},
|
||||
{"content-type", "application/json"}
|
||||
],
|
||||
{Url, Headers, Body};
|
||||
http_api_request(brevo, Key, From, To, Subject, Plain, Html) ->
|
||||
Url = case env_bin("EMAIL_API_URL", <<>>) of
|
||||
<<>> -> <<"https://api.brevo.com/v3/smtp/email">>;
|
||||
U -> U
|
||||
end,
|
||||
Body = jsx:encode(#{
|
||||
<<"sender">> => #{
|
||||
<<"name">> => <<"CalenTIQ">>,
|
||||
<<"email">> => From
|
||||
},
|
||||
<<"to">> => [#{<<"email">> => To}],
|
||||
<<"subject">> => Subject,
|
||||
<<"htmlContent">> => Html,
|
||||
<<"textContent">> => Plain
|
||||
}),
|
||||
Headers = [
|
||||
{"api-key", binary_to_list(Key)},
|
||||
{"accept", "application/json"},
|
||||
{"content-type", "application/json"}
|
||||
],
|
||||
{Url, Headers, Body}.
|
||||
|
||||
api_provider() ->
|
||||
case env_bin("EMAIL_API_PROVIDER", <<"resend">>) of
|
||||
<<"resend">> -> resend;
|
||||
<<"brevo">> -> brevo;
|
||||
<<"sendinblue">> -> brevo;
|
||||
Other ->
|
||||
case application:get_env(eventhub, email_api_provider) of
|
||||
{ok, resend} -> resend;
|
||||
{ok, brevo} -> brevo;
|
||||
_ when Other =:= <<>> -> resend;
|
||||
_ ->
|
||||
logger:warning(#{what => email_api_unknown_provider, provider => Other}),
|
||||
resend
|
||||
end
|
||||
end.
|
||||
|
||||
api_key() ->
|
||||
case application:get_env(eventhub, email_api_key) of
|
||||
{ok, K} when is_binary(K), K =/= <<>> -> K;
|
||||
{ok, K} when is_list(K), K =/= "" -> unicode:characters_to_binary(K);
|
||||
_ -> env_bin("EMAIL_API_KEY", <<>>)
|
||||
end.
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% Shared
|
||||
%%%-------------------------------------------------------------------
|
||||
|
||||
from_addr() ->
|
||||
env_bin("SMTP_FROM", <<"noreply@calentiq.com">>).
|
||||
|
||||
abs_url(PathPrefix, Token) ->
|
||||
Base = strip_slash(env_bin("PUBLIC_APP_URL", <<"https://stage.calentiq.com">>)),
|
||||
TokenB = to_bin(Token),
|
||||
<<Base/binary, PathPrefix/binary, TokenB/binary>>.
|
||||
|
||||
abs_path(Path0) ->
|
||||
Base = strip_slash(env_bin("PUBLIC_APP_URL", <<"https://stage.calentiq.com">>)),
|
||||
Path = case to_bin(Path0) of
|
||||
<<$/, Rest/binary>> -> <<$/, Rest/binary>>;
|
||||
Rest -> <<$/, Rest/binary>>
|
||||
end,
|
||||
<<Base/binary, Path/binary>>.
|
||||
|
||||
format_utc({{Y, Mo, D}, {H, Mi, _S}}) ->
|
||||
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
|
||||
[Y, Mo, D, H, Mi])).
|
||||
|
||||
strip_slash(Url) ->
|
||||
case binary:last(Url) of
|
||||
$/ -> binary:part(Url, 0, byte_size(Url) - 1);
|
||||
_ -> Url
|
||||
end.
|
||||
|
||||
rfc822(From, To, Subject, Plain, Html) ->
|
||||
Bound = <<"eh-alt-01">>,
|
||||
iolist_to_binary([
|
||||
<<"From: CalenTIQ <">>, From, <<">\r\n">>,
|
||||
<<"To: ">>, To, <<"\r\n">>,
|
||||
<<"Subject: ">>, encode_subject(Subject), <<"\r\n">>,
|
||||
<<"MIME-Version: 1.0\r\n">>,
|
||||
<<"Content-Type: multipart/alternative; boundary=\"">>, Bound, <<"\"\r\n">>,
|
||||
<<"\r\n">>,
|
||||
<<"--">>, Bound, <<"\r\n">>,
|
||||
<<"Content-Type: text/plain; charset=utf-8\r\n">>,
|
||||
<<"Content-Transfer-Encoding: 8bit\r\n\r\n">>,
|
||||
Plain, <<"\r\n">>,
|
||||
<<"--">>, Bound, <<"\r\n">>,
|
||||
<<"Content-Type: text/html; charset=utf-8\r\n">>,
|
||||
<<"Content-Transfer-Encoding: 8bit\r\n\r\n">>,
|
||||
Html, <<"\r\n">>,
|
||||
<<"--">>, Bound, <<"--\r\n">>
|
||||
]).
|
||||
|
||||
app_or_env(AppKey, EnvName) ->
|
||||
case application:get_env(eventhub, AppKey) of
|
||||
{ok, Val} when Val =/= undefined, Val =/= <<>>, Val =/= "" -> to_bin(Val);
|
||||
_ -> env_bin(EnvName, <<>>)
|
||||
end.
|
||||
|
||||
env_bin(Name, Default) ->
|
||||
case os:getenv(Name) of
|
||||
false -> Default;
|
||||
"" -> Default;
|
||||
S -> unicode:characters_to_binary(S)
|
||||
end.
|
||||
|
||||
env_int(Name, Default) ->
|
||||
case os:getenv(Name) of
|
||||
false -> Default;
|
||||
"" -> Default;
|
||||
S ->
|
||||
try list_to_integer(S) of
|
||||
N -> N
|
||||
catch
|
||||
_:_ -> Default
|
||||
end
|
||||
end.
|
||||
|
||||
to_bin(B) when is_binary(B) -> B;
|
||||
to_bin(L) when is_list(L) -> unicode:characters_to_binary(L).
|
||||
|
||||
encode_subject(Bin) ->
|
||||
<<"=?UTF-8?B?", (base64:encode(Bin))/binary, "?=">>.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user