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,2 @@
schema: spec-driven
created: 2026-10-12
@@ -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` и спеках.
@@ -0,0 +1,57 @@
# Proposal
## Why
Управление аккаунтом сейчас ограничено регистрацией и входом: пользователь не может
изменить имя или пароль, а `PATCH /auth/me/preferences` умеет только настройки отображения.
Вход возможен только по email, хотя мобильный сценарий естественно подразумевает номер
телефона. Нужен явный профиль и вход по любому из двух идентификаторов.
## What Changes
- Профиль пользователя: изменение имени; смена пароля с подтверждением текущего пароля и
отзывом **всех** refresh-токенов (все сессии, включая текущую).
- `phone` добавляется в аккаунт как второй идентификатор входа, хранится в строгом формате
E.164, уникален, без подтверждения (SMS/OTP вне объёма).
- `email` перестаёт быть обязательным: обязателен **хотя бы один** из `email`/`phone`.
Оба — логины: заданный идентификатор нельзя изменить или удалить, но недостающий можно
добавить один раз (add-only).
- Вход по единому полю: `identifier` — email или E.164-телефон; определение по наличию `@`.
- **BREAKING** (API): `POST /auth/login` принимает `identifier` вместо `email`;
`POST /auth/register` принимает `phone` и допускает отсутствие `email`.
- Новые инструменты профиля на всех поверхностях: страница `/profile` в вебе и отдельный
экран в Android; поля входа/регистрации обновляются на обеих поверхностях.
- Удаление аккаунта со всем содержимым: по подтверждению текущего пароля, немедленно и
необратимо. Каскадно удаляются альбомы пользователя (включая доступ других участников),
фото, комментарии, лайки, метки, уведомления, share-ссылки и приглашения; блобы добираются
фоновым `reconcile`.
- Вне объёма: подтверждение телефона (SMS/OTP), смена/удаление заданных логинов, изменение
avatar, «корзина»/отложенное удаление аккаунта, экспорт данных перед удалением.
## Capabilities
### New Capabilities
- `user-profile`: управление аккаунтом пользователя на всех поверхностях — чтение профиля,
изменение имени, смена пароля с отзывом сессий, добавление недостающего идентификатора и
удаление аккаунта со всем содержимым; телефон виден только владельцу (email, как и прежде,
виден участникам альбома).
### Modified Capabilities
- `auth`: вход по email **или** телефону через единый `identifier`; регистрация с
требованием «хотя бы одного» из email/phone; хранение телефона в E.164.
## Impact
- Backend: `backend/internal/auth/{service.go,handlers.go,store.go,sqlite_store.go,repository.go}`,
`backend/internal/models/models.go`, `backend/internal/app/app.go` (маршруты).
- Миграции: PostgreSQL `000013_user_phone.*`, SQLite `000005_user_phone.*` (в SQLite — rebuild
`users` для снятия `NOT NULL` с email).
- Frontend: `routes/(public)/login/+page.svelte`, новая `routes/(app)/profile/+page.svelte`,
`lib/components/AppShell.svelte`, `lib/api.ts`, `lib/types.ts`, `lib/stores/auth.svelte.ts`.
- Android: `data/network/dto/Dtos.kt`, `data/network/GaloApi.kt`, `data/network/ErrorMapper.kt`,
`ui/auth/*`, новый `ui/profile/*`, `ui/GaloNavHost.kt`.
- Данные/скрипты: `scripts/seed.py` (передача phone), существующие аккаунты остаются
email-only и получают `phone = NULL`.
- Удаление: каскад FK в PostgreSQL/SQLite; очистка блобов существующим `reconcile`
(eventual, не в транзакции удаления).
- Приватность: `phone` не попадает в `PublicUser` (участники, загрузчики, комментарии).
@@ -0,0 +1,41 @@
# Spec Delta
## RENAMED Requirements
- FROM: `### Requirement: Вход по email и паролю`
- TO: `### Requirement: Вход по email или телефону`
## MODIFIED Requirements
### Requirement: Регистрация пользователя
Пользователь SHALL регистрироваться, указав имя, пароль и хотя бы один из идентификаторов —
email или телефон. Пароль SHALL храниться в виде bcrypt-хэша. Телефон SHALL нормализоваться
к формату E.164 и быть уникальным; email SHALL приводиться к нижнему регистру и быть
уникальным.
#### Scenario: Успешная регистрация
- **WHEN** пользователь отправляет свободный email, имя и пароль
- **THEN** система создаёт учётную запись и возвращает access- и refresh-токены
#### Scenario: Регистрация по телефону
- **WHEN** пользователь отправляет свободный телефон в формате E.164, имя и пароль
- **THEN** система создаёт учётную запись и возвращает access- и refresh-токены
#### Scenario: Нет ни email, ни телефона
- **WHEN** пользователь не указывает ни email, ни телефон
- **THEN** система отклоняет регистрацию
#### Scenario: Повторный email
- **WHEN** пользователь регистрируется с уже занятым email или телефоном
- **THEN** система возвращает ошибку конфликта и не создаёт вторую запись
### Requirement: Вход по email или телефону
Пользователь SHALL входить по единому идентификатору — email или телефону в формате
E.164 — и паролю.
#### Scenario: Успешный вход
- **WHEN** пользователь вводит существующий email или телефон и верный пароль
- **THEN** система возвращает access- и refresh-токены
#### Scenario: Неверные учётные данные
- **WHEN** пользователь вводит несуществующий идентификатор или неверный пароль
- **THEN** система возвращает 401, не раскрывая, какое именно поле неверно
@@ -0,0 +1,120 @@
# Spec Delta
## Purpose
Управление аккаунтом пользователя: просмотр и изменение профиля, смена пароля с отзывом
сессий, добавление второго идентификатора входа и удаление аккаунта со всем содержимым —
единообразно в веб-интерфейсе и Android-клиенте.
## ADDED Requirements
### Requirement: Просмотр собственного профиля
Пользователь SHALL получать свой профиль с именем, email, телефоном и аватаром.
Телефон SHALL быть виден только владельцу и SHALL NOT попадать в публичные представления
пользователя (участники альбома, загрузчики, комментарии).
#### Scenario: Получение профиля
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
- **THEN** ответ содержит его имя, email (если задан), телефон (если задан) и аватар
#### Scenario: Телефон не утекает
- **WHEN** пользователь просматривает участников альбома
- **THEN** представления других пользователей не содержат телефон
### Requirement: Изменение имени
Пользователь SHALL изменять своё имя. Пустое имя SHALL отклоняться. Смена имени SHALL
применяться немедленно и отражаться при следующем чтении профиля.
#### Scenario: Успешное изменение
- **WHEN** пользователь отправляет непустое имя
- **THEN** система сохраняет его и возвращает обновлённый профиль
#### Scenario: Пустое имя
- **WHEN** пользователь отправляет пустое имя
- **THEN** система отклоняет запрос и не меняет профиль
### Requirement: Смена пароля с отзывом сессий
Пользователь SHALL менять пароль, подтвердив текущий пароль. Система SHALL отклонять
неверный текущий пароль и новый пароль короче 8 символов. Успешная смена SHALL отзывать
все refresh-токены пользователя на всех устройствах.
#### Scenario: Успешная смена
- **WHEN** пользователь вводит верный текущий пароль и новый пароль допустимой длины
- **THEN** система сохраняет новый хэш и отзывает все refresh-токены пользователя
#### Scenario: Неверный текущий пароль
- **WHEN** пользователь вводит неверный текущий пароль
- **THEN** система возвращает ошибку и не меняет пароль
#### Scenario: Слабый новый пароль
- **WHEN** новый пароль короче 8 символов
- **THEN** система отклоняет запрос и сохраняет старый пароль
### Requirement: Добавление недостающего идентификатора
Пользователь с одним заданным идентификатором (email или телефон) SHALL добавить
недостающий один раз после проверки уникальности. Система SHALL отклонять добавление, когда
оба идентификатора уже заданы, а также изменение или удаление заданного. После добавления
оба идентификатора SHALL работать для входа.
#### Scenario: Добавление телефона
- **WHEN** пользователь без телефона добавляет свободный номер в формате E.164
- **THEN** система сохраняет номер, и вход по нему становится возможен
#### Scenario: Занятый идентификатор
- **WHEN** добавляемый email или телефон уже принадлежит другому пользователю
- **THEN** система возвращает ошибку конфликта и не меняет профиль
#### Scenario: Оба уже заданы
- **WHEN** у пользователя уже заданы и email, и телефон, и он пытается добавить третий
- **THEN** система отклоняет запрос
### Requirement: Удаление аккаунта со всем содержимым
Пользователь SHALL удалять свой аккаунт, подтвердив текущий пароль. Неверный пароль SHALL
отклоняться. Успешное удаление SHALL быть немедленным и необратимым: SHALL каскадно
удаляться альбомы пользователя (вместе с доступом других участников), его фото,
комментарии, лайки, метки, уведомления, share-ссылки и приглашения. Блобы SHALL
освобождаться фоновой сверкой хранилища, а не в транзакции удаления.
#### Scenario: Успешное удаление
- **WHEN** пользователь подтверждает удаление верным текущим паролем
- **THEN** учётная запись и связанное содержимое удаляются, и вход больше невозможен
#### Scenario: Общие альбомы
- **WHEN** удаляемый пользователь владеет альбомом, в котором есть другие участники
- **THEN** альбом удаляется вместе с ними
#### Scenario: Неверный пароль
- **WHEN** пользователь подтверждает удаление неверным паролем
- **THEN** система возвращает ошибку и не удаляет аккаунт
### Requirement: Страница профиля в веб-интерфейсе
Веб-приложение SHALL предоставлять отдельную страницу профиля, доступную из шапки, с
показом идентификаторов и формами изменения имени, смены пароля и удаления аккаунта.
#### Scenario: Открытие страницы
- **WHEN** аутентифицированный пользователь открывает профиль из шапки
- **THEN** отображаются его идентификаторы и формы редактирования
#### Scenario: Выход после смены пароля
- **WHEN** смена пароля завершилась успешно
- **THEN** клиент очищает токены и перенаправляет на страницу входа
#### Scenario: Выход после удаления
- **WHEN** удаление аккаунта завершилось успешно
- **THEN** клиент очищает токены и перенаправляет на страницу входа
### Requirement: Экран профиля в Android-клиенте
Android-приложение SHALL предоставлять отдельный экран профиля, доступный из списка
альбомов, с теми же возможностями, что и веб-страница.
#### Scenario: Открытие экрана
- **WHEN** пользователь открывает профиль из списка альбомов
- **THEN** отображаются идентификаторы и формы редактирования
#### Scenario: Возврат после смены пароля
- **WHEN** смена пароля завершилась успешно
- **THEN** приложение очищает токены и возвращает на экран входа
#### Scenario: Возврат после удаления
- **WHEN** удаление аккаунта завершилось успешно
- **THEN** приложение очищает токены и возвращает на экран входа
@@ -0,0 +1,51 @@
# Tasks
## 1. Модель данных и миграции
- [x] 1.1 Добавить `Phone` в `models.User` (но НЕ в `PublicUser`) — unit-тест: JSON `PublicUser` не содержит телефон
- [x] 1.2 Миграция PostgreSQL `000013_user_phone` (снять `NOT NULL` с email, добавить `phone` + unique, down-миграция) — проверить `make migrate` и откат на чистой БД
- [x] 1.3 Миграция SQLite `000005_user_phone` через rebuild `users` — проверить на копии `galo.db`, что данные сохранены, email nullable, `phone` присутствует
- [x] 1.4 Расширить миграционный тест `internal/e2e` — проверка схемы `users` (nullable email, колонка `phone`, уникальность)
## 2. Backend: вход и регистрация по идентификатору
- [x] 2.1 Нормализация E.164 и определение типа идентификатора (`@` → email) — unit-тест на валидные/невалидные номера
- [x] 2.2 Хранилища: `COALESCE(email,'')` в выборках, `GetUserByPhone` в `store.go` и `sqlite_store.go` — тесты обоих хранилищ
- [x] 2.3 `Service.Register` принимает email и/или телефон, требует «хотя бы один», ловит конфликты `email_taken`/`phone_taken` — тесты
- [x] 2.4 `Service.Login` по единому идентификатору — тесты: вход по email, по телефону, несуществующий, неверный пароль (ответ неразличим)
- [x] 2.5 Хендлеры `login` (`identifier`) и `register` (`phone` необязателен) — тесты хендлеров
## 3. Backend: профиль (имя, пароль, добавление идентификатора)
- [x] 3.1 `PATCH /auth/me` для изменения имени (пустое отклоняется) — тесты сервиса и хендлера
- [x] 3.2 `POST /auth/me/password`: проверка текущего пароля, минимум 8 символов, `RevokeAllUserTokens` при успехе — тесты
- [x] 3.3 `POST /auth/me/identifiers` (add-only): добавление недостающего email/phone, конфликты, отказ когда оба заданы — тесты
- [x] 3.4 `/auth/me` отдаёт телефон только владельцу — тест: ответ `Me` содержит phone, публичные представления — нет
## 4. Backend: удаление аккаунта
- [x] 4.1 `DELETE /auth/me` с подтверждением пароля и `DeleteUser` в обоих хранилищах — тесты: неверный пароль, успешное удаление
- [x] 4.2 Интеграционный тест: удаление владельца с общим альбомом убирает альбом у другого участника
## 5. Web-интерфейс
- [x] 5.1 Login/register: единое поле «Email или телефон», необязательные email/phone с проверкой «хотя бы одно»; обновить `api.ts`, `types.ts` — проверить `npm run check` и ручной вход
- [x] 5.2 Новая страница `(app)/profile` (имя, пароль, добавление идентификатора, удаление) и ссылка из шапки `AppShell` — проверить `npm run check` и ручной сценарий
- [x] 5.3 Очистка токенов и редирект на вход после смены пароля и удаления — ручная проверка в браузере
## 6. Android-клиент
- [x] 6.1 DTO/API: login по `identifier`, `phone` при регистрации, эндпоинты профиля; обновить `ErrorMapper` — unit-тест маппинга ошибок
- [x] 6.2 `AuthScreen`: единое поле идентификатора, необязательные email/phone — Robolectric-тест
- [x] 6.3 `ProfileScreen` + `ProfileViewModel` + запись в `GaloNavHost`, вход из списка альбомов — Robolectric-тест
- [x] 6.4 Очистка `TokenStore` и возврат на экран auth после смены пароля/удаления — Robolectric-тест
## 7. Интеграция и документация
- [x] 7.1 E2E против запущенного сервера: регистрация только по телефону, вход, добавление email, смена пароля (сессии отозваны), удаление аккаунта
- [x] 7.2 `scripts/seed.py`: поддержать необязательный `phone`; обновить README (новые эндпоинты профиля) — проверить запуск `make testdata` и чтение README
- [ ] 7.3 Полный прогон: `make test`, `cd frontend && npm run check`, `cd android && ./gradlew test`
## Workflow follow-up
- Архивировать изменение после прохождения ревью и синхронизировать основные спеки.