Compare commits
27 Commits
73ff212020
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 99855dc2f6 | |||
| 59726d394c | |||
| b79fd81260 | |||
| 588a282b6d | |||
| 65afff6629 | |||
| 4a96834d8e | |||
| 14da77fd7e | |||
| f68f39f5f9 | |||
| 9b4499e884 | |||
| 4dd741ba48 | |||
| 1182568c9d | |||
| 515db26a0c | |||
| 3189390794 | |||
| 07e89052fe | |||
| 02e49a40f5 | |||
| dbabc4e8f3 | |||
| 436e0a70c0 | |||
| 0fa213a1f8 | |||
| 28e2795199 | |||
| 938a022ad8 | |||
| d451753be2 | |||
| d7c5ae3ac4 | |||
| 1bed282cfb | |||
| 5fa00a44bf | |||
| 80e10caf3d | |||
| e87236b981 | |||
| 8e8e361e77 |
+120
-119
@@ -1,119 +1,120 @@
|
||||
---
|
||||
description: Типичные ошибки агентов EventHub — shell, git, docker, scope, токены (корень + субагенты)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# EventHub: типичные ошибки агентов (избегать)
|
||||
|
||||
Канон. Субагентам: включать в промпт «следуй `agent-pitfalls.mdc` + `shell-wsl-not-powershell.mdc`».
|
||||
|
||||
## 1. Shell / PowerShell
|
||||
|
||||
| Симптом | Причина | Как правильно |
|
||||
|---------|---------|---------------|
|
||||
| `bash: /mnt/c/.../.tmp-*.sh: No such file or directory` при том что `ls` файл видит | **CRLF** от Cursor Write: shebang = `bash\r` | **Всегда** `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../script.sh` (или `sed -i 's/\r$//' && bash`). **Не** голый `wsl -e bash /mnt/c/.../script.sh` |
|
||||
| `unexpected EOF`, `ParserError`, пустой curl | PS ломает `"`, `$`, heredoc, `&&` | Файл `.sh` → `run-wsl-sh.sh` |
|
||||
| `bash: syntax error near '('` | `grep -E ^(...)` в PS; **`fix(ci):` / `(…)` в `wsl -lc` one-liner** | Только внутри `.sh`; commit → `-F` файл (см. ниже) |
|
||||
| `{{.Names}}: command not found` | `docker --format "{{…}}"` в PS | Формат в `.sh` или простой `docker ps` |
|
||||
| `&&` is not valid | PowerShell не bash | Отдельные команды или `.sh` |
|
||||
|
||||
**Запрещено в PS:** голый `wsl -e bash /mnt/c/.../.tmp-*.sh` (без `run-wsl-sh.sh`), `wsl -e bash -lc "…\"…"`, `curl -d "{\"x\":…}"`, heredoc commit, `npm`/`rebar3`.
|
||||
|
||||
Канон запуска скриптов: см. `shell-wsl-not-powershell.mdc` § CRLF.
|
||||
|
||||
## 2. Git
|
||||
|
||||
| Симптом | Как правильно |
|
||||
|---------|---------------|
|
||||
| `#` в `-m` обрезает сообщение | `-F` файл; в тексте `Refs EventHub/EventHubBack#N` |
|
||||
| `syntax error near '('` на commit | Сообщение `fix(ci): …` inline в `wsl -lc '… git-commit.sh … fix(ci): …'` | **Только** `.tmp-commit-msg.txt` + `git commit -F`; или `.sh` без inline `-m` |
|
||||
| `Co-authored-by: Cursor <…>` / `<` ParserError | `git commit -F .tmp-msg.txt` из PS **или** `git-commit.sh` **из `.sh`**, не из PS one-liner |
|
||||
| `editor` / not a terminal | Не `git commit` без `-F` в WSL; из Windows: `git commit -F file` |
|
||||
| Сотни `M` после reset на `/mnt/c` | `git config core.filemode false` (локально); не «чинить» код |
|
||||
| Удалились `.cursor/`, `.idea/` | **`git clean -fdx` только с backup** env + certs; IDE-директории **не трогать** |
|
||||
| Push «висит» / ждёт login | **Не** `git push origin` — только `~/.cursor/eventhub/git-push.sh` / `git-push-main.sh` |
|
||||
| Push заблокирован Auto-review | Push в `master` — только по явной просьбе; `request_smart_mode_approval` |
|
||||
|
||||
**`git clean` / reset к origin:** сначала сохранить `docker/.env*`, `.env.development`, `docker/traefik/certs/*`.
|
||||
|
||||
**Push:** запрещены голые `git push` / `git push origin …`. Канон — `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh <repo-dir>` (токен из secrets).
|
||||
|
||||
**Commit (сообщение со скобками, `#`, `$`, пробелами):** не передавать subject/body аргументом через PowerShell или `wsl -e bash -lc '…'`. Пиши `.tmp-commit-msg.txt` в репо, затем в `.sh`:
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
|
||||
git add path/to/file
|
||||
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
|
||||
```
|
||||
|
||||
Запуск из PS: `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../EventHubBack/.tmp-commit.sh` — **без** голого `wsl -e bash …/.tmp-commit.sh`, **без** `-lc` и **без** `-m "fix(ci): …"`.
|
||||
|
||||
## 3. Docker dev (Swarm локально)
|
||||
|
||||
| Ошибка | Правильно |
|
||||
|--------|-----------|
|
||||
| «Балансировка» → 3× eventhub | **dev:** `traefik=1` + **`eventhub=1`** (см. `EventHubDevOps/docs/STANDS.md`) |
|
||||
| `host-mode port already in use` | У eventhub опубликованы 8080/8445 — **нельзя** scale >1; LB = traefik |
|
||||
| Собрали admin-ui/front без просьбы | «Только бэк» → eventhub (+ traefik если нужен HTTPS), **без** Front/Admin образов |
|
||||
| Эмулятор не остановился | `docker service scale eventhub_bot-emulator-users=0` |
|
||||
| `No such image: eventhub:latest` | Сначала `docker build -t eventhub:latest …` или `docker/build-images.sh` |
|
||||
| Admin API dev не отвечает на :443 | Traefik без ports → **`http://127.0.0.1:8445`**; stage → SSH + `--resolve …:443:127.0.0.1` |
|
||||
|
||||
## 4. Scope (не додумывать)
|
||||
|
||||
- Делать **ровно** запрос; не раздувать (полный `build-images.sh`, 3 ноды, admin-ui, spec commit).
|
||||
- Перед scale/deploy — прочитать compose и STANDS.md.
|
||||
- Секреты: `gitea.ps1` / `.sh` + env; не inline `source secrets` в PS one-liner.
|
||||
|
||||
## 5. Субагенты (Task)
|
||||
|
||||
В промпт субагента добавлять:
|
||||
|
||||
```text
|
||||
Shell: только WSL + .sh (см. shell-wsl-not-powershell.mdc, agent-pitfalls.mdc).
|
||||
Один репо. Итог на русском. Не push без явного OK.
|
||||
```
|
||||
|
||||
## 6. Экономия токенов / лимитов моделей
|
||||
|
||||
### Контекст чата
|
||||
- **Один чат — одна задача**; после «готово / CI зелёный» — новый чат на следующий шаг (см. `WORKFLOW.md` §5).
|
||||
- Workspace: для Back-задачи достаточно **`EventHubBack`** (+ Spec при необходимости); не держать все репо без нужды.
|
||||
- **`@` точечно** — файл/папка, не `@Codebase` без причины.
|
||||
- Не просить «проанализируй все transcripts» — они огромные.
|
||||
|
||||
### Дешёвые vs дорогие действия
|
||||
| Дорого | Дешевле |
|
||||
|--------|---------|
|
||||
| Task explore «very thorough» | `grep` + 1–2 файла |
|
||||
| Полный `build-images.sh` | Только нужный образ |
|
||||
| Весь лог CI (80k строк) | API job + 20 строк вокруг ошибки |
|
||||
| Несколько subagents «на всякий» | 1 subagent, узкий промпт |
|
||||
| Browser deep-walk stage | Smoke-скрипт / один URL |
|
||||
|
||||
### Меньше итераций
|
||||
- Сразу **`.sh`**, не 5 попыток PS one-liner (каждая = новый turn).
|
||||
- Git перед commit: **один** `status` + `diff`, не три раза подряд.
|
||||
- Тесты: `rebar3 eunit --module=…` если трогали один модуль; не полный suite без нужды.
|
||||
- Docker: не rebuild, если `docker images` уже есть нужный tag.
|
||||
|
||||
### Модели и режимы
|
||||
- **Ask / Plan** — вопросы и варианты без tool calls.
|
||||
- **Agent** — только когда нужны команды и правки.
|
||||
- Bugbot / Security Review — не на каждый мелкий diff.
|
||||
|
||||
### Scope в запросе (шаблон)
|
||||
```text
|
||||
Репо: EventHubBack
|
||||
Issue: #N
|
||||
Scope: только …
|
||||
Не: front, docker, push, spec
|
||||
Done: eunit / lint / …
|
||||
```
|
||||
|
||||
### Subagents
|
||||
- Редко; промпт = issue + файлы + done, **без** всей переписки.
|
||||
- `one-repo-per-agent` — меньше контекста и ошибок.
|
||||
|
||||
### Повторяемое — в скрипты
|
||||
Токены verify, smoke creds, stage deploy — **`~/.cursor/eventhub/*.sh`**, не заново через SSH в каждом чате.
|
||||
---
|
||||
description: Типичные ошибки агентов EventHub — shell, git, docker, scope, токены (корень + субагенты)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# EventHub: типичные ошибки агентов (избегать)
|
||||
|
||||
Канон. Субагентам: включать в промпт «следуй `agent-pitfalls.mdc` + `shell-wsl-not-powershell.mdc`».
|
||||
|
||||
## 1. Shell / PowerShell
|
||||
|
||||
| Симптом | Причина | Как правильно |
|
||||
|---------|---------|---------------|
|
||||
| `bash: /mnt/c/.../.tmp-*.sh: No such file or directory` при том что `ls` файл видит | **CRLF** от Cursor Write: shebang = `bash\r` | **Всегда** `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../script.sh` (или `sed -i 's/\r$//' && bash`). **Не** голый `wsl -e bash /mnt/c/.../script.sh` |
|
||||
| IFT e2e: **N skipped**, exit 0 | Нет `SMOKE_USER_*` (голый `npm run test:e2e:ift`) | `run-front-ift.sh` / source `eventhub-ift.env` **до** npm. Не путать со «стенд недоступен». CI уже крутит e2e-ift → локально не дублировать |
|
||||
| `unexpected EOF`, `ParserError`, пустой curl | PS ломает `"`, `$`, heredoc, `&&` | Файл `.sh` → `run-wsl-sh.sh` |
|
||||
| `bash: syntax error near '('` | `grep -E ^(...)` в PS; **`fix(ci):` / `(…)` в `wsl -lc` one-liner** | Только внутри `.sh`; commit → `-F` файл (см. ниже) |
|
||||
| `{{.Names}}: command not found` | `docker --format "{{…}}"` в PS | Формат в `.sh` или простой `docker ps` |
|
||||
| `&&` is not valid | PowerShell не bash | Отдельные команды или `.sh` |
|
||||
|
||||
**Запрещено в PS:** голый `wsl -e bash /mnt/c/.../.tmp-*.sh` (без `run-wsl-sh.sh`), `wsl -e bash -lc "…\"…"`, `curl -d "{\"x\":…}"`, heredoc commit, `npm`/`rebar3`.
|
||||
|
||||
Канон запуска скриптов: см. `shell-wsl-not-powershell.mdc` § CRLF.
|
||||
|
||||
## 2. Git
|
||||
|
||||
| Симптом | Как правильно |
|
||||
|---------|---------------|
|
||||
| `#` в `-m` обрезает сообщение | `-F` файл; в тексте `Refs EventHub/EventHubBack#N` |
|
||||
| `syntax error near '('` на commit | Сообщение `fix(ci): …` inline в `wsl -lc '… git-commit.sh … fix(ci): …'` | **Только** `.tmp-commit-msg.txt` + `git commit -F`; или `.sh` без inline `-m` |
|
||||
| `Co-authored-by: Cursor <…>` / `<` ParserError | `git commit -F .tmp-msg.txt` из PS **или** `git-commit.sh` **из `.sh`**, не из PS one-liner |
|
||||
| `editor` / not a terminal | Не `git commit` без `-F` в WSL; из Windows: `git commit -F file` |
|
||||
| Сотни `M` после reset на `/mnt/c` | `git config core.filemode false` (локально); не «чинить» код |
|
||||
| Удалились `.cursor/`, `.idea/` | **`git clean -fdx` только с backup** env + certs; IDE-директории **не трогать** |
|
||||
| Push «висит» / ждёт login | **Не** `git push origin` — только `~/.cursor/eventhub/git-push.sh` / `git-push-main.sh` |
|
||||
| Push заблокирован Auto-review | Push в `master` — только по явной просьбе; `request_smart_mode_approval` |
|
||||
|
||||
**`git clean` / reset к origin:** сначала сохранить `docker/.env*`, `.env.development`, `docker/traefik/certs/*`.
|
||||
|
||||
**Push:** запрещены голые `git push` / `git push origin …`. Канон — `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh <repo-dir>` (токен из secrets).
|
||||
|
||||
**Commit (сообщение со скобками, `#`, `$`, пробелами):** не передавать subject/body аргументом через PowerShell или `wsl -e bash -lc '…'`. Пиши `.tmp-commit-msg.txt` в репо, затем в `.sh`:
|
||||
|
||||
```bash
|
||||
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
|
||||
git add path/to/file
|
||||
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
|
||||
```
|
||||
|
||||
Запуск из PS: `wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/.../EventHubBack/.tmp-commit.sh` — **без** голого `wsl -e bash …/.tmp-commit.sh`, **без** `-lc` и **без** `-m "fix(ci): …"`.
|
||||
|
||||
## 3. Docker dev (Swarm локально)
|
||||
|
||||
| Ошибка | Правильно |
|
||||
|--------|-----------|
|
||||
| «Балансировка» → 3× eventhub | **dev:** `traefik=1` + **`eventhub=1`** (см. `EventHubDevOps/docs/STANDS.md`) |
|
||||
| `host-mode port already in use` | У eventhub опубликованы 8080/8445 — **нельзя** scale >1; LB = traefik |
|
||||
| Собрали admin-ui/front без просьбы | «Только бэк» → eventhub (+ traefik если нужен HTTPS), **без** Front/Admin образов |
|
||||
| Эмулятор не остановился | `docker service scale eventhub_bot-emulator-users=0` |
|
||||
| `No such image: eventhub:latest` | Сначала `docker build -t eventhub:latest …` или `docker/build-images.sh` |
|
||||
| Admin API dev не отвечает на :443 | Traefik без ports → **`http://127.0.0.1:8445`**; stage → SSH + `--resolve …:443:127.0.0.1` |
|
||||
|
||||
## 4. Scope (не додумывать)
|
||||
|
||||
- Делать **ровно** запрос; не раздувать (полный `build-images.sh`, 3 ноды, admin-ui, spec commit).
|
||||
- Перед scale/deploy — прочитать compose и STANDS.md.
|
||||
- Секреты: `gitea.ps1` / `.sh` + env; не inline `source secrets` в PS one-liner.
|
||||
|
||||
## 5. Субагенты (Task)
|
||||
|
||||
В промпт субагента добавлять:
|
||||
|
||||
```text
|
||||
Shell: только WSL + .sh (см. shell-wsl-not-powershell.mdc, agent-pitfalls.mdc).
|
||||
Один репо. Итог на русском. Не push без явного OK.
|
||||
```
|
||||
|
||||
## 6. Экономия токенов / лимитов моделей
|
||||
|
||||
### Контекст чата
|
||||
- **Один чат — одна задача**; после «готово / CI зелёный» — новый чат на следующий шаг (см. `WORKFLOW.md` §5).
|
||||
- Workspace: для Back-задачи достаточно **`EventHubBack`** (+ Spec при необходимости); не держать все репо без нужды.
|
||||
- **`@` точечно** — файл/папка, не `@Codebase` без причины.
|
||||
- Не просить «проанализируй все transcripts» — они огромные.
|
||||
|
||||
### Дешёвые vs дорогие действия
|
||||
| Дорого | Дешевле |
|
||||
|--------|---------|
|
||||
| Task explore «very thorough» | `grep` + 1–2 файла |
|
||||
| Полный `build-images.sh` | Только нужный образ |
|
||||
| Весь лог CI (80k строк) | API job + 20 строк вокруг ошибки |
|
||||
| Несколько subagents «на всякий» | 1 subagent, узкий промпт |
|
||||
| Browser deep-walk stage | Smoke-скрипт / один URL |
|
||||
|
||||
### Меньше итераций
|
||||
- Сразу **`.sh`**, не 5 попыток PS one-liner (каждая = новый turn).
|
||||
- Git перед commit: **один** `status` + `diff`, не три раза подряд.
|
||||
- Тесты: `rebar3 eunit --module=…` если трогали один модуль; не полный suite без нужды.
|
||||
- Docker: не rebuild, если `docker images` уже есть нужный tag.
|
||||
|
||||
### Модели и режимы
|
||||
- **Ask / Plan** — вопросы и варианты без tool calls.
|
||||
- **Agent** — только когда нужны команды и правки.
|
||||
- Bugbot / Security Review — не на каждый мелкий diff.
|
||||
|
||||
### Scope в запросе (шаблон)
|
||||
```text
|
||||
Репо: EventHubBack
|
||||
Issue: #N
|
||||
Scope: только …
|
||||
Не: front, docker, push, spec
|
||||
Done: eunit / lint / …
|
||||
```
|
||||
|
||||
### Subagents
|
||||
- Редко; промпт = issue + файлы + done, **без** всей переписки.
|
||||
- `one-repo-per-agent` — меньше контекста и ошибок.
|
||||
|
||||
### Повторяемое — в скрипты
|
||||
Токены verify, smoke creds, stage deploy — **`~/.cursor/eventhub/*.sh`**, не заново через SSH в каждом чате.
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
---
|
||||
description: EventHub — локальные сборки/тесты только через WSL (Windows toolchain сломан)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# EventHub: рабочее окружение агента
|
||||
|
||||
На этой машине **не использовать** нативный Windows для:
|
||||
|
||||
| Стек | Нельзя (Windows) | Нужно |
|
||||
|------|------------------|--------|
|
||||
| Front / FrontAdmin | `npm`, `npx`, Playwright | **WSL** `npm` |
|
||||
| Back (Erlang) | Windows `erl` / `rebar3` | **WSL** + `scripts/wsl-dev-env.sh` |
|
||||
|
||||
## Front (EventHubFront)
|
||||
|
||||
```bash
|
||||
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront && npm run lint && npm run build && npm run test:e2e'
|
||||
```
|
||||
|
||||
## FrontAdmin
|
||||
|
||||
```bash
|
||||
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFrontAdmin && npm run lint && npm run build'
|
||||
```
|
||||
|
||||
## Back
|
||||
|
||||
```bash
|
||||
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack && source scripts/wsl-dev-env.sh && rebar3 eunit'
|
||||
```
|
||||
|
||||
Подробности: `EventHubFront/.cursor/rules/npm-wsl.mdc`, `EventHubBack/.cursor/rules/otp-rebar-wsl.mdc`.
|
||||
---
|
||||
description: EventHub — локальные сборки/тесты только через WSL (Windows toolchain сломан)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# EventHub: рабочее окружение агента
|
||||
|
||||
На этой машине **не использовать** нативный Windows для:
|
||||
|
||||
| Стек | Нельзя (Windows) | Нужно |
|
||||
|------|------------------|--------|
|
||||
| Front / FrontAdmin | `npm`, `npx`, Playwright | **WSL** `npm` |
|
||||
| Back (Erlang) | Windows `erl` / `rebar3` | **WSL** + `scripts/wsl-dev-env.sh` |
|
||||
|
||||
## Front (EventHubFront)
|
||||
|
||||
```bash
|
||||
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront && npm run lint && npm run build && npm run test:e2e'
|
||||
```
|
||||
|
||||
## FrontAdmin
|
||||
|
||||
```bash
|
||||
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFrontAdmin && npm run lint && npm run build'
|
||||
```
|
||||
|
||||
## Back
|
||||
|
||||
```bash
|
||||
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack && source scripts/wsl-dev-env.sh && rebar3 eunit'
|
||||
```
|
||||
|
||||
Подробности: `EventHubFront/.cursor/rules/npm-wsl.mdc`, `EventHubBack/.cursor/rules/otp-rebar-wsl.mdc`.
|
||||
|
||||
Front IFT e2e: не голый `npm run test:e2e:ift` (получите skipped). Нужны `SMOKE_USER_*` — `run-front-ift.sh` / `eventhub-ift.env`.
|
||||
|
||||
@@ -1,130 +1,130 @@
|
||||
---
|
||||
description: EventHub — команды только через WSL/bash; PowerShell для сложного запрещён
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# EventHub: как запускать команды (обязательно)
|
||||
|
||||
## Проблема
|
||||
PowerShell в Cursor **ломает** кавычки, `$`, JSON, heredoc и вложенный `bash -lc "..."`. Из‑за этого агенты снова и снова получают `unexpected EOF`, `Invalid JSON`, пустой curl.
|
||||
|
||||
## Правило
|
||||
1. **Нельзя** гонять сложные one-liner’ы в PowerShell (npm, curl+JSON, python -c, git commit heredoc, вложенный bash с кавычками).
|
||||
2. **Можно** в PowerShell только простые вещи: `cd`, `git status`, `ls`, одиночный `wsl …` без вложенных кавычек в аргументе.
|
||||
3. Рабочий способ — **WSL bash** или **файл `.sh` + launcher** (см. ниже).
|
||||
|
||||
## CRLF / «No such file or directory» (обязательно)
|
||||
|
||||
Cursor `Write` на Windows часто сохраняет `.sh` с **CRLF**. Тогда:
|
||||
|
||||
```text
|
||||
wsl -e bash /mnt/c/.../EventHubFront/.tmp-run.sh
|
||||
# bash: .../.tmp-run.sh: No such file or directory
|
||||
```
|
||||
|
||||
Файл **есть** (`ls` его видит) — ломается shebang (`#!/usr/bin/env bash\r`). Это **не** «файл не записался».
|
||||
|
||||
### Канон запуска любого `.sh` с `/mnt/c/...`
|
||||
|
||||
**Всегда** через хелпер (снимает `\r`, потом `bash`):
|
||||
|
||||
```text
|
||||
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront/.tmp-run.sh
|
||||
```
|
||||
|
||||
**Запрещено** после `Write` голый:
|
||||
|
||||
```text
|
||||
wsl -e bash /mnt/c/.../repo/.tmp-something.sh
|
||||
```
|
||||
|
||||
Альтернатива без хелпера (тот же смысл):
|
||||
|
||||
```text
|
||||
wsl -e bash -lc 'sed -i "s/\r$//" /mnt/c/.../script.sh && bash /mnt/c/.../script.sh'
|
||||
```
|
||||
|
||||
Предпочтительно класть долгоживущие скрипты в `/mnt/c/Users/alexc/.cursor/eventhub/` и тоже гонять через `run-wsl-sh.sh`.
|
||||
|
||||
## Канон (копируй)
|
||||
|
||||
### Вариант A — скрипт (предпочтительно)
|
||||
1. Запиши команды в файл, например `EventHubBack/.tmp-run.sh` или `~/.cursor/eventhub/….sh` (`#!/usr/bin/env bash`).
|
||||
2. Запусти **только** так (из PowerShell, без вложенных кавычек):
|
||||
|
||||
```text
|
||||
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack/.tmp-run.sh
|
||||
```
|
||||
|
||||
### Вариант B — один простой WSL вызов
|
||||
Только если внутри **одинарные** кавычки bash и нет `"`/`$` конфликтов с PS (и **нет** нового `.sh` с диска C):
|
||||
|
||||
```text
|
||||
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront && npm run lint'
|
||||
```
|
||||
|
||||
В PowerShell для `-lc` используй **одинарные** кавычки снаружи (`'...'`), внутри — обычный bash.
|
||||
|
||||
### Запрещено
|
||||
```text
|
||||
wsl -e bash /mnt/c/.../.tmp-run.sh # без run-wsl-sh / sed — CRLF → No such file
|
||||
wsl -e bash -lc ".... python -c \"import...\" ...." # ломается
|
||||
curl ... -d "{\"email\":\"$x\"}" # в PS ломается
|
||||
git commit -m "$(cat <<'EOF' ...)" # в PS не так
|
||||
wsl -e bash -lc 'git-commit.sh … fix(ci): subject' # ( ) ломают bash
|
||||
wsl -e bash -lc 'git commit -m "fix(ci): …"' # то же
|
||||
npm / npx / node из Windows # сломан
|
||||
```
|
||||
|
||||
### Git commit — канон (subject с `( )`, `#`, `$`)
|
||||
|
||||
1. Текст коммита — в файл репо, напр. `.tmp-commit-msg.txt` (без heredoc из PS).
|
||||
2. Скрипт `.tmp-commit.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
|
||||
git add path1 path2
|
||||
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
|
||||
```
|
||||
|
||||
3. Из PowerShell:
|
||||
|
||||
```text
|
||||
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack/.tmp-commit.sh
|
||||
```
|
||||
|
||||
**Не** передавать сообщение аргументом в `git-commit.sh` / `-m` через `wsl -lc` — скобки в `fix(ci):` парсятся как subshell.
|
||||
|
||||
## Стек → команда
|
||||
|
||||
| Задача | Как |
|
||||
|-----|-----|
|
||||
| Front lint/build/e2e | `.sh` + `run-wsl-sh.sh` или `-lc` с PATH-fix + `npm …` |
|
||||
| Front перед push main | сначала `npm run test:e2e:ift` в WSL, потом `git-push-main.sh` |
|
||||
| Back rebar/eunit | `source scripts/wsl-dev-env.sh` в WSL |
|
||||
| Git commit/push | **commit:** `.tmp-commit-msg.txt` + `git commit -F` в `.sh` через `run-wsl-sh.sh`; **push:** `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh …` |
|
||||
| JSON/API/python | скрипт `.sh`/`.py` + `run-wsl-sh.sh` |
|
||||
|
||||
## Git push (обязательно)
|
||||
|
||||
**Запрещено агентам:** `git push`, `git push origin`, `git push origin HEAD` / `master` напрямую — без токена зависают на HTTPS prompt.
|
||||
|
||||
**Канон:**
|
||||
```text
|
||||
bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
|
||||
bash /mnt/c/Users/alexc/.cursor/eventhub/git-push-main.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront
|
||||
```
|
||||
|
||||
Путь WSL: `/mnt/c/Users/alexc/.cursor/eventhub/` (не `~/.cursor/…` — home в WSL может отличаться).
|
||||
|
||||
Credential store в WSL уже настроен (`credential.helper=store` + `git.sabilin.com`); хелпер всё равно предпочтителен (явный URL с токеном из secrets).
|
||||
|
||||
## Перед push Front
|
||||
1. WSL: `npm run lint && npm run build` (ловит синтаксис/TS)
|
||||
2. WSL: `npm run test:e2e:ift`
|
||||
3. Только потом push через `git-push-main.sh`. **Не пушить**, если lint/build/IFT красные.
|
||||
|
||||
## Частые сбои (подробнее)
|
||||
См. **`agent-pitfalls.mdc`**: CRLF/`run-wsl-sh.sh`, git `-F`, `&&` в PS, docker dev scale, `git clean` vs `.cursor/`.
|
||||
---
|
||||
description: EventHub — команды только через WSL/bash; PowerShell для сложного запрещён
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# EventHub: как запускать команды (обязательно)
|
||||
|
||||
## Проблема
|
||||
PowerShell в Cursor **ломает** кавычки, `$`, JSON, heredoc и вложенный `bash -lc "..."`. Из‑за этого агенты снова и снова получают `unexpected EOF`, `Invalid JSON`, пустой curl.
|
||||
|
||||
## Правило
|
||||
1. **Нельзя** гонять сложные one-liner’ы в PowerShell (npm, curl+JSON, python -c, git commit heredoc, вложенный bash с кавычками).
|
||||
2. **Можно** в PowerShell только простые вещи: `cd`, `git status`, `ls`, одиночный `wsl …` без вложенных кавычек в аргументе.
|
||||
3. Рабочий способ — **WSL bash** или **файл `.sh` + launcher** (см. ниже).
|
||||
|
||||
## CRLF / «No such file or directory» (обязательно)
|
||||
|
||||
Cursor `Write` на Windows часто сохраняет `.sh` с **CRLF**. Тогда:
|
||||
|
||||
```text
|
||||
wsl -e bash /mnt/c/.../EventHubFront/.tmp-run.sh
|
||||
# bash: .../.tmp-run.sh: No such file or directory
|
||||
```
|
||||
|
||||
Файл **есть** (`ls` его видит) — ломается shebang (`#!/usr/bin/env bash\r`). Это **не** «файл не записался».
|
||||
|
||||
### Канон запуска любого `.sh` с `/mnt/c/...`
|
||||
|
||||
**Всегда** через хелпер (снимает `\r`, потом `bash`):
|
||||
|
||||
```text
|
||||
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront/.tmp-run.sh
|
||||
```
|
||||
|
||||
**Запрещено** после `Write` голый:
|
||||
|
||||
```text
|
||||
wsl -e bash /mnt/c/.../repo/.tmp-something.sh
|
||||
```
|
||||
|
||||
Альтернатива без хелпера (тот же смысл):
|
||||
|
||||
```text
|
||||
wsl -e bash -lc 'sed -i "s/\r$//" /mnt/c/.../script.sh && bash /mnt/c/.../script.sh'
|
||||
```
|
||||
|
||||
Предпочтительно класть долгоживущие скрипты в `/mnt/c/Users/alexc/.cursor/eventhub/` и тоже гонять через `run-wsl-sh.sh`.
|
||||
|
||||
## Канон (копируй)
|
||||
|
||||
### Вариант A — скрипт (предпочтительно)
|
||||
1. Запиши команды в файл, например `EventHubBack/.tmp-run.sh` или `~/.cursor/eventhub/….sh` (`#!/usr/bin/env bash`).
|
||||
2. Запусти **только** так (из PowerShell, без вложенных кавычек):
|
||||
|
||||
```text
|
||||
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack/.tmp-run.sh
|
||||
```
|
||||
|
||||
### Вариант B — один простой WSL вызов
|
||||
Только если внутри **одинарные** кавычки bash и нет `"`/`$` конфликтов с PS (и **нет** нового `.sh` с диска C):
|
||||
|
||||
```text
|
||||
wsl -e bash -lc 'cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront && npm run lint'
|
||||
```
|
||||
|
||||
В PowerShell для `-lc` используй **одинарные** кавычки снаружи (`'...'`), внутри — обычный bash.
|
||||
|
||||
### Запрещено
|
||||
```text
|
||||
wsl -e bash /mnt/c/.../.tmp-run.sh # без run-wsl-sh / sed — CRLF → No such file
|
||||
wsl -e bash -lc ".... python -c \"import...\" ...." # ломается
|
||||
curl ... -d "{\"email\":\"$x\"}" # в PS ломается
|
||||
git commit -m "$(cat <<'EOF' ...)" # в PS не так
|
||||
wsl -e bash -lc 'git-commit.sh … fix(ci): subject' # ( ) ломают bash
|
||||
wsl -e bash -lc 'git commit -m "fix(ci): …"' # то же
|
||||
npm / npx / node из Windows # сломан
|
||||
```
|
||||
|
||||
### Git commit — канон (subject с `( )`, `#`, `$`)
|
||||
|
||||
1. Текст коммита — в файл репо, напр. `.tmp-commit-msg.txt` (без heredoc из PS).
|
||||
2. Скрипт `.tmp-commit.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
|
||||
git add path1 path2
|
||||
GIT_EDITOR=true git commit -F .tmp-commit-msg.txt
|
||||
```
|
||||
|
||||
3. Из PowerShell:
|
||||
|
||||
```text
|
||||
wsl -e bash /mnt/c/Users/alexc/.cursor/eventhub/run-wsl-sh.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack/.tmp-commit.sh
|
||||
```
|
||||
|
||||
**Не** передавать сообщение аргументом в `git-commit.sh` / `-m` через `wsl -lc` — скобки в `fix(ci):` парсятся как subshell.
|
||||
|
||||
## Стек → команда
|
||||
|
||||
| Задача | Как |
|
||||
|-----|-----|
|
||||
| Front lint/build/e2e | `.sh` + `run-wsl-sh.sh` или `-lc` с PATH-fix + `npm …` |
|
||||
| Front перед push main | сначала `npm run test:e2e:ift` в WSL, потом `git-push-main.sh` |
|
||||
| Back rebar/eunit | `source scripts/wsl-dev-env.sh` в WSL |
|
||||
| Git commit/push | **commit:** `.tmp-commit-msg.txt` + `git commit -F` в `.sh` через `run-wsl-sh.sh`; **push:** `bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh …` |
|
||||
| JSON/API/python | скрипт `.sh`/`.py` + `run-wsl-sh.sh` |
|
||||
|
||||
## Git push (обязательно)
|
||||
|
||||
**Запрещено агентам:** `git push`, `git push origin`, `git push origin HEAD` / `master` напрямую — без токена зависают на HTTPS prompt.
|
||||
|
||||
**Канон:**
|
||||
```text
|
||||
bash /mnt/c/Users/alexc/.cursor/eventhub/git-push.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubBack
|
||||
bash /mnt/c/Users/alexc/.cursor/eventhub/git-push-main.sh /mnt/c/Users/alexc/IdeaProjects/eventHub/EventHubFront
|
||||
```
|
||||
|
||||
Путь WSL: `/mnt/c/Users/alexc/.cursor/eventhub/` (не `~/.cursor/…` — home в WSL может отличаться).
|
||||
|
||||
Credential store в WSL уже настроен (`credential.helper=store` + `git.sabilin.com`); хелпер всё равно предпочтителен (явный URL с токеном из secrets).
|
||||
|
||||
## Перед push Front
|
||||
1. WSL: `npm run lint && npm run build` (ловит синтаксис/TS)
|
||||
2. WSL: `npm run test:e2e:ift`
|
||||
3. Только потом push через `git-push-main.sh`. **Не пушить**, если lint/build/IFT красные.
|
||||
|
||||
## Частые сбои (подробнее)
|
||||
См. **`agent-pitfalls.mdc`**: CRLF/`run-wsl-sh.sh`, git `-F`, `&&` в PS, docker dev scale, `git clean` vs `.cursor/`.
|
||||
|
||||
+5
-2
@@ -1,2 +1,5 @@
|
||||
/.idea/
|
||||
/EventHubSpec.iml
|
||||
/.idea/
|
||||
/EventHubSpec.iml
|
||||
|
||||
# Temporary/scratch files
|
||||
.tmp-*
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
# Архив календаря (Фаза 3, волна 1)
|
||||
|
||||
Канон хранения и чтения истории. Трекер: [Spec#20](https://git.sabilin.com/EventHub/EventHubSpec/issues/20), эпик [Spec#19](https://git.sabilin.com/EventHub/EventHubSpec/issues/19).
|
||||
|
||||
Реализация: Back#76, Front#73. Этот документ — контракт; код подтягивается после него.
|
||||
|
||||
## Цель
|
||||
|
||||
Горячая Mnesia и live-запросы **не растут с годами истории**. Прошлые месяцы остаются usable в том же React-grid, без iframe как основного UX.
|
||||
|
||||
Критерий выигрыша: размер и нагрузка **рабочей** БД (dump/checkpoint, репликация `disc_copies`, `index_read` календаря), не «ещё одна копия тех же строк в другой таблице/ноде».
|
||||
|
||||
## Почему не старый pipeline (#15)
|
||||
|
||||
Старый pipeline (`archive_controller` / `archive_manager` / extra BEAM / HTML `GET …/view`):
|
||||
|
||||
- не планировался и не переносил данные (типы дат, схема `#event{}` разъехалась);
|
||||
- extra BEAM на день/месяц — **больше** схем и load, не меньше;
|
||||
- HTML `/view` и renderer **удалены**: история только через JSON `GET …/events`.
|
||||
|
||||
Extra-node, `slave`/`peer` для архива и построчный `event_archive` **удалены** (не «мёртвый» код).
|
||||
|
||||
## Модель: три яруса, ключ месяц
|
||||
|
||||
Единица архива: **календарь × `YYYY-MM`**. Не строка события.
|
||||
|
||||
| Ярус | Где | Рост | Кто читает |
|
||||
|------|-----|------|------------|
|
||||
| **Hot** | `event`, `booking` (`disc_copies`) | **текущий календарный месяц + всё будущее** | живой grid; выборка по `from`/`to`, не весь календарь |
|
||||
| **Warm** | `month_snapshot` (`disc_only_copies`), ключ `{calendar_id, year_month}` | **3 закрытых месяца** gzip JSON | частый flip на прошлый месяц — один lookup |
|
||||
| **Cold** | `{UPLOAD_DIR}/archive/{calendar_id}/{YYYY-MM}.json.gz` | диск volume, не Mnesia | тот же JSON API |
|
||||
|
||||
### Оценка нагрузки (зачем не 90 дней)
|
||||
|
||||
Живые запросы Front:
|
||||
|
||||
| Путь | Окно |
|
||||
|------|------|
|
||||
| Month grid | ~42 дня viewport (хвосты соседних месяцев) |
|
||||
| Week / ghost | 7–14 дней |
|
||||
| Time Arc hints | сегодня + **21 день** вперёд |
|
||||
| Day | 1 день |
|
||||
|
||||
90 суток lookback в hot **не обслуживает** ни один live-путь: прошлый календарный месяц в UI — уже «архив». Пока строки висят в `event`, `dirty_index_read(calendar_id)` (как сейчас) тащит их на каждый `GET /events`.
|
||||
|
||||
Оценка busy-студии (8 слотов × 8 мастеров ≈ 64 события/день), **только lookback** (будущее всё равно в hot):
|
||||
|
||||
| Lookback | Лишние строки в hot на календарь |
|
||||
|----------|----------------------------------|
|
||||
| 90 суток | ~5800 |
|
||||
| текущий месяц (~17–31 день) | ~1100–2000 |
|
||||
| закрытый месяц после grace | **0** исторических месяцев |
|
||||
|
||||
100 таких студий: 90д lookback ≈ **0.5M** лишних hot-строк в `disc_copies` на **каждый** узел кластера. Warm-blob 24 месяцев в Mnesia тоже тяжёлый (~1 MB gzip/мес × 24 × 100 ≈ 2 GB disc_only). Три месяца warm ≈ **300 MB** на ту же сотню.
|
||||
|
||||
### Дефолты (зафиксировано)
|
||||
|
||||
- **Hot:** все события с `start_time` в **текущем календарном месяце** (TZ календаря / UTC — в реализации, один канон) **или в будущем**.
|
||||
- **Снимок месяца M:** когда наступило **7 суток** следующего месяца (`ARCHIVE_GRACE_DAYS=7`) — хвост week/month-grid и правки конца месяца; затем M **read-only**.
|
||||
- **Warm:** последние **3 закрытых месяца** blob в Mnesia (`ARCHIVE_WARM_MONTHS=3`).
|
||||
- **Cold:** старше 3 закрытых месяцев, **без TTL**.
|
||||
- `GET …/events?from&to` **склеивает ярусы**: viewport августа 1-го числа читает хвост июля из warm/cold + август из hot.
|
||||
- Recurring **master** с вхождениями в hot/будущем остаётся в hot; в snapshot — только вхождения закрытого месяца.
|
||||
- **Booking** остаётся в hot (inbox `/bookings`); в snapshot — копия занятости на момент снимка (через `event_to_json`).
|
||||
- Mutate в archived month → `409`. Reopen — не в волне 1.
|
||||
|
||||
Env (без смены контракта): `ARCHIVE_GRACE_DAYS`, `ARCHIVE_WARM_MONTHS`.
|
||||
|
||||
```
|
||||
Hot --(месяц закрыт + 7д grace)--> Warm snapshot --(>3 закрытых мес)--> Cold file
|
||||
```
|
||||
|
||||
## API чтения (контракт Back#76)
|
||||
|
||||
Клиент **не** переключается на HTML для прошлого месяца.
|
||||
|
||||
- Живой диапазон: `GET /v1/calendars/:id/events?from&to` — hot по времени; если `from`
|
||||
задевает закрытый месяц — **merge** warm/cold (хвост month-grid). Не отдавать весь
|
||||
календарь без `from`/`to` как единственный путь live-grid.
|
||||
- Прошлый месяц (и week/day, курсор в archived month): тот же path и shape JSON. Сервер сам берёт warm snapshot или cold file и отдаёт события, попадающие в `from`/`to`.
|
||||
- Ответ может содержать флаг `archived: true` на уровне списка или заголовка/обёртки — чтобы Front показал бейдж «Архив». Точная форма — в реализации, без второго URL.
|
||||
- `GET /v1/calendars/:id/view` **нет**: HTML-календарь снят.
|
||||
|
||||
ACL: как у живого календаря — owner **или** share `read|write|admin` (кто уже `can_access`). Не только owner. Guest / public commercial: только hot public events; deep archive чужой студии — **не** в волне 1.
|
||||
|
||||
## Запись
|
||||
|
||||
- Create/update/delete событий и booking — только hot.
|
||||
- Scheduler (один узел, как `infra_cleanup`): закрыть месяц → собрать JSON → gzip → `month_snapshot` → удалить из hot строки этого месяца (кроме защищённых master). Затем по TTL — сбросить blob в файл, ужать/убрать запись Mnesia до pointer.
|
||||
- Идемпотентность: повторный прогон того же `{calendar_id, YYYY-MM}` не дублирует и не теряет данные.
|
||||
- Backup (DevOps): volume `eventhub-data` включает `UPLOAD_DIR/archive/`.
|
||||
|
||||
## Front (контракт Front#73)
|
||||
|
||||
- Owner/share, вид месяц/неделя/день, **прошедший** период: React-grid по JSON, не iframe.
|
||||
- Бейдж «Архив».
|
||||
- Lens overview на прошлом месяце — минимум (не «нет lens, потому что HTML»).
|
||||
- Текущий и будущий месяц без регрессии.
|
||||
- Time Arc / ghosts на истории — **не** в волне 1.
|
||||
|
||||
## Non-goals волны 1
|
||||
|
||||
- Write в warm/cold, reopen месяца.
|
||||
- Гостевой архив чужой студии.
|
||||
- Time Arc на прошлых месяцах.
|
||||
- S3 / внешняя БД / extra Erlang node.
|
||||
- Удаление истории по TTL (файлы храним).
|
||||
- Починка `archive_controller`/`slave` как целевой путь.
|
||||
|
||||
## Критерии приёмки канона
|
||||
|
||||
- [x] Этот документ согласован (Spec#20).
|
||||
- [ ] Back#76: scheduler + snapshot + JSON read + тесты; peer/slave не в hot path.
|
||||
- [ ] Front#73: past period на React; e2e навигации на прошлый месяц.
|
||||
- [ ] После тестов: BackSpec/FrontSpec сверены с кодом (не с этим черновиком API-флага).
|
||||
+12
-9
@@ -33,17 +33,20 @@ IA без изменений: top nav + bottom nav mobile; без left sidebar;
|
||||
Runtime-копии в приложении: `EventHubFront/public/brand/` (mark, lockup, favicons, mood app icons).
|
||||
Локальные ZIP (`calentiq-brand-pack-local.zip` и т.п.) — рабочие сборки; в git SoT не кладём.
|
||||
|
||||
## Domain / DNS (чеклист, без покупки)
|
||||
## Domain / DNS (чеклист, `calentiq.com`)
|
||||
|
||||
Публичный бренд ≠ внутренние хосты `*.eventhub.local` / `*.eventhub.test` (DevOps). Перед продом:
|
||||
Публичный бренд ≠ внутренние хосты `*.eventhub.local` / `*.eventhub.test` (DevOps).
|
||||
**Канон apex:** `calentiq.com` (куплен, в использовании на stage / IFT / redirects).
|
||||
|
||||
- [ ] Выбрать домен (кандидат: `calentiq.*` / аналог) — **не покупать** без явного ок владельца
|
||||
- [ ] RDAP/whois: свободен ли домен; нет ли конфликтующих TM
|
||||
- [ ] DNS: A/AAAA или CNAME на edge (Traefik/CDN); отдельно `www` / apex
|
||||
- [ ] TLS: ACME (Let’s Encrypt) или managed cert; проверить SAN
|
||||
- [ ] Почта: SPF/DKIM/DMARC когда будет SMTP (серия B2)
|
||||
- [ ] Stage/IFT: либо оставить `eventhub.*` стенды, либо завести `*.calentiq.*` aliases в DevOps
|
||||
- [ ] Обновить публичные URL в UI/письмах только после cutover
|
||||
Проверка 2026-08-16 (публичный DNS + HTTPS):
|
||||
|
||||
- [x] Домен: `calentiq.com` (куплен, в проде/stage/IFT)
|
||||
- [x] Регистрация: домен занят нами (NS `nameself` / regtime); TM-конфликты — вне DNS-чеклиста
|
||||
- [x] DNS: A `calentiq.com` / `www` / `stage.*` / `api.stage.*` → `195.208.119.190`; apex+www → 308 `https://stage.calentiq.com/`; IFT — split-DNS (`EventHubDevOps/docs/CALENTIQ-CERTS.md`, `STANDS.md`)
|
||||
- [x] TLS: Let’s Encrypt (Traefik `certResolver: le`); SAN stage/IFT живые (issuer LE YR2)
|
||||
- [x] Почта: SPF/DKIM/DMARC для Resend (канон stage) — статус в `EventHubDevOps/docs/SMTP.md`
|
||||
- [x] Stage/IFT aliases `*.calentiq.com` — в DevOps (`STANDS.md`, `CALENTIQ-CERTS.md`)
|
||||
- [x] Публичные URL: `PUBLIC_APP_URL` / `SMTP_FROM=noreply@calentiq.com`; Front same-origin + legal stubs `/privacy` `/terms`
|
||||
|
||||
## Epic
|
||||
|
||||
|
||||
+236
-60
@@ -13,16 +13,27 @@ EventHub — платформа для управления событиями
|
||||
|
||||
### 2.1. Календари
|
||||
- CRUD календаря (название, описание, теги, владелец)
|
||||
- Расшаривание по ссылке / приглашения с правами (`calendar_share`) — **фаза 2** (таблица есть;
|
||||
user HTTP API и UI — Future, см. §2.1.3)
|
||||
- Расшаривание по ссылке / приглашения с правами (`calendar_share`) — invite/accept/revoke +
|
||||
ACL `read|write|admin` (Back#73); personal и commercial (заместитель); UI — Front;
|
||||
не путать со `specialist_invite` / follow.
|
||||
- Типы календарей: `personal` | `commercial` — семантика и коммерческий контур: **§2.1.2**
|
||||
- Гибкое подтверждение заявок: `auto` | `manual` | `{timeout, N}` (секунды) — детали §2.1.2 / §2.3
|
||||
- Теги календаря, рейтинг (средняя оценка, количество голосов)
|
||||
- После успешной верификации email (`POST /v1/verify`, статус пользователя → `active`) система
|
||||
идемпотентно создаёт дефолтный **personal**-календарь владельцу (`logic_calendar:ensure_default_calendar/1`):
|
||||
название — `nickname` или «Мой календарь», `confirmation=manual`. Повторный вызов не создаёт дубликат,
|
||||
если у пользователя уже есть active personal-календарь. Существующим пользователям без календаря
|
||||
backfill не выполняется.
|
||||
идемпотентно нормализует **единственный personal** владельца
|
||||
(`logic_calendar:ensure_default_calendar/1` → `normalize_owner_personals/1`):
|
||||
- title в БД — **`Default`** (EN); UI — i18n «По умолчанию»;
|
||||
- `confirmation=manual`;
|
||||
- если personal нет — создаёт; если есть — оставляет **самый ранний** (`created_at`), title → `Default`;
|
||||
- лишние personal: с событиями/специалистами → `type=commercial` (system path, без требования
|
||||
subscription); пустые → soft-delete.
|
||||
- Повторный вызов идемпотентен. Миграция `20260730200000_single_default_personal` —
|
||||
`backfill_single_personal/0` для всех владельцев.
|
||||
- `POST /v1/calendars` с `type=personal` при уже существующем personal → **409**
|
||||
`{error: "personal_exists"}`.
|
||||
- `DELETE` единственного personal → **403** `{error: "default_calendar"}`.
|
||||
- `PUT` `personal → commercial` на единственном personal → **403**
|
||||
`{error: "default_calendar"}` (новый бизнес — отдельный create commercial).
|
||||
|
||||
**Новые поля (задача #12):**
|
||||
- `short_name` — короткое уникальное имя для API и поиска
|
||||
@@ -36,6 +47,8 @@ EventHub — платформа для управления событиями
|
||||
- `default_duration_minutes` — integer 1..1440
|
||||
- `default_recurrence` — `null` или
|
||||
`{ "enabled": boolean, "freq": "DAILY"|"WEEKLY"|"MONTHLY", "interval": integer ≥ 1 }`
|
||||
- `waitlist_enabled` — boolean (только смысл для commercial; default `false`):
|
||||
лист ожидания на полных слотах (Back#72)
|
||||
- Невалидное значение известного ключа → **400** `{error: "invalid_settings", key: "…"}`
|
||||
(см. EventHub/EventHubBack#63, UI: EventHub/EventHubFront#44)
|
||||
|
||||
@@ -56,7 +69,7 @@ EventHub — платформа для управления событиями
|
||||
| | `personal` | `commercial` |
|
||||
|---|---|---|
|
||||
| Подписка владельца | не требуется | нужна active (trial или paid), иначе **restricted** |
|
||||
| Видимость чужим | только владелец (+ share — фаза 2) | публичный просмотр; в search/discover — только если **не** restricted |
|
||||
| Видимость чужим | только владелец или share grant | публичный просмотр; в search/discover — только если **не** restricted |
|
||||
| Booking чужим | **запрещён** (бэкенд `403`, код `personal_calendar`) | разрешён при active подписке владельца |
|
||||
| Follow | нет (нет доступа) | да |
|
||||
| Специалисты | не используются | CRUD владельца; `specialist_id` на событии |
|
||||
@@ -105,6 +118,10 @@ Trial стартует **только** явным `POST /v1/subscription` с `a
|
||||
|
||||
- `POST /v1/events/:id/bookings` только если календарь события commercial, `booking_open=true`,
|
||||
событие `active`, есть свободная вместимость.
|
||||
- Для **recurring** master в теле обязательно `occurrence_start` (ISO8601). Без поля → `400`
|
||||
(`occurrence_start required`). Невалидное или отменённое вхождение → `400`.
|
||||
Back материализует instance (`is_instance=true`, `master_id`) и вешает booking на его `id`.
|
||||
Для `single` / уже материализованного instance тело опционально, `occurrence_start` игнорируется.
|
||||
- **Pending занимает capacity** наравне с confirmed (защита от overbook при auto/timeout).
|
||||
- Capacity: число booking со статусом `pending` | `confirmed`; `cancelled` и `expired` не считаются.
|
||||
- Политика `confirmation` календаря при создании booking:
|
||||
@@ -121,14 +138,44 @@ Trial стартует **только** явным `POST /v1/subscription` с `a
|
||||
no-op успех (как для `cancelled`).
|
||||
- WS: `booking_update` участнику и владельцу (и specialist при confirm на «своём» событии).
|
||||
|
||||
#### Лист ожидания (commercial, опционально)
|
||||
|
||||
Включается флагом `settings.waitlist_enabled=true` на commercial-календаре
|
||||
(default `false`; personal игнорируется).
|
||||
|
||||
- `POST /v1/events/:id/waitlist` — встать в очередь: commercial, `booking_open`,
|
||||
`waitlist_enabled`, слот полный (`pending`+`confirmed` ≥ capacity), нет своей
|
||||
активной booking, ещё не в очереди. Иначе `400`: `waitlist_disabled` |
|
||||
`not_full` | `already_booked` | `already_on_waitlist` | …
|
||||
- `DELETE /v1/events/:id/waitlist` — выйти из очереди (`left`); `404` если не в waiting.
|
||||
- `GET /v1/events/:id/waitlist` — гость: `{enabled, joined, position?, total}`;
|
||||
owner/specialist: список waiting (FIFO по `created_at`).
|
||||
- **Promote:** при освобождении места (cancel / decline / expire / timeout-cancel)
|
||||
первый `waiting` → booking по политике `confirmation` календаря; статус entry
|
||||
`promoted`; email + in-app `waitlist_promoted`.
|
||||
|
||||
#### Специалисты
|
||||
|
||||
- Специалист = существующий `user`, привязанный к commercial-календарю после **принятия приглашения**.
|
||||
- Специалист = существующий `user`, привязанный к commercial-календарю
|
||||
(`calendar_specialist`, `status` active|inactive).
|
||||
- **Не путать** с `calendar_share` (права read/write/admin — фаза 2).
|
||||
|
||||
##### Owner как specialist (одиночки)
|
||||
|
||||
- При **create** `commercial` (и при системном upgrade personal→commercial) бэкенд
|
||||
**идемпотентно** создаёт строку `calendar_specialist` на `owner_id`:
|
||||
`status=active`, `name` из nickname (иначе email), `specialization=[]`.
|
||||
- Owner-строка **неудаляема**: `DELETE /v1/calendars/:id/specialists/:owner_id` →
|
||||
`403` (`Owner specialist cannot be removed` / `owner_specialist_protected`).
|
||||
- «Убрать себя из специалистов» = `PUT` с `status=inactive` (продуктовая галочка);
|
||||
снова включить — `status=active`. Редактируются также `name` / `specialization`.
|
||||
- Invite себе **не** требуется. Миграций/backfill старых календарей нет (wipe БД
|
||||
на стендах при деплое).
|
||||
|
||||
##### Приглашение (`specialist_invite`)
|
||||
|
||||
Владелец commercial **не** вводит сырой `user_id` в продуктовом UI. Добавление — через invite:
|
||||
Владелец commercial **не** вводит сырой `user_id` в продуктовом UI для **других**
|
||||
специалистов. Добавление команды — через invite:
|
||||
|
||||
| Канал | Как |
|
||||
|-------|-----|
|
||||
@@ -172,7 +219,10 @@ Trial стартует **только** явным `POST /v1/subscription` с `a
|
||||
- `POST /v1/calendars/:id/specialist-invites` — тело: `{ user_id }` **или** `{ email }`,
|
||||
опционально `name`, `specialization`
|
||||
- `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отмена pending (`cancelled`)
|
||||
- `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / remove уже принятого
|
||||
- `PUT /v1/calendars/:id/specialists/:user_id` — update `name` / `specialization` / `status`
|
||||
(в т.ч. owner)
|
||||
- `DELETE /v1/calendars/:id/specialists/:user_id` — remove принятого; для
|
||||
`user_id = owner_id` → **403** (см. Owner как specialist)
|
||||
|
||||
Invitee:
|
||||
|
||||
@@ -193,13 +243,58 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
|
||||
по тем же правилам (owner — все события своих календарей; specialist — только свои слоты);
|
||||
past-pending → `expired` и из ответа исключается.
|
||||
|
||||
#### Фаза 2 (вне текущего контракта реализации)
|
||||
- `calendar_share` (`read` | `write` | `admin`): не путать с follow, specialist_invite и платной subscription.
|
||||
- Реальный эквайринг, waitlist, оплата услуги клиентом (B2C).
|
||||
#### Фаза 2 (вне текущего контракта реализации; трекер Spec#8)
|
||||
- **Pilot / ops:** SMTP / transactional email; backup; secrets / certs / alerts; legal stubs;
|
||||
позиция и боевой эквайринг **подписки владельца** (не оплата услуги клиентом); DNS/SPF
|
||||
для `calentiq.com` (домен в использовании; чеклист `BRANDING.md`).
|
||||
- Waitlist на событие — **сделано** (Back#72): таблица `waitlist_entry`;
|
||||
API `POST/DELETE/GET /v1/events/:id/waitlist`; только commercial с
|
||||
`settings.waitlist_enabled=true`; join при полном слоте; FIFO promote при
|
||||
cancel/decline/expire + email/in-app `waitlist_promoted`. UI join — Front#70.
|
||||
- Серверный logout / revoke session; загрузка файлов avatar/cover —
|
||||
**сделано** (Back#71); вложения к событию — позже.
|
||||
- Email-reminder booking — **сделано** (Back#70, §2.6); Web Push + prefs —
|
||||
**сделано** (Back#75): `GET/PUT /v1/notifications/prefs`,
|
||||
`GET /v1/push/vapid-public-key`, `POST/DELETE /v1/push/subscriptions`;
|
||||
UI — Front#71.
|
||||
- `calendar_share` (`read` | `write` | `admin`): соредактор personal / заместитель commercial;
|
||||
invite/accept/revoke + grants; personal **не** в search/discovery. Не путать с follow,
|
||||
specialist_invite и платной subscription.
|
||||
- Управление своими сессиями + `client_type`/`device_name` — **сделано** (Back#74):
|
||||
`GET/DELETE /v1/sessions`, `POST /v1/sessions/revoke-others`; login принимает
|
||||
`client_type` (`web`|`mobile`) и `device_name`, сохраняет `User-Agent`.
|
||||
UI списка — Front#72. Kick-on-login / нативный клиент — по-прежнему future.
|
||||
- **Не в фазе 2:** оплата услуги клиентом (B2C) — вне горизонта продукта.
|
||||
|
||||
### 2.1.3. Share / приглашения (фаза 2)
|
||||
Таблица `calendar_share` (`calendar_id`, `user_id`, `rights`: `read` | `write` | `admin`)
|
||||
существует в схеме. User HTTP API и клиентский UI — Future до отдельной задачи.
|
||||
#### Фаза 3 / архив (Spec#19, канон Spec#20)
|
||||
- История календаря: hot = текущий месяц + будущее; warm = 3 закрытых месяца;
|
||||
cold = gzip-файлы. Контракт: [`ARCHIVE.md`](ARCHIVE.md). Код: Back#76.
|
||||
|
||||
#### Фаза 3 / гео (Spec#19, канон Spec#21)
|
||||
- Стек: OpenCage (внешний API, прокси через бэк) + MapLibre/OpenFreeMap; Google/Яндекс **не** провайдеры
|
||||
(только outbound-ссылки с карточки). Контракт: [`GEO.md`](GEO.md). Код: Back#77.
|
||||
|
||||
### 2.1.3. Share / приглашения (соредактор и заместитель)
|
||||
|
||||
Таблица `calendar_share` (`id`, `calendar_id`, `user_id`, `rights`: `read` | `write` | `admin`,
|
||||
`mirror_to_default`) и `calendar_share_invite` (зеркало specialist_invite + `rights`).
|
||||
|
||||
- Invite создаёт **owner** или share `admin` (personal **и** commercial).
|
||||
- Accept → upsert grant; для personal `mirror_to_default` default `true` (выбор invitee);
|
||||
commercial mirror игнорируется / false.
|
||||
- ACL: `can_access` personal = owner | share; commercial public view + share edit;
|
||||
`can_edit` = owner | share `write|admin`. DELETE calendar / смена type — только owner.
|
||||
- `GET /v1/calendars` = owned ∪ shared (`role`, `share_rights`, `mirror_to_default`).
|
||||
- Personal **никогда** не discoverable в `/v1/search`.
|
||||
|
||||
API:
|
||||
- `GET/POST /v1/calendars/:id/share-invites`, `DELETE …/:invite_id`
|
||||
- `GET /v1/calendars/:id/shares`, `DELETE/PUT …/shares/:user_id`
|
||||
- `PUT /v1/user/shares/:calendar_id` `{mirror_to_default}`
|
||||
- `GET /v1/user/share-invites`, `POST /v1/share-invites/:id/accept|decline`,
|
||||
`POST /v1/share-invites/accept` `{token}`
|
||||
|
||||
Не путать со `specialist_invite` (booking team) и `calendar_follow` (публичный follow).
|
||||
|
||||
### 2.2. События (расширенная версия с повторяющимися событиями)
|
||||
|
||||
@@ -223,12 +318,11 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
|
||||
- Возвращать как одиночные, так и сгенерированные вхождения в едином списке.
|
||||
|
||||
#### 2.2.3. Материализация при записи участника
|
||||
При записи участника на конкретное вхождение повторяющегося события:
|
||||
- Система материализует (создаёт) физическую запись события для этого вхождения, если оно ещё
|
||||
не было материализовано (например, для хранения количества записавшихся).
|
||||
- Материализованное событие имеет `is_instance = true` и ссылается на `master_id`.
|
||||
- Запись участника (`booking`) всегда привязывается к конкретному экземпляру (материализованному
|
||||
или одиночному событию).
|
||||
`POST /v1/events/:id/bookings` на мастер серии:
|
||||
- в теле JSON: `occurrence_start` — время вхождения;
|
||||
- если instance с этим `start_time` ещё нет — создаётся (`is_instance=true`, `master_id`);
|
||||
- `booking.event_id` — id материализованного вхождения, не master.
|
||||
Одиночные события бронируются как раньше (тело может быть пустым `{}`).
|
||||
|
||||
#### 2.2.4. Изменение и удаление серий
|
||||
- При редактировании мастера можно применить изменения ко всем будущим экземплярам или создать
|
||||
@@ -311,16 +405,36 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
|
||||
- Полнотекстовый поиск по названиям событий, календарей, тегам (при наличии `q` или любого
|
||||
фильтра из списка выше).
|
||||
- Фильтрация по дате, категории, местоположению, рейтингу.
|
||||
- **Гео (волна 2, [GEO.md](GEO.md)):** `lat`+`lon` выключают discovery-tops. События —
|
||||
по `event.location`; commercial-календари — по `settings.default_location`.
|
||||
`radius` **опционален** (1..100 км); без ключа — без отсечения, сортировка `distance`.
|
||||
В ответе `distance_km`. Без координат сущность в geo-выборку не входит.
|
||||
- В элементе `calendars` поле `default_location` (`address`/`lat`/`lon`, либо `null`)
|
||||
возвращается всегда, если у календаря задана точка (Back#77) — вид карты в поиске
|
||||
строится и без гео-запроса. `distance_km` присутствует только при `lat`+`lon` в запросе.
|
||||
- Пагинация результатов (`limit`, `offset`; default `limit=20`, max `100`).
|
||||
- Поиск должен учитывать права доступа (не показывать скрытые/заблокированные календари).
|
||||
|
||||
### 2.6. Расширенные возможности
|
||||
- Локация события (`location` запись с адресом, широтой, долготой).
|
||||
- Локация события (`location` запись с адресом, широтой, долготой). Address-only
|
||||
допустим; lat/lon оба или ни одного. Геокодер: [`GEO.md`](GEO.md)
|
||||
(`GET /v1/geo/suggest`, `POST /v1/geo/geocode`, `POST /v1/geo/reverse`).
|
||||
- Онлайн-ссылка (`online_link`).
|
||||
- Вложения к событию (`attachments`).
|
||||
- История изменений события (`edit_history`).
|
||||
- Заметки пользователя к бронированию (`notes`).
|
||||
- Напоминания о событии (поле `reminder_sent` в бронировании, логика отправки не реализована).
|
||||
- Напоминания о событии: поле `reminder_sent` в бронировании; job
|
||||
`logic_booking:process_reminders/0` (тик `subscription_worker`, ~15s) шлёт email
|
||||
клиенту записи и in-app `event_reminder`, если `status=confirmed`,
|
||||
`reminder_sent=false`, событие `active` и старт в окне
|
||||
`REMINDER_LEAD_HOURS` (default 24). Флаг ставится до отправки (один раз).
|
||||
Шаблон CalenTIQ; ссылка `/c/:calendarId/e/:eventId`.
|
||||
Prefs: `user.preferences.notify_email` / `notify_push` (default true);
|
||||
`GET/PUT /v1/notifications/prefs` → `{ email, push }`.
|
||||
Web Push (Back#75): таблица `push_subscription`; VAPID env
|
||||
`VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT`; send на reminder
|
||||
и waitlist promote (если `notify_push`); 404/410 удаляет subscription.
|
||||
Verify / invite / reset password — email всегда (не через prefs).
|
||||
|
||||
### 2.7. Модерация и безопасность
|
||||
- Пользователи могут отправлять жалобы (`report`) на календари, события, отзывы.
|
||||
@@ -349,7 +463,12 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
|
||||
- Пользователи могут оформить подписку (`subscription`) с разными планами: monthly, quarterly,
|
||||
biannual, annual.
|
||||
- Цены (`logic_subscription:plan_price/1`, minor units): monthly **999**, quarterly **2499**,
|
||||
biannual **4499**, annual **7999**; trial — 0. Платёжный шлюз — заглушка (`process_payment` → ok).
|
||||
biannual **4499**, annual **7999**; trial — 0.
|
||||
- **Пилот (Spec#18, вариант A):** боевого PSP **нет**. `process_payment` — заглушка → ok.
|
||||
Владелец получает commercial через: (1) `POST /v1/subscription` `action=start_trial`,
|
||||
и/или (2) admin activate (`POST /v1/admin/subscriptions/:id`). Реальный эквайринг
|
||||
подписки владельца — отдельная задача после пилота (не блокер soft-launch).
|
||||
- **Вне горизонта:** оплата услуги клиентом (B2C) — не в фазе 2 / не в пилоте.
|
||||
- Статус подписки: `active`, `expired`, `cancelled`.
|
||||
- Отслеживание использования пробного периода (`trial_used`); trial стартует **только**
|
||||
явным `POST /v1/subscription` (`action=start_trial`), не при create/upgrade commercial
|
||||
@@ -409,7 +528,9 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
|
||||
- Полная репликация горячих таблиц между всеми узлами кластера (задача #14).
|
||||
- Автоматическое обнаружение узлов через DNS-имя `eventhub-node` или статический список (задача #14).
|
||||
- Периодическая очистка «мёртвых» узлов из схемы Mnesia (каждые 30 секунд) (задача #14).
|
||||
- Архивирование исторических данных (старше 30 дней) в отдельные Mnesia-узлы с `disc_only_copies` (задача #15).
|
||||
- Архив календаря (Фаза 3, [ARCHIVE.md](ARCHIVE.md), Spec#20): hot `disc_copies`
|
||||
(текущий месяц + будущее) + `month_snapshot` `disc_only` (3 закрытых месяца) +
|
||||
gzip-файлы старше. Не extra-node и не построчный `event_archive` (#15 отвергнут).
|
||||
- Пагинация всех списков.
|
||||
|
||||
### 3.2. Надёжность
|
||||
@@ -424,7 +545,8 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
|
||||
- `EVENTHUB_ENV`: `dev` — допускаются documented fallbacks; `stage`/`prod` — fail-fast при слабых или отсутствующих `JWT_SECRET`, `ADMIN_JWT_SECRET` и паролях seed-админов (#25).
|
||||
- Проверка прав доступа к календарям и событиям.
|
||||
- Пароли хэшируются с использованием Argon2.
|
||||
- Защита от несанкционированного просмотра архивных данных (только владелец календаря) (задача #15).
|
||||
- Архив месяца: тот же ACL, что у живого календаря (`can_access`: owner | share).
|
||||
Guest/public deep-archive чужой студии — не в волне 1 ([ARCHIVE.md](ARCHIVE.md)).
|
||||
|
||||
### 3.4. Наблюдаемость
|
||||
- Логирование в JSON-формате.
|
||||
@@ -453,12 +575,10 @@ Legacy: прямой `POST /v1/calendars/:id/specialists` **не использ
|
||||
```
|
||||
src/
|
||||
├── core/ — бизнес-логика (core_user, core_event, core_booking, core_review, ...)
|
||||
├── handlers/ — обработчики HTTP (handler_login, handler_calendar_view, ...)
|
||||
├── infra/ — инфраструктура (infra_mnesia, infra_sup, cluster_discovery,
|
||||
│ archive_manager, archive_controller, stats_collector,
|
||||
│ migration_engine, ...)
|
||||
├── archive/ — архивирование и рендеринг (archive_controller, archive_manager,
|
||||
│ calendar_html_renderer, archive_fetcher)
|
||||
├── handlers/ — обработчики HTTP (handler_login, handler_events, ...)
|
||||
├── infra/ — infra_mnesia, infra_sup, month_archive_worker, stats_collector,
|
||||
│ migration_engine, ...
|
||||
├── logic/ — в т.ч. logic_month_archive (снимки месяца)
|
||||
├── migrations/ — файлы миграций
|
||||
└── eventhub_app.erl — точка входа приложения
|
||||
```
|
||||
@@ -470,11 +590,23 @@ src/
|
||||
- `POST /v1/register` — регистрация.
|
||||
- `POST /v1/verify` — подтверждение email; при успехе — `active` и дефолтный personal-календарь.
|
||||
- `POST /v1/forgot-password` — `{email}` → всегда `200` (без enumeration); письмо со сбросом
|
||||
только для `active` (stub `logic_email:send_password_reset/2`, как verification).
|
||||
только для `active`. Почта: `logic_email` через SMTP (`SMTP_HOST` и др.); без хоста —
|
||||
log-only. HTML+plain шаблоны: `priv/email/layout.html` (CalenTIQ). Ссылки: `PUBLIC_APP_URL` + `/verify?token=`,
|
||||
`/invites?token=`, `/reset-password?token=` (бренд CalenTIQ).
|
||||
- `POST /v1/reset-password` — `{token, password}` (пароль ≥ 8 символов); успех → новый hash,
|
||||
токен удалён, refresh-сессии user отозваны; `404`/`410`/`400`/`403`.
|
||||
- `POST /v1/login` — вход.
|
||||
- `POST /v1/refresh` — обновление токена.
|
||||
- `POST /v1/login` — вход; опционально `client_type` (`web`|`mobile`, default `web`),
|
||||
`device_name` (max 120); `User-Agent` пишется в сессию. Ответ:
|
||||
`{ token, refresh_token, session_id, user }`. Неверный `client_type` → `400`.
|
||||
- `POST /v1/refresh` — обновление токена; ответ `{ token, refresh_token, session_id }`.
|
||||
- `POST /v1/logout` — `{ refresh_token }` → отзыв текущей `auth_session`; после этого
|
||||
refresh той же сессии → `401`. Access JWT до истечения TTL не отзывается.
|
||||
Невалидный refresh → `401`; отсутствие поля → `400`. Клиент чистит storage даже при ошибке сети.
|
||||
- `GET /v1/sessions` — список активных своих user-сессий (Bearer):
|
||||
`session_id`, `client_type`, `device_name`, `user_agent`, `created_at`, `updated_at`, `expires_at`.
|
||||
- `DELETE /v1/sessions/:id` — отзыв своей сессии; чужая/нет → `404`.
|
||||
- `POST /v1/sessions/revoke-others` — Bearer + `{ refresh_token }`: оставить сессию
|
||||
из refresh, отозвать остальные; чужой subject → `403`; невалидный refresh → `401`.
|
||||
- `GET /v1/user/me` — профиль пользователя.
|
||||
- `PATCH /v1/user/me` — частичное обновление своего профиля: `language` (`ru`|`en`),
|
||||
`nickname`, `timezone`, `phone`, `avatar_url`, `preferences`; смена пароля —
|
||||
@@ -483,42 +615,75 @@ src/
|
||||
- `GET /v1/user/bookings` — бронирования пользователя **как участника**.
|
||||
- `GET /v1/user/booking-requests` — **actionable** pending-заявки к подтверждению, где
|
||||
текущий пользователь — **owner** календаря события или **assigned specialist**
|
||||
(`event.specialist_id` = user и specialist active). Past-pending помечается `expired` и
|
||||
**не** попадает в ответ. Ответ — массив объектов booking + `role` (`owner`|`specialist`) +
|
||||
вложенный `event` (`id`, `calendar_id`, `calendar_title`, `title`, `start_time`, `duration`,
|
||||
`specialist_id`). Confirm/decline — существующий `PUT /v1/bookings/:id`.
|
||||
(`event.specialist_id` = user и specialist active). Учитываются и материализованные
|
||||
occurrence (`is_instance`). Past-pending помечается `expired` и **не** попадает в ответ.
|
||||
- `GET /v1/user/studio-bookings` — pending **и confirmed** на тех же календарях (журнал студии).
|
||||
Форма ответа как у booking-requests (`role` + вложенный `event`).
|
||||
- `GET /v1/user/reviews` — отзывы пользователя.
|
||||
- `GET /v1/user/following` — календари, которые пользователь отслеживает (follow).
|
||||
- `GET /v1/search` — поиск; пустой запрос (только auth + пагинация/`type`) — discovery tops.
|
||||
- `GET /v1/search` — поиск; **без токена** (гость) — только commercial по `can_access`;
|
||||
пустой запрос + пагинация/`type` — discovery tops. Personal **никогда** не в выдаче
|
||||
(в т.ч. свои). С Bearer — commercial + доступные; busy для Time Arc берётся из personal отдельно.
|
||||
В calendar-результатах — `image_url` (если задан на календаре). Upload/multipart
|
||||
**не** входит в контракт: поле URL.
|
||||
- `GET /v1/calendars` — список календарей.
|
||||
Гео: `lat`+`lon` (и опциональный `radius` 1..100) — ближайшие события и календари;
|
||||
`sort=distance`; в элементах `distance_km` ([GEO.md](GEO.md)).
|
||||
- `GET /v1/geo/suggest` — typeahead адреса (OpenCage); **без токена** + IP rate-limit.
|
||||
- `POST /v1/geo/geocode` — `{q, lang?}` → `{address, lat, lon, source}` (Bearer).
|
||||
- `POST /v1/geo/reverse` — `{lat, lon, lang?}` (Bearer). OpenCage недоступен/квота → `503` `geo_unavailable`.
|
||||
- `GET /v1/calendars` — список календарей (auth): owned ∪ shared (`role`, `share_rights`,
|
||||
`mirror_to_default`).
|
||||
- `POST /v1/calendars` — создать календарь (`commercial` → нужна уже active sub/trial;
|
||||
иначе `402`; auto-start trial нет).
|
||||
- `GET /v1/calendars/:id` — календарь (`following`, `booking_open` для текущего контекста).
|
||||
- `GET /v1/calendars/:id` — календарь **или unique `short_name`**. **Без токена** для active commercial (`following: false`);
|
||||
personal без доступа → `403`. С сессией: `following`, `booking_open`.
|
||||
- `PUT /v1/calendars/:id` — обновить календарь (`personal→commercial` → нужна уже
|
||||
active sub/trial, иначе `402`).
|
||||
- `DELETE /v1/calendars/:id` — удалить календарь.
|
||||
- `POST /v1/calendars/:id/follow` — отслеживать чужой календарь.
|
||||
- `DELETE /v1/calendars/:id/follow` — снять follow.
|
||||
- `GET /v1/users/lookup?q=` — typeahead пользователей для invite (минимальный PII, rate-limit).
|
||||
- `GET /v1/calendars/:id/specialists` — список специалистов (владелец).
|
||||
- `GET /v1/calendars/:id/specialists` — список специалистов (гость на commercial — да;
|
||||
personal — как `can_access`).
|
||||
- `PUT/DELETE /v1/calendars/:id/specialists/:user_id` — deactivate / убрать специалиста (владелец).
|
||||
- `GET/POST /v1/calendars/:id/specialist-invites` — исходящие invite / создать (владелец).
|
||||
- `DELETE /v1/calendars/:id/specialist-invites/:invite_id` — отменить pending (владелец).
|
||||
- `GET /v1/user/specialist-invites` — входящие приглашения.
|
||||
- `POST /v1/specialist-invites/:id/accept` | `…/decline` — ответ invitee.
|
||||
- `POST /v1/specialist-invites/accept` `{ token }` — accept по email deep-link.
|
||||
- `GET /v1/calendars/:calendar_id/events` — события календаря.
|
||||
- `GET/POST /v1/calendars/:id/share-invites` — исходящие share invite / создать (owner|admin).
|
||||
- `DELETE /v1/calendars/:id/share-invites/:invite_id` — отменить pending.
|
||||
- `GET /v1/calendars/:id/shares` — принятые grants; `DELETE/PUT …/shares/:user_id`.
|
||||
- `PUT /v1/user/shares/:calendar_id` — свой `mirror_to_default`.
|
||||
- `GET /v1/user/share-invites` — входящие share invites.
|
||||
- `POST /v1/share-invites/:id/accept` | `…/decline`; `POST /v1/share-invites/accept` `{token}`.
|
||||
- `GET /v1/calendars/:calendar_id/events` — события календаря (гость: commercial).
|
||||
В JSON каждого события commercial-календаря — опциональное поле
|
||||
`booking_occupancy`: `"free"` | `"pending"` | `"confirmed"`.
|
||||
Считается по **active** bookings события (`pending` | `confirmed`);
|
||||
`cancelled` / `expired` **не** дают занятость. Приоритет агрегата:
|
||||
`confirmed` > `pending` > `free` (если есть хотя бы один confirmed →
|
||||
`"confirmed"`; иначе если есть pending → `"pending"`; иначе `"free"`).
|
||||
Для personal: поле можно omit или всегда `"free"`.
|
||||
Virtual occurrences (expand списка): occupancy по **event id в ответе**
|
||||
(материализованный instance, если он уже есть и его id отдан; иначе —
|
||||
тот id, с которым Back отдаёт вхождение — обычно master / шаблон —
|
||||
bookings смотрятся по этому id).
|
||||
- `POST /v1/calendars/:calendar_id/events` — создать событие (тело может включать
|
||||
опциональный `specialist_id`; invalid → `400`).
|
||||
- `GET /v1/events/:id` — событие.
|
||||
- `GET /v1/events/:id` — событие (тот же контракт `booking_occupancy`, что
|
||||
у списка events выше).
|
||||
- `PUT /v1/events/:id` — обновить событие (в т.ч. `specialist_id`; invalid → `400`).
|
||||
- `DELETE /v1/events/:id` — удалить событие.
|
||||
- `GET /v1/events/:id/occurrences` — вхождения повторяющегося события.
|
||||
- `DELETE /v1/events/:id/occurrences/:start_time` — отменить вхождение серии.
|
||||
- `POST /v1/events/:id/bookings` — запись на событие.
|
||||
- `POST /v1/events/:id/bookings` — запись на событие. Recurring master: тело
|
||||
`{ "occurrence_start": "<ISO8601>" }` обязательно; booking на материализованный instance.
|
||||
- `GET /v1/events/:id/bookings` — список бронирований события (владелец).
|
||||
- `POST /v1/events/:id/waitlist` — лист ожидания (commercial + `settings.waitlist_enabled`);
|
||||
см. §2.1.2 «Лист ожидания».
|
||||
- `DELETE /v1/events/:id/waitlist` — выйти из очереди.
|
||||
- `GET /v1/events/:id/waitlist` — статус (гость) или список (owner/specialist).
|
||||
- `GET /v1/bookings/:id` — статус бронирования.
|
||||
- `PUT /v1/bookings/:id` — подтвердить/отклонить (`confirm`|`decline`); владелец —
|
||||
любые booking календаря; active specialist — только события со своим `specialist_id`.
|
||||
@@ -540,7 +705,8 @@ src/
|
||||
- `GET /v1/subscription` — подписка пользователя.
|
||||
- `POST /v1/subscription` — `action=start_trial` | `activate` (+ `plan`, опционально
|
||||
`payment_info`). Trial — только через `start_trial`, не через create commercial.
|
||||
- `GET /v1/calendars/:calendar_id/view?month=YYYY-MM` — HTML-календарь (владелец), включая архив.
|
||||
- `GET /v1/calendars/:calendar_id/events?from&to` — события диапазона: **hot** для живого окна;
|
||||
archived month — из `month_snapshot` / файла, тот же JSON ([ARCHIVE.md](ARCHIVE.md), Back#76).
|
||||
|
||||
### WebSocket (порт 8081)
|
||||
- `WS /ws` — пользовательский канал real-time (подписка на обновления событий календаря; детали — §2.11).
|
||||
@@ -550,6 +716,7 @@ src/
|
||||
- `GET /v1/admin/stats` — агрегированная статистика дашборда.
|
||||
- `POST /v1/admin/login` — вход администратора.
|
||||
- `POST /v1/admin/refresh` — обновление пары access/refresh JWT администратора.
|
||||
- `POST /v1/admin/logout` — `{ refresh_token }` → отзыв admin `auth_session` (как user logout).
|
||||
- `GET /v1/admin/users`, `GET /v1/admin/users/:id` — пользователи.
|
||||
- `GET /v1/admin/users/:id/verification-token` — get/create email-verify token (стенды).
|
||||
- `GET /v1/admin/users/:id/password-reset-token` — get/create password-reset token (стенды).
|
||||
@@ -588,43 +755,47 @@ src/
|
||||
| `current_jti` | Актуальный jti refresh JWT |
|
||||
| `expires_at` | Срок жизни сессии (30 дней) |
|
||||
| `revoked` | Флаг отзыва |
|
||||
| `device_name` | Человекочитаемая метка устройства (с login; может быть пустой) |
|
||||
| `user_agent` | `User-Agent` с login (может быть пустой) |
|
||||
| `created_at` / `updated_at` | Создание / последняя ротация или отзыв |
|
||||
|
||||
**Refresh JWT (admin)** — claims:
|
||||
- `typ=refresh`, `aud=admin`, `sub=<admin_id>`
|
||||
- `sid=<session_id>`, `fid=<family_id>`, `jti=<current_jti>`
|
||||
- `client=admin`, `exp`, `iat`
|
||||
|
||||
**Refresh JWT (user, фаза 2)** — claims:
|
||||
**Refresh JWT (user)** — claims:
|
||||
- `typ=refresh`, `aud=user`, `sub=<user_id>`
|
||||
- `sid`, `fid`, `jti` — как у admin
|
||||
- `client=web` (фаза 3: `mobile`), `exp`, `iat`
|
||||
- `client=web` \| `mobile` (из сессии; refresh не меняет device/UA)
|
||||
- Подпись: `JWT_SECRET` (user JWK), отдельно от admin
|
||||
|
||||
**Поток login admin** (`POST /v1/admin/login`):
|
||||
1. Проверка email/password.
|
||||
2. Создание записи `auth_session`.
|
||||
3. Ответ: `{ token, refresh_token, user }`.
|
||||
3. Ответ: `{ token, refresh_token, session_id, user }`.
|
||||
|
||||
**Поток refresh admin** (`POST /v1/admin/refresh`):
|
||||
1. Верификация подписи и срока refresh JWT.
|
||||
2. Сверка `jti` из JWT с `current_jti` в Mnesia.
|
||||
3. При совпадении — ротация: новый `jti`, новая пара токенов.
|
||||
3. При совпадении — ротация: новый `jti`, новая пара токенов + `session_id`.
|
||||
4. При несовпадении (reuse) — `revoke_family`, ответ 401.
|
||||
|
||||
**Поток login user** (`POST /v1/login`):
|
||||
1. Проверка email/password.
|
||||
2. Создание записи `auth_session` (`subject_type=user`, `client_type=web`).
|
||||
3. Ответ: `{ token, refresh_token, user }`.
|
||||
2. Создание `auth_session` (`subject_type=user`, `client_type`, `device_name`, `user_agent`).
|
||||
3. Ответ: `{ token, refresh_token, session_id, user }`.
|
||||
|
||||
**Поток refresh user** (`POST /v1/refresh`):
|
||||
1. Верификация refresh JWT (`aud=user`).
|
||||
2. Сверка `jti` с `current_jti` в Mnesia, ротация при успехе.
|
||||
2. Сверка `jti` с `current_jti` в Mnesia, ротация при успехе; `client` берётся из сессии.
|
||||
3. Reuse — `revoke_family`, 401.
|
||||
|
||||
**Фазы внедрения:**
|
||||
- Фаза 1 (#23): admin API — реализовано.
|
||||
- Фаза 2 (#26): user login + `/v1/refresh` — реализовано.
|
||||
- Фаза 3: client web + mobile (явный `client_type`, тот же контракт).
|
||||
- Фаза 3 (Back#74): явный `client_type` web|mobile, device meta, list/revoke sessions — реализовано.
|
||||
Kick-on-login / concurrent policy по `client_type` — не сделано.
|
||||
|
||||
### Legacy (не используется login/refresh user)
|
||||
- Таблица `session` (`ram_copies`) и opaque refresh в `core_session` — оставлены в кодовой базе, hot path user API переведён на `auth_session`.
|
||||
@@ -652,10 +823,15 @@ src/
|
||||
- Система не поддерживает транзакционную целостность между несколькими таблицами на уровне
|
||||
приложения (полагаемся на Mnesia).
|
||||
- В текущей версии отсутствует полноценная система уведомлений (только таблица).
|
||||
- Загрузка файлов (вложения) пока не реализована.
|
||||
- Серверный рендеринг календаря работает только для владельца календаря.
|
||||
- Автоматическое архивирование через `archive_controller` в локальном режиме использует
|
||||
`slave:start`, который устарел; в production планируется `peer`.
|
||||
- Загрузка файлов: **avatar/cover** — `POST /v1/user/me/avatar`,
|
||||
`POST /v1/calendars/:id/cover` (multipart field `file`); MIME jpeg/png/webp
|
||||
(magic bytes), лимит `UPLOAD_MAX_BYTES` (default 2 MiB) → 413/415;
|
||||
хранение local disk `UPLOAD_DIR` (default `/app/data/uploads` на volume
|
||||
`eventhub-data`); отдача `GET /v1/media/:kind/:owner/:file` (public).
|
||||
Вложения к событию — пока не реализованы.
|
||||
- HTML `GET …/view` **удалён**; архив читается JSON-ом (Spec#20 / [ARCHIVE.md](ARCHIVE.md)).
|
||||
- `archive_controller` + extra-node (`slave`/`peer`) **удалены**; архив — `month_snapshot`
|
||||
на тех же нодах (Back#76, [ARCHIVE.md](ARCHIVE.md)).
|
||||
|
||||
## 9. ТРЕБОВАНИЯ К ОКРУЖЕНИЮ
|
||||
- Erlang/OTP 28
|
||||
|
||||
+125
-37
@@ -21,15 +21,19 @@ Deep-link на календарь, владельцем которого явл
|
||||
Эндпоинты:
|
||||
- `POST /v1/register` — регистрация (`email`, `password`); статус пользователя `pending` до верификации.
|
||||
- `POST /v1/verify` — подтверждение email `{ token }`; после успеха у пользователя есть дефолтный personal-календарь.
|
||||
- `POST /v1/login` — вход; ответ `{ token, refresh_token, user }`. Сессия `auth_session` с `client_type=web`.
|
||||
- `POST /v1/refresh` — ротация пары токенов по `{ refresh_token }`.
|
||||
- `POST /v1/login` — вход; тело: `email`, `password`, `client_type=web`, `device_name`
|
||||
(метка браузера/ОС). Ответ `{ token, refresh_token, session_id, user }`.
|
||||
- `POST /v1/refresh` — ротация по `{ refresh_token }`; ответ включает `session_id`.
|
||||
- `POST /v1/logout` — `{ refresh_token }` до очистки storage.
|
||||
- `GET /v1/sessions`, `DELETE /v1/sessions/:id`, `POST /v1/sessions/revoke-others` —
|
||||
активные сессии в профиле (устройство, «текущая», отзыв / выйти на других).
|
||||
- `GET /v1/user/me` — профиль текущего пользователя.
|
||||
|
||||
Реализация:
|
||||
- JWT в `localStorage` под ключами, отличными от Admin UI (избежать коллизии при общем origin).
|
||||
- JWT + `session_id` в `localStorage` (ключи, отличные от Admin UI).
|
||||
- Axios: `Authorization: Bearer <access>`; при 401 — single-flight refresh; при неудаче — редирект на `/login`.
|
||||
- При старте приложения — `GET /v1/user/me` при наличии токена.
|
||||
- Logout — только клиентский (очистка storage); серверного revoke нет.
|
||||
- Logout — `POST /v1/logout`, затем clear storage.
|
||||
- WebSocket: `ws://…:8081/ws?token=<access_jwt>`; subscribe/unsubscribe по `calendar_id`.
|
||||
|
||||
## 3. Технологический стек
|
||||
@@ -66,7 +70,8 @@ Deep-link на календарь, владельцем которого явл
|
||||
клике на вкладку; при смене mood — `DEFAULT_LENS_BY_MOOD`. Workspace chrome: (1) селектор;
|
||||
(2) период + вид Месяц/Неделя/День; (3) owner actions — для commercial только
|
||||
«+ Новое событие» («Заполнить расписание» — во вкладке Студия). Без flip / CREATOR.
|
||||
Нет lens на HTML-архиве месяца / не-calendar routes.
|
||||
Нет lens на не-calendar routes. Прошлый месяц — React-grid (не HTML-iframe); lens overview
|
||||
на истории — минимум ([ARCHIVE.md](ARCHIVE.md), Front#73).
|
||||
- **Week view (Front#40):** одна строка day-headers (без дубля заголовков
|
||||
`WeekDayColumn`); горизонтальный скролл через `.eh-cal-week-scroll` (mobile ~390).
|
||||
- Контекст виджета: свой календарь (селектор) или browse чужого (после поиска). Чужой `personal` —
|
||||
@@ -92,12 +97,14 @@ Deep-link на календарь, владельцем которого явл
|
||||
- **Месяц + Студия:** density markers в ячейках дня, **не** список чипов слотов.
|
||||
- **Personal:** без studio-агрегата и без вкладки «Команда» / Team.
|
||||
- ★ `0.0` на free-слотах **не** показывать.
|
||||
- Agenda / rail desktop (commercial owner): вкладки **Расписание | Команда | Студия** в
|
||||
- Agenda / rail desktop (commercial owner, `≥ 1024px`): вкладки **Расписание | Команда | Студия** в
|
||||
общем `.eh-owner-rail-panel` **фиксированной высоты** (вкладки не прыгают). Команда:
|
||||
invite свёрнут за «Пригласить». **Студия** = hub: about + «Заполнить расписание» +
|
||||
«Редактировать» (+ link `/calendars`); Delete только на `/calendars`. Mobile: segmented
|
||||
**День | Команда | Студия** под grid. Списки — `ScrollRegion` (полоса скрыта, стрелки
|
||||
по краям при overflow); dialogs — thin scrollbar on hover.
|
||||
«Редактировать» (+ link `/calendars`); Delete только на `/calendars`. Phone **и tablet**:
|
||||
segmented **День | Команда | Студия** под grid (не боковой rail — иначе колонки недели
|
||||
сжимаются). Карточка события: sheet `< 768px`, боковая панель `≥ 768px`. Списки —
|
||||
`ScrollRegion` (полоса скрыта, стрелки по краям при overflow); dialogs — thin scrollbar
|
||||
on hover.
|
||||
- Agenda empty: personal «Нет событий»; commercial «Свободных окон нет». UI-тип commercial —
|
||||
«Студия» (не «коммерческий»).
|
||||
- Выбор события открывает карточку действий: mobile — bottom sheet; desktop — боковая панель.
|
||||
@@ -109,10 +116,12 @@ Deep-link на календарь, владельцем которого явл
|
||||
- Поиск (`/search`): query/filters в URL params (восстановление при возврате); chip type фильтрует
|
||||
discovery tops без ухода из Popular; в строке результата — id snippet и `calendar_title` для event.
|
||||
- CRUD своих календарей — `/calendars` (из «Ещё»). Legacy `/discover`, `/calendars/:id` → redirects.
|
||||
- На мобиле — bottom tab bar (+ safe-area); на desktop — **одна строка** chrome:
|
||||
logo + mood chip | nav tabs | nickname + logout (не два ряда header+nav). Сырой email
|
||||
в chrome не показывать — primary identity = `nickname` (fallback без `@`).
|
||||
Mobile: logo mark-only + mood chip icon-only (Front#45); desktop — полный wordmark + label.
|
||||
- На **phone и tablet** (`< 1024px`, Tailwind `lg`) — одна primary nav: bottom tab bar
|
||||
(+ safe-area). На **desktop** (`≥ 1024px`) — **одна строка** chrome:
|
||||
logo + mood chip | nav tabs | nickname + logout (не два ряда header+nav, не bottom+top
|
||||
одновременно). Сырой email в chrome не показывать — primary identity = `nickname`
|
||||
(fallback без `@`). Mobile: logo mark-only + mood chip icon-only (Front#45);
|
||||
desktop — полный wordmark + label.
|
||||
Nav `aria-current` синхронизирован с `useLocation` (без remount `Outlet` по pathname).
|
||||
- Mood themes — см. §3.2.
|
||||
|
||||
@@ -147,28 +156,46 @@ CONTROL→bookings. Контент: free gaps / события сегодня /
|
||||
|
||||
### 3.3. Time Arc (живая сетка, Front#50)
|
||||
|
||||
На commercial week/day в режиме Студия клиент считает предложенный час без LLM и без Back: свободные слоты студии ∩ занятость пользователя (personal + confirmed bookings) × mood (FLOW — воздух и середина дня, MOMENTUM — ближайший, CONTROL — доля свободных мест). Одна ячейка — `data-time-arc`; коллизии — `data-collided`. Тап — обычный Book.
|
||||
На commercial week/day в режиме Студия клиент считает предложенный час без LLM и без Back: свободные слоты студии ∩ занятость пользователя (personal + confirmed bookings + **mirror** shared-personal) × mood (FLOW — воздух и середина дня, MOMENTUM — ближайший, CONTROL — доля свободных мест). Одна ячейка — `data-time-arc`; коллизии — `data-collided`. Тап — обычный Book.
|
||||
|
||||
Владелец: ghost-слоты из прошлой ISO-недели в дырах текущей; «Проявить неделю» — preview, затем существующий POST events. Без подтверждения в API не пишем. EventHubAiRouter не используется.
|
||||
Владелец **или** share `write|admin` (заместитель): черновики слотов из прошлой ISO-недели в дырах текущей; «Создать неделю» —
|
||||
preview, затем существующий POST events. После создания черновик снимается сразу
|
||||
(без F5), в том числе если время в форме сдвинули (прошлый час → +1 день / правка start).
|
||||
Фраза с часом уже в прошлом («11» в 14:00) ставит черновик на следующий день, тот же час.
|
||||
|
||||
**v2 (Front#51):** отказ от дуги (тап другой свободной ячейки / свайп недели) пишет штраф часу и мастеру в `localStorage` (`eh.timeArc.skips`) — дуга переезжает. ≥2 confirmed записей к одному `specialist_id` бустят его слоты. Месяц: тепло `data-time-arc-heat` на днях со скоренным часом. Personal: призрак «тот же час +7д» после прошедшего confirmed визита; тап открывает студию.
|
||||
**v2 (Front#51):** отказ от дуги (тап другой свободной ячейки / свайп недели) пишет штраф часу и мастеру в `localStorage` (`eh.timeArc.skips`) — дуга переезжает. ≥2 confirmed записей к одному `specialist_id` бустят его слоты. Месяц: тепло `data-time-arc-heat` на днях со скоренным часом. Personal: черновик «тот же час +7д» после прошедшего confirmed визита; тап открывает студию.
|
||||
|
||||
**v3 (дуга между календарями):** `/following` сортирует студии по ближайшему Time Arc; тап открывает неделю студии с курсором на этом дне (`data-time-arc` как в v1). Discover остаётся списком: на строке calendar — «когда» (`data-time-arc-when`), без общей сетки нескольких студий. Ghosts владельца считаются по активной студии (переключение календаря не смешивает паттерны). По-прежнему без LLM / Back AI.
|
||||
**v3 (дуга между календарями):** `/following` сортирует студии по ближайшему Time Arc
|
||||
(`start_time`); баннер «Ближайший час» — **min** среди подписок, без дубля `when`
|
||||
на той же строке списка. `/search`: баннер nearest — другая студия, чем первая
|
||||
карточка выдачи (если совпадает — следующий hint); Discover остаётся списком
|
||||
с `data-time-arc-when` на остальных calendar-строках. Тап открывает неделю студии
|
||||
с курсором на этом дне. Черновики владельца по активной студии. Без LLM / Back AI.
|
||||
|
||||
**v4 (Front#53):** pending occupancy тянет demand-ghosts на тот же час в свободные дни текущей ISO-недели (`data-time-arc-demand`). Long-press пустого часа week/day у владельца commercial — локальный разбор фразы → phrase-ghost (`data-time-arc-phrase`); клик и long-press месяца по-прежнему открывают create. Специалист на чужой студии: дуга на своём будущем pending-слоте. Без LLM / AiRouter.
|
||||
**v4 (Front#53):** pending occupancy тянет demand-черновики на тот же час в свободные дни текущей ISO-недели (`data-time-arc-demand`). Long-press пустого часа week/day у владельца commercial — локальный разбор фразы → phrase-черновик (`data-time-arc-phrase`); клик и long-press месяца по-прежнему открывают create. Специалист на чужой студии: дуга на своём будущем pending-слоте. Без LLM / AiRouter.
|
||||
|
||||
**Share / заместитель (Back#73, согласование Front#49):** `GET /v1/calendars` включает shared;
|
||||
busy Time Arc += события personal с `mirror_to_default`; ghost/demand/phrase у share
|
||||
`write|admin`; Following не смешивать с share. Confirmed foreign bookings overlay на
|
||||
default personal — только `role=owner` ids в ownSet.
|
||||
|
||||
Канон ИИ клиента (подсказки / голос, Future — [PRODUCT-AI.md](PRODUCT-AI.md), см. §7.3):
|
||||
умность — в ячейках; опциональные hint-ghosts и голос-кнопка mark → ActionPlan →
|
||||
подтверждение. Не чат; AiRouter вне продукта.
|
||||
|
||||
## 4. Маршруты
|
||||
|
||||
Публичные:
|
||||
- `/login`, `/register`, `/verify`
|
||||
- `/login`, `/register`, `/verify`, `/forgot-password`, `/reset-password`
|
||||
- `/search`, `/discover` — каталог без сессии
|
||||
- `/c/:calendarId`, `/c/:calendarId/e/:eventId` — read-only неделя commercial-студии;
|
||||
`calendarId` — UUID **или** `short_name` (гость: active commercial).
|
||||
запись / follow / «мой календарь» → `/login?next=`
|
||||
|
||||
Защищённые (`ProtectedRoute`):
|
||||
- `/default` — resolve единственного personal → `/c/:id` (post-login и tab «Календарь»)
|
||||
- `/default/e/:eventId` — то же + карточка события
|
||||
- `/` — alias → `/default`
|
||||
- `/c/:calendarId` — workspace с календарём
|
||||
- `/c/:calendarId/e/:eventId` — workspace + карточка события
|
||||
- `/search` — поиск календарей/событий
|
||||
- `/bookings` — grouped inbox: (A) к подтверждению (owner/specialist pending) +
|
||||
(B) мои записи участника; deep-link в `/c/.../e/...`
|
||||
- `/calendars` — управление своими календарями (create только Студия; personal delete UI скрыт)
|
||||
@@ -189,13 +216,26 @@ Redirects: `/discover` → `/search`; `/calendars/:id` → `/c/:id`; `/calendars
|
||||
Регистрация, верификация, вход, восстановление сессии.
|
||||
|
||||
### 5.2. Профиль
|
||||
`GET /v1/user/me` + форма на `/profile`. Редактируемые через `PATCH /v1/user/me`: `nickname`, `phone`, `timezone`, `avatar_url`, `language`; смена пароля — `current_password` + `password`. Read-only в UI: `id`, `email`, `role`, `status`. Mood — chip в AppShell рядом с BrandWordmark (+ compact на auth); persist `preferences.mood` через `PATCH /v1/user/me` (**не** в `/more`, **не** в calendar toolbar). Язык при первом визите — из браузера; при первом логине без `language` — пишется в профиль. Mood по умолчанию `calm`, при логине не сидится. Lens — только localStorage (не профиль).
|
||||
`GET /v1/user/me` + форма на `/profile`. Редактируемые через `PATCH /v1/user/me`: `nickname`, `phone`, `timezone`, `avatar_url`, `language`; смена пароля — `current_password` + `password`. Upload аватара: `POST /v1/user/me/avatar` (multipart `file`) → обновляет `avatar_url` (Front#69). Read-only в UI: `id`, `email`, `role`, `status`. Mood — chip в AppShell рядом с BrandWordmark (+ compact на auth); persist `preferences.mood` через `PATCH /v1/user/me` (**не** в `/more`, **не** в calendar toolbar). Язык при первом визите — из браузера; при первом логине без `language` — пишется в профиль. Mood по умолчанию `calm`, при логине не сидится. Lens — только localStorage (не профиль).
|
||||
|
||||
### 5.3. Поиск (участник)
|
||||
`GET /v1/search` — фильтры (`type`, `q`, `tags`, даты, `lat`/`lon`/`radius`, sort, order, пагинация). Без `q`/дат/tags/geo/sort — топы календарей и событий; иначе — поиск. В списке бейдж типа — `calendar`|`event` (не `personal`/`commercial`). Даты, теги, geo и сортировка свёрнуты по умолчанию. Переход к `/c/:id` или `/c/:id/e/:eventId`.
|
||||
В результатах calendar (и event, если Back отдаёт) может быть `image_url` — Discover показывает
|
||||
cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-discover-media--fallback`,
|
||||
не fake photo-hero). Битый `image_url` → fallback. **Upload API нет** — поле URL (seed / ручная установка).
|
||||
не fake photo-hero). Битый `image_url` → fallback. Cover upload владельца: `POST /v1/calendars/:id/cover` (Front#69); URL также можно задать через `PUT` `image_url`.
|
||||
|
||||
**Гео (волна 2, [GEO.md](GEO.md), Front#74):** основной путь — «рядом со мной» (GPS) или
|
||||
центр по адресу (suggest), не ручные lat/lon. Радиус опционален (пресеты 1/5/10/25/50 км
|
||||
и «без ограничения»). В выдаче — `distance_km`. Список, не map-first.
|
||||
|
||||
**Вид карты (Back#77):** у результатов segment-переключатель «Список | Карта»
|
||||
(`?view=map` в URL, переживает перезагрузку). Данные — тот же `GET /v1/search`
|
||||
(`useSearch`); в режиме карты пагинация списка и блок «ближайшее» скрыты.
|
||||
Маркеры группируются по точке (~1 м): пин календаря с бейджем числа событий в группе,
|
||||
событие с отличной точкой — отдельная малая точка; клик по маркеру → попап со списком
|
||||
карточек группы (те же href `/c/:id` и `/c/:cid/e/:eid`, что в списке). Результаты без
|
||||
координат не рисуются — счётчик «Без адреса: N». При активном гео-фильтре — точка центра
|
||||
и круг радиуса. Тайлы OpenFreeMap (MapLibre GL, без ключа); компонент карты — lazy-chunk.
|
||||
|
||||
### 5.4. Календари
|
||||
- Список своих: `GET /v1/calendars`
|
||||
@@ -204,7 +244,9 @@ cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-disco
|
||||
(UI: «Отслеживать» в about, страница `/following`; не путать с `/subscription`)
|
||||
- Просмотр коммерческого чужого календаря по id; при `booking_open=false` — баннер
|
||||
«Запись временно недоступна», кнопка записи скрыта
|
||||
- HTML month view владельца: `GET /v1/calendars/:calendar_id/view?month=YYYY-MM` — при переключении виджета на **прошедший месяц** (owner + вид «Месяц») автоматически подменяет клиентский grid серверным HTML (live/архив, задача #15). Текущий и будущие месяцы — React-виджет по `GET …/events`.
|
||||
- Прошедший месяц/неделя/день: тот же React-виджет по `GET …/events?from&to` (сервер отдаёт
|
||||
snapshot/файл); бейдж «Архив». HTML `GET …/view` **нет** ([ARCHIVE.md](ARCHIVE.md),
|
||||
Front#73). Текущий и будущие месяцы — без изменения, только hot.
|
||||
- Создание / апгрейд `commercial` без **уже active** sub/trial → `402` → `/subscription`
|
||||
(trial — явный `start_trial`, не auto при create); после оплаты —
|
||||
возврат к созданию/редактированию
|
||||
@@ -238,7 +280,13 @@ cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-disco
|
||||
опционально `booking_occupancy` (`"free"` | `"pending"` | `"confirmed"`;
|
||||
приоритет confirmed > pending > free; см. BackSpec §6). Studio-агрегат /
|
||||
popover мастеров опираются на это поле (+ capacity/bookings при необходимости).
|
||||
- CRUD владельца: `POST/PUT/DELETE` (в т.ч. опциональный `specialist_id`)
|
||||
- CRUD владельца: `POST/PUT/DELETE` (в т.ч. опциональный `specialist_id`).
|
||||
Create-форма: **capacity = 1** (или `settings.default_capacity` студии);
|
||||
`specialist_id` подставляется из текущего roster-фильтра сетки; recurrence —
|
||||
из org defaults календаря. **WeekFill:** горизонт по умолчанию **1 неделя**;
|
||||
«Создать расписание» disabled, пока нет именованных слотов и выбранного
|
||||
специалиста; превью считает, сколько событий будет создано; пустой apply —
|
||||
один error toast, не success.
|
||||
- Детали: `GET /v1/events/:id` (отображение специалиста, если задан;
|
||||
тот же `booking_occupancy`)
|
||||
- Вхождения: `GET /v1/events/:id/occurrences`
|
||||
@@ -254,7 +302,8 @@ cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-disco
|
||||
(actionable pending, где user — owner календаря или `event.specialist_id`; past-pending
|
||||
Back отдаёт как `expired` и **не** включает в inbox); те же
|
||||
`PUT /v1/bookings/:id` `{ action: confirm | decline }` (на `expired` → `409`)
|
||||
- UI `/bookings`: две группы (пустые скрывать): **К подтверждению** + **Мои записи**
|
||||
- UI `/bookings`: три группы (пустые скрывать): **К подтверждению** (`booking-requests`) +
|
||||
**Записи студии** (`GET /v1/user/studio-bookings`, confirmed) + **Мои записи**
|
||||
(upcoming/past participant). `specialist_invite` сюда **не** попадает.
|
||||
Empty state: title + hint + CTA Discover / «Мои календари».
|
||||
Время слота на list-карточках — soft (secondary to title).
|
||||
@@ -278,16 +327,20 @@ cover (`.eh-discover-media--photo`); иначе mood wash + initials (`.eh-disco
|
||||
### 5.7. Отзывы
|
||||
`GET/POST /v1/reviews`, `GET/PUT/DELETE /v1/reviews/:id`, `GET /v1/user/reviews`. Цели: `event` | `calendar`.
|
||||
Голос: `PUT /v1/reviews/:id/vote` `{ "value": "like"|"dislike" }`, `DELETE /v1/reviews/:id/vote`; в ответах — `my_vote`.
|
||||
UI: форма отзыва скрыта без confirmed booking (event — booking на это событие; calendar — booking на событие календаря); подсказка + жалоба остаются.
|
||||
UI: без сессии скрыты форма отзыва, жалоба (календарь/событие/отзыв) и кнопки like/dislike
|
||||
(счётчики лайков в списке отзывов остаются). С сессией: форма отзыва скрыта без
|
||||
confirmed booking; подсказка gated + жалоба остаются (жалоба не требует booking).
|
||||
|
||||
### 5.8. Жалобы
|
||||
`POST /v1/reports` — `target_type`: `event` | `calendar` | `review`. Жалоба не требует booking.
|
||||
В client UI жалоба доступна только залогиненному пользователю.
|
||||
|
||||
### 5.9. Подписка
|
||||
`GET /v1/subscription`, `POST /v1/subscription` (`start_trial` | `activate` + `plan` + опционально `payment_info`).
|
||||
Планы и цены (как Back `plan_price/1`, minor units → ₽): monthly **999**, quarterly **2499**, biannual **4499**, annual **7999**.
|
||||
UI: карточки сравнения, локализованный «Бесплатно» без подписки, trial, демо-оплата (шлюз-заглушка).
|
||||
Create/upgrade commercial без **уже active** sub/trial → `402` → `/subscription`
|
||||
**Пилот (Spec#18 A):** без боевого PSP; commercial через `start_trial` и/или admin activate.
|
||||
B2C оплаты услуги нет. Create/upgrade commercial без **уже active** sub/trial → `402` → `/subscription`
|
||||
(сначала явный `start_trial` или `activate`; auto-start trial при create **нет**,
|
||||
Back#61 / BackSpec §2.1.2, §2.9). После renew commercial-календари владельца снова
|
||||
с `booking_open=true` без смены type (restricted → full).
|
||||
@@ -338,18 +391,53 @@ CI: lint → build → Playwright **mock** (до push) → push image → deploy
|
||||
|
||||
E2E: `e2e/TESTIDS.md`. Моки (`npm run test:e2e`, `project=mock`) — только локально / до деплоя. После деплоя на IFT/stage — `SMOKE_USER_*` + `npm run test:e2e:ift|stage` против реального API (без Playwright route-моков).
|
||||
|
||||
## 7. Ограничения MVP (Future)
|
||||
## 7. Ограничения MVP / Фаза 2 (Spec#8)
|
||||
|
||||
Не в текущей волне (кроме specialist_invite — см. §5.4 / BackSpec §2.1.2):
|
||||
- шаринг календаря с правами (`calendar_share`) — фаза 2 (не путать со specialist_invite)
|
||||
- серверный logout / revoke session
|
||||
- загрузка файлов (вложения)
|
||||
- явный `client_type=mobile` (фаза 3 бэка)
|
||||
- полноценный push (сейчас in-app `notification` + email для specialist_invite)
|
||||
- waitlist / оплата услуги клиентом (B2C)
|
||||
Не в текущем контракте реализации (кроме specialist_invite — см. §5.4 / BackSpec §2.1.2).
|
||||
Эпик: https://git.sabilin.com/EventHub/EventHubSpec/issues/8
|
||||
|
||||
**Pilot / ops (фаза 2):** SMTP / transactional email; backup; secrets / certs / alerts;
|
||||
legal stubs; позиция по подписке **владельца**; DNS/SPF для `calentiq.com` (домен в использовании; чеклист `BRANDING.md`).
|
||||
|
||||
**Продукт (фаза 2):**
|
||||
- серверный logout / revoke session (`POST /v1/logout` + `{ refresh_token }`; клиент зовёт до clear storage) — сделано
|
||||
- список/отзыв сессий в профиле (Back#74 / Front#72): устройство (`device_name`/UA),
|
||||
revoke одной и revoke-others
|
||||
- загрузка файлов: avatar `POST /v1/user/me/avatar`, cover
|
||||
`POST /v1/calendars/:id/cover` (Back#71); UI — Front#69; вложения — позже
|
||||
- полноценный push / reminders (email-reminder: Back#70; Web Push + prefs:
|
||||
Back#75 / Front#71 — тумблеры в профиле, SW `/sw-push.js`)
|
||||
- waitlist: API Back#72; включение — чекбокс «Лист ожидания»
|
||||
(`settings.waitlist_enabled`) в настройках commercial; join/leave на
|
||||
карточке события при полном слоте — Front#70
|
||||
- шаринг календаря с правами (`calendar_share`) — Back#73 API + Front list/invites/mirror;
|
||||
не путать со specialist_invite / Following
|
||||
- нативный `client_type=mobile` клиент — хвост (API уже принимает `mobile`)
|
||||
|
||||
**Вне горизонта (не фаза 2):** оплата услуги клиентом (B2C).
|
||||
|
||||
Лайки/дизлайки отзывов: `PUT/DELETE /v1/reviews/:id/vote`, поле `my_vote` в ответах отзывов (EventHubBack#47).
|
||||
|
||||
## 7.1. Фаза 3 — архив (Spec#19 / Spec#20)
|
||||
|
||||
Канон: [`ARCHIVE.md`](ARCHIVE.md). Hot = текущий месяц + будущее; warm = 3 мес;
|
||||
cold = файлы. UI истории — React, не iframe. Код: Back#76, Front#73.
|
||||
|
||||
## 7.2. Фаза 3 — гео (Spec#19 / Spec#21)
|
||||
|
||||
Канон: [`GEO.md`](GEO.md). Стек: MapLibre + OpenFreeMap + OpenCage (внешний геокодер через бэк).
|
||||
Пикер адреса/пина; карточка с мини-картой и outbound Google/Яндекс/OSM;
|
||||
Discover ближайшие с опциональным радиусом. Код: Back#77, Front#74, DevOps#16.
|
||||
|
||||
## 7.3. Фаза 3 — ИИ клиента (Spec#19 / Spec#22)
|
||||
|
||||
Канон: [`PRODUCT-AI.md`](PRODUCT-AI.md). Позиция «ИИ молчит и делает»: L0 умность в
|
||||
ячейках Time Arc (shipped, без LLM); L1 опциональные подсказки — ghosts `kind: 'hint'`
|
||||
с причиной (Front#75); L2 голос-кнопка Time Arc mark, press-to-talk → ActionPlan-чипы
|
||||
с подтверждением пишущих действий, палитра команд как fallback (Front#76). STT/intent
|
||||
через Back-прокси `/v1/ai/*`, как GEO/OpenCage. Anti-scope: чат, Composer, AI-home,
|
||||
авто-booking без подтверждения, подводка AiRouter к Front. Код — после гейтов канона.
|
||||
|
||||
## 8. Источники истины
|
||||
|
||||
- Маршруты и поведение: `EventHubBack/src/eventhub_app.erl` + handlers
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# Гео и карты (Фаза 3, волна 2)
|
||||
|
||||
Канон геокодирования, хранения координат и UX карты. Трекер: [Spec#21](https://git.sabilin.com/EventHub/EventHubSpec/issues/21), эпик [Spec#19](https://git.sabilin.com/EventHub/EventHubSpec/issues/19).
|
||||
|
||||
Реализация: [Back#77](https://git.sabilin.com/EventHub/EventHubBack/issues/77), [Front#74](https://git.sabilin.com/EventHub/EventHubFront/issues/74), [DevOps#16](https://git.sabilin.com/EventHub/EventHubDevOps/issues/16). Этот документ — контракт; код подтягивается после него.
|
||||
|
||||
## Цель
|
||||
|
||||
Владелец задаёт место события (и org-default календаря) через поиск адреса и пин на карте. Координаты **хранятся у нас**. Участник ищет **ближайшие** commercial-календари и события с опциональным радиусом. Гость видит мини-карту и может открыть ту же точку в привычном приложении карт.
|
||||
|
||||
Критерий выигрыша: не зависеть от ToS Google/Яндекс на кэш геокода; не блокировать create события, если геокодер лежит.
|
||||
|
||||
## Почему не Google / Яндекс как провайдеры
|
||||
|
||||
Событие — общая сущность: владелец пишет `lat`/`lon`, участники и Discover читают те же числа.
|
||||
|
||||
- **Google Geocoding:** кэш lat/lng до 30 дней, затем удалить; бессрочно — `place_id`. Исключение «кэш изолирован на одного end-user» **не покрывает** общую точку события. Результат Google нельзя показывать на чужой карте.
|
||||
- **Яндекс (бесплатный API):** данные геокодера нельзя сохранять в свою БД. Коммерческая лицензия — отдельный договор и обязательный логотип / «Открыть в Картах».
|
||||
|
||||
**Не используем:** Google Maps JavaScript API, Geocoding/Places, Яндекс JS API / Геокодер, ключи карт.
|
||||
|
||||
**Разрешено:** outbound-ссылки на Google Maps и Яндекс.Карты с **нашими** координатами (OpenCage или пин пользователя). Это не вызов их API и не хранение их ответа.
|
||||
|
||||
## Стек (вариант Ц)
|
||||
|
||||
| Слой | Выбор | Не берём |
|
||||
|------|--------|----------|
|
||||
| Карта в браузере | [MapLibre GL JS](https://maplibre.org/) | Leaflet + растр `tile.openstreetmap.org` (политика тайлов OSMF) |
|
||||
| Тайлы | [OpenFreeMap](https://openfreemap.org/) публичный инстанс, без ключа | Carto/MapTiler Free (не для коммерции EventHub); свой tile-server |
|
||||
| Геокод | **[OpenCage](https://opencagedata.com)** (внешний API), прокси только через EventHubBack, ключ не покидает бэкенд | публичный Nominatim OSMF (1 rps, autocomplete запрещён); `photon.komoot.io`; self-host Photon (вес индекса, RAM) |
|
||||
| Хранение | `address` + `lat`/`lon` в EventHub | `place_id` Google, сырой ответ Яндекса |
|
||||
|
||||
ToS OpenCage разрешает бессрочно хранить результаты геокодирования — кэш и сохранённые координаты события легальны. Данные OSM — [ODbL](https://www.openstreetmap.org/copyright). На карте видимая атрибуция: «© OpenStreetMap contributors» со ссылкой на copyright.
|
||||
|
||||
```
|
||||
Front (MapLibre) --tiles--> OpenFreeMap
|
||||
Front --suggest/geocode/reverse--> Back /v1/geo --> OpenCage API (ключ в env бэка)
|
||||
Back --persist--> event.location / calendar.settings.default_location
|
||||
```
|
||||
|
||||
Front **не** ходит в OpenCage и Nominatim напрямую (ключ не должен попасть в браузер).
|
||||
|
||||
## Модель данных (уже есть)
|
||||
|
||||
- Событие: `location` = `{ address, lat?, lon? }`.
|
||||
- Календарь: `settings.default_location` = `{ address, lat?, lon? }` (как сейчас: `address` непустой; `lat`/`lon` оба или ни одного).
|
||||
|
||||
Волна 2:
|
||||
|
||||
- **Address-only разрешён.** Create/update события с адресом без координат **не** отбрасывает `location` (сейчас `parse_location` без пары lat/lon даёт `undefined`).
|
||||
- `lat`/`lon` — оба или ни одного; не пара `0, 0` как «пусто» в API (пусто = поля отсутствуют / `null`).
|
||||
- Поиск «рядом»: см. § «Ближайшие календари и события». Сейчас geo режет только события; календари по `default_location` — волна 2 (Back#77).
|
||||
|
||||
## API геокодирования (контракт Back#77)
|
||||
|
||||
`POST` geocode/reverse: user Bearer (пикер). `GET /v1/geo/suggest`: **без токена** (Discover «центр по адресу» для гостя) + rate-limit по IP; с Bearer — тот же лимит на user. Ключ OpenCage с браузера не светим.
|
||||
|
||||
Кэш одинаковых `q`+`lang` (ETS/Mnesia, TTL дни). Таймаут апстрима ~3 s.
|
||||
|
||||
### `GET /v1/geo/suggest?q=&lang=`
|
||||
|
||||
Typeahead. Клиент debounce (≥300 ms). Пустой / слишком короткий `q` → `400` `{error: "invalid_query"}`.
|
||||
|
||||
Ответ `200`:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{ "address": "Москва, Красная площадь", "lat": 55.7539, "lon": 37.6208 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Пустой список — валидный `200`, не ошибка.
|
||||
|
||||
### `POST /v1/geo/geocode`
|
||||
|
||||
Тело: `{ "q": "…", "lang": "ru" }` (`lang` опционален, иначе язык пользователя / `ru`).
|
||||
|
||||
- `200`: `{ "address": "…", "lat": 55.75, "lon": 37.62, "source": "opencage" }`
|
||||
- нет хита: `404` `{error: "not_found"}`
|
||||
- OpenCage недоступен / таймаут / квота (402/403/429): `503` `{error: "geo_unavailable"}` — **не** 500
|
||||
|
||||
### `POST /v1/geo/reverse`
|
||||
|
||||
Тело: `{ "lat": 55.75, "lon": 37.62, "lang": "ru" }`. Подпись пина после drag.
|
||||
|
||||
Тот же shape, что geocode (`source: "opencage"`). Нет хита → `200` с `address` из округлённых координат или короткий fallback «lat, lon», без падения пикера. `503` только если OpenCage недоступен.
|
||||
|
||||
### Degrade
|
||||
|
||||
- Geo-эндпоинты: `503` / пустой suggest, не 500 на весь API.
|
||||
- Create/update события и `default_location`: **не** зависят от геокодера. Текстовый адрес сохраняется.
|
||||
- IFT/stage без ключа OpenCage: сервис живой; e2e пикера пропускают или проверяют degrade (текстовое поле).
|
||||
|
||||
## Ops (контракт DevOps#16)
|
||||
|
||||
Не API-ключи Google/Яндекс.
|
||||
|
||||
- Своего контейнера геокодера нет: бэкенд ходит во внешний OpenCage API (`https://api.opencagedata.com/geocode/v1/json`).
|
||||
- Env бэкенда: `OPENCAGE_API_KEY` (секрет, только в `.env` стендов), `OPENCAGE_TIMEOUT_MS`, опционально `OPENCAGE_COUNTRYCODE`, `OPENCAGE_THROTTLE_RPS` (1 rps под free-тариф). Нет map API keys.
|
||||
- Free-тариф OpenCage: 2500 запросов/сутки, 1 rps; 402/403/429 апстрима → `503 geo_unavailable`. ToS разрешает хранение результатов.
|
||||
- CSP (Traefik / Front): тайлы OpenFreeMap, не Google/Yandex script.
|
||||
|
||||
Ориентир CSP (уточнить по фактическому style URL):
|
||||
|
||||
```
|
||||
connect-src 'self' https://tiles.openfreemap.org
|
||||
img-src 'self' data: blob: https://tiles.openfreemap.org
|
||||
font-src 'self' data: https://fonts.gstatic.com https://tiles.openfreemap.org
|
||||
worker-src 'self' blob:
|
||||
```
|
||||
|
||||
Geo — same-origin `/v1/geo`. IFT/stage: ключ OpenCage в `.env` стенда; без ключа — degrade, не падение EventHub.
|
||||
|
||||
**Stage:** ключ OpenCage в `.env` (внешний сервис — без нагрузки на VPS). Пикер адреса работает на всех стендах с ключом; без ключа — текстовый адрес / пин. GPS «рядом» работает без геокодера.
|
||||
|
||||
## Front (контракт Front#74)
|
||||
|
||||
Библиотека: MapLibre GL JS. Стиль OpenFreeMap (например Liberty). Ключей в репозитории Front нет.
|
||||
|
||||
### Пикер
|
||||
|
||||
Форма события и org `default_location` (не сырые lat/lon как основной путь):
|
||||
|
||||
1. Поле адреса + suggest (`GET /v1/geo/suggest`).
|
||||
2. Выбор хита → подставить address/lat/lon.
|
||||
3. Мини-карта, перетаскиваемый пин → `POST /v1/geo/reverse`.
|
||||
4. Ручной lat/lon можно спрятать (advanced), не убирать из контракта API.
|
||||
|
||||
OpenCage `503`: тост/подсказка, карта и ручной адрес остаются.
|
||||
|
||||
### Карточка / просмотр
|
||||
|
||||
Если есть валидные `lat`/`lon`:
|
||||
|
||||
- мини-карта MapLibre с пином и атрибуцией OSM;
|
||||
- **рядом** ссылки (`target=_blank`, `rel="noopener noreferrer"`):
|
||||
- Google Maps: `https://www.google.com/maps?q={lat},{lon}`
|
||||
- Яндекс.Карты: `https://yandex.ru/maps/?pt={lon},{lat}&z=16` (порядок **lon,lat**)
|
||||
- OpenStreetMap: `https://www.openstreetmap.org/?mlat={lat}&mlon={lon}#map=16/{lat}/{lon}`
|
||||
|
||||
Только адрес без координат: текст адреса, без карты и без deep-link.
|
||||
|
||||
i18n ru/en.
|
||||
|
||||
### Discover — ближайшие
|
||||
|
||||
Не map-first: тот же список `/search`. Ручной ввод lat/lon **не** основной путь.
|
||||
|
||||
**Центр поиска** (нужны оба `lat` и `lon`):
|
||||
|
||||
1. «Рядом со мной» — `navigator.geolocation` (гость и логин). Отказ/ошибка — без падения; подсказка выбрать адрес.
|
||||
2. «Искать у адреса» — suggest (`GET /v1/geo/suggest`) → lat/lon центра. Не GPS.
|
||||
|
||||
**Радиус опционален** (км, UI-пресеты **1 / 5 / 10 / 25 / 50**):
|
||||
|
||||
| Радиус | Query | Смысл |
|
||||
|--------|--------|--------|
|
||||
| не выбран / «без ограничения» | `lat`,`lon` без `radius` | все точки с координатами, **сортировка по dist** |
|
||||
| выбран пресет | `lat`,`lon`,`radius` | только ≤ N км, сортировка по dist |
|
||||
|
||||
Дефолт после «рядом со мной»: **10 км** (как сейчас на бэке), пользователь может сменить или снять лимит. `radius` без пары lat/lon игнорируется.
|
||||
|
||||
Фильтр `type` (`calendar` / `event` / оба) работает как сейчас. `q`/даты/теги сочетаются с geo.
|
||||
|
||||
В карточке результата — дистанция (`distance_km` с бэка), не сырые координаты.
|
||||
|
||||
## Ближайшие календари и события (контракт Back#77)
|
||||
|
||||
Тот же `GET /v1/search`. Geo-режим: есть `lat` и `lon` → **не** discovery-tops.
|
||||
|
||||
### Что сравниваем
|
||||
|
||||
| Тип | Точка | Нет координат |
|
||||
|-----|--------|----------------|
|
||||
| `event` | `event.location.lat/lon` | вне geo-выборки |
|
||||
| `calendar` | `settings.default_location.lat/lon` | вне geo-выборки (сейчас календари geo **не** фильтруются — это дыра волны 2) |
|
||||
|
||||
Personal по-прежнему не в search. ACL `can_access` без изменений. Restricted commercial — как в обычном search.
|
||||
|
||||
Формула: haversine, км (уже есть `logic_search:distance/4`).
|
||||
|
||||
### `radius`
|
||||
|
||||
- Задан: integer **1..100** км; вне диапазона → `400`. Фильтр `distance ≤ radius`.
|
||||
- Не задан: **без отсечения** по dist; только сущности с валидными координатами.
|
||||
- Default бэка `radius=10`, если клиент прислал lat/lon **без** ключа radius — **менять:** отсутствие ключа = без лимита. Front при «рядом» **явно** шлёт `radius=10` (или выбранный пресет).
|
||||
|
||||
### Сортировка
|
||||
|
||||
Geo-режим, клиент не передал `sort` → **`distance` asc**. Явный `sort=title|created_at|start_time` — как сейчас, но только внутри уже отфильтрованных по радиусу.
|
||||
|
||||
Новое значение: `sort=distance` (км по возрастанию; `order=desc` — дальние первыми, редко нужно).
|
||||
|
||||
### Ответ
|
||||
|
||||
В каждом элементе `events[]` / `calendars[]` при geo-запросе:
|
||||
|
||||
```json
|
||||
"distance_km": 1.4
|
||||
```
|
||||
|
||||
Округление 1 знак. Без geo-параметров поле **нет** (не `null`).
|
||||
|
||||
Календари в geo-выдаче могут отдавать краткий `default_location` (`address`, `lat`, `lon`) — чтобы Front не гадал.
|
||||
|
||||
Индекс/PostGIS — **не** волна 2: тот же in-memory scan, что сейчас.
|
||||
|
||||
## Non-goals волны 2
|
||||
|
||||
- Turn-by-turn, пробки, трекинг курьеров/сотрудников.
|
||||
- Map-first Discover / каталог пинами на всю выдачу.
|
||||
- Свой tile-server / self-host OpenFreeMap.
|
||||
- Публичный Nominatim или `photon.komoot.io` как fallback (чтобы не сесть на чужой fair-use).
|
||||
- Google/Yandex JS SDK, ключи, Places Autocomplete.
|
||||
- Пакетная геокодировка существующих событий без координат (можно later).
|
||||
|
||||
## Критерии приёмки канона
|
||||
|
||||
- [x] Этот документ согласован (Spec#21, вариант Ц).
|
||||
- [ ] DevOps#16: ключ OpenCage в `.env` стендов, CSP OpenFreeMap; без Google/Yandex keys.
|
||||
- [ ] Back#77: `/v1/geo/*`, кэш, address-only location; search: geo по календарям, optional radius, `sort=distance`, `distance_km`; тесты (mock OpenCage).
|
||||
- [ ] Front#74: пикер + мини-карта + outbound Google/Яндекс/OSM; Discover ближайшие (GPS/адрес, пресеты радиуса); e2e/mock.
|
||||
- [ ] После тестов: BackSpec/FrontSpec сверены с кодом.
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# ИИ клиента CalenTIQ (Фаза 3, волна 3)
|
||||
|
||||
Канон продуктового ИИ: умная сетка + опциональные предиктивные подсказки + голосовой центр выполнения **без чата**. Трекер: [Spec#22](https://git.sabilin.com/EventHub/EventHubSpec/issues/22), эпик [Spec#19](https://git.sabilin.com/EventHub/EventHubSpec/issues/19).
|
||||
|
||||
Реализация (Future): [Front#75](https://git.sabilin.com/EventHub/EventHubFront/issues/75) подсказки, [Front#76](https://git.sabilin.com/EventHub/EventHubFront/issues/76) голосовой центр. Этот документ — контракт; код подтягивается после него.
|
||||
|
||||
## Позиция: «ИИ молчит и делает»
|
||||
|
||||
ИИ CalenTIQ — не собеседник и не интерфейс. Это слой, который **предлагает действия**, а человек их подтверждает и исполняет. Никакого свободного текста от ИИ, никакого диалога, никаких уточняющих вопросов, никакой «личности ассистента».
|
||||
|
||||
Три следствия:
|
||||
|
||||
1. Результат любой ИИ-операции — **план действий в UI** (чипы, ghosts, навигация), а не текст.
|
||||
2. Неопределённость разрешается **выбором из кандидатов**, не разговором.
|
||||
3. Запись в API — только после явного подтверждения человека.
|
||||
|
||||
## Уровни ИИ
|
||||
|
||||
| Уровень | Что | Статус | Где живёт |
|
||||
|---------|-----|--------|-----------|
|
||||
| **L0 — умность в ячейках** | детерминированный скоринг Time Arc (без LLM, без Back) | shipped (v1–v4) | ячейки сетки |
|
||||
| **L1 — предиктивные подсказки** | те же ghosts, `kind: 'hint'` + причина «почему» | Future (Front#75), opt-in | ячейки сетки |
|
||||
| **L2 — голосовой центр** | голос → ActionPlan → подтверждение | Future (Front#76), opt-in | кнопка Time Arc mark |
|
||||
|
||||
Единый визуальный язык: подсказка — **не** тост и **не** баннер, а ghost прямо в сетке. Интеллект всегда в ячейках — подсказки тоже в ячейках. Новый UI-слой не изобретается: расширяется уже shipped-механика ghosts (`copy` / `demand` / `phrase` / `repeat` → `+hint`).
|
||||
|
||||
## Статус Time Arc v1–v4 (L0, shipped)
|
||||
|
||||
См. FrontSpec §3.3. Кратко:
|
||||
|
||||
- **v1 (Front#50):** клиентский скоринг лучшего часа: свободные слоты студии ∩ занятость пользователя × mood. Черновики владельца из прошлой недели. Без LLM и без Back.
|
||||
- **v2 (Front#51):** память отказов `eh.timeArc.skips` (штраф часу/мастеру, дуга переезжает); буст мастера с ≥2 confirmed; тепло месяца.
|
||||
- **v3:** дуга между календарями — `/following` и `/search` баннеры ближайшего часа.
|
||||
- **v4 (Front#53):** demand-черновики по pending occupancy; локальный разбор фразы → phrase-черновик у владельца (long-press). Без LLM / AiRouter.
|
||||
|
||||
Всё L0 — локальные вычисления Front. Этот канон L0 **не меняет**.
|
||||
|
||||
## Голос-кнопка (L2, контракт Front#76)
|
||||
|
||||
### Кнопка
|
||||
|
||||
- Кнопка = **Time Arc mark** рядом с BrandWordmark в AppShell (см. FrontSpec §3.2) — всегда видна, отдельного слота не занимает.
|
||||
- **Press-to-talk**: нажал и держишь → говоришь → отпустил → разбор. Отпустил без звука — ничего не происходит. Никакого «зависшего» прослушивания, таймеров VAD и модалок.
|
||||
- Состояния (4): `idle` / `listening` / `processing` / `done` — анимируются **самим mark** (дуга заполняется, пока держишь). Бренд сам себе индикатор.
|
||||
|
||||
### Палитра быстрых команд (обязательный fallback)
|
||||
|
||||
Голос и тап — **два канала одного движка действий**. Кнопка при недоступности голоса (offline, mic denied, STT упал) открывает палитру тапабельных типовых действий из того же enum интентов. UX голоса не умирает ни в одном degrade-сценарии.
|
||||
|
||||
### Результат — план действий
|
||||
|
||||
После разбора показывается стек action-чипов (план), а не текст. Пишущие действия — с одной кнопкой «Выполнить»; обратимые исполняются сразу (см. классы ниже).
|
||||
|
||||
## ActionPlan (контракт later)
|
||||
|
||||
STT + intent-разбор выдают структурированный план, не текст. Намётка схемы:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"locale": "ru",
|
||||
"confidence": 0.87,
|
||||
"actions": [
|
||||
{ "id": "a1", "intent": "open_calendar", "target": { "calendar_hint": "аурора" }, "resolved": null },
|
||||
{ "id": "a2", "intent": "select_hour", "time": { "hour": 19, "day": "next", "dow": 2 } },
|
||||
{ "id": "a3", "intent": "book", "confirm": "required" }
|
||||
],
|
||||
"candidates": []
|
||||
}
|
||||
```
|
||||
|
||||
### Интенты — конечный enum
|
||||
|
||||
Каждый интент исполняется через **уже существующий** UI-флоу. Новых write-эндпоинтов в Back нет.
|
||||
|
||||
| Intent | Глаголы (ru / en) | Исполняется через |
|
||||
|--------|-------------------|-------------------|
|
||||
| `navigate` | перейти, открой, покажи / go, open | роуты FrontSpec §4 |
|
||||
| `open_calendar` | календарь + название | `/c/:id`; fuzzy-match по `GET /v1/calendars` + following |
|
||||
| `select_hour` | «в 19», «завтра в 11» | курсор `calendarContextStore` + подсветка ячейки |
|
||||
| `book` | запиши, записаться / book | существующий флоу Book (mandatory confirm + owner pending) |
|
||||
| `create_slot_draft` | создай слот, поставь окно | механика phrase-черновиков v4: голос — ещё один источник фразы |
|
||||
| `confirm_booking` / `decline_booking` | подтверди, отклони заявку | `PUT /v1/bookings/:id` через `/bookings` |
|
||||
| `follow` / `unfollow` | подпишись, отслеживай | `POST/DELETE /v1/calendars/:id/follow` |
|
||||
| `search` | найди, ищи | `/search` с заполненными фильтрами |
|
||||
|
||||
Всё вне enum — отбрасывается одним тостом, без retry-цикла.
|
||||
|
||||
### Разбор: rule-first
|
||||
|
||||
Двухслойный: словарь глаголов + regex покрывает типовые фразы («запиши к Ане завтра в 19» — глагол + имя + время, детерминированно). LLM-провайдер вызывается **только** при miss и обязан вернуть тот же ActionPlan-JSON — никогда свободный текст.
|
||||
|
||||
`create_slot_draft` у владельца использует локальный разбор фразы из v4 → **полностью офлайн** rule-path.
|
||||
|
||||
### Resolution и кандидаты (вместо вопросов)
|
||||
|
||||
Каждый `target.hint` резолвится **на клиенте** по локальным данным (календари, following, bookings, roster). Не резолвится → пишется в `candidates` и показывается чипами на выбор. Уточняющих вопросов нет по контракту.
|
||||
|
||||
### Классы действий
|
||||
|
||||
| Класс | Примеры | Подтверждение |
|
||||
|-------|---------|----------------|
|
||||
| **Обратимые** | `navigate`, `open_calendar`, `select_hour`, `search` | исполняются сразу — навигацию всегда можно отменить «назад» |
|
||||
| **Пишущие** | `book`, `create_slot_draft`, `confirm/decline_booking`, `follow` | обязательный preview + явный тап «Выполнить» |
|
||||
|
||||
Обязательное подтверждение перед записью в API соблюдается буквально: запись в API — это только пишущие действия.
|
||||
|
||||
### Dry-run preview
|
||||
|
||||
Пишущие действия рендерятся в сетку **до** «Выполнить»: `create_slot_draft` — ghost-ячейкой, `book` — подсветкой целевого часа. Превью плана — тем же визуальным языком, что результат: пользователь видит ровно то, что получит.
|
||||
|
||||
### Исполнение
|
||||
|
||||
Последовательное, стоп на первой ошибке, без авто-продолжения. `book` всегда последний в плане и всегда с confirm; сверху — существующий owner-confirm pending (двойная защита записи).
|
||||
|
||||
## Предиктивные подсказки (L1, контракт Front#75)
|
||||
|
||||
- Подсказка = ghost `kind: 'hint'` в сетке + причина «почему».
|
||||
- **Причины — шаблоны i18n, не LLM**: «вы обычно по вторникам в 19:00». Объяснимость без провайдера.
|
||||
- Сигналы — **local-first**: паттерны bookings, `eh.timeArc.skips`, mood; ничего не уходит провайдерам.
|
||||
- Частотный cap: не более N hint в неделю на экран (N уточняет Front#75).
|
||||
- Dismiss пишет штраф в `timeArcMemoryStore` — тот же механизм skips; подсказка обучается отказам локально.
|
||||
|
||||
## Opt-in, privacy, degrade
|
||||
|
||||
### Opt-in
|
||||
|
||||
- L1 и L2 — **default off**. Тумблеры в `preferences` (`PATCH /v1/user/me`), как mood; до логина — выключены безусловно.
|
||||
- Выключенный L1: ghosts v1–v4 работают как сейчас, `hint` не рисуется. Выключенный L2: mark не реагирует на hold, палитра скрыта.
|
||||
|
||||
### Приват-инварианты
|
||||
|
||||
- Транскрипт живёт **только в памяти** на время показа плана; не пишется ни в localStorage, ни в Back.
|
||||
- Контекст календаря (события, bookings, предпочтения) **никогда** не уходит провайдерам — только аудио/фраза запроса.
|
||||
- Аудио не сохраняется: стриминг в STT, discard после транскрипта.
|
||||
|
||||
### Degrade-матрица
|
||||
|
||||
| Сценарий | L1 подсказки | L2 голос |
|
||||
|----------|--------------|----------|
|
||||
| Offline | работают (local-first) | inert-state + тултип; у owner доступен rule-path `create_slot_draft` |
|
||||
| Mic denied / нет микрофона | n/a | открывается палитра быстрых команд |
|
||||
| STT недоступен / 5xx / таймаут | n/a | один тост; палитра как fallback |
|
||||
| Низкий confidence | n/a | один тост «не расслышал», без retry-цикла |
|
||||
| Средний confidence | n/a | чипы-кандидаты вместо прямого исполнения |
|
||||
| Target не найден | подсказка не показывается | кандидаты; пустой список → тост |
|
||||
| Write-ошибка при исполнении (409/403/402) | n/a | план остановлен; стандартная ошибка флоу (402 → `/subscription`) |
|
||||
| Opt-in off | ghosts v1–v4 без изменений | mark без реакции |
|
||||
|
||||
**Инвариант:** ни одно состояние ИИ-слоя не блокирует первичные флоу (сетка, запись, навигация живут независимо).
|
||||
|
||||
## Провайдер и Back-прокси (контракт later)
|
||||
|
||||
- STT и intent-разбор — **продуктовый провайдер** (вендор STT + мелкий LLM для intent-JSON при miss rule-слоя).
|
||||
- Front ходит только в прокси Back (`/v1/ai/*`): ключи провайдера не покидают бэкенд, единая точка rate-limit, аудита и смены провайдера. Паттерн идентичен GEO/OpenCage (см. [GEO.md](GEO.md)).
|
||||
- Back не персистит аудио и транскрипты.
|
||||
- **EventHubAiRouter — не продуктовый путь**: это dev-tooling для coding-агентов (Zed). Продуктовый ИИ-клиент о нём не знает; зависимостей Front → AiRouter нет и не будет.
|
||||
|
||||
## Anti-scope
|
||||
|
||||
Вне продукта (зафиксировано):
|
||||
|
||||
- чат с ИИ в любой форме; Composer; отдельная AI-вкладка / AI-home;
|
||||
- авто-booking и любые записи в API без подтверждения человека;
|
||||
- свободные текстовые ответы ИИ и уточняющие вопросы;
|
||||
- подводка EventHubAiRouter / Zed к клиентскому приложению;
|
||||
- персист аудио/транскриптов, отправка контекста календаря провайдерам.
|
||||
|
||||
## Критерии старта Future-реализаций
|
||||
|
||||
Метрики для решения (собираются в ходе L0/Front#75):
|
||||
|
||||
- hint CTR; plan-confirm-rate; plan-abort-rate; доля rule-path vs provider-path.
|
||||
|
||||
Гейты:
|
||||
|
||||
- **Подсказки (Front#75):** ≥20% активных пользователей с ≥2 confirmed bookings через ghosts за месяц + явное продуктовое решение.
|
||||
- **Голос (Front#76):** после пилота подсказок; STT-провайдер с RU и приемлемым ToS; пилот на IFT; минимум один сценарий с конверсией лучше ручного пути.
|
||||
|
||||
## Future Stories
|
||||
|
||||
- [Front#75](https://git.sabilin.com/EventHub/EventHubFront/issues/75) — предиктивные подсказки (ghosts `kind: 'hint'`).
|
||||
- [Front#76](https://git.sabilin.com/EventHub/EventHubFront/issues/76) — голосовой центр: mark press-to-talk + палитра + ActionPlan.
|
||||
@@ -5,6 +5,10 @@
|
||||
## 4. [Правила работы (workflow)](WORKFLOW.md)
|
||||
## 5. [Stage UI в Cursor Browser](STAGE-BROWSER.md) (HTTPS `*.calentiq.com`; proxy — fallback)
|
||||
## 6. [UX backlog (Client UI)](UX-BACKLOG.md)
|
||||
## 7. [Карта архитектуры для Zed/агентов](ZED-ARCHITECTURE.md)
|
||||
## 8. [Архив календаря (Фаза 3)](ARCHIVE.md)
|
||||
## 9. [Гео и карты (Фаза 3)](GEO.md)
|
||||
## 10. [ИИ клиента (Фаза 3)](PRODUCT-AI.md)
|
||||
|
||||
# **Репозитории разработки EventHub**
|
||||
## 1. [EventHub Backend](https://git.sabilin.com/EventHub/EventHubBack)
|
||||
|
||||
@@ -7,8 +7,9 @@ E2E и smoke иногда оставляют commercial-календари с р
|
||||
|
||||
1. **Не** удалять «живые» smoke-календари пилота без явного фильтра.
|
||||
2. Чистить только по безопасным маркерам (название/тег/описание содержат `e2e`, `E2E`, `playwright`, `ift-litter`, или owner = известный e2e user).
|
||||
3. Soft-delete через API (`DELETE /v1/calendars/:id` владельцем / admin), не ручной wipe Mnesia.
|
||||
3. Soft-delete через API (`DELETE /v1/calendars/:id` владельцем / admin) для точечного litter; **полный** сброс стенда — Back CI `workflow_dispatch` `wipe-mnesia-ift` / `wipe-mnesia-stage` (`scripts/ci-full-mnesia-wipe.sh`), только вручную, не prod.
|
||||
4. Front already tracks calendars created in-session via `e2e/helpers/standCleanup.ts` — предпочитать это для новых тестов.
|
||||
5. Catalog BizCal (`short_name` `bizcal-*`, tag `BizCal`) и disposable LargeCal — не путать с smoke; seed: Front `seed-catalog-ift|stage` (нужны `BIZCAL_CLIENT_*` в `.env` стенда).
|
||||
|
||||
## Suggested filter (admin / ops)
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# EventHub — карта для AI-агентов (Zed / Cursor)
|
||||
|
||||
Кросс-репо ориентир: где какой модуль и куда класть правки. Детали API — в `EventHubBackSpec.md` / `EventHubFrontSpec.md` / `EventHubFrontAdminSpec.md`. Процесс — `WORKFLOW.md`.
|
||||
|
||||
## Репозитории
|
||||
|
||||
| Репо | Стек | Зона |
|
||||
|------|------|------|
|
||||
| `EventHubBack` | Erlang/OTP 28, Cowboy, Mnesia | Public + Admin API |
|
||||
| `EventHubFront` | React + Vite + TanStack Query | Клиентский SPA (CalenTIQ) |
|
||||
| `EventHubFrontAdmin` | React + Vite | Admin SPA |
|
||||
| `EventHubSpec` | Markdown | Спеки, UI-PARITY, workflow |
|
||||
| `EventHubAiRouter` | FastAPI + LiteLLM | Zed gateway / routing |
|
||||
| `EventHubDevOps` | Docker Swarm | Стенды, deploy |
|
||||
|
||||
**Правило:** один агент / один PR — один репо. Не смешивать Front и Back в одной сессии правок.
|
||||
|
||||
Бренд UI: **CalenTIQ**. Внутренние имена репо `EventHub*` не менять.
|
||||
|
||||
## Домен (кратко)
|
||||
|
||||
- **User** — владелец календарей / запись на слоты
|
||||
- **Calendar** — расписание, специалисты, follow
|
||||
- **Event** — слоты (в т.ч. recurring)
|
||||
- **Booking / booking-request** — запись / заявка
|
||||
- **Ticket / Review / Report** — поддержка, отзывы, жалобы
|
||||
- **Subscription** — подписка владельца
|
||||
- **Specialist invite** — приглашение специалиста в календарь
|
||||
- **Admin** — модерация, аудит, stats (отдельный JWT)
|
||||
|
||||
Стенды: **dev** локально, **IFT**, **stage** (`https://stage.calentiq.com`, admin `https://admin.stage.calentiq.com`).
|
||||
|
||||
---
|
||||
|
||||
## EventHubBack (`src/`)
|
||||
|
||||
Слои (сверху вниз):
|
||||
|
||||
| Слой | Путь | Ответственность |
|
||||
|------|------|-----------------|
|
||||
| HTTP | `handlers/`, `handlers/admin/` | Cowboy handlers, валидация входа/ответа |
|
||||
| Logic | `logic/` | Бизнес-правила, оркестрация core |
|
||||
| Core | `core/` | Mnesia-таблицы / CRUD сущности |
|
||||
| Infra | `infra/` | Mnesia, auth helpers, workers, migrations |
|
||||
| Middleware | `middlewares/` | Auth JWT и т.п. |
|
||||
|
||||
### Типичные пары handler → logic → core
|
||||
|
||||
| Домен | Handler(s) | Logic | Core |
|
||||
|-------|------------|-------|------|
|
||||
| Auth / session | `handler_login`, `handler_register`, `handler_auth`, `handler_refresh`, `handler_verify` | `logic_auth`, `logic_auth_session`, `logic_user` | `core_user`, `core_session`, `core_auth_session`, `core_verification` |
|
||||
| Password reset | `handler_forgot_password`, `handler_reset_password` | `logic_password_reset` | `core_password_reset` |
|
||||
| Calendars | `handler_calendars`, `handler_calendar_by_id` | `logic_calendar` | `core_calendar` |
|
||||
| Specialists | `handler_calendar_specialists`, `handler_specialist_invites`, `handler_calendar_specialist_invites` | `logic_calendar_specialist`, `logic_specialist_invite` | `core_calendar_specialist`, `core_specialist_invite` |
|
||||
| Follow | `handler_calendar_follow`, `handler_user_following` | `logic_calendar_follow` | `core_calendar_follow` |
|
||||
| Events | `handler_events`, `handler_event_by_id`, `handler_event_occurrences` | `logic_event`, `logic_recurrence` | `core_event` |
|
||||
| Bookings | `handler_bookings`, `handler_booking_by_id`, `handler_user_bookings`, `handler_user_booking_requests` | `logic_booking` | `core_booking` |
|
||||
| Reviews | `handler_reviews`, `handler_review_by_id`, `handler_user_reviews`, `handler_review_vote` | `logic_review` | `core_review`, `core_review_vote` |
|
||||
| Tickets | `handler_tickets`, `handler_ticket_by_id` | `logic_ticket` | `core_ticket` |
|
||||
| Reports | `handler_reports` | `logic_report` | `core_report` |
|
||||
| Search / lookup | `handler_search`, `handler_users_lookup` | `logic_search`, `logic_user_lookup` | — |
|
||||
| Subscription | `handler_subscription` | `logic_subscription` | `core_subscription` |
|
||||
| Notifications | — | `logic_notification` | `core_notification` |
|
||||
| Admin API | `handlers/admin/admin_handler_*` | `logic_admin`, `logic_moderation`, `logic_automoderation`, `logic_stats` | `core_admin`, `core_admin_*`, `core_banned_words`, `core_automod_*` |
|
||||
|
||||
Миграции: `src/migrations/`. Сборка/тесты: **только WSL** (`source scripts/wsl-dev-env.sh`, `rebar3 eunit`).
|
||||
|
||||
---
|
||||
|
||||
## EventHubFront (`src/`)
|
||||
|
||||
| Путь | Зачем |
|
||||
|------|--------|
|
||||
| `pages/` | Экраны маршрутов |
|
||||
| `api/*Api.ts` | HTTP к Back |
|
||||
| `components/calendar/` | UI календаря / workspace |
|
||||
| `layouts/AppShell.tsx` | Оболочка |
|
||||
| `store/` | Zustand (auth, mood, locale, calendar context) |
|
||||
| `hooks/` | React hooks (API, WS, swipe) |
|
||||
| `lib/` | Утилиты (payloads, schedule, booking display) |
|
||||
| `i18n/` | Локали |
|
||||
|
||||
### Страницы → смысл
|
||||
|
||||
| Page | Маршрут (ориентир) | Тема |
|
||||
|------|-------------------|------|
|
||||
| `DiscoverPage` | discover | Поиск / лента |
|
||||
| `CalendarsPage` | calendars | Список календарей |
|
||||
| `CalendarWorkspacePage` | `/c/:id` | Основной workspace слотов |
|
||||
| `BookingsPage` | bookings | Мои записи / inbox заявок |
|
||||
| `ReviewsPage` | reviews | Отзывы |
|
||||
| `TicketsPage` / `TicketDetailPage` | tickets | Тикеты |
|
||||
| `SubscriptionPage` | subscription | Подписка |
|
||||
| `FollowingPage` | following | Подписки на календари |
|
||||
| `SpecialistInvitesPage` | invites | Инвайты специалиста |
|
||||
| `ProfilePage` / `MorePage` | profile / more | Профиль / меню |
|
||||
| `pages/auth/*` | login/register/… | Auth |
|
||||
|
||||
npm / Playwright — **только WSL** (см. `.cursor/rules/npm-wsl.mdc`).
|
||||
|
||||
---
|
||||
|
||||
## EventHubFrontAdmin (`src/`)
|
||||
|
||||
| Путь | Зачем |
|
||||
|------|--------|
|
||||
| `pages/dashboard` | Дашборд |
|
||||
| `pages/users`, `calendars`, `events` | Справочники |
|
||||
| `pages/tickets`, `reports`, `inbox` | Модерация / inbox |
|
||||
| `pages/reviews`, `automod`, `banned-words` | Контент / автомод |
|
||||
| `pages/subscriptions`, `admins`, `audit`, `monitoring` | Биллинг / админы / аудит / метрики |
|
||||
| `api/*Api.ts` | Admin API client |
|
||||
| `store/authStore.ts` | Admin session |
|
||||
|
||||
---
|
||||
|
||||
## EventHubAiRouter (`router/`, `config/`)
|
||||
|
||||
| Файл / папка | Зачем |
|
||||
|--------------|--------|
|
||||
| `router/router.py` | Zed `/v1/chat/completions`, ветка tools vs text |
|
||||
| `router/hierarchical.py` | Plan (Qwen3.8-Max) / workers / verify (DeepSeek) / plan_confirm |
|
||||
| `router/agent_hier.py` | При `tools`: Coder-30B executor + tool_calls (Zed Write) |
|
||||
| `router/orchestrator.py` | Tiers / lanes A–B–C, Redis session |
|
||||
| `config/orchestration.yaml` | Модели plan/verify/executor, таймауты, synthetic=never |
|
||||
| `config/providers.yaml` | Novita / VPN (`novita-planner` = Max) |
|
||||
| `scripts/gen-litellm-config.py` | Генерация LiteLLM YAML |
|
||||
|
||||
IFT: `https://ai-router.ift.calentiq.com/v1`, модель `smart-router`.
|
||||
Клиент-канон: **Zed Agent** (openai-compatible) → AiRouter; не Claude Code/Codex ACP как primary (обход бюджета).
|
||||
|
||||
---
|
||||
|
||||
## Куда править (шпаргалка)
|
||||
|
||||
| Задача | Репо | Куда смотреть |
|
||||
|--------|------|----------------|
|
||||
| Баг API / Mnesia | Back | `handlers` → `logic` → `core` |
|
||||
| UI клиент | Front | `pages` + `api` + `components/calendar` |
|
||||
| UI админки | FrontAdmin | `pages` + `api` |
|
||||
| Контракт / процесс | Spec | `*Spec.md`, `WORKFLOW.md`, `design/` |
|
||||
| Zed routing / hierarchical | AiRouter | `router/*`, `config/orchestration.yaml` |
|
||||
|
||||
Перед крупным планом: **утвердить** шаги с пользователем; не раздувать scope (не трогать соседний репо «заодно»).
|
||||
+18
-15
@@ -20,17 +20,20 @@ SoT: [`boards/calentiq-ui-board-final.png`](boards/calentiq-ui-board-final.png).
|
||||
| Bookings list §D | список заявок с Confirm/Decline на `/bookings` | `/bookings` = **grouped inbox**: (A) К подтверждению — pending owner/specialist + Confirm/Decline; (B) Мои записи — participant upcoming/past + Cancel; empty groups hidden. Event card Confirm/Decline **остаётся** обязательным |
|
||||
| Login §D phone chrome | три phone frames + (на борде) Google CTA | brand-first hero + mood `--auth-*`; email/password only; compact mood на auth; taglines — в mood sheet AppShell (не в `/more`); **без** Google OAuth |
|
||||
| Mood placement | board §C switcher cards | **Front#40/#45:** mood chip в AppShell рядом с BrandWordmark (+ compact auth); mobile chip icon-only (полный CalenTIQ wordmark); **не** в `/more`, **не** в toolbar Month/Week/Day; 3 moods, без flip / CREATOR; switcher glyphs §C (sprout/bolt/briefcase), Time Arc appicon — только favicon |
|
||||
| Lens + strip | board Today/Upcoming rail only | **Front#39 + controls IA:** lens Обзор/На сегодня/Заявки в **шапке strip** (underline tabs / mobile sheet); view Месяц/Неделя/День рядом с периодом; owner schedule actions отдельным рядом; localStorage only; soft-default по mood |
|
||||
| Lens + strip | board Today/Upcoming rail only | **Front#46:** lens инфо-табло **под AppShell** (не в ViewMode toolbar); Обзор/Сегодня/Заявки + метрики; soft-default по mood без auto-pin; view Месяц/Неделя/День в calendar chrome; commercial owner: «+ событие» в toolbar, WeekFill во вкладке Студия |
|
||||
| Week view headers | — | **Front#40:** одна строка day-headers (без дубля `WeekDayColumn`); scroll `.eh-cal-week-scroll` (mobile ~390) |
|
||||
| Favicon set | 3 отдельных favicon файла на борде | mood appicon через `applyMood` |
|
||||
| Desktop nav | phone bottom tabs на борде | desktop: **одна строка** chrome — logo + mood \| nav (Календарь / Найти / Записи / Ещё) \| nickname + logout; bottom nav — mobile |
|
||||
| Header identity | — | nickname primary (не raw email); email только в `title` tooltip |
|
||||
| Bookings past/expired | — | `expired` бейдж + Cancel скрыт; empty-state с CTA Discover / календари; soft list time |
|
||||
| Agenda empty copy | — | personal «Нет событий»; commercial «Нет слотов» |
|
||||
| Agenda empty copy | — | personal «Нет событий»; commercial «Свободных окон нет» |
|
||||
| Default personal | — | **Front#46 / Back#64:** один personal title `Default` / UI «По умолчанию»; `/default`; overlay только на нём; create UI только Студия |
|
||||
| Owner rail Team/Studio | — | **Front#46:** фиксированная высота panel; Команда invite свёрнут; Студия = hub (WeekFill + Edit); ScrollRegion стрелки |
|
||||
| More invites | пункт меню | скрыт при pending=0; badge счётчика при pending > 0 |
|
||||
| D9 guest specialist schedule | — | **модель B (Front#35):** roster карточек → тап → фильтр слотов по `event.specialist_id`; «Все слоты студии»; book без specialist в body; **не** chip-filter |
|
||||
| D9 guest specialist schedule | — | **модель B (Front#35):** roster карточек → тап → фильтр слотов по `event.specialist_id`; «Все слоты студии»; book без specialist в body; **не** chip-filter; в «Все» — studio aggregate occupancy |
|
||||
| Confirmed на personal | — | Front-only overlay confirmed чужих commercial на default personal; title `событие · студия · спец`; клик → `/c/:foreignCalId/e/:eventId` |
|
||||
| SpecialistsPanel density | — | mobile: имя/теги сверху, actions снизу full-width (Front#33/#35) |
|
||||
| SpecialistsPanel density | — | mobile: имя/теги сверху, actions снизу full-width (Front#33/#35); owner первым + бейдж «Владелец» + галочка «Я специалист» (Front#48) |
|
||||
| Studio / Мастер commercial grid | board §E denser studio | Owner: Студия (агрегат `start_time`+`duration`, `K/N свободно`, popover) / Мастер (roster filter); месяц — density markers; rail Расписание\|Команда\|Студия; `booking_occupancy` из API; ★ 0.0 на free не показывать; personal без агрегата |
|
||||
| Stage | полный smoke на stage | **done** 2026-07-27: Login×3 / Calendar×3 + agenda / Discover / CONTROL commercial + Confirm·Decline / More moods |
|
||||
| Домены calentiq.* | — | публичный stage: `stage.calentiq.com` / `admin.stage.calentiq.com` (канон STAGE-BROWSER.md) |
|
||||
|
||||
@@ -88,7 +91,7 @@ API: `GET /v1/user/booking-requests` (Back) + существующий `PUT /v1/
|
||||
изменений.
|
||||
3. **SpecialistsPanel density (mobile):** имя/теги сверху, actions снизу full-width.
|
||||
|
||||
### Mood + Lens (locked Front#39 / placement Front#40)
|
||||
### Mood + Lens (locked Front#39/#40; dashboard Front#46)
|
||||
|
||||
1. **Mood** = визуальная атмосфера (`html[data-mood]`, FLOW/MOMENTUM/CONTROL). Primary UX —
|
||||
chip → sheet в **AppShell** рядом с BrandWordmark (Front#40); compact на auth.
|
||||
@@ -97,16 +100,15 @@ API: `GET /v1/user/booking-requests` (Back) + существующий `PUT /v1/
|
||||
полный CalenTIQ wordmark; desktop — полный label; sheet — §C glyph + label + tagline.
|
||||
Glyphs: FLOW sprout, MOMENTUM bolt, CONTROL briefcase (не Time Arc appicon).
|
||||
2. **Lens** = отдельный контентный режим (`overview` / `today` / `bookings`), не mood и не
|
||||
view month/week/day. UI: Обзор / На сегодня / Заявки. Переключатель — в шапке strip
|
||||
(underline/text tabs на desktop; chip → sheet на mobile), **не** рядом с ViewMode.
|
||||
Soft-default при смене mood, если lens не pinned: FLOW→overview, MOMENTUM→today,
|
||||
CONTROL→bookings.
|
||||
3. **Strip** над сеткой (не на HTML-архиве месяца): Front-only derive — free gaps /
|
||||
события сегодня / upcoming bookings. Desktop раскрыт по умолчанию; mobile свёрнут
|
||||
(tap раскрывает). Сетка всегда видна. Workspace toolbar: контекст (селектор) /
|
||||
виджет (период + view) / owner schedule actions (отдельный ряд).
|
||||
4. **Persistence:** mood → DB; language → `user.language`; lens → `localStorage`
|
||||
(`eh.calendar.lens` + pinned). Без нового Back API.
|
||||
view month/week/day. UI: Обзор / Сегодня / Заявки. **Front#46:** инфо-табло **под
|
||||
AppShell** (surface + сегменты + метрики/chips), **не** рядом с ViewMode и не в
|
||||
calendar toolbar. Soft-default при смене mood **без auto-pin** при клике:
|
||||
FLOW→overview, MOMENTUM→today, CONTROL→bookings.
|
||||
3. Контент lens (не на HTML-архиве): Front-only derive — free gaps / события сегодня /
|
||||
upcoming bookings. Workspace toolbar: селектор / период+view / «+ Новое событие»
|
||||
(WeekFill — во вкладке Студия).
|
||||
4. **Persistence:** mood → DB; language → `user.language`; lens — session + soft LS
|
||||
(без вечного pin от клика). Без нового Back API.
|
||||
5. **Week view (Front#40):** одна строка day-headers (без дубля `WeekDayColumn`);
|
||||
горизонтальный скролл `.eh-cal-week-scroll` (mobile ~390).
|
||||
|
||||
@@ -121,6 +123,7 @@ API: `GET /v1/user/booking-requests` (Back) + существующий `PUT /v1/
|
||||
| D9 B + confirmed personal overlay | **done** Front#35 (`57cfcf3`) |
|
||||
| Mood chip + Lens strip | **done** Front#39 (`c2cb754` / `d38a927`); chip → AppShell Front#40; mobile icon-only Front#45 |
|
||||
| Week view single header + scroll | **done** Front#40 (`db11c41`) |
|
||||
| Studio / Мастер commercial grid + `booking_occupancy` | Spec: FrontSpec §3.1/§5.4 + BackSpec events; aggregate по `start_time`+`duration`, rail Расписание\|Команда\|Студия; follow-up visual polish board §E |
|
||||
|
||||
## Mood concept = product surface
|
||||
|
||||
|
||||
Reference in New Issue
Block a user