# Wishpad: database specification
## 1. Цель документа
Документ фиксирует техническое описание базы данных Wishpad для написания Laravel migrations.
Основная БД:
- PostgreSQL;
- Laravel migrations;
- UUID primary keys для доменных сущностей;
- `timestamptz` для дат;
- `snake_case` для таблиц, колонок, индексов и constraints.
Документ дополняет `SPEC.md`. Если возникает расхождение, для миграций приоритет имеет `DATABASE_SPEC.md`.
## 2. Общие Правила
### UUID
Все доменные сущности используют UUID:
- `users.uuid`;
- `wishlists.uuid`;
- `wishlist_items.uuid`;
- `reservations.uuid`;
- `app_currencies.uuid`;
- `app_locale_settings.uuid`.
Laravel model primary key:
- `$primaryKey = 'uuid'`;
- `$keyType = 'string'`;
- `$incrementing = false`.
UUID генерируется backend/migration/model layer, не принимается от frontend.
### Даты
Основные даты:
- `created_at`;
- `updated_at`;
- `deleted_at`, если сущность поддерживает деактивацию или soft delete model.
Тип PostgreSQL:
- `timestamptz`.
Laravel должен работать в UTC на backend. Пользовательские timezone/format применяются на уровне отображения.
### Имена Constraints И Индексов
Рекомендуемый стиль:
- primary key: `{table}_pkey`;
- foreign key: `{table}_{column}_foreign`;
- unique: `{table}_{column}_unique`;
- index: `{table}_{columns}_index`;
- check: `{table}_{rule}_check`.
Точные имена можно оставить Laravel defaults, если они стабильны и читаемы.
### Удаление
В проекте различаются:
- деактивация;
- физическое удаление.
Деактивация пользователя или wishlist не удаляет строки из БД.
Физическое удаление:
- выполняется системой после срока восстановления;
- может выполняться администратором для wishlist;
- удаляет связанные данные каскадно там, где это явно указано.
## 3. Доменные Таблицы
### 3.1. `users`
Зарегистрированные пользователи: владельцы списков, зарегистрированные гости и администраторы.
### Поля
| Поле | Тип PostgreSQL | NULL | Default | Описание |
| --- | --- | --- | --- | --- |
| `uuid` | `uuid` | нет | generated UUID | Primary key пользователя. |
| `email` | `varchar(255)` | нет | - | Email для входа. |
| `password` | `varchar(255)` | нет | - | Хеш пароля. |
| `role` | `varchar(32)` | нет | `user` | Роль: `user` или `admin`. |
| `is_blocked` | `boolean` | нет | `false` | Заблокирован ли пользователь. |
| `must_change_password` | `boolean` | нет | `false` | Нужно ли настойчиво попросить пользователя сменить пароль после входа. Используется для первого администратора и временных паролей. |
| `email_notifications_enabled` | `boolean` | нет | `true` | Общий переключатель email-уведомлений. |
| `notify_reservations_enabled` | `boolean` | нет | `true` | Уведомления о новых и отменённых бронях. |
| `notify_wishlist_changes_enabled` | `boolean` | нет | `true` | Уведомления об изменении, деактивации и удалении wishlist. |
| `notify_gift_changes_enabled` | `boolean` | нет | `true` | Уведомления об изменении и удалении подарков. |
| `locale` | `varchar(16)` | нет | `ru` | Язык интерфейса пользователя. |
| `timezone` | `varchar(64)` | нет | auto/default | Timezone пользователя. В MVP определяется автоматически и не редактируется вручную в owner settings. |
| `date_format` | `varchar(32)` | да | `NULL` | Пользовательский формат даты. `NULL` означает формат языка по умолчанию. |
| `time_format` | `varchar(32)` | да | `NULL` | Пользовательский формат времени. `NULL` означает формат языка по умолчанию. |
| `number_format` | `varchar(32)` | да | `NULL` | Пользовательский формат чисел. `NULL` означает формат языка по умолчанию. |
| `deleted_at` | `timestamptz` | да | `NULL` | Дата деактивации аккаунта. `NULL` означает активный аккаунт. |
| `restore_until` | `timestamptz` | да | `NULL` | Дата, до которой аккаунт можно восстановить после деактивации. |
| `created_at` | `timestamptz` | нет | current timestamp | Дата создания. |
| `updated_at` | `timestamptz` | нет | current timestamp | Дата обновления. |
### Constraints
- primary key: `uuid`;
- unique: `email`;
- check: `role in ('user', 'admin')`;
- check: `restore_until is null or deleted_at is not null`.
### Индексы
- unique index: `email`;
- index: `role`;
- index: `is_blocked`;
- index: `deleted_at`;
- index: `restore_until`;
- index: `created_at`.
### Правила
- В MVP достаточно поля `role`.
- Более сложная permission model остаётся future scope.
- Email в MVP нельзя менять из owner settings.
- Первый администратор создаётся с `must_change_password = true`.
- После успешной смены пароля backend выставляет `must_change_password = false`.
- Если пользователь выключил все дочерние уведомления, `email_notifications_enabled` должен стать `false`.
### 3.2. `wishlists`
Списки подарков, принадлежащие пользователям.
### Поля
| Поле | Тип PostgreSQL | NULL | Default | Описание |
| --- | --- | --- | --- | --- |
| `uuid` | `uuid` | нет | generated UUID | Primary key wishlist. |
| `user_uuid` | `uuid` | нет | - | Владелец списка. FK на `users.uuid`. |
| `title` | `varchar(128)` | нет | - | Название списка. |
| `description` | `varchar(256)` | да | `NULL` | Описание списка, видимое гостям. |
| `public_token` | `varchar(128)` | нет | generated token | Постоянный секретный токен публичной ссылки. |
| `is_active` | `boolean` | нет | `true` | Активен ли список. |
| `deleted_at` | `timestamptz` | да | `NULL` | Дата деактивации списка. `NULL` означает активный список. |
| `created_at` | `timestamptz` | нет | current timestamp | Дата создания. |
| `updated_at` | `timestamptz` | нет | current timestamp | Дата обновления. |
### Constraints
- primary key: `uuid`;
- foreign key: `user_uuid` -> `users.uuid`;
- unique: `public_token`;
- check: `char_length(title) between 1 and 128`;
- check: `description is null or char_length(description) <= 256`;
- check: `(is_active = true and deleted_at is null) or (is_active = false)`.
### Индексы
- index: `user_uuid`;
- unique index: `public_token`;
- index: `is_active`;
- index: `deleted_at`;
- index: `created_at`;
- composite index: `user_uuid, is_active, created_at`.
### FK-Поведение
- При физическом удалении пользователя его wishlists удаляются каскадно.
- При деактивации пользователя wishlists только деактивируются.
Laravel migration:
- `foreign('user_uuid')->references('uuid')->on('users')->cascadeOnDelete()`.
### Правила
- `public_token` генерируется backend.
- `public_token` не является UUID wishlist.
- Публичная ссылка постоянная для одного wishlist и не регенерируется в MVP.
- Публичная ссылка не отзывается отдельно от деактивации wishlist.
- Один пользователь может иметь не более 256 wishlists. Это application-level validation.
### 3.3. `wishlist_items`
Подарки внутри wishlist.
### Поля
| Поле | Тип PostgreSQL | NULL | Default | Описание |
| --- | --- | --- | --- | --- |
| `uuid` | `uuid` | нет | generated UUID | Primary key подарка. |
| `wishlist_uuid` | `uuid` | нет | - | Wishlist, которому принадлежит подарок. FK на `wishlists.uuid`. |
| `title` | `varchar(128)` | нет | - | Название подарка. |
| `description` | `varchar(256)` | да | `NULL` | Описание подарка. |
| `url` | `text` | да | `NULL` | Ссылка на товар. |
| `image_url` | `text` | да | `NULL` | Ссылка на изображение подарка. |
| `price` | `numeric(12, 2)` | нет | `0` | Цена. Может быть дробной. Не может быть отрицательной. |
| `currency` | `varchar(8)` | нет | locale default currency | Валюта цены. Может быть custom currency code. |
| `sort_order` | `integer` | нет | `0` | Порядок отображения внутри wishlist. |
| `created_at` | `timestamptz` | нет | current timestamp | Дата создания. |
| `updated_at` | `timestamptz` | нет | current timestamp | Дата обновления. |
### Constraints
- primary key: `uuid`;
- foreign key: `wishlist_uuid` -> `wishlists.uuid`;
- check: `char_length(title) between 1 and 128`;
- check: `description is null or char_length(description) <= 256`;
- check: `price >= 0`;
- check: `char_length(currency) between 1 and 8`;
- check: `currency` contains only letters/digits after normalization at application level.
### Индексы
- index: `wishlist_uuid`;
- composite index: `wishlist_uuid, sort_order`;
- index: `created_at`.
### FK-Поведение
- При физическом удалении wishlist его gifts удаляются каскадно.
Laravel migration:
- `foreign('wishlist_uuid')->references('uuid')->on('wishlists')->cascadeOnDelete()`.
### Правила
- Один wishlist может иметь не более 100 подарков. Это application-level validation.
- Подарки сортируются по добавлению владельцем через `sort_order`.
- Если цена `0`, UI цену не показывает.
- URL валидируется минимально на application layer: только HTTPS для metadata import. Для ручной ссылки strict URL validation не требуется.
- В MVP изображения не скачиваются в storage, хранится только `image_url`.
- Custom currency code не добавляет валюту в `app_currencies`.
### 3.4. `reservations`
Бронь подарка гостем.
### Поля
| Поле | Тип PostgreSQL | NULL | Default | Описание |
| --- | --- | --- | --- | --- |
| `uuid` | `uuid` | нет | generated UUID | Primary key брони. |
| `wishlist_item_uuid` | `uuid` | нет | - | Забронированный подарок. FK на `wishlist_items.uuid`. |
| `guest_user_uuid` | `uuid` | да | `NULL` | Зарегистрированный пользователь-гость. FK на `users.uuid`. `NULL` для анонимного гостя. |
| `guest_name` | `varchar(64)` | нет | - | Имя гостя, которое видно другим гостям. |
| `guest_token_hash` | `varchar(255)` | нет | - | Хеш guest cookie token. Даёт право отменить бронь. |
| `created_at` | `timestamptz` | нет | current timestamp | Дата создания брони. |
| `updated_at` | `timestamptz` | нет | current timestamp | Дата обновления брони. |
### Constraints
- primary key: `uuid`;
- foreign key: `wishlist_item_uuid` -> `wishlist_items.uuid`;
- nullable foreign key: `guest_user_uuid` -> `users.uuid`;
- unique: `wishlist_item_uuid`;
- check: `char_length(guest_name) between 1 and 64`.
### Индексы
- unique index: `wishlist_item_uuid`;
- index: `guest_user_uuid`;
- index: `created_at`;
### FK-Поведение
- При физическом удалении подарка его reservation удаляется каскадно.
- При физическом удалении пользователя-гостя связь с reservation должна стать `NULL`, если сама бронь ещё существует.
Laravel migration:
- `foreign('wishlist_item_uuid')->references('uuid')->on('wishlist_items')->cascadeOnDelete()`;
- `foreign('guest_user_uuid')->references('uuid')->on('users')->nullOnDelete()`.
### Правила
- Один подарок может быть забронирован только одним человеком.
- Один гость может бронировать несколько подарков.
- Отмена гостем разрешена только при совпадении guest cookie с `guest_token_hash`.
- В БД хранится только hash token, не plaintext token.
- Если guest cookie потерян, самостоятельная отмена гостем невозможна.
- Если подарок с бронью удаляется, перед удалением пишется audit log `reservation.deleted_with_item`.
### 3.5. `app_currencies`
Глобальный список валют для форм и отображения.
### Поля
| Поле | Тип PostgreSQL | NULL | Default | Описание |
| --- | --- | --- | --- | --- |
| `uuid` | `uuid` | нет | generated UUID | Primary key валюты. |
| `code` | `varchar(8)` | нет | - | Код валюты, например `RUB`, `USD`, `EUR`. |
| `symbol` | `varchar(8)` | да | `NULL` | Символ валюты, например `₽`, `$`, `€`. |
| `title` | `varchar(64)` | нет | - | Название валюты для интерфейса. |
| `is_enabled` | `boolean` | нет | `true` | Доступна ли валюта пользователям. |
| `created_at` | `timestamptz` | нет | current timestamp | Дата создания. |
| `updated_at` | `timestamptz` | нет | current timestamp | Дата обновления. |
### Constraints
- primary key: `uuid`;
- unique: `code`;
- check: `char_length(code) between 1 and 8`;
- check: `char_length(title) between 1 and 64`;
- check: `symbol is null or char_length(symbol) <= 8`.
### Индексы
- unique index: `code`;
- index: `is_enabled`;
### Правила
- `code` нормализуется в uppercase.
- `GET /api/app/currencies` возвращает только `is_enabled = true`.
- Отключение валюты влияет только на новые выборы в формах.
- Подарки, где валюта уже использована, остаются без изменений.
### 3.6. `app_locale_settings`
Глобальные языковые настройки приложения.
### Поля
| Поле | Тип PostgreSQL | NULL | Default | Описание |
| --- | --- | --- | --- | --- |
| `uuid` | `uuid` | нет | generated UUID | Primary key настройки языка. |
| `locale` | `varchar(16)` | нет | - | Код языка, например `ru` или `en`. |
| `is_enabled` | `boolean` | нет | `true` | Доступен ли язык пользователям. |
| `date_format` | `varchar(32)` | нет | - | Глобальный формат даты для языка. |
| `time_format` | `varchar(32)` | нет | - | Глобальный формат времени для языка. |
| `number_format` | `varchar(32)` | нет | - | Глобальный формат чисел для языка. |
| `currency` | `varchar(8)` | нет | - | Валюта по умолчанию для языка. FK на `app_currencies.code`. |
| `created_at` | `timestamptz` | нет | current timestamp | Дата создания. |
| `updated_at` | `timestamptz` | нет | current timestamp | Дата обновления. |
### Constraints
- primary key: `uuid`;
- unique: `locale`;
- foreign key: `currency` -> `app_currencies.code`;
- check: `char_length(locale) between 2 and 16`;
- check: `char_length(date_format) between 1 and 32`;
- check: `char_length(time_format) between 1 and 32`;
- check: `char_length(number_format) between 1 and 32`.
### Индексы
- unique index: `locale`;
- index: `is_enabled`;
- index: `currency`;
### FK-Поведение
- Валюту нельзя удалить, если она используется как default currency языка.
Laravel migration:
- `foreign('currency')->references('code')->on('app_currencies')->restrictOnDelete()`.
### Правила
- В MVP включён русский язык.
- Русский язык нельзя удалить в MVP.
- Другие языки можно предусмотреть, но не включать в MVP UI как полноценную локализацию.
## 4. Laravel И Системные Таблицы
Эти таблицы создаются стандартными Laravel/Sanctum migrations или близкими к ним migrations проекта.
### 4.1. `personal_access_tokens`
Таблица Laravel Sanctum для mobile app bearer token flow.
Используется для Capacitor mobile app. Web SPA использует Sanctum first-party SPA session/cookie flow.
Структура может оставаться стандартной из Sanctum migration.
Ключевые поля:
| Поле | Тип PostgreSQL | NULL | Описание |
| --- | --- | --- | --- |
| `id` | `bigint` | нет | Primary key Sanctum token. |
| `tokenable_type` | `varchar(255)` | нет | Morph type модели. |
| `tokenable_id` | `uuid` | нет | UUID пользователя. Использовать Sanctum migration с `uuidMorphs`, а не numeric `morphs`. |
| `name` | `varchar(255)` | нет | Название token. |
| `token` | `varchar(64)` | нет | Hash token. |
| `abilities` | `text` | да | Abilities token. |
| `last_used_at` | `timestamptz` | да | Последнее использование. |
| `expires_at` | `timestamptz` | да | Истечение token, если используется. |
| `created_at` | `timestamptz` | да | Дата создания. |
| `updated_at` | `timestamptz` | да | Дата обновления. |
Индексы:
- index: `tokenable_type, tokenable_id`;
- unique: `token`.
### 4.2. `sessions`
Для MVP выбираем Laravel session driver `database`.
Это удобно для Docker/VPS: session state не зависит от файловой системы конкретного PHP-контейнера.
Ключевые поля:
| Поле | Тип PostgreSQL | NULL | Описание |
| --- | --- | --- | --- |
| `id` | `varchar(255)` | нет | Session id. |
| `user_id` | `uuid` | да | UUID пользователя, если сессия авторизована. |
| `ip_address` | `varchar(45)` | да | IP. |
| `user_agent` | `text` | да | User-Agent. |
| `payload` | `text` | нет | Session payload. |
| `last_activity` | `integer` | нет | Unix timestamp активности. |
Индексы:
- primary key: `id`;
- index: `user_id`;
- index: `last_activity`.
### 4.3. `jobs`
Laravel database queue.
Используется для email notifications и других фоновых задач MVP.
Структура может оставаться стандартной Laravel database queue migration.
Ключевые поля:
| Поле | Тип PostgreSQL | NULL | Описание |
| --- | --- | --- | --- |
| `id` | `bigint` | нет | Primary key job. |
| `queue` | `varchar(255)` | нет | Название очереди. |
| `payload` | `text` | нет | Serialized job payload. |
| `attempts` | `smallint` | нет | Количество попыток. |
| `reserved_at` | `integer` | да | Unix timestamp резервации job worker. |
| `available_at` | `integer` | нет | Unix timestamp доступности. |
| `created_at` | `integer` | нет | Unix timestamp создания. |
Индексы:
- index: `queue`;
- index: `queue, reserved_at`;
### 4.4. `failed_jobs`
Laravel failed jobs.
Используется для диагностики ошибок фоновых задач, включая ошибки отправки email через SMTP.
Структура может оставаться стандартной Laravel migration.
Ключевые поля:
| Поле | Тип PostgreSQL | NULL | Описание |
| --- | --- | --- | --- |
| `id` | `bigint` | нет | Primary key failed job. |
| `uuid` | `varchar(255)` | нет | UUID failed job в формате Laravel. |
| `connection` | `text` | нет | Queue connection. |
| `queue` | `text` | нет | Queue name. |
| `payload` | `text` | нет | Serialized job payload. |
| `exception` | `text` | нет | Exception text. |
| `failed_at` | `timestamptz` | нет | Дата ошибки. |
Индексы:
- unique: `uuid`;
- index: `failed_at`;
- index: `queue`;
### 4.5. `password_reset_tokens`
Таблица для восстановления пароля.
В MVP полноценный reset flow можно оставить future scope, но таблицу можно создать стандартной Laravel migration, чтобы не возвращаться к ней позже.
Ключевые поля:
| Поле | Тип PostgreSQL | NULL | Описание |
| --- | --- | --- | --- |
| `email` | `varchar(255)` | нет | Email пользователя. |
| `token` | `varchar(255)` | нет | Hash reset token. |
| `created_at` | `timestamptz` | да | Дата создания token. |
Индексы:
- primary/unique key: `email`.
### 4.6. `cache` И `cache_locks`
Для MVP выбираем Laravel cache driver `file`.
Поэтому `cache` и `cache_locks` можно не создавать в MVP migrations.
Если позже будет выбран `database` cache driver, используются стандартные Laravel cache migrations:
- `cache`;
- `cache_locks`.
Redis в MVP не используется.
## 5. Таблицы, Которых Нет В MVP
В MVP не создаём:
- `activity_log` для `spatie/laravel-activitylog`;
- отдельную таблицу подписок на wishlist;
- отдельную таблицу истории отменённых reservations;
- отдельную таблицу metadata import results;
- отдельную таблицу uploaded files/images;
- Redis-related storage.
Обоснование:
- audit хранится в файловых JSONL логах;
- зарегистрированный гость считается подписанным на wishlist, если у него есть или была бронь в этом wishlist;
- отменённые брони нужны админу через audit log, а не через пользовательскую таблицу;
- в MVP изображения хранятся как URL;
- Redis в MVP не нужен.
## 6. Связи
```mermaid
erDiagram
users ||--o{ wishlists : owns
wishlists ||--o{ wishlist_items : contains
wishlist_items ||--o| reservations : reserved_by
users ||--o{ reservations : guest
app_currencies ||--o{ app_locale_settings : default_currency
```
Связи:
- `users.uuid` -> `wishlists.user_uuid`;
- `wishlists.uuid` -> `wishlist_items.wishlist_uuid`;
- `wishlist_items.uuid` -> `reservations.wishlist_item_uuid`;
- `users.uuid` -> `reservations.guest_user_uuid`;
- `app_currencies.code` -> `app_locale_settings.currency`.
## 7. Seed Data
Минимальные seed данные MVP:
### `app_currencies`
| code | symbol | title | is_enabled |
| --- | --- | --- | --- |
| `RUB` | `₽` | `Российский рубль` | `true` |
| `USD` | `$` | `Доллар США` | `true` |
| `EUR` | `€` | `Евро` | `true` |
### `app_locale_settings`
| locale | is_enabled | date_format | time_format | number_format | currency |
| --- | --- | --- | --- | --- | --- |
| `ru` | `true` | `dd.MM.yyyy` | `HH:mm` | `ru-RU` | `RUB` |
### Первый Администратор
Первый администратор создаётся через Artisan command из `.env`.
После первого входа UI должен настойчиво попросить сменить пароль.
Для этого используется поле `users.must_change_password`.
Правила:
- Artisan command создаёт первого администратора с `must_change_password = true`;
- после успешной смены пароля backend выставляет `must_change_password = false`;
- frontend не должен давать продолжить работу, пока `must_change_password = true`.
## 8. Application-Level Ограничения
Эти ограничения лучше держать в Form Requests, Services/Actions и тестах, а не обязательно в DB constraints:
- один пользователь может иметь не более 256 wishlists;
- один wishlist может иметь не более 100 gifts;
- `title` обрезается по краям и ограничивается 128 символами;
- `guest_name` обрезается по краям и ограничивается 64 символами;
- `description` обрезается по краям и ограничивается 256 символами;
- `currency` нормализуется в uppercase и ограничивается буквами/цифрами до 8 символов;
- metadata import принимает только HTTPS URL;
- metadata import читает не более 2 MB response body;
- metadata import использует явный User-Agent;
- owner spoiler protection проверяется на backend через auth session и `owner_wishlist_tokens`.
## 9. Миграционный Порядок
Рекомендуемый порядок migrations:
1. Create `users`.
2. Create Laravel auth/system tables: `password_reset_tokens`, `sessions`.
3. Create Sanctum `personal_access_tokens`.
4. Create queue tables: `jobs`, `failed_jobs`.
5. Create `app_currencies`.
6. Create `app_locale_settings`.
7. Create `wishlists`.
8. Create `wishlist_items`.
9. Create `reservations`.
Seed order:
1. `app_currencies`.
2. `app_locale_settings`.
3. First admin via Artisan command.
## 10. Решения Для MVP
Для миграций MVP фиксируем:
- Laravel session driver: `database`;
- Laravel cache driver: `file`;
- явный DB-флаг обязательной смены пароля: `users.must_change_password`;
- `users.role`: `varchar(32)` + check constraint/application validation, без PostgreSQL enum type.