Files
ds-galo/openspec/changes/archive/2026-10-12-add-user-profile/design.md
T

117 lines
9.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` и спеках.