feat(db): add service_agenda_items table + slides FK migration

This commit is contained in:
Thorsten Bus 2026-03-29 11:34:55 +02:00
parent 5cf0c43241
commit 31d7634dbf
10 changed files with 339 additions and 6 deletions

View file

@ -7,8 +7,8 @@
use App\Models\Song;
use App\Services\ProBundleExportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
@ -275,7 +275,7 @@ public function download(Service $service): JsonResponse|BinaryFileResponse
}
try {
$playlistService = new \App\Services\PlaylistExportService();
$playlistService = new \App\Services\PlaylistExportService;
$result = $playlistService->generatePlaylist($service);
$response = response()->download($result['path'], $result['filename']);

View file

@ -0,0 +1,57 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ServiceAgendaItem extends Model
{
use HasFactory;
protected $fillable = [
'service_id',
'cts_agenda_item_id',
'position',
'title',
'type',
'note',
'duration',
'start',
'is_before_event',
'responsible',
'service_song_id',
'sort_order',
];
protected function casts(): array
{
return [
'responsible' => 'array',
'is_before_event' => 'boolean',
];
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function serviceSong(): BelongsTo
{
return $this->belongsTo(ServiceSong::class);
}
public function slides(): HasMany
{
return $this->hasMany(Slide::class, 'service_agenda_item_id');
}
public function scopeVisible(Builder $query): void
{
$query->where('is_before_event', false);
}
}

View file

@ -15,6 +15,7 @@ class Slide extends Model
protected $fillable = [
'type',
'service_id',
'service_agenda_item_id',
'original_filename',
'stored_filename',
'thumbnail_filename',
@ -36,4 +37,9 @@ public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function serviceAgendaItem(): BelongsTo
{
return $this->belongsTo(ServiceAgendaItem::class);
}
}

View file

@ -24,7 +24,7 @@ public function generatePlaylist(Service $service): array
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
$skippedEmpty = 0;
$exportService = new ProExportService();
$exportService = new ProExportService;
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
mkdir($tempDir, 0755, true);

View file

@ -69,7 +69,7 @@ public function generateBundle(Service $service, string $blockType): string
$bundlePath = sys_get_temp_dir().'/'.uniqid($blockType.'-').'.probundle';
$zip = new ZipArchive();
$zip = new ZipArchive;
$openResult = $zip->open($bundlePath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($openResult !== true) {
throw new InvalidArgumentException('Konnte .probundle nicht erstellen.');

View file

@ -0,0 +1,51 @@
<?php
namespace Database\Factories;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use Illuminate\Database\Eloquent\Factories\Factory;
class ServiceAgendaItemFactory extends Factory
{
protected $model = ServiceAgendaItem::class;
public function definition(): array
{
return [
'service_id' => Service::factory(),
'cts_agenda_item_id' => $this->faker->uuid(),
'position' => $this->faker->numerify('#.#'),
'title' => $this->faker->sentence(3),
'type' => $this->faker->randomElement(['Song', 'Default', 'Header']),
'note' => $this->faker->optional()->sentence(),
'duration' => $this->faker->optional()->numerify('#'),
'start' => $this->faker->optional()->time(),
'is_before_event' => false,
'responsible' => $this->faker->optional()->randomElements(
['person1', 'person2', 'person3'],
$this->faker->numberBetween(1, 2)
),
'service_song_id' => null,
'sort_order' => $this->faker->numberBetween(1, 20),
];
}
public function withSong(ServiceSong $serviceSong): self
{
return $this->state(fn () => [
'service_song_id' => $serviceSong->id,
'service_id' => $serviceSong->service_id,
'type' => 'Song',
]);
}
public function nonSong(): self
{
return $this->state(fn () => [
'service_song_id' => null,
'type' => $this->faker->randomElement(['Default', 'Header']),
]);
}
}

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('service_agenda_items', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->constrained()->cascadeOnDelete();
$table->string('cts_agenda_item_id')->nullable()->index();
$table->string('position');
$table->string('title');
$table->string('type');
$table->text('note')->nullable();
$table->string('duration')->nullable();
$table->string('start')->nullable();
$table->boolean('is_before_event')->default(false);
$table->json('responsible')->nullable();
$table->foreignId('service_song_id')->nullable()->constrained()->nullOnDelete();
$table->unsignedInteger('sort_order');
$table->timestamps();
$table->index('service_id');
$table->unique(['service_id', 'sort_order']);
});
}
public function down(): void
{
Schema::dropIfExists('service_agenda_items');
}
};

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('slides', function (Blueprint $table) {
$table->foreignId('service_agenda_item_id')->nullable()->after('service_id')->constrained()->nullOnDelete();
});
}
public function down(): void
{
Schema::table('slides', function (Blueprint $table) {
$table->dropConstrainedForeignId('service_agenda_item_id');
});
}
};

View file

@ -60,7 +60,7 @@ public function test_probundle_enthaelt_data_und_bilder(): void
$copiedPath = sys_get_temp_dir().'/probundle-test-'.uniqid().'.probundle';
copy($baseResponse->getFile()->getPathname(), $copiedPath);
$zip = new ZipArchive();
$zip = new ZipArchive;
$openResult = $zip->open($copiedPath);
$this->assertTrue($openResult === true);
@ -128,7 +128,7 @@ public function test_probundle_ohne_slides_enthaelt_nur_data(): void
$copiedPath = sys_get_temp_dir().'/probundle-empty-test-'.uniqid().'.probundle';
copy($baseResponse->getFile()->getPathname(), $copiedPath);
$zip = new ZipArchive();
$zip = new ZipArchive;
$openResult = $zip->open($copiedPath);
$this->assertTrue($openResult === true);

View file

@ -0,0 +1,161 @@
<?php
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Slide;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Schema;
/*
|--------------------------------------------------------------------------
| ServiceAgendaItem Model & Migration Tests
|--------------------------------------------------------------------------
*/
test('migration creates service_agenda_items table with correct columns', function () {
expect(Schema::hasTable('service_agenda_items'))->toBeTrue();
$columns = [
'id',
'service_id',
'cts_agenda_item_id',
'position',
'title',
'type',
'note',
'duration',
'start',
'is_before_event',
'responsible',
'service_song_id',
'sort_order',
'created_at',
'updated_at',
];
foreach ($columns as $column) {
expect(Schema::hasColumn('service_agenda_items', $column))->toBeTrue();
}
});
test('factory creates valid service agenda item', function () {
$item = ServiceAgendaItem::factory()->create();
expect($item)->toBeInstanceOf(ServiceAgendaItem::class);
expect($item->id)->toBeGreaterThan(0);
expect($item->service_id)->toBeGreaterThan(0);
expect($item->title)->not->toBeNull();
expect($item->type)->toBeIn(['Song', 'Default', 'Header']);
expect($item->position)->not->toBeNull();
expect($item->is_before_event)->toBeFalse();
expect($item->sort_order)->toBeGreaterThan(0);
});
test('service relationship returns correct service', function () {
$service = Service::factory()->create();
$item = ServiceAgendaItem::factory()->create(['service_id' => $service->id]);
expect($item->service)->toBeInstanceOf(Service::class);
expect($item->service->id)->toBe($service->id);
});
test('serviceSong relationship is nullable', function () {
$item = ServiceAgendaItem::factory()->create(['service_song_id' => null]);
expect($item->serviceSong)->toBeNull();
});
test('serviceSong relationship returns correct service song', function () {
$song = ServiceSong::factory()->create();
$item = ServiceAgendaItem::factory()->create(['service_song_id' => $song->id]);
expect($item->serviceSong)->toBeInstanceOf(ServiceSong::class);
expect($item->serviceSong->id)->toBe($song->id);
});
test('slides relationship returns associated slides', function () {
$item = ServiceAgendaItem::factory()->create();
Slide::factory()->count(3)->create(['service_agenda_item_id' => $item->id]);
expect($item->slides)->toHaveCount(3);
expect($item->slides->first())->toBeInstanceOf(Slide::class);
});
test('scopeVisible excludes is_before_event true items', function () {
ServiceAgendaItem::factory()->count(2)->create(['is_before_event' => false]);
ServiceAgendaItem::factory()->count(3)->create(['is_before_event' => true]);
$visible = ServiceAgendaItem::visible()->get();
expect($visible)->toHaveCount(2);
$visible->each(fn ($item) => expect($item->is_before_event)->toBeFalse());
});
test('responsible is cast to array', function () {
$item = ServiceAgendaItem::factory()->create([
'responsible' => ['person1', 'person2'],
]);
expect($item->responsible)->toBeArray();
expect($item->responsible)->toHaveCount(2);
});
test('is_before_event is cast to boolean', function () {
$item = ServiceAgendaItem::factory()->create(['is_before_event' => true]);
expect($item->is_before_event)->toBeTrue();
expect(is_bool($item->is_before_event))->toBeTrue();
});
test('unique constraint on service_id and sort_order', function () {
$service = Service::factory()->create();
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'sort_order' => 1,
]);
expect(fn () => ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'sort_order' => 1,
]))->toThrow(QueryException::class);
});
test('withSong factory state', function () {
$song = ServiceSong::factory()->create();
$item = ServiceAgendaItem::factory()->withSong($song)->create();
expect($item->service_song_id)->toBe($song->id);
expect($item->service_id)->toBe($song->service_id);
expect($item->type)->toBe('Song');
});
test('nonSong factory state', function () {
$item = ServiceAgendaItem::factory()->nonSong()->create();
expect($item->service_song_id)->toBeNull();
expect($item->type)->toBeIn(['Default', 'Header']);
});
test('cascadeOnDelete removes agenda items when service deleted', function () {
$service = Service::factory()->create();
ServiceAgendaItem::factory()->count(3)->create(['service_id' => $service->id]);
expect(ServiceAgendaItem::where('service_id', $service->id)->count())->toBe(3);
$service->delete();
expect(ServiceAgendaItem::where('service_id', $service->id)->count())->toBe(0);
});
test('nullOnDelete nullifies service_song_id when song deleted', function () {
$song = ServiceSong::factory()->create();
$item = ServiceAgendaItem::factory()->create(['service_song_id' => $song->id]);
expect($item->service_song_id)->toBe($song->id);
$song->delete();
$item->refresh();
expect($item->service_song_id)->toBeNull();
});