diff --git a/docs/data-model.md b/docs/data-model.md
index a44ad47..a0777ba 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -8,21 +8,23 @@
## users (auth-коллекция, уже существует в PocketBase)
-| Поле | Тип | Ограничения |
-| --- | --- | --- |
-| `email` | email | обязательно, уникально |
-| `password` | password | минимум 8 символов |
-| `name` | text | необязательно, до 100 символов |
-| `verified` | bool | системное; гость не может выставить `true` |
+| Поле | Тип | Ограничения |
+| ---------- | -------- | ------------------------------------------------------------------- |
+| `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 не делаем |
+| Правило | Значение | Смысл |
+| ----------- | --------------------------------- | --------------------------------- |
+| list / view | `id = @request.auth.id` | пользователь видит только себя |
+| create | `""` (всем) | открытая регистрация |
+| update | `id = @request.auth.id` | меняет только себя |
+| delete | `null` (только суперпользователь) | удаление аккаунта в MVP не делаем |
Поле `avatar`, созданное PocketBase по умолчанию, можно оставить, в MVP оно не используется.
@@ -30,11 +32,11 @@
Метки заменяют и категории, и источники заявок: «Установка Windows», «YouDo», «Профи.ру», «Сарафан», «Гарантия» — это всё метки. Набор у каждого пользователя свой.
-| Поле | Тип | Ограничения |
-| --- | --- | --- |
-| `owner` | relation → users | обязательно, одна, `cascadeDelete: true` |
-| `name` | text | обязательно, 1–50 символов |
-| `color` | select | одно из: `gray`, `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `purple`, `pink`; обязательно |
+| Поле | Тип | Ограничения |
+| ------- | ---------------- | -------------------------------------------------------------------------------------------------- |
+| `owner` | relation → users | обязательно, одна, `cascadeDelete: true` |
+| `name` | text | обязательно, 1–50 символов |
+| `color` | select | одно из: `gray`, `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `purple`, `pink`; обязательно |
Индекс: уникальный `(owner, name COLLATE NOCASE)` — у одного пользователя нет двух меток с одинаковым названием без учёта регистра; у разных пользователей названия могут совпадать.
@@ -42,16 +44,16 @@
## 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 символов |
+| Поле | Тип | Ограничения |
+| --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| `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`. Остальные номера хранятся как есть (только цифры).
@@ -59,33 +61,33 @@
## 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_-]`; уникален среди непустых |
+| Поле | Тип | Ограничения |
+| -------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `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` | Не выполнена |
+| Значение | Подпись |
+| ------------- | ------------- |
+| `new` | Новая |
+| `scheduled` | Запланирована |
+| `in_progress` | В работе |
+| `done` | Выполнена |
+| `failed` | Не выполнена |
Переходы между статусами не ограничены: мастер может выставить любой.
@@ -93,28 +95,28 @@
### Соответствие полям 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` |
+| 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` |
+| Правило | Значение |
+| ----------- | ------------------------------------------------------------------------------------------------------------- |
+| 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):
@@ -127,13 +129,13 @@
API-тесты проверяют именно это поведение. Если фактический код в PocketBase 0.40 окажется другим, тесты фиксируют фактический, а главное требование неизменно: **чужие данные никогда не попадают в ответ и не меняются**.
-| Операция | Правило не выполнено | Правило `null` (не суперпользователь) |
-| --- | --- | --- |
-| list | 200, чужие записи отфильтрованы | 403 |
-| view | 404 | 403 |
-| create | 400 | 403 |
-| update | 404 | 403 |
-| delete | 404 | 403 |
+| Операция | Правило не выполнено | Правило `null` (не суперпользователь) |
+| -------- | ------------------------------- | ------------------------------------- |
+| list | 200, чужие записи отфильтрованы | 403 |
+| view | 404 | 403 |
+| create | 400 | 403 |
+| update | 404 | 403 |
+| delete | 404 | 403 |
## Публичный маршрут
diff --git a/pocketbase/pb_hooks/users_email.pb.js b/pocketbase/pb_hooks/users_email.pb.js
new file mode 100644
index 0000000..e69b63e
--- /dev/null
+++ b/pocketbase/pb_hooks/users_email.pb.js
@@ -0,0 +1,17 @@
+///
+
+// Встроенная проверка PocketBase пропускает кириллицу и домен верхнего уровня из одной буквы.
+// onRecordValidate срабатывает при любом сохранении: регистрация, смена email, суперпользователь.
+onRecordValidate((e) => {
+ // Обработчики выполняются изолированно, поэтому регулярка объявлена внутри.
+ // Только печатные символы ASCII без пробела: что-то@что-то.минимум-два-символа.
+ const email = /^[!-?A-~]+@[!-?A-~]+\.[!-?A-~]{2,}$/;
+
+ if (!email.test(e.record.email())) {
+ throw new BadRequestError('Некорректный email.', {
+ email: new ValidationError('validation_invalid_email', 'Некорректный email.'),
+ });
+ }
+
+ e.next();
+}, 'users');
diff --git a/pocketbase/pb_migrations/1793884947_configure_users.js b/pocketbase/pb_migrations/1793884947_configure_users.js
new file mode 100644
index 0000000..f0e7dc4
--- /dev/null
+++ b/pocketbase/pb_migrations/1793884947_configure_users.js
@@ -0,0 +1,40 @@
+///
+
+function configureUsers(app, { deleteRule, nameMax, emailColumn }) {
+ const users = app.findCollectionByNameOrId('users');
+
+ users.listRule = 'id = @request.auth.id';
+ users.viewRule = 'id = @request.auth.id';
+ users.createRule = '';
+ users.updateRule = 'id = @request.auth.id';
+ users.deleteRule = deleteRule;
+
+ // Встроенный индекс PocketBase учитывает регистр: без замены A@x.ru и a@x.ru — разные аккаунты.
+ users.addIndex('idx_email__pb_users_auth_', true, emailColumn, "`email` != ''");
+
+ users.fields.getByName('password').min = 8;
+ users.fields.getByName('name').max = nameMax;
+
+ users.passwordAuth.enabled = true;
+ users.passwordAuth.identityFields = ['email'];
+ users.oauth2.enabled = false;
+ users.otp.enabled = false;
+ users.mfa.enabled = false;
+
+ app.save(users);
+}
+
+migrate(
+ (app) =>
+ configureUsers(app, {
+ deleteRule: null,
+ nameMax: 100,
+ emailColumn: '`email` COLLATE NOCASE',
+ }),
+ (app) =>
+ configureUsers(app, {
+ deleteRule: 'id = @request.auth.id',
+ nameMax: 255,
+ emailColumn: '`email`',
+ }),
+);
diff --git a/tests/api/users.api.spec.ts b/tests/api/users.api.spec.ts
new file mode 100644
index 0000000..42a72eb
--- /dev/null
+++ b/tests/api/users.api.spec.ts
@@ -0,0 +1,251 @@
+import { describe, expect, it } from 'vitest';
+import { asGuest, asSuperuser, asUser, createUser } from '../support/clients';
+
+const newEmail = () => `user-${crypto.randomUUID()}@test.local`;
+
+function register(overrides: Record = {}) {
+ const data = { email: newEmail(), password: crypto.randomUUID(), ...overrides };
+ return asGuest()
+ .collection('users')
+ .create({ ...data, passwordConfirm: data.password });
+}
+
+const invalidField = (field: string) => ({
+ status: 400,
+ response: { data: { [field]: expect.anything() } },
+});
+
+describe('users: регистрация', () => {
+ it('регистрирует гостя с валидными данными, неподтверждённым и с именем', async () => {
+ const record = await register({ name: 'Иван' });
+
+ expect(record['verified']).toBe(false);
+ expect(record['name']).toBe('Иван');
+ });
+
+ it('регистрирует гостя без имени', async () => {
+ await expect(register()).resolves.toMatchObject({ verified: false });
+ });
+
+ it('принимает пароль из 8 символов и имя из 100 символов', async () => {
+ const name = 'я'.repeat(100);
+
+ const record = await register({ password: 'a1b2c3d4', name });
+
+ expect(record['name']).toBe(name);
+ });
+
+ it('не даёт гостю зарегистрироваться подтверждённым', async () => {
+ const email = newEmail();
+
+ // Поведение 6 допускает оба исхода: поле проигнорировано или запрос отклонён.
+ await register({ email, verified: true }).catch((error: unknown) => {
+ expect(error).toMatchObject({ status: 400 });
+ });
+
+ const su = await asSuperuser();
+ const { items } = await su
+ .collection('users')
+ .getList(1, 1, { filter: su.filter('email = {:email}', { email }) });
+ expect(items.map((record) => record['verified'])).not.toContain(true);
+ });
+
+ it('отклоняет повторный email с ошибкой в поле email', async () => {
+ const email = newEmail();
+ await register({ email });
+
+ await expect(register({ email })).rejects.toMatchObject(invalidField('email'));
+ });
+
+ it('отклоняет тот же email в другом регистре с ошибкой в поле email', async () => {
+ const email = newEmail();
+ await register({ email });
+
+ await expect(register({ email: email.toUpperCase() })).rejects.toMatchObject(
+ invalidField('email'),
+ );
+ });
+
+ it('отклоняет пароль из 7 символов с ошибкой в поле password', async () => {
+ await expect(register({ password: 'a1b2c3d' })).rejects.toMatchObject(
+ invalidField('password'),
+ );
+ });
+
+ it('отклоняет невалидный email с ошибкой в поле email', async () => {
+ await expect(register({ email: 'not-an-email' })).rejects.toMatchObject(
+ invalidField('email'),
+ );
+ });
+
+ it.each([
+ 'иван@test.local',
+ 'user@домен.рф',
+ 'user@test.рф',
+ 'a@b.c',
+ 'a@b',
+ '@test.local',
+ 'user@.local',
+ ])('отклоняет email %s с ошибкой в поле email', async (email) => {
+ await expect(register({ email })).rejects.toMatchObject(invalidField('email'));
+ });
+
+ it.each(['a@b.cd', 'first.last+tag@sub.example.com'])('принимает email %s', async (email) => {
+ await expect(register({ email })).resolves.toMatchObject({ id: expect.any(String) });
+ });
+
+ it('не даёт сменить email на не-ASCII даже суперпользователю', async () => {
+ const a = await createUser();
+ const su = await asSuperuser();
+
+ await expect(
+ su
+ .collection('users')
+ .update(a.id, { email: `иван-${crypto.randomUUID()}@test.local` }),
+ ).rejects.toMatchObject(invalidField('email'));
+
+ const record = await su.collection('users').getOne(a.id);
+ expect(record['email']).toBe(a.email);
+ });
+
+ it('отклоняет имя из 101 символа с ошибкой в поле name', async () => {
+ await expect(register({ name: 'я'.repeat(101) })).rejects.toMatchObject(
+ invalidField('name'),
+ );
+ });
+});
+
+describe('users: вход', () => {
+ it('с верным паролем возвращает токен и запись пользователя', async () => {
+ const user = await createUser();
+
+ const auth = await asGuest()
+ .collection('users')
+ .authWithPassword(user.email, user.password);
+
+ expect(auth.token).not.toBe('');
+ expect(auth.record.id).toBe(user.id);
+ });
+
+ it('с неверным паролем отвечает 400', async () => {
+ const user = await createUser();
+
+ await expect(
+ asGuest().collection('users').authWithPassword(user.email, 'wrong-password'),
+ ).rejects.toMatchObject({ status: 400 });
+ });
+
+ it('из способов входа включён только пароль по email', async () => {
+ const methods = await asGuest().collection('users').listAuthMethods();
+
+ expect(methods.password.enabled).toBe(true);
+ expect(methods.password.identityFields).toEqual(['email']);
+ expect(methods.oauth2.enabled).toBe(false);
+ expect(methods.otp.enabled).toBe(false);
+ expect(methods.mfa.enabled).toBe(false);
+ });
+});
+
+describe('users: доступ', () => {
+ it('показывает пользователю в list только его самого', async () => {
+ const a = await createUser();
+ await createUser();
+
+ const { items, totalItems } = await (await asUser(a)).collection('users').getList();
+
+ expect(items.map((record) => record.id)).toEqual([a.id]);
+ expect(totalItems).toBe(1);
+ });
+
+ it('отдаёт пользователю свою запись и не отдаёт чужую', async () => {
+ const a = await createUser();
+ const b = await createUser();
+ const pb = await asUser(a);
+
+ await expect(pb.collection('users').getOne(a.id)).resolves.toMatchObject({ id: a.id });
+ await expect(pb.collection('users').getOne(b.id)).rejects.toMatchObject({ status: 404 });
+ });
+
+ it('не показывает пользователей гостю', async () => {
+ const a = await createUser();
+ const guest = asGuest();
+
+ const { items, totalItems } = await guest.collection('users').getList();
+
+ expect(items).toEqual([]);
+ expect(totalItems).toBe(0);
+ await expect(guest.collection('users').getOne(a.id)).rejects.toMatchObject({
+ status: 404,
+ });
+ });
+
+ it('не даёт гостю изменить и удалить пользователя', async () => {
+ const a = await createUser({ name: 'А' });
+ const guest = asGuest();
+
+ await expect(
+ guest.collection('users').update(a.id, { name: 'Взлом' }),
+ ).rejects.toMatchObject({ status: 404 });
+ await expect(guest.collection('users').delete(a.id)).rejects.toMatchObject({ status: 403 });
+
+ const record = await (await asSuperuser()).collection('users').getOne(a.id);
+ expect(record['name']).toBe('А');
+ });
+
+ it('не находит чужую запись через filter по email', async () => {
+ const a = await createUser();
+ const b = await createUser();
+
+ for (const pb of [await asUser(a), asGuest()]) {
+ const { items, totalItems } = await pb
+ .collection('users')
+ .getList(1, 50, { filter: pb.filter('email = {:email}', { email: b.email }) });
+
+ expect(items).toEqual([]);
+ expect(totalItems).toBe(0);
+ }
+ });
+
+ it('даёт изменить своё имя и не даёт изменить чужое', async () => {
+ const a = await createUser({ name: 'А' });
+ const b = await createUser({ name: 'Б' });
+ const pb = await asUser(a);
+
+ await expect(pb.collection('users').update(a.id, { name: 'Новое' })).resolves.toMatchObject(
+ { name: 'Новое' },
+ );
+ await expect(pb.collection('users').update(b.id, { name: 'Взлом' })).rejects.toMatchObject({
+ status: 404,
+ });
+
+ const recordB = await (await asSuperuser()).collection('users').getOne(b.id);
+ expect(recordB['name']).toBe('Б');
+ });
+
+ it('не даёт пользователю подтвердить себя через update', async () => {
+ const a = await createUser();
+ const pb = await asUser(a);
+
+ // Важен итог, а не ответ: поле может быть проигнорировано или запрос отклонён.
+ await pb
+ .collection('users')
+ .update(a.id, { verified: true })
+ .catch(() => undefined);
+
+ const record = await (await asSuperuser()).collection('users').getOne(a.id);
+ expect(record['verified']).toBe(false);
+ });
+
+ it('не даёт пользователю удалить ни свою, ни чужую запись', async () => {
+ const a = await createUser();
+ const b = await createUser();
+ const pb = await asUser(a);
+
+ await expect(pb.collection('users').delete(a.id)).rejects.toMatchObject({ status: 403 });
+ await expect(pb.collection('users').delete(b.id)).rejects.toMatchObject({ status: 403 });
+
+ const su = await asSuperuser();
+ await expect(su.collection('users').getOne(a.id)).resolves.toMatchObject({ id: a.id });
+ await expect(su.collection('users').getOne(b.id)).resolves.toMatchObject({ id: b.id });
+ });
+});