✨ feat(user): add language onboarding and settings#61
Conversation
Add language selection flow for new users and settings menu for all users: - Add LanguageCode value object to domain layer - Add language_code field to User entity and repository - Create database migration for language_code column (default: 'en') - Add UpdateLanguageInteractor for persisting language preference - Create I18n middleware that checks DB before Telegram language - Add onboarding flow: new users select language on first /start - Add Settings menu with language selection for returning users - Add settings.ftl translations for en/ru locales Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add tests covering repository call, transaction commit, and return value. Fixes missing test coverage identified in tech debt analysis. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add logging to all settings router handlers for better observability - Add edit_or_answer utility function to handle both callback and message updates - Refactor handlers to use edit_or_answer for consistent message handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…s router Move user creation/upsert logic from command handler to middleware, ensuring user is always available in handlers. Extract helper functions for referral processing and onboarding. Consolidate DTOs into dedicated module for better separation of concerns. - Move CreateUserInputDTO/OutputDTO to src/application/user/dtos.py - Middleware now upserts user and injects user DTO + i18n - Extract _process_referral_if_applicable and _start_onboarding helpers - Add __str__ method to User entity for debugging - Update tests to match new handler signature Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
itisnotyourenv
left a comment
There was a problem hiding this comment.
Comprehensive PR Review -- PR #61: Language Onboarding and Settings
Review performed by 4 specialized agents: code quality, silent failure analysis, test coverage, and type design.
Overall Assessment: Request Changes 🔴
The architecture is solid -- clean layer separation, proper use of interactors and DI, and good localization setup. However, there are critical bugs and significant gaps that need addressing before merge.
🔴 Critical Issues (Must Fix)
1. UserService.upsert_user silently erases language_code on every request
File: src/application/user/service.py:27-36
The middleware calls CreateUserInteractor (which calls upsert_user) on every incoming event. For existing users, upsert_user constructs a new User entity without language_code, defaulting it to None. The update_user repo method then overwrites the DB row, erasing the user's saved language preference.
user = User(
id=user_id,
first_name=FirstName(data.first_name),
# ... other fields
# language_code is MISSING -- defaults to None
)Impact: Every time a returning user sends a message, their language preference is reset. This completely breaks the core feature of this PR.
Fix: Preserve language_code from the existing user:
language_code=existing_user.language_code if existing_user else None,2. edit_or_answer fallback incorrectly uses CallbackQuery.answer() for messages
File: src/presentation/bot/utils/__init__.py:20-22
When edit_text raises TelegramBadRequest, the fallback calls update.answer(text, reply_markup=reply_markup). For a CallbackQuery, .answer() shows a small toast notification popup -- it does NOT send a message and silently ignores reply_markup.
Impact: Users see a brief toast popup instead of the intended message with keyboard when editing fails.
Fix:
except TelegramBadRequest:
if isinstance(update, types.CallbackQuery) and update.message:
await update.message.answer(text, reply_markup=reply_markup)
else:
await update.answer(text, reply_markup=reply_markup)3. Overly broad TelegramBadRequest catch swallows real errors
File: src/presentation/bot/utils/__init__.py:20
The catch block handles ALL TelegramBadRequest errors, not just "message is not modified". This silently swallows real bugs: malformed markup, deleted messages, empty text from broken locales, etc.
Fix: Check the exception message:
except TelegramBadRequest as e:
if "message is not modified" in str(e):
return # Safe to ignore
logger.error("TelegramBadRequest in edit_or_answer: %s", e)
raise4. update_language silently succeeds when user doesn't exist
File: src/infrastructure/db/repos/user.py:117-125
The UPDATE query doesn't check rowcount. If the user doesn't exist, 0 rows are affected, the transaction commits, and the user sees "Language changed successfully" -- but nothing was saved.
Fix: Check result.rowcount:
result = await self._session.execute(stmt)
if result.rowcount == 0:
raise UserNotFoundError(f"User {user_id} not found")🟡 Important Issues (Should Fix)
5. Middleware has zero error handling
File: src/presentation/bot/middleware/user_and_locale.py:37-58
This middleware runs on every event and performs a DB upsert. If the database is unreachable or the DI container fails, the exception propagates unhandled and the bot goes completely silent for all users -- no response, no logging at the middleware level.
6. Incorrect type annotation in middleware
File: src/presentation/bot/middleware/user_and_locale.py:33
from_user: AiogramUser = getattr(event, "from_user", None)Should be AiogramUser | None since getattr returns None as default.
7. Misleading comment on user parameter
File: src/presentation/bot/routers/commands.py:56
user: CreateUserOutputDTO, # can be None if the user is newThe comment says "can be None" but the type is non-optional, and the middleware always provides a valid DTO (with is_new=True for new users). The comment is wrong.
8. onboarding.py doesn't use edit_or_answer helper
File: src/presentation/bot/routers/onboarding.py:44
Uses callback.message.edit_text() directly while settings.py consistently uses edit_or_answer. This is inconsistent and lacks the fallback behavior.
9. No validation on callback data code field
File: src/presentation/bot/utils/cb_data.py:10-15
LanguageCBData.code and OnboardingCBData.code accept any string. A crafted callback could pass an unsupported language code (e.g., "xx"), which would be saved to the DB and silently break the user's locale.
10. Repository protocol method style inconsistency
File: src/domain/user/repository.py:65-68
update_language uses raise NotImplementedError while recent protocol methods use ... (the idiomatic style). Should also include a docstring.
🧪 Test Coverage Gaps
| Priority | Component | Criticality |
|---|---|---|
| 1 | UserAndLocaleMiddleware (runs on every event) |
9/10 |
| 2 | /start handler new-user/onboarding path (is_new=True) |
8/10 |
| 3 | onboarding_language_selected handler |
8/10 |
| 4 | Settings handlers (4 handlers, 0 tests) | 7/10 |
| 5 | edit_or_answer utility (3 code paths) |
6/10 |
| 6 | entity_to_dto with language_code |
5/10 |
| 7 | LanguageCode value object boundaries |
5/10 |
Existing tests only cover returning users (is_new=False). The core feature of this PR -- new user onboarding -- is completely untested.
🏗️ Type Design Notes
UpdateLanguageDTOis well-designed (frozen, domain types, required fields) -- 8.0/10LanguageCodehas solid enforcement but accepts any 2-5 char string, not just supported locales -- 6.5/10CreateUserOutputDTOshould be frozen (@dataclass(frozen=True)) -- 3.75/10SettingsCBDatashould be aStrEnumor useFinalannotations -- 3.25/10
📋 Commit History
Last 3 commits (24b3fa7, 7e08f78, 1dc2379) have vague messages ("update language", "upd", "upd"). Consider squashing these before merge for a clean history.
✅ What's Good
- Clean Architecture adherence with proper layer separation
UpdateLanguageInteractoris well-structured and properly testededit_or_answerutility concept is sound (implementation needs fixes)- Fluent localization files for both languages
- Proper DI registration for all new components
- Good use of
loggingin settings handlers - Onboarding keyboard correctly uses hardcoded labels (no localization needed for language names)
Summary
| Category | Count |
|---|---|
| Critical | 4 |
| Important | 6 |
| Test Gaps | 7 components |
Priority order for fixes:
- Fix
upsert_userto preservelanguage_code(breaks the core feature) - Fix
edit_or_answerfallback and catch scope - Add
rowcountcheck toupdate_language - Add tests for middleware and onboarding path
- Address the remaining important issues
🤖 Review performed with Claude Code
itisnotyourenv
left a comment
There was a problem hiding this comment.
Correction to Critical Issue #1
The upsert_user erasing language_code finding was WRONG. After re-reading the code more carefully:
UserRepositoryImpl.update_user() (src/infrastructure/db/repos/user.py:44-59) uses an explicit .values() clause that only updates username, first_name, last_name, updated_at, and last_login_at. It does not include language_code in the UPDATE statement. The database column is untouched and the saved language preference is preserved.
Similarly, create_user() does not set language_code, so new users get the server_default="en" from the migration.
This is not a bug. Apologies for the false alarm. The remaining critical issues (#2-#4) still stand.
Updated Critical Issues (3 remaining):
RETRACTED -- repo uses explicit column listupsert_usererasinglanguage_codeedit_or_answerfallback incorrectly usesCallbackQuery.answer()for messages- Overly broad
TelegramBadRequestcatch swallows real errors update_languagesilently succeeds when user doesn't exist (norowcountcheck)
- Fix type annotation for from_user to AiogramUser | None in middleware - Add callback.answer() in onboarding handler to dismiss loading state - Use edit_or_answer helper in onboarding for consistent message handling - Remove misleading comment on user parameter in commands router Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
Add language selection flow for new users (onboarding) and settings menu for all users to change their language preference.
User Flow
Changes
Domain Layer
LanguageCodevalue object (2-5 characters validation)language_codefield toUserentity with__str__for debuggingupdate_language()method toUserRepositoryprotocolInfrastructure Layer
LanguageCodeTypeSQLAlchemy TypeDecoratorlanguage_codecolumn (default: 'en')UserModelandUserMapperwith language_code mappingUserAndLocaleMiddlewarenow handles user upsert and injectsuserDTO +i18nApplication Layer
UpdateLanguageInteractorwith DI registrationsrc/application/user/dtos.pywithentity_to_dtomapperCreateUserInteractorby using shared DTO modulePresentation Layer
SettingsCBData,LanguageCBData,OnboardingCBDatacallback data classes/starthandler with extracted helpers:_process_referral_if_applicable,_start_onboardingLocalization
locales/en/settings.ftlandlocales/ru/settings.ftlChecklist
add_language_code)UpdateLanguageInteractorcommand_start_handlerupdatedTest Plan
/startas new user → see language selection/start→ verify language persistsRelated Issues
Closes code review findings in:
Known Limitations
Based on PR review, the following items are noted for future improvement:
callback.messagenull checks could be added for edge casesLanguageCodevalidation could be stricter (format validation)🤖 Generated with Claude Code