# 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.
