diff --git a/app/Console/Commands/Database/CleanupLecturers.php b/app/Console/Commands/Database/CleanupLecturers.php new file mode 100644 index 0000000..b3a4a9d --- /dev/null +++ b/app/Console/Commands/Database/CleanupLecturers.php @@ -0,0 +1,95 @@ +where('name', self::MERGE_TARGET_NAME)->first(); + + if (! $target) { + $this->error(sprintf('Merge target lecturer "%s" not found. Aborting.', self::MERGE_TARGET_NAME)); + + return Command::FAILURE; + } + + $candidates = Lecturer::query() + ->whereKeyNot($target->getKey()) + ->whereDoesntHave('courses') + ->whereDoesntHave('coursesEvents') + ->get(); + + if ($candidates->isEmpty()) { + $this->info('No empty lecturers to clean up.'); + + return Command::SUCCESS; + } + + $force = (bool) $this->option('force'); + + $this->info(sprintf( + '%s %d empty lecturer(s). Library items & media will be merged into "%s" (#%d).', + $force ? 'Deleting' : '[DRY-RUN] Would delete', + $candidates->count(), + $target->name, + $target->getKey(), + )); + + if ($force && ! $this->confirm('This permanently deletes lecturers on this database. Continue?', false)) { + $this->warn('Aborted.'); + + return Command::FAILURE; + } + + $stats = ['deleted' => 0, 'libraryItems' => 0, 'media' => 0]; + + $this->withProgressBar($candidates, function (Lecturer $lecturer) use ($target, $force, &$stats): void { + // Use ->count() (query) instead of loading the relation, so the + // media delete-hook on $lecturer->delete() sees an empty set after reassignment. + $libraryItems = $lecturer->libraryItems()->count(); + $media = $lecturer->media()->count(); + + $stats['libraryItems'] += $libraryItems; + $stats['media'] += $media; + $stats['deleted']++; + + if (! $force) { + return; + } + + DB::transaction(function () use ($lecturer, $target): void { + $lecturer->libraryItems()->update(['lecturer_id' => $target->getKey()]); + // ponytail: reassigning model_id keeps files & model_type (both are Lecturer) intact. + // Ceiling: merged 'avatar' media land in the target's singleFile collection, so a later + // avatar upload on the target will prune them. Move them to 'images' if that matters. + $lecturer->media()->update(['model_id' => $target->getKey()]); + $lecturer->delete(); + }); + }); + + $this->newLine(2); + $this->table(['Metric', 'Count'], [ + [$force ? 'Lecturers deleted' : 'Lecturers to delete', $stats['deleted']], + [$force ? 'Library items merged' : 'Library items to merge', $stats['libraryItems']], + [$force ? 'Media merged' : 'Media to merge', $stats['media']], + ]); + + if (! $force) { + $this->newLine(); + $this->comment('Dry-run only. Re-run with --force to apply.'); + } + + return Command::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index db0c0ef..200e13b 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\User; use Dedoc\Scramble\Attributes\Group; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -18,9 +19,33 @@ class UserController extends Controller */ public function __invoke(Request $request): JsonResponse { - $user = $request->user(); + return response()->json($this->profilePayload($request->user())); + } - return response()->json([ + /** + * Profil aktualisieren + * + * Erlaubt dem Token-Inhaber, den eigenen Anzeigenamen zu ändern. + * Rollen (is_lecturer/is_leader) sind bewusst NICHT änderbar. + */ + public function update(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + ]); + + $user = $request->user(); + $user->update(['name' => $validated['name']]); + + return response()->json($this->profilePayload($user->fresh())); + } + + /** + * @return array + */ + private function profilePayload(User $user): array + { + return [ 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, @@ -28,6 +53,6 @@ class UserController extends Controller 'is_lecturer' => (bool) $user->is_lecturer, 'is_leader' => (bool) $user->is_leader, 'avatar' => $user->profile_photo_url, - ]); + ]; } } diff --git a/routes/api.php b/routes/api.php index 590b9d6..d872455 100644 --- a/routes/api.php +++ b/routes/api.php @@ -42,6 +42,7 @@ Route::middleware('auth:sanctum') ->as('api.') ->group(function () { Route::get('user', UserController::class)->name('user'); + Route::patch('user', [UserController::class, 'update'])->name('user.update'); Route::post('courses', [CourseController::class, 'store']) ->name('courses.store'); diff --git a/tests/Feature/Auth/MobileAuthTest.php b/tests/Feature/Auth/MobileAuthTest.php index 7088c0e..a946692 100644 --- a/tests/Feature/Auth/MobileAuthTest.php +++ b/tests/Feature/Auth/MobileAuthTest.php @@ -225,6 +225,36 @@ it('denies /api/user without a token', function () { $this->getJson('/api/user')->assertUnauthorized(); }); +it('updates the token owner display name', function () { + $user = User::factory()->create(['name' => 'Old Name']); + Sanctum::actingAs($user); + + $this->patchJson('/api/user', ['name' => 'Satoshi']) + ->assertOk() + ->assertJsonPath('name', 'Satoshi'); + + expect($user->fresh()->name)->toBe('Satoshi'); +}); + +it('does not let the user change roles via the profile update', function () { + $user = User::factory()->create(['is_lecturer' => false]); + Sanctum::actingAs($user); + + $this->patchJson('/api/user', ['name' => 'Satoshi', 'is_lecturer' => true]) + ->assertOk(); + + expect((bool) $user->fresh()->is_lecturer)->toBeFalse(); +}); + +it('rejects an empty display name', function () { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $this->patchJson('/api/user', ['name' => '']) + ->assertUnprocessable() + ->assertJsonValidationErrors('name'); +}); + it('revokes the requesting token on mobile logout', function () { $user = User::factory()->create(); $plainTextToken = $user->createToken('Pixel 10')->plainTextToken; diff --git a/tests/Feature/Console/CleanupLecturersTest.php b/tests/Feature/Console/CleanupLecturersTest.php new file mode 100644 index 0000000..c4271d7 --- /dev/null +++ b/tests/Feature/Console/CleanupLecturersTest.php @@ -0,0 +1,45 @@ +create(['name' => 'Orphan']); + + $this->artisan('lecturers:cleanup', ['--force' => true])->assertExitCode(1); + + expect(Lecturer::query()->where('name', 'Orphan')->exists())->toBeTrue(); +}); + +it('does nothing on a dry-run', function () { + Lecturer::factory()->create(['name' => 'Einundzwanzig']); + $empty = Lecturer::factory()->create(); + + $this->artisan('lecturers:cleanup')->assertExitCode(0); + + expect(Lecturer::query()->find($empty->id))->not->toBeNull(); +}); + +it('merges library items and deletes empty lecturers with --force', function () { + $target = Lecturer::factory()->create(['name' => 'Einundzwanzig']); + $empty = Lecturer::factory()->create(); + $item = LibraryItem::factory()->create(['lecturer_id' => $empty->id]); + + $this->artisan('lecturers:cleanup', ['--force' => true]) + ->expectsConfirmation('This permanently deletes lecturers on this database. Continue?', 'yes') + ->assertExitCode(0); + + expect(Lecturer::query()->find($empty->id))->toBeNull(); + expect($item->fresh()->lecturer_id)->toBe($target->id); +}); + +it('keeps lecturers that have a course', function () { + Lecturer::factory()->create(['name' => 'Einundzwanzig']); + $withCourse = Lecturer::factory()->create(); + Course::factory()->create(['lecturer_id' => $withCourse->id]); + + $this->artisan('lecturers:cleanup', ['--force' => true])->assertExitCode(0); + + expect(Lecturer::query()->find($withCourse->id))->not->toBeNull(); +});