chore(openspec): archive add-user-profile and sync main specs

This commit is contained in:
iu
2026-10-12 14:27:19 +03:00
parent 86aff695df
commit 657a04309e
8 changed files with 139 additions and 7 deletions
@@ -0,0 +1,116 @@
# 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` и спеках.