Skip to content

Множественные телефоны пользователя — предложение для бэкенда

Контекст: Фронтенд будет реализован под этот контракт. Документ описывает что нужно от бэкенда, чтобы фронт мог работать с множественными телефонами в профиле и выбором телефона при создании объявления.

Цель: Заменить одно поле users.phone на отдельную таблицу user_phones (до 5 номеров на пользователя) с метками и выбором основного номера. При создании объявления пользователь выбирает телефон из своих. В будущем — SMS-верификация и подменные номера.


1. Схема данных

Новая таблица user_phones

ПолеТипОграниченияОписание
idSERIALPKАвтоинкремент
user_idINTFK → users(id) ON DELETE CASCADE, NOT NULLВладелец
phoneVARCHAR(20)NOT NULLE.164 формат: +79219876543
labelVARCHAR(50)NOT NULL, DEFAULT 'Основной'Метка (предустановленная или своя)
is_primaryBOOLEANNOT NULL, DEFAULT falseОсновной номер
sort_orderSMALLINTNOT NULL, DEFAULT 0Порядок отображения
phone_verified_atTIMESTAMPNULLДля будущей SMS-верификации
created_atTIMESTAMPNOT NULL, DEFAULT NOW()Дата создания

Индексы:

  • UNIQUE (user_id, phone) — один номер не дублируется у пользователя
  • INDEX (user_id, sort_order) — для сортированной выборки

Изменения в products

ПолеТипОграниченияОписание
phone_idINTFK → 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

Ошибки:

КодУсловиеТело
422phone_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.phpEntity (см. секцию 2)
app/Domain/Repository/UserPhoneRepositoryInterface.phpfindByUser(int $userId), findByIdAndUser(int $id, int $userId), countByUser(int $userId), save(), delete()
app/Infrastructure/Persistence/DoctrineUserPhoneRepository.phpРеализация
app/Actions/Vendor/ListPhonesAction.phpGET /vendor/me/phones
app/Actions/Vendor/CreatePhoneAction.phpPOST /vendor/me/phones
app/Actions/Vendor/UpdatePhoneAction.phpPUT /vendor/me/phones/
app/Actions/Vendor/DeletePhoneAction.phpDELETE /vendor/me/phones/
migrations/Version2026XXXX.phpМиграция (см. секцию 1)

Изменить

ФайлЧто изменить
config/routes.phpДобавить группу /vendor/me/phones
config/container.phpПривязка UserPhoneRepositoryInterfaceDoctrineUserPhoneRepository
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 по порядку добавления)