mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-22 18:20:23 +00:00
✨ Add lecturer cleanup job and update profile update functionality
- 🧹 Introduce `lecturers:cleanup` command to delete lecturers without associated courses or events, merging their items into "Einundzwanzig." - ⚙️ Add `update` method to `UserController` for handling profile updates, allowing name changes while restricting role modifications. - 🌐 Register `PATCH /api/user` route for profile updates and update related API tests. - 🧪 Add feature and console tests for `lecturers:cleanup`, covering dry-run, forced deletion, and edge cases.
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Database;
|
||||||
|
|
||||||
|
use App\Models\Lecturer;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
#[Signature('lecturers:cleanup {--force : Actually delete instead of doing a dry-run}')]
|
||||||
|
#[Description('Delete lecturers without any courses or course events. Their library items and media are merged into the "Einundzwanzig" lecturer first.')]
|
||||||
|
class CleanupLecturers extends Command
|
||||||
|
{
|
||||||
|
private const MERGE_TARGET_NAME = 'Einundzwanzig';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$target = Lecturer::query()->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use Dedoc\Scramble\Attributes\Group;
|
use Dedoc\Scramble\Attributes\Group;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -18,9 +19,33 @@ class UserController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function __invoke(Request $request): JsonResponse
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
private function profilePayload(User $user): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
@@ -28,6 +53,6 @@ class UserController extends Controller
|
|||||||
'is_lecturer' => (bool) $user->is_lecturer,
|
'is_lecturer' => (bool) $user->is_lecturer,
|
||||||
'is_leader' => (bool) $user->is_leader,
|
'is_leader' => (bool) $user->is_leader,
|
||||||
'avatar' => $user->profile_photo_url,
|
'avatar' => $user->profile_photo_url,
|
||||||
]);
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ Route::middleware('auth:sanctum')
|
|||||||
->as('api.')
|
->as('api.')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
Route::get('user', UserController::class)->name('user');
|
Route::get('user', UserController::class)->name('user');
|
||||||
|
Route::patch('user', [UserController::class, 'update'])->name('user.update');
|
||||||
|
|
||||||
Route::post('courses', [CourseController::class, 'store'])
|
Route::post('courses', [CourseController::class, 'store'])
|
||||||
->name('courses.store');
|
->name('courses.store');
|
||||||
|
|||||||
@@ -225,6 +225,36 @@ it('denies /api/user without a token', function () {
|
|||||||
$this->getJson('/api/user')->assertUnauthorized();
|
$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 () {
|
it('revokes the requesting token on mobile logout', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$plainTextToken = $user->createToken('Pixel 10')->plainTextToken;
|
$plainTextToken = $user->createToken('Pixel 10')->plainTextToken;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Course;
|
||||||
|
use App\Models\Lecturer;
|
||||||
|
use App\Models\LibraryItem;
|
||||||
|
|
||||||
|
it('aborts when the merge target is missing', function () {
|
||||||
|
Lecturer::factory()->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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user