Compare commits
34 Commits
2567585333
..
master
| 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 |
@@ -12,6 +12,7 @@ alwaysApply: true
|
|||||||
| Симптом | Причина | Как правильно |
|
| Симптом | Причина | Как правильно |
|
||||||
|---------|---------|---------------|
|
|---------|---------|---------------|
|
||||||
| `bash: /mnt/c/.../.tmp-*.sh: No such file or directory` при том что `ls` файл видит | **CRLF** от Cursor Write: shebang = `bash\r` | **Всегда** `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../script.sh` (или `sed -i 's/\r$//' && bash`). **Не** голый `wsl -e bash /mnt/c/.../script.sh` |
|
| `bash: /mnt/c/.../.tmp-*.sh: No such file or directory` при том что `ls` файл видит | **CRLF** от Cursor Write: shebang = `bash\r` | **Всегда** `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../script.sh` (или `sed -i 's/\r$//' && bash`). **Не** голый `wsl -e bash /mnt/c/.../script.sh` |
|
||||||
|
| 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` |
|
| `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` файл (см. ниже) |
|
| `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` |
|
| `{{.Names}}: command not found` | `docker --format "{{…}}"` в PS | Формат в `.sh` или простой `docker ps` |
|
||||||
|
|||||||
+6
-4
@@ -36,7 +36,9 @@ docker/.env.stage
|
|||||||
# Пул пользователей для stress hot-path (см. test/tsung/ift-users.csv.example)
|
# Пул пользователей для stress hot-path (см. test/tsung/ift-users.csv.example)
|
||||||
/test/tsung/ift-users.csv
|
/test/tsung/ift-users.csv
|
||||||
|
|
||||||
# Traefik TLS private keys (generated locally for dev/staging).
|
# Traefik TLS certs (generated locally for dev/staging) — не коммитить.
|
||||||
# Никогда не коммитим приватный ключ в репозиторий.
|
docker/traefik/certs/*
|
||||||
docker/traefik/certs/*.key
|
!docker/traefik/certs/make-traefik-certs.sh
|
||||||
docker/traefik/certs/*.pem
|
|
||||||
|
# Temporary/scratch files
|
||||||
|
.tmp-*
|
||||||
|
|||||||
+52
-12
@@ -1,21 +1,61 @@
|
|||||||
# Скопируйте в .env (файл .env в .gitignore). Для локальной разработки: EVENTHUB_ENV=dev
|
# 小泻芯锌懈褉褍泄褌械 胁 .env (褎邪泄谢 .env 胁 .gitignore). 袛谢褟 谢芯泻邪谢褜薪芯泄 褉邪蟹褉邪斜芯褌泻懈: EVENTHUB_ENV=dev
|
||||||
|
|
||||||
# Режим: dev | stage | prod (stage/prod — обязательные сильные секреты)
|
# 袪械卸懈屑: dev | stage | prod (stage/prod 鈥� 芯斜褟蟹邪褌械谢褜薪褘械 褋懈谢褜薪褘械 褋械泻褉械褌褘)
|
||||||
EVENTHUB_ENV=dev
|
EVENTHUB_ENV=dev
|
||||||
|
|
||||||
RELEASE_COOKIE=ваш-очень-длинный-секретный-куки-минимум-32-символа
|
RELEASE_COOKIE=胁邪褕-芯褔械薪褜-写谢懈薪薪褘泄-褋械泻褉械褌薪褘泄-泻褍泻懈-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪
|
||||||
GRAFANA_ADMIN_PASSWORD=сложный-уникальный-пароль
|
GRAFANA_ADMIN_PASSWORD=褋谢芯卸薪褘泄-褍薪懈泻邪谢褜薪褘泄-锌邪褉芯谢褜
|
||||||
|
|
||||||
# JWT (минимум 32 символа, не из denylist — см. infra_secrets)
|
# JWT (屑懈薪懈屑褍屑 32 褋懈屑胁芯谢邪, 薪械 懈蟹 denylist 鈥� 褋屑. infra_secrets)
|
||||||
JWT_SECRET=замените-на-случайную-строку-минимум-32-символа-для-user-jwt
|
JWT_SECRET=蟹邪屑械薪懈褌械-薪邪-褋谢褍褔邪泄薪褍褞-褋褌褉芯泻褍-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪-写谢褟-user-jwt
|
||||||
ADMIN_JWT_SECRET=замените-на-другую-случайную-строку-минимум-32-символа
|
ADMIN_JWT_SECRET=蟹邪屑械薪懈褌械-薪邪-写褉褍谐褍褞-褋谢褍褔邪泄薪褍褞-褋褌褉芯泻褍-屑懈薪懈屑褍屑-32-褋懈屑胁芯谢邪
|
||||||
|
|
||||||
# Seed админов (обязательны в stage/prod при пустой таблице admin; в dev — опционально)
|
# Seed 邪写屑懈薪芯胁 (芯斜褟蟹邪褌械谢褜薪褘 胁 stage/prod 锌褉懈 锌褍褋褌芯泄 褌邪斜谢懈褑械 admin; 胁 dev 鈥� 芯锌褑懈芯薪邪谢褜薪芯)
|
||||||
ADMIN_SUPER_EMAIL=superadmin@eventhub.local
|
ADMIN_SUPER_EMAIL=superadmin@eventhub.local
|
||||||
ADMIN_SUPER_PASSWORD=замените-сильный-пароль-суперадмина
|
ADMIN_SUPER_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-褋褍锌械褉邪写屑懈薪邪
|
||||||
ADMIN_EMAIL=admin@eventhub.local
|
ADMIN_EMAIL=admin@eventhub.local
|
||||||
ADMIN_PASSWORD=замените-сильный-пароль-админа
|
ADMIN_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-邪写屑懈薪邪
|
||||||
ADMIN_MODER_EMAIL=moderator@eventhub.local
|
ADMIN_MODER_EMAIL=moderator@eventhub.local
|
||||||
ADMIN_MODER_PASSWORD=замените-сильный-пароль-модератора
|
ADMIN_MODER_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-屑芯写械褉邪褌芯褉邪
|
||||||
ADMIN_SUPPORT_EMAIL=support@eventhub.local
|
ADMIN_SUPPORT_EMAIL=support@eventhub.local
|
||||||
ADMIN_SUPPORT_PASSWORD=замените-сильный-пароль-поддержки
|
ADMIN_SUPPORT_PASSWORD=蟹邪屑械薪懈褌械-褋懈谢褜薪褘泄-锌邪褉芯谢褜-锌芯写写械褉卸泻懈
|
||||||
|
|
||||||
|
# Email: EMAIL_TRANSPORT=auto|smtp|http_api|log (auto: API key 鈫� http_api, else SMTP_HOST 鈫� smtp, else log)
|
||||||
|
EMAIL_TRANSPORT=auto
|
||||||
|
# HTTP API (HTTPS 鈥� when outbound SMTP ports blocked). Canon: Resend (Brevo optional).
|
||||||
|
EMAIL_API_KEY=
|
||||||
|
EMAIL_API_PROVIDER=resend
|
||||||
|
# EMAIL_API_URL=https://api.resend.com/emails
|
||||||
|
# Web Push (VAPID). Generate: npx web-push generate-vapid-keys
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_SUBJECT=mailto:admin@calentiq.com
|
||||||
|
# SMTP (锌褍褋褌芯 SMTP_HOST + 薪械褌 API key = 褌芯谢褜泻芯 谢芯谐; 锌邪褉芯谢褜 薪械 泻芯屑屑懈褌懈褌褜)
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=noreply@calentiq.com
|
||||||
|
SMTP_TLS=if_available
|
||||||
|
PUBLIC_APP_URL=https://stage.calentiq.com
|
||||||
|
# 袨泻薪芯 薪邪锌芯屑懈薪邪薪懈泄 芯 booking (褔邪褋褘 写芯 褋褌邪褉褌邪; Back#70)
|
||||||
|
REMINDER_LEAD_HOURS=24
|
||||||
|
# Uploads (avatar/cover) 鈥� 泻邪褌邪谢芯谐 薪邪 volume /app/data (Back#71)
|
||||||
|
UPLOAD_DIR=/app/data/uploads
|
||||||
|
UPLOAD_MAX_BYTES=2097152
|
||||||
|
# OpenCage (DevOps#16). 象耱铋 觌� = /v1/geo -> 503 (degrade). Free: 2500/耋蜿�, 1 rps.
|
||||||
|
OPENCAGE_API_KEY=
|
||||||
|
OPENCAGE_TIMEOUT_MS=3000
|
||||||
|
# STT-прокси голосового центра (Back#79, Front#76). Без STT_API_KEY
|
||||||
|
# /v1/ai/transcribe отвечает 503 unavailable, клиент деградирует в палитру.
|
||||||
|
# Провайдеры: xai (POST {STT_URL}, Bearer) | openai (OpenAI-совместимый).
|
||||||
|
STT_PROVIDER=xai
|
||||||
|
STT_URL=https://api.x.ai/v1/stt
|
||||||
|
STT_API_KEY=
|
||||||
|
# Опционально: модель для openai-провайдера и таймаут апстрима.
|
||||||
|
STT_MODEL=
|
||||||
|
STT_TIMEOUT_MS=15000
|
||||||
|
# Опционально: HTTP-эгресс (сервис stt-egress в стеке, vless-образ);
|
||||||
|
# гео-блокированные STT-провайдеры вызываются через него.
|
||||||
|
STT_PROXY_URL=
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ RUN rebar3 get-deps
|
|||||||
|
|
||||||
COPY include/ include/
|
COPY include/ include/
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
|
COPY priv/ priv/
|
||||||
|
|
||||||
COPY src/config/sys.config ./config/sys.config
|
COPY src/config/sys.config ./config/sys.config
|
||||||
COPY src/config/vm.args ./config/vm.args
|
COPY src/config/vm.args ./config/vm.args
|
||||||
|
|||||||
@@ -41,6 +41,23 @@ services:
|
|||||||
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
|
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
|
||||||
- CLUSTER_MODE=true
|
- CLUSTER_MODE=true
|
||||||
- DNS_NAME=eventhub-node
|
- DNS_NAME=eventhub-node
|
||||||
|
- EMAIL_TRANSPORT=${EMAIL_TRANSPORT:-auto}
|
||||||
|
- EMAIL_API_KEY=${EMAIL_API_KEY:-}
|
||||||
|
- EMAIL_API_PROVIDER=${EMAIL_API_PROVIDER:-resend}
|
||||||
|
- EMAIL_API_URL=${EMAIL_API_URL:-}
|
||||||
|
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-}
|
||||||
|
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-}
|
||||||
|
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@calentiq.com}
|
||||||
|
- SMTP_HOST=${SMTP_HOST:-}
|
||||||
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
|
- SMTP_USER=${SMTP_USER:-}
|
||||||
|
- SMTP_PASS=${SMTP_PASS:-}
|
||||||
|
- SMTP_FROM=${SMTP_FROM:-noreply@calentiq.com}
|
||||||
|
- SMTP_TLS=${SMTP_TLS:-if_available}
|
||||||
|
- PUBLIC_APP_URL=${PUBLIC_APP_URL:-https://stage.calentiq.com}
|
||||||
|
- REMINDER_LEAD_HOURS=${REMINDER_LEAD_HOURS:-24}
|
||||||
|
- UPLOAD_DIR=${UPLOAD_DIR:-/app/data/uploads}
|
||||||
|
- UPLOAD_MAX_BYTES=${UPLOAD_MAX_BYTES:-2097152}
|
||||||
networks:
|
networks:
|
||||||
eventhub-net:
|
eventhub-net:
|
||||||
aliases:
|
aliases:
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ services:
|
|||||||
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
|
- ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD}
|
||||||
- CLUSTER_MODE=true
|
- CLUSTER_MODE=true
|
||||||
- DNS_NAME=eventhub-node
|
- DNS_NAME=eventhub-node
|
||||||
|
- OPENCAGE_API_KEY=${OPENCAGE_API_KEY:-}
|
||||||
|
- OPENCAGE_TIMEOUT_MS=${OPENCAGE_TIMEOUT_MS:-3000}
|
||||||
networks:
|
networks:
|
||||||
eventhub-net:
|
eventhub-net:
|
||||||
aliases:
|
aliases:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ FROM alpine:${ALPINE_VERSION}
|
|||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
openssl libstdc++ libgcc ncurses-libs libsodium \
|
openssl libstdc++ libgcc ncurses-libs libsodium \
|
||||||
file \
|
file \
|
||||||
&& mkdir -p /app/data \
|
&& mkdir -p /app/data /app/data/uploads \
|
||||||
&& chmod 777 /app/data
|
&& chmod 777 /app/data /app/data/uploads
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -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-----
|
|
||||||
+67
-5
@@ -78,7 +78,9 @@
|
|||||||
expires_at :: calendar:datetime(),
|
expires_at :: calendar:datetime(),
|
||||||
revoked :: boolean(),
|
revoked :: boolean(),
|
||||||
created_at :: calendar:datetime(),
|
created_at :: calendar:datetime(),
|
||||||
updated_at :: calendar:datetime()
|
updated_at :: calendar:datetime(),
|
||||||
|
device_name :: binary(),
|
||||||
|
user_agent :: binary()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
%% ------------------- Календари ---------------------------------------
|
%% ------------------- Календари ---------------------------------------
|
||||||
@@ -103,10 +105,27 @@
|
|||||||
updated_at :: calendar:datetime()
|
updated_at :: calendar:datetime()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
|
%% PK = id. Уникальность пары calendar_id+user_id — в logic.
|
||||||
-record(calendar_share, {
|
-record(calendar_share, {
|
||||||
calendar_id :: binary(),
|
id :: binary(),
|
||||||
user_id :: binary(),
|
calendar_id :: binary(),
|
||||||
rights :: read | write | admin
|
user_id :: binary(),
|
||||||
|
rights :: read | write | admin,
|
||||||
|
mirror_to_default :: boolean()
|
||||||
|
}).
|
||||||
|
|
||||||
|
%% Приглашения к calendar_share (соредактор / заместитель).
|
||||||
|
-record(calendar_share_invite, {
|
||||||
|
id :: binary(),
|
||||||
|
calendar_id :: binary(),
|
||||||
|
inviter_id :: binary(),
|
||||||
|
invitee_user_id :: binary(), % <<>> если ещё неизвестен
|
||||||
|
invitee_email :: binary(), % <<>> если только user_id
|
||||||
|
rights :: read | write | admin,
|
||||||
|
status :: pending | accepted | declined | expired | cancelled,
|
||||||
|
token :: binary(),
|
||||||
|
created_at :: calendar:datetime(),
|
||||||
|
expires_at :: calendar:datetime()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
%% Follow чужого календаря (не путать с платной subscription / calendar_share)
|
%% Follow чужого календаря (не путать с платной subscription / calendar_share)
|
||||||
@@ -200,6 +219,16 @@
|
|||||||
updated_at :: calendar:datetime()
|
updated_at :: calendar:datetime()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
|
%% ------------------- Лист ожидания (commercial, settings.waitlist_enabled) ----
|
||||||
|
-record(waitlist_entry, {
|
||||||
|
id :: binary(),
|
||||||
|
event_id :: binary(),
|
||||||
|
user_id :: binary(),
|
||||||
|
status :: waiting | promoted | left,
|
||||||
|
created_at :: calendar:datetime(),
|
||||||
|
updated_at :: calendar:datetime()
|
||||||
|
}).
|
||||||
|
|
||||||
%% ------------------- Отзывы ------------------------------------------
|
%% ------------------- Отзывы ------------------------------------------
|
||||||
-record(review, {
|
-record(review, {
|
||||||
id :: binary(),
|
id :: binary(),
|
||||||
@@ -319,13 +348,24 @@
|
|||||||
id :: binary(),
|
id :: binary(),
|
||||||
user_id :: binary(),
|
user_id :: binary(),
|
||||||
type :: booking_confirmed | event_reminder | event_cancelled |
|
type :: booking_confirmed | event_reminder | event_cancelled |
|
||||||
specialist_invite | custom,
|
specialist_invite | waitlist_promoted | custom,
|
||||||
title :: binary(),
|
title :: binary(),
|
||||||
body :: binary(),
|
body :: binary(),
|
||||||
is_read :: boolean(),
|
is_read :: boolean(),
|
||||||
created_at :: calendar:datetime()
|
created_at :: calendar:datetime()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
|
%% Web Push subscription (Back#75). Prefs live in user.preferences:
|
||||||
|
%% notify_email / notify_push (boolean, default true).
|
||||||
|
-record(push_subscription, {
|
||||||
|
id :: binary(),
|
||||||
|
user_id :: binary(),
|
||||||
|
endpoint :: binary(),
|
||||||
|
p256dh :: binary(),
|
||||||
|
auth :: binary(),
|
||||||
|
created_at :: calendar:datetime()
|
||||||
|
}).
|
||||||
|
|
||||||
%% Upsert-счётчики админ-статистики (абсолютные значения).
|
%% Upsert-счётчики админ-статистики (абсолютные значения).
|
||||||
%% key: atom() | {atom(), atom()} | {atom(), atom(), atom()}
|
%% key: atom() | {atom(), atom()} | {atom(), atom(), atom()}
|
||||||
%% users_total-подобные totals не храним (table_info),
|
%% users_total-подобные totals не храним (table_info),
|
||||||
@@ -364,7 +404,29 @@
|
|||||||
cpu_utilization :: float()
|
cpu_utilization :: float()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
|
-record(month_snapshot, {
|
||||||
|
id :: {binary(), binary()},
|
||||||
|
calendar_id :: binary(),
|
||||||
|
year_month :: binary(),
|
||||||
|
payload :: binary(),
|
||||||
|
storage :: mnesia | file,
|
||||||
|
file_rel :: binary(),
|
||||||
|
created_at :: calendar:datetime()
|
||||||
|
}).
|
||||||
|
|
||||||
-record(schema_migration, {
|
-record(schema_migration, {
|
||||||
version :: string(),
|
version :: string(),
|
||||||
applied_at :: calendar:datetime()
|
applied_at :: calendar:datetime()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
|
%% Пилотные метрики ИИ-подсказок (PRODUCT-AI, Spec#22): дневные счётчики
|
||||||
|
%% shown/confirm/dismiss по паттерну. Id = {user_id, day, pattern_key, kind}.
|
||||||
|
-record(ai_hint_metric, {
|
||||||
|
id :: {binary(), binary(), binary(), binary()},
|
||||||
|
user_id :: binary(),
|
||||||
|
day :: binary(),
|
||||||
|
pattern_key :: binary(),
|
||||||
|
kind :: binary(),
|
||||||
|
count :: non_neg_integer(),
|
||||||
|
updated_at :: calendar:datetime()
|
||||||
|
}).
|
||||||
@@ -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>
|
||||||
+3
-2
@@ -10,7 +10,8 @@
|
|||||||
{meck, "0.9.2"},
|
{meck, "0.9.2"},
|
||||||
{gun, "2.2.0"},
|
{gun, "2.2.0"},
|
||||||
{prometheus_cowboy, "0.2.0"},
|
{prometheus_cowboy, "0.2.0"},
|
||||||
{cowboy_swagger, "2.8.0"}
|
{cowboy_swagger, "2.8.0"},
|
||||||
|
{gen_smtp, "1.3.0"}
|
||||||
]}.
|
]}.
|
||||||
|
|
||||||
{shell, [
|
{shell, [
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
{profiles, [
|
{profiles, [
|
||||||
{prod, [
|
{prod, [
|
||||||
{relx, [
|
{relx, [
|
||||||
{release, {eventhub, "0.0.1"}, [eventhub, sasl,cowboy, jose, jsx, argon2, runtime_tools, os_mon, prometheus_cowboy]},
|
{release, {eventhub, "0.0.1"}, [eventhub, sasl, cowboy, jose, jsx, argon2, gen_smtp, ssl, runtime_tools, os_mon, prometheus_cowboy]},
|
||||||
{include_erts, true},
|
{include_erts, true},
|
||||||
{extended_start_script, true},
|
{extended_start_script, true},
|
||||||
{sys_config, "./src/config/sys.config"}
|
{sys_config, "./src/config/sys.config"}
|
||||||
|
|||||||
@@ -12,17 +12,19 @@ set -euo pipefail
|
|||||||
STAND="${1:?stand: ift|stage}"
|
STAND="${1:?stand: ift|stage}"
|
||||||
case "${STAND}" in
|
case "${STAND}" in
|
||||||
ift)
|
ift)
|
||||||
API_HOST="${API_HOST:-https://api.ift.eventhub.local}"
|
[[ -n "${API_HOST:-}" ]] || API_HOST="https://api.ift.eventhub.local"
|
||||||
ADMIN_API_HOST="${ADMIN_API_HOST:-https://admin-api.ift.eventhub.local}"
|
[[ -n "${ADMIN_API_HOST:-}" ]] || ADMIN_API_HOST="https://admin-api.ift.eventhub.local"
|
||||||
WS_HOST="${WS_HOST:-wss://ws.ift.eventhub.local}"
|
[[ -n "${WS_HOST:-}" ]] || WS_HOST="wss://ws.ift.eventhub.local"
|
||||||
ADMIN_WS_HOST="${ADMIN_WS_HOST:-wss://admin-ws.ift.eventhub.local}"
|
[[ -n "${ADMIN_WS_HOST:-}" ]] || ADMIN_WS_HOST="wss://admin-ws.ift.eventhub.local"
|
||||||
DEVOPS_SMOKE="/opt/eventhub-ift/devops/scripts/smoke-core.sh"
|
DEVOPS_SMOKE="/opt/eventhub-ift/devops/scripts/smoke-core.sh"
|
||||||
;;
|
;;
|
||||||
stage)
|
stage)
|
||||||
API_HOST="${API_HOST:-https://api.stage.eventhub.local}"
|
# Публичный stage (канон STAGE-BROWSER.md). Пустые E2E_STAGE_* из CI
|
||||||
ADMIN_API_HOST="${ADMIN_API_HOST:-https://admin-api.stage.eventhub.local}"
|
# не должны оставлять API_HOST="" (иначе CT бьёт в *.eventhub.local).
|
||||||
WS_HOST="${WS_HOST:-wss://ws.stage.eventhub.local}"
|
[[ -n "${API_HOST:-}" ]] || API_HOST="https://api.stage.calentiq.com"
|
||||||
ADMIN_WS_HOST="${ADMIN_WS_HOST:-wss://admin-ws.stage.eventhub.local}"
|
[[ -n "${ADMIN_API_HOST:-}" ]] || ADMIN_API_HOST="https://admin-api.stage.calentiq.com"
|
||||||
|
[[ -n "${WS_HOST:-}" ]] || WS_HOST="wss://ws.stage.calentiq.com"
|
||||||
|
[[ -n "${ADMIN_WS_HOST:-}" ]] || ADMIN_WS_HOST="wss://admin-ws.stage.calentiq.com"
|
||||||
DEVOPS_SMOKE="/opt/eventhub-stage/devops/scripts/smoke-core.sh"
|
DEVOPS_SMOKE="/opt/eventhub-stage/devops/scripts/smoke-core.sh"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
@@ -49,6 +51,27 @@ echo " ADMIN_API_HOST=${ADMIN_API_HOST}"
|
|||||||
echo " WS_HOST=${WS_HOST}"
|
echo " WS_HOST=${WS_HOST}"
|
||||||
echo " ADMIN_WS_HOST=${ADMIN_WS_HOST}"
|
echo " ADMIN_WS_HOST=${ADMIN_WS_HOST}"
|
||||||
|
|
||||||
|
# После deploy-stage контейнер стенда может ещё стартовать; traefik в
|
||||||
|
# это время отвечает 502/503, и первые сьюиты падают с {badmatch,{ok,502}}.
|
||||||
|
# Ждём, пока HTTP-фронт приложений начнёт отвечать (любой код, кроме 5xx).
|
||||||
|
for probe in "${API_HOST}/v1/calendars" "${ADMIN_API_HOST}/v1/admin/health"; do
|
||||||
|
ready=0
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "${probe}" 2>/dev/null || echo 000)
|
||||||
|
case "${code}" in
|
||||||
|
5*) : ;;
|
||||||
|
000) : ;;
|
||||||
|
*) ready=1; break ;;
|
||||||
|
esac
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
if [[ "${ready}" -eq 1 ]]; then
|
||||||
|
echo " readiness OK: ${probe} (HTTP ${code})"
|
||||||
|
else
|
||||||
|
echo "::warning::${probe} не отвечает после 150s — стартуем сьюиты как есть"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Docker Desktop/WSL: --network=host часто НЕ наследует /etc/hosts стенда,
|
# Docker Desktop/WSL: --network=host часто НЕ наследует /etc/hosts стенда,
|
||||||
# из‑за чего CT remote не резолвит *.ift.eventhub.local (health timeout 30s).
|
# из‑за чего CT remote не резолвит *.ift.eventhub.local (health timeout 30s).
|
||||||
docker_add_hosts=()
|
docker_add_hosts=()
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -6,17 +6,30 @@
|
|||||||
-module(core_auth_session).
|
-module(core_auth_session).
|
||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
-include_lib("stdlib/include/ms_transform.hrl").
|
-include_lib("stdlib/include/ms_transform.hrl").
|
||||||
-export([create/3, get/1, rotate/2, revoke/1, revoke_family/1, revoke_all_for_subject/2]).
|
-export([
|
||||||
|
create/3, create/5, get/1, rotate/2, revoke/1, revoke_family/1,
|
||||||
|
revoke_all_for_subject/2, list_active_for_subject/2, revoke_others_for_subject/3
|
||||||
|
]).
|
||||||
|
|
||||||
-define(REFRESH_TTL_SECONDS, 30 * 24 * 3600).
|
-define(REFRESH_TTL_SECONDS, 30 * 24 * 3600).
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Создать новую сессию устройства/клиента.
|
%%% @doc Создать новую сессию устройства/клиента (без device meta).
|
||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary()) ->
|
-spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary()) ->
|
||||||
{ok, #auth_session{}}.
|
{ok, #auth_session{}}.
|
||||||
create(SubjectId, SubjectType, ClientType) ->
|
create(SubjectId, SubjectType, ClientType) ->
|
||||||
|
create(SubjectId, SubjectType, ClientType, <<>>, <<>>).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Создать сессию с device_name и user_agent.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec create(SubjectId :: binary(), SubjectType :: user | admin, ClientType :: binary(),
|
||||||
|
DeviceName :: binary(), UserAgent :: binary()) ->
|
||||||
|
{ok, #auth_session{}}.
|
||||||
|
create(SubjectId, SubjectType, ClientType, DeviceName, UserAgent) ->
|
||||||
Now = calendar:universal_time(),
|
Now = calendar:universal_time(),
|
||||||
ExpiresAt = calendar:gregorian_seconds_to_datetime(
|
ExpiresAt = calendar:gregorian_seconds_to_datetime(
|
||||||
calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS
|
calendar:datetime_to_gregorian_seconds(Now) + ?REFRESH_TTL_SECONDS
|
||||||
@@ -31,7 +44,9 @@ create(SubjectId, SubjectType, ClientType) ->
|
|||||||
expires_at = ExpiresAt,
|
expires_at = ExpiresAt,
|
||||||
revoked = false,
|
revoked = false,
|
||||||
created_at = Now,
|
created_at = Now,
|
||||||
updated_at = Now
|
updated_at = Now,
|
||||||
|
device_name = DeviceName,
|
||||||
|
user_agent = UserAgent
|
||||||
},
|
},
|
||||||
%% Transaction ensures atomic write of the new session record.
|
%% Transaction ensures atomic write of the new session record.
|
||||||
case mnesia:transaction(fun() -> mnesia:write(Session) end) of
|
case mnesia:transaction(fun() -> mnesia:write(Session) end) of
|
||||||
@@ -169,6 +184,56 @@ revoke_all_for_subject(SubjectId, SubjectType) ->
|
|||||||
{error, transaction_failed}
|
{error, transaction_failed}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Активные (не revoked, не expired) сессии субъекта.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec list_active_for_subject(SubjectId :: binary(), SubjectType :: user | admin) ->
|
||||||
|
[#auth_session{}].
|
||||||
|
list_active_for_subject(SubjectId, SubjectType) ->
|
||||||
|
Now = calendar:universal_time(),
|
||||||
|
MS = ets:fun2ms(fun(#auth_session{subject_id = Sid} = S)
|
||||||
|
when Sid =:= SubjectId -> S end),
|
||||||
|
Sessions = mnesia:dirty_select(auth_session, MS),
|
||||||
|
lists:filter(fun(S) ->
|
||||||
|
S#auth_session.subject_type =:= SubjectType andalso
|
||||||
|
S#auth_session.revoked =:= false andalso
|
||||||
|
S#auth_session.expires_at > Now
|
||||||
|
end, Sessions).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Отозвать все активные сессии субъекта, кроме KeepSessionId.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec revoke_others_for_subject(SubjectId :: binary(), SubjectType :: user | admin,
|
||||||
|
KeepSessionId :: binary()) ->
|
||||||
|
{ok, non_neg_integer()} | {error, transaction_failed}.
|
||||||
|
revoke_others_for_subject(SubjectId, SubjectType, KeepSessionId) ->
|
||||||
|
F = fun() ->
|
||||||
|
MS = ets:fun2ms(fun(#auth_session{subject_id = Sid} = S)
|
||||||
|
when Sid =:= SubjectId -> S end),
|
||||||
|
Sessions = mnesia:select(auth_session, MS, write),
|
||||||
|
Now = calendar:universal_time(),
|
||||||
|
lists:foldl(fun(Session, Acc) ->
|
||||||
|
case Session#auth_session.subject_type =:= SubjectType andalso
|
||||||
|
Session#auth_session.revoked =:= false andalso
|
||||||
|
Session#auth_session.session_id =/= KeepSessionId of
|
||||||
|
true ->
|
||||||
|
mnesia:write(Session#auth_session{revoked = true, updated_at = Now}),
|
||||||
|
Acc + 1;
|
||||||
|
false ->
|
||||||
|
Acc
|
||||||
|
end
|
||||||
|
end, 0, Sessions)
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, Count} ->
|
||||||
|
lists:foreach(fun(_) -> dec_counter(SubjectType) end, lists:seq(1, Count)),
|
||||||
|
{ok, Count};
|
||||||
|
{aborted, _Reason} ->
|
||||||
|
{error, transaction_failed}
|
||||||
|
end.
|
||||||
|
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% Internal
|
%%% Internal
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
-module(core_calendar).
|
-module(core_calendar).
|
||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
-export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]).
|
-export([create/4, create/5, get_by_id/1, get_by_short_name/1, get_by_id_or_short_name/1,
|
||||||
|
list_by_owner/1, update/2, delete/1]).
|
||||||
-export([count_calendars/0, list_all/0]).
|
-export([count_calendars/0, list_all/0]).
|
||||||
-export([freeze/2, unfreeze/2]).
|
-export([freeze/2, unfreeze/2]).
|
||||||
-export([count_calendars_by_date/2]).
|
-export([count_calendars_by_date/2]).
|
||||||
@@ -78,6 +79,28 @@ get_by_id(Id) ->
|
|||||||
[Calendar] -> {ok, Calendar}
|
[Calendar] -> {ok, Calendar}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
%% Unique public slug. Empty name is not a slug (many Default calendars).
|
||||||
|
-spec get_by_short_name(Name :: binary()) -> {ok, #calendar{}} | {error, not_found}.
|
||||||
|
get_by_short_name(<<>>) ->
|
||||||
|
{error, not_found};
|
||||||
|
get_by_short_name(Name) when is_binary(Name) ->
|
||||||
|
case mnesia:dirty_index_read(calendar, Name, #calendar.short_name) of
|
||||||
|
[] ->
|
||||||
|
{error, not_found};
|
||||||
|
Rows ->
|
||||||
|
case [C || C <- Rows, C#calendar.status =:= active] of
|
||||||
|
[C | _] -> {ok, C};
|
||||||
|
[] -> {error, not_found}
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec get_by_id_or_short_name(IdOrSlug :: binary()) -> {ok, #calendar{}} | {error, not_found}.
|
||||||
|
get_by_id_or_short_name(IdOrSlug) ->
|
||||||
|
case get_by_id(IdOrSlug) of
|
||||||
|
{ok, C} -> {ok, C};
|
||||||
|
{error, not_found} -> get_by_short_name(IdOrSlug)
|
||||||
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Список активных календарей владельца.
|
%%% @doc Список активных календарей владельца.
|
||||||
%%% @end
|
%%% @end
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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,
|
cowboy,
|
||||||
jsx,
|
jsx,
|
||||||
trails,
|
trails,
|
||||||
cowboy_swagger
|
cowboy_swagger,
|
||||||
|
ssl,
|
||||||
|
inets,
|
||||||
|
gen_smtp
|
||||||
]},
|
]},
|
||||||
{env, [
|
{env, [
|
||||||
{http_port, 8080},
|
{http_port, 8080},
|
||||||
|
|||||||
+28
-2
@@ -57,7 +57,6 @@ start_application() ->
|
|||||||
ok = migration_engine:ensure_applied(),
|
ok = migration_engine:ensure_applied(),
|
||||||
%% После wait + migrations: иначе backfill ловит {no_exists, user} на ещё не загруженных таблицах.
|
%% После wait + migrations: иначе backfill ловит {no_exists, user} на ещё не загруженных таблицах.
|
||||||
ok = stats_collector:subscribe(),
|
ok = stats_collector:subscribe(),
|
||||||
calendar_html_renderer:init_cache(),
|
|
||||||
application:ensure_all_started(cowboy),
|
application:ensure_all_started(cowboy),
|
||||||
start_http(), % Пользовательский API (8080)
|
start_http(), % Пользовательский API (8080)
|
||||||
start_admin_http(), % Административный API (8445)
|
start_admin_http(), % Административный API (8445)
|
||||||
@@ -83,37 +82,61 @@ start_http() ->
|
|||||||
{'_', [
|
{'_', [
|
||||||
{"/metrics/[:registry]", prometheus_cowboy2_handler, []},
|
{"/metrics/[:registry]", prometheus_cowboy2_handler, []},
|
||||||
{"/health", handler_health, []},
|
{"/health", handler_health, []},
|
||||||
|
{"/v1/media/:kind/:owner/:file", handler_media, []},
|
||||||
{"/v1/register", handler_register, []},
|
{"/v1/register", handler_register, []},
|
||||||
{"/v1/verify", handler_verify, []},
|
{"/v1/verify", handler_verify, []},
|
||||||
{"/v1/forgot-password", handler_forgot_password, []},
|
{"/v1/forgot-password", handler_forgot_password, []},
|
||||||
{"/v1/reset-password", handler_reset_password, []},
|
{"/v1/reset-password", handler_reset_password, []},
|
||||||
{"/v1/login", handler_login, []},
|
{"/v1/login", handler_login, []},
|
||||||
{"/v1/refresh", handler_refresh, []},
|
{"/v1/refresh", handler_refresh, []},
|
||||||
|
{"/v1/logout", handler_logout, []},
|
||||||
|
{"/v1/sessions", handler_sessions, []},
|
||||||
|
{"/v1/sessions/revoke-others", handler_sessions, []},
|
||||||
|
{"/v1/sessions/:id", handler_sessions, []},
|
||||||
{"/v1/user/me", handler_user_me, []},
|
{"/v1/user/me", handler_user_me, []},
|
||||||
|
{"/v1/user/me/avatar", handler_user_avatar, []},
|
||||||
|
{"/v1/notifications/prefs", handler_notification_prefs, []},
|
||||||
|
{"/v1/push/vapid-public-key", handler_push, []},
|
||||||
|
{"/v1/push/subscriptions", handler_push, []},
|
||||||
{"/v1/user/bookings", handler_user_bookings, []},
|
{"/v1/user/bookings", handler_user_bookings, []},
|
||||||
{"/v1/user/booking-requests", handler_user_booking_requests, []},
|
{"/v1/user/booking-requests", handler_user_booking_requests, []},
|
||||||
{"/v1/user/studio-bookings", handler_user_studio_bookings, []},
|
{"/v1/user/studio-bookings", handler_user_studio_bookings, []},
|
||||||
{"/v1/user/reviews", handler_user_reviews, []},
|
{"/v1/user/reviews", handler_user_reviews, []},
|
||||||
{"/v1/user/following", handler_user_following, []},
|
{"/v1/user/following", handler_user_following, []},
|
||||||
{"/v1/user/specialist-invites", handler_specialist_invites, []},
|
{"/v1/user/specialist-invites", handler_specialist_invites, []},
|
||||||
|
{"/v1/user/share-invites", handler_share_invites, []},
|
||||||
|
{"/v1/user/shares/:calendar_id", handler_calendar_shares, []},
|
||||||
{"/v1/users/lookup", handler_users_lookup, []},
|
{"/v1/users/lookup", handler_users_lookup, []},
|
||||||
{"/v1/search", handler_search, []},
|
{"/v1/search", handler_search, []},
|
||||||
|
{"/v1/geo/suggest", handler_geo, []},
|
||||||
|
{"/v1/geo/geocode", handler_geo, []},
|
||||||
|
{"/v1/geo/reverse", handler_geo, []},
|
||||||
|
{"/v1/ai/hint-metrics", handler_ai_metrics, []},
|
||||||
|
{"/v1/ai/transcribe", handler_ai_transcribe, []},
|
||||||
{"/v1/calendars", handler_calendars, []},
|
{"/v1/calendars", handler_calendars, []},
|
||||||
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
||||||
|
{"/v1/calendars/:id/cover", handler_calendar_cover, []},
|
||||||
{"/v1/calendars/:id/follow", handler_calendar_follow, []},
|
{"/v1/calendars/:id/follow", handler_calendar_follow, []},
|
||||||
{"/v1/calendars/:id/specialists", handler_calendar_specialists, []},
|
{"/v1/calendars/:id/specialists", handler_calendar_specialists, []},
|
||||||
{"/v1/calendars/:id/specialists/:user_id", handler_calendar_specialists, []},
|
{"/v1/calendars/:id/specialists/:user_id", handler_calendar_specialists, []},
|
||||||
{"/v1/calendars/:id/specialist-invites", handler_calendar_specialist_invites, []},
|
{"/v1/calendars/:id/specialist-invites", handler_calendar_specialist_invites, []},
|
||||||
{"/v1/calendars/:id/specialist-invites/:invite_id", handler_calendar_specialist_invites, []},
|
{"/v1/calendars/:id/specialist-invites/:invite_id", handler_calendar_specialist_invites, []},
|
||||||
|
{"/v1/calendars/:id/share-invites", handler_calendar_share_invites, []},
|
||||||
|
{"/v1/calendars/:id/share-invites/:invite_id", handler_calendar_share_invites, []},
|
||||||
|
{"/v1/calendars/:id/shares", handler_calendar_shares, []},
|
||||||
|
{"/v1/calendars/:id/shares/:user_id", handler_calendar_shares, []},
|
||||||
{"/v1/specialist-invites/accept", handler_specialist_invites, []},
|
{"/v1/specialist-invites/accept", handler_specialist_invites, []},
|
||||||
{"/v1/specialist-invites/:id/accept", handler_specialist_invites, []},
|
{"/v1/specialist-invites/:id/accept", handler_specialist_invites, []},
|
||||||
{"/v1/specialist-invites/:id/decline", handler_specialist_invites, []},
|
{"/v1/specialist-invites/:id/decline", handler_specialist_invites, []},
|
||||||
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
{"/v1/share-invites/accept", handler_share_invites, []},
|
||||||
|
{"/v1/share-invites/:id/accept", handler_share_invites, []},
|
||||||
|
{"/v1/share-invites/:id/decline", handler_share_invites, []},
|
||||||
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
||||||
{"/v1/events/:id", handler_event_by_id, []},
|
{"/v1/events/:id", handler_event_by_id, []},
|
||||||
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
|
||||||
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
|
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
|
||||||
{"/v1/events/:id/bookings", handler_bookings, []},
|
{"/v1/events/:id/bookings", handler_bookings, []},
|
||||||
|
{"/v1/events/:id/waitlist", handler_event_waitlist, []},
|
||||||
{"/v1/bookings/:id", handler_booking_by_id, []},
|
{"/v1/bookings/:id", handler_booking_by_id, []},
|
||||||
{"/v1/reviews", handler_reviews, []},
|
{"/v1/reviews", handler_reviews, []},
|
||||||
{"/v1/reviews/:id", handler_review_by_id, []},
|
{"/v1/reviews/:id", handler_review_by_id, []},
|
||||||
@@ -147,6 +170,7 @@ start_admin_http() ->
|
|||||||
{"/v1/admin/nodes/metrics", admin_handler_node_metrics, []},
|
{"/v1/admin/nodes/metrics", admin_handler_node_metrics, []},
|
||||||
{"/v1/admin/login", admin_handler_login, []},
|
{"/v1/admin/login", admin_handler_login, []},
|
||||||
{"/v1/admin/refresh", admin_handler_refresh, []},
|
{"/v1/admin/refresh", admin_handler_refresh, []},
|
||||||
|
{"/v1/admin/logout", admin_handler_logout, []},
|
||||||
% ================== ПОЛЬЗОВАТЕЛИ ==================
|
% ================== ПОЛЬЗОВАТЕЛИ ==================
|
||||||
{"/v1/admin/users", admin_handler_users, []},
|
{"/v1/admin/users", admin_handler_users, []},
|
||||||
{"/v1/admin/users/stats", admin_handler_user_stats, []},
|
{"/v1/admin/users/stats", admin_handler_user_stats, []},
|
||||||
@@ -185,6 +209,8 @@ start_admin_http() ->
|
|||||||
{"/v1/admin/subscriptions", admin_handler_subscriptions, []},
|
{"/v1/admin/subscriptions", admin_handler_subscriptions, []},
|
||||||
{"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []},
|
{"/v1/admin/subscriptions/stats", admin_handler_subscription_stats, []},
|
||||||
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
|
{"/v1/admin/subscriptions/:id", admin_handler_subscriptions_by_id, []},
|
||||||
|
% ================== ИИ-МЕТРИКИ ==================
|
||||||
|
{"/v1/admin/ai-metrics/stats", admin_handler_ai_metrics, []},
|
||||||
% ================== Управление ролями (только для superadmin) ==================
|
% ================== Управление ролями (только для superadmin) ==================
|
||||||
{"/v1/admin/me", admin_handler_me, []},
|
{"/v1/admin/me", admin_handler_me, []},
|
||||||
{"/v1/admin/admins", admin_handler_admins, []},
|
{"/v1/admin/admins", admin_handler_admins, []},
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc GET /v1/admin/ai-metrics/stats — сводка пилота ИИ-подсказок
|
||||||
|
%%% (PRODUCT-AI, Spec#22): CTR-счётчики, гейт-доля активных с >=2
|
||||||
|
%%% confirm за 30 дней, топ паттернов.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(admin_handler_ai_metrics).
|
||||||
|
-behaviour(cowboy_handler).
|
||||||
|
-export([init/2]).
|
||||||
|
-export([trails/0]).
|
||||||
|
|
||||||
|
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
init(Req, _Opts) ->
|
||||||
|
case cowboy_req:method(Req) of
|
||||||
|
<<"GET">> -> get_stats(Req);
|
||||||
|
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec trails() -> [map()].
|
||||||
|
trails() ->
|
||||||
|
[ #{
|
||||||
|
path => <<"/v1/admin/ai-metrics/stats">>,
|
||||||
|
method => <<"GET">>,
|
||||||
|
description => <<"AI hint pilot summary (PRODUCT-AI gates)">>,
|
||||||
|
tags => [<<"Statistics">>],
|
||||||
|
responses => #{
|
||||||
|
200 => #{description => <<"AI hint pilot statistics">>},
|
||||||
|
401 => #{description => <<"Unauthorized">>}
|
||||||
|
}
|
||||||
|
} ].
|
||||||
|
|
||||||
|
get_stats(Req) ->
|
||||||
|
case handler_utils:auth_admin(Req) of
|
||||||
|
{ok, _AdminId, Req1} ->
|
||||||
|
handler_utils:send_json(Req1, 200, logic_ai_metrics:summary());
|
||||||
|
{error, Code, Message, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Message)
|
||||||
|
end.
|
||||||
@@ -206,14 +206,7 @@ convert_field({<<"start_time">>, Val}) ->
|
|||||||
convert_field({<<"duration">>, Val}) -> {duration, Val};
|
convert_field({<<"duration">>, Val}) -> {duration, Val};
|
||||||
convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
|
convert_field({<<"recurrence">>, Val}) -> {recurrence_rule, jsx:encode(Val)};
|
||||||
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
||||||
convert_field({<<"location">>, Val}) when is_map(Val) ->
|
convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
|
||||||
Loc = #location{
|
|
||||||
address = maps:get(<<"address">>, Val, undefined),
|
|
||||||
lat = maps:get(<<"lat">>, Val, undefined),
|
|
||||||
lon = maps:get(<<"lon">>, Val, undefined)
|
|
||||||
},
|
|
||||||
{location, Loc};
|
|
||||||
convert_field({<<"location">>, Val}) -> {location, Val};
|
|
||||||
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
||||||
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
||||||
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ init(Req0, _State) ->
|
|||||||
{ok, _Token, User} ->
|
{ok, _Token, User} ->
|
||||||
UserId = maps:get(id, User),
|
UserId = maps:get(id, User),
|
||||||
Role = maps:get(role, User),
|
Role = maps:get(role, User),
|
||||||
{ok, Token, RefreshToken} = logic_auth_session:issue_admin_tokens(UserId, Role),
|
{ok, Token, RefreshToken, SessionId} = logic_auth_session:issue_admin_tokens(UserId, Role),
|
||||||
core_admin:update_last_login(UserId),
|
core_admin:update_last_login(UserId),
|
||||||
% Аудит успешного входа
|
% Аудит успешного входа
|
||||||
admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, <<>>, Req1),
|
admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, <<>>, Req1),
|
||||||
@@ -33,7 +33,8 @@ init(Req0, _State) ->
|
|||||||
<<"email">> => maps:get(email, User),
|
<<"email">> => maps:get(email, User),
|
||||||
<<"role">> => maps:get(role, User)
|
<<"role">> => maps:get(role, User)
|
||||||
},
|
},
|
||||||
<<"refresh_token">> => RefreshToken
|
<<"refresh_token">> => RefreshToken,
|
||||||
|
<<"session_id">> => SessionId
|
||||||
},
|
},
|
||||||
handler_utils:send_json(Req1, 200, Resp);
|
handler_utils:send_json(Req1, 200, Resp);
|
||||||
{error, insufficient_permissions} ->
|
{error, insufficient_permissions} ->
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc POST /v1/admin/logout — отзыв admin refresh-сессии (вариант A / Back#69).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(admin_handler_logout).
|
||||||
|
-behaviour(cowboy_handler).
|
||||||
|
-export([init/2, trails/0]).
|
||||||
|
|
||||||
|
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
init(Req, _Opts) ->
|
||||||
|
case cowboy_req:method(Req) of
|
||||||
|
<<"POST">> -> logout(Req);
|
||||||
|
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec trails() -> [map()].
|
||||||
|
trails() ->
|
||||||
|
[
|
||||||
|
#{
|
||||||
|
path => <<"/v1/admin/logout">>,
|
||||||
|
method => <<"POST">>,
|
||||||
|
description => <<"Revoke current admin refresh session">>,
|
||||||
|
tags => [<<"Admin Auth">>],
|
||||||
|
requestBody => #{
|
||||||
|
required => true,
|
||||||
|
content => #{
|
||||||
|
<<"application/json">> => #{
|
||||||
|
schema => #{
|
||||||
|
type => object,
|
||||||
|
required => [<<"refresh_token">>],
|
||||||
|
properties => #{refresh_token => #{type => string}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses => #{
|
||||||
|
200 => #{description => <<"Session revoked">>},
|
||||||
|
400 => #{description => <<"Missing refresh_token or invalid JSON">>},
|
||||||
|
401 => #{description => <<"Invalid refresh token">>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].
|
||||||
|
|
||||||
|
logout(Req) ->
|
||||||
|
{ok, Body, Req1} = cowboy_req:read_body(Req),
|
||||||
|
try jsx:decode(Body, [return_maps]) of
|
||||||
|
#{<<"refresh_token">> := RefreshToken} ->
|
||||||
|
case logic_auth_session:logout_admin(RefreshToken) of
|
||||||
|
ok ->
|
||||||
|
handler_utils:send_json(Req1, 200, #{ok => true});
|
||||||
|
{error, _} ->
|
||||||
|
handler_utils:send_error(Req1, 401, <<"Invalid refresh token">>)
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
handler_utils:send_error(Req1, 400, <<"Missing refresh_token field">>)
|
||||||
|
catch
|
||||||
|
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
|
||||||
|
end.
|
||||||
@@ -71,10 +71,11 @@ refresh(Req) ->
|
|||||||
try jsx:decode(Body, [return_maps]) of
|
try jsx:decode(Body, [return_maps]) of
|
||||||
#{<<"refresh_token">> := RefreshToken} ->
|
#{<<"refresh_token">> := RefreshToken} ->
|
||||||
case logic_auth_session:refresh_admin(RefreshToken) of
|
case logic_auth_session:refresh_admin(RefreshToken) of
|
||||||
{ok, NewToken, NewRefreshToken} ->
|
{ok, NewToken, NewRefreshToken, SessionId} ->
|
||||||
handler_utils:send_json(Req1, 200, #{
|
handler_utils:send_json(Req1, 200, #{
|
||||||
token => NewToken,
|
token => NewToken,
|
||||||
refresh_token => NewRefreshToken
|
refresh_token => NewRefreshToken,
|
||||||
|
session_id => SessionId
|
||||||
});
|
});
|
||||||
{error, reuse_detected} ->
|
{error, reuse_detected} ->
|
||||||
handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>);
|
handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>);
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -121,7 +121,8 @@ get_calendar(Req) ->
|
|||||||
case logic_calendar:get_calendar(UserId, CalendarId) of
|
case logic_calendar:get_calendar(UserId, CalendarId) of
|
||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
Json0 = handler_utils:calendar_to_json(Calendar),
|
Json0 = handler_utils:calendar_to_json(Calendar),
|
||||||
Following = UserId =/= <<>> andalso logic_calendar_follow:is_following(UserId, CalendarId),
|
Following = UserId =/= <<>> andalso
|
||||||
|
logic_calendar_follow:is_following(UserId, Calendar#calendar.id),
|
||||||
handler_utils:send_json(Req1, 200, Json0#{following => Following});
|
handler_utils:send_json(Req1, 200, Json0#{following => Following});
|
||||||
{error, access_denied} ->
|
{error, access_denied} ->
|
||||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||||
|
|||||||
@@ -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">>).
|
||||||
@@ -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.
|
|
||||||
@@ -168,7 +168,7 @@ list_calendars(Req) ->
|
|||||||
{ok, UserId, Req1} ->
|
{ok, UserId, Req1} ->
|
||||||
case logic_calendar:list_calendars(UserId) of
|
case logic_calendar:list_calendars(UserId) of
|
||||||
{ok, Calendars} ->
|
{ok, Calendars} ->
|
||||||
Response = [calendar_to_json(C) || C <- Calendars],
|
Response = [calendar_list_json(UserId, C) || C <- Calendars],
|
||||||
handler_utils:send_json(Req1, 200, Response);
|
handler_utils:send_json(Req1, 200, Response);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||||
@@ -181,6 +181,21 @@ list_calendars(Req) ->
|
|||||||
%%% Внутренние функции
|
%%% Внутренние функции
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
|
|
||||||
|
-spec calendar_list_json(binary(), #calendar{}) -> map().
|
||||||
|
calendar_list_json(UserId, #calendar{owner_id = UserId} = Calendar) ->
|
||||||
|
(calendar_to_json(Calendar))#{role => <<"owner">>};
|
||||||
|
calendar_list_json(UserId, Calendar) ->
|
||||||
|
Base = (calendar_to_json(Calendar))#{role => <<"shared">>},
|
||||||
|
case core_calendar_share:get(Calendar#calendar.id, UserId) of
|
||||||
|
{ok, Share} ->
|
||||||
|
Base#{
|
||||||
|
share_rights => Share#calendar_share.rights,
|
||||||
|
mirror_to_default => Share#calendar_share.mirror_to_default
|
||||||
|
};
|
||||||
|
{error, not_found} ->
|
||||||
|
Base
|
||||||
|
end.
|
||||||
|
|
||||||
-spec calendar_to_json(#calendar{}) -> map().
|
-spec calendar_to_json(#calendar{}) -> map().
|
||||||
calendar_to_json(Calendar) ->
|
calendar_to_json(Calendar) ->
|
||||||
Base = handler_utils:calendar_to_json(Calendar),
|
Base = handler_utils:calendar_to_json(Calendar),
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ update_event(Req) ->
|
|||||||
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
||||||
{error, event_in_past} ->
|
{error, event_in_past} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||||
|
{error, archived} ->
|
||||||
|
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
|
||||||
{error, {content_banned, _}} ->
|
{error, {content_banned, _}} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||||
{error, invalid_specialist} ->
|
{error, invalid_specialist} ->
|
||||||
@@ -215,6 +217,8 @@ delete_event(Req) ->
|
|||||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||||
|
{error, archived} ->
|
||||||
|
handler_utils:send_error(Req1, 409, <<"Month is archived">>);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||||
end;
|
end;
|
||||||
@@ -245,14 +249,7 @@ convert_field({<<"recurrence">>, Val}) ->
|
|||||||
RuleJson = jsx:encode(Val),
|
RuleJson = jsx:encode(Val),
|
||||||
{recurrence_rule, RuleJson};
|
{recurrence_rule, RuleJson};
|
||||||
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
||||||
convert_field({<<"location">>, Val}) when is_map(Val) ->
|
convert_field({<<"location">>, Val}) -> {location, handler_utils:parse_location(Val)};
|
||||||
Loc = #location{
|
|
||||||
address = maps:get(<<"address">>, Val, undefined),
|
|
||||||
lat = maps:get(<<"lat">>, Val, undefined),
|
|
||||||
lon = maps:get(<<"lon">>, Val, undefined)
|
|
||||||
},
|
|
||||||
{location, Loc};
|
|
||||||
convert_field({<<"location">>, Val}) -> {location, Val};
|
|
||||||
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
convert_field({<<"tags">>, Val}) -> {tags, Val};
|
||||||
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
||||||
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -184,6 +184,8 @@ create_event(Req) ->
|
|||||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||||
{error, event_in_past} ->
|
{error, event_in_past} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||||
|
{error, archived} ->
|
||||||
|
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
|
||||||
{error, {content_banned, _}} ->
|
{error, {content_banned, _}} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
@@ -201,6 +203,8 @@ create_event(Req) ->
|
|||||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||||
{error, event_in_past} ->
|
{error, event_in_past} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
|
||||||
|
{error, archived} ->
|
||||||
|
handler_utils:send_error(Req2, 409, <<"Month is archived">>);
|
||||||
{error, {content_banned, _}} ->
|
{error, {content_banned, _}} ->
|
||||||
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
handler_utils:send_error(Req2, 400, <<"Content contains banned words">>);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
@@ -239,7 +243,10 @@ list_events(Req) ->
|
|||||||
{FromStr, ToStr} ->
|
{FromStr, ToStr} ->
|
||||||
FromDt = parse_datetime_binary(FromStr),
|
FromDt = parse_datetime_binary(FromStr),
|
||||||
ToDt = parse_datetime_binary(ToStr),
|
ToDt = parse_datetime_binary(ToStr),
|
||||||
expand_recurring_events(UserId, Events, FromDt, ToDt)
|
HotJson = expand_recurring_events(UserId, Events, FromDt, ToDt),
|
||||||
|
SnapJson = logic_month_archive:events_json_in_range(
|
||||||
|
CalendarId, FromDt, ToDt),
|
||||||
|
merge_event_json(HotJson, SnapJson)
|
||||||
end,
|
end,
|
||||||
handler_utils:send_json(Req1, 200, Response);
|
handler_utils:send_json(Req1, 200, Response);
|
||||||
{error, access_denied} ->
|
{error, access_denied} ->
|
||||||
@@ -295,37 +302,42 @@ update_event_fields(UserId, EventId, Location, Decoded) ->
|
|||||||
end.
|
end.
|
||||||
|
|
||||||
parse_location(undefined) -> undefined;
|
parse_location(undefined) -> undefined;
|
||||||
parse_location(LocationMap) when is_map(LocationMap) ->
|
parse_location(LocationMap) -> handler_utils:parse_location(LocationMap).
|
||||||
case LocationMap of
|
|
||||||
#{<<"lat">> := Lat, <<"lon">> := Lon} ->
|
|
||||||
Address = maps:get(<<"address">>, LocationMap, <<"">>),
|
|
||||||
#location{address = Address, lat = Lat, lon = Lon};
|
|
||||||
_ -> undefined
|
|
||||||
end;
|
|
||||||
parse_location(_) -> undefined.
|
|
||||||
|
|
||||||
expand_recurring_events(UserId, Events, From, To) ->
|
expand_recurring_events(UserId, Events, From, To) ->
|
||||||
lists:flatmap(fun(Event) ->
|
lists:flatmap(fun(Event) ->
|
||||||
case Event#event.event_type of
|
case Event#event.event_type of
|
||||||
single ->
|
single ->
|
||||||
case is_in_range(Event#event.start_time, From, To) of
|
case logic_month_archive:month_is_closed(Event#event.start_time) of
|
||||||
true -> [handler_utils:event_to_json(Event)];
|
true -> [];
|
||||||
false -> []
|
false ->
|
||||||
|
case is_in_range(Event#event.start_time, From, To) of
|
||||||
|
true -> [handler_utils:event_to_json(Event)];
|
||||||
|
false -> []
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
recurring ->
|
recurring ->
|
||||||
case logic_event:get_occurrences(UserId, Event#event.id, To) of
|
case logic_event:get_occurrences(UserId, Event#event.id, To) of
|
||||||
{ok, Occurrences} ->
|
{ok, Occurrences} ->
|
||||||
lists:filtermap(
|
lists:filtermap(
|
||||||
fun({virtual, Occ}) ->
|
fun({virtual, Occ}) ->
|
||||||
case is_in_range(Occ, From, To) of
|
case logic_month_archive:month_is_closed(Occ) of
|
||||||
true -> {true, occurrence_to_json(Event, Occ)};
|
true -> false;
|
||||||
false -> false
|
false ->
|
||||||
|
case is_in_range(Occ, From, To) of
|
||||||
|
true -> {true, occurrence_to_json(Event, Occ)};
|
||||||
|
false -> false
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
({materialized, Instance}) ->
|
({materialized, Instance}) ->
|
||||||
case is_in_range(Instance#event.start_time, From, To) of
|
case logic_month_archive:month_is_closed(Instance#event.start_time) of
|
||||||
true -> {true, handler_utils:event_to_json(Instance)};
|
true -> false;
|
||||||
false -> false
|
false ->
|
||||||
end
|
case is_in_range(Instance#event.start_time, From, To) of
|
||||||
|
true -> {true, handler_utils:event_to_json(Instance)};
|
||||||
|
false -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
end, Occurrences);
|
end, Occurrences);
|
||||||
_ -> []
|
_ -> []
|
||||||
end
|
end
|
||||||
@@ -334,6 +346,16 @@ expand_recurring_events(UserId, Events, From, To) ->
|
|||||||
|
|
||||||
is_in_range(Time, From, To) -> Time >= From andalso Time =< To.
|
is_in_range(Time, From, To) -> Time >= From andalso Time =< To.
|
||||||
|
|
||||||
|
merge_event_json(Hot, Snap) ->
|
||||||
|
Ids = maps:from_list(
|
||||||
|
[{maps:get(<<"id">>, M, maps:get(id, M, undefined)), true} || M <- Hot]),
|
||||||
|
Extra = [M || M <- Snap,
|
||||||
|
begin
|
||||||
|
Id = maps:get(<<"id">>, M, maps:get(id, M, undefined)),
|
||||||
|
Id =:= undefined orelse not maps:is_key(Id, Ids)
|
||||||
|
end],
|
||||||
|
Hot ++ Extra.
|
||||||
|
|
||||||
parse_datetime_binary(Str) ->
|
parse_datetime_binary(Str) ->
|
||||||
{ok, Dt} = handler_utils:parse_datetime(Str),
|
{ok, Dt} = handler_utils:parse_datetime(Str),
|
||||||
Dt.
|
Dt.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -32,8 +32,10 @@ trails() ->
|
|||||||
type => object,
|
type => object,
|
||||||
required => [<<"email">>, <<"password">>],
|
required => [<<"email">>, <<"password">>],
|
||||||
properties => #{
|
properties => #{
|
||||||
email => #{type => string, format => <<"email">>},
|
email => #{type => string, format => <<"email">>},
|
||||||
password => #{type => string, format => <<"password">>}
|
password => #{type => string, format => <<"password">>},
|
||||||
|
client_type => #{type => string, enum => [<<"web">>, <<"mobile">>]},
|
||||||
|
device_name => #{type => string}
|
||||||
}
|
}
|
||||||
}}}
|
}}}
|
||||||
},
|
},
|
||||||
@@ -69,23 +71,35 @@ login(Req) ->
|
|||||||
handler_utils:send_error(Req1, 400, <<"Empty request body">>);
|
handler_utils:send_error(Req1, 400, <<"Empty request body">>);
|
||||||
_ ->
|
_ ->
|
||||||
try jsx:decode(Body, [return_maps]) of
|
try jsx:decode(Body, [return_maps]) of
|
||||||
#{<<"email">> := Email, <<"password">> := Password} ->
|
#{<<"email">> := Email, <<"password">> := Password} = Map ->
|
||||||
|
ClientType = maps:get(<<"client_type">>, Map, <<"web">>),
|
||||||
|
DeviceName = maps:get(<<"device_name">>, Map, <<>>),
|
||||||
|
UserAgent = case cowboy_req:header(<<"user-agent">>, Req1) of
|
||||||
|
undefined -> <<>>;
|
||||||
|
UA -> UA
|
||||||
|
end,
|
||||||
case eventhub_auth:authenticate_user_request(Req1, Email, Password) of
|
case eventhub_auth:authenticate_user_request(Req1, Email, Password) of
|
||||||
{ok, _Token, User} ->
|
{ok, _Token, User} ->
|
||||||
UserId = maps:get(id, User),
|
UserId = maps:get(id, User),
|
||||||
Role = maps:get(role, User),
|
Role = maps:get(role, User),
|
||||||
{ok, Token, RefreshToken} = logic_auth_session:issue_user_tokens(UserId, Role),
|
case logic_auth_session:issue_user_tokens(
|
||||||
core_user:update_last_login(UserId),
|
UserId, Role, ClientType, DeviceName, UserAgent) of
|
||||||
Response = #{
|
{ok, Token, RefreshToken, SessionId} ->
|
||||||
<<"token">> => Token,
|
core_user:update_last_login(UserId),
|
||||||
<<"user">> => #{
|
Response = #{
|
||||||
<<"id">> => UserId,
|
<<"token">> => Token,
|
||||||
<<"email">> => maps:get(email, User),
|
<<"user">> => #{
|
||||||
<<"role">> => maps:get(role, User)
|
<<"id">> => UserId,
|
||||||
},
|
<<"email">> => maps:get(email, User),
|
||||||
<<"refresh_token">> => RefreshToken
|
<<"role">> => maps:get(role, User)
|
||||||
},
|
},
|
||||||
handler_utils:send_json(Req1, 200, Response);
|
<<"refresh_token">> => RefreshToken,
|
||||||
|
<<"session_id">> => SessionId
|
||||||
|
},
|
||||||
|
handler_utils:send_json(Req1, 200, Response);
|
||||||
|
{error, invalid_client_type} ->
|
||||||
|
handler_utils:send_error(Req1, 400, <<"Invalid client_type">>)
|
||||||
|
end;
|
||||||
{error, not_verified} ->
|
{error, not_verified} ->
|
||||||
handler_utils:send_error(Req1, 403, <<"Account not verified">>);
|
handler_utils:send_error(Req1, 403, <<"Account not verified">>);
|
||||||
{error, frozen} ->
|
{error, frozen} ->
|
||||||
|
|||||||
@@ -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,
|
type => object,
|
||||||
properties => #{
|
properties => #{
|
||||||
token => #{type => string, description => <<"JWT access token">>},
|
token => #{type => string, description => <<"JWT access token">>},
|
||||||
refresh_token => #{type => string, description => <<"Refresh token">>}
|
refresh_token => #{type => string, description => <<"Refresh token">>},
|
||||||
|
session_id => #{type => string, description => <<"auth_session id">>}
|
||||||
}
|
}
|
||||||
}.
|
}.
|
||||||
|
|
||||||
@@ -72,10 +73,11 @@ refresh(Req) ->
|
|||||||
try jsx:decode(Body, [return_maps]) of
|
try jsx:decode(Body, [return_maps]) of
|
||||||
#{<<"refresh_token">> := RefreshToken} ->
|
#{<<"refresh_token">> := RefreshToken} ->
|
||||||
case logic_auth_session:refresh_user(RefreshToken) of
|
case logic_auth_session:refresh_user(RefreshToken) of
|
||||||
{ok, NewToken, NewRefreshToken} ->
|
{ok, NewToken, NewRefreshToken, SessionId} ->
|
||||||
handler_utils:send_json(Req1, 200, #{
|
handler_utils:send_json(Req1, 200, #{
|
||||||
token => NewToken,
|
token => NewToken,
|
||||||
refresh_token => NewRefreshToken
|
refresh_token => NewRefreshToken,
|
||||||
|
session_id => SessionId
|
||||||
});
|
});
|
||||||
{error, reuse_detected} ->
|
{error, reuse_detected} ->
|
||||||
handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>);
|
handler_utils:send_error(Req1, 401, <<"Refresh token reuse detected">>);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ trails() ->
|
|||||||
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>},
|
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>},
|
||||||
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>},
|
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>},
|
||||||
#{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>},
|
#{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>},
|
||||||
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>]}, description => <<"Field to sort by">>},
|
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>, <<"distance">>]}, description => <<"Field to sort by">>},
|
||||||
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>},
|
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>},
|
||||||
#{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>},
|
#{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>},
|
||||||
#{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>},
|
#{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>},
|
||||||
@@ -79,18 +79,22 @@ search(Req) ->
|
|||||||
Qs = cowboy_req:parse_qs(Req1),
|
Qs = cowboy_req:parse_qs(Req1),
|
||||||
Type = proplists:get_value(<<"type">>, Qs, undefined),
|
Type = proplists:get_value(<<"type">>, Qs, undefined),
|
||||||
Query = proplists:get_value(<<"q">>, Qs, undefined),
|
Query = proplists:get_value(<<"q">>, Qs, undefined),
|
||||||
Params = parse_params(Qs),
|
case parse_params(Qs) of
|
||||||
case logic_search:search(Type, Query, UserId, Params) of
|
{error, invalid_radius} ->
|
||||||
{ok, Total, Results} ->
|
handler_utils:send_error(Req1, 400, <<"invalid_radius">>);
|
||||||
Response = #{
|
Params ->
|
||||||
total => Total,
|
case logic_search:search(Type, Query, UserId, Params) of
|
||||||
limit => maps:get(limit, Params, 20),
|
{ok, Total, Results} ->
|
||||||
offset => maps:get(offset, Params, 0),
|
Response = #{
|
||||||
results => Results
|
total => Total,
|
||||||
},
|
limit => maps:get(limit, Params, 20),
|
||||||
handler_utils:send_json(Req1, 200, Response);
|
offset => maps:get(offset, Params, 0),
|
||||||
{error, _} ->
|
results => Results
|
||||||
handler_utils:send_error(Req1, 500, <<"Search failed">>)
|
},
|
||||||
|
handler_utils:send_json(Req1, 200, Response);
|
||||||
|
{error, _} ->
|
||||||
|
handler_utils:send_error(Req1, 500, <<"Search failed">>)
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
{error, Code, Message, Req1} ->
|
{error, Code, Message, Req1} ->
|
||||||
handler_utils:send_error(Req1, Code, Message)
|
handler_utils:send_error(Req1, Code, Message)
|
||||||
@@ -103,7 +107,8 @@ search(Req) ->
|
|||||||
%% @private Собирает карту параметров для поискового движка.
|
%% @private Собирает карту параметров для поискового движка.
|
||||||
%% Не кладёт sort/tags/geo/даты, если их нет в QS — иначе
|
%% Не кладёт sort/tags/geo/даты, если их нет в QS — иначе
|
||||||
%% logic_search:is_discovery_request/2 никогда не сработает (Back#50).
|
%% logic_search:is_discovery_request/2 никогда не сработает (Back#50).
|
||||||
-spec parse_params(cowboy_req:qs()) -> map().
|
%% radius без ключа при lat/lon — без отсечения (GEO.md).
|
||||||
|
-spec parse_params(cowboy_req:qs()) -> map() | {error, invalid_radius}.
|
||||||
parse_params(Qs) ->
|
parse_params(Qs) ->
|
||||||
Params0 = #{
|
Params0 = #{
|
||||||
limit => parse_int_param(Qs, <<"limit">>, 20),
|
limit => parse_int_param(Qs, <<"limit">>, 20),
|
||||||
@@ -125,12 +130,22 @@ parse_params(Qs) ->
|
|||||||
end,
|
end,
|
||||||
Params1#{sort => Sort, order => Order}
|
Params1#{sort => Sort, order => Order}
|
||||||
end,
|
end,
|
||||||
Params3 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
||||||
{{ok, Lat}, {ok, Lon}} ->
|
{{ok, Lat}, {ok, Lon}} ->
|
||||||
Radius = parse_int_param(Qs, <<"radius">>, 10),
|
case parse_optional_radius(Qs) of
|
||||||
Params2#{lat => Lat, lon => Lon, radius => Radius};
|
{error, invalid_radius} = E -> E;
|
||||||
_ -> Params2
|
Radius ->
|
||||||
end,
|
Params3 = case Radius of
|
||||||
|
undefined -> Params2#{lat => Lat, lon => Lon};
|
||||||
|
R -> Params2#{lat => Lat, lon => Lon, radius => R}
|
||||||
|
end,
|
||||||
|
with_dates(Qs, Params3)
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
with_dates(Qs, Params2)
|
||||||
|
end.
|
||||||
|
|
||||||
|
with_dates(Qs, Params3) ->
|
||||||
case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
||||||
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
|
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
|
||||||
{{ok, From}, error} -> Params3#{from => From};
|
{{ok, From}, error} -> Params3#{from => From};
|
||||||
@@ -138,6 +153,18 @@ parse_params(Qs) ->
|
|||||||
_ -> Params3
|
_ -> Params3
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
parse_optional_radius(Qs) ->
|
||||||
|
case proplists:get_value(<<"radius">>, Qs) of
|
||||||
|
undefined -> undefined;
|
||||||
|
<<>> -> undefined;
|
||||||
|
Val ->
|
||||||
|
N = handler_utils:parse_int_qs(Val, 0),
|
||||||
|
case N >= 1 andalso N =< 100 of
|
||||||
|
true -> N;
|
||||||
|
false -> {error, invalid_radius}
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
|
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
|
||||||
parse_int_param(Qs, Key, Default) ->
|
parse_int_param(Qs, Key, Default) ->
|
||||||
handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default).
|
handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default).
|
||||||
@@ -146,7 +173,18 @@ parse_int_param(Qs, Key, Default) ->
|
|||||||
parse_float_param(Qs, Key) ->
|
parse_float_param(Qs, Key) ->
|
||||||
case proplists:get_value(Key, Qs) of
|
case proplists:get_value(Key, Qs) of
|
||||||
undefined -> error;
|
undefined -> error;
|
||||||
Val -> {ok, binary_to_float(Val)}
|
<<>> -> error;
|
||||||
|
Val ->
|
||||||
|
try binary_to_float(Val) of
|
||||||
|
F -> {ok, F}
|
||||||
|
catch
|
||||||
|
_:_ ->
|
||||||
|
try binary_to_integer(Val) of
|
||||||
|
I -> {ok, float(I)}
|
||||||
|
catch
|
||||||
|
_:_ -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
end.
|
end.
|
||||||
|
|
||||||
-spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error.
|
-spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -33,7 +33,10 @@
|
|||||||
is_superadmin/1,
|
is_superadmin/1,
|
||||||
pagination_headers/2,
|
pagination_headers/2,
|
||||||
maybe_report_internal_error/3,
|
maybe_report_internal_error/3,
|
||||||
report_and_send_error/4
|
report_and_send_error/4,
|
||||||
|
parse_location/1,
|
||||||
|
location_to_json/1,
|
||||||
|
meaningful_coords/2
|
||||||
]).
|
]).
|
||||||
-export([admin_to_json/1, audit_to_json/1]).
|
-export([admin_to_json/1, audit_to_json/1]).
|
||||||
|
|
||||||
@@ -317,11 +320,7 @@ audit_reason(_) -> <<>>.
|
|||||||
%% (confirmed > pending > free; cancelled/expired и past-pending → не занятость).
|
%% (confirmed > pending > free; cancelled/expired и past-pending → не занятость).
|
||||||
-spec event_to_json(#event{}) -> map().
|
-spec event_to_json(#event{}) -> map().
|
||||||
event_to_json(Event) ->
|
event_to_json(Event) ->
|
||||||
LocationJson = case Event#event.location of
|
LocationJson = location_to_json(Event#event.location),
|
||||||
undefined -> null;
|
|
||||||
#location{address = Addr, lat = Lat, lon = Lon} ->
|
|
||||||
#{address => Addr, lat => Lat, lon => Lon}
|
|
||||||
end,
|
|
||||||
RecurrenceJson = case Event#event.recurrence_rule of
|
RecurrenceJson = case Event#event.recurrence_rule of
|
||||||
undefined -> null;
|
undefined -> null;
|
||||||
Rule -> try jsx:decode(Rule, [return_maps]) of
|
Rule -> try jsx:decode(Rule, [return_maps]) of
|
||||||
@@ -648,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.
|
||||||
@@ -14,13 +14,15 @@
|
|||||||
|
|
||||||
-define(TABLES, [
|
-define(TABLES, [
|
||||||
user, session, verification, password_reset, admin, admin_session, auth_session,
|
user, session, verification, password_reset, admin, admin_session, auth_session,
|
||||||
calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite,
|
calendar, calendar_share, calendar_share_invite, calendar_follow, calendar_specialist,
|
||||||
|
specialist_invite,
|
||||||
event, recurrence_exception,
|
event, recurrence_exception,
|
||||||
booking,
|
booking, waitlist_entry,
|
||||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||||
ticket, subscription,
|
ticket, subscription,
|
||||||
admin_audit, notification,
|
admin_audit, notification, push_subscription,
|
||||||
stats_counter, stats_daily, node_metric, schema_migration
|
stats_counter, stats_daily, node_metric, schema_migration,
|
||||||
|
month_snapshot
|
||||||
]).
|
]).
|
||||||
|
|
||||||
-define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]).
|
-define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]).
|
||||||
@@ -210,6 +212,18 @@ ensure_schema_disc() ->
|
|||||||
true -> ok
|
true -> ok
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
add_local_disc_copy(month_snapshot) ->
|
||||||
|
case lists:member(node(), mnesia:table_info(month_snapshot, disc_only_copies) ++
|
||||||
|
mnesia:table_info(month_snapshot, disc_copies)) of
|
||||||
|
true -> ok;
|
||||||
|
false ->
|
||||||
|
case mnesia:add_table_copy(month_snapshot, node(), disc_only_copies) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, _}} -> ok;
|
||||||
|
{aborted, Reason} ->
|
||||||
|
io:format("Could not add disc_only copy for month_snapshot: ~p~n", [Reason])
|
||||||
|
end
|
||||||
|
end;
|
||||||
add_local_disc_copy(Tab) ->
|
add_local_disc_copy(Tab) ->
|
||||||
case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of
|
case lists:member(node(), mnesia:table_info(Tab, disc_copies)) of
|
||||||
false ->
|
false ->
|
||||||
@@ -283,7 +297,9 @@ prune_dead_nodes() ->
|
|||||||
lists:foreach(fun(Node) ->
|
lists:foreach(fun(Node) ->
|
||||||
io:format("Removing dead node ~p from Mnesia schema...~n", [Node]),
|
io:format("Removing dead node ~p from Mnesia schema...~n", [Node]),
|
||||||
lists:foreach(fun(Tab) ->
|
lists:foreach(fun(Tab) ->
|
||||||
case lists:member(Node, mnesia:table_info(Tab, disc_copies)) of
|
HasDisc = lists:member(Node, mnesia:table_info(Tab, disc_copies)),
|
||||||
|
HasOnly = lists:member(Node, mnesia:table_info(Tab, disc_only_copies)),
|
||||||
|
case HasDisc orelse HasOnly of
|
||||||
true -> catch mnesia:del_table_copy(Tab, Node);
|
true -> catch mnesia:del_table_copy(Tab, Node);
|
||||||
false -> ok
|
false -> ok
|
||||||
end
|
end
|
||||||
@@ -317,7 +333,12 @@ create_table(Table) ->
|
|||||||
table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, user)}];
|
table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, user)}];
|
||||||
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
|
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
|
||||||
table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||||
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
table_opts(calendar_share) ->
|
||||||
|
[{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)},
|
||||||
|
{index, [calendar_id, user_id]}];
|
||||||
|
table_opts(calendar_share_invite) ->
|
||||||
|
[{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share_invite)},
|
||||||
|
{index, [calendar_id, invitee_user_id, invitee_email, token, status]}];
|
||||||
table_opts(calendar_follow) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
|
table_opts(calendar_follow) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
|
||||||
table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
||||||
table_opts(specialist_invite) -> [{disc_copies, [node()]}, {attributes, record_info(fields, specialist_invite)}];
|
table_opts(specialist_invite) -> [{disc_copies, [node()]}, {attributes, record_info(fields, specialist_invite)}];
|
||||||
@@ -326,6 +347,7 @@ table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
|
|||||||
table_opts(recurrence_exception) ->
|
table_opts(recurrence_exception) ->
|
||||||
[{disc_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
|
[{disc_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
|
||||||
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
||||||
|
table_opts(waitlist_entry) -> [{disc_copies, [node()]}, {attributes, record_info(fields, waitlist_entry)}];
|
||||||
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
|
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
|
||||||
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
|
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
|
||||||
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
|
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
|
||||||
@@ -336,9 +358,12 @@ table_opts(ticket) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
|
|||||||
table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}];
|
table_opts(subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, subscription)}];
|
||||||
table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
|
table_opts(admin_audit) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
|
||||||
table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(fields, notification)}];
|
table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(fields, notification)}];
|
||||||
|
table_opts(push_subscription) -> [{disc_copies, [node()]}, {attributes, record_info(fields, push_subscription)}];
|
||||||
table_opts(stats_counter) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
|
table_opts(stats_counter) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
|
||||||
table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}];
|
table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats_daily)}];
|
||||||
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
||||||
|
table_opts(month_snapshot) ->
|
||||||
|
[{disc_only_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}];
|
||||||
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
||||||
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
||||||
table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}];
|
table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}];
|
||||||
@@ -362,6 +387,9 @@ create_indices() ->
|
|||||||
mnesia:add_table_index(booking, event_id),
|
mnesia:add_table_index(booking, event_id),
|
||||||
mnesia:add_table_index(booking, user_id),
|
mnesia:add_table_index(booking, user_id),
|
||||||
mnesia:add_table_index(booking, status),
|
mnesia:add_table_index(booking, status),
|
||||||
|
mnesia:add_table_index(waitlist_entry, event_id),
|
||||||
|
mnesia:add_table_index(waitlist_entry, user_id),
|
||||||
|
mnesia:add_table_index(waitlist_entry, status),
|
||||||
mnesia:add_table_index(review_vote, review_id),
|
mnesia:add_table_index(review_vote, review_id),
|
||||||
mnesia:add_table_index(review_vote, user_id),
|
mnesia:add_table_index(review_vote, user_id),
|
||||||
mnesia:add_table_index(calendar, owner_id),
|
mnesia:add_table_index(calendar, owner_id),
|
||||||
@@ -381,8 +409,12 @@ create_indices() ->
|
|||||||
mnesia:add_table_index(password_reset, user_id),
|
mnesia:add_table_index(password_reset, user_id),
|
||||||
mnesia:add_table_index(notification, user_id),
|
mnesia:add_table_index(notification, user_id),
|
||||||
mnesia:add_table_index(notification, is_read),
|
mnesia:add_table_index(notification, is_read),
|
||||||
|
mnesia:add_table_index(push_subscription, user_id),
|
||||||
|
mnesia:add_table_index(push_subscription, endpoint),
|
||||||
mnesia:add_table_index(auth_session, family_id),
|
mnesia:add_table_index(auth_session, family_id),
|
||||||
mnesia:add_table_index(auth_session, subject_id),
|
mnesia:add_table_index(auth_session, subject_id),
|
||||||
mnesia:add_table_index(report, resolved_by),
|
mnesia:add_table_index(report, resolved_by),
|
||||||
mnesia:add_table_index(ticket, assigned_to),
|
mnesia:add_table_index(ticket, assigned_to),
|
||||||
|
mnesia:add_table_index(month_snapshot, calendar_id),
|
||||||
|
mnesia:add_table_index(month_snapshot, year_month),
|
||||||
ok.
|
ok.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% EventHub – инфраструктурный супервизор (с archive_manager)
|
%% EventHub – инфраструктурный супервизор
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
-module(infra_sup).
|
-module(infra_sup).
|
||||||
-behaviour(supervisor).
|
-behaviour(supervisor).
|
||||||
@@ -39,12 +39,12 @@ init([]) ->
|
|||||||
start => {node_monitor, start_link, []},
|
start => {node_monitor, start_link, []},
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
type => worker},
|
type => worker},
|
||||||
#{id => archive_manager,
|
#{id => month_archive_worker,
|
||||||
start => {archive_manager, start_link, []},
|
start => {month_archive_worker, start_link, []},
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
shutdown => 5000,
|
shutdown => 5000,
|
||||||
type => worker,
|
type => worker,
|
||||||
modules => [archive_manager]},
|
modules => [month_archive_worker]},
|
||||||
#{id => migration_engine,
|
#{id => migration_engine,
|
||||||
start => {migration_engine, start_link, []},
|
start => {migration_engine, start_link, []},
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
|
|||||||
@@ -28,7 +28,13 @@
|
|||||||
'20260720210000_calendar_follow',
|
'20260720210000_calendar_follow',
|
||||||
'20260722150000_calendar_specialist_id',
|
'20260722150000_calendar_specialist_id',
|
||||||
'20260722190000_specialist_invite',
|
'20260722190000_specialist_invite',
|
||||||
'20260730120000_recurrence_exception_bag'
|
'20260730120000_recurrence_exception_bag',
|
||||||
|
'20260814193000_waitlist_entry',
|
||||||
|
'20260815200000_push_subscription',
|
||||||
|
'20260815220000_calendar_share_invite',
|
||||||
|
'20260816180000_auth_session_device',
|
||||||
|
'20260817140000_month_snapshot',
|
||||||
|
'20260820180000_ai_hint_metric'
|
||||||
]).
|
]).
|
||||||
|
|
||||||
%% ------------------------------
|
%% ------------------------------
|
||||||
|
|||||||
@@ -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) ->
|
handle_info(tick, State) ->
|
||||||
_ = catch logic_subscription:handle_expired_subscriptions(),
|
_ = catch logic_subscription:handle_expired_subscriptions(),
|
||||||
_ = catch logic_booking:process_timeout_bookings(),
|
_ = catch logic_booking:process_timeout_bookings(),
|
||||||
|
_ = catch logic_booking:process_reminders(),
|
||||||
erlang:send_after(?INTERVAL_MS, self(), tick),
|
erlang:send_after(?INTERVAL_MS, self(), tick),
|
||||||
{noreply, State};
|
{noreply, State};
|
||||||
handle_info(_Info, State) ->
|
handle_info(_Info, State) ->
|
||||||
|
|||||||
@@ -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).
|
-module(logic_auth_session).
|
||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
-export([issue_admin_tokens/2, refresh_admin/1,
|
-export([
|
||||||
issue_user_tokens/2, refresh_user/1]).
|
issue_admin_tokens/2, refresh_admin/1, logout_admin/1,
|
||||||
|
issue_user_tokens/2, issue_user_tokens/5, refresh_user/1, logout_user/1,
|
||||||
|
list_user_sessions/1, revoke_user_session/2, revoke_other_user_sessions/2,
|
||||||
|
normalize_user_client_type/1, normalize_device_name/1
|
||||||
|
]).
|
||||||
|
|
||||||
-define(USER_CLIENT_WEB, <<"web">>).
|
-define(USER_CLIENT_WEB, <<"web">>).
|
||||||
|
-define(USER_CLIENT_MOBILE, <<"mobile">>).
|
||||||
|
-define(DEVICE_NAME_MAX, 120).
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% Admin (фаза 1)
|
%%% Admin (фаза 1)
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
|
|
||||||
-spec issue_admin_tokens(AdminId :: binary(), Role :: binary()) ->
|
-spec issue_admin_tokens(AdminId :: binary(), Role :: binary()) ->
|
||||||
{ok, AccessToken :: binary(), RefreshToken :: binary()}.
|
{ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()}.
|
||||||
issue_admin_tokens(AdminId, Role) ->
|
issue_admin_tokens(AdminId, Role) ->
|
||||||
issue_tokens(AdminId, admin, <<"admin">>, Role, fun eventhub_auth:generate_admin_token/2,
|
issue_tokens(AdminId, admin, <<"admin">>, <<>>, <<>>, Role,
|
||||||
|
fun eventhub_auth:generate_admin_token/2,
|
||||||
fun eventhub_auth:generate_admin_refresh_token/5).
|
fun eventhub_auth:generate_admin_refresh_token/5).
|
||||||
|
|
||||||
-spec refresh_admin(RefreshToken :: binary()) ->
|
-spec refresh_admin(RefreshToken :: binary()) ->
|
||||||
{ok, AccessToken :: binary(), NewRefreshToken :: binary()} |
|
{ok, AccessToken :: binary(), NewRefreshToken :: binary(), SessionId :: binary()} |
|
||||||
{error, atom()}.
|
{error, atom()}.
|
||||||
refresh_admin(RefreshToken) ->
|
refresh_admin(RefreshToken) ->
|
||||||
refresh_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1,
|
refresh_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1,
|
||||||
fun core_admin:get_by_id/1, admin, fun eventhub_auth:generate_admin_token/2,
|
fun core_admin:get_by_id/1, admin, fun eventhub_auth:generate_admin_token/2,
|
||||||
fun eventhub_auth:generate_admin_refresh_token/5, <<"admin">>).
|
fun eventhub_auth:generate_admin_refresh_token/5).
|
||||||
|
|
||||||
|
-spec logout_admin(RefreshToken :: binary()) -> ok | {error, atom()}.
|
||||||
|
logout_admin(RefreshToken) ->
|
||||||
|
logout_subject(RefreshToken, fun eventhub_auth:verify_admin_refresh_token/1).
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% User (фаза 2)
|
%%% User (фаза 2 / 3)
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
|
|
||||||
-spec issue_user_tokens(UserId :: binary(), Role :: binary()) ->
|
-spec issue_user_tokens(UserId :: binary(), Role :: binary()) ->
|
||||||
{ok, AccessToken :: binary(), RefreshToken :: binary()}.
|
{ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()}.
|
||||||
issue_user_tokens(UserId, Role) ->
|
issue_user_tokens(UserId, Role) ->
|
||||||
issue_tokens(UserId, user, ?USER_CLIENT_WEB, Role, fun eventhub_auth:generate_user_token/2,
|
issue_user_tokens(UserId, Role, ?USER_CLIENT_WEB, <<>>, <<>>).
|
||||||
fun eventhub_auth:generate_user_refresh_token/5).
|
|
||||||
|
-spec issue_user_tokens(UserId :: binary(), Role :: binary(),
|
||||||
|
ClientType :: binary(), DeviceName :: binary(),
|
||||||
|
UserAgent :: binary()) ->
|
||||||
|
{ok, AccessToken :: binary(), RefreshToken :: binary(), SessionId :: binary()} |
|
||||||
|
{error, invalid_client_type}.
|
||||||
|
issue_user_tokens(UserId, Role, ClientType0, DeviceName0, UserAgent0) ->
|
||||||
|
case normalize_user_client_type(ClientType0) of
|
||||||
|
{ok, ClientType} ->
|
||||||
|
DeviceName = normalize_device_name(DeviceName0),
|
||||||
|
UserAgent = case UserAgent0 of
|
||||||
|
U when is_binary(U) -> U;
|
||||||
|
_ -> <<>>
|
||||||
|
end,
|
||||||
|
issue_tokens(UserId, user, ClientType, DeviceName, UserAgent, Role,
|
||||||
|
fun eventhub_auth:generate_user_token/2,
|
||||||
|
fun eventhub_auth:generate_user_refresh_token/5);
|
||||||
|
{error, _} = Err ->
|
||||||
|
Err
|
||||||
|
end.
|
||||||
|
|
||||||
-spec refresh_user(RefreshToken :: binary()) ->
|
-spec refresh_user(RefreshToken :: binary()) ->
|
||||||
{ok, AccessToken :: binary(), NewRefreshToken :: binary()} |
|
{ok, AccessToken :: binary(), NewRefreshToken :: binary(), SessionId :: binary()} |
|
||||||
{error, atom()}.
|
{error, atom()}.
|
||||||
refresh_user(RefreshToken) ->
|
refresh_user(RefreshToken) ->
|
||||||
refresh_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1,
|
refresh_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1,
|
||||||
fun core_user:get_by_id/1, user, fun eventhub_auth:generate_user_token/2,
|
fun core_user:get_by_id/1, user, fun eventhub_auth:generate_user_token/2,
|
||||||
fun eventhub_auth:generate_user_refresh_token/5, ?USER_CLIENT_WEB).
|
fun eventhub_auth:generate_user_refresh_token/5).
|
||||||
|
|
||||||
|
-spec logout_user(RefreshToken :: binary()) -> ok | {error, atom()}.
|
||||||
|
logout_user(RefreshToken) ->
|
||||||
|
logout_subject(RefreshToken, fun eventhub_auth:verify_user_refresh_token/1).
|
||||||
|
|
||||||
|
-spec list_user_sessions(UserId :: binary()) -> [#auth_session{}].
|
||||||
|
list_user_sessions(UserId) ->
|
||||||
|
core_auth_session:list_active_for_subject(UserId, user).
|
||||||
|
|
||||||
|
-spec revoke_user_session(UserId :: binary(), SessionId :: binary()) ->
|
||||||
|
ok | {error, not_found}.
|
||||||
|
revoke_user_session(UserId, SessionId) ->
|
||||||
|
case core_auth_session:get(SessionId) of
|
||||||
|
{ok, #auth_session{subject_id = UserId, subject_type = user}} ->
|
||||||
|
core_auth_session:revoke(SessionId);
|
||||||
|
{ok, _} ->
|
||||||
|
{error, not_found};
|
||||||
|
{error, not_found} ->
|
||||||
|
{error, not_found}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec revoke_other_user_sessions(UserId :: binary(), RefreshToken :: binary()) ->
|
||||||
|
{ok, non_neg_integer()} | {error, atom()}.
|
||||||
|
revoke_other_user_sessions(UserId, RefreshToken) ->
|
||||||
|
case eventhub_auth:verify_user_refresh_token(RefreshToken) of
|
||||||
|
{ok, Claims} ->
|
||||||
|
case maps:get(<<"sub">>, Claims) of
|
||||||
|
UserId ->
|
||||||
|
SessionId = maps:get(<<"sid">>, Claims),
|
||||||
|
core_auth_session:revoke_others_for_subject(UserId, user, SessionId);
|
||||||
|
_ ->
|
||||||
|
{error, forbidden}
|
||||||
|
end;
|
||||||
|
{error, _} = Err ->
|
||||||
|
Err
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec normalize_user_client_type(term()) -> {ok, binary()} | {error, invalid_client_type}.
|
||||||
|
normalize_user_client_type(undefined) -> {ok, ?USER_CLIENT_WEB};
|
||||||
|
normalize_user_client_type(null) -> {ok, ?USER_CLIENT_WEB};
|
||||||
|
normalize_user_client_type(<<>>) -> {ok, ?USER_CLIENT_WEB};
|
||||||
|
normalize_user_client_type(?USER_CLIENT_WEB) -> {ok, ?USER_CLIENT_WEB};
|
||||||
|
normalize_user_client_type(?USER_CLIENT_MOBILE) -> {ok, ?USER_CLIENT_MOBILE};
|
||||||
|
normalize_user_client_type(<<"WEB">>) -> {ok, ?USER_CLIENT_WEB};
|
||||||
|
normalize_user_client_type(<<"MOBILE">>) -> {ok, ?USER_CLIENT_MOBILE};
|
||||||
|
normalize_user_client_type(_) -> {error, invalid_client_type}.
|
||||||
|
|
||||||
|
-spec normalize_device_name(term()) -> binary().
|
||||||
|
normalize_device_name(Name) when is_binary(Name) ->
|
||||||
|
Trimmed = string:trim(Name),
|
||||||
|
case byte_size(Trimmed) > ?DEVICE_NAME_MAX of
|
||||||
|
true -> binary:part(Trimmed, 0, ?DEVICE_NAME_MAX);
|
||||||
|
false -> Trimmed
|
||||||
|
end;
|
||||||
|
normalize_device_name(_) ->
|
||||||
|
<<>>.
|
||||||
|
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% Internal
|
%%% Internal
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
|
|
||||||
issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) ->
|
logout_subject(RefreshToken, VerifyFn) ->
|
||||||
{ok, Session} = core_auth_session:create(SubjectId, SubjectType, ClientType),
|
case VerifyFn(RefreshToken) of
|
||||||
|
{ok, Claims} ->
|
||||||
|
SessionId = maps:get(<<"sid">>, Claims),
|
||||||
|
case core_auth_session:revoke(SessionId) of
|
||||||
|
ok -> ok;
|
||||||
|
{error, not_found} -> ok;
|
||||||
|
{error, _} = Err -> Err
|
||||||
|
end;
|
||||||
|
{error, _} = Err ->
|
||||||
|
Err
|
||||||
|
end.
|
||||||
|
|
||||||
|
issue_tokens(SubjectId, SubjectType, ClientType, DeviceName, UserAgent, Role, GenAccess, GenRefresh) ->
|
||||||
|
{ok, Session} = core_auth_session:create(
|
||||||
|
SubjectId, SubjectType, ClientType, DeviceName, UserAgent),
|
||||||
AccessToken = GenAccess(SubjectId, Role),
|
AccessToken = GenAccess(SubjectId, Role),
|
||||||
{RefreshToken, _} = GenRefresh(
|
{RefreshToken, _} = GenRefresh(
|
||||||
Session#auth_session.session_id,
|
Session#auth_session.session_id,
|
||||||
@@ -60,28 +160,29 @@ issue_tokens(SubjectId, SubjectType, ClientType, Role, GenAccess, GenRefresh) ->
|
|||||||
SubjectId,
|
SubjectId,
|
||||||
ClientType
|
ClientType
|
||||||
),
|
),
|
||||||
{ok, AccessToken, RefreshToken}.
|
{ok, AccessToken, RefreshToken, Session#auth_session.session_id}.
|
||||||
|
|
||||||
refresh_subject(RefreshToken, VerifyFn, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) ->
|
refresh_subject(RefreshToken, VerifyFn, GetSubjectFn, SubjectType, GenAccess, GenRefresh) ->
|
||||||
case VerifyFn(RefreshToken) of
|
case VerifyFn(RefreshToken) of
|
||||||
{ok, Claims} ->
|
{ok, Claims} ->
|
||||||
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType);
|
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh);
|
||||||
Error ->
|
Error ->
|
||||||
Error
|
Error
|
||||||
end.
|
end.
|
||||||
|
|
||||||
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh, ClientType) ->
|
do_refresh(Claims, GetSubjectFn, SubjectType, GenAccess, GenRefresh) ->
|
||||||
SessionId = maps:get(<<"sid">>, Claims),
|
SessionId = maps:get(<<"sid">>, Claims),
|
||||||
Jti = maps:get(<<"jti">>, Claims),
|
Jti = maps:get(<<"jti">>, Claims),
|
||||||
SubjectId = maps:get(<<"sub">>, Claims),
|
SubjectId = maps:get(<<"sub">>, Claims),
|
||||||
FamilyId = maps:get(<<"fid">>, Claims),
|
FamilyId = maps:get(<<"fid">>, Claims),
|
||||||
case core_auth_session:rotate(SessionId, Jti) of
|
case core_auth_session:rotate(SessionId, Jti) of
|
||||||
{ok, NewJti, _Session} ->
|
{ok, NewJti, Session} ->
|
||||||
{ok, Subject} = GetSubjectFn(SubjectId),
|
{ok, Subject} = GetSubjectFn(SubjectId),
|
||||||
Role = subject_role(Subject, SubjectType),
|
Role = subject_role(Subject, SubjectType),
|
||||||
|
ClientType = Session#auth_session.client_type,
|
||||||
AccessToken = GenAccess(SubjectId, Role),
|
AccessToken = GenAccess(SubjectId, Role),
|
||||||
{NewRefresh, _} = GenRefresh(SessionId, FamilyId, NewJti, SubjectId, ClientType),
|
{NewRefresh, _} = GenRefresh(SessionId, FamilyId, NewJti, SubjectId, ClientType),
|
||||||
{ok, AccessToken, NewRefresh};
|
{ok, AccessToken, NewRefresh, SessionId};
|
||||||
{error, reuse_detected} ->
|
{error, reuse_detected} ->
|
||||||
core_auth_session:revoke_family(FamilyId),
|
core_auth_session:revoke_family(FamilyId),
|
||||||
{error, reuse_detected};
|
{error, reuse_detected};
|
||||||
|
|||||||
+124
-6
@@ -7,8 +7,8 @@
|
|||||||
delete_booking/2,
|
delete_booking/2,
|
||||||
list_bookings_admin/0, get_booking_admin/1,
|
list_bookings_admin/0, get_booking_admin/1,
|
||||||
list_event_bookings/1, list_event_bookings/2,
|
list_event_bookings/1, list_event_bookings/2,
|
||||||
process_timeout_bookings/0, cancel_pending_for_owner/1,
|
process_timeout_bookings/0, process_reminders/0, cancel_pending_for_owner/1,
|
||||||
cancel_pending_for_calendar/1]).
|
cancel_pending_for_calendar/1, can_manage_event_bookings/2]).
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
|
%%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
|
||||||
@@ -133,7 +133,13 @@ confirm_booking(UserId, BookingId, decline) ->
|
|||||||
true ->
|
true ->
|
||||||
case ensure_pending_actionable(Booking) of
|
case ensure_pending_actionable(Booking) of
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
core_booking:update(BookingId, [{status, cancelled}]);
|
case core_booking:update(BookingId, [{status, cancelled}]) of
|
||||||
|
{ok, _} = Ok ->
|
||||||
|
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
|
||||||
|
Ok;
|
||||||
|
Err ->
|
||||||
|
Err
|
||||||
|
end;
|
||||||
{error, Reason} ->
|
{error, Reason} ->
|
||||||
{error, Reason}
|
{error, Reason}
|
||||||
end;
|
end;
|
||||||
@@ -155,7 +161,14 @@ cancel_booking(BookingId, UserId) ->
|
|||||||
{ok, Booking};
|
{ok, Booking};
|
||||||
_ ->
|
_ ->
|
||||||
case Booking#booking.user_id =:= UserId of
|
case Booking#booking.user_id =:= UserId of
|
||||||
true -> core_booking:update(BookingId, [{status, cancelled}]);
|
true ->
|
||||||
|
case core_booking:update(BookingId, [{status, cancelled}]) of
|
||||||
|
{ok, _} = Ok ->
|
||||||
|
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
|
||||||
|
Ok;
|
||||||
|
Err ->
|
||||||
|
Err
|
||||||
|
end;
|
||||||
false -> {error, access_denied}
|
false -> {error, access_denied}
|
||||||
end
|
end
|
||||||
end;
|
end;
|
||||||
@@ -367,6 +380,95 @@ process_timeout_bookings() ->
|
|||||||
lists:foreach(fun(B) -> maybe_timeout(B, NowSec) end, Pending),
|
lists:foreach(fun(B) -> maybe_timeout(B, NowSec) end, Pending),
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Email + in-app reminder before event start (Back#70).
|
||||||
|
%%% Confirmed bookings with reminder_sent=false whose event starts within
|
||||||
|
%%% REMINDER_LEAD_HOURS (default 24). Flag set before send (once).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec process_reminders() -> ok.
|
||||||
|
process_reminders() ->
|
||||||
|
NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
|
||||||
|
LeadSec = reminder_lead_hours() * 3600,
|
||||||
|
Horizon = NowSec + LeadSec,
|
||||||
|
Candidates = mnesia:dirty_match_object(
|
||||||
|
#booking{status = confirmed, reminder_sent = false, _ = '_'}),
|
||||||
|
lists:foreach(fun(B) -> maybe_remind(B, NowSec, Horizon) end, Candidates),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
maybe_remind(#booking{id = Id, event_id = EventId, user_id = UserId} = Booking,
|
||||||
|
NowSec, Horizon) ->
|
||||||
|
case core_event:get_by_id(EventId) of
|
||||||
|
{ok, #event{status = active, start_time = Start, title = Title,
|
||||||
|
calendar_id = CalId} = _Event} ->
|
||||||
|
StartSec = calendar:datetime_to_gregorian_seconds(Start),
|
||||||
|
case StartSec > NowSec andalso StartSec =< Horizon of
|
||||||
|
true ->
|
||||||
|
case core_booking:update(Id, [{reminder_sent, true}]) of
|
||||||
|
{ok, _} ->
|
||||||
|
send_reminder(UserId, Title, Start, CalId, EventId),
|
||||||
|
ok;
|
||||||
|
_ ->
|
||||||
|
ok
|
||||||
|
end;
|
||||||
|
false ->
|
||||||
|
ok
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
ok
|
||||||
|
end,
|
||||||
|
Booking.
|
||||||
|
|
||||||
|
send_reminder(UserId, Title, Start, CalId, EventId) ->
|
||||||
|
Path = <<"/c/", CalId/binary, "/e/", EventId/binary>>,
|
||||||
|
WhenText = format_when(Start),
|
||||||
|
case logic_notification_prefs:email_enabled(UserId) of
|
||||||
|
true ->
|
||||||
|
case core_user:get_by_id(UserId) of
|
||||||
|
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
|
||||||
|
_ = logic_email:send_booking_reminder(Email, Title, Start, Path);
|
||||||
|
_ ->
|
||||||
|
ok
|
||||||
|
end;
|
||||||
|
false ->
|
||||||
|
ok
|
||||||
|
end,
|
||||||
|
_ = logic_notification:notify_event_reminder(UserId, #{
|
||||||
|
event_id => EventId,
|
||||||
|
calendar_id => CalId,
|
||||||
|
title => Title,
|
||||||
|
start_time => Start,
|
||||||
|
when_text => WhenText
|
||||||
|
}),
|
||||||
|
_ = logic_web_push:send(
|
||||||
|
UserId,
|
||||||
|
<<"Напоминание о записи"/utf8>>,
|
||||||
|
iolist_to_binary([<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText]),
|
||||||
|
Path
|
||||||
|
),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
format_when({{Y, Mo, D}, {H, Mi, _S}}) ->
|
||||||
|
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
|
||||||
|
[Y, Mo, D, H, Mi])).
|
||||||
|
|
||||||
|
reminder_lead_hours() ->
|
||||||
|
case application:get_env(eventhub, reminder_lead_hours) of
|
||||||
|
{ok, N} when is_integer(N), N > 0 -> N;
|
||||||
|
_ ->
|
||||||
|
case os:getenv("REMINDER_LEAD_HOURS") of
|
||||||
|
false -> 24;
|
||||||
|
"" -> 24;
|
||||||
|
S ->
|
||||||
|
try list_to_integer(S) of
|
||||||
|
N when N > 0 -> N;
|
||||||
|
_ -> 24
|
||||||
|
catch
|
||||||
|
_:_ -> 24
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Booking, NowSec) ->
|
maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Booking, NowSec) ->
|
||||||
case core_event:get_by_id(EventId) of
|
case core_event:get_by_id(EventId) of
|
||||||
{ok, Event} ->
|
{ok, Event} ->
|
||||||
@@ -384,7 +486,12 @@ maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Book
|
|||||||
Now = calendar:universal_time(),
|
Now = calendar:universal_time(),
|
||||||
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
|
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
|
||||||
false ->
|
false ->
|
||||||
_ = core_booking:update(Id, [{status, cancelled}])
|
case core_booking:update(Id, [{status, cancelled}]) of
|
||||||
|
{ok, _} ->
|
||||||
|
_ = logic_waitlist:maybe_promote(EventId);
|
||||||
|
_ ->
|
||||||
|
ok
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
false ->
|
false ->
|
||||||
ok
|
ok
|
||||||
@@ -475,7 +582,18 @@ ensure_not_past_pending(B) ->
|
|||||||
|
|
||||||
-spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}.
|
-spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}.
|
||||||
mark_expired(BookingId) ->
|
mark_expired(BookingId) ->
|
||||||
core_booking:update(BookingId, [{status, expired}]).
|
case core_booking:get_by_id(BookingId) of
|
||||||
|
{ok, #booking{event_id = EventId}} ->
|
||||||
|
case core_booking:update(BookingId, [{status, expired}]) of
|
||||||
|
{ok, _} = Ok ->
|
||||||
|
_ = logic_waitlist:maybe_promote(EventId),
|
||||||
|
Ok;
|
||||||
|
Err ->
|
||||||
|
Err
|
||||||
|
end;
|
||||||
|
{error, _} = Err ->
|
||||||
|
Err
|
||||||
|
end.
|
||||||
|
|
||||||
-spec event_started(#event{}, non_neg_integer()) -> boolean().
|
-spec event_started(#event{}, non_neg_integer()) -> boolean().
|
||||||
event_started(#event{start_time = Start}, NowSec) ->
|
event_started(#event{start_time = Start}, NowSec) ->
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ calendar_has_content(CalendarId) ->
|
|||||||
|
|
||||||
%% Получение календаря с проверкой доступа
|
%% Получение календаря с проверкой доступа
|
||||||
get_calendar(UserId, CalendarId) ->
|
get_calendar(UserId, CalendarId) ->
|
||||||
case core_calendar:get_by_id(CalendarId) of
|
case core_calendar:get_by_id_or_short_name(CalendarId) of
|
||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
case can_access(UserId, Calendar) of
|
case can_access(UserId, Calendar) of
|
||||||
true -> {ok, Calendar};
|
true -> {ok, Calendar};
|
||||||
@@ -172,9 +172,21 @@ get_calendar(UserId, CalendarId) ->
|
|||||||
Error
|
Error
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%% Список календарей пользователя
|
%% Список календарей пользователя: owned ∪ shared grants
|
||||||
list_calendars(UserId) ->
|
list_calendars(UserId) ->
|
||||||
core_calendar:list_by_owner(UserId).
|
{ok, Owned} = core_calendar:list_by_owner(UserId),
|
||||||
|
Shared = shared_calendars(UserId),
|
||||||
|
OwnedIds = [C#calendar.id || C <- Owned],
|
||||||
|
SharedOnly = [C || C <- Shared, not lists:member(C#calendar.id, OwnedIds)],
|
||||||
|
{ok, Owned ++ SharedOnly}.
|
||||||
|
|
||||||
|
shared_calendars(UserId) ->
|
||||||
|
lists:filtermap(fun(#calendar_share{calendar_id = CalId}) ->
|
||||||
|
case core_calendar:get_by_id(CalId) of
|
||||||
|
{ok, #calendar{status = active} = Cal} -> {true, Cal};
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end, core_calendar_share:list_by_user(UserId)).
|
||||||
|
|
||||||
%% Обновление календаря
|
%% Обновление календаря
|
||||||
update_calendar(UserId, CalendarId, Updates) ->
|
update_calendar(UserId, CalendarId, Updates) ->
|
||||||
@@ -227,16 +239,16 @@ apply_calendar_update(CalendarId, Calendar, ValidUpdates) ->
|
|||||||
end
|
end
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%% Единственный personal нельзя превратить в commercial (дневник пользователя).
|
%% Смена type — только owner. Единственный personal → commercial запрещён.
|
||||||
gate_type_change(_UserId, #calendar{type = personal}, Updates) ->
|
gate_type_change(UserId, #calendar{owner_id = OwnerId} = Calendar, Updates) ->
|
||||||
case lists:keyfind(type, 1, Updates) of
|
case lists:keyfind(type, 1, Updates) of
|
||||||
{type, commercial} ->
|
{type, _} when UserId =/= OwnerId ->
|
||||||
|
{error, access_denied};
|
||||||
|
{type, commercial} when Calendar#calendar.type =:= personal ->
|
||||||
{error, default_calendar};
|
{error, default_calendar};
|
||||||
_ ->
|
_ ->
|
||||||
{ok, Updates}
|
{ok, Updates}
|
||||||
end;
|
end.
|
||||||
gate_type_change(_UserId, _Calendar, Updates) ->
|
|
||||||
{ok, Updates}.
|
|
||||||
|
|
||||||
maybe_cancel_pending_on_personal(#calendar{type = commercial},
|
maybe_cancel_pending_on_personal(#calendar{type = commercial},
|
||||||
#calendar{type = personal, id = Id}) ->
|
#calendar{type = personal, id = Id}) ->
|
||||||
@@ -270,18 +282,15 @@ apply_text_results(Updates, [], []) -> Updates;
|
|||||||
apply_text_results(Updates, [F | Fs], [T | Ts]) ->
|
apply_text_results(Updates, [F | Fs], [T | Ts]) ->
|
||||||
apply_text_results(lists:keystore(F, 1, Updates, {F, T}), Fs, Ts).
|
apply_text_results(lists:keystore(F, 1, Updates, {F, T}), Fs, Ts).
|
||||||
|
|
||||||
%% Удаление календаря (единственный personal удалять нельзя)
|
%% Удаление календаря: только owner (share write/admin не удаляет)
|
||||||
delete_calendar(UserId, CalendarId) ->
|
delete_calendar(UserId, CalendarId) ->
|
||||||
case core_calendar:get_by_id(CalendarId) of
|
case core_calendar:get_by_id(CalendarId) of
|
||||||
{ok, #calendar{type = personal}} ->
|
{ok, #calendar{type = personal}} ->
|
||||||
{error, default_calendar};
|
{error, default_calendar};
|
||||||
{ok, Calendar} ->
|
{ok, #calendar{owner_id = UserId, status = active}} ->
|
||||||
case can_edit(UserId, Calendar) of
|
core_calendar:delete(CalendarId);
|
||||||
true ->
|
{ok, #calendar{}} ->
|
||||||
core_calendar:delete(CalendarId);
|
{error, access_denied};
|
||||||
false ->
|
|
||||||
{error, access_denied}
|
|
||||||
end;
|
|
||||||
Error ->
|
Error ->
|
||||||
Error
|
Error
|
||||||
end.
|
end.
|
||||||
@@ -289,16 +298,22 @@ delete_calendar(UserId, CalendarId) ->
|
|||||||
%% Проверка прав доступа (просмотр)
|
%% Проверка прав доступа (просмотр)
|
||||||
can_access(UserId, #calendar{owner_id = UserId, status = active}) ->
|
can_access(UserId, #calendar{owner_id = UserId, status = active}) ->
|
||||||
true;
|
true;
|
||||||
|
can_access(UserId, #calendar{id = CalId, type = personal, status = active}) ->
|
||||||
|
logic_calendar_share:get_rights(UserId, CalId) =/= none;
|
||||||
can_access(_UserId, #calendar{type = commercial, status = active}) ->
|
can_access(_UserId, #calendar{type = commercial, status = active}) ->
|
||||||
true;
|
true;
|
||||||
can_access(_UserId, _) ->
|
can_access(_UserId, _) ->
|
||||||
false.
|
false.
|
||||||
|
|
||||||
%% Проверка прав редактирования
|
%% Проверка прав редактирования (owner или share write|admin)
|
||||||
can_edit(UserId, #calendar{owner_id = UserId, status = active}) ->
|
can_edit(UserId, #calendar{owner_id = UserId, status = active}) ->
|
||||||
true;
|
true;
|
||||||
can_edit(_UserId, #calendar{owner_id = _OwnerId}) ->
|
can_edit(UserId, #calendar{id = CalId, status = active}) ->
|
||||||
false;
|
case logic_calendar_share:get_rights(UserId, CalId) of
|
||||||
|
write -> true;
|
||||||
|
admin -> true;
|
||||||
|
_ -> false
|
||||||
|
end;
|
||||||
can_edit(_, _) ->
|
can_edit(_, _) ->
|
||||||
false.
|
false.
|
||||||
|
|
||||||
@@ -391,6 +406,10 @@ validate_settings_key(<<"default_duration_minutes">>, Val) ->
|
|||||||
validate_default_duration(Val);
|
validate_default_duration(Val);
|
||||||
validate_settings_key(<<"default_recurrence">>, Val) ->
|
validate_settings_key(<<"default_recurrence">>, Val) ->
|
||||||
validate_default_recurrence(Val);
|
validate_default_recurrence(Val);
|
||||||
|
validate_settings_key(<<"waitlist_enabled">>, Val) when is_boolean(Val) ->
|
||||||
|
ok;
|
||||||
|
validate_settings_key(<<"waitlist_enabled">>, _) ->
|
||||||
|
{error, {invalid_settings, <<"waitlist_enabled">>}};
|
||||||
validate_settings_key(_Unknown, _Val) ->
|
validate_settings_key(_Unknown, _Val) ->
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
-spec follow(UserId :: binary(), CalendarId :: binary()) ->
|
-spec follow(UserId :: binary(), CalendarId :: binary()) ->
|
||||||
{ok, #calendar_follow{}} | {error, term()}.
|
{ok, #calendar_follow{}} | {error, term()}.
|
||||||
follow(UserId, CalendarId) ->
|
follow(UserId, CalendarId) ->
|
||||||
case core_calendar:get_by_id(CalendarId) of
|
case core_calendar:get_by_id_or_short_name(CalendarId) of
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
{error, not_found};
|
{error, not_found};
|
||||||
{ok, #calendar{owner_id = UserId}} ->
|
{ok, #calendar{owner_id = UserId}} ->
|
||||||
@@ -27,7 +27,7 @@ follow(UserId, CalendarId) ->
|
|||||||
true ->
|
true ->
|
||||||
case Calendar#calendar.status of
|
case Calendar#calendar.status of
|
||||||
active ->
|
active ->
|
||||||
core_calendar_follow:follow(CalendarId, UserId);
|
core_calendar_follow:follow(Calendar#calendar.id, UserId);
|
||||||
_ ->
|
_ ->
|
||||||
{error, not_found}
|
{error, not_found}
|
||||||
end
|
end
|
||||||
@@ -40,14 +40,13 @@ follow(UserId, CalendarId) ->
|
|||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec unfollow(UserId :: binary(), CalendarId :: binary()) -> ok | {error, term()}.
|
-spec unfollow(UserId :: binary(), CalendarId :: binary()) -> ok | {error, term()}.
|
||||||
unfollow(UserId, CalendarId) ->
|
unfollow(UserId, CalendarId) ->
|
||||||
case core_calendar:get_by_id(CalendarId) of
|
case core_calendar:get_by_id_or_short_name(CalendarId) of
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
%% всё равно снимаем локальный follow, если был
|
|
||||||
core_calendar_follow:unfollow(CalendarId, UserId);
|
core_calendar_follow:unfollow(CalendarId, UserId);
|
||||||
{ok, #calendar{owner_id = UserId}} ->
|
{ok, #calendar{owner_id = UserId}} ->
|
||||||
{error, own_calendar};
|
{error, own_calendar};
|
||||||
{ok, _} ->
|
{ok, Calendar} ->
|
||||||
core_calendar_follow:unfollow(CalendarId, UserId)
|
core_calendar_follow:unfollow(Calendar#calendar.id, UserId)
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -10,12 +10,9 @@
|
|||||||
-spec list(ActorId :: binary(), CalendarId :: binary()) ->
|
-spec list(ActorId :: binary(), CalendarId :: binary()) ->
|
||||||
{ok, [#calendar_specialist{}]} | {error, not_found | access_denied}.
|
{ok, [#calendar_specialist{}]} | {error, not_found | access_denied}.
|
||||||
list(ActorId, CalendarId) ->
|
list(ActorId, CalendarId) ->
|
||||||
case core_calendar:get_by_id(CalendarId) of
|
case logic_calendar:get_calendar(ActorId, CalendarId) of
|
||||||
{ok, Cal} ->
|
{ok, Cal} ->
|
||||||
case logic_calendar:can_access(ActorId, Cal) of
|
{ok, core_calendar_specialist:list_by_calendar(Cal#calendar.id)};
|
||||||
true -> {ok, core_calendar_specialist:list_by_calendar(CalendarId)};
|
|
||||||
false -> {error, access_denied}
|
|
||||||
end;
|
|
||||||
Error -> Error
|
Error -> Error
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
|||||||
+353
-4
@@ -1,11 +1,360 @@
|
|||||||
-module(logic_email).
|
-module(logic_email).
|
||||||
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2]).
|
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2,
|
||||||
|
send_booking_reminder/4, send_waitlist_promoted/3]).
|
||||||
|
|
||||||
|
%% Email transport (Spec#8 / stage outbound :25 blocked):
|
||||||
|
%% EMAIL_TRANSPORT = smtp | http_api | log | auto (default auto)
|
||||||
|
%% auto: EMAIL_API_KEY set → http_api; else SMTP_HOST set → smtp; else log
|
||||||
|
%% SMTP: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_TLS
|
||||||
|
%% HTTP API: EMAIL_API_KEY, EMAIL_API_PROVIDER=resend|brevo (default resend),
|
||||||
|
%% optional EMAIL_API_URL; SMTP_FROM as sender
|
||||||
|
%% Tests: smtp_deliver / email_http_deliver app env mocks
|
||||||
|
|
||||||
|
-spec send_verification_email(binary(), binary()) -> ok.
|
||||||
send_verification_email(Email, Token) ->
|
send_verification_email(Email, Token) ->
|
||||||
io:format("Sending verification email to ~s with token ~s~n", [Email, Token]).
|
send_kind(verify, Email, abs_url(<<"/verify?token=">>, Token)).
|
||||||
|
|
||||||
|
-spec send_specialist_invite(binary(), binary()) -> ok.
|
||||||
send_specialist_invite(Email, Token) ->
|
send_specialist_invite(Email, Token) ->
|
||||||
io:format("Sending specialist invite email to ~s with token ~s~n", [Email, Token]).
|
send_kind(invite, Email, abs_url(<<"/invites?token=">>, Token)).
|
||||||
|
|
||||||
|
-spec send_password_reset(binary(), binary()) -> ok.
|
||||||
send_password_reset(Email, Token) ->
|
send_password_reset(Email, Token) ->
|
||||||
io:format("Sending password reset email to ~s with token ~s~n", [Email, Token]).
|
send_kind(reset, Email, abs_url(<<"/reset-password?token=">>, Token)).
|
||||||
|
|
||||||
|
-spec send_booking_reminder(binary(), binary(), calendar:datetime(), binary()) -> ok.
|
||||||
|
send_booking_reminder(Email, EventTitle, StartTime, EventPath) ->
|
||||||
|
Url = abs_path(EventPath),
|
||||||
|
WhenText = format_utc(StartTime),
|
||||||
|
{Subject, Plain, Html} =
|
||||||
|
logic_email_templates:render_reminder(Url, to_bin(EventTitle), WhenText),
|
||||||
|
send(to_bin(Email), Subject, Plain, Html).
|
||||||
|
|
||||||
|
-spec send_waitlist_promoted(binary(), binary(), binary()) -> ok.
|
||||||
|
send_waitlist_promoted(Email, EventTitle, EventPath) ->
|
||||||
|
Url = abs_path(EventPath),
|
||||||
|
{Subject, Plain, Html} =
|
||||||
|
logic_email_templates:render_waitlist_promoted(Url, to_bin(EventTitle)),
|
||||||
|
send(to_bin(Email), Subject, Plain, Html).
|
||||||
|
|
||||||
|
send_kind(Kind, To0, Url) ->
|
||||||
|
{Subject, Plain, Html} = logic_email_templates:render(Kind, Url),
|
||||||
|
send(to_bin(To0), Subject, Plain, Html).
|
||||||
|
|
||||||
|
send(To, Subject, Plain, Html) ->
|
||||||
|
case transport() of
|
||||||
|
log ->
|
||||||
|
logger:info(#{what => email_log_only, to => To, subject => Subject}),
|
||||||
|
ok;
|
||||||
|
smtp ->
|
||||||
|
send_smtp(To, Subject, Plain, Html);
|
||||||
|
http_api ->
|
||||||
|
send_http_api(To, Subject, Plain, Html)
|
||||||
|
end.
|
||||||
|
|
||||||
|
transport() ->
|
||||||
|
case env_bin("EMAIL_TRANSPORT", <<>>) of
|
||||||
|
<<"smtp">> -> smtp;
|
||||||
|
<<"http_api">> -> http_api;
|
||||||
|
<<"log">> -> log;
|
||||||
|
<<"auto">> -> transport_auto();
|
||||||
|
<<>> ->
|
||||||
|
case application:get_env(eventhub, email_transport) of
|
||||||
|
{ok, smtp} -> smtp;
|
||||||
|
{ok, http_api} -> http_api;
|
||||||
|
{ok, log} -> log;
|
||||||
|
_ -> transport_auto()
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
transport_auto()
|
||||||
|
end.
|
||||||
|
|
||||||
|
transport_auto() ->
|
||||||
|
case application:get_env(eventhub, email_transport) of
|
||||||
|
{ok, smtp} -> smtp;
|
||||||
|
{ok, http_api} -> http_api;
|
||||||
|
{ok, log} -> log;
|
||||||
|
_ ->
|
||||||
|
case api_key() of
|
||||||
|
<<>> ->
|
||||||
|
case smtp_relay() of
|
||||||
|
undefined -> log;
|
||||||
|
_ -> smtp
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
http_api
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% SMTP
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
|
||||||
|
send_smtp(To, Subject, Plain, Html) ->
|
||||||
|
case smtp_relay() of
|
||||||
|
undefined ->
|
||||||
|
logger:info(#{what => email_log_only, to => To, subject => Subject}),
|
||||||
|
ok;
|
||||||
|
Relay ->
|
||||||
|
From = from_addr(),
|
||||||
|
Raw = rfc822(From, To, Subject, Plain, Html),
|
||||||
|
Opts = smtp_opts(Relay),
|
||||||
|
safe_smtp_deliver(From, To, Raw, Opts)
|
||||||
|
end.
|
||||||
|
|
||||||
|
safe_smtp_deliver(From, To, Raw, Opts) ->
|
||||||
|
Fun = smtp_deliver_fun(),
|
||||||
|
try Fun(From, To, Raw, Opts) of
|
||||||
|
{ok, _} -> ok;
|
||||||
|
ok -> ok;
|
||||||
|
{error, Reason} ->
|
||||||
|
logger:warning(#{what => smtp_send_error, to => To, reason => Reason}),
|
||||||
|
ok;
|
||||||
|
Other ->
|
||||||
|
logger:warning(#{what => smtp_send_unexpected, to => To, result => Other}),
|
||||||
|
ok
|
||||||
|
catch
|
||||||
|
Class:Reason:Stack ->
|
||||||
|
logger:warning(#{what => smtp_send_crash, to => To,
|
||||||
|
class => Class, reason => Reason, stack => Stack}),
|
||||||
|
ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
smtp_deliver_fun() ->
|
||||||
|
case application:get_env(eventhub, smtp_deliver) of
|
||||||
|
{ok, Fun} when is_function(Fun, 4) -> Fun;
|
||||||
|
_ -> fun default_smtp_deliver/4
|
||||||
|
end.
|
||||||
|
|
||||||
|
default_smtp_deliver(From, To, Raw, Opts) ->
|
||||||
|
gen_smtp_client:send({From, [To], Raw}, Opts).
|
||||||
|
|
||||||
|
smtp_relay() ->
|
||||||
|
case app_or_env(smtp_host, "SMTP_HOST") of
|
||||||
|
<<>> -> undefined;
|
||||||
|
Host -> binary_to_list(Host)
|
||||||
|
end.
|
||||||
|
|
||||||
|
smtp_opts(Relay) ->
|
||||||
|
Port = env_int("SMTP_PORT", 587),
|
||||||
|
Tls = tls_mode(env_bin("SMTP_TLS", <<"if_available">>)),
|
||||||
|
Base = [
|
||||||
|
{relay, Relay},
|
||||||
|
{port, Port},
|
||||||
|
{tls, Tls},
|
||||||
|
{hostname, "calentiq.com"}
|
||||||
|
],
|
||||||
|
User = env_bin("SMTP_USER", <<>>),
|
||||||
|
Pass = env_bin("SMTP_PASS", <<>>),
|
||||||
|
case User of
|
||||||
|
<<>> -> Base;
|
||||||
|
_ ->
|
||||||
|
[{username, binary_to_list(User)},
|
||||||
|
{password, binary_to_list(Pass)},
|
||||||
|
{auth, always} | Base]
|
||||||
|
end.
|
||||||
|
|
||||||
|
tls_mode(<<"always">>) -> always;
|
||||||
|
tls_mode(<<"never">>) -> never;
|
||||||
|
tls_mode(_) -> if_available.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% HTTP API (HTTPS transactional — no outbound :25/:587)
|
||||||
|
%%% Providers: resend (default/canon), brevo (optional)
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
|
||||||
|
send_http_api(To, Subject, Plain, Html) ->
|
||||||
|
case api_key() of
|
||||||
|
<<>> ->
|
||||||
|
logger:warning(#{what => email_api_missing_key, to => To}),
|
||||||
|
ok;
|
||||||
|
Key ->
|
||||||
|
From = from_addr(),
|
||||||
|
safe_http_deliver(Key, From, To, Subject, Plain, Html)
|
||||||
|
end.
|
||||||
|
|
||||||
|
safe_http_deliver(Key, From, To, Subject, Plain, Html) ->
|
||||||
|
Fun = http_deliver_fun(),
|
||||||
|
try Fun(Key, From, To, Subject, Plain, Html) of
|
||||||
|
ok -> ok;
|
||||||
|
{ok, _} -> ok;
|
||||||
|
{error, Reason} ->
|
||||||
|
logger:warning(#{what => email_api_error, to => To, reason => Reason}),
|
||||||
|
ok;
|
||||||
|
Other ->
|
||||||
|
logger:warning(#{what => email_api_unexpected, to => To, result => Other}),
|
||||||
|
ok
|
||||||
|
catch
|
||||||
|
Class:Reason:Stack ->
|
||||||
|
logger:warning(#{what => email_api_crash, to => To,
|
||||||
|
class => Class, reason => Reason, stack => Stack}),
|
||||||
|
ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
http_deliver_fun() ->
|
||||||
|
case application:get_env(eventhub, email_http_deliver) of
|
||||||
|
{ok, Fun} when is_function(Fun, 6) -> Fun;
|
||||||
|
_ -> fun default_http_deliver/6
|
||||||
|
end.
|
||||||
|
|
||||||
|
default_http_deliver(Key, From, To, Subject, Plain, Html) ->
|
||||||
|
_ = application:ensure_all_started(inets),
|
||||||
|
_ = application:ensure_all_started(ssl),
|
||||||
|
Provider = api_provider(),
|
||||||
|
{Url, Headers, Body} = http_api_request(Provider, Key, From, To, Subject, Plain, Html),
|
||||||
|
Request = {binary_to_list(Url), Headers, "application/json", Body},
|
||||||
|
HttpOpts = [{timeout, 15000}, {ssl, [{verify, verify_peer},
|
||||||
|
{cacerts, public_key:cacerts_get()}]}],
|
||||||
|
case httpc:request(post, Request, HttpOpts, [{body_format, binary}]) of
|
||||||
|
{ok, {{_, Code, _}, _, _Resp}} when Code >= 200, Code < 300 ->
|
||||||
|
ok;
|
||||||
|
{ok, {{_, Code, _}, _, Resp}} ->
|
||||||
|
{error, {http_status, Code, Resp}};
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
http_api_request(resend, Key, From, To, Subject, Plain, Html) ->
|
||||||
|
Url = case env_bin("EMAIL_API_URL", <<>>) of
|
||||||
|
<<>> -> <<"https://api.resend.com/emails">>;
|
||||||
|
U -> U
|
||||||
|
end,
|
||||||
|
FromLine = <<"CalenTIQ <", From/binary, ">">>,
|
||||||
|
Body = jsx:encode(#{
|
||||||
|
<<"from">> => FromLine,
|
||||||
|
<<"to">> => [To],
|
||||||
|
<<"subject">> => Subject,
|
||||||
|
<<"html">> => Html,
|
||||||
|
<<"text">> => Plain
|
||||||
|
}),
|
||||||
|
Headers = [
|
||||||
|
{"authorization", "Bearer " ++ binary_to_list(Key)},
|
||||||
|
{"accept", "application/json"},
|
||||||
|
{"content-type", "application/json"}
|
||||||
|
],
|
||||||
|
{Url, Headers, Body};
|
||||||
|
http_api_request(brevo, Key, From, To, Subject, Plain, Html) ->
|
||||||
|
Url = case env_bin("EMAIL_API_URL", <<>>) of
|
||||||
|
<<>> -> <<"https://api.brevo.com/v3/smtp/email">>;
|
||||||
|
U -> U
|
||||||
|
end,
|
||||||
|
Body = jsx:encode(#{
|
||||||
|
<<"sender">> => #{
|
||||||
|
<<"name">> => <<"CalenTIQ">>,
|
||||||
|
<<"email">> => From
|
||||||
|
},
|
||||||
|
<<"to">> => [#{<<"email">> => To}],
|
||||||
|
<<"subject">> => Subject,
|
||||||
|
<<"htmlContent">> => Html,
|
||||||
|
<<"textContent">> => Plain
|
||||||
|
}),
|
||||||
|
Headers = [
|
||||||
|
{"api-key", binary_to_list(Key)},
|
||||||
|
{"accept", "application/json"},
|
||||||
|
{"content-type", "application/json"}
|
||||||
|
],
|
||||||
|
{Url, Headers, Body}.
|
||||||
|
|
||||||
|
api_provider() ->
|
||||||
|
case env_bin("EMAIL_API_PROVIDER", <<"resend">>) of
|
||||||
|
<<"resend">> -> resend;
|
||||||
|
<<"brevo">> -> brevo;
|
||||||
|
<<"sendinblue">> -> brevo;
|
||||||
|
Other ->
|
||||||
|
case application:get_env(eventhub, email_api_provider) of
|
||||||
|
{ok, resend} -> resend;
|
||||||
|
{ok, brevo} -> brevo;
|
||||||
|
_ when Other =:= <<>> -> resend;
|
||||||
|
_ ->
|
||||||
|
logger:warning(#{what => email_api_unknown_provider, provider => Other}),
|
||||||
|
resend
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
api_key() ->
|
||||||
|
case application:get_env(eventhub, email_api_key) of
|
||||||
|
{ok, K} when is_binary(K), K =/= <<>> -> K;
|
||||||
|
{ok, K} when is_list(K), K =/= "" -> unicode:characters_to_binary(K);
|
||||||
|
_ -> env_bin("EMAIL_API_KEY", <<>>)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% Shared
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
|
||||||
|
from_addr() ->
|
||||||
|
env_bin("SMTP_FROM", <<"noreply@calentiq.com">>).
|
||||||
|
|
||||||
|
abs_url(PathPrefix, Token) ->
|
||||||
|
Base = strip_slash(env_bin("PUBLIC_APP_URL", <<"https://stage.calentiq.com">>)),
|
||||||
|
TokenB = to_bin(Token),
|
||||||
|
<<Base/binary, PathPrefix/binary, TokenB/binary>>.
|
||||||
|
|
||||||
|
abs_path(Path0) ->
|
||||||
|
Base = strip_slash(env_bin("PUBLIC_APP_URL", <<"https://stage.calentiq.com">>)),
|
||||||
|
Path = case to_bin(Path0) of
|
||||||
|
<<$/, Rest/binary>> -> <<$/, Rest/binary>>;
|
||||||
|
Rest -> <<$/, Rest/binary>>
|
||||||
|
end,
|
||||||
|
<<Base/binary, Path/binary>>.
|
||||||
|
|
||||||
|
format_utc({{Y, Mo, D}, {H, Mi, _S}}) ->
|
||||||
|
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
|
||||||
|
[Y, Mo, D, H, Mi])).
|
||||||
|
|
||||||
|
strip_slash(Url) ->
|
||||||
|
case binary:last(Url) of
|
||||||
|
$/ -> binary:part(Url, 0, byte_size(Url) - 1);
|
||||||
|
_ -> Url
|
||||||
|
end.
|
||||||
|
|
||||||
|
rfc822(From, To, Subject, Plain, Html) ->
|
||||||
|
Bound = <<"eh-alt-01">>,
|
||||||
|
iolist_to_binary([
|
||||||
|
<<"From: CalenTIQ <">>, From, <<">\r\n">>,
|
||||||
|
<<"To: ">>, To, <<"\r\n">>,
|
||||||
|
<<"Subject: ">>, encode_subject(Subject), <<"\r\n">>,
|
||||||
|
<<"MIME-Version: 1.0\r\n">>,
|
||||||
|
<<"Content-Type: multipart/alternative; boundary=\"">>, Bound, <<"\"\r\n">>,
|
||||||
|
<<"\r\n">>,
|
||||||
|
<<"--">>, Bound, <<"\r\n">>,
|
||||||
|
<<"Content-Type: text/plain; charset=utf-8\r\n">>,
|
||||||
|
<<"Content-Transfer-Encoding: 8bit\r\n\r\n">>,
|
||||||
|
Plain, <<"\r\n">>,
|
||||||
|
<<"--">>, Bound, <<"\r\n">>,
|
||||||
|
<<"Content-Type: text/html; charset=utf-8\r\n">>,
|
||||||
|
<<"Content-Transfer-Encoding: 8bit\r\n\r\n">>,
|
||||||
|
Html, <<"\r\n">>,
|
||||||
|
<<"--">>, Bound, <<"--\r\n">>
|
||||||
|
]).
|
||||||
|
|
||||||
|
app_or_env(AppKey, EnvName) ->
|
||||||
|
case application:get_env(eventhub, AppKey) of
|
||||||
|
{ok, Val} when Val =/= undefined, Val =/= <<>>, Val =/= "" -> to_bin(Val);
|
||||||
|
_ -> env_bin(EnvName, <<>>)
|
||||||
|
end.
|
||||||
|
|
||||||
|
env_bin(Name, Default) ->
|
||||||
|
case os:getenv(Name) of
|
||||||
|
false -> Default;
|
||||||
|
"" -> Default;
|
||||||
|
S -> unicode:characters_to_binary(S)
|
||||||
|
end.
|
||||||
|
|
||||||
|
env_int(Name, Default) ->
|
||||||
|
case os:getenv(Name) of
|
||||||
|
false -> Default;
|
||||||
|
"" -> Default;
|
||||||
|
S ->
|
||||||
|
try list_to_integer(S) of
|
||||||
|
N -> N
|
||||||
|
catch
|
||||||
|
_:_ -> Default
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
to_bin(B) when is_binary(B) -> B;
|
||||||
|
to_bin(L) when is_list(L) -> unicode:characters_to_binary(L).
|
||||||
|
|
||||||
|
encode_subject(Bin) ->
|
||||||
|
<<"=?UTF-8?B?", (base64:encode(Bin))/binary, "?=">>.
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
-module(logic_email_templates).
|
||||||
|
-export([render/2, render_reminder/3, render_waitlist_promoted/2]).
|
||||||
|
|
||||||
|
%% Kind: verify | invite | reset
|
||||||
|
%% Non-ASCII binaries MUST use /utf8 (OTP truncates codepoints to 8 bits otherwise).
|
||||||
|
-spec render(verify | invite | reset, binary()) ->
|
||||||
|
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
|
||||||
|
render(Kind, Url) ->
|
||||||
|
{Subject, Heading, Lead, Cta, Foot, Pre} = copy(Kind),
|
||||||
|
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
|
||||||
|
|
||||||
|
%% Booking reminder (Back#70): title + when text in lead.
|
||||||
|
-spec render_reminder(binary(), binary(), binary()) ->
|
||||||
|
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
|
||||||
|
render_reminder(Url, Title, WhenText) ->
|
||||||
|
Subject = <<"CalenTIQ: напоминание о записи"/utf8>>,
|
||||||
|
Heading = <<"Напоминание о записи"/utf8>>,
|
||||||
|
Lead = iolist_to_binary([
|
||||||
|
<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText, <<" (UTC).">>
|
||||||
|
]),
|
||||||
|
Cta = <<"Открыть запись"/utf8>>,
|
||||||
|
Foot = <<"Вы получили письмо, потому что записаны на это событие в CalenTIQ."/utf8>>,
|
||||||
|
Pre = <<"Напоминание о записи в CalenTIQ"/utf8>>,
|
||||||
|
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
|
||||||
|
|
||||||
|
-spec render_waitlist_promoted(binary(), binary()) ->
|
||||||
|
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
|
||||||
|
render_waitlist_promoted(Url, Title) ->
|
||||||
|
Subject = <<"CalenTIQ: место в листе ожидания"/utf8>>,
|
||||||
|
Heading = <<"Для вас освободилось место"/utf8>>,
|
||||||
|
Lead = iolist_to_binary([
|
||||||
|
<<"Вы были в листе ожидания на «"/utf8>>, Title,
|
||||||
|
<<"». Мы записали вас — откройте событие в CalenTIQ."/utf8>>
|
||||||
|
]),
|
||||||
|
Cta = <<"Открыть событие"/utf8>>,
|
||||||
|
Foot = <<"Если запись вам не нужна, отмените её в приложении."/utf8>>,
|
||||||
|
Pre = <<"Место из листа ожидания CalenTIQ"/utf8>>,
|
||||||
|
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
|
||||||
|
|
||||||
|
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre) ->
|
||||||
|
Plain = iolist_to_binary([Heading, <<"\n\n">>, Lead, <<"\n\n">>, Url, <<"\n">>]),
|
||||||
|
Html = fill(layout(), [
|
||||||
|
{<<"{{preheader}}">>, Pre},
|
||||||
|
{<<"{{heading}}">>, Heading},
|
||||||
|
{<<"{{lead}}">>, Lead},
|
||||||
|
{<<"{{cta_url}}">>, Url},
|
||||||
|
{<<"{{cta_label}}">>, Cta},
|
||||||
|
{<<"{{footnote}}">>, Foot}
|
||||||
|
]),
|
||||||
|
{Subject, Plain, Html}.
|
||||||
|
|
||||||
|
copy(verify) ->
|
||||||
|
{<<"CalenTIQ: подтвердите email"/utf8>>,
|
||||||
|
<<"Подтвердите email"/utf8>>,
|
||||||
|
<<"Один календарь. Три режима для вашего времени. Нажмите кнопку, чтобы активировать аккаунт."/utf8>>,
|
||||||
|
<<"Подтвердить email"/utf8>>,
|
||||||
|
<<"Если вы не регистрировались в CalenTIQ, просто проигнорируйте письмо."/utf8>>,
|
||||||
|
<<"Подтвердите email в CalenTIQ"/utf8>>};
|
||||||
|
copy(invite) ->
|
||||||
|
{<<"CalenTIQ: приглашение специалиста"/utf8>>,
|
||||||
|
<<"Вас пригласили как специалиста"/utf8>>,
|
||||||
|
<<"Откройте приглашение, чтобы принять или отклонить его в CalenTIQ."/utf8>>,
|
||||||
|
<<"Открыть приглашение"/utf8>>,
|
||||||
|
<<"Письмо отправлено, потому что владелец календаря указал этот адрес."/utf8>>,
|
||||||
|
<<"Приглашение специалиста в CalenTIQ"/utf8>>};
|
||||||
|
copy(reset) ->
|
||||||
|
{<<"CalenTIQ: сброс пароля"/utf8>>,
|
||||||
|
<<"Сброс пароля"/utf8>>,
|
||||||
|
<<"Если это были вы — задайте новый пароль по кнопке. Ссылка одноразовая."/utf8>>,
|
||||||
|
<<"Сменить пароль"/utf8>>,
|
||||||
|
<<"Если вы не запрашивали сброс, проигнорируйте письмо — пароль не изменится."/utf8>>,
|
||||||
|
<<"Сброс пароля CalenTIQ"/utf8>>}.
|
||||||
|
|
||||||
|
layout() ->
|
||||||
|
case read_priv() of
|
||||||
|
{ok, Bin} -> Bin;
|
||||||
|
{error, _} -> builtin_layout()
|
||||||
|
end.
|
||||||
|
|
||||||
|
read_priv() ->
|
||||||
|
case code:priv_dir(eventhub) of
|
||||||
|
{error, _} = E -> E;
|
||||||
|
Dir ->
|
||||||
|
Path = filename:join([Dir, "email", "layout.html"]),
|
||||||
|
file:read_file(Path)
|
||||||
|
end.
|
||||||
|
|
||||||
|
fill(Html, []) -> Html;
|
||||||
|
fill(Html, [{Needle, Val} | Rest]) ->
|
||||||
|
fill(binary:replace(Html, Needle, Val, [global]), Rest).
|
||||||
|
|
||||||
|
builtin_layout() ->
|
||||||
|
<<"<!DOCTYPE html><html><body style=\"font-family:Arial,sans-serif;color:#324A5F\">"
|
||||||
|
"<p><b>CalenTIQ</b></p><h1>{{heading}}</h1><p>{{lead}}</p>"
|
||||||
|
"<p><a href=\"{{cta_url}}\" style=\"background:#FF6A3D;color:#fff\">{{cta_label}}</a></p>"
|
||||||
|
"<p>{{cta_url}}</p><p>{{footnote}}</p></body></html>">>.
|
||||||
@@ -19,13 +19,16 @@ create_event(UserId, CalendarId, Title, StartTime, Duration, Description) ->
|
|||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
case logic_calendar:can_edit(UserId, Calendar) of
|
case logic_calendar:can_edit(UserId, Calendar) of
|
||||||
true ->
|
true ->
|
||||||
|
case logic_month_archive:assert_writable(StartTime) of
|
||||||
|
{error, archived} -> {error, archived};
|
||||||
|
ok ->
|
||||||
case validate_event_time(StartTime, UserId) of
|
case validate_event_time(StartTime, UserId) of
|
||||||
ok ->
|
ok ->
|
||||||
case logic_automoderation:evaluate_texts([Title, Description]) of
|
case logic_automoderation:evaluate_texts([Title, Description]) of
|
||||||
{reject, Words} ->
|
{reject, Words} ->
|
||||||
{error, {content_banned, Words}};
|
{error, {content_banned, Words}};
|
||||||
{ok, Action, [Title2, Desc2], Words} ->
|
{ok, Action, [Title2, Desc2], Words} ->
|
||||||
case core_event:create(CalendarId, Title2, StartTime, Duration) of
|
case core_event:create(Calendar#calendar.id, Title2, StartTime, Duration) of
|
||||||
{ok, Event} ->
|
{ok, Event} ->
|
||||||
case Desc2 of
|
case Desc2 of
|
||||||
<<>> -> ok;
|
<<>> -> ok;
|
||||||
@@ -39,6 +42,7 @@ create_event(UserId, CalendarId, Title, StartTime, Duration, Description) ->
|
|||||||
end;
|
end;
|
||||||
{error, _} = Error ->
|
{error, _} = Error ->
|
||||||
Error
|
Error
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
false ->
|
false ->
|
||||||
{error, access_denied}
|
{error, access_denied}
|
||||||
@@ -56,6 +60,9 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De
|
|||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
case logic_calendar:can_edit(UserId, Calendar) of
|
case logic_calendar:can_edit(UserId, Calendar) of
|
||||||
true ->
|
true ->
|
||||||
|
case logic_month_archive:assert_writable(StartTime) of
|
||||||
|
{error, archived} -> {error, archived};
|
||||||
|
ok ->
|
||||||
case validate_event_time(StartTime, UserId) of
|
case validate_event_time(StartTime, UserId) of
|
||||||
ok ->
|
ok ->
|
||||||
case logic_recurrence:validate_rrule(RRule) of
|
case logic_recurrence:validate_rrule(RRule) of
|
||||||
@@ -64,7 +71,7 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De
|
|||||||
{reject, Words} ->
|
{reject, Words} ->
|
||||||
{error, {content_banned, Words}};
|
{error, {content_banned, Words}};
|
||||||
{ok, Action, [Title2, Desc2], Words} ->
|
{ok, Action, [Title2, Desc2], Words} ->
|
||||||
case core_event:create_recurring(CalendarId, Title2, StartTime, Duration, RRule) of
|
case core_event:create_recurring(Calendar#calendar.id, Title2, StartTime, Duration, RRule) of
|
||||||
{ok, Event} ->
|
{ok, Event} ->
|
||||||
case Desc2 of
|
case Desc2 of
|
||||||
<<>> -> ok;
|
<<>> -> ok;
|
||||||
@@ -81,6 +88,7 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, De
|
|||||||
end;
|
end;
|
||||||
{error, _} = Error ->
|
{error, _} = Error ->
|
||||||
Error
|
Error
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
false ->
|
false ->
|
||||||
{error, access_denied}
|
{error, access_denied}
|
||||||
@@ -184,8 +192,8 @@ get_event(UserId, EventId) ->
|
|||||||
%% Список событий календаря
|
%% Список событий календаря
|
||||||
list_events(UserId, CalendarId) ->
|
list_events(UserId, CalendarId) ->
|
||||||
case logic_calendar:get_calendar(UserId, CalendarId) of
|
case logic_calendar:get_calendar(UserId, CalendarId) of
|
||||||
{ok, _} ->
|
{ok, Calendar} ->
|
||||||
core_event:list_by_calendar(CalendarId);
|
core_event:list_by_calendar(Calendar#calendar.id);
|
||||||
Error ->
|
Error ->
|
||||||
Error
|
Error
|
||||||
end.
|
end.
|
||||||
@@ -202,6 +210,13 @@ update_event(UserId, EventId, Updates) ->
|
|||||||
{error, _} = E ->
|
{error, _} = E ->
|
||||||
E;
|
E;
|
||||||
ok ->
|
ok ->
|
||||||
|
NewStart = proplists:get_value(start_time, Updates, Event#event.start_time),
|
||||||
|
case logic_month_archive:assert_writable(Event#event.start_time) of
|
||||||
|
{error, archived} -> {error, archived};
|
||||||
|
ok ->
|
||||||
|
case logic_month_archive:assert_writable(NewStart) of
|
||||||
|
{error, archived} -> {error, archived};
|
||||||
|
ok ->
|
||||||
ValidUpdates = validate_updates(Updates, UserId),
|
ValidUpdates = validate_updates(Updates, UserId),
|
||||||
Title = proplists:get_value(title, ValidUpdates, Event#event.title),
|
Title = proplists:get_value(title, ValidUpdates, Event#event.title),
|
||||||
Desc = proplists:get_value(description, ValidUpdates, Event#event.description),
|
Desc = proplists:get_value(description, ValidUpdates, Event#event.description),
|
||||||
@@ -231,6 +246,8 @@ update_event(UserId, EventId, Updates) ->
|
|||||||
Error
|
Error
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
false ->
|
false ->
|
||||||
{error, access_denied}
|
{error, access_denied}
|
||||||
@@ -250,7 +267,10 @@ delete_event(UserId, EventId) ->
|
|||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
case logic_calendar:can_edit(UserId, Calendar) of
|
case logic_calendar:can_edit(UserId, Calendar) of
|
||||||
true ->
|
true ->
|
||||||
core_event:delete(EventId);
|
case logic_month_archive:assert_writable(Event#event.start_time) of
|
||||||
|
{error, archived} -> {error, archived};
|
||||||
|
ok -> core_event:delete(EventId)
|
||||||
|
end;
|
||||||
false ->
|
false ->
|
||||||
{error, access_denied}
|
{error, access_denied}
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Прокси к OpenCage API: suggest / geocode / reverse.
|
||||||
|
%%% Кэш и rate-limit в ETS. Без OPENCAGE_API_KEY — {error, unavailable}.
|
||||||
|
%%% Глобальный троттлинг апстрима (free-тариф: 1 rps, 2500/сутки).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(logic_geo).
|
||||||
|
-export([suggest/3, geocode/3, reverse/4, ensure/0]).
|
||||||
|
-export([format_address/1]).
|
||||||
|
|
||||||
|
-define(CACHE, eventhub_geo_cache).
|
||||||
|
-define(RL, eventhub_geo_rl).
|
||||||
|
-define(UP, eventhub_geo_upstream).
|
||||||
|
-define(MIN_Q, 3).
|
||||||
|
-define(CACHE_TTL_SEC, 604800).
|
||||||
|
-define(RL_WINDOW_SEC, 60).
|
||||||
|
-define(RL_SUGGEST, 40).
|
||||||
|
-define(RL_WRITE, 20).
|
||||||
|
-define(DEFAULT_URL, <<"https://api.opencagedata.com/geocode/v1/json">>).
|
||||||
|
|
||||||
|
-spec ensure() -> ok.
|
||||||
|
ensure() ->
|
||||||
|
case ets:info(?CACHE) of
|
||||||
|
undefined ->
|
||||||
|
ets:new(?CACHE, [named_table, public, set, {read_concurrency, true}]);
|
||||||
|
_ -> ok
|
||||||
|
end,
|
||||||
|
case ets:info(?RL) of
|
||||||
|
undefined ->
|
||||||
|
ets:new(?RL, [named_table, public, set, {write_concurrency, true}]);
|
||||||
|
_ -> ok
|
||||||
|
end,
|
||||||
|
case ets:info(?UP) of
|
||||||
|
undefined ->
|
||||||
|
ets:new(?UP, [named_table, public, set]);
|
||||||
|
_ -> ok
|
||||||
|
end,
|
||||||
|
ok.
|
||||||
|
|
||||||
|
-spec suggest(binary(), binary(), binary()) ->
|
||||||
|
{ok, [map()]} | {error, invalid_query | rate_limited | unavailable}.
|
||||||
|
suggest(Q, Lang, RlKey) ->
|
||||||
|
ensure(),
|
||||||
|
case normalize_q(Q) of
|
||||||
|
{error, invalid_query} = E -> E;
|
||||||
|
{ok, Qn} ->
|
||||||
|
case allow(RlKey, ?RL_SUGGEST) of
|
||||||
|
false -> {error, rate_limited};
|
||||||
|
true ->
|
||||||
|
LangN = lang(Lang),
|
||||||
|
CacheKey = {suggest, Qn, LangN},
|
||||||
|
case cache_get(CacheKey) of
|
||||||
|
{ok, Hits} -> {ok, Hits};
|
||||||
|
miss ->
|
||||||
|
case opencage_get(forward_q(Qn, LangN, <<"8">>)) of
|
||||||
|
{ok, Body} ->
|
||||||
|
Hits = results_to_hits(Body),
|
||||||
|
cache_put(CacheKey, Hits),
|
||||||
|
{ok, Hits};
|
||||||
|
{error, rate_limited} -> {error, rate_limited};
|
||||||
|
{error, _} -> {error, unavailable}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec geocode(binary(), binary(), binary()) ->
|
||||||
|
{ok, map()} | {error, invalid_query | not_found | rate_limited | unavailable}.
|
||||||
|
geocode(Q, Lang, RlKey) ->
|
||||||
|
ensure(),
|
||||||
|
case normalize_q(Q) of
|
||||||
|
{error, invalid_query} = E -> E;
|
||||||
|
{ok, Qn} ->
|
||||||
|
case allow(RlKey, ?RL_WRITE) of
|
||||||
|
false -> {error, rate_limited};
|
||||||
|
true ->
|
||||||
|
LangN = lang(Lang),
|
||||||
|
CacheKey = {geocode, Qn, LangN},
|
||||||
|
case cache_get(CacheKey) of
|
||||||
|
{ok, Hit} -> {ok, Hit};
|
||||||
|
miss ->
|
||||||
|
case opencage_get(forward_q(Qn, LangN, <<"1">>)) of
|
||||||
|
{ok, Body} ->
|
||||||
|
case results_to_hits(Body) of
|
||||||
|
[Hit | _] ->
|
||||||
|
Out = Hit#{<<"source">> => <<"opencage">>},
|
||||||
|
cache_put(CacheKey, Out),
|
||||||
|
{ok, Out};
|
||||||
|
[] -> {error, not_found}
|
||||||
|
end;
|
||||||
|
{error, rate_limited} -> {error, rate_limited};
|
||||||
|
{error, _} -> {error, unavailable}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec reverse(number(), number(), binary(), binary()) ->
|
||||||
|
{ok, map()} | {error, invalid_query | rate_limited | unavailable}.
|
||||||
|
reverse(Lat, Lon, Lang, RlKey) when is_number(Lat), is_number(Lon) ->
|
||||||
|
ensure(),
|
||||||
|
case {valid_lat(Lat), valid_lon(Lon)} of
|
||||||
|
{true, true} ->
|
||||||
|
case allow(RlKey, ?RL_WRITE) of
|
||||||
|
false -> {error, rate_limited};
|
||||||
|
true ->
|
||||||
|
LangN = lang(Lang),
|
||||||
|
LatR = round_coord(Lat),
|
||||||
|
LonR = round_coord(Lon),
|
||||||
|
CacheKey = {reverse, LatR, LonR, LangN},
|
||||||
|
case cache_get(CacheKey) of
|
||||||
|
{ok, Hit} -> {ok, Hit};
|
||||||
|
miss ->
|
||||||
|
LatB = float_to_bin(LatR),
|
||||||
|
LonB = float_to_bin(LonR),
|
||||||
|
Qs = [{<<"q">>, <<LatB/binary, ",", LonB/binary>>},
|
||||||
|
{<<"language">>, LangN}],
|
||||||
|
case opencage_get(Qs) of
|
||||||
|
{ok, Body} ->
|
||||||
|
Hit = case results_to_hits(Body) of
|
||||||
|
[H | _] -> H#{<<"source">> => <<"opencage">>};
|
||||||
|
[] ->
|
||||||
|
#{<<"address">> => fallback_addr(LatR, LonR),
|
||||||
|
<<"lat">> => LatR,
|
||||||
|
<<"lon">> => LonR,
|
||||||
|
<<"source">> => <<"opencage">>}
|
||||||
|
end,
|
||||||
|
cache_put(CacheKey, Hit),
|
||||||
|
{ok, Hit};
|
||||||
|
{error, rate_limited} -> {error, rate_limited};
|
||||||
|
{error, _} -> {error, unavailable}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end;
|
||||||
|
_ -> {error, invalid_query}
|
||||||
|
end;
|
||||||
|
reverse(_, _, _, _) ->
|
||||||
|
{error, invalid_query}.
|
||||||
|
|
||||||
|
%% ——— internals ———
|
||||||
|
|
||||||
|
normalize_q(Q) when is_binary(Q) ->
|
||||||
|
T = string:trim(Q),
|
||||||
|
case string:length(T) >= ?MIN_Q of
|
||||||
|
true -> {ok, T};
|
||||||
|
false -> {error, invalid_query}
|
||||||
|
end;
|
||||||
|
normalize_q(_) ->
|
||||||
|
{error, invalid_query}.
|
||||||
|
|
||||||
|
lang(<<>>) -> <<"ru">>;
|
||||||
|
lang(undefined) -> <<"ru">>;
|
||||||
|
lang(L) when is_binary(L), byte_size(L) >= 2 -> binary:part(L, 0, 2);
|
||||||
|
lang(_) -> <<"ru">>.
|
||||||
|
|
||||||
|
valid_lat(Lat) -> is_number(Lat) andalso Lat >= -90 andalso Lat =< 90.
|
||||||
|
valid_lon(Lon) -> is_number(Lon) andalso Lon >= -180 andalso Lon =< 180.
|
||||||
|
|
||||||
|
round_coord(N) when is_integer(N) -> float(N);
|
||||||
|
round_coord(N) when is_float(N) -> round(N * 100000) / 100000.
|
||||||
|
|
||||||
|
float_to_bin(N) when is_integer(N) -> integer_to_binary(N);
|
||||||
|
float_to_bin(N) when is_float(N) ->
|
||||||
|
iolist_to_binary(io_lib:format("~.6f", [N])).
|
||||||
|
|
||||||
|
fallback_addr(Lat, Lon) ->
|
||||||
|
iolist_to_binary(io_lib:format("~.5f, ~.5f", [float(Lat), float(Lon)])).
|
||||||
|
|
||||||
|
allow(Key, Limit) ->
|
||||||
|
Now = erlang:monotonic_time(second),
|
||||||
|
case ets:lookup(?RL, Key) of
|
||||||
|
[{Key, Count, Start}] when Now - Start < ?RL_WINDOW_SEC ->
|
||||||
|
case Count < Limit of
|
||||||
|
true ->
|
||||||
|
ets:update_counter(?RL, Key, {2, 1}),
|
||||||
|
true;
|
||||||
|
false -> false
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
ets:insert(?RL, {Key, 1, Now}),
|
||||||
|
true
|
||||||
|
end.
|
||||||
|
|
||||||
|
cache_get(Key) ->
|
||||||
|
Now = erlang:system_time(second),
|
||||||
|
case ets:lookup(?CACHE, Key) of
|
||||||
|
[{Key, Exp, Val}] when Exp > Now -> {ok, Val};
|
||||||
|
[_] -> ets:delete(?CACHE, Key), miss;
|
||||||
|
[] -> miss
|
||||||
|
end.
|
||||||
|
|
||||||
|
cache_put(Key, Val) ->
|
||||||
|
Exp = erlang:system_time(second) + ?CACHE_TTL_SEC,
|
||||||
|
ets:insert(?CACHE, {Key, Exp, Val}),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
%% Forward-запрос: q + language + limit (+ countrycode, если задан).
|
||||||
|
forward_q(Q, LangN, Limit) ->
|
||||||
|
Qs = [{<<"q">>, Q}, {<<"language">>, LangN}, {<<"limit">>, Limit}],
|
||||||
|
case countrycode() of
|
||||||
|
<<>> -> Qs;
|
||||||
|
CC -> [{<<"countrycode">>, CC} | Qs]
|
||||||
|
end.
|
||||||
|
|
||||||
|
api_key() ->
|
||||||
|
case os:getenv("OPENCAGE_API_KEY") of
|
||||||
|
false -> <<>>;
|
||||||
|
"" -> <<>>;
|
||||||
|
K -> list_to_binary(string:trim(K))
|
||||||
|
end.
|
||||||
|
|
||||||
|
base_url() ->
|
||||||
|
case os:getenv("OPENCAGE_URL") of
|
||||||
|
false -> ?DEFAULT_URL;
|
||||||
|
"" -> ?DEFAULT_URL;
|
||||||
|
Url -> list_to_binary(string:trim(Url))
|
||||||
|
end.
|
||||||
|
|
||||||
|
countrycode() ->
|
||||||
|
case os:getenv("OPENCAGE_COUNTRYCODE") of
|
||||||
|
false -> <<>>;
|
||||||
|
"" -> <<>>;
|
||||||
|
CC -> list_to_binary(string:trim(CC))
|
||||||
|
end.
|
||||||
|
|
||||||
|
timeout_ms() ->
|
||||||
|
case os:getenv("OPENCAGE_TIMEOUT_MS") of
|
||||||
|
false -> 3000;
|
||||||
|
"" -> 3000;
|
||||||
|
S ->
|
||||||
|
try list_to_integer(S) of
|
||||||
|
N when N > 0 -> N;
|
||||||
|
_ -> 3000
|
||||||
|
catch
|
||||||
|
_:_ -> 3000
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% 0 — троттлинг выключен (платный тариф); по умолчанию 1 rps (free).
|
||||||
|
throttle_rps() ->
|
||||||
|
case os:getenv("OPENCAGE_THROTTLE_RPS") of
|
||||||
|
false -> 1;
|
||||||
|
"" -> 1;
|
||||||
|
S ->
|
||||||
|
try list_to_integer(S) of
|
||||||
|
N when N >= 0 -> N;
|
||||||
|
_ -> 1
|
||||||
|
catch
|
||||||
|
_:_ -> 1
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
throttle_allow() ->
|
||||||
|
case throttle_rps() of
|
||||||
|
0 -> true;
|
||||||
|
Rps when Rps > 0 ->
|
||||||
|
NowMs = erlang:monotonic_time(millisecond),
|
||||||
|
MinGap = 1000 div Rps,
|
||||||
|
case ets:lookup(?UP, last) of
|
||||||
|
[{last, Prev}] when NowMs - Prev < MinGap -> false;
|
||||||
|
_ ->
|
||||||
|
ets:insert(?UP, {last, NowMs}),
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
opencage_get(Qs) ->
|
||||||
|
case api_key() of
|
||||||
|
<<>> -> {error, unavailable};
|
||||||
|
Key ->
|
||||||
|
case throttle_allow() of
|
||||||
|
false -> {error, rate_limited};
|
||||||
|
true ->
|
||||||
|
Full = [{<<"key">>, Key}, {<<"no_annotations">>, <<"1">>} | Qs],
|
||||||
|
Query = uri_string:compose_query(Full),
|
||||||
|
Url = binary_to_list(<<(strip_slash(base_url()))/binary,
|
||||||
|
$?, Query/binary>>),
|
||||||
|
http_get(Url)
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
strip_slash(B) ->
|
||||||
|
case binary:last(B) of
|
||||||
|
$/ -> binary:part(B, 0, byte_size(B) - 1);
|
||||||
|
_ -> B
|
||||||
|
end.
|
||||||
|
|
||||||
|
http_get(Url) ->
|
||||||
|
Fun = case application:get_env(eventhub, geo_http_get) of
|
||||||
|
{ok, F} when is_function(F, 1) -> F;
|
||||||
|
_ -> fun default_http_get/1
|
||||||
|
end,
|
||||||
|
Fun(Url).
|
||||||
|
|
||||||
|
default_http_get(Url) ->
|
||||||
|
_ = application:ensure_all_started(inets),
|
||||||
|
Headers = [{"user-agent", "EventHub/1.0 (geo; +https://calentiq.com)"}],
|
||||||
|
HttpOpts = [{timeout, timeout_ms()}],
|
||||||
|
case httpc:request(get, {Url, Headers}, HttpOpts, [{body_format, binary}]) of
|
||||||
|
{ok, {{_, Code, _}, _, Body}} when Code >= 200, Code < 300, is_binary(Body) ->
|
||||||
|
{ok, Body};
|
||||||
|
{ok, {{_, Code, _}, _, _}} ->
|
||||||
|
{error, {http_status, Code}};
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
results_to_hits(Body) when is_binary(Body) ->
|
||||||
|
try jsx:decode(Body, [return_maps]) of
|
||||||
|
#{<<"results">> := Results} when is_list(Results) ->
|
||||||
|
lists:filtermap(fun result_to_hit/1, Results);
|
||||||
|
_ -> []
|
||||||
|
catch
|
||||||
|
_:_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
result_to_hit(#{<<"geometry">> := #{<<"lat">> := Lat, <<"lng">> := Lng}} = R) ->
|
||||||
|
{true, #{
|
||||||
|
<<"address">> => address_of(R),
|
||||||
|
<<"lat">> => to_num(Lat),
|
||||||
|
<<"lon">> => to_num(Lng)
|
||||||
|
}};
|
||||||
|
result_to_hit(_) ->
|
||||||
|
false.
|
||||||
|
|
||||||
|
address_of(R) ->
|
||||||
|
case bin(maps:get(<<"formatted">>, R, <<>>)) of
|
||||||
|
<<>> ->
|
||||||
|
C = maps:get(<<"components">>, R, #{}),
|
||||||
|
Name = bin(maps:get(<<"name">>, C, <<>>)),
|
||||||
|
House = bin(maps:get(<<"house_number">>, C, <<>>)),
|
||||||
|
Road = bin(maps:get(<<"road">>, C, <<>>)),
|
||||||
|
StreetLine = case {House, Road} of
|
||||||
|
{<<>>, Rd} -> Rd;
|
||||||
|
{H, <<>>} -> H;
|
||||||
|
{H, Rd} -> <<H/binary, " ", Rd/binary>>
|
||||||
|
end,
|
||||||
|
City = first_nonempty([
|
||||||
|
maps:get(<<"city">>, C, <<>>),
|
||||||
|
maps:get(<<"town">>, C, <<>>),
|
||||||
|
maps:get(<<"village">>, C, <<>>)
|
||||||
|
]),
|
||||||
|
Country = bin(maps:get(<<"country">>, C, <<>>)),
|
||||||
|
Parts = [P || P <- [Name, StreetLine, City, Country], P =/= <<>>],
|
||||||
|
case unique_keep(Parts) of
|
||||||
|
[] -> <<"—">>;
|
||||||
|
Ps -> join_comma(Ps)
|
||||||
|
end;
|
||||||
|
Addr -> Addr
|
||||||
|
end.
|
||||||
|
|
||||||
|
to_num(N) when is_integer(N) -> float(N);
|
||||||
|
to_num(N) when is_float(N) -> N;
|
||||||
|
to_num(_) -> 0.0.
|
||||||
|
|
||||||
|
-spec format_address(map()) -> binary().
|
||||||
|
format_address(Props) when is_map(Props) ->
|
||||||
|
Name = bin(maps:get(<<"name">>, Props, <<>>)),
|
||||||
|
House = bin(maps:get(<<"housenumber">>, Props, <<>>)),
|
||||||
|
Street = bin(maps:get(<<"street">>, Props, <<>>)),
|
||||||
|
StreetLine = case {House, Street} of
|
||||||
|
{<<>>, S} -> S;
|
||||||
|
{H, <<>>} -> H;
|
||||||
|
{H, S} -> <<H/binary, " ", S/binary>>
|
||||||
|
end,
|
||||||
|
City = first_nonempty([
|
||||||
|
maps:get(<<"city">>, Props, <<>>),
|
||||||
|
maps:get(<<"town">>, Props, <<>>),
|
||||||
|
maps:get(<<"village">>, Props, <<>>),
|
||||||
|
maps:get(<<"locality">>, Props, <<>>)
|
||||||
|
]),
|
||||||
|
Country = bin(maps:get(<<"country">>, Props, <<>>)),
|
||||||
|
Parts0 = [Name, StreetLine, City, Country],
|
||||||
|
Parts = [P || P <- Parts0, P =/= <<>>],
|
||||||
|
Unique = unique_keep(Parts),
|
||||||
|
case Unique of
|
||||||
|
[] -> <<"—">>;
|
||||||
|
_ -> join_comma(Unique)
|
||||||
|
end.
|
||||||
|
|
||||||
|
bin(B) when is_binary(B) -> string:trim(B);
|
||||||
|
bin(I) when is_integer(I) -> integer_to_binary(I);
|
||||||
|
bin(_) -> <<>>.
|
||||||
|
|
||||||
|
first_nonempty([]) -> <<>>;
|
||||||
|
first_nonempty([H | T]) ->
|
||||||
|
B = bin(H),
|
||||||
|
case B of
|
||||||
|
<<>> -> first_nonempty(T);
|
||||||
|
_ -> B
|
||||||
|
end.
|
||||||
|
|
||||||
|
unique_keep(List) ->
|
||||||
|
lists:reverse(lists:foldl(fun(X, Acc) ->
|
||||||
|
case lists:member(X, Acc) of
|
||||||
|
true -> Acc;
|
||||||
|
false -> [X | Acc]
|
||||||
|
end
|
||||||
|
end, [], List)).
|
||||||
|
|
||||||
|
join_comma([H]) -> H;
|
||||||
|
join_comma([H | T]) ->
|
||||||
|
Rest = join_comma(T),
|
||||||
|
<<H/binary, ", ", Rest/binary>>.
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% Calendar month snapshots (Back#76). Same node, no extra BEAM.
|
||||||
|
%%% Bookings stay in hot (inbox). Events of closed months move to snapshot/file.
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(logic_month_archive).
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-export([
|
||||||
|
grace_days/0,
|
||||||
|
warm_months/0,
|
||||||
|
month_key/2,
|
||||||
|
month_is_closed/1,
|
||||||
|
month_is_closed/2,
|
||||||
|
assert_writable/1,
|
||||||
|
events_json_in_range/3,
|
||||||
|
snapshot_month/3,
|
||||||
|
run_once/0,
|
||||||
|
run_once/1
|
||||||
|
]).
|
||||||
|
|
||||||
|
-define(DEFAULT_GRACE, 7).
|
||||||
|
-define(DEFAULT_WARM, 3).
|
||||||
|
|
||||||
|
grace_days() -> env_int("ARCHIVE_GRACE_DAYS", ?DEFAULT_GRACE).
|
||||||
|
warm_months() -> env_int("ARCHIVE_WARM_MONTHS", ?DEFAULT_WARM).
|
||||||
|
|
||||||
|
-spec month_key(integer(), integer()) -> binary().
|
||||||
|
month_key(Year, Month) ->
|
||||||
|
iolist_to_binary(io_lib:format("~4..0B-~2..0B", [Year, Month])).
|
||||||
|
|
||||||
|
-spec month_is_closed(calendar:datetime()) -> boolean().
|
||||||
|
month_is_closed({{Y, M, _}, _}) -> month_is_closed(Y, M).
|
||||||
|
|
||||||
|
-spec month_is_closed(integer(), integer()) -> boolean().
|
||||||
|
month_is_closed(Year, Month) ->
|
||||||
|
{{CY, CM, CD}, _} = calendar:universal_time(),
|
||||||
|
{NY, NM} = next_month(Year, Month),
|
||||||
|
GraceEnd = add_days({NY, NM, 1}, grace_days()),
|
||||||
|
{CY, CM, CD} >= GraceEnd.
|
||||||
|
|
||||||
|
-spec assert_writable(calendar:datetime()) -> ok | {error, archived}.
|
||||||
|
assert_writable(StartTime) ->
|
||||||
|
case month_is_closed(StartTime) of
|
||||||
|
true -> {error, archived};
|
||||||
|
false -> ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Snapshot JSON maps overlapping [From, To] (inclusive). Missing snapshot → [].
|
||||||
|
-spec events_json_in_range(binary(), calendar:datetime(), calendar:datetime()) -> [map()].
|
||||||
|
events_json_in_range(CalendarId, From, To) ->
|
||||||
|
Months = months_covering(From, To),
|
||||||
|
lists:flatmap(
|
||||||
|
fun({Y, M}) ->
|
||||||
|
case month_is_closed(Y, M) of
|
||||||
|
false -> [];
|
||||||
|
true ->
|
||||||
|
lists:filter(
|
||||||
|
fun(Map) -> json_in_range(Map, From, To) end,
|
||||||
|
load_events(CalendarId, month_key(Y, M)))
|
||||||
|
end
|
||||||
|
end, Months).
|
||||||
|
|
||||||
|
-spec snapshot_month(binary(), integer(), integer()) -> ok | {error, term()}.
|
||||||
|
snapshot_month(CalendarId, Year, Month) ->
|
||||||
|
Key = month_key(Year, Month),
|
||||||
|
Id = {CalendarId, Key},
|
||||||
|
case mnesia:dirty_read(month_snapshot, Id) of
|
||||||
|
[#month_snapshot{}] ->
|
||||||
|
delete_hot_for_month(CalendarId, Year, Month),
|
||||||
|
maybe_spill(CalendarId, Key),
|
||||||
|
ok;
|
||||||
|
[] ->
|
||||||
|
Events = hot_events_in_month(CalendarId, Year, Month),
|
||||||
|
Payload = encode_events(Events),
|
||||||
|
Rec = #month_snapshot{
|
||||||
|
id = Id,
|
||||||
|
calendar_id = CalendarId,
|
||||||
|
year_month = Key,
|
||||||
|
payload = Payload,
|
||||||
|
storage = mnesia,
|
||||||
|
file_rel = <<>>,
|
||||||
|
created_at = calendar:universal_time()
|
||||||
|
},
|
||||||
|
WriteDel = fun() ->
|
||||||
|
ok = mnesia:write(Rec),
|
||||||
|
lists:foreach(fun(E) -> mnesia:delete({event, E#event.id}) end,
|
||||||
|
deletable_events(Events)),
|
||||||
|
ok
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(WriteDel) of
|
||||||
|
{atomic, ok} ->
|
||||||
|
maybe_spill(CalendarId, Key),
|
||||||
|
ok;
|
||||||
|
{aborted, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec run_once() -> non_neg_integer().
|
||||||
|
run_once() ->
|
||||||
|
run_once(calendar:universal_time()).
|
||||||
|
|
||||||
|
-spec run_once(calendar:datetime()) -> non_neg_integer().
|
||||||
|
run_once(_Now) ->
|
||||||
|
Cals = mnesia:dirty_all_keys(calendar),
|
||||||
|
lists:foldl(fun(CalId, Acc) -> Acc + snapshot_due(CalId) end, 0, Cals).
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
snapshot_due(CalendarId) ->
|
||||||
|
Events = case catch mnesia:dirty_index_read(event, CalendarId, #event.calendar_id) of
|
||||||
|
List when is_list(List) -> List;
|
||||||
|
_ -> []
|
||||||
|
end,
|
||||||
|
Months = lists:usort([ym(E#event.start_time) || E <- Events]),
|
||||||
|
lists:foldl(
|
||||||
|
fun({Y, M}, N) ->
|
||||||
|
case month_is_closed(Y, M) of
|
||||||
|
true ->
|
||||||
|
case snapshot_month(CalendarId, Y, M) of
|
||||||
|
ok -> N + 1;
|
||||||
|
_ -> N
|
||||||
|
end;
|
||||||
|
false -> N
|
||||||
|
end
|
||||||
|
end, 0, Months).
|
||||||
|
|
||||||
|
ym({{Y, M, _}, _}) -> {Y, M};
|
||||||
|
ym(_) -> {1970, 1}.
|
||||||
|
|
||||||
|
hot_events_in_month(CalendarId, Year, Month) ->
|
||||||
|
{Start, End} = month_bounds(Year, Month),
|
||||||
|
case mnesia:dirty_index_read(event, CalendarId, #event.calendar_id) of
|
||||||
|
List when is_list(List) ->
|
||||||
|
[E || E <- List, E#event.start_time >= Start, E#event.start_time =< End];
|
||||||
|
_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
deletable_events(Events) ->
|
||||||
|
[E || E <- Events, not keep_in_hot(E)].
|
||||||
|
|
||||||
|
delete_hot_for_month(CalendarId, Year, Month) ->
|
||||||
|
Events = deletable_events(hot_events_in_month(CalendarId, Year, Month)),
|
||||||
|
lists:foreach(fun(E) -> mnesia:dirty_delete({event, E#event.id}) end, Events).
|
||||||
|
|
||||||
|
keep_in_hot(#event{event_type = recurring, is_instance = false}) -> true;
|
||||||
|
keep_in_hot(_) -> false.
|
||||||
|
|
||||||
|
encode_events(Events) ->
|
||||||
|
Maps = [handler_utils:event_to_json(E) || E <- Events, not keep_in_hot(E)],
|
||||||
|
zlib:gzip(jsx:encode(#{<<"v">> => 1, <<"events">> => Maps})).
|
||||||
|
|
||||||
|
load_events(CalendarId, YearMonth) ->
|
||||||
|
Id = {CalendarId, YearMonth},
|
||||||
|
case mnesia:dirty_read(month_snapshot, Id) of
|
||||||
|
[#month_snapshot{storage = file, file_rel = Rel}] when Rel =/= <<>> ->
|
||||||
|
decode_file(Rel);
|
||||||
|
[#month_snapshot{payload = Bin}] when is_binary(Bin), Bin =/= <<>> ->
|
||||||
|
decode_bin(Bin);
|
||||||
|
_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
decode_file(Rel) ->
|
||||||
|
Path = filename:join(logic_upload:upload_dir(), binary_to_list(Rel)),
|
||||||
|
case file:read_file(Path) of
|
||||||
|
{ok, Bin} -> decode_bin(Bin);
|
||||||
|
_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
decode_bin(Bin) ->
|
||||||
|
try jsx:decode(zlib:gunzip(Bin), [return_maps]) of
|
||||||
|
#{<<"events">> := List} when is_list(List) -> List;
|
||||||
|
_ -> []
|
||||||
|
catch
|
||||||
|
_:_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
maybe_spill(CalendarId, YearMonth) ->
|
||||||
|
Warm = warm_months(),
|
||||||
|
Id = {CalendarId, YearMonth},
|
||||||
|
case mnesia:dirty_index_read(month_snapshot, CalendarId, #month_snapshot.calendar_id) of
|
||||||
|
List when is_list(List), length(List) > Warm ->
|
||||||
|
Sorted = lists:sort(
|
||||||
|
fun(#month_snapshot{year_month = A}, #month_snapshot{year_month = B}) ->
|
||||||
|
A =< B
|
||||||
|
end, List),
|
||||||
|
{Old, Keep} = lists:split(length(Sorted) - Warm, Sorted),
|
||||||
|
_ = Keep,
|
||||||
|
lists:foreach(fun(Rec) -> spill_one(Rec) end, Old),
|
||||||
|
ok;
|
||||||
|
_ ->
|
||||||
|
_ = Id,
|
||||||
|
ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
spill_one(#month_snapshot{storage = file}) -> ok;
|
||||||
|
spill_one(#month_snapshot{id = Id, calendar_id = Cal, year_month = YM,
|
||||||
|
payload = Payload} = Rec) ->
|
||||||
|
Rel = filename:join(["archive", binary_to_list(Cal), binary_to_list(YM) ++ ".json.gz"]),
|
||||||
|
Abs = filename:join(logic_upload:upload_dir(), Rel),
|
||||||
|
ok = filelib:ensure_dir(Abs),
|
||||||
|
case file:write_file(Abs, Payload) of
|
||||||
|
ok ->
|
||||||
|
mnesia:dirty_write(Rec#month_snapshot{
|
||||||
|
payload = <<>>,
|
||||||
|
storage = file,
|
||||||
|
file_rel = list_to_binary(Rel)
|
||||||
|
}),
|
||||||
|
ok;
|
||||||
|
_ ->
|
||||||
|
_ = Id,
|
||||||
|
ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
json_in_range(Map, From, To) ->
|
||||||
|
case maps:get(<<"start_time">>, Map, maps:get(start_time, Map, undefined)) of
|
||||||
|
undefined -> false;
|
||||||
|
Iso when is_binary(Iso) ->
|
||||||
|
case handler_utils:parse_datetime(Iso) of
|
||||||
|
{ok, Dt} -> Dt >= From andalso Dt =< To;
|
||||||
|
_ -> false
|
||||||
|
end;
|
||||||
|
_ -> false
|
||||||
|
end.
|
||||||
|
|
||||||
|
months_covering({{Y1, M1, _}, _}, {{Y2, M2, _}, _}) ->
|
||||||
|
months_covering1({Y1, M1}, {Y2, M2}, []).
|
||||||
|
|
||||||
|
months_covering1(Cur, End, Acc) when Cur > End -> lists:reverse(Acc);
|
||||||
|
months_covering1({Y, M} = Cur, End, Acc) ->
|
||||||
|
months_covering1(next_month(Y, M), End, [Cur | Acc]).
|
||||||
|
|
||||||
|
next_month(Y, 12) -> {Y + 1, 1};
|
||||||
|
next_month(Y, M) -> {Y, M + 1}.
|
||||||
|
|
||||||
|
month_bounds(Year, Month) ->
|
||||||
|
Last = calendar:last_day_of_the_month(Year, Month),
|
||||||
|
{{{Year, Month, 1}, {0, 0, 0}}, {{Year, Month, Last}, {23, 59, 59}}}.
|
||||||
|
|
||||||
|
add_days({Y, M, D}, Days) ->
|
||||||
|
Greg = calendar:date_to_gregorian_days(Y, M, D) + Days,
|
||||||
|
calendar:gregorian_days_to_date(Greg).
|
||||||
|
|
||||||
|
env_int(Name, Default) ->
|
||||||
|
case os:getenv(Name) of
|
||||||
|
false -> Default;
|
||||||
|
"" -> Default;
|
||||||
|
S ->
|
||||||
|
try list_to_integer(S) of
|
||||||
|
N when N > 0 -> N;
|
||||||
|
_ -> Default
|
||||||
|
catch
|
||||||
|
_:_ -> Default
|
||||||
|
end
|
||||||
|
end.
|
||||||
@@ -5,7 +5,9 @@
|
|||||||
-export([notify_calendar_update/1]).
|
-export([notify_calendar_update/1]).
|
||||||
-export([notify_event_update/1]).
|
-export([notify_event_update/1]).
|
||||||
-export([notify_admin/2]).
|
-export([notify_admin/2]).
|
||||||
-export([notify_specialist_invite/2]).
|
-export([notify_specialist_invite/2, notify_calendar_share_invite/2]).
|
||||||
|
-export([notify_event_reminder/2]).
|
||||||
|
-export([notify_waitlist_promoted/2]).
|
||||||
|
|
||||||
%% Уведомление о бронировании
|
%% Уведомление о бронировании
|
||||||
notify_booking(UserId, Booking) ->
|
notify_booking(UserId, Booking) ->
|
||||||
@@ -45,6 +47,29 @@ notify_specialist_invite(UserId, Invite) ->
|
|||||||
},
|
},
|
||||||
broadcast_to_user(UserId, specialist_invite, Data).
|
broadcast_to_user(UserId, specialist_invite, Data).
|
||||||
|
|
||||||
|
%% In-app / WS: приглашение calendar_share
|
||||||
|
notify_calendar_share_invite(UserId, Invite) ->
|
||||||
|
Data = #{
|
||||||
|
invite_id => Invite#calendar_share_invite.id,
|
||||||
|
calendar_id => Invite#calendar_share_invite.calendar_id,
|
||||||
|
rights => Invite#calendar_share_invite.rights,
|
||||||
|
status => Invite#calendar_share_invite.status
|
||||||
|
},
|
||||||
|
broadcast_to_user(UserId, calendar_share_invite, Data).
|
||||||
|
|
||||||
|
%% In-app / WS: напоминание о записи (Back#70)
|
||||||
|
notify_event_reminder(UserId, #{title := Title, when_text := WhenText} = Data) ->
|
||||||
|
NTitle = <<"Напоминание о записи"/utf8>>,
|
||||||
|
Body = iolist_to_binary([<<"Скоро: «"/utf8>>, Title, <<"» — "/utf8>>, WhenText, <<" (UTC)">>]),
|
||||||
|
_ = core_notification:create(UserId, event_reminder, NTitle, Body),
|
||||||
|
broadcast_to_user(UserId, event_reminder, Data#{user_id => UserId}).
|
||||||
|
|
||||||
|
notify_waitlist_promoted(UserId, #{title := Title} = Data) ->
|
||||||
|
NTitle = <<"Место из листа ожидания"/utf8>>,
|
||||||
|
Body = iolist_to_binary([<<"Вас записали на «"/utf8>>, Title, <<"»."/utf8>>]),
|
||||||
|
_ = core_notification:create(UserId, waitlist_promoted, NTitle, Body),
|
||||||
|
broadcast_to_user(UserId, waitlist_promoted, Data#{user_id => UserId}).
|
||||||
|
|
||||||
%% Уведомление для администраторов
|
%% Уведомление для администраторов
|
||||||
notify_admin(Type, Data) ->
|
notify_admin(Type, Data) ->
|
||||||
Message = {admin_notification, Type, Data},
|
Message = {admin_notification, Type, Data},
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Notification channel prefs in user.preferences (Back#75).
|
||||||
|
%%% Keys: notify_email, notify_push (boolean; missing → true).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(logic_notification_prefs).
|
||||||
|
-export([get_prefs/1, put_prefs/2, email_enabled/1, push_enabled/1]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-spec get_prefs(binary()) -> {ok, #{email := boolean(), push := boolean()}} | {error, not_found}.
|
||||||
|
get_prefs(UserId) ->
|
||||||
|
case core_user:get_by_id(UserId) of
|
||||||
|
{ok, #user{preferences = Prefs}} ->
|
||||||
|
{ok, #{
|
||||||
|
email => pref_bool(Prefs, <<"notify_email">>, true),
|
||||||
|
push => pref_bool(Prefs, <<"notify_push">>, true)
|
||||||
|
}};
|
||||||
|
{error, _} = E ->
|
||||||
|
E
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec put_prefs(binary(), #{email := boolean(), push := boolean()}) ->
|
||||||
|
{ok, #{email := boolean(), push := boolean()}} | {error, term()}.
|
||||||
|
put_prefs(UserId, #{email := Email, push := Push})
|
||||||
|
when is_boolean(Email), is_boolean(Push) ->
|
||||||
|
case core_user:get_by_id(UserId) of
|
||||||
|
{ok, #user{preferences = Prefs0}} ->
|
||||||
|
Prefs1 = ensure_map(Prefs0),
|
||||||
|
Prefs2 = Prefs1#{
|
||||||
|
<<"notify_email">> => Email,
|
||||||
|
<<"notify_push">> => Push
|
||||||
|
},
|
||||||
|
case core_user:update(UserId, [{preferences, Prefs2}]) of
|
||||||
|
{ok, _} -> {ok, #{email => Email, push => Push}};
|
||||||
|
{error, _} = E -> E
|
||||||
|
end;
|
||||||
|
{error, _} = E ->
|
||||||
|
E
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec email_enabled(binary()) -> boolean().
|
||||||
|
email_enabled(UserId) ->
|
||||||
|
try
|
||||||
|
case get_prefs(UserId) of
|
||||||
|
{ok, #{email := V}} -> V;
|
||||||
|
_ -> true
|
||||||
|
end
|
||||||
|
catch
|
||||||
|
_:_ -> true
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec push_enabled(binary()) -> boolean().
|
||||||
|
push_enabled(UserId) ->
|
||||||
|
try
|
||||||
|
case get_prefs(UserId) of
|
||||||
|
{ok, #{push := V}} -> V;
|
||||||
|
_ -> true
|
||||||
|
end
|
||||||
|
catch
|
||||||
|
_:_ -> true
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_map(M) when is_map(M) -> M;
|
||||||
|
ensure_map(_) -> #{}.
|
||||||
|
|
||||||
|
pref_bool(Prefs, Key, Default) when is_map(Prefs) ->
|
||||||
|
case maps:get(Key, Prefs, Default) of
|
||||||
|
true -> true;
|
||||||
|
false -> false;
|
||||||
|
<<"true">> -> true;
|
||||||
|
<<"false">> -> false;
|
||||||
|
1 -> true;
|
||||||
|
0 -> false;
|
||||||
|
_ -> Default
|
||||||
|
end;
|
||||||
|
pref_bool(_, _, Default) ->
|
||||||
|
Default.
|
||||||
+174
-44
@@ -98,7 +98,7 @@ discovery_events(UserId, Params, Limit, Offset) ->
|
|||||||
search_events(undefined, UserId, Params, Limit, Offset);
|
search_events(undefined, UserId, Params, Limit, Offset);
|
||||||
Items ->
|
Items ->
|
||||||
Total = length(Items),
|
Total = length(Items),
|
||||||
{ok, Total, format_events(paginate(Items, Limit, Offset))}
|
{ok, Total, format_events(paginate(Items, Limit, Offset), Params)}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
discovery_calendars(UserId, Params, Limit, Offset) ->
|
discovery_calendars(UserId, Params, Limit, Offset) ->
|
||||||
@@ -110,7 +110,7 @@ discovery_calendars(UserId, Params, Limit, Offset) ->
|
|||||||
search_calendars(undefined, UserId, Params, Limit, Offset);
|
search_calendars(undefined, UserId, Params, Limit, Offset);
|
||||||
Items ->
|
Items ->
|
||||||
Total = length(Items),
|
Total = length(Items),
|
||||||
{ok, Total, format_calendars(paginate(Items, Limit, Offset))}
|
{ok, Total, format_calendars(paginate(Items, Limit, Offset), Params)}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%% ============ Поиск событий ============
|
%% ============ Поиск событий ============
|
||||||
@@ -131,7 +131,7 @@ search_events(Query, UserId, Params, Limit, Offset) ->
|
|||||||
%% Pagination is applied BEFORE formatting so only the page slice is
|
%% Pagination is applied BEFORE formatting so only the page slice is
|
||||||
%% enriched with calendar data (see format_events).
|
%% enriched with calendar data (see format_events).
|
||||||
Paginated = paginate(Sorted, Limit, Offset),
|
Paginated = paginate(Sorted, Limit, Offset),
|
||||||
{ok, length(Filtered), format_events(Paginated)}.
|
{ok, length(Filtered), format_events(Paginated, Params)}.
|
||||||
|
|
||||||
%% ============ Поиск календарей ============
|
%% ============ Поиск календарей ============
|
||||||
|
|
||||||
@@ -145,8 +145,9 @@ search_calendars(Query, UserId, Params, Limit, Offset) ->
|
|||||||
AllCalendars = get_all_calendars(),
|
AllCalendars = get_all_calendars(),
|
||||||
AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId),
|
AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId),
|
||||||
Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params),
|
Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params),
|
||||||
Paginated = paginate(Filtered, Limit, Offset),
|
Sorted = sort_calendars(Filtered, Params),
|
||||||
{ok, length(Filtered), format_calendars(Paginated)}.
|
Paginated = paginate(Sorted, Limit, Offset),
|
||||||
|
{ok, length(Filtered), format_calendars(Paginated, Params)}.
|
||||||
|
|
||||||
%% ============ Получение данных ============
|
%% ============ Получение данных ============
|
||||||
|
|
||||||
@@ -191,10 +192,13 @@ filter_accessible_calendars(Calendars, UserId) ->
|
|||||||
end, Calendars).
|
end, Calendars).
|
||||||
|
|
||||||
%% Restricted commercial не показываем в search/discovery (deep-link остаётся).
|
%% Restricted commercial не показываем в search/discovery (deep-link остаётся).
|
||||||
|
%% Personal всегда приватны — только list/deep-link для owner|share.
|
||||||
calendar_discoverable(#calendar{type = commercial} = C) ->
|
calendar_discoverable(#calendar{type = commercial} = C) ->
|
||||||
logic_calendar:booking_open(C);
|
logic_calendar:booking_open(C);
|
||||||
|
calendar_discoverable(#calendar{type = personal}) ->
|
||||||
|
false;
|
||||||
calendar_discoverable(_) ->
|
calendar_discoverable(_) ->
|
||||||
true.
|
false.
|
||||||
|
|
||||||
%% @doc Builds a #{calendar_id => #calendar{}} map using dirty_read
|
%% @doc Builds a #{calendar_id => #calendar{}} map using dirty_read
|
||||||
%% (key-based lookups). Used to batch-fetch calendars and avoid N+1
|
%% (key-based lookups). Used to batch-fetch calendars and avoid N+1
|
||||||
@@ -220,21 +224,41 @@ apply_event_filters(Events, Query, Params) ->
|
|||||||
-spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}].
|
-spec apply_calendar_filters([#calendar{}], binary() | undefined, map()) -> [#calendar{}].
|
||||||
apply_calendar_filters(Calendars, Query, Params) ->
|
apply_calendar_filters(Calendars, Query, Params) ->
|
||||||
Calendars1 = filter_by_text(Calendars, Query),
|
Calendars1 = filter_by_text(Calendars, Query),
|
||||||
filter_by_tags(Calendars1, Params).
|
Calendars2 = filter_by_tags(Calendars1, Params),
|
||||||
|
filter_calendars_by_location(Calendars2, Params).
|
||||||
|
|
||||||
%% --- Текстовый поиск ---
|
%% --- Текстовый поиск ---
|
||||||
-spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}].
|
-spec filter_by_text([#event{} | #calendar{}], binary() | undefined) -> [#event{} | #calendar{}].
|
||||||
filter_by_text(Items, undefined) -> Items;
|
filter_by_text(Items, undefined) -> Items;
|
||||||
filter_by_text(Items, <<>>) -> Items;
|
filter_by_text(Items, <<>>) -> Items;
|
||||||
filter_by_text(Items, Query) ->
|
filter_by_text(Items, Query) ->
|
||||||
QueryLower = string:lowercase(binary_to_list(Query)),
|
QueryLower = unicode_lower(Query),
|
||||||
lists:filter(fun(Item) ->
|
lists:filter(fun(Item) ->
|
||||||
Title = binary_to_list(get_title(Item)),
|
contains_ci(get_title(Item), QueryLower) orelse
|
||||||
Description = binary_to_list(get_description(Item)),
|
contains_ci(get_description(Item), QueryLower)
|
||||||
string:find(string:lowercase(Title), QueryLower) =/= nomatch orelse
|
|
||||||
string:find(string:lowercase(Description), QueryLower) =/= nomatch
|
|
||||||
end, Items).
|
end, Items).
|
||||||
|
|
||||||
|
%% @doc Case-insensitive contains: бинарники UTF-8 приводим к юникод-
|
||||||
|
%% кодпоинтам. string:lowercase(binary_to_list/1) работал по байтам
|
||||||
|
%% (latin-1) и ломал кириллицу: «аврора» не находила «Аврора».
|
||||||
|
-spec contains_ci(binary() | undefined, string()) -> boolean().
|
||||||
|
contains_ci(undefined, _) -> false;
|
||||||
|
contains_ci(Bin, QueryLower) ->
|
||||||
|
case unicode:characters_to_list(Bin, utf8) of
|
||||||
|
Text when is_list(Text) ->
|
||||||
|
string:find(string:lowercase(Text), QueryLower) =/= nomatch;
|
||||||
|
_ ->
|
||||||
|
%% Битый UTF-8: фолбэк на побайтовое сравнение.
|
||||||
|
string:find(binary_to_list(Bin), QueryLower) =/= nomatch
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec unicode_lower(binary()) -> string().
|
||||||
|
unicode_lower(Query) ->
|
||||||
|
case unicode:characters_to_list(Query, utf8) of
|
||||||
|
Q when is_list(Q) -> string:lowercase(Q);
|
||||||
|
_ -> string:lowercase(binary_to_list(Query))
|
||||||
|
end.
|
||||||
|
|
||||||
%% --- Фильтр по тегам ---
|
%% --- Фильтр по тегам ---
|
||||||
-spec filter_by_tags([#event{} | #calendar{}], map()) -> [#event{} | #calendar{}].
|
-spec filter_by_tags([#event{} | #calendar{}], map()) -> [#event{} | #calendar{}].
|
||||||
filter_by_tags(Items, Params) ->
|
filter_by_tags(Items, Params) ->
|
||||||
@@ -266,21 +290,63 @@ filter_by_date_range(Events, Params) ->
|
|||||||
%% --- Гео-фильтр ---
|
%% --- Гео-фильтр ---
|
||||||
-spec filter_by_location([#event{}], map()) -> [#event{}].
|
-spec filter_by_location([#event{}], map()) -> [#event{}].
|
||||||
filter_by_location(Events, Params) ->
|
filter_by_location(Events, Params) ->
|
||||||
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
|
case geo_origin(Params) of
|
||||||
{undefined, _} -> Events;
|
none -> Events;
|
||||||
{_, undefined} -> Events;
|
|
||||||
{Lat, Lon} ->
|
{Lat, Lon} ->
|
||||||
Radius = maps:get(radius, Params, 10),
|
Radius = maps:get(radius, Params, undefined),
|
||||||
lists:filter(fun(Event) ->
|
lists:filter(fun(Event) ->
|
||||||
case Event#event.location of
|
within_radius(event_coords(Event), Lat, Lon, Radius)
|
||||||
#location{lat = EventLat, lon = EventLon}
|
end, Events)
|
||||||
when is_number(EventLat), is_number(EventLon) ->
|
|
||||||
distance(Lat, Lon, EventLat, EventLon) =< Radius;
|
|
||||||
_ -> false
|
|
||||||
end
|
|
||||||
end, Events)
|
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
-spec filter_calendars_by_location([#calendar{}], map()) -> [#calendar{}].
|
||||||
|
filter_calendars_by_location(Calendars, Params) ->
|
||||||
|
case geo_origin(Params) of
|
||||||
|
none -> Calendars;
|
||||||
|
{Lat, Lon} ->
|
||||||
|
Radius = maps:get(radius, Params, undefined),
|
||||||
|
lists:filter(fun(Cal) ->
|
||||||
|
within_radius(calendar_coords(Cal), Lat, Lon, Radius)
|
||||||
|
end, Calendars)
|
||||||
|
end.
|
||||||
|
|
||||||
|
geo_origin(Params) ->
|
||||||
|
case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of
|
||||||
|
{Lat, Lon} when is_number(Lat), is_number(Lon) -> {Lat, Lon};
|
||||||
|
_ -> none
|
||||||
|
end.
|
||||||
|
|
||||||
|
within_radius(none, _, _, _) -> false;
|
||||||
|
within_radius({ok, ELat, ELon}, Lat, Lon, Radius) ->
|
||||||
|
D = distance(Lat, Lon, ELat, ELon),
|
||||||
|
Radius =:= undefined orelse D =< Radius.
|
||||||
|
|
||||||
|
event_coords(#event{location = Loc}) -> loc_coords(Loc);
|
||||||
|
event_coords(_) -> none.
|
||||||
|
|
||||||
|
calendar_coords(#calendar{settings = Settings}) when is_map(Settings) ->
|
||||||
|
Loc = maps:get(<<"default_location">>, Settings,
|
||||||
|
maps:get(default_location, Settings, undefined)),
|
||||||
|
loc_coords(Loc);
|
||||||
|
calendar_coords(_) -> none.
|
||||||
|
|
||||||
|
loc_coords(#location{lat = Lat, lon = Lon}) ->
|
||||||
|
case handler_utils:meaningful_coords(Lat, Lon) of
|
||||||
|
true -> {ok, to_float(Lat), to_float(Lon)};
|
||||||
|
false -> none
|
||||||
|
end;
|
||||||
|
loc_coords(Loc) when is_map(Loc) ->
|
||||||
|
Lat = maps:get(<<"lat">>, Loc, maps:get(lat, Loc, undefined)),
|
||||||
|
Lon = maps:get(<<"lon">>, Loc, maps:get(lon, Loc, undefined)),
|
||||||
|
case handler_utils:meaningful_coords(Lat, Lon) of
|
||||||
|
true -> {ok, to_float(Lat), to_float(Lon)};
|
||||||
|
false -> none
|
||||||
|
end;
|
||||||
|
loc_coords(_) -> none.
|
||||||
|
|
||||||
|
to_float(N) when is_integer(N) -> float(N);
|
||||||
|
to_float(N) when is_float(N) -> N.
|
||||||
|
|
||||||
%% ============ Вспомогательные функции ============
|
%% ============ Вспомогательные функции ============
|
||||||
|
|
||||||
-spec get_title(#event{} | #calendar{}) -> binary().
|
-spec get_title(#event{} | #calendar{}) -> binary().
|
||||||
@@ -319,19 +385,66 @@ deg_to_rad(Deg) -> Deg * math:pi() / 180.
|
|||||||
|
|
||||||
-spec sort_events([#event{}], map()) -> [#event{}].
|
-spec sort_events([#event{}], map()) -> [#event{}].
|
||||||
sort_events(Events, Params) ->
|
sort_events(Events, Params) ->
|
||||||
SortBy = maps:get(sort, Params, <<"start_time">>),
|
Default = case geo_origin(Params) of
|
||||||
|
none -> <<"start_time">>;
|
||||||
|
_ -> <<"distance">>
|
||||||
|
end,
|
||||||
|
SortBy = maps:get(sort, Params, Default),
|
||||||
Order = maps:get(order, Params, <<"asc">>),
|
Order = maps:get(order, Params, <<"asc">>),
|
||||||
Sorted = case SortBy of
|
Sorted = case SortBy of
|
||||||
|
<<"distance">> -> sort_by_distance_events(Events, Params);
|
||||||
<<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events);
|
<<"start_time">> -> lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events);
|
||||||
<<"rating">> -> lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events);
|
<<"rating">> -> lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events);
|
||||||
<<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
|
<<"created_at">> -> lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events);
|
||||||
|
<<"title">> -> lists:sort(fun(A, B) -> A#event.title =< B#event.title end, Events);
|
||||||
_ -> Events
|
_ -> Events
|
||||||
end,
|
end,
|
||||||
|
case {SortBy, Order} of
|
||||||
|
{<<"distance">>, <<"desc">>} -> lists:reverse(Sorted);
|
||||||
|
{<<"rating">>, _} -> Sorted;
|
||||||
|
{_, <<"desc">>} -> lists:reverse(Sorted);
|
||||||
|
_ -> Sorted
|
||||||
|
end.
|
||||||
|
|
||||||
|
sort_calendars(Calendars, Params) ->
|
||||||
|
Default = case geo_origin(Params) of
|
||||||
|
none -> <<"title">>;
|
||||||
|
_ -> <<"distance">>
|
||||||
|
end,
|
||||||
|
SortBy = maps:get(sort, Params, Default),
|
||||||
|
Order = maps:get(order, Params, <<"asc">>),
|
||||||
|
Sorted = case SortBy of
|
||||||
|
<<"distance">> -> sort_by_distance_calendars(Calendars, Params);
|
||||||
|
<<"created_at">> -> lists:sort(fun(A, B) -> A#calendar.created_at =< B#calendar.created_at end, Calendars);
|
||||||
|
<<"title">> -> lists:sort(fun(A, B) -> A#calendar.title =< B#calendar.title end, Calendars);
|
||||||
|
_ -> Calendars
|
||||||
|
end,
|
||||||
case Order of
|
case Order of
|
||||||
<<"desc">> -> lists:reverse(Sorted);
|
<<"desc">> -> lists:reverse(Sorted);
|
||||||
_ -> Sorted
|
_ -> Sorted
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
sort_by_distance_events(Events, Params) ->
|
||||||
|
case geo_origin(Params) of
|
||||||
|
none -> Events;
|
||||||
|
{Lat, Lon} ->
|
||||||
|
lists:sort(fun(A, B) ->
|
||||||
|
dist_or_inf(event_coords(A), Lat, Lon) =< dist_or_inf(event_coords(B), Lat, Lon)
|
||||||
|
end, Events)
|
||||||
|
end.
|
||||||
|
|
||||||
|
sort_by_distance_calendars(Cals, Params) ->
|
||||||
|
case geo_origin(Params) of
|
||||||
|
none -> Cals;
|
||||||
|
{Lat, Lon} ->
|
||||||
|
lists:sort(fun(A, B) ->
|
||||||
|
dist_or_inf(calendar_coords(A), Lat, Lon) =< dist_or_inf(calendar_coords(B), Lat, Lon)
|
||||||
|
end, Cals)
|
||||||
|
end.
|
||||||
|
|
||||||
|
dist_or_inf({ok, ELat, ELon}, Lat, Lon) -> distance(Lat, Lon, ELat, ELon);
|
||||||
|
dist_or_inf(none, _, _) -> 1.0e12.
|
||||||
|
|
||||||
%% ============ Пагинация ============
|
%% ============ Пагинация ============
|
||||||
|
|
||||||
-spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()].
|
-spec paginate([term()], non_neg_integer(), non_neg_integer()) -> [term()].
|
||||||
@@ -340,28 +453,24 @@ paginate(List, Limit, Offset) ->
|
|||||||
|
|
||||||
%% ============ Форматирование ответа ============
|
%% ============ Форматирование ответа ============
|
||||||
|
|
||||||
-spec format_events([#event{}]) -> [map()].
|
-spec format_events([#event{}], map()) -> [map()].
|
||||||
format_events(Events) ->
|
format_events(Events, Params) ->
|
||||||
%% Optimization: batch-fetch calendars for the (already paginated)
|
%% Optimization: batch-fetch calendars for the (already paginated)
|
||||||
%% subset only, avoiding an N+1 query per event during formatting.
|
%% subset only, avoiding an N+1 query per event during formatting.
|
||||||
CalendarMap = build_calendar_map(
|
CalendarMap = build_calendar_map(
|
||||||
lists:usort([E#event.calendar_id || E <- Events])),
|
lists:usort([E#event.calendar_id || E <- Events])),
|
||||||
lists:map(fun(E) -> format_event(E, CalendarMap) end, Events).
|
lists:map(fun(E) -> format_event(E, CalendarMap, Params) end, Events).
|
||||||
|
|
||||||
-spec format_event(#event{}, #{binary() => #calendar{}}) -> map().
|
-spec format_event(#event{}, #{binary() => #calendar{}}, map()) -> map().
|
||||||
format_event(Event, CalendarMap) ->
|
format_event(Event, CalendarMap, Params) ->
|
||||||
Location = case Event#event.location of
|
Location = handler_utils:location_to_json(Event#event.location),
|
||||||
undefined -> null;
|
|
||||||
#location{address = Addr, lat = Lat, lon = Lon} ->
|
|
||||||
#{address => Addr, lat => Lat, lon => Lon}
|
|
||||||
end,
|
|
||||||
{CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of
|
{CalendarTitle, ImageUrl} = case maps:find(Event#event.calendar_id, CalendarMap) of
|
||||||
{ok, #calendar{title = T, image_url = <<>>}} -> {T, null};
|
{ok, #calendar{title = T, image_url = <<>>}} -> {T, null};
|
||||||
{ok, #calendar{title = T, image_url = undefined}} -> {T, null};
|
{ok, #calendar{title = T, image_url = undefined}} -> {T, null};
|
||||||
{ok, #calendar{title = T, image_url = Url}} -> {T, Url};
|
{ok, #calendar{title = T, image_url = Url}} -> {T, Url};
|
||||||
error -> {null, null}
|
error -> {null, null}
|
||||||
end,
|
end,
|
||||||
#{
|
Base = #{
|
||||||
id => Event#event.id,
|
id => Event#event.id,
|
||||||
calendar_id => Event#event.calendar_id,
|
calendar_id => Event#event.calendar_id,
|
||||||
calendar_title => CalendarTitle,
|
calendar_title => CalendarTitle,
|
||||||
@@ -377,15 +486,25 @@ format_event(Event, CalendarMap) ->
|
|||||||
rating_count => Event#event.rating_count,
|
rating_count => Event#event.rating_count,
|
||||||
status => Event#event.status,
|
status => Event#event.status,
|
||||||
image_url => ImageUrl
|
image_url => ImageUrl
|
||||||
}.
|
},
|
||||||
|
maybe_distance(Base, event_coords(Event), Params).
|
||||||
|
|
||||||
-spec format_calendars([#calendar{}]) -> [map()].
|
-spec format_calendars([#calendar{}], map()) -> [map()].
|
||||||
format_calendars(Calendars) ->
|
format_calendars(Calendars, Params) ->
|
||||||
lists:map(fun format_calendar/1, Calendars).
|
lists:map(fun(C) -> format_calendar(C, Params) end, Calendars).
|
||||||
|
|
||||||
-spec format_calendar(#calendar{}) -> map().
|
-spec format_calendar(#calendar{}, map()) -> map().
|
||||||
format_calendar(Calendar) ->
|
format_calendar(Calendar, Params) ->
|
||||||
#{
|
Settings = case Calendar#calendar.settings of
|
||||||
|
M when is_map(M) -> M;
|
||||||
|
_ -> #{}
|
||||||
|
end,
|
||||||
|
LocJson = case maps:get(<<"default_location">>, Settings,
|
||||||
|
maps:get(default_location, Settings, undefined)) of
|
||||||
|
undefined -> null;
|
||||||
|
Loc -> Loc
|
||||||
|
end,
|
||||||
|
Base = #{
|
||||||
id => Calendar#calendar.id,
|
id => Calendar#calendar.id,
|
||||||
owner_id => Calendar#calendar.owner_id,
|
owner_id => Calendar#calendar.owner_id,
|
||||||
title => Calendar#calendar.title,
|
title => Calendar#calendar.title,
|
||||||
@@ -400,8 +519,19 @@ format_calendar(Calendar) ->
|
|||||||
tags => Calendar#calendar.tags,
|
tags => Calendar#calendar.tags,
|
||||||
rating_avg => Calendar#calendar.rating_avg,
|
rating_avg => Calendar#calendar.rating_avg,
|
||||||
rating_count => Calendar#calendar.rating_count,
|
rating_count => Calendar#calendar.rating_count,
|
||||||
status => Calendar#calendar.status
|
status => Calendar#calendar.status,
|
||||||
}.
|
%% Всегда отдаём координаты (не только при гео-запросе) — вид карты
|
||||||
|
%% на странице поиска строит маркеры и без lat/lon в запросе.
|
||||||
|
default_location => LocJson
|
||||||
|
},
|
||||||
|
maybe_distance(Base, calendar_coords(Calendar), Params).
|
||||||
|
|
||||||
|
maybe_distance(Map, Coords, Params) ->
|
||||||
|
case {geo_origin(Params), Coords} of
|
||||||
|
{{OLat, OLon}, {ok, Lat, Lon}} ->
|
||||||
|
Map#{distance_km => round(distance(OLat, OLon, Lat, Lon) * 10) / 10};
|
||||||
|
_ -> Map
|
||||||
|
end.
|
||||||
|
|
||||||
-spec datetime_to_iso8601(calendar:datetime()) -> binary().
|
-spec datetime_to_iso8601(calendar:datetime()) -> binary().
|
||||||
datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) ->
|
datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) ->
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ do_create(OwnerId, CalendarId, UserId, Email, Opts) ->
|
|||||||
|
|
||||||
notify_invite(#specialist_invite{invitee_user_id = UserId, calendar_id = CalId,
|
notify_invite(#specialist_invite{invitee_user_id = UserId, calendar_id = CalId,
|
||||||
token = Token} = Inv) when UserId =/= <<>> ->
|
token = Token} = Inv) when UserId =/= <<>> ->
|
||||||
Title = <<"Приглашение стать специалистом">>,
|
Title = <<"Приглашение стать специалистом"/utf8>>,
|
||||||
Body = <<"Вас пригласили в календарь ", CalId/binary>>,
|
Body = <<"Вас пригласили в календарь "/utf8, CalId/binary>>,
|
||||||
_ = core_notification:create(UserId, specialist_invite, Title, Body),
|
_ = core_notification:create(UserId, specialist_invite, Title, Body),
|
||||||
logic_notification:notify_specialist_invite(UserId, Inv),
|
logic_notification:notify_specialist_invite(UserId, Inv),
|
||||||
maybe_email(Inv, Token);
|
maybe_email(Inv, Token);
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Local-disk uploads for avatar/cover (Back#71, variant A).
|
||||||
|
%%% Env: UPLOAD_DIR (default /app/data/uploads), UPLOAD_MAX_BYTES (default 2MiB).
|
||||||
|
%%% Public URLs: /v1/media/{avatar|cover}/:owner/:file
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(logic_upload).
|
||||||
|
-export([max_bytes/0, upload_dir/0, ensure_dir/0,
|
||||||
|
save_avatar/2, save_cover/3,
|
||||||
|
media_abs_path/3, detect_image/1, maybe_delete_url/1]).
|
||||||
|
|
||||||
|
-define(DEFAULT_MAX, 2097152). %% 2 MiB
|
||||||
|
-define(DEFAULT_DIR, "/app/data/uploads").
|
||||||
|
|
||||||
|
-spec max_bytes() -> pos_integer().
|
||||||
|
max_bytes() ->
|
||||||
|
case application:get_env(eventhub, upload_max_bytes) of
|
||||||
|
{ok, N} when is_integer(N), N > 0 -> N;
|
||||||
|
_ ->
|
||||||
|
case os:getenv("UPLOAD_MAX_BYTES") of
|
||||||
|
false -> ?DEFAULT_MAX;
|
||||||
|
"" -> ?DEFAULT_MAX;
|
||||||
|
S ->
|
||||||
|
try list_to_integer(S) of
|
||||||
|
N when N > 0 -> N;
|
||||||
|
_ -> ?DEFAULT_MAX
|
||||||
|
catch
|
||||||
|
_:_ -> ?DEFAULT_MAX
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec upload_dir() -> string().
|
||||||
|
upload_dir() ->
|
||||||
|
case application:get_env(eventhub, upload_dir) of
|
||||||
|
{ok, Dir} when is_list(Dir), Dir =/= "" -> Dir;
|
||||||
|
{ok, Dir} when is_binary(Dir), Dir =/= <<>> -> binary_to_list(Dir);
|
||||||
|
_ ->
|
||||||
|
case os:getenv("UPLOAD_DIR") of
|
||||||
|
false -> ?DEFAULT_DIR;
|
||||||
|
"" -> ?DEFAULT_DIR;
|
||||||
|
S -> S
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec ensure_dir() -> ok | {error, term()}.
|
||||||
|
ensure_dir() ->
|
||||||
|
Dir = upload_dir(),
|
||||||
|
case filelib:is_dir(Dir) of
|
||||||
|
true -> ok;
|
||||||
|
false -> filelib:ensure_dir(filename:join(Dir, "dummy"))
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% @doc Save avatar bytes; returns public URL path.
|
||||||
|
-spec save_avatar(UserId :: binary(), Bin :: binary()) ->
|
||||||
|
{ok, Url :: binary()} | {error, too_large | unsupported_media | term()}.
|
||||||
|
save_avatar(UserId, Bin) when is_binary(UserId), is_binary(Bin) ->
|
||||||
|
save(avatar, UserId, Bin).
|
||||||
|
|
||||||
|
%% @doc Save calendar cover; returns public URL path.
|
||||||
|
-spec save_cover(CalendarId :: binary(), OwnerUserId :: binary(), Bin :: binary()) ->
|
||||||
|
{ok, Url :: binary()} | {error, too_large | unsupported_media | term()}.
|
||||||
|
save_cover(CalendarId, _OwnerUserId, Bin)
|
||||||
|
when is_binary(CalendarId), is_binary(Bin) ->
|
||||||
|
save(cover, CalendarId, Bin).
|
||||||
|
|
||||||
|
-spec media_abs_path(Kind :: avatar | cover, OwnerId :: binary(), File :: binary()) ->
|
||||||
|
{ok, file:filename_all()} | {error, not_found | invalid}.
|
||||||
|
media_abs_path(Kind, OwnerId, File)
|
||||||
|
when (Kind =:= avatar orelse Kind =:= cover),
|
||||||
|
is_binary(OwnerId), is_binary(File) ->
|
||||||
|
case safe_segment(OwnerId) andalso safe_filename(File) of
|
||||||
|
true ->
|
||||||
|
KindS = atom_to_list(Kind),
|
||||||
|
Path = filename:join([upload_dir(), KindS,
|
||||||
|
binary_to_list(OwnerId), binary_to_list(File)]),
|
||||||
|
case filelib:is_regular(Path) of
|
||||||
|
true -> {ok, Path};
|
||||||
|
false -> {error, not_found}
|
||||||
|
end;
|
||||||
|
false ->
|
||||||
|
{error, invalid}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec detect_image(binary()) ->
|
||||||
|
{ok, Ext :: binary(), Mime :: binary()} | {error, unsupported_media}.
|
||||||
|
detect_image(<<16#FF, 16#D8, 16#FF, _/binary>>) ->
|
||||||
|
{ok, <<"jpg">>, <<"image/jpeg">>};
|
||||||
|
detect_image(<<137, 80, 78, 71, 13, 10, 26, 10, _/binary>>) ->
|
||||||
|
{ok, <<"png">>, <<"image/png">>};
|
||||||
|
detect_image(<<"RIFF", _:4/binary, "WEBP", _/binary>>) ->
|
||||||
|
{ok, <<"webp">>, <<"image/webp">>};
|
||||||
|
detect_image(_) ->
|
||||||
|
{error, unsupported_media}.
|
||||||
|
|
||||||
|
-spec maybe_delete_url(binary() | default | undefined) -> ok.
|
||||||
|
maybe_delete_url(Url) when is_binary(Url) ->
|
||||||
|
case Url of
|
||||||
|
<<"/v1/media/", Rest/binary>> ->
|
||||||
|
case binary:split(Rest, <<"/">>, [global]) of
|
||||||
|
[KindB, OwnerId, File] ->
|
||||||
|
Kind = case KindB of
|
||||||
|
<<"avatar">> -> avatar;
|
||||||
|
<<"cover">> -> cover;
|
||||||
|
_ -> undefined
|
||||||
|
end,
|
||||||
|
case Kind of
|
||||||
|
undefined -> ok;
|
||||||
|
_ ->
|
||||||
|
case media_abs_path(Kind, OwnerId, File) of
|
||||||
|
{ok, Path} -> _ = file:delete(Path), ok;
|
||||||
|
_ -> ok
|
||||||
|
end
|
||||||
|
end;
|
||||||
|
_ -> ok
|
||||||
|
end;
|
||||||
|
_ -> ok
|
||||||
|
end;
|
||||||
|
maybe_delete_url(_) ->
|
||||||
|
ok.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% INTERNAL
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
save(Kind, OwnerId, Bin) ->
|
||||||
|
case byte_size(Bin) > max_bytes() of
|
||||||
|
true ->
|
||||||
|
{error, too_large};
|
||||||
|
false ->
|
||||||
|
case detect_image(Bin) of
|
||||||
|
{ok, Ext, _Mime} ->
|
||||||
|
case safe_segment(OwnerId) of
|
||||||
|
false ->
|
||||||
|
{error, invalid};
|
||||||
|
true ->
|
||||||
|
write_new(Kind, OwnerId, Ext, Bin)
|
||||||
|
end;
|
||||||
|
{error, _} = E ->
|
||||||
|
E
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
write_new(Kind, OwnerId, Ext, Bin) ->
|
||||||
|
_ = ensure_dir(),
|
||||||
|
FileId = infra_utils:generate_id(16),
|
||||||
|
File = <<FileId/binary, ".", Ext/binary>>,
|
||||||
|
KindS = atom_to_list(Kind),
|
||||||
|
RelDir = filename:join([upload_dir(), KindS, binary_to_list(OwnerId)]),
|
||||||
|
AbsFile = filename:join(RelDir, binary_to_list(File)),
|
||||||
|
ok = filelib:ensure_dir(AbsFile),
|
||||||
|
case file:write_file(AbsFile, Bin) of
|
||||||
|
ok ->
|
||||||
|
Url = iolist_to_binary([<<"/v1/media/">>, KindS, <<"/">>,
|
||||||
|
OwnerId, <<"/">>, File]),
|
||||||
|
{ok, Url};
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
safe_segment(Bin) when is_binary(Bin), byte_size(Bin) > 0, byte_size(Bin) < 128 ->
|
||||||
|
case binary:match(Bin, [<<"/">>, <<"..">>, <<"\\">>]) of
|
||||||
|
nomatch -> true;
|
||||||
|
_ -> false
|
||||||
|
end;
|
||||||
|
safe_segment(_) ->
|
||||||
|
false.
|
||||||
|
|
||||||
|
safe_filename(Bin) when is_binary(Bin), byte_size(Bin) > 0, byte_size(Bin) < 128 ->
|
||||||
|
case re:run(Bin, <<"^[A-Za-z0-9_-]+\\.(jpg|png|webp)$">>, [{capture, none}]) of
|
||||||
|
match -> true;
|
||||||
|
nomatch -> false
|
||||||
|
end;
|
||||||
|
safe_filename(_) ->
|
||||||
|
false.
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Event waitlist (Back#72). Requires commercial calendar
|
||||||
|
%%% settings.waitlist_enabled = true. FIFO promote on free slot.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(logic_waitlist).
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-export([join/2, leave/2, status_for_user/2, list_for_manager/2,
|
||||||
|
maybe_promote/1, waitlist_enabled/1]).
|
||||||
|
|
||||||
|
-spec waitlist_enabled(#calendar{}) -> boolean().
|
||||||
|
waitlist_enabled(#calendar{type = commercial, settings = Settings}) when is_map(Settings) ->
|
||||||
|
case maps:get(<<"waitlist_enabled">>, Settings, maps:get(waitlist_enabled, Settings, false)) of
|
||||||
|
true -> true;
|
||||||
|
<<"true">> -> true;
|
||||||
|
_ -> false
|
||||||
|
end;
|
||||||
|
waitlist_enabled(_) ->
|
||||||
|
false.
|
||||||
|
|
||||||
|
-spec join(UserId :: binary(), EventId :: binary()) ->
|
||||||
|
{ok, map()} |
|
||||||
|
{error, not_found | access_denied | waitlist_disabled | not_full |
|
||||||
|
already_booked | already_on_waitlist | personal_calendar |
|
||||||
|
subscription_inactive | event_not_active | own_event}.
|
||||||
|
join(UserId, EventId) ->
|
||||||
|
case core_event:get_by_id(EventId) of
|
||||||
|
{ok, #event{status = active} = Event} ->
|
||||||
|
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||||
|
{ok, Cal} ->
|
||||||
|
join_checked(UserId, Event, Cal);
|
||||||
|
{error, not_found} ->
|
||||||
|
{error, not_found}
|
||||||
|
end;
|
||||||
|
{ok, _} ->
|
||||||
|
{error, event_not_active};
|
||||||
|
{error, not_found} ->
|
||||||
|
{error, not_found}
|
||||||
|
end.
|
||||||
|
|
||||||
|
join_checked(UserId, Event, Cal) ->
|
||||||
|
case Cal#calendar.type of
|
||||||
|
personal ->
|
||||||
|
{error, personal_calendar};
|
||||||
|
commercial ->
|
||||||
|
case logic_calendar:booking_open(Cal) of
|
||||||
|
false ->
|
||||||
|
{error, subscription_inactive};
|
||||||
|
true ->
|
||||||
|
case waitlist_enabled(Cal) of
|
||||||
|
false ->
|
||||||
|
{error, waitlist_disabled};
|
||||||
|
true ->
|
||||||
|
join_ready(UserId, Event, Cal)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
join_ready(UserId, #event{id = EventId, capacity = Cap} = Event, _Cal) ->
|
||||||
|
case owner_of_event(UserId, Event) of
|
||||||
|
true ->
|
||||||
|
{error, own_event};
|
||||||
|
false ->
|
||||||
|
case logic_booking_occupied(EventId, UserId) of
|
||||||
|
true ->
|
||||||
|
{error, already_booked};
|
||||||
|
false ->
|
||||||
|
case core_waitlist:get_waiting(EventId, UserId) of
|
||||||
|
{ok, _} ->
|
||||||
|
{error, already_on_waitlist};
|
||||||
|
{error, not_found} ->
|
||||||
|
case is_full(EventId, Cap) of
|
||||||
|
false ->
|
||||||
|
{error, not_full};
|
||||||
|
true ->
|
||||||
|
case core_waitlist:create(EventId, UserId) of
|
||||||
|
{ok, Entry} ->
|
||||||
|
{ok, entry_public(Entry, EventId)};
|
||||||
|
{error, _} = E ->
|
||||||
|
E
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
owner_of_event(UserId, Event) ->
|
||||||
|
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||||
|
{ok, #calendar{owner_id = UserId}} -> true;
|
||||||
|
_ -> false
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec leave(UserId :: binary(), EventId :: binary()) ->
|
||||||
|
ok | {error, not_found}.
|
||||||
|
leave(UserId, EventId) ->
|
||||||
|
case core_waitlist:get_waiting(EventId, UserId) of
|
||||||
|
{ok, #waitlist_entry{id = Id}} ->
|
||||||
|
_ = core_waitlist:update(Id, [{status, left}]),
|
||||||
|
ok;
|
||||||
|
{error, not_found} ->
|
||||||
|
{error, not_found}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec status_for_user(UserId :: binary(), EventId :: binary()) ->
|
||||||
|
{ok, map()} | {error, not_found | access_denied}.
|
||||||
|
status_for_user(UserId, EventId) ->
|
||||||
|
case core_event:get_by_id(EventId) of
|
||||||
|
{ok, Event} ->
|
||||||
|
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||||
|
{ok, Cal} ->
|
||||||
|
Enabled = waitlist_enabled(Cal),
|
||||||
|
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
|
||||||
|
case core_waitlist:get_waiting(EventId, UserId) of
|
||||||
|
{ok, Entry} ->
|
||||||
|
Pos = position_of(Entry#waitlist_entry.id, Waiting),
|
||||||
|
{ok, #{
|
||||||
|
enabled => Enabled,
|
||||||
|
joined => true,
|
||||||
|
position => Pos,
|
||||||
|
total => length(Waiting),
|
||||||
|
entry => entry_json(Entry)
|
||||||
|
}};
|
||||||
|
{error, not_found} ->
|
||||||
|
{ok, #{
|
||||||
|
enabled => Enabled,
|
||||||
|
joined => false,
|
||||||
|
position => null,
|
||||||
|
total => length(Waiting)
|
||||||
|
}}
|
||||||
|
end;
|
||||||
|
{error, not_found} ->
|
||||||
|
{error, not_found}
|
||||||
|
end;
|
||||||
|
{error, not_found} ->
|
||||||
|
{error, not_found}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec list_for_manager(UserId :: binary(), EventId :: binary()) ->
|
||||||
|
{ok, map()} | {error, not_found | access_denied}.
|
||||||
|
list_for_manager(UserId, EventId) ->
|
||||||
|
case can_manage(UserId, EventId) of
|
||||||
|
true ->
|
||||||
|
case core_event:get_by_id(EventId) of
|
||||||
|
{ok, Event} ->
|
||||||
|
{ok, Cal} = core_calendar:get_by_id(Event#event.calendar_id),
|
||||||
|
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
|
||||||
|
{ok, #{
|
||||||
|
enabled => waitlist_enabled(Cal),
|
||||||
|
total => length(Waiting),
|
||||||
|
items => [entry_json(E) || E <- Waiting]
|
||||||
|
}};
|
||||||
|
{error, not_found} ->
|
||||||
|
{error, not_found}
|
||||||
|
end;
|
||||||
|
false ->
|
||||||
|
{error, access_denied}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% Promote first waiter when a booking slot frees.
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec maybe_promote(EventId :: binary()) -> ok.
|
||||||
|
maybe_promote(EventId) ->
|
||||||
|
case core_event:get_by_id(EventId) of
|
||||||
|
{ok, #event{status = active, capacity = Cap, calendar_id = CalId} = Event} ->
|
||||||
|
case core_calendar:get_by_id(CalId) of
|
||||||
|
{ok, Cal} ->
|
||||||
|
case waitlist_enabled(Cal) andalso logic_calendar:booking_open(Cal)
|
||||||
|
andalso has_free_slot(EventId, Cap) of
|
||||||
|
true ->
|
||||||
|
promote_one(Event, Cal);
|
||||||
|
false ->
|
||||||
|
ok
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
ok
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
promote_one(Event, Cal) ->
|
||||||
|
case sort_waiting(core_waitlist:list_waiting_by_event(Event#event.id)) of
|
||||||
|
[] ->
|
||||||
|
ok;
|
||||||
|
[#waitlist_entry{id = Wid, user_id = UserId} = _Entry | _] ->
|
||||||
|
case logic_booking_occupied(Event#event.id, UserId) of
|
||||||
|
true ->
|
||||||
|
_ = core_waitlist:update(Wid, [{status, left}]),
|
||||||
|
maybe_promote(Event#event.id);
|
||||||
|
false ->
|
||||||
|
Status = initial_status(Cal#calendar.confirmation),
|
||||||
|
case core_booking:create(Event#event.id, UserId, Status) of
|
||||||
|
{ok, Booking} ->
|
||||||
|
_ = core_waitlist:update(Wid, [{status, promoted}]),
|
||||||
|
notify_promoted(UserId, Event, Booking),
|
||||||
|
ok;
|
||||||
|
{error, _} ->
|
||||||
|
ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
initial_status(auto) -> confirmed;
|
||||||
|
initial_status(_) -> pending.
|
||||||
|
|
||||||
|
notify_promoted(UserId, Event, Booking) ->
|
||||||
|
Title = Event#event.title,
|
||||||
|
Path = <<"/c/", (Event#event.calendar_id)/binary, "/e/", (Event#event.id)/binary>>,
|
||||||
|
case logic_notification_prefs:email_enabled(UserId) of
|
||||||
|
true ->
|
||||||
|
case core_user:get_by_id(UserId) of
|
||||||
|
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
|
||||||
|
_ = logic_email:send_waitlist_promoted(Email, Title, Path);
|
||||||
|
_ ->
|
||||||
|
ok
|
||||||
|
end;
|
||||||
|
false ->
|
||||||
|
ok
|
||||||
|
end,
|
||||||
|
_ = logic_notification:notify_waitlist_promoted(UserId, #{
|
||||||
|
event_id => Event#event.id,
|
||||||
|
calendar_id => Event#event.calendar_id,
|
||||||
|
booking_id => Booking#booking.id,
|
||||||
|
title => Title,
|
||||||
|
status => Booking#booking.status
|
||||||
|
}),
|
||||||
|
_ = logic_web_push:send(
|
||||||
|
UserId,
|
||||||
|
<<"Место из листа ожидания"/utf8>>,
|
||||||
|
iolist_to_binary([<<"Вас записали на «"/utf8>>, Title, <<"»."/utf8>>]),
|
||||||
|
Path
|
||||||
|
),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% INTERNAL
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
is_full(_EventId, Cap) when Cap =:= undefined; Cap =:= 0 ->
|
||||||
|
false;
|
||||||
|
is_full(EventId, Cap) when is_integer(Cap), Cap > 0 ->
|
||||||
|
occupied(EventId) >= Cap;
|
||||||
|
is_full(_, _) ->
|
||||||
|
false.
|
||||||
|
|
||||||
|
has_free_slot(_EventId, Cap) when Cap =:= undefined; Cap =:= 0 ->
|
||||||
|
true;
|
||||||
|
has_free_slot(EventId, Cap) when is_integer(Cap), Cap > 0 ->
|
||||||
|
occupied(EventId) < Cap;
|
||||||
|
has_free_slot(_, _) ->
|
||||||
|
true.
|
||||||
|
|
||||||
|
occupied(EventId) ->
|
||||||
|
{ok, Bookings} = core_booking:list_by_event(EventId),
|
||||||
|
length([B || B <- Bookings,
|
||||||
|
B#booking.status =:= pending orelse B#booking.status =:= confirmed]).
|
||||||
|
|
||||||
|
logic_booking_occupied(EventId, UserId) ->
|
||||||
|
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'},
|
||||||
|
case [B || B <- mnesia:dirty_match_object(Match),
|
||||||
|
B#booking.status =:= pending orelse B#booking.status =:= confirmed] of
|
||||||
|
[_ | _] -> true;
|
||||||
|
[] -> false
|
||||||
|
end.
|
||||||
|
|
||||||
|
sort_waiting(List) ->
|
||||||
|
lists:sort(fun(A, B) -> A#waitlist_entry.created_at =< B#waitlist_entry.created_at end, List).
|
||||||
|
|
||||||
|
position_of(Id, Waiting) ->
|
||||||
|
position_of(Id, Waiting, 1).
|
||||||
|
|
||||||
|
position_of(_Id, [], _) -> null;
|
||||||
|
position_of(Id, [#waitlist_entry{id = Id} | _], N) -> N;
|
||||||
|
position_of(Id, [_ | Rest], N) -> position_of(Id, Rest, N + 1).
|
||||||
|
|
||||||
|
entry_public(Entry, EventId) ->
|
||||||
|
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
|
||||||
|
#{
|
||||||
|
entry => entry_json(Entry),
|
||||||
|
position => position_of(Entry#waitlist_entry.id, Waiting),
|
||||||
|
total => length(Waiting)
|
||||||
|
}.
|
||||||
|
|
||||||
|
entry_json(#waitlist_entry{} = E) ->
|
||||||
|
#{
|
||||||
|
id => E#waitlist_entry.id,
|
||||||
|
event_id => E#waitlist_entry.event_id,
|
||||||
|
user_id => E#waitlist_entry.user_id,
|
||||||
|
status => E#waitlist_entry.status,
|
||||||
|
created_at => handler_utils:datetime_to_iso8601(E#waitlist_entry.created_at),
|
||||||
|
updated_at => handler_utils:datetime_to_iso8601(E#waitlist_entry.updated_at)
|
||||||
|
}.
|
||||||
|
|
||||||
|
can_manage(UserId, EventId) ->
|
||||||
|
case core_event:get_by_id(EventId) of
|
||||||
|
{ok, Event} ->
|
||||||
|
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||||
|
{ok, #calendar{owner_id = UserId}} ->
|
||||||
|
true;
|
||||||
|
{ok, Calendar} ->
|
||||||
|
SpecOk = is_binary(Event#event.specialist_id)
|
||||||
|
andalso Event#event.specialist_id =/= <<>>
|
||||||
|
andalso Event#event.specialist_id =:= UserId
|
||||||
|
andalso core_calendar_specialist:is_active_specialist(
|
||||||
|
Calendar#calendar.id, UserId),
|
||||||
|
SpecOk orelse admin_utils:is_admin(UserId);
|
||||||
|
_ ->
|
||||||
|
admin_utils:is_admin(UserId)
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end.
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Web Push send (VAPID + aes128gcm). Back#75.
|
||||||
|
%%% Env: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY (base64url), VAPID_SUBJECT.
|
||||||
|
%%% Tests may set {eventhub, web_push_deliver} fun/4 mock.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(logic_web_push).
|
||||||
|
-export([send/4, vapid_public_key/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-spec vapid_public_key() -> {ok, binary()} | {error, missing}.
|
||||||
|
vapid_public_key() ->
|
||||||
|
case env_bin("VAPID_PUBLIC_KEY") of
|
||||||
|
<<>> -> {error, missing};
|
||||||
|
Key -> {ok, Key}
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec send(binary(), binary(), binary(), binary()) -> ok.
|
||||||
|
send(UserId, Title, Body, Url) ->
|
||||||
|
case logic_notification_prefs:push_enabled(UserId) of
|
||||||
|
false ->
|
||||||
|
ok;
|
||||||
|
true ->
|
||||||
|
case {env_bin("VAPID_PUBLIC_KEY"), env_bin("VAPID_PRIVATE_KEY")} of
|
||||||
|
{<<>>, _} ->
|
||||||
|
logger:info(#{what => web_push_skip_no_vapid, user_id => UserId}),
|
||||||
|
ok;
|
||||||
|
{_, <<>>} ->
|
||||||
|
logger:info(#{what => web_push_skip_no_vapid, user_id => UserId}),
|
||||||
|
ok;
|
||||||
|
{Pub, Priv} ->
|
||||||
|
Payload = jsx:encode(#{
|
||||||
|
<<"title">> => Title,
|
||||||
|
<<"body">> => Body,
|
||||||
|
<<"url">> => Url
|
||||||
|
}),
|
||||||
|
lists:foreach(
|
||||||
|
fun(Sub) -> safe_push(Sub, Pub, Priv, Payload) end,
|
||||||
|
core_push_subscription:list_by_user(UserId)
|
||||||
|
),
|
||||||
|
ok
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
safe_push(#push_subscription{endpoint = Ep, p256dh = P256, auth = Auth} = Sub,
|
||||||
|
VapidPub, VapidPriv, Payload) ->
|
||||||
|
Fun = deliver_fun(),
|
||||||
|
try Fun(Ep, P256, Auth, Payload, VapidPub, VapidPriv) of
|
||||||
|
ok -> ok;
|
||||||
|
{ok, _} -> ok;
|
||||||
|
{error, gone} ->
|
||||||
|
_ = core_push_subscription:delete_by_id(Sub#push_subscription.id),
|
||||||
|
ok;
|
||||||
|
{error, Reason} ->
|
||||||
|
logger:warning(#{what => web_push_error, endpoint => Ep, reason => Reason}),
|
||||||
|
ok;
|
||||||
|
Other ->
|
||||||
|
logger:warning(#{what => web_push_unexpected, endpoint => Ep, result => Other}),
|
||||||
|
ok
|
||||||
|
catch
|
||||||
|
Class:Reason:Stack ->
|
||||||
|
logger:warning(#{what => web_push_crash, endpoint => Ep,
|
||||||
|
class => Class, reason => Reason, stack => Stack}),
|
||||||
|
ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
deliver_fun() ->
|
||||||
|
case application:get_env(eventhub, web_push_deliver) of
|
||||||
|
{ok, Fun} when is_function(Fun, 6) -> Fun;
|
||||||
|
_ -> fun default_deliver/6
|
||||||
|
end.
|
||||||
|
|
||||||
|
default_deliver(Endpoint, P256dhB64, AuthB64, Payload, VapidPub, VapidPriv) ->
|
||||||
|
_ = application:ensure_all_started(inets),
|
||||||
|
_ = application:ensure_all_started(ssl),
|
||||||
|
case encrypt_aes128gcm(P256dhB64, AuthB64, Payload) of
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason};
|
||||||
|
{ok, Body, CryptoHeaders} ->
|
||||||
|
case vapid_auth_header(Endpoint, VapidPub, VapidPriv) of
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason};
|
||||||
|
{ok, Authz} ->
|
||||||
|
Headers = [
|
||||||
|
{"authorization", Authz},
|
||||||
|
{"ttl", "86400"},
|
||||||
|
{"content-encoding", "aes128gcm"},
|
||||||
|
{"content-type", "application/octet-stream"}
|
||||||
|
| CryptoHeaders
|
||||||
|
],
|
||||||
|
Request = {binary_to_list(Endpoint), Headers, "application/octet-stream", Body},
|
||||||
|
HttpOpts = [{timeout, 15000}, {ssl, [{verify, verify_peer},
|
||||||
|
{cacerts, public_key:cacerts_get()}]}],
|
||||||
|
case httpc:request(post, Request, HttpOpts, [{body_format, binary}]) of
|
||||||
|
{ok, {{_, Code, _}, _, _}} when Code >= 200, Code < 300 ->
|
||||||
|
ok;
|
||||||
|
{ok, {{_, Code, _}, _, _}} when Code =:= 404; Code =:= 410 ->
|
||||||
|
{error, gone};
|
||||||
|
{ok, {{_, Code, _}, _, Resp}} ->
|
||||||
|
{error, {http_status, Code, Resp}};
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% VAPID (RFC 8292) via jose ES256
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
|
||||||
|
vapid_auth_header(Endpoint, PubB64, PrivB64) ->
|
||||||
|
try
|
||||||
|
Audience = audience(Endpoint),
|
||||||
|
Sub = case env_bin("VAPID_SUBJECT") of
|
||||||
|
<<>> -> <<"mailto:admin@calentiq.com">>;
|
||||||
|
S -> S
|
||||||
|
end,
|
||||||
|
Exp = erlang:system_time(second) + 12 * 3600,
|
||||||
|
Claims = #{
|
||||||
|
<<"aud">> => Audience,
|
||||||
|
<<"exp">> => Exp,
|
||||||
|
<<"sub">> => Sub
|
||||||
|
},
|
||||||
|
JWK = vapid_jwk(PubB64, PrivB64),
|
||||||
|
{_, TokenBin} = jose_jwt:sign(JWK, #{<<"alg">> => <<"ES256">>}, Claims),
|
||||||
|
Compact = jose_jws:compact(TokenBin),
|
||||||
|
Token = iolist_to_binary(Compact),
|
||||||
|
H = <<"vapid t=", Token/binary, ", k=", PubB64/binary>>,
|
||||||
|
{ok, binary_to_list(H)}
|
||||||
|
catch
|
||||||
|
Class:Reason:Stack ->
|
||||||
|
{error, {vapid, Class, Reason, Stack}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
audience(Endpoint) ->
|
||||||
|
%% scheme://host[:port]
|
||||||
|
case uri_string:parse(Endpoint) of
|
||||||
|
#{scheme := Scheme, host := Host} = U ->
|
||||||
|
Port = maps:get(port, U, undefined),
|
||||||
|
case Port of
|
||||||
|
undefined ->
|
||||||
|
iolist_to_binary([Scheme, <<"://">>, Host]);
|
||||||
|
P ->
|
||||||
|
iolist_to_binary([Scheme, <<"://">>, Host, <<":">>, integer_to_binary(P)])
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
Endpoint
|
||||||
|
end.
|
||||||
|
|
||||||
|
vapid_jwk(PubB64, PrivB64) ->
|
||||||
|
Pub = b64url_decode(PubB64),
|
||||||
|
Priv = b64url_decode(PrivB64),
|
||||||
|
<<16#04, X:32/binary, Y:32/binary>> = Pub,
|
||||||
|
jose_jwk:from_map(#{
|
||||||
|
<<"kty">> => <<"EC">>,
|
||||||
|
<<"crv">> => <<"P-256">>,
|
||||||
|
<<"x">> => b64url_encode(X),
|
||||||
|
<<"y">> => b64url_encode(Y),
|
||||||
|
<<"d">> => b64url_encode(Priv)
|
||||||
|
}).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% RFC 8291 aes128gcm (simplified single-record)
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
|
||||||
|
encrypt_aes128gcm(P256dhB64, AuthB64, Payload) ->
|
||||||
|
try
|
||||||
|
UaPublic = b64url_decode(P256dhB64),
|
||||||
|
AuthSecret = b64url_decode(AuthB64),
|
||||||
|
{AsPublic, AsPrivate} = crypto:generate_key(ecdh, secp256r1),
|
||||||
|
Shared = crypto:compute_key(ecdh, UaPublic, AsPrivate, secp256r1),
|
||||||
|
%% ikm
|
||||||
|
AuthInfo = <<"WebPush: info", 0, UaPublic/binary, AsPublic/binary>>,
|
||||||
|
Ikm = hkdf(AuthSecret, Shared, AuthInfo, 32),
|
||||||
|
Salt = crypto:strong_rand_bytes(16),
|
||||||
|
%% cek + nonce
|
||||||
|
KeyInfo = <<"Content-Encoding: aes128gcm", 0>>,
|
||||||
|
NonceInfo = <<"Content-Encoding: nonce", 0>>,
|
||||||
|
Cek = hkdf(Salt, Ikm, KeyInfo, 16),
|
||||||
|
Nonce = hkdf(Salt, Ikm, NonceInfo, 12),
|
||||||
|
%% pad: payload || 0x02 (delimiter) for aes128gcm
|
||||||
|
Plain = <<Payload/binary, 2>>,
|
||||||
|
{Cipher, Tag} = crypto:crypto_one_time_aead(aes_128_gcm, Cek, Nonce, Plain, <<>>, true),
|
||||||
|
Ciphertext = <<Cipher/binary, Tag/binary>>,
|
||||||
|
Rs = 4096,
|
||||||
|
IdLen = byte_size(AsPublic),
|
||||||
|
Body = <<Salt/binary, Rs:32, IdLen:8, AsPublic/binary, Ciphertext/binary>>,
|
||||||
|
{ok, Body, []}
|
||||||
|
catch
|
||||||
|
Class:Reason:Stack ->
|
||||||
|
{error, {encrypt, Class, Reason, Stack}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% HKDF-Extract + Expand (SHA-256), RFC 5869
|
||||||
|
hkdf(Salt, Ikm, Info, Len) ->
|
||||||
|
Prk = crypto:mac(hmac, sha256, Salt, Ikm),
|
||||||
|
hkdf_expand(Prk, Info, Len).
|
||||||
|
|
||||||
|
hkdf_expand(Prk, Info, Len) ->
|
||||||
|
hkdf_expand(Prk, Info, Len, 1, <<>>, <<>>).
|
||||||
|
|
||||||
|
hkdf_expand(_Prk, _Info, Len, _I, Acc, _Prev) when byte_size(Acc) >= Len ->
|
||||||
|
binary:part(Acc, 0, Len);
|
||||||
|
hkdf_expand(Prk, Info, Len, I, Acc, Prev) ->
|
||||||
|
T = crypto:mac(hmac, sha256, Prk, <<Prev/binary, Info/binary, I:8>>),
|
||||||
|
hkdf_expand(Prk, Info, Len, I + 1, <<Acc/binary, T/binary>>, T).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% helpers
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
|
||||||
|
env_bin("VAPID_PUBLIC_KEY") ->
|
||||||
|
app_or_os(vapid_public_key, "VAPID_PUBLIC_KEY");
|
||||||
|
env_bin("VAPID_PRIVATE_KEY") ->
|
||||||
|
app_or_os(vapid_private_key, "VAPID_PRIVATE_KEY");
|
||||||
|
env_bin("VAPID_SUBJECT") ->
|
||||||
|
app_or_os(vapid_subject, "VAPID_SUBJECT");
|
||||||
|
env_bin(Name) ->
|
||||||
|
case os:getenv(Name) of
|
||||||
|
false -> <<>>;
|
||||||
|
"" -> <<>>;
|
||||||
|
S -> unicode:characters_to_binary(S)
|
||||||
|
end.
|
||||||
|
|
||||||
|
app_or_os(AppKey, EnvName) ->
|
||||||
|
case application:get_env(eventhub, AppKey) of
|
||||||
|
{ok, B} when is_binary(B), B =/= <<>> -> B;
|
||||||
|
{ok, L} when is_list(L), L =/= "" -> unicode:characters_to_binary(L);
|
||||||
|
_ ->
|
||||||
|
case os:getenv(EnvName) of
|
||||||
|
false -> <<>>;
|
||||||
|
"" -> <<>>;
|
||||||
|
S -> unicode:characters_to_binary(S)
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
b64url_decode(Bin) when is_binary(Bin) ->
|
||||||
|
S0 = binary_to_list(Bin),
|
||||||
|
S1 = lists:map(fun($-) -> $+; ($_) -> $/; (C) -> C end, S0),
|
||||||
|
Pad = case length(S1) rem 4 of
|
||||||
|
0 -> "";
|
||||||
|
2 -> "==";
|
||||||
|
3 -> "=";
|
||||||
|
1 -> "="
|
||||||
|
end,
|
||||||
|
base64:decode(S1 ++ Pad).
|
||||||
|
|
||||||
|
b64url_encode(Bin) when is_binary(Bin) ->
|
||||||
|
S = base64:encode_to_string(Bin),
|
||||||
|
S2 = lists:filter(fun(C) -> C =/= $= end, S),
|
||||||
|
unicode:characters_to_binary(
|
||||||
|
lists:map(fun($+) -> $-; ($/) -> $_; (C) -> C end, S2)).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
%% @doc Create waitlist_entry table and indexes (Back#72).
|
||||||
|
-module('20260814193000_waitlist_entry').
|
||||||
|
|
||||||
|
-export([up/0, down/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
up() ->
|
||||||
|
ensure_table(waitlist_entry, record_info(fields, waitlist_entry)),
|
||||||
|
ensure_index(waitlist_entry, event_id),
|
||||||
|
ensure_index(waitlist_entry, user_id),
|
||||||
|
ensure_index(waitlist_entry, status),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
down() ->
|
||||||
|
_ = mnesia:delete_table(waitlist_entry),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
ensure_table(Table, Attrs) ->
|
||||||
|
case lists:member(Table, mnesia:system_info(tables)) of
|
||||||
|
true ->
|
||||||
|
ok;
|
||||||
|
false ->
|
||||||
|
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table}} -> ok;
|
||||||
|
{aborted, Reason} -> error({create_table_failed, Table, Reason})
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_index(Table, Attr) ->
|
||||||
|
case mnesia:add_table_index(Table, Attr) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||||
|
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||||
|
{aborted, {already_exists, _}} -> ok;
|
||||||
|
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||||
|
end.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
%% @doc Create push_subscription table (Back#75).
|
||||||
|
-module('20260815200000_push_subscription').
|
||||||
|
|
||||||
|
-export([up/0, down/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
up() ->
|
||||||
|
ensure_table(push_subscription, record_info(fields, push_subscription)),
|
||||||
|
ensure_index(push_subscription, user_id),
|
||||||
|
ensure_index(push_subscription, endpoint),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
down() ->
|
||||||
|
_ = mnesia:delete_table(push_subscription),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
ensure_table(Table, Attrs) ->
|
||||||
|
case lists:member(Table, mnesia:system_info(tables)) of
|
||||||
|
true ->
|
||||||
|
ok;
|
||||||
|
false ->
|
||||||
|
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table}} -> ok;
|
||||||
|
{aborted, Reason} -> error({create_table_failed, Table, Reason})
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_index(Table, Attr) ->
|
||||||
|
case mnesia:add_table_index(Table, Attr) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||||
|
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||||
|
{aborted, {already_exists, _}} -> ok;
|
||||||
|
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||||
|
end.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
%% @doc calendar_share: id PK + mirror_to_default; create calendar_share_invite.
|
||||||
|
%% Old schema PK was calendar_id — mnesia:transform_table cannot change the key,
|
||||||
|
%% so we rebuild the table (feature was unused until Back#73).
|
||||||
|
-module('20260815220000_calendar_share_invite').
|
||||||
|
|
||||||
|
-export([up/0, down/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
up() ->
|
||||||
|
rebuild_calendar_share_if_needed(),
|
||||||
|
ensure_index(calendar_share, calendar_id),
|
||||||
|
ensure_index(calendar_share, user_id),
|
||||||
|
ensure_table(calendar_share_invite, record_info(fields, calendar_share_invite)),
|
||||||
|
ensure_index(calendar_share_invite, calendar_id),
|
||||||
|
ensure_index(calendar_share_invite, invitee_user_id),
|
||||||
|
ensure_index(calendar_share_invite, invitee_email),
|
||||||
|
ensure_index(calendar_share_invite, token),
|
||||||
|
ensure_index(calendar_share_invite, status),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
down() ->
|
||||||
|
_ = mnesia:delete_table(calendar_share_invite),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
rebuild_calendar_share_if_needed() ->
|
||||||
|
case lists:member(calendar_share, mnesia:system_info(tables)) of
|
||||||
|
false ->
|
||||||
|
ensure_table(calendar_share, record_info(fields, calendar_share));
|
||||||
|
true ->
|
||||||
|
Attrs = mnesia:table_info(calendar_share, attributes),
|
||||||
|
case Attrs =:= record_info(fields, calendar_share) of
|
||||||
|
true ->
|
||||||
|
ok;
|
||||||
|
false ->
|
||||||
|
Rows = dump_calendar_share_rows(),
|
||||||
|
case mnesia:delete_table(calendar_share) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, Reason} -> error({delete_calendar_share_failed, Reason})
|
||||||
|
end,
|
||||||
|
ensure_table(calendar_share, record_info(fields, calendar_share)),
|
||||||
|
case mnesia:wait_for_tables([calendar_share], 30000) of
|
||||||
|
ok -> ok;
|
||||||
|
{timeout, _} -> error({wait_calendar_share_timeout})
|
||||||
|
end,
|
||||||
|
lists:foreach(fun(Rec) -> ok = mnesia:dirty_write(Rec) end, Rows),
|
||||||
|
ok
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
dump_calendar_share_rows() ->
|
||||||
|
Raw = try mnesia:dirty_select(calendar_share, [{'_', [], ['$_']}])
|
||||||
|
catch _:_ -> []
|
||||||
|
end,
|
||||||
|
[transform_row(R) || R <- Raw].
|
||||||
|
|
||||||
|
transform_row({calendar_share, CalId, UserId, Rights})
|
||||||
|
when is_binary(CalId), is_binary(UserId) ->
|
||||||
|
#calendar_share{
|
||||||
|
id = infra_utils:generate_id(16),
|
||||||
|
calendar_id = CalId,
|
||||||
|
user_id = UserId,
|
||||||
|
rights = Rights,
|
||||||
|
mirror_to_default = false
|
||||||
|
};
|
||||||
|
transform_row({calendar_share, Id, CalId, UserId, Rights})
|
||||||
|
when is_binary(Id), is_binary(CalId), is_binary(UserId) ->
|
||||||
|
#calendar_share{
|
||||||
|
id = Id,
|
||||||
|
calendar_id = CalId,
|
||||||
|
user_id = UserId,
|
||||||
|
rights = Rights,
|
||||||
|
mirror_to_default = false
|
||||||
|
};
|
||||||
|
transform_row(#calendar_share{} = Rec) ->
|
||||||
|
Rec;
|
||||||
|
transform_row(Other) ->
|
||||||
|
error({unexpected_calendar_share_row, Other}).
|
||||||
|
|
||||||
|
ensure_table(Table, Attrs) ->
|
||||||
|
case lists:member(Table, mnesia:system_info(tables)) of
|
||||||
|
true ->
|
||||||
|
ok;
|
||||||
|
false ->
|
||||||
|
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table}} -> ok;
|
||||||
|
{aborted, Reason} -> error({create_table_failed, Table, Reason})
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_index(Table, Attr) ->
|
||||||
|
case lists:member(Table, mnesia:system_info(tables)) of
|
||||||
|
false ->
|
||||||
|
ok;
|
||||||
|
true ->
|
||||||
|
case mnesia:add_table_index(Table, Attr) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||||
|
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||||
|
{aborted, {already_exists, _}} -> ok;
|
||||||
|
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||||
|
end
|
||||||
|
end.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
%% @doc Add device_name and user_agent to auth_session (Back#74).
|
||||||
|
-module('20260816180000_auth_session_device').
|
||||||
|
|
||||||
|
-export([up/0, down/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
up() ->
|
||||||
|
case table_exists(auth_session) of
|
||||||
|
false ->
|
||||||
|
ok;
|
||||||
|
true ->
|
||||||
|
ensure_device_fields(),
|
||||||
|
ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
down() ->
|
||||||
|
ok.
|
||||||
|
|
||||||
|
table_exists(Table) ->
|
||||||
|
try lists:member(Table, mnesia:system_info(tables))
|
||||||
|
catch
|
||||||
|
_:_ -> false
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_device_fields() ->
|
||||||
|
Attrs = mnesia:table_info(auth_session, attributes),
|
||||||
|
case lists:member(device_name, Attrs) andalso lists:member(user_agent, Attrs) of
|
||||||
|
true ->
|
||||||
|
ok;
|
||||||
|
false ->
|
||||||
|
Fun = fun(Rec) ->
|
||||||
|
case tuple_size(Rec) of
|
||||||
|
%% auth_session atom + 10 fields (pre-device)
|
||||||
|
11 ->
|
||||||
|
list_to_tuple(tuple_to_list(Rec) ++ [<<>>, <<>>]);
|
||||||
|
_ ->
|
||||||
|
Rec
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transform_table(auth_session, Fun, record_info(fields, auth_session)) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, Reason} -> error({transform_auth_session_failed, Reason})
|
||||||
|
end
|
||||||
|
end.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
%% @doc month_snapshot table (Back#76). disc_only — blobs not in RAM.
|
||||||
|
-module('20260817140000_month_snapshot').
|
||||||
|
|
||||||
|
-export([up/0, down/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
up() ->
|
||||||
|
ensure_table(),
|
||||||
|
ensure_index(month_snapshot, calendar_id),
|
||||||
|
ensure_index(month_snapshot, year_month),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
down() ->
|
||||||
|
_ = mnesia:delete_table(month_snapshot),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
ensure_table() ->
|
||||||
|
case lists:member(month_snapshot, mnesia:system_info(tables)) of
|
||||||
|
true ->
|
||||||
|
ok;
|
||||||
|
false ->
|
||||||
|
Attrs = record_info(fields, month_snapshot),
|
||||||
|
case mnesia:create_table(month_snapshot, [
|
||||||
|
{disc_only_copies, [node()]},
|
||||||
|
{attributes, Attrs}
|
||||||
|
]) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, month_snapshot}} -> ok;
|
||||||
|
{aborted, Reason} -> error({create_table_failed, month_snapshot, Reason})
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_index(Table, Attr) ->
|
||||||
|
case mnesia:add_table_index(Table, Attr) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||||
|
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||||
|
{aborted, {already_exists, _}} -> ok;
|
||||||
|
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||||
|
end.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
%% @doc Create ai_hint_metric table — пилотные метрики ИИ-подсказок (PRODUCT-AI, Spec#22).
|
||||||
|
-module('20260820180000_ai_hint_metric').
|
||||||
|
|
||||||
|
-export([up/0, down/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
up() ->
|
||||||
|
ensure_table(ai_hint_metric, record_info(fields, ai_hint_metric)),
|
||||||
|
ensure_index(ai_hint_metric, user_id),
|
||||||
|
ensure_index(ai_hint_metric, day),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
down() ->
|
||||||
|
_ = mnesia:delete_table(ai_hint_metric),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
ensure_table(Table, Attrs) ->
|
||||||
|
case lists:member(Table, mnesia:system_info(tables)) of
|
||||||
|
true ->
|
||||||
|
ok;
|
||||||
|
false ->
|
||||||
|
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table}} -> ok;
|
||||||
|
{aborted, Reason} -> error({create_table_failed, Table, Reason})
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_index(Table, Attr) ->
|
||||||
|
case mnesia:add_table_index(Table, Attr) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||||
|
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||||
|
{aborted, {already_exists, _}} -> ok;
|
||||||
|
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||||
|
end.
|
||||||
@@ -646,56 +646,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/v1/calendars/:calendar_id/view": {
|
|
||||||
"get": {
|
|
||||||
"description": "Get calendar HTML view for a specific month",
|
|
||||||
"tags": [
|
|
||||||
"Calendars"
|
|
||||||
],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"in": "path",
|
|
||||||
"name": "calendar_id",
|
|
||||||
"description": "Calendar ID",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"in": "query",
|
|
||||||
"name": "month",
|
|
||||||
"description": "Month in YYYY-MM format",
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^\\d{4}-\\d{2}$"
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "HTML calendar page",
|
|
||||||
"content": {
|
|
||||||
"text/html": {
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Missing or invalid 'month' parameter"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Access denied"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/v1/calendars/:id": {
|
"/v1/calendars/:id": {
|
||||||
"delete": {
|
"delete": {
|
||||||
"description": "Delete calendar",
|
"description": "Delete calendar",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ admin() ->
|
|||||||
admin_handler_node_metrics,
|
admin_handler_node_metrics,
|
||||||
admin_handler_login,
|
admin_handler_login,
|
||||||
admin_handler_refresh,
|
admin_handler_refresh,
|
||||||
|
admin_handler_logout,
|
||||||
% ================== ПОЛЬЗОВАТЕЛИ ==================
|
% ================== ПОЛЬЗОВАТЕЛИ ==================
|
||||||
admin_handler_users,
|
admin_handler_users,
|
||||||
admin_handler_user_by_id,
|
admin_handler_user_by_id,
|
||||||
@@ -48,6 +49,8 @@ admin() ->
|
|||||||
admin_handler_subscriptions,
|
admin_handler_subscriptions,
|
||||||
admin_handler_subscription_stats,
|
admin_handler_subscription_stats,
|
||||||
admin_handler_subscriptions_by_id,
|
admin_handler_subscriptions_by_id,
|
||||||
|
% ================== ИИ-МЕТРИКИ ==================
|
||||||
|
admin_handler_ai_metrics,
|
||||||
% ================== МОДЕРАЦИЯ (общий маршрут) ==================
|
% ================== МОДЕРАЦИЯ (общий маршрут) ==================
|
||||||
admin_handler_moderation,
|
admin_handler_moderation,
|
||||||
% ================== Управление ролями (только для superadmin) ==================
|
% ================== Управление ролями (только для superadmin) ==================
|
||||||
@@ -69,6 +72,8 @@ user() ->
|
|||||||
handler_register,
|
handler_register,
|
||||||
handler_login,
|
handler_login,
|
||||||
handler_refresh,
|
handler_refresh,
|
||||||
|
handler_logout,
|
||||||
|
handler_sessions,
|
||||||
handler_verify,
|
handler_verify,
|
||||||
handler_forgot_password,
|
handler_forgot_password,
|
||||||
handler_reset_password,
|
handler_reset_password,
|
||||||
@@ -80,16 +85,19 @@ user() ->
|
|||||||
handler_calendar_specialist_invites,
|
handler_calendar_specialist_invites,
|
||||||
handler_specialist_invites,
|
handler_specialist_invites,
|
||||||
handler_users_lookup,
|
handler_users_lookup,
|
||||||
handler_calendar_view,
|
|
||||||
handler_calendars,
|
handler_calendars,
|
||||||
handler_event_by_id,
|
handler_event_by_id,
|
||||||
handler_event_occurrences,
|
handler_event_occurrences,
|
||||||
handler_events,
|
handler_events,
|
||||||
|
handler_event_waitlist,
|
||||||
handler_reports,
|
handler_reports,
|
||||||
handler_review_by_id,
|
handler_review_by_id,
|
||||||
handler_review_vote,
|
handler_review_vote,
|
||||||
handler_reviews,
|
handler_reviews,
|
||||||
handler_search,
|
handler_search,
|
||||||
|
handler_geo,
|
||||||
|
handler_ai_metrics,
|
||||||
|
handler_ai_transcribe,
|
||||||
handler_subscription,
|
handler_subscription,
|
||||||
handler_ticket_by_id,
|
handler_ticket_by_id,
|
||||||
handler_tickets,
|
handler_tickets,
|
||||||
@@ -98,6 +106,11 @@ user() ->
|
|||||||
handler_user_studio_bookings,
|
handler_user_studio_bookings,
|
||||||
handler_user_following,
|
handler_user_following,
|
||||||
handler_user_me,
|
handler_user_me,
|
||||||
|
handler_user_avatar,
|
||||||
|
handler_notification_prefs,
|
||||||
|
handler_push,
|
||||||
|
handler_calendar_cover,
|
||||||
|
handler_media,
|
||||||
handler_user_reviews
|
handler_user_reviews
|
||||||
],
|
],
|
||||||
lists:flatmap(fun trails_from_module/1, Modules).
|
lists:flatmap(fun trails_from_module/1, Modules).
|
||||||
|
|||||||
@@ -205,8 +205,7 @@ test_ws_connect_debug(Url, Token) ->
|
|||||||
ct:pal(" Host: ~s", [Host]),
|
ct:pal(" Host: ~s", [Host]),
|
||||||
ct:pal(" Port: ~p", [Port]),
|
ct:pal(" Port: ~p", [Port]),
|
||||||
ct:pal(" Path: ~s", [Path]),
|
ct:pal(" Path: ~s", [Path]),
|
||||||
{ok, ConnPid} = gun:open(Host, Port, Opts),
|
ConnPid = ws_connect_up(Host, Port, Opts, 3),
|
||||||
{ok, http} = gun:await_up(ConnPid, 5000),
|
|
||||||
Headers = [{<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))}],
|
Headers = [{<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))}],
|
||||||
StreamRef = gun:ws_upgrade(ConnPid, Path, Headers),
|
StreamRef = gun:ws_upgrade(ConnPid, Path, Headers),
|
||||||
receive
|
receive
|
||||||
@@ -243,6 +242,31 @@ test_ws_connect_debug(Url, Token) ->
|
|||||||
{error, timeout}
|
{error, timeout}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
%% @doc TCP/TLS-коннект с ретраями: сразу после deploy-stage traefik/бэкенд
|
||||||
|
%% могут быть нестабильны, и одиночный 5s-таймаут ронял сьюит
|
||||||
|
%% (badmatch {error,timeout}).
|
||||||
|
-spec ws_connect_up(string(), integer(), map(), non_neg_integer()) -> pid().
|
||||||
|
ws_connect_up(Host, Port, Opts, Retries) ->
|
||||||
|
Result =
|
||||||
|
case gun:open(Host, Port, Opts) of
|
||||||
|
{ok, ConnPid} ->
|
||||||
|
case gun:await_up(ConnPid, 5000) of
|
||||||
|
{ok, http} -> {ok, ConnPid};
|
||||||
|
{error, Reason} -> gun:close(ConnPid), {error, Reason}
|
||||||
|
end;
|
||||||
|
{error, Reason} -> {error, Reason}
|
||||||
|
end,
|
||||||
|
case Result of
|
||||||
|
{ok, Pid} -> Pid;
|
||||||
|
{error, Err} when Retries > 1 ->
|
||||||
|
ct:pal(" WS connect failed (~p), retry in 2s (~p attempts left)",
|
||||||
|
[Err, Retries - 1]),
|
||||||
|
timer:sleep(2000),
|
||||||
|
ws_connect_up(Host, Port, Opts, Retries - 1);
|
||||||
|
{error, Err} ->
|
||||||
|
error({ws_connect_failed, Err})
|
||||||
|
end.
|
||||||
|
|
||||||
test_ws_send(ConnPid, Data) ->
|
test_ws_send(ConnPid, Data) ->
|
||||||
Msg = jsx:encode(Data),
|
Msg = jsx:encode(Data),
|
||||||
ct:pal(" Sending: ~s", [Msg]),
|
ct:pal(" Sending: ~s", [Msg]),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ test() ->
|
|||||||
|
|
||||||
test_get_calendar(Token, CalId),
|
test_get_calendar(Token, CalId),
|
||||||
test_get_calendar_guest(Token, CalId),
|
test_get_calendar_guest(Token, CalId),
|
||||||
|
test_get_calendar_guest_short_name(Token, CalId),
|
||||||
test_get_calendar_not_found(Token),
|
test_get_calendar_not_found(Token),
|
||||||
test_update_calendar(Token, CalId),
|
test_update_calendar(Token, CalId),
|
||||||
test_update_calendar_settings(Token, CalId),
|
test_update_calendar_settings(Token, CalId),
|
||||||
@@ -49,6 +50,20 @@ test_get_calendar_guest(Token, CalId) ->
|
|||||||
{ok, 403, _, _} = api_test_runner:client_request(get, PPath, <<>>),
|
{ok, 403, _, _} = api_test_runner:client_request(get, PPath, <<>>),
|
||||||
ct:pal(" OK: guest commercial 200, personal 403").
|
ct:pal(" OK: guest commercial 200, personal 403").
|
||||||
|
|
||||||
|
test_get_calendar_guest_short_name(Token, CalId) ->
|
||||||
|
ct:pal(" TEST: Guest GET commercial by short_name"),
|
||||||
|
PathId = <<"/v1/calendars/", CalId/binary>>,
|
||||||
|
Slug2 = <<"sn", (integer_to_binary(erlang:unique_integer([positive])))/binary>>,
|
||||||
|
_ = api_test_runner:client_put(PathId, Token, #{short_name => Slug2}),
|
||||||
|
PathSlug = <<"/v1/calendars/", Slug2/binary>>,
|
||||||
|
{ok, 200, _, Body} = api_test_runner:client_request(get, PathSlug, <<>>),
|
||||||
|
Dec = jsx:decode(list_to_binary(Body), [return_maps]),
|
||||||
|
?assertEqual(CalId, maps:get(<<"id">>, Dec)),
|
||||||
|
?assertEqual(Slug2, maps:get(<<"short_name">>, Dec)),
|
||||||
|
EvPath = <<"/v1/calendars/", Slug2/binary, "/events">>,
|
||||||
|
{ok, 200, _, _} = api_test_runner:client_request(get, EvPath, <<>>),
|
||||||
|
ct:pal(" OK: guest GET by short_name").
|
||||||
|
|
||||||
test_get_calendar_not_found(Token) ->
|
test_get_calendar_not_found(Token) ->
|
||||||
ct:pal(" TEST: Get non-existent calendar (404)"),
|
ct:pal(" TEST: Get non-existent calendar (404)"),
|
||||||
Resp = api_test_runner:client_request(get, <<"/v1/calendars/fakeid">>, Token),
|
Resp = api_test_runner:client_request(get, <<"/v1/calendars/fakeid">>, Token),
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @doc Тесты клиентского API для HTML-представления календаря.
|
|
||||||
%%%
|
|
||||||
%%% Покрывает эндпоинты:
|
|
||||||
%%% GET /v1/calendars/:calendar_id/view
|
|
||||||
%%%
|
|
||||||
%%% Проверяет:
|
|
||||||
%%% - успешное получение HTML-страницы (200, text/html)
|
|
||||||
%%% - ошибку 401 без токена
|
|
||||||
%%% @end
|
|
||||||
%%%-------------------------------------------------------------------
|
|
||||||
-module(user_calendar_view_tests).
|
|
||||||
-include_lib("eunit/include/eunit.hrl").
|
|
||||||
|
|
||||||
-export([test/0]).
|
|
||||||
|
|
||||||
%%%===================================================================
|
|
||||||
%%% Главная тестовая функция
|
|
||||||
%%%===================================================================
|
|
||||||
|
|
||||||
-spec test() -> ok.
|
|
||||||
test() ->
|
|
||||||
ct:pal("=== User Calendar View Tests ==="),
|
|
||||||
Token = api_test_runner:get_user_token(),
|
|
||||||
|
|
||||||
% Создаём календарь
|
|
||||||
CalId = api_test_runner:create_calendar(Token, #{title => <<"ViewCal">>}),
|
|
||||||
|
|
||||||
test_get_calendar_view(Token, CalId),
|
|
||||||
test_get_calendar_view_unauthorized(CalId),
|
|
||||||
|
|
||||||
ct:pal("=== All user calendar view tests passed ==="),
|
|
||||||
ok.
|
|
||||||
|
|
||||||
%%%===================================================================
|
|
||||||
%%% Тестовые функции
|
|
||||||
%%%===================================================================
|
|
||||||
|
|
||||||
%% @doc Успешный запрос HTML-представления: 200 OK, тип text/html.
|
|
||||||
-spec test_get_calendar_view(binary(), binary()) -> ok.
|
|
||||||
test_get_calendar_view(Token, CalId) ->
|
|
||||||
ct:pal(" TEST: Get calendar HTML view"),
|
|
||||||
Path = <<"/v1/calendars/", CalId/binary, "/view?month=2026-06">>,
|
|
||||||
Resp = api_test_runner:client_request(get, Path, Token),
|
|
||||||
{ok, 200, Headers, Body} = Resp,
|
|
||||||
?assert(lists:keymember("content-type", 1, Headers)),
|
|
||||||
{"content-type", CT} = lists:keyfind("content-type", 1, Headers),
|
|
||||||
?assert(string:str(CT, "text/html") > 0),
|
|
||||||
% Body может быть строкой или binary, приводим к binary и проверяем непустоту
|
|
||||||
BodyBin = iolist_to_binary(Body),
|
|
||||||
?assert(byte_size(BodyBin) > 0),
|
|
||||||
ct:pal(" OK: got HTML of ~p bytes", [byte_size(BodyBin)]).
|
|
||||||
|
|
||||||
%% @doc Запрос без токена: 401 Unauthorized.
|
|
||||||
-spec test_get_calendar_view_unauthorized(binary()) -> ok.
|
|
||||||
test_get_calendar_view_unauthorized(CalId) ->
|
|
||||||
ct:pal(" TEST: Get calendar view without token"),
|
|
||||||
Path = <<"/v1/calendars/", CalId/binary, "/view?month=2026-06">>,
|
|
||||||
Resp = api_test_runner:client_request(get, Path, <<>>),
|
|
||||||
?assertMatch({ok, 401, _, _}, Resp),
|
|
||||||
ct:pal(" OK: got 401").
|
|
||||||
@@ -91,8 +91,7 @@ test_ws_connect_debug(Url, Token) ->
|
|||||||
ct:pal(" Host: ~s", [Host]),
|
ct:pal(" Host: ~s", [Host]),
|
||||||
ct:pal(" Port: ~p", [Port]),
|
ct:pal(" Port: ~p", [Port]),
|
||||||
ct:pal(" Path: ~s", [Path]),
|
ct:pal(" Path: ~s", [Path]),
|
||||||
{ok, ConnPid} = gun:open(Host, Port, Opts),
|
ConnPid = ws_connect_up(Host, Port, Opts, 3),
|
||||||
{ok, http} = gun:await_up(ConnPid, 5000),
|
|
||||||
Headers = [{<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))}],
|
Headers = [{<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))}],
|
||||||
StreamRef = gun:ws_upgrade(ConnPid, Path, Headers),
|
StreamRef = gun:ws_upgrade(ConnPid, Path, Headers),
|
||||||
receive
|
receive
|
||||||
@@ -129,6 +128,31 @@ test_ws_connect_debug(Url, Token) ->
|
|||||||
{error, timeout}
|
{error, timeout}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
%% @doc TCP/TLS-коннект с ретраями: сразу после deploy-stage traefik/бэкенд
|
||||||
|
%% могут быть нестабильны, и одиночный 5s-таймаут ронял сьюит
|
||||||
|
%% (badmatch {error,timeout}).
|
||||||
|
-spec ws_connect_up(string(), integer(), map(), non_neg_integer()) -> pid().
|
||||||
|
ws_connect_up(Host, Port, Opts, Retries) ->
|
||||||
|
Result =
|
||||||
|
case gun:open(Host, Port, Opts) of
|
||||||
|
{ok, ConnPid} ->
|
||||||
|
case gun:await_up(ConnPid, 5000) of
|
||||||
|
{ok, http} -> {ok, ConnPid};
|
||||||
|
{error, Reason} -> gun:close(ConnPid), {error, Reason}
|
||||||
|
end;
|
||||||
|
{error, Reason} -> {error, Reason}
|
||||||
|
end,
|
||||||
|
case Result of
|
||||||
|
{ok, Pid} -> Pid;
|
||||||
|
{error, Err} when Retries > 1 ->
|
||||||
|
ct:pal(" WS connect failed (~p), retry in 2s (~p attempts left)",
|
||||||
|
[Err, Retries - 1]),
|
||||||
|
timer:sleep(2000),
|
||||||
|
ws_connect_up(Host, Port, Opts, Retries - 1);
|
||||||
|
{error, Err} ->
|
||||||
|
error({ws_connect_failed, Err})
|
||||||
|
end.
|
||||||
|
|
||||||
test_ws_send(ConnPid, Data) ->
|
test_ws_send(ConnPid, Data) ->
|
||||||
Msg = jsx:encode(Data),
|
Msg = jsx:encode(Data),
|
||||||
ct:pal(" Sending: ~s", [Msg]),
|
ct:pal(" Sending: ~s", [Msg]),
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ all() ->
|
|||||||
user_test_user_me,
|
user_test_user_me,
|
||||||
user_test_calendars,
|
user_test_calendars,
|
||||||
user_test_calendar_by_id,
|
user_test_calendar_by_id,
|
||||||
user_test_calendar_view,
|
|
||||||
user_test_event_by_id,
|
user_test_event_by_id,
|
||||||
user_test_events,
|
user_test_events,
|
||||||
user_test_occurrence_cancel,
|
user_test_occurrence_cancel,
|
||||||
@@ -122,9 +121,6 @@ user_test_calendars(_Config) ->
|
|||||||
user_test_calendar_by_id(_Config) ->
|
user_test_calendar_by_id(_Config) ->
|
||||||
user_calendar_by_id_tests:test().
|
user_calendar_by_id_tests:test().
|
||||||
|
|
||||||
user_test_calendar_view(_Config) ->
|
|
||||||
user_calendar_view_tests:test().
|
|
||||||
|
|
||||||
user_test_events(_Config) ->
|
user_test_events(_Config) ->
|
||||||
user_events_tests:test().
|
user_events_tests:test().
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,11 @@ def do_random_action(bot):
|
|||||||
resp_cal = request("GET", f"{base}/v1/calendars", headers=headers)
|
resp_cal = request("GET", f"{base}/v1/calendars", headers=headers)
|
||||||
if resp_cal and resp_cal.status_code == 200 and resp_cal.json():
|
if resp_cal and resp_cal.status_code == 200 and resp_cal.json():
|
||||||
cal = random.choice(resp_cal.json())
|
cal = random.choice(resp_cal.json())
|
||||||
request("GET", f"{base}/v1/calendars/{cal['id']}/view?month=2026-06", headers=headers)
|
request(
|
||||||
|
"GET",
|
||||||
|
f"{base}/v1/calendars/{cal['id']}/events?from=2026-06-01T00:00:00Z&to=2026-06-30T23:59:59Z",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
elif action == 14:
|
elif action == 14:
|
||||||
request("POST", f"{base}/v1/refresh", json={"refresh_token": "dummy"}, headers=headers)
|
request("POST", f"{base}/v1/refresh", json={"refresh_token": "dummy"}, headers=headers)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -55,9 +55,9 @@
|
|||||||
</request>
|
</request>
|
||||||
<thinktime min="500" max="2000" random="true"/>
|
<thinktime min="500" max="2000" random="true"/>
|
||||||
|
|
||||||
<!-- 5. GET /v1/calendars/:id/view?month=2026-06 – HTML-представление -->
|
<!-- 5. GET /v1/calendars/:id/events – месяц (hot + archive merge) -->
|
||||||
<request subst="true">
|
<request subst="true">
|
||||||
<http url="/v1/calendars/%%_calendar_id%%/view?month=2026-06" method="GET">
|
<http url="/v1/calendars/%%_calendar_id%%/events?from=2026-06-01T00:00:00Z&to=2026-06-30T23:59:59Z" method="GET">
|
||||||
<http_header name="Authorization" value="Bearer %%_token%%"/>
|
<http_header name="Authorization" value="Bearer %%_token%%"/>
|
||||||
</http>
|
</http>
|
||||||
</request>
|
</request>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ admin_refresh_test_() ->
|
|||||||
]}.
|
]}.
|
||||||
|
|
||||||
test_refresh_ok() ->
|
test_refresh_ok() ->
|
||||||
{ok, _Access, Refresh} = logic_auth_session:issue_admin_tokens(?ADMIN_ID, <<"admin">>),
|
{ok, _Access, Refresh, _} = logic_auth_session:issue_admin_tokens(?ADMIN_ID, <<"admin">>),
|
||||||
{Status, _, Body} = eh_test_support:call(admin_handler_refresh, #{
|
{Status, _, Body} = eh_test_support:call(admin_handler_refresh, #{
|
||||||
method => <<"POST">>,
|
method => <<"POST">>,
|
||||||
path => <<"/v1/admin/refresh">>,
|
path => <<"/v1/admin/refresh">>,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
-include_lib("eunit/include/eunit.hrl").
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
|
|
||||||
-define(TABLES, [user, calendar, event, booking, admin, subscription]).
|
-define(TABLES, [user, calendar, event, booking, admin, subscription, calendar_share]).
|
||||||
|
|
||||||
setup() ->
|
setup() ->
|
||||||
eh_test_support:start_mnesia(),
|
eh_test_support:start_mnesia(),
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-module(calendar_share_migration_tests).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
setup() ->
|
||||||
|
mnesia:stop(),
|
||||||
|
mnesia:delete_schema([node()]),
|
||||||
|
mnesia:create_schema([node()]),
|
||||||
|
mnesia:start(),
|
||||||
|
{atomic, ok} = mnesia:create_table(calendar_share, [
|
||||||
|
{ram_copies, [node()]},
|
||||||
|
{attributes, [calendar_id, user_id, rights]}
|
||||||
|
]),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
cleanup(_) ->
|
||||||
|
catch mnesia:delete_table(calendar_share),
|
||||||
|
catch mnesia:delete_table(calendar_share_invite),
|
||||||
|
mnesia:stop(),
|
||||||
|
mnesia:delete_schema([node()]),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
calendar_share_migration_test_() ->
|
||||||
|
{foreach, fun setup/0, fun cleanup/1, [
|
||||||
|
{"transforms old calendar_share to id+mirror", fun test_transform_old_share/0},
|
||||||
|
{"idempotent when already new schema", fun test_idempotent_new_schema/0}
|
||||||
|
]}.
|
||||||
|
|
||||||
|
test_transform_old_share() ->
|
||||||
|
%% Pre-#73 row shape: PK = calendar_id (no id / mirror_to_default).
|
||||||
|
ok = mnesia:dirty_write({calendar_share, <<"cal1">>, <<"u1">>, write}),
|
||||||
|
?assertEqual([calendar_id, user_id, rights],
|
||||||
|
mnesia:table_info(calendar_share, attributes)),
|
||||||
|
ok = '20260815220000_calendar_share_invite':up(),
|
||||||
|
?assertEqual(record_info(fields, calendar_share),
|
||||||
|
mnesia:table_info(calendar_share, attributes)),
|
||||||
|
[Rec] = mnesia:dirty_match_object(#calendar_share{calendar_id = <<"cal1">>, _ = '_'}),
|
||||||
|
?assertMatch(#calendar_share{user_id = <<"u1">>, rights = write,
|
||||||
|
mirror_to_default = false}, Rec),
|
||||||
|
?assert(is_binary(Rec#calendar_share.id)),
|
||||||
|
?assert(lists:member(calendar_share_invite, mnesia:system_info(tables))),
|
||||||
|
{ok, Share} = core_calendar_share:upsert(<<"cal2">>, <<"u2">>, admin, true),
|
||||||
|
?assertEqual(admin, Share#calendar_share.rights),
|
||||||
|
?assertEqual(true, Share#calendar_share.mirror_to_default).
|
||||||
|
|
||||||
|
test_idempotent_new_schema() ->
|
||||||
|
ok = '20260815220000_calendar_share_invite':up(),
|
||||||
|
ok = '20260815220000_calendar_share_invite':up(),
|
||||||
|
?assertEqual(record_info(fields, calendar_share),
|
||||||
|
mnesia:table_info(calendar_share, attributes)).
|
||||||
@@ -28,9 +28,11 @@ cleanup(_) ->
|
|||||||
core_auth_session_test_() ->
|
core_auth_session_test_() ->
|
||||||
{foreach, fun setup/0, fun cleanup/1, [
|
{foreach, fun setup/0, fun cleanup/1, [
|
||||||
{"Create session", fun test_create/0},
|
{"Create session", fun test_create/0},
|
||||||
|
{"Create with device", fun test_create_device/0},
|
||||||
{"Rotate session updates jti", fun test_rotate/0},
|
{"Rotate session updates jti", fun test_rotate/0},
|
||||||
{"Reuse detection", fun test_reuse/0},
|
{"Reuse detection", fun test_reuse/0},
|
||||||
{"Revoke family", fun test_revoke_family/0}
|
{"Revoke family", fun test_revoke_family/0},
|
||||||
|
{"List active and revoke others", fun test_list_and_revoke_others/0}
|
||||||
]}.
|
]}.
|
||||||
|
|
||||||
test_create() ->
|
test_create() ->
|
||||||
@@ -38,9 +40,16 @@ test_create() ->
|
|||||||
?assertEqual(<<"admin1">>, Session#auth_session.subject_id),
|
?assertEqual(<<"admin1">>, Session#auth_session.subject_id),
|
||||||
?assertEqual(admin, Session#auth_session.subject_type),
|
?assertEqual(admin, Session#auth_session.subject_type),
|
||||||
?assertEqual(false, Session#auth_session.revoked),
|
?assertEqual(false, Session#auth_session.revoked),
|
||||||
|
?assertEqual(<<>>, Session#auth_session.device_name),
|
||||||
{ok, Loaded} = core_auth_session:get(Session#auth_session.session_id),
|
{ok, Loaded} = core_auth_session:get(Session#auth_session.session_id),
|
||||||
?assertEqual(Session#auth_session.session_id, Loaded#auth_session.session_id).
|
?assertEqual(Session#auth_session.session_id, Loaded#auth_session.session_id).
|
||||||
|
|
||||||
|
test_create_device() ->
|
||||||
|
{ok, Session} = core_auth_session:create(
|
||||||
|
<<"u1">>, user, <<"web">>, <<"Chrome · Win">>, <<"Mozilla">>),
|
||||||
|
?assertEqual(<<"Chrome · Win">>, Session#auth_session.device_name),
|
||||||
|
?assertEqual(<<"Mozilla">>, Session#auth_session.user_agent).
|
||||||
|
|
||||||
test_rotate() ->
|
test_rotate() ->
|
||||||
{ok, Session} = core_auth_session:create(<<"admin1">>, admin, <<"admin">>),
|
{ok, Session} = core_auth_session:create(<<"admin1">>, admin, <<"admin">>),
|
||||||
OldJti = Session#auth_session.current_jti,
|
OldJti = Session#auth_session.current_jti,
|
||||||
@@ -61,3 +70,16 @@ test_revoke_family() ->
|
|||||||
ok = core_auth_session:revoke_family(S1#auth_session.family_id),
|
ok = core_auth_session:revoke_family(S1#auth_session.family_id),
|
||||||
?assertMatch({error, revoked},
|
?assertMatch({error, revoked},
|
||||||
core_auth_session:rotate(S1#auth_session.session_id, S1#auth_session.current_jti)).
|
core_auth_session:rotate(S1#auth_session.session_id, S1#auth_session.current_jti)).
|
||||||
|
|
||||||
|
test_list_and_revoke_others() ->
|
||||||
|
{ok, Keep} = core_auth_session:create(<<"u1">>, user, <<"web">>, <<"A">>, <<"ua">>),
|
||||||
|
{ok, Other} = core_auth_session:create(<<"u1">>, user, <<"mobile">>, <<"B">>, <<"ub">>),
|
||||||
|
Active = core_auth_session:list_active_for_subject(<<"u1">>, user),
|
||||||
|
?assertEqual(2, length(Active)),
|
||||||
|
{ok, 1} = core_auth_session:revoke_others_for_subject(
|
||||||
|
<<"u1">>, user, Keep#auth_session.session_id),
|
||||||
|
Left = core_auth_session:list_active_for_subject(<<"u1">>, user),
|
||||||
|
?assertEqual(1, length(Left)),
|
||||||
|
?assertEqual(Keep#auth_session.session_id, (hd(Left))#auth_session.session_id),
|
||||||
|
{ok, Revoked} = core_auth_session:get(Other#auth_session.session_id),
|
||||||
|
?assertEqual(true, Revoked#auth_session.revoked).
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ ensure_indexes(Tables) when is_list(Tables) ->
|
|||||||
{event, [calendar_id, title, created_at, start_time, event_type,
|
{event, [calendar_id, title, created_at, start_time, event_type,
|
||||||
master_id, specialist_id, status]},
|
master_id, specialist_id, status]},
|
||||||
{booking, [event_id, user_id, status]},
|
{booking, [event_id, user_id, status]},
|
||||||
|
{waitlist_entry, [event_id, user_id, status]},
|
||||||
{review_vote, [review_id, user_id]},
|
{review_vote, [review_id, user_id]},
|
||||||
{calendar, [owner_id, status, short_name, category]},
|
{calendar, [owner_id, status, short_name, category]},
|
||||||
{calendar_specialist, [calendar_id, user_id]},
|
{calendar_specialist, [calendar_id, user_id]},
|
||||||
@@ -95,9 +96,12 @@ ensure_indexes(Tables) when is_list(Tables) ->
|
|||||||
{verification, [user_id]},
|
{verification, [user_id]},
|
||||||
{password_reset, [user_id]},
|
{password_reset, [user_id]},
|
||||||
{notification, [user_id, is_read]},
|
{notification, [user_id, is_read]},
|
||||||
|
{push_subscription, [user_id, endpoint]},
|
||||||
{auth_session, [family_id, subject_id]},
|
{auth_session, [family_id, subject_id]},
|
||||||
{report, [resolved_by]},
|
{report, [resolved_by]},
|
||||||
{ticket, [assigned_to]}
|
{ticket, [assigned_to]},
|
||||||
|
{month_snapshot, [calendar_id, year_month]},
|
||||||
|
{ai_hint_metric, [user_id, day]}
|
||||||
]),
|
]),
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
@@ -141,7 +145,11 @@ table_opts(admin) ->
|
|||||||
table_opts(calendar) ->
|
table_opts(calendar) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||||
table_opts(calendar_share) ->
|
table_opts(calendar_share) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)},
|
||||||
|
{index, [calendar_id, user_id]}];
|
||||||
|
table_opts(calendar_share_invite) ->
|
||||||
|
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share_invite)},
|
||||||
|
{index, [calendar_id, invitee_user_id, invitee_email, token, status]}];
|
||||||
table_opts(calendar_follow) ->
|
table_opts(calendar_follow) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
|
||||||
table_opts(calendar_specialist) ->
|
table_opts(calendar_specialist) ->
|
||||||
@@ -156,6 +164,8 @@ table_opts(recurrence_exception) ->
|
|||||||
[{ram_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
|
[{ram_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
|
||||||
table_opts(booking) ->
|
table_opts(booking) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
||||||
|
table_opts(waitlist_entry) ->
|
||||||
|
[{ram_copies, [node()]}, {attributes, record_info(fields, waitlist_entry)}];
|
||||||
table_opts(review) ->
|
table_opts(review) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, review)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, review)}];
|
||||||
table_opts(review_vote) ->
|
table_opts(review_vote) ->
|
||||||
@@ -176,6 +186,8 @@ table_opts(admin_audit) ->
|
|||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
|
||||||
table_opts(notification) ->
|
table_opts(notification) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, notification)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, notification)}];
|
||||||
|
table_opts(push_subscription) ->
|
||||||
|
[{ram_copies, [node()]}, {attributes, record_info(fields, push_subscription)}];
|
||||||
table_opts(stats_counter) ->
|
table_opts(stats_counter) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
|
||||||
table_opts(stats_daily) ->
|
table_opts(stats_daily) ->
|
||||||
@@ -194,7 +206,11 @@ table_opts(node_metric) ->
|
|||||||
[{ram_copies, [node()]}, {local_content, true},
|
[{ram_copies, [node()]}, {local_content, true},
|
||||||
{attributes, record_info(fields, node_metric)}];
|
{attributes, record_info(fields, node_metric)}];
|
||||||
table_opts(schema_migration) ->
|
table_opts(schema_migration) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, schema_migration)}].
|
[{ram_copies, [node()]}, {attributes, record_info(fields, schema_migration)}];
|
||||||
|
table_opts(month_snapshot) ->
|
||||||
|
[{ram_copies, [node()]}, {attributes, record_info(fields, month_snapshot)}];
|
||||||
|
table_opts(ai_hint_metric) ->
|
||||||
|
[{ram_copies, [node()]}, {attributes, record_info(fields, ai_hint_metric)}].
|
||||||
|
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% Domain seeds
|
%%% Domain seeds
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user