# Модель данных (PocketBase) Схема создаётся JS-миграциями в `pocketbase/pb_migrations/` (одна миграция на изменение, пишется руками, не через админку). Кастомные маршруты и хуки — в `pocketbase/pb_hooks/`. У всех коллекций, кроме `users`, есть поле `owner` — ссылка на владельца. Это основа изоляции: любые правила доступа начинаются с `owner = @request.auth.id`. Поля `id`, `created`, `updated` (autodate) есть у всех коллекций и ниже не перечисляются. ## users (auth-коллекция, уже существует в PocketBase) | Поле | Тип | Ограничения | | ---------- | -------- | ------------------------------------------------------------------- | | `email` | email | обязательно, уникально без учёта регистра (индекс `COLLATE NOCASE`) | | `password` | password | минимум 8 символов | | `name` | text | необязательно, до 100 символов | | `verified` | bool | системное; гость не может выставить `true` | Формат email дополнительно проверяет хук `pb_hooks/users_email.pb.js` при любом сохранении записи: только печатные символы ASCII без пробелов, минимум один символ до `@`, один после `@`, точка и минимум два символа после неё. Поэтому кириллические адреса отклоняются (400, ошибка в поле `email`), а индекс `COLLATE NOCASE` покрывает все допустимые адреса. Способ входа — только email + пароль (OAuth2, OTP, MFA выключены). | Правило | Значение | Смысл | | ----------- | --------------------------------- | --------------------------------- | | list / view | `id = @request.auth.id` | пользователь видит только себя | | create | `""` (всем) | открытая регистрация | | update | `id = @request.auth.id` | меняет только себя | | delete | `null` (только суперпользователь) | удаление аккаунта в MVP не делаем | Поле `avatar`, созданное PocketBase по умолчанию, можно оставить, в MVP оно не используется. ## tags — метки Метки заменяют и категории, и источники заявок: «Установка Windows», «YouDo», «Профи.ру», «Сарафан», «Гарантия» — это всё метки. Набор у каждого пользователя свой. | Поле | Тип | Ограничения | | ------- | ---------------- | -------------------------------------------------------------------------------------------------- | | `owner` | relation → users | обязательно, одна, `cascadeDelete: true` | | `name` | text | обязательно, 1–50 символов | | `color` | select | одно из: `gray`, `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `purple`, `pink`; обязательно | Индекс: уникальный `(owner, name COLLATE NOCASE)` — у одного пользователя нет двух меток с одинаковым названием без учёта регистра; у разных пользователей названия могут совпадать. Цвет — ключ из фиксированной палитры, а не произвольный HEX: так UI гарантирует контраст AA в светлой и тёмной теме. ## clients — клиенты | Поле | Тип | Ограничения | | --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `owner` | relation → users | обязательно, `cascadeDelete: true` | | `name` | text | обязательно, 1–200 символов (как клиента зовут или как он подписан на площадке) | | `phones` | json | массив нормализованных номеров (`["79991234567"]`), до 5 штук, можно пустой | | `email` | email | необязательно | | `contacts` | text | свободный текст до 2000 символов: мессенджеры и прочие контакты | | `external_urls` | json | необязательно; массив ссылок на профили клиента на любых площадках (YouDo, Профи.ру, Avito и др.), до 5 штук, каждая — валидный `http(s)` URL | | `address` | text | необязательно, до 500 символов | | `note` | text | внутренняя заметка, до 5000 символов | Нормализация телефона (делает фронтенд, функция в `clients/domain`): оставить только цифры; `8XXXXXXXXXX` → `7XXXXXXXXXX`; 10 цифр, начинающиеся на `9`, → добавить `7`. Остальные номера хранятся как есть (только цифры). Удалить клиента, у которого есть заявки, нельзя (связь `orders.client` обязательная и без каскада): сначала удаляются или переносятся заявки. ## orders — заявки | Поле | Тип | Ограничения | | -------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `owner` | relation → users | обязательно, `cascadeDelete: true` | | `client` | relation → clients | обязательно, одна, без каскада | | `title` | text | обязательно, 1–200 символов | | `description` | text | описание проблемы со слов клиента, до 10000 | | `status` | select | обязательно: `new`, `scheduled`, `in_progress`, `done`, `failed` | | `scheduled_at` | date | дата и время визита, необязательно | | `address` | text | адрес выезда, до 500 (по умолчанию в форме подставляется адрес клиента) | | `price` | number | целые рубли, ≥ 0, необязательно | | `result` | text | что сделано, до 10000; **виден по публичной ссылке** | | `note` | text | примечания мастера, до 10000; **видны по публичной ссылке** | | `review` | text | отзыв клиента (например, с площадки), до 5000; **виден по публичной ссылке** | | `external_url` | url | необязательно; ссылка на страницу заказа на любой площадке (YouDo, Профи.ру, Avito и др.) | | `tags` | relation → tags | несколько, до 20, необязательно | | `attachments` | file | несколько, до 20 файлов, до 50 МБ каждый, **protected**; изображения (`image/jpeg`, `image/png`, `image/webp`, `image/heic`) и аудио (`audio/mpeg`, `audio/mp4`, `audio/x-m4a`, `audio/aac`, `audio/ogg`, `audio/wav`, `audio/webm`, `audio/amr`); миниатюры `200x200` | | `share_token` | text | необязательно; если задан — 43 символа `[A-Za-z0-9_-]`; уникален среди непустых | Статусы и подписи в UI: | Значение | Подпись | | ------------- | ------------- | | `new` | Новая | | `scheduled` | Запланирована | | `in_progress` | В работе | | `done` | Выполнена | | `failed` | Не выполнена | Переходы между статусами не ограничены: мастер может выставить любой. Индексы: `owner`, `(owner, status)`, `(owner, created)`, уникальный частичный `share_token WHERE share_token != ''`. ### Соответствие полям YouDo (для справки) | YouDo | Pchelpcrm | | -------------------- | -------------------------- | | title, description | `title`, `description` | | price | `price` | | address, privateInfo | `address` | | customerName | `clients.name` | | customerUrl | `clients.external_urls` | | taskUrl | `external_url` | | category | метка | | review | `review` | | taskDate | `created` / `scheduled_at` | ## Правила доступа для tags, clients, orders Общий шаблон (для `orders` добавляются проверки связей): | Правило | Значение | | ----------- | ------------------------------------------------------------------------------------------------------------- | | list / view | `owner = @request.auth.id` | | create | `@request.auth.id != "" && owner = @request.auth.id` | | update | `owner = @request.auth.id && (@request.body.owner:isset = false \|\| @request.body.owner = @request.auth.id)` | | delete | `owner = @request.auth.id` | Дополнительно для `orders` (create и update): - клиент принадлежит тому же пользователю: `client.owner = @request.auth.id`; - все метки принадлежат тому же пользователю (для пустого списка условие выполняется). Если какое-то условие невозможно выразить правилом, его реализуют хуком в `pocketbase/pb_hooks/`. Решение принимается при реализации, поведение фиксируют тесты. ### Как PocketBase отвечает на нарушения правил API-тесты проверяют именно это поведение. Если фактический код в PocketBase 0.40 окажется другим, тесты фиксируют фактический, а главное требование неизменно: **чужие данные никогда не попадают в ответ и не меняются**. | Операция | Правило не выполнено | Правило `null` (не суперпользователь) | | -------- | ------------------------------- | ------------------------------------- | | list | 200, чужие записи отфильтрованы | 403 | | view | 404 | 403 | | create | 400 | 403 | | update | 404 | 403 | | delete | 404 | 403 | ## Публичный маршрут `GET /api/public/orders/{token}` (хук в `pocketbase/pb_hooks/`), без авторизации. - Ищет заявку по непустому `share_token`, совпадающему точно. - Ответ 200 содержит **только**: `title`, `description`, `result`, `note`, `review`, `status`, `scheduled_at`, `price`, `created`, `tags: [{ name, color }]`. - Никогда не содержит: `id` заявки, `owner`, данные клиента (имя, телефоны, контакты, адрес, ссылки на профили), `address` заявки, `external_url`, `attachments`, `share_token`, email мастера. - Нет заявки или токен пустой/неверного формата — 404 с одинаковым телом. - Заголовки: `Cache-Control: no-store`, `X-Robots-Tag: noindex`. Ссылку создаёт владелец, записывая в `share_token` случайную строку (32 случайных байта в base64url, сгенерированные в браузере через `crypto.getRandomValues`). Отключить ссылку — записать пустую строку; перевыпустить — записать новую.