Migration sets access rules, password min 8, name max 100, password-only auth, and a case-insensitive unique email index. A hook rejects non-ASCII and malformed emails on every save. API tests cover registration, login, and isolation between two users and a guest. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
18 KiB
Модель данных (PocketBase)
Схема создаётся JS-миграциями в pocketbase/pb_migrations/ (одна миграция на изменение, пишется руками, не через админку). Кастомные маршруты и хуки — в pocketbase/pb_hooks/.
У всех коллекций, кроме users, есть поле owner — ссылка на владельца. Это основа изоляции: любые правила доступа начинаются с owner = @request.auth.id.
Поля id, created, updated (autodate) есть у всех коллекций и ниже не перечисляются.
users (auth-коллекция, уже существует в PocketBase)
| Поле | Тип | Ограничения |
|---|---|---|
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 |
необязательно | |
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). Отключить ссылку — записать пустую строку; перевыпустить — записать новую.