From 6fa4d03ca06204aa0c877d81e0863dfecca71936 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 16 Nov 2025 20:31:45 +0100 Subject: [PATCH 1/3] feat: add user language preference endpoint - Add preferred_locale column to users table (VARCHAR(5), nullable) - Add PATCH /v1/me/language endpoint with validation - Support for en (English) and de (German) locales - Allow null to reset preference to default - Update User model, factory, and AuthController - Add UpdateUserLanguageRequest with validation rules - Add 8 comprehensive feature tests (TDD approach) - Update CHANGELOG.md Fixes #86 --- CHANGELOG.md | 9 ++ app/Http/Controllers/AuthController.php | 23 ++++ .../Requests/UpdateUserLanguageRequest.php | 38 ++++++ app/Models/User.php | 2 + database/factories/UserFactory.php | 1 + ...06_add_preferred_locale_to_users_table.php | 33 +++++ routes/api.php | 1 + .../Api/V1/UserLanguagePreferenceTest.php | 119 ++++++++++++++++++ 8 files changed, 226 insertions(+) create mode 100644 app/Http/Requests/UpdateUserLanguageRequest.php create mode 100644 database/migrations/2025_11_16_192506_add_preferred_locale_to_users_table.php create mode 100644 tests/Feature/Controllers/Api/V1/UserLanguagePreferenceTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c05716..d4d71bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **User Language Preference** (#86) + - New `preferred_locale` column in `users` table (VARCHAR(5), nullable) + - PATCH `/v1/me/language` endpoint to update user's preferred language + - Supports `en` (English) and `de` (German) + - Can be set to `null` to use default/Accept-Language header + - Form request validation via `UpdateUserLanguageRequest` + - 8 comprehensive feature tests + - Database migration: `2025_11_16_192506_add_preferred_locale_to_users_table` + - **Secret Sharing & Access Control (Phase 3)** (#182) - **Secret CRUD API**: Full REST API for password manager functionality - Create secrets with encrypted title, username, password, URL, notes (POST `/v1/secrets`) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 3af3d0c..d1ad248 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -8,6 +8,7 @@ use App\Http\Requests\PasswordResetRequest; use App\Http\Requests\PasswordResetRequestRequest; use App\Http\Requests\TokenRequest; +use App\Http\Requests\UpdateUserLanguageRequest; use App\Mail\PasswordResetMail; use App\Models\User; use Illuminate\Http\JsonResponse; @@ -103,6 +104,28 @@ public function me(Request $request): JsonResponse ]); } + /** + * Update the authenticated user's language preference. + */ + public function updateLanguage(UpdateUserLanguageRequest $request): JsonResponse + { + /** @var User $user */ + $user = $request->user(); + + /** @var array{locale: string|null} $validated */ + $validated = $request->validated(); + + $user->update([ + 'preferred_locale' => $validated['locale'], + ]); + + return response()->json([ + 'data' => [ + 'preferred_locale' => $user->preferred_locale, + ], + ]); + } + /** * Request a password reset email. * diff --git a/app/Http/Requests/UpdateUserLanguageRequest.php b/app/Http/Requests/UpdateUserLanguageRequest.php new file mode 100644 index 0000000..d69dee3 --- /dev/null +++ b/app/Http/Requests/UpdateUserLanguageRequest.php @@ -0,0 +1,38 @@ +|string> + */ + public function rules(): array + { + return [ + 'locale' => [ + 'present', + 'nullable', + Rule::in(['en', 'de']), + ], + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e4759fb..a195382 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -27,6 +27,7 @@ * @property string $password * @property ?\Illuminate\Support\Carbon $email_verified_at * @property string|null $remember_token + * @property string|null $preferred_locale * @property \Illuminate\Support\Carbon $created_at * @property \Illuminate\Support\Carbon $updated_at */ @@ -52,6 +53,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'preferred_locale', ]; /** diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index d40b7ac..929d76b 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -32,6 +32,7 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + 'preferred_locale' => null, ]; } diff --git a/database/migrations/2025_11_16_192506_add_preferred_locale_to_users_table.php b/database/migrations/2025_11_16_192506_add_preferred_locale_to_users_table.php new file mode 100644 index 0000000..8f4774b --- /dev/null +++ b/database/migrations/2025_11_16_192506_add_preferred_locale_to_users_table.php @@ -0,0 +1,33 @@ +string('preferred_locale', 5)->nullable()->after('remember_token'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('preferred_locale'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index f76c4ca..58453b2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -48,6 +48,7 @@ Route::post('/auth/logout', [AuthController::class, 'logout']); Route::post('/auth/logout-all', [AuthController::class, 'logoutAll']); Route::get('/me', [AuthController::class, 'me']); + Route::patch('/me/language', [AuthController::class, 'updateLanguage']); // Role Management CRUD API // Authorization: Route-level permission middleware + Policy (defense-in-depth) diff --git a/tests/Feature/Controllers/Api/V1/UserLanguagePreferenceTest.php b/tests/Feature/Controllers/Api/V1/UserLanguagePreferenceTest.php new file mode 100644 index 0000000..efb1075 --- /dev/null +++ b/tests/Feature/Controllers/Api/V1/UserLanguagePreferenceTest.php @@ -0,0 +1,119 @@ +create(['preferred_locale' => null]); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', [ + 'locale' => 'de', + ]); + + $response->assertOk() + ->assertJson([ + 'data' => [ + 'preferred_locale' => 'de', + ], + ]); + + expect($user->fresh()->preferred_locale)->toBe('de'); + }); + + test('user can update their language preference to English', function () { + /** @var User $user */ + $user = User::factory()->create(['preferred_locale' => 'de']); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', [ + 'locale' => 'en', + ]); + + $response->assertOk(); + expect($user->fresh()->preferred_locale)->toBe('en'); + }); + + test('user can reset language preference to null', function () { + /** @var User $user */ + $user = User::factory()->create(['preferred_locale' => 'de']); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', [ + 'locale' => null, + ]); + + $response->assertOk(); + expect($user->fresh()->preferred_locale)->toBeNull(); + }); + + test('validation rejects invalid locale codes', function () { + /** @var User $user */ + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', [ + 'locale' => 'fr', // French not supported + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['locale']); + }); + + test('empty string is treated as null and accepted', function () { + /** @var User $user */ + $user = User::factory()->create(['preferred_locale' => 'de']); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', [ + 'locale' => '', + ]); + + $response->assertOk(); + expect($user->fresh()->preferred_locale)->toBeNull(); + }); + + test('validation requires locale field', function () { + /** @var User $user */ + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->patchJson('/v1/me/language', []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['locale']); + }); + + test('unauthenticated users cannot update language preference', function () { + $response = $this->patchJson('/v1/me/language', [ + 'locale' => 'de', + ]); + + $response->assertStatus(401); + }); + + test('endpoint only affects authenticated user', function () { + /** @var User $user1 */ + $user1 = User::factory()->create(['preferred_locale' => null]); + /** @var User $user2 */ + $user2 = User::factory()->create(['preferred_locale' => null]); + + $response = $this->actingAs($user1) + ->patchJson('/v1/me/language', [ + 'locale' => 'de', + ]); + + $response->assertOk(); + expect($user1->fresh()->preferred_locale)->toBe('de'); + expect($user2->fresh()->preferred_locale)->toBeNull(); + }); +}); From 059857e4fa3667d2fd17a48af328f6bee15df496 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 16 Nov 2025 20:38:41 +0100 Subject: [PATCH 2/3] fix: add validation messages and PHPDoc annotations - Add messages() method to UpdateUserLanguageRequest with custom error message - Add @return JsonResponse annotation to AuthController::updateLanguage() - Follow patterns from sibling Form Requests (TokenRequest, GrantShareRequest) Addresses Copilot review comments in PR #186 --- app/Http/Controllers/AuthController.php | 2 ++ app/Http/Requests/UpdateUserLanguageRequest.php | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index d1ad248..2514176 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -106,6 +106,8 @@ public function me(Request $request): JsonResponse /** * Update the authenticated user's language preference. + * + * @return JsonResponse */ public function updateLanguage(UpdateUserLanguageRequest $request): JsonResponse { diff --git a/app/Http/Requests/UpdateUserLanguageRequest.php b/app/Http/Requests/UpdateUserLanguageRequest.php index d69dee3..b898169 100644 --- a/app/Http/Requests/UpdateUserLanguageRequest.php +++ b/app/Http/Requests/UpdateUserLanguageRequest.php @@ -35,4 +35,16 @@ public function rules(): array ], ]; } + + /** + * Get the custom error messages for validation rules. + * + * @return array + */ + public function messages(): array + { + return [ + 'locale.in' => 'Language must be either English (en) or German (de).', + ]; + } } From f2788495fc584b80f4baef8eb4f7a0ae0ebb1573 Mon Sep 17 00:00:00 2001 From: Holger Schmermbeck Date: Sun, 16 Nov 2025 20:43:28 +0100 Subject: [PATCH 3/3] fix: remove superfluous @return PHPDoc tag - Remove @return JsonResponse annotation (already in method signature) - Comply with no_superfluous_phpdoc_tags Pint rule - Match pattern used in sibling methods (logout, logoutAll, me) The return type is already declared in the method signature, making the PHPDoc @return tag redundant per PSR-12 standards. --- app/Http/Controllers/AuthController.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 2514176..d1ad248 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -106,8 +106,6 @@ public function me(Request $request): JsonResponse /** * Update the authenticated user's language preference. - * - * @return JsonResponse */ public function updateLanguage(UpdateUserLanguageRequest $request): JsonResponse {