Appearance
Множественные телефоны пользователя — предложение для бэкенда
Контекст: Фронтенд будет реализован под этот контракт. Документ описывает что нужно от бэкенда, чтобы фронт мог работать с множественными телефонами в профиле и выбором телефона при создании объявления.
Цель: Заменить одно поле users.phone на отдельную таблицу user_phones (до 5 номеров на пользователя) с метками и выбором основного номера. При создании объявления пользователь выбирает телефон из своих. В будущем — SMS-верификация и подменные номера.
1. Схема данных
Новая таблица user_phones
| Поле | Тип | Ограничения | Описание |
|---|---|---|---|
id | SERIAL | PK | Автоинкремент |
user_id | INT | FK → users(id) ON DELETE CASCADE, NOT NULL | Владелец |
phone | VARCHAR(20) | NOT NULL | E.164 формат: +79219876543 |
label | VARCHAR(50) | NOT NULL, DEFAULT 'Основной' | Метка (предустановленная или своя) |
is_primary | BOOLEAN | NOT NULL, DEFAULT false | Основной номер |
sort_order | SMALLINT | NOT NULL, DEFAULT 0 | Порядок отображения |
phone_verified_at | TIMESTAMP | NULL | Для будущей SMS-верификации |
created_at | TIMESTAMP | NOT NULL, DEFAULT NOW() | Дата создания |
Индексы:
UNIQUE (user_id, phone)— один номер не дублируется у пользователяINDEX (user_id, sort_order)— для сортированной выборки
Изменения в products
| Поле | Тип | Ограничения | Описание |
|---|---|---|---|
phone_id | INT | FK → user_phones(id) ON DELETE SET NULL, NULL | Выбранный телефон для объявления |
ON DELETE SET NULL — при удалении телефона объявление не удаляется, просто теряет привязку. Фронтенд при отображении делает fallback на primary телефон продавца.
Миграция существующих данных
sql
-- 1. Создать таблицу user_phones
CREATE TABLE user_phones (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
phone VARCHAR(20) NOT NULL,
label VARCHAR(50) NOT NULL DEFAULT 'Основной',
is_primary BOOLEAN NOT NULL DEFAULT false,
sort_order SMALLINT NOT NULL DEFAULT 0,
phone_verified_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_user_phones_user_phone ON user_phones(user_id, phone);
CREATE INDEX idx_user_phones_user_sort ON user_phones(user_id, sort_order);
-- 2. Мигрировать существующие телефоны
INSERT INTO user_phones (user_id, phone, label, is_primary, sort_order, phone_verified_at, created_at)
SELECT id, phone, 'Основной', true, 0, phone_verified_at, NOW()
FROM users
WHERE phone IS NOT NULL AND phone != '';
-- 3. Добавить phone_id в products
ALTER TABLE products ADD COLUMN phone_id INT NULL REFERENCES user_phones(id) ON DELETE SET NULL;
-- 4. Удалить старые поля (после проверки миграции)
ALTER TABLE users DROP COLUMN phone;
ALTER TABLE users DROP COLUMN phone_verified_at;Откат (down)
sql
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NULL;
ALTER TABLE users ADD COLUMN phone_verified_at TIMESTAMP(0) WITHOUT TIME ZONE NULL;
UPDATE users u SET phone = (
SELECT up.phone FROM user_phones up
WHERE up.user_id = u.id AND up.is_primary = true
LIMIT 1
);
ALTER TABLE products DROP COLUMN phone_id;
DROP TABLE user_phones;2. Новая Entity: UserPhone
По аналогии с UserSession — ManyToOne от User.
php
#[ORM\Entity]
#[ORM\Table(name: 'user_phones')]
#[ORM\UniqueConstraint(name: 'uq_user_phones_user_phone', columns: ['user_id', 'phone'])]
class UserPhone
{
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column(type: Types::INTEGER)]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', nullable: false)]
private User $user;
#[ORM\Column(type: Types::STRING, length: 20)]
private string $phone;
#[ORM\Column(type: Types::STRING, length: 50)]
private string $label = 'Основной';
#[ORM\Column(name: 'is_primary', type: Types::BOOLEAN)]
private bool $isPrimary = false;
#[ORM\Column(name: 'sort_order', type: Types::SMALLINT)]
private int $sortOrder = 0;
#[ORM\Column(name: 'phone_verified_at', type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $phoneVerifiedAt = null;
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $createdAt;
public function toArray(): array
{
return [
'id' => $this->id,
'phone' => $this->phone,
'label' => $this->label,
'is_primary' => $this->isPrimary,
'phone_verified_at' => $this->phoneVerifiedAt?->format('c'),
'sort_order' => $this->sortOrder,
'created_at' => $this->createdAt->format('c'),
];
}
}3. API эндпоинты
Новая группа: /vendor/me/phones
Все под AuthMiddleware (как остальные /vendor/*).
GET /vendor/me/phones
Список телефонов текущего пользователя.
Ответ:
json
{
"data": [
{
"id": 1,
"phone": "+79219876543",
"label": "Основной",
"is_primary": true,
"phone_verified_at": null,
"sort_order": 0,
"created_at": "2026-02-23T12:00:00+03:00"
},
{
"id": 2,
"phone": "+79111234567",
"label": "WhatsApp",
"is_primary": false,
"phone_verified_at": null,
"sort_order": 1,
"created_at": "2026-02-23T12:05:00+03:00"
}
]
}Сортировка: sort_order ASC, id ASC.
POST /vendor/me/phones
Добавить телефон.
Тело запроса:
json
{
"phone": "+79219876543",
"label": "Рабочий",
"is_primary": false
}Валидация:
| Поле | Правила |
|---|---|
phone | Обязательно. Формат: /^\+7\d{10}$/. Уникален для пользователя. |
label | Опционально. Строка 1–50 символов. По умолчанию "Основной". |
is_primary | Опционально. Boolean. По умолчанию false. |
Бизнес-логика:
- Если это первый телефон пользователя →
is_primary = true(игнорировать значение из запроса) - Если
is_primary = true→ снятьis_primaryу всех остальных телефонов пользователя sort_order= максимальный среди телефонов пользователя + 1- Лимит 5 штук на пользователя
Ошибки:
| Код | Условие | Тело |
|---|---|---|
| 422 | Невалидный формат | { "phone": ["Неверный формат номера. Используйте +7XXXXXXXXXX"] } |
| 422 | Дубликат | { "phone": ["Этот номер уже добавлен"] } |
| 422 | Лимит | { "phones": ["Максимум 5 номеров телефона"] } |
Успешный ответ: 201 { "data": UserPhone }
PUT /vendor/me/phones/{id}
Обновить телефон. Только свои (проверять user_id).
Тело запроса (все поля опциональны):
json
{
"phone": "+79111234567",
"label": "Telegram",
"is_primary": true,
"sort_order": 0
}Бизнес-логика:
- Если
is_primary = true→ снять у остальных - Нельзя снять
is_primary = falseесли это единственный телефон (или вернуть ошибку, или игнорировать) - Валидация
phoneта же что при создании (уникальность среди телефонов пользователя, кроме текущего)
Ошибки:
| Код | Условие | Тело |
|---|---|---|
| 404 | Телефон не найден или чужой | { "message": "Phone not found" } |
| 422 | Невалидный формат | { "phone": ["Неверный формат номера"] } |
| 422 | Дубликат | { "phone": ["Этот номер уже добавлен"] } |
Успешный ответ: 200 { "data": UserPhone }
DELETE /vendor/me/phones/{id}
Удалить телефон. Только свои.
Бизнес-логика:
- Если удаляется
is_primary = true→ назначить primary следующему поsort_order(если есть) - Объявления с
phone_id→ автоматическиSET NULL(через FK constraint) - Можно удалить последний телефон (пользователь остаётся без номеров)
Ответ: 204 No Content
Ошибки:
| Код | Условие | Тело |
|---|---|---|
| 404 | Не найден или чужой | { "message": "Phone not found" } |
Изменения в существующих эндпоинтах
GET /auth/me и GET /vendor/me
Добавить массив phones в ответ. Убрать старое поле phone.
Было:
json
{
"data": {
"id": 1,
"email": "user@example.com",
"phone": "+79219876543",
...
}
}Стало:
json
{
"data": {
"id": 1,
"email": "user@example.com",
"phones": [
{
"id": 1,
"phone": "+79219876543",
"label": "Основной",
"is_primary": true,
"phone_verified_at": null,
"sort_order": 0,
"created_at": "2026-02-23T12:00:00+03:00"
}
],
...
}
}Фронтенд использует
phonesиз auth store для выбора телефона при создании объявления.
PUT /vendor/me (UpdateMyProfileAction)
Убрать приём поля phone. Телефоны управляются только через /vendor/me/phones.
POST /vendor/products и PUT /vendor/products/{id}
Добавить опциональное поле phone_id.
Валидация:
phone_id— опционально, INT или null- Если указан — проверить что принадлежит текущему пользователю (
user_phones.user_id = auth_user_id) - Если не указан (null) — бэкенд не подставляет primary автоматически, просто оставляет null
Ошибки:
| Код | Условие | Тело |
|---|---|---|
| 422 | phone_id чужой или не существует | { "phone_id": ["Телефон не найден"] } |
GET /vendor/products/{id} и GET /store/products/{id}
Добавить в ответ phone_id: number | null. Не резолвить номер — фронт знает номера из auth store (для своих объявлений) или пока не показывает чужие.
4. Формат телефона
- Хранение и API: E.164 —
+79219876543(строка, 12 символов для РФ) - Валидация regex:
/^\+7\d{10}$/ - Отображение на фронте:
+7 (921) 987-65-43(форматирование на клиенте) - Ввод на фронте: маска
+7 (___) ___-__-__, перед отправкой снимается до E.164
5. Бизнес-правила (сводка)
| Правило | Описание |
|---|---|
| Лимит | Максимум 5 телефонов на пользователя |
| Primary | Ровно один is_primary = true (или ноль если телефонов нет) |
| Первый телефон | Автоматически is_primary = true |
| Смена primary | При is_primary = true → снять у остальных |
| Удаление primary | Следующий по sort_order становится primary |
| Удаление с привязкой | products.phone_id → SET NULL (FK constraint) |
| Уникальность | Один номер один раз у пользователя |
| Пустой номер | Допустимо — пользователь может не иметь телефонов |
6. Файлы для создания/изменения (бэкенд)
Создать
| Файл | Описание |
|---|---|
app/Domain/Entity/UserPhone.php | Entity (см. секцию 2) |
app/Domain/Repository/UserPhoneRepositoryInterface.php | findByUser(int $userId), findByIdAndUser(int $id, int $userId), countByUser(int $userId), save(), delete() |
app/Infrastructure/Persistence/DoctrineUserPhoneRepository.php | Реализация |
app/Actions/Vendor/ListPhonesAction.php | GET /vendor/me/phones |
app/Actions/Vendor/CreatePhoneAction.php | POST /vendor/me/phones |
app/Actions/Vendor/UpdatePhoneAction.php | PUT /vendor/me/phones/ |
app/Actions/Vendor/DeletePhoneAction.php | DELETE /vendor/me/phones/ |
migrations/Version2026XXXX.php | Миграция (см. секцию 1) |
Изменить
| Файл | Что изменить |
|---|---|
config/routes.php | Добавить группу /vendor/me/phones |
config/container.php | Привязка UserPhoneRepositoryInterface → DoctrineUserPhoneRepository |
app/Domain/Entity/User.php | Убрать $phone, $phoneVerifiedAt, геттеры/сеттеры. Убрать phone из toArray() |
app/Actions/Vendor/UpdateMyProfileAction.php | Убрать приём поля phone |
app/Actions/Vendor/GetMyProfileAction.php | Добавить phones[] в ответ (запросить через repository) |
app/Actions/Auth/GetMeAction.php | Добавить phones[] в ответ |
app/Actions/Vendor/CreateProductAction.php | Принимать phone_id, валидировать принадлежность |
app/Actions/Vendor/UpdateProductAction.php | Принимать phone_id, валидировать принадлежность |
app/Domain/Entity/Product.php | Добавить $phoneId (ManyToOne → UserPhone, nullable) |
7. Что фронтенд уже будет делать
- Профиль: CRUD телефонов через
/vendor/me/phones, маска ввода, комбо-метки - Форма объявления: USelectMenu с телефонами из
authStore.user.phones, отправляетphone_id - Zod-валидация:
/^\+7\d{10}$/перед отправкой - Форматирование:
+7 (XXX) XXX-XX-XXна UI - Отображение телефона в объявлении: пока не реализуем (ждём подменные номера)
- Auth store: ожидает
phones: UserPhone[]в ответе/auth/me
8. Что можно отложить
| Фича | Статус |
|---|---|
| SMS-верификация | Позже (поле phone_verified_at заложено) |
| Показ телефона в карточке товара покупателю | Позже (ждём подменные номера) |
| Сортировка drag-n-drop | Позже (сейчас sort_order по порядку добавления) |