Skip to content

✨ feat(user): add language onboarding and settings#61

Merged
itisnotyourenv merged 9 commits intomainfrom
feat/user-language-onboarding
Feb 5, 2026
Merged

✨ feat(user): add language onboarding and settings#61
itisnotyourenv merged 9 commits intomainfrom
feat/user-language-onboarding

Conversation

@itisnotyourenv
Copy link
Copy Markdown
Owner

@itisnotyourenv itisnotyourenv commented Feb 4, 2026

Summary

Add language selection flow for new users (onboarding) and settings menu for all users to change their language preference.

User Flow

NEW USER:
/start → Language Selection → [picks en/ru] → Welcome Message (in chosen language)

RETURNING USER:
/start → Welcome Message (in saved language)

ANY USER (Settings):
Welcome → ⚙️ Settings → 🌐 Language → [picks language] → Confirmation

Changes

Domain Layer

  • Add LanguageCode value object (2-5 characters validation)
  • Add language_code field to User entity with __str__ for debugging
  • Add update_language() method to UserRepository protocol

Infrastructure Layer

  • Add LanguageCodeType SQLAlchemy TypeDecorator
  • Add database migration for language_code column (default: 'en')
  • Update UserModel and UserMapper with language_code mapping
  • UserAndLocaleMiddleware now handles user upsert and injects user DTO + i18n

Application Layer

  • Add UpdateLanguageInteractor with DI registration
  • Consolidate DTOs into src/application/user/dtos.py with entity_to_dto mapper
  • Simplify CreateUserInteractor by using shared DTO module

Presentation Layer

  • Add SettingsCBData, LanguageCBData, OnboardingCBData callback data classes
  • Add keyboard markups for settings, language selection, and onboarding
  • Add settings router (4 handlers: menu, language, change, back)
  • Add onboarding router (language selection for new users)
  • Refactor /start handler with extracted helpers: _process_referral_if_applicable, _start_onboarding

Localization

  • Add locales/en/settings.ftl and locales/ru/settings.ftl

Checklist

  • Database migration added (add_language_code)
  • Unit tests for UpdateLanguageInteractor
  • Unit tests for command_start_handler updated
  • Localization strings for EN and RU
  • Code passes ruff formatting and linting
  • All 212 tests passing

Test Plan

  • Manual test: /start as new user → see language selection
  • Manual test: Pick language → see welcome in chosen language
  • Manual test: Settings → Language → change → verify UI updates
  • Manual test: Restart bot, /start → verify language persists

Related Issues

Closes code review findings in:

Known Limitations

Based on PR review, the following items are noted for future improvement:

  • Error handling in handlers could be enhanced (silent failures on DB errors)
  • callback.message null checks could be added for edge cases
  • LanguageCode validation could be stricter (format validation)

🤖 Generated with Claude Code

itisnotyourenv and others added 2 commits February 4, 2026 08:28
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>
itisnotyourenv

This comment was marked as outdated.

itisnotyourenv and others added 3 commits February 4, 2026 14:02
- 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 itisnotyourenv changed the title feat(user): add language onboarding and settings ✨ feat(user): add language onboarding and settings Feb 5, 2026
@itisnotyourenv itisnotyourenv self-assigned this Feb 5, 2026
Copy link
Copy Markdown
Owner Author

@itisnotyourenv itisnotyourenv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
    raise

4. 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 new

The 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

  • UpdateLanguageDTO is well-designed (frozen, domain types, required fields) -- 8.0/10
  • LanguageCode has solid enforcement but accepts any 2-5 char string, not just supported locales -- 6.5/10
  • CreateUserOutputDTO should be frozen (@dataclass(frozen=True)) -- 3.75/10
  • SettingsCBData should be a StrEnum or use Final annotations -- 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
  • UpdateLanguageInteractor is well-structured and properly tested
  • edit_or_answer utility concept is sound (implementation needs fixes)
  • Fluent localization files for both languages
  • Proper DI registration for all new components
  • Good use of logging in 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:

  1. Fix upsert_user to preserve language_code (breaks the core feature)
  2. Fix edit_or_answer fallback and catch scope
  3. Add rowcount check to update_language
  4. Add tests for middleware and onboarding path
  5. Address the remaining important issues

🤖 Review performed with Claude Code

Copy link
Copy Markdown
Owner Author

@itisnotyourenv itisnotyourenv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  1. upsert_user erasing language_code RETRACTED -- repo uses explicit column list
  2. edit_or_answer fallback incorrectly uses CallbackQuery.answer() for messages
  3. Overly broad TelegramBadRequest catch swallows real errors
  4. update_language silently succeeds when user doesn't exist (no rowcount check)

- 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>
@itisnotyourenv itisnotyourenv merged commit a476709 into main Feb 5, 2026
1 of 9 checks passed
@itisnotyourenv itisnotyourenv deleted the feat/user-language-onboarding branch February 5, 2026 04:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant