98 lines
14 KiB
Markdown
98 lines
14 KiB
Markdown
# crm-wiki
|
||
|
||
Вики (база знаний) для CRM Tvorymo. Открывается в iframe из боковой панели CRM, пользователя узнаёт по токену CRM (postMessage `crm:ready` → `crm:auth`).
|
||
|
||
- Сайт: https://wiki-crm.tes.gd
|
||
- Спецификация: [SPEC.md](SPEC.md)
|
||
- Стек: ASP.NET Core 10 (minimal API) + SQLite (FTS5), фронтенд Vite + React + TipTap.
|
||
- Данные: `/data/wiki.db` и `/data/images/` на постоянном томе `wiki-data` (Coolify).
|
||
|
||
## Авторизация
|
||
|
||
Каждый запрос к `/api/*` (кроме `/api/verify` и `/api/config`) требует:
|
||
|
||
| Заголовок | Значение |
|
||
|---|---|
|
||
| `Authorization` | `Bearer <access_token Supabase из CRM>` |
|
||
| `X-Wiki-Workspace` | `shared` (по умолчанию), `personal:<uid>` или id пользовательского workspace CRM (например, `wsmv5l7d425re160`, как отдаёт `crm workspaces list`) |
|
||
|
||
Сервер проверяет токен: `exp`, `aud = "authenticated"` и `GET <SUPABASE_URL>/auth/v1/user` с publishable-ключом (результат кэшируется на 5 минут по SHA-256 токена). Workspace из запроса не доверяется: `shared` доступен всем; `personal:<uid>` — владельцу `uid`, а другим — по выдаче доступа CRM («Доступ для акаунтів», таблица `access_grants`, строка `<owner>:<grantee>`): `GET /rest/v1/access_grants?owner_uid=eq.<uid>&grantee_uid=eq.<я>` с токеном пользователя, личную вики открывает `projects = all` («Мої особисті проєкти: Усі»), права — `level` (`read` — лишь просмотр, `full` — полные); «Вибрані» проекты и один лишь план вики не открывают. Id пользовательского workspace проверяется запросом `GET /rest/v1/workspaces?id=eq.<id>&select=id,owner_uid,members` с токеном пользователя (RLS Supabase): владелец и участники — полные права; строка видна, но я не владелец и не участник — значит, её открыли через `access_grants.shared = all` владельцем или участником, права по `level`. Результат проверки workspace кэшируется на 1 минуту, поэтому выданный или отозванный в CRM доступ вступает в силу в пределах минуты. В workspace «лишь просмотр» любой запрос, кроме `GET`/`HEAD`, получает `403`; `/api/me` и `/api/tree` возвращают `access: "read" | "full"`, интерфейс в режиме `read` прячет кнопки изменения. Id в таблице `workspaces` CRM текстовые: `shared`, `personal:<uid>` и для пользовательских `ws` + base36 (`createWorkspace` в CRM). Принимается любой id из `[A-Za-z0-9_-]{1,64}` (в том числе uuid), другие символы отклоняются ещё до запроса к Supabase. Ответы: `401` — токен не принят или истёк, `403` — нет доступа к workspace.
|
||
|
||
Пока CRM не передаёт workspace (задача Олега `mv6p5b92slw1an`), фронтенд работает в `shared`. Когда в `crm:auth` появится `workspace: { id, kind, title }`, он подхватится автоматически: `kind: "shared"` → `shared`, `kind: "personal"` → `personal:<uid>` (свой), иначе `id` (в том числе `personal:<uid владельца>` с `kind: "granted"` — чужое личное, открытое мне). Сообщение о смене workspace тоже обрабатывается (`{ type: "crm:workspace", workspace: {…} }`), если Олег выберет другое имя — поправить в `frontend/src/crm.ts`.
|
||
|
||
## Ссылки на страницы в CRM
|
||
|
||
Спецификация «Ссылки на страницы вики в CRM» от 2026-10-14 (часть CRM — задача Олега `mv7z4jkhgzaelh`).
|
||
|
||
- **CRM → вики `crm:context`:** `{ type: "crm:context", version: 1, link_template: "https://<crm>/#/module/<id>?path={path}&ws={ws}" }`. Принимается только с origin из `Crm__Origins`. Формат терпимый: `link_template` (или `linkTemplate`) ищется в самом сообщении `crm:context` или `crm:auth`, а также во вложенных `context` / `crm_context` / `crm`. Шаблон без `{path}` или не http(s) игнорируется.
|
||
- **Кнопка «Скопіювати посилання»** (страница и папка): ссылка по `link_template`, `{path}` = `/p/<id>` или `/f/<id>`, `{ws}` = `workspace.id` из CRM (оба через `encodeURIComponent`). Пока шаблона нет — прямая ссылка `https://wiki-crm.tes.gd/p/<id>`. Копирование с запасным путём (textarea + `execCommand`).
|
||
- **CRM → вики `crm:theme`:** `{ type: "crm:theme", version: 1, theme: "light"|"dark", palette: "<имя>" }` — в ответ на `crm:ready`/`crm:refresh` и при каждой смене темы или палитры. Только с origin из `Crm__Origins` и от `window.parent`. Вики ставит `<html data-theme data-pal>` (`palette` по умолчанию `classic`), остальное делают стили `tvorymo-ui.css`. Последняя тема запоминается в `localStorage`, чтобы при загрузке не мигала системная.
|
||
- **Вики → CRM `crm:navigate`:** `{ type: "crm:navigate", version: 1, path }` при каждом переходе (страница, папка, главная, назад/вперёд) и один раз после первого `crm:auth`. Только в iframe и только на origin CRM.
|
||
- **Вне iframe:** для `/p/<id>` и `/f/<id>` кнопка «Відкрити в CRM» по env `CRM_LINK_TEMPLATE` (`{ws}` пустой). `CRM_AUTO_REDIRECT=true` — сразу `location.replace` туда. Без шаблона всё как раньше. Оба значения отдаёт `GET /api/config` (`crmLinkTemplate`, `crmAutoRedirect`).
|
||
|
||
## REST API
|
||
|
||
Все ответы JSON (кроме `/markdown` и картинок). Ошибки: `{ "error": "…" }`.
|
||
|
||
| Метод | Путь | Что делает |
|
||
|---|---|---|
|
||
| `GET` | `/health` | `{ status, dbCreatedAt }` — без авторизации; `dbCreatedAt` не меняется, пока жив том с базой |
|
||
| `GET` | `/api/config` | `{ crmOrigins: [...] }` — без авторизации |
|
||
| `POST` | `/api/verify` | `{ access_token }` → `{ ok, id, email, name, exp }` — без авторизации |
|
||
| `GET` | `/api/me` | текущий пользователь, workspace и `access` (`read` / `full`) |
|
||
| `GET` | `/api/tree` | `{ workspace, access, nodes: [Node] }` — все папки и страницы workspace без текста |
|
||
| `GET` | `/api/nodes/{id}` | папка или страница (Page) |
|
||
| `GET` | `/api/pages/{id}` | страница (Page), `404` для папки |
|
||
| `GET` | `/api/pages/{id}/markdown` | текст страницы, `text/markdown` |
|
||
| `POST` | `/api/pages` | `{ title, parentId?, markdown? }` → `201` Page |
|
||
| `POST` | `/api/folders` | `{ title, parentId? }` → `201` Page (kind = folder) |
|
||
| `PUT` | `/api/pages/{id}` | `{ title?, markdown?, version }` → Page; если `version` устарела — `409 { error, current }` |
|
||
| `PATCH` | `/api/nodes/{id}` | переименовать/переместить: `{ title?, parentId?, version? }`; `parentId: null` — в корень. Перенос в себя/потомка или в страницу — `400` |
|
||
| `DELETE` | `/api/nodes/{id}` | удалить узел со всем содержимым → `{ deleted: N }` |
|
||
| `GET` | `/api/search?q=…&limit=30` | `{ results: [{ id, kind, title, snippet, path }] }` — FTS5 по названию и тексту, префиксный поиск по словам; совпадения в `snippet` обрамлены `\u0002…\u0003` |
|
||
| `POST` | `/api/images` | `multipart/form-data`: `file` (PNG/JPEG/GIF/WebP, до 10 МБ), `pageId?` → `201 { id, url }` |
|
||
| `GET` | `/api/images/{id}` | картинка; нужен токен и доступ к workspace картинки |
|
||
| `DELETE` | `/api/images/{id}` | удалить картинку своего workspace (`X-Wiki-Workspace`): строку в базе и файл → `200 { id, deleted: true }`. Нет такой картинки в этом workspace — `404`. Картинка упомянута на странице (`/api/images/{id}` в тексте любой страницы любого workspace) — `409 { error, pages: [{ id, workspace, title }] }`, ничего не удаляется; `title` = `null` для страниц чужого workspace |
|
||
|
||
```ts
|
||
type Node = { id, parentId: string|null, kind: "folder"|"page", title, version: number,
|
||
createdAt, updatedAt, updatedBy /* uid */, updatedByName };
|
||
type Page = Node & { markdown: string, path: { id, kind, title }[] /* папки от корня */,
|
||
author: Person | null /* кто создал (createdAt) */,
|
||
lastEditor: Person | null /* кто последний правил (updatedAt) */ };
|
||
type Person = { id /* uid Supabase */, name: string|null, email: string|null,
|
||
avatarUrl: string|null /* публичный https-URL или data:image */, color: string|null /* кружок с инициалами */ };
|
||
```
|
||
|
||
### Автор
|
||
|
||
У каждой страницы и папки хранятся автор (`created_by` = uid из проверенного токена, `createdAt`) и последний редактор (`updated_by`, `updatedAt`); перенос и переименование тоже считаются правкой. Агенты (бот «Лана» и т. п.) — такие же авторы, как люди. Поля `author` и `lastEditor` есть в ответах `GET /api/pages/{id}`, `GET /api/nodes/{id}` и в ответах на создание и сохранение; в `/api/tree` их нет. Если автор неизвестен, поле `null`; если uid известен, а профиля нет, `name`/`avatarUrl` будут `null`.
|
||
|
||
Профиль берётся там же, где его берёт CRM: довідник виконавців в `app_config` (`id = 'main'`, `data.assignees[]`: `{ id, name, email, uid, avatar, color }`), запрос с токеном пользователя (RLS), сопоставление по `uid`, затем по email. Обновляется не чаще раза в 10 минут на пользователя и хранится в таблице `users` вики. Аватар — URL из `avatar`: CRM кладёт фото в публичный бакет Storage `logos` (`avatars/<id записи>`) и сохраняет `getPublicUrl`, поэтому картинка грузится без токена. Принимаются только `https://` и `data:image/`. Нет фото — кружок с инициалами на цвете `color` (свой цвет записи или `colorOf(id)`, как в CRM). Нет записи в довіднику (например, бот) — имя из токена (`user_metadata.name` → email), инициалы.
|
||
|
||
Формат текста — Markdown (GFM: таблицы, списки задач). Ссылка на другую страницу: `[текст](/p/<id>)`, картинка: ``. Ссылки по id не ломаются при переименовании и переносе.
|
||
|
||
Пример (агент или скрипт):
|
||
|
||
```sh
|
||
curl -H "Authorization: Bearer $TOKEN" https://wiki-crm.tes.gd/api/tree
|
||
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||
-d '{"title":"Регламент","markdown":"# Привет"}' https://wiki-crm.tes.gd/api/pages
|
||
```
|
||
|
||
## Заголовки
|
||
|
||
`Content-Security-Policy: frame-ancestors https://shotam.tvorymo.com https://units.tvorymo.com; …` (без `X-Frame-Options`). Список origin CRM задаётся env `CRM_ORIGINS` или `Crm__Origins` (через пробел или запятую): из них принимается `crm:auth` (плюс `ev.source === window.parent`) и они же попадают в `frame-ancestors`. Origin родителя фронтенд определяет по `location.ancestorOrigins` или `document.referrer` и шлёт `crm:ready` только ему; если родитель не определён — каждому origin из списка (браузер доставит лишь совпавшему). Домен стилей CRM — `Crm__AssetsOrigin`.
|
||
|
||
На экране ожидания есть диагностика: origin родителя, куда ушёл `crm:ready`, какие сообщения пришли (без токенов); через 10 секунд без `crm:auth` — подсказка. Интерфейс на украинском.
|
||
|
||
## Разработка
|
||
|
||
```sh
|
||
cd backend/Wiki.Api && ASPNETCORE_URLS=http://127.0.0.1:5080 dotnet run # база в ./data
|
||
cd frontend && npm i && npm run dev # Vite, /api проксируется на 127.0.0.1:5080
|
||
dotnet test backend/Wiki.Api.Tests # тесты проверки доступа к workspace
|
||
```
|
||
|
||
Настройки (env): `Storage__DataDir`, `CRM_ORIGINS` (`Crm__Origins`), `Crm__AssetsOrigin`, `Supabase__Url`, `Supabase__PublishableKey`.
|