Files
ds-galo/openspec/changes/add-user-profile/design.md
T

9.8 KiB
Raw Blame History

Design

Context

См. proposal.md — Why. Существенное для реализации:

  • users.email сейчас NOT NULL UNIQUE в обеих схемах; уникальность — users_email_key.
  • Два хранилища с общим интерфейсом auth.Repository: store.go (pgx) и sqlite_store.go (database/sql). Две отдельные истории миграций: backend/migrations/*.sql (Postgres) и backend/migrations/sqlite/*.sql (консолидированная + инкременты).
  • models.PublicUser используется во всех публичных представлениях (участники, загрузчики, комментарии). User.Public() конвертирует в него; /auth/me сейчас тоже отдаёт Public().
  • FK уже каскадные (albums.owner_id, photos, comments, likes, tags, notifications, share_links, invites → users ON DELETE CASCADE).
  • Блобы не в БД: удаление строк оставляет объекты, которые добирает cmd/reconcile.
  • scripts/seed.py регистрирует по email.

Goals / Non-Goals

Goals:

  • Единый идентификатор входа (email или E.164-телефон) без отправки SMS.
  • Управление профилем (имя, пароль) и удаление аккаунта со всем содержимым — на backend, web и Android.
  • Миграция без потери существующих email-only аккаунтов.

Non-Goals:

  • Верификация телефона, смена/удаление заданных логинов, avatar-загрузка, «корзина» аккаунта.
  • Мгновенная очистка блобов в транзакции удаления (остаётся на reconcile).

Decisions

1. Схема: оба идентификатора nullable, «хотя бы один» на уровне приложения

email и phone оба NULL-допустимы и уникальны; обязательность «хотя бы одного» проверяет сервис регистрации/добавления, а не CHECK в БД. Так существующие строки и phone-only новые аккаунты сосуществуют одинаково в обеих СУБД, а сообщения об ошибке остаются контролируемыми. Альтернатива с CHECK (email IS NOT NULL OR phone IS NOT NULL) отвергнута: в SQLite её добавление всё равно требует rebuild, а выигрыш невелик.

2. E.164-нормализация в сервисном слое

Отдельная функция нормализации: обязательный ведущий +, только цифры, длина 8–15. Одна точка нормализации для регистрации, добавления и входа — чтобы вход был точным сравнением по каноническому значению. Определение типа идентификатора: наличие @ → email, иначе → телефон.

3. Добавление идентификатора — отдельный add-only эндпоинт

POST /auth/me/identifiers с необязательными email и phone. Сервис разрешает ровно один новый идентификатор, отклоняет попытку задать уже существующий/второй, проверяет уникальность и конфликты (email_taken, phone_taken). Альтернатива «PATCH /auth/me с опциональными полями» отвергнута: неявное смешение изменяемого (имя) и неизменяемого (логины) в одном ресурсе.

4. Смена пароля: текущий пароль + отзыв всех refresh-токенов

POST /auth/me/password { current_password, new_password }. При успехе RevokeAllUserTokens. Клиенты очищают токены и уходят на экран входа. Access-токен (≤15 мин) формально жив, но refresh мёртв; для консистентности клиенты всё равно разлогиниваются.

5. Удаление аккаунта: подтверждение паролем + удаление строки

DELETE /auth/me { password }. Проверяем пароль, затем DELETE FROM users WHERE id. Каскад FK удаляет альбомы (вместе с доступом других участников), фото, соцданные, уведомления, share-ссылки, приглашения и токены. После удаления любой запрос с прежним access-токеном завершается 401, потому что строки пользователя больше нет. Возврат 204. Блобы освобождаются reconcile. Альтернативы (запрет при общих альбомах, передача владения, grace-период) отклонены по решению: удалять всё и сразу.

6. Приватность телефона

phone добавляется в models.User, но НЕ в PublicUser, поэтому не попадает в списки участников/загрузчиков/комментариев. Собственный профиль отдаётся отдельным ответом с телефоном, а не через Public().

7. Миграции

  • Postgres 000013_user_phone: ALTER TABLE users ALTER COLUMN email DROP NOT NULL, ADD COLUMN phone text, ADD CONSTRAINT users_phone_key UNIQUE (phone).
  • SQLite 000005_user_phone: rebuild users по 12-шаговому паттерну (создать новую таблицу без NOT NULL у email, с phone, перенести данные, пересоздать индексы), т.к. SQLite не умеет снимать ограничения через ALTER.
  • Существующие строки получают phone = NULL; email сохраняется.

8. Клиенты

  • Web: маршрут /profile под (app), вход из шапки по имени; login-страница — одно поле «Email или телефон»; при регистрации поля email/phone с проверкой «хотя бы одно».
  • Android: ProfileScreen в GaloNavHost, вход из AlbumsScreen; AuthScreen — одно поле идентификатора и необязательные email/phone при регистрации; по успеху пароль/удаление → очистка TokenStore → на экран auth.
  • Оба клиента обновляются вместе с сервером: контракт login/register — BREAKING.

Risks / Trade-offs

  • [Телефон-логин без верификации → занятие чужого номера] → принято осознанно для self-hosted; задел под OTP вне объёма.
  • [SQLite rebuild users] → выполнять в одной миграции с временным отключением FK и пересозданием индексов; проверить на копии galo.db.
  • [Запросы сканируют email в string; теперь возможен NULL] → перейти на COALESCE(email,'') в обоих хранилищах и покрыть тестами.
  • [Гонка уникальности] → опираться на unique-индексы, маппить нарушение в email_taken/ phone_taken.
  • [Удаление каскадом уничтожает общие альбомы] → необратимо и покрыто подтверждением паролем; поведение задокументировано в спеке.
  • [Access-токен живёт ≤15 мин после смены пароля/удаления] → refresh отозван, при удалении пользователь отсутствует; клиенты разлогиниваются немедленно.
  • [Значение email в JWT-claim может быть пустым] → claim не обязателен для авторизации (используется uid); оставляем как есть.

Migration Plan

  1. Применить миграции: Postgres 000013, SQLite 000005 (на копии проверить rebuild).
  2. Задеплоить backend с новым контрактом.
  3. Обновить web и Android синхронно (единый релиз) из-за breaking login/register.
  4. Проверки: регистрация email-only, phone-only, обоими; вход обоими; добавление второго идентификатора; смена пароля (все сессии отозваны); удаление аккаунта с общими альбомами.
  5. Откат: down-миграции возвращают email NOT NULL и удаляют phone; выполним только если нет phone-only аккаунтов. Удаление аккаунтов необратимо.

Open Questions

Нет — все развилки (верификация, обязательность, иммутабельность, политика пароля, поведение общих альбомов, подтверждение удаления) зафиксированы в proposal.md и спеках.