feat(ccli): add CcliPasteController endpoints

- POST /api/ccli/preview: parse-only endpoint (no DB writes)
- POST /api/songs/import-from-ccli-paste: 3 modes (create / pair-with-song / assign-to-service-song)
- GET /songs/import-from-ccli-paste: Inertia page with base64 bookmarklet prefill
- Routes guarded by auth:sanctum + throttle:30,1 (API); auth + web stack (web)
- Maps DuplicateCcliSongException to 409 with existing_song_id and edit_url
- Pest tests (10 cases, 63 assertions): preview, all 3 import modes, 409 dup, 422 errors, unauth, prefill happy/error, login redirect
This commit is contained in:
Thorsten Bus 2026-05-11 09:23:11 +02:00
parent cd0a72124d
commit 35d3298251
5 changed files with 370 additions and 0 deletions

View file

@ -99,3 +99,12 @@ ### 2026-05-10 CCLI Translation Pairing
- `CcliTranslationPairingService` returns a review-only mapping and never writes `SongSlide.text_content_translated`; callers remain responsible for persistence.
- Pairing canonicalizes both local arrangement labels and CCLI sections with `CcliLabels::normalizeLabelName()` + lowercase, so `Strophe 1``Verse 1` and `Refrain``Chorus` work across languages.
- Distribution mirrors `TranslationService::importTranslation()` by filling local slide slots in arrangement order using each local slide's original line count; overflow CCLI lines are kept on the final local slide for that section.
### 2026-05-11 CcliPasteController (T10)
- `SongMatchingService::manualAssign(ServiceSong, Song)` takes a **Song object** (not int id) — different from initial task plan.
- API routes use `auth:sanctum` middleware (not just `auth`); Sanctum's `EnsureFrontendRequestsAreStateful` is prepended globally to API in bootstrap/app.php.
- Apply `throttle:30,1` via a nested `Route::middleware('throttle:30,1')->group(...)` inside the existing sanctum group; combined middleware shown by `route:list -vv`.
- `assertInertia()` enforces page-component file existence by default. For pages whose Vue component is created in a later task (T16 `Songs/ImportFromCcliPaste`), pass `$shouldExist=false` to `component()` as second arg.
- `tests/fixtures/ccli/` (lowercase) is the canonical fixture directory; existing tests already declare a top-level `ccliFixturePath()` helper, so new test files need a uniquely-named helper to avoid `Cannot redeclare function` errors in Pest.
- Web route `songs.import-from-ccli-paste` needs the `auth` middleware (web-style redirect to login), while the API routes use sanctum (401 JSON response); the difference matters for unauthenticated test assertions (`assertRedirect(route('login'))` vs `assertUnauthorized()`).
- `CcliImportService::import()` throws `RuntimeException` for missing CCLI id and `InvalidArgumentException` (via parser) for parse failures; controller catches both to return 422 with a German message.

View file

@ -0,0 +1,177 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\DuplicateCcliSongException;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Services\CcliImportService;
use App\Services\CcliPasteParser;
use App\Services\CcliTranslationPairingService;
use App\Services\SongMatchingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
final class CcliPasteController extends Controller
{
public function __construct(
private readonly CcliPasteParser $parser,
private readonly CcliImportService $importService,
private readonly CcliTranslationPairingService $pairingService,
private readonly SongMatchingService $matchingService,
) {}
/**
* POST /api/ccli/preview
* Parse raw text and return DTO as JSON. No DB writes.
*/
public function preview(Request $request): JsonResponse
{
$validated = $request->validate([
'raw_text' => ['required', 'string', 'max:200000'],
]);
try {
$parsed = $this->parser->parse($validated['raw_text']);
} catch (InvalidArgumentException $e) {
return response()->json([
'message' => $e->getMessage(),
], 422);
}
return response()->json([
'title' => $parsed->title,
'author' => $parsed->author,
'ccliId' => $parsed->ccliId,
'year' => $parsed->year,
'copyrightText' => $parsed->copyrightText,
'sections' => array_map(fn ($s) => [
'label' => $s->label,
'kind' => $s->kind,
'number' => $s->number,
'modifier' => $s->modifier,
'lines' => $s->lines,
'hasTranslation' => $s->linesTranslated !== null,
], $parsed->sections),
]);
}
/**
* POST /api/songs/import-from-ccli-paste
* Import a CCLI paste in one of 3 modes.
*/
public function importPaste(Request $request): JsonResponse
{
$validated = $request->validate([
'raw_text' => ['required', 'string', 'max:200000'],
'mode' => ['required', Rule::in(['create', 'pair-with-song', 'assign-to-service-song'])],
'target_id' => ['required_unless:mode,create', 'nullable', 'integer'],
'source_url' => ['nullable', 'string', 'max:500'],
]);
$rawText = $validated['raw_text'];
$mode = $validated['mode'];
$targetId = $validated['target_id'] ?? null;
$sourceUrl = $validated['source_url'] ?? null;
try {
if ($mode === 'create') {
$result = $this->importService->import($rawText, $sourceUrl);
return response()->json([
'song_id' => $result['song']->id,
'status' => $result['status'],
'warnings' => $result['warnings'],
], 201);
}
if ($mode === 'pair-with-song') {
$localSong = Song::findOrFail($targetId);
$result = $this->pairingService->pair($localSong, $rawText);
session()->flash('ccli_prefilled', $result['distributed_text']);
return response()->json([
'song_id' => $localSong->id,
'mapping' => $result['mapping'],
'unmatched_labels' => $result['unmatched_labels'],
'redirect_to' => route('songs.translate', $localSong->id).'?prefilled=true',
]);
}
if ($mode === 'assign-to-service-song') {
$result = $this->importService->import($rawText, $sourceUrl);
$song = $result['song'];
$serviceSong = ServiceSong::findOrFail($targetId);
$this->matchingService->manualAssign($serviceSong, $song);
return response()->json([
'song_id' => $song->id,
'service_song_id' => $serviceSong->id,
'status' => $result['status'],
], 201);
}
} catch (DuplicateCcliSongException $e) {
return response()->json([
'message' => $e->getMessage(),
'existing_song_id' => $e->existingSongId,
'edit_url' => route('songs.index').'#song-'.$e->existingSongId,
], 409);
} catch (InvalidArgumentException $e) {
return response()->json(['message' => $e->getMessage()], 422);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['message' => 'Unbekannter Modus'], 422);
}
/**
* GET /songs/import-from-ccli-paste
* Render the Inertia page for bookmarklet redirect.
*/
public function showImportPage(Request $request): InertiaResponse
{
$prefill = $request->query('prefill');
$prefilledText = null;
$prefilledMetadata = null;
$prefillError = null;
if ($prefill !== null && is_string($prefill)) {
try {
$decoded = base64_decode($prefill, strict: true);
if ($decoded === false) {
throw new InvalidArgumentException('Ungültige Kodierung');
}
$payload = json_decode($decoded, associative: true, flags: JSON_THROW_ON_ERROR);
if (! is_array($payload)) {
throw new InvalidArgumentException('Ungültiges Payload-Format');
}
$prefilledText = $payload['rawText'] ?? null;
$prefilledMetadata = [
'title' => $payload['title'] ?? null,
'author' => $payload['author'] ?? null,
'ccliId' => $payload['ccliId'] ?? null,
'sourceUrl' => $payload['sourceUrl'] ?? null,
];
} catch (Throwable) {
$prefillError = 'Lesezeichen-Daten konnten nicht gelesen werden. Bitte den Liedtext manuell einfügen.';
}
}
return Inertia::render('Songs/ImportFromCcliPaste', [
'prefilledText' => $prefilledText,
'prefilledMetadata' => $prefilledMetadata,
'prefillError' => $prefillError,
]);
}
}

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\CcliPasteController;
use App\Http\Controllers\ProFileController;
use App\Http\Controllers\ServiceSongController;
use App\Http\Controllers\SongController;
@ -47,4 +48,12 @@
Route::get('/songs/{song}/download-pro', [ProFileController::class, 'downloadPro'])
->name('api.songs.download-pro');
// CCLI Paste Import (manuelles Einfügen oder Bookmarklet)
Route::middleware('throttle:30,1')->group(function () {
Route::post('/ccli/preview', [CcliPasteController::class, 'preview'])
->name('api.ccli.preview');
Route::post('/songs/import-from-ccli-paste', [CcliPasteController::class, 'importPaste'])
->name('api.ccli.import');
});
});

View file

@ -3,6 +3,7 @@
use App\Http\Controllers\ApiLogController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\BookmarkletController;
use App\Http\Controllers\CcliPasteController;
use App\Http\Controllers\LabelImportController;
use App\Http\Controllers\MacroAssignmentController;
use App\Http\Controllers\MacroImportController;
@ -72,6 +73,9 @@
return Inertia::render('Songs/Index');
})->name('songs.index');
Route::get('/songs/import-from-ccli-paste', [CcliPasteController::class, 'showImportPage'])
->name('songs.import-from-ccli-paste');
Route::get('/api-logs', [ApiLogController::class, 'index'])->name('api-logs.index');
Route::get('/api-logs/{log}/response-body', [ApiLogController::class, 'responseBody'])->name('api-logs.response-body');

View file

@ -0,0 +1,171 @@
<?php
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function ccliPasteControllerFixture(string $name): string
{
return file_get_contents(base_path("tests/fixtures/ccli/{$name}"));
}
test('preview returns parsed DTO without writing to DB', function () {
$user = User::factory()->create();
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
$songCountBefore = Song::count();
$response = $this->actingAs($user)
->postJson(route('api.ccli.preview'), ['raw_text' => $content]);
$response->assertStatus(200);
$response->assertJsonStructure(['title', 'author', 'ccliId', 'year', 'copyrightText', 'sections']);
expect($response->json('title'))->toBe('Test Song 1');
expect($response->json('ccliId'))->toBe('9999001');
expect(Song::count())->toBe($songCountBefore);
});
test('importPaste create mode persists song and returns 201', function () {
$user = User::factory()->create();
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
$response = $this->actingAs($user)
->postJson(route('api.ccli.import'), [
'raw_text' => $content,
'mode' => 'create',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['song_id', 'status', 'warnings']);
expect($response->json('status'))->toBe('created');
expect(Song::find($response->json('song_id')))->not->toBeNull();
});
test('importPaste returns 409 on duplicate ccli_id with existing_song_id', function () {
$user = User::factory()->create();
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
$first = $this->actingAs($user)
->postJson(route('api.ccli.import'), ['raw_text' => $content, 'mode' => 'create']);
$existingId = $first->json('song_id');
$response = $this->actingAs($user)
->postJson(route('api.ccli.import'), ['raw_text' => $content, 'mode' => 'create']);
$response->assertStatus(409);
$response->assertJsonStructure(['message', 'existing_song_id', 'edit_url']);
expect($response->json('existing_song_id'))->toBe($existingId);
expect($response->json('message'))->toContain('existiert bereits');
});
test('importPaste returns 422 on malformed paste with German error', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson(route('api.ccli.import'), [
'raw_text' => 'This has no section labels at all',
'mode' => 'create',
]);
$response->assertStatus(422);
$response->assertJsonStructure(['message']);
expect($response->json('message'))->not->toBeEmpty();
});
test('importPaste returns 422 on empty raw_text', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson(route('api.ccli.import'), [
'raw_text' => '',
'mode' => 'create',
]);
$response->assertStatus(422);
});
test('importPaste assign-to-service-song imports and assigns song to service song', function () {
$user = User::factory()->create();
$content = ccliPasteControllerFixture('english-only-multi-verse.txt');
$service = Service::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'service_id' => $service->id,
'song_id' => null,
'song_arrangement_id' => null,
'matched_at' => null,
]);
$response = $this->actingAs($user)
->postJson(route('api.ccli.import'), [
'raw_text' => $content,
'mode' => 'assign-to-service-song',
'target_id' => $serviceSong->id,
]);
$response->assertStatus(201);
$response->assertJsonStructure(['song_id', 'service_song_id', 'status']);
$serviceSong->refresh();
expect($serviceSong->song_id)->toBe($response->json('song_id'));
expect($serviceSong->matched_at)->not->toBeNull();
});
test('importPaste rejects unauthenticated requests with 401', function () {
$response = $this->postJson(route('api.ccli.import'), [
'raw_text' => 'test',
'mode' => 'create',
]);
$response->assertUnauthorized();
});
test('showImportPage renders Inertia page with prefilled data from valid base64', function () {
$user = User::factory()->create();
$this->withoutVite();
$payload = json_encode([
'title' => 'Amazing Grace',
'author' => 'John Newton',
'ccliId' => '4760',
'sourceUrl' => 'https://songselect.ccli.com/Songs/4760',
'rawText' => "Amazing Grace\nJohn Newton\n\nVerse 1\nAmazing grace how sweet the sound\n\n© 1779\nCCLI # 4760",
]);
$encoded = base64_encode($payload);
$response = $this->actingAs($user)
->get(route('songs.import-from-ccli-paste', ['prefill' => $encoded]));
$response->assertStatus(200);
$response->assertInertia(
fn ($page) => $page->component('Songs/ImportFromCcliPaste', false)
->has('prefilledText')
->has('prefilledMetadata')
->where('prefilledMetadata.title', 'Amazing Grace')
->where('prefilledMetadata.ccliId', '4760')
);
});
test('showImportPage renders with error for invalid base64', function () {
$user = User::factory()->create();
$this->withoutVite();
$response = $this->actingAs($user)
->get(route('songs.import-from-ccli-paste', ['prefill' => 'NOT_VALID_BASE64!!!']));
$response->assertStatus(200);
$response->assertInertia(
fn ($page) => $page->component('Songs/ImportFromCcliPaste', false)
->where('prefillError', fn ($v) => $v !== null)
);
});
test('showImportPage redirects unauthenticated to login', function () {
$response = $this->get(route('songs.import-from-ccli-paste'));
$response->assertRedirect(route('login'));
});