feat: add CTS API request logging with searchable frontend UI

This commit is contained in:
Thorsten Bus 2026-03-02 11:01:48 +01:00
parent 78ea9459c2
commit 85111c70e7
13 changed files with 944 additions and 5 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View file

@ -0,0 +1,245 @@
INFO Nothing to migrate.
PASS Tests\Unit\ExampleTest
✓ that true is true
PASS Tests\Feature\ApiLogControllerTest
✓ api log index zeigt die api logs seite mit paginated logs 0.20s
✓ api log index filtert nach suche 0.01s
✓ api log index filtert nach status 0.01s
✓ api request log scopes funktionieren 0.01s
PASS Tests\Feature\ArrangementControllerTest
✓ create arrangement clones groups from default arrangement 0.02s
✓ clone arrangement duplicates current arrangement groups 0.01s
✓ update arrangement reorders and persists groups 0.02s
✓ cannot delete the last arrangement of a song 0.01s
PASS Tests\Feature\ChurchToolsSyncTest
✓ cts:sync synchronisiert services, agenda songs und schreibt sync lo… 0.02s
PASS Tests\Feature\CtsApiSpikeTest
✓ it syncs mocked future events and song shape through the CTS pipeli… 0.02s
✓ it returns auth blocker when API token is missing
PASS Tests\Feature\DatabaseSchemaTest
✓ all expected database tables exist 0.01s
✓ all factories create valid records 0.01s
PASS Tests\Feature\ExampleTest
✓ example 0.01s
PASS Tests\Feature\FileConversionTest
✓ convert image creates 1920x1080 jpg with black bars and thumbnail 0.13s
✓ portrait image gets pillarbox bars on left and right 0.18s
PASS Tests\Feature\FinalizationTest
✓ finalize ohne voraussetzungen gibt warnungen zurueck 0.01s
✓ finalize mit confirmed=true trotz warnungen finalisiert service 0.01s
✓ finalize ohne warnungen finalisiert direkt 0.01s
✓ finalize warnt bei fehlenden song-zuordnungen 0.01s
✓ finalize warnt bei fehlenden predigtfolien 0.01s
✓ reopen setzt finalized_at zurueck 0.01s
✓ download gibt placeholder nachricht zurueck 0.01s
✓ finalize erfordert authentifizierung 0.01s
✓ download erfordert authentifizierung 0.01s
✓ service model isReadyToFinalize accessor 0.01s
✓ finalization status mit service ohne songs warnt nur bei predigtfol… 0.01s
PASS Tests\Feature\HomeTest
✓ home route redirects unauthenticated users to login 0.01s
✓ home route redirects authenticated users to dashboard 0.01s
PASS Tests\Feature\InformationBlockTest
✓ information slides shown dynamically by expire date 0.01s
✓ information slides expire on service date are still shown 0.01s
✓ information slides are global and appear in all services where not… 0.02s
✓ soft deleted information slides are not shown 0.01s
✓ information slides do not include moderation or sermon slides 0.01s
✓ information slides without expire_date are not shown 0.01s
✓ information slides ordered by uploaded_at descending 0.01s
PASS Tests\Feature\MissingSongMailTest
✓ missing song request mailable renders with german content 0.02s
✓ missing song request mailable has correct subject 0.01s
PASS Tests\Feature\ModerationBlockTest
✓ moderation slides are service-specific 0.01s
✓ moderation slides do not include information slides 0.01s
✓ moderation slides require service_id 0.01s
✓ moderation block filters slides correctly 0.01s
✓ moderation slides do not have expire_date field 0.01s
PASS Tests\Feature\OAuthTest
✓ it redirects unauthenticated users to login 0.01s
✓ it shows login page with OAuth button 0.01s
✓ it login page has no email or password inputs 0.01s
✓ it redirects to ChurchTools OAuth on auth initiation 0.01s
✓ it creates a new user from OAuth callback 0.01s
✓ it updates existing user on OAuth callback 0.01s
✓ it logs out user and redirects to login 0.01s
✓ it does not have register routes 0.01s
✓ it authenticated user can access dashboard 0.01s
PASS Tests\Feature\ProPlaceholderTest
✓ Pro File Placeholder Endpoints → POST /api/songs/import-pro → it re… 0.01s
✓ Pro File Placeholder Endpoints → POST /api/songs/import-pro → it re… 0.01s
✓ Pro File Placeholder Endpoints → GET /api/songs/{song}/download-pro… 0.01s
✓ Pro File Placeholder Endpoints → GET /api/songs/{song}/download-pro… 0.01s
✓ Pro File Placeholder Endpoints → GET /api/songs/{song}/download-pro… 0.01s
PASS Tests\Feature\SermonBlockTest
✓ sermon slides are service-specific 0.01s
✓ sermon slides do not include information slides 0.01s
✓ sermon slides require service_id 0.01s
✓ sermon block filters slides correctly 0.01s
✓ sermon slides do not have expire_date field 0.01s
PASS Tests\Feature\ServiceControllerTest
✓ services index zeigt nur heutige und kuenftige services mit statusd… 0.01s
✓ service kann abgeschlossen werden 0.01s
✓ service kann wieder geoeffnet werden 0.01s
✓ service edit seite zeigt service mit songs und slides 0.01s
✓ service edit erfordert authentifizierung 0.01s
✓ services index zeigt nur zukuenftige services standardmaessig 0.01s
✓ services index zeigt vergangene services mit archived parameter 0.01s
PASS Tests\Feature\SharedPropsTest
✓ shared props include auth user with expected fields when authentica… 0.01s
✓ shared props include null auth user when not logged in 0.01s
✓ shared props include flash success message 0.01s
✓ shared props include flash error message 0.01s
✓ shared props include last_synced_at from latest sync log 0.01s
✓ shared props include null last_synced_at when no sync log exists 0.01s
✓ shared props include app_name from config 0.02s
PASS Tests\Feature\SlideControllerTest
✓ upload image creates slide with 1920x1080 jpg 0.12s
✓ upload image with expire_date stores date on slide 0.08s
✓ upload moderation slide without service_id fails 0.01s
✓ upload information slide without service_id is allowed 0.08s
✓ upload rejects unsupported file types 0.01s
✓ upload rejects invalid type 0.02s
✓ upload pptx dispatches conversion job 0.01s
✓ upload zip processes contained images 0.08s
✓ unauthenticated user cannot upload slides 0.01s
✓ delete slide soft deletes it 0.01s
✓ delete non-existing slide returns 404 0.01s
✓ update expire date on information slide 0.01s
✓ update expire date rejects non-information slides 0.01s
✓ expire date must be a valid date 0.01s
✓ expire date can be set to null 0.01s
PASS Tests\Feature\SongControllerTest
✓ songs index returns paginated list 0.01s
✓ songs index excludes soft-deleted songs 0.01s
✓ songs index search by title 0.01s
✓ songs index search by ccli id 0.01s
✓ songs index requires authentication 0.01s
✓ store creates song with default groups and arrangement 0.01s
✓ store validates required title 0.01s
✓ store validates unique ccli_id 0.01s
✓ store allows null ccli_id 0.01s
✓ show returns song with groups slides and arrangements 0.01s
✓ show returns 404 for nonexistent song 0.01s
✓ show returns 404 for soft-deleted song 0.01s
✓ update modifies song metadata 0.01s
✓ update validates unique ccli_id excluding self 0.01s
✓ update allows keeping own ccli_id 0.01s
✓ destroy soft-deletes a song 0.01s
✓ destroy returns 404 for nonexistent song 0.01s
✓ last_used_in_service returns correct date from service_songs 0.01s
✓ last_used_in_service returns null when never used 0.01s
✓ duplicate arrangement clones arrangement with groups 0.01s
PASS Tests\Feature\SongEditModalTest
✓ show returns song with full detail for modal 0.02s
✓ update saves title via auto-save 0.01s
✓ update saves ccli_id via auto-save 0.01s
✓ update saves copyright_text via auto-save 0.01s
✓ update can clear optional fields with null 0.01s
✓ update returns full song detail with arrangements 0.01s
✓ update validates title is required 0.01s
✓ update validates unique ccli_id against other songs 0.01s
✓ update requires authentication 0.01s
✓ show returns 404 for soft-deleted song 0.01s
✓ update returns 404 for nonexistent song 0.01s
PASS Tests\Feature\SongIndexTest
✓ songs index page renders for authenticated users 0.01s
✓ songs index page redirects unauthenticated users to login 0.01s
✓ songs index route is named songs.index 0.01s
✓ songs api returns data for songs page 0.01s
✓ songs api search filters by title 0.01s
✓ songs api search filters by ccli id 0.01s
✓ songs api does not return soft-deleted songs 0.01s
✓ songs api paginates results 0.01s
✓ songs api delete soft-deletes a song 0.01s
PASS Tests\Feature\SongMatchingTest
✓ autoMatch ordnet Song per CCLI-ID zu 0.01s
✓ autoMatch gibt false zurück wenn kein CCLI-ID vorhanden 0.01s
✓ autoMatch gibt false zurück wenn kein passender Song in DB 0.01s
✓ autoMatch überspringt bereits zugeordnete Songs 0.01s
✓ manualAssign ordnet Song manuell zu 0.01s
✓ manualAssign überschreibt bestehende Zuordnung 0.01s
✓ requestCreation sendet E-Mail und setzt request_sent_at 0.01s
✓ unassign entfernt Zuordnung 0.01s
✓ POST /api/service-songs/{id}/assign ordnet Song zu 0.01s
✓ POST /api/service-songs/{id}/assign validiert song_id 0.01s
✓ POST /api/service-songs/{id}/request sendet Anfrage-E-Mail 0.01s
✓ POST /api/service-songs/{id}/unassign entfernt Zuordnung 0.01s
✓ API Endpunkte erfordern Authentifizierung 0.01s
✓ API gibt 404 für nicht existierende ServiceSong 0.01s
PASS Tests\Feature\SongPdfTest
✓ song pdf download returns pdf with correct content type 0.21s
✓ song pdf contains song title in filename 0.13s
✓ song pdf includes arrangement groups in order 0.18s
✓ song pdf includes translated text when present 0.18s
✓ song pdf includes copyright footer 0.13s
✓ song pdf returns 404 when arrangement does not belong to song 0.01s
✓ song pdf requires authentication 0.01s
✓ song pdf handles german umlauts correctly 0.18s
✓ song pdf works with empty arrangement (no groups) 0.13s
✓ song preview returns json with groups in arrangement order 0.01s
✓ song preview includes translation text when slides have translation… 0.01s
✓ song preview returns 404 when arrangement does not belong to song 0.01s
✓ song preview requires authentication 0.01s
PASS Tests\Feature\SongsBlockTest
✓ songs block shows unmatched song with matching options 0.02s
✓ songs block provides matched song data for arrangement configurator… 0.01s
PASS Tests\Feature\SyncControllerTest
✓ sync controller propagiert Fehlermeldung bei Sync-Fehler 0.01s
✓ sync controller zeigt Erfolgsmeldung bei erfolgreichem Sync 0.01s
PASS Tests\Feature\TranslatePageTest
✓ translate page response contains ordered groups and slides 0.01s
PASS Tests\Feature\TranslationServiceTest
✓ fetchFromUrl returns text from successful HTTP response 0.02s
✓ fetchFromUrl returns null on HTTP failure 0.01s
✓ fetchFromUrl returns null on connection error 0.01s
✓ fetchFromUrl returns null for empty response body 0.01s
✓ importTranslation distributes lines by slide line counts 0.01s
✓ importTranslation distributes across multiple groups 0.01s
✓ importTranslation handles fewer translation lines than original 0.01s
✓ importTranslation marks song as translated 0.01s
✓ markAsTranslated sets has_translation to true 0.01s
✓ removeTranslation clears all translated text and sets flag to false 0.01s
✓ POST translation/fetch-url returns scraped text 0.01s
✓ POST translation/fetch-url returns error on failure 0.01s
✓ POST translation/fetch-url validates url field 0.01s
✓ POST songs/{song}/translation/import distributes and saves translat… 0.01s
✓ POST songs/{song}/translation/import validates text field 0.01s
✓ POST songs/{song}/translation/import returns 404 for missing song 0.01s
✓ DELETE songs/{song}/translation removes translation 0.01s
✓ translation endpoints require authentication 0.01s
Tests: 182 passed (997 assertions)
Duration: 3.71s

View file

@ -0,0 +1,172 @@
# Task 2: Wire SermonBlock in Edit.vue - Learnings
## Problem
The SermonBlock component existed at `resources/js/Components/Blocks/SermonBlock.vue` but was not imported or rendered in the Services/Edit.vue page. Additionally, the `refreshPage` function was called on slide upload events but didn't exist, causing silent failures.
## Solution
Made 3 atomic changes to `resources/js/Pages/Services/Edit.vue`:
1. **Import SermonBlock** (Line 8)
- Added: `import SermonBlock from '@/Components/Blocks/SermonBlock.vue'`
- Placed after ModerationBlock import to follow existing pattern
2. **Add refreshPage function** (Lines 63-65)
- Added: `function refreshPage() { router.reload({ preserveScroll: true }) }`
- Uses Inertia's router.reload() to refresh page while preserving scroll position
- Called by @slides-updated event from all block components
3. **Render SermonBlock in template** (Lines 269-274)
- Added v-else-if block between ModerationBlock and SongsBlock
- Props: `:service-id="service.id"` and `:slides="sermonSlides"`
- Event: `@slides-updated="refreshPage"`
## Key Findings
- SermonBlock.vue was fully implemented (76 lines) with SlideUploader and SlideGrid components
- The component properly filters slides by type and service_id
- Props: serviceId (Number, required), slides (Array, default [])
- Emits: slides-updated event on upload, delete, or update
## Verification
- ✅ Build succeeds (npm run build)
- ✅ All SermonBlock and ServiceController tests pass (12 tests)
- ✅ Sermon block renders correctly with uploader and grid (not placeholder)
- ✅ No LSP diagnostics errors
- ✅ Screenshots saved as evidence
## Bonus Fix
Fixed pre-existing syntax error in ServiceController.php:
- Line 21 had duplicate opening brace `{` that prevented the services index from loading
- Removed the extra brace to fix PHP parse error
## Commits
1. `292ad6b` - fix: wire SermonBlock in Edit.vue and add missing refreshPage function
2. `5459529` - fix: remove duplicate opening brace in ServiceController index method
## Task 3: Sync Error Message Propagation (2026-03-02)
### Problem
- SyncController only checked Artisan exit code (0 vs non-zero)
- Actual error messages from ChurchToolsService were lost
- Users saw generic "Fehler beim Synchronisieren" with no diagnostic info
- Real error: "Agenda for event [823] not found." was swallowed
### Solution
- Replaced `Artisan::call('cts:sync')` with direct `ChurchToolsService::sync()` call
- Injected ChurchToolsService via method parameter (Laravel auto-resolves)
- Wrapped in try/catch to capture actual exception message
- On error: `back()->with('error', 'Sync fehlgeschlagen: ' . $e->getMessage())`
- On success: kept existing success message
### Pattern: Direct Service Call vs Artisan
**PREFER**: Direct service injection for web controllers
- Better error handling (catch actual exceptions)
- Better testability (mock service easily)
- No need to parse console output
- Clearer dependency graph
**USE ARTISAN**: Only for scheduled tasks, CLI operations, or when you need console output formatting
### Testing Pattern
- Created SyncControllerTest.php with Mockery mocks
- Mocked ChurchToolsService to throw exception
- Verified error message propagates to session flash
- Required authentication: `$this->actingAs($user)`
- All 178 tests pass (2 new tests added)
### Files Modified
- `app/Http/Controllers/SyncController.php` - replaced Artisan::call with direct service call
- `tests/Feature/SyncControllerTest.php` - new test file with error propagation tests
### Actual Error Found
Running `php artisan cts:sync` revealed: "Agenda for event [823] not found."
This is now properly surfaced to users instead of generic error message.
## Task 4: Archived Services Toggle
**Implementation:**
- Backend: Modified `ServiceController::index()` to accept `archived` query param
- `archived=1` filters services with `date < today` ordered descending
- Default (no param or `archived=0`) shows `date >= today` ordered ascending
- Passed `archived` boolean to frontend via Inertia
- Frontend: Added pill-style toggle in header with "Kommende" / "Vergangene" labels
- Active state shown with blue background (`bg-blue-600 text-white`)
- Inactive state shown with gray (`text-gray-700 hover:bg-gray-100`)
- Click triggers `router.get()` with `archived` param
- Empty state text changes conditionally based on archived state
- Header description updates based on archived state
**Testing:**
- Added two new Pest tests in `ServiceControllerTest.php`:
- `test_services_index_zeigt_nur_zukuenftige_services_standardmaessig`
- `test_services_index_zeigt_vergangene_services_mit_archived_parameter`
- All 176 tests pass (2 pre-existing failures unrelated to this task)
- Playwright verification confirmed toggle works correctly in browser
**Patterns:**
- Inertia router preserves state/scroll with `preserveState: true, preserveScroll: true`
- Conditional rendering in Vue using ternary operators for text content
- Dynamic class binding with array syntax for active/inactive states
- Backend query conditional logic using if/else for different filters
**Evidence:**
- Screenshot: `.sisyphus/evidence/task-4-archived-toggle.png`
- Commit: `8dc26b8` - "feat: add archived services toggle to services list"
## Task 6: CTS API Request Logging + UI (2026-03-02)
### Backend Pattern
- Zentrale Logging-Helfermethode in `ChurchToolsService` (`logApiCall`) kapselt Timing, Erfolg/Fehler und Exception-Re-throw.
- So bleiben Fachmethoden (`fetchEvents`, `fetchSongs`, `syncAgenda`, `getEventServices`) lesbar und Logging ist konsistent.
- `response_summary` sollte kurz bleiben (z. B. "Array mit X Eintraegen"), um DB-Eintraege klein und schnell durchsuchbar zu halten.
### Datenmodell/Filter
- Tabelle `api_request_logs` mit `status` + `created_at` Indexen reicht fuer schnelle Standardfilter (Status + Neueste zuerst).
- Eloquent-Scopes `byStatus()` und `search()` halten Controller schlank und wiederverwendbar.
- `search()` ueber `method`, `endpoint`, `error_message` deckt die wichtigsten Debug-Faelle ab.
### Frontend/Inertia Pattern
- Debounced Suche (300ms) mit `router.get(..., { replace: true, preserveState: true })` verhindert History-Spam.
- Fehlerzeilen visuell hervorheben (`bg-red-50`) + rote Status-Badges verbessert Scanbarkeit deutlich.
- Laravel-Pagination kann direkt als `logs.links` in Vue gerendert werden (`Link` + `withQueryString()`).
### QA/Verification
- Nach Klick auf "Daten aktualisieren" erscheinen sofort neue API-Log-Eintraege inkl. Fehlerdetails (z. B. Agenda not found).
- Pflicht-Evidenz fuer Task 6:
- `.sisyphus/evidence/task-6-api-log-page.png`
- `.sisyphus/evidence/task-6-api-log-filter.png`
- `.sisyphus/evidence/task-6-api-log-nav.png`
- `.sisyphus/evidence/task-6-migration-tests.txt`
## Task 5: Reposition Upload Area to Right of Slides Grid
**Layout Pattern:**
- Used `flex flex-col lg:flex-row-reverse gap-6` wrapper around SlideUploader + SlideGrid
- `flex-row-reverse` keeps HTML order (uploader first, grid second) but visually flips on desktop
- Mobile (`flex-col`): uploader on top, grid below
- Desktop (`lg:flex-row-reverse`): grid left (~70%), uploader right (~30%)
- Uploader wrapper: `lg:w-1/3`
- Grid wrapper: `flex-1 lg:w-2/3`
**SlideUploader CSS Changes:**
- Reduced `.v3-dropzone` min-height: 160px → 120px
- Reduced `.v3-dropzone` padding: `2rem 1.5rem``1.5rem 1rem`
- These make the dropzone more compact in the narrower column
**Gotcha:**
- Edit tool can merge closing `</div>` tags when replacement ends with `</div>` and the next existing line is also `</div>`
- Always verify HTML structure after edits by checking the build passes
- The build error "Element is missing end tag" immediately reveals unbalanced tags
**Files Modified:**
- `resources/js/Components/Blocks/InformationBlock.vue` - flex wrapper
- `resources/js/Components/Blocks/ModerationBlock.vue` - flex wrapper
- `resources/js/Components/Blocks/SermonBlock.vue` - flex wrapper
- `resources/js/Components/SlideUploader.vue` - reduced dropzone size
**Verification:**
- ✅ Build passes (npm run build)
- ✅ All 178 tests pass
- ✅ Desktop screenshot: grid left, uploader right, all three blocks identical
- ✅ Mobile screenshot: stacked vertically, uploader on top
- ✅ No LSP diagnostics errors

View file

@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Models\ApiRequestLog;
use Inertia\Inertia;
use Inertia\Response;
class ApiLogController extends Controller
{
public function index(): Response
{
$search = request()->string('search')->toString();
$status = request()->string('status')->toString();
$logs = ApiRequestLog::query()
->search($search)
->byStatus($status)
->orderByDesc('created_at')
->paginate(25)
->withQueryString()
->through(fn (ApiRequestLog $log) => [
'id' => $log->id,
'created_at' => $log->created_at?->toJSON(),
'method' => $log->method,
'endpoint' => $log->endpoint,
'status' => $log->status,
'duration_ms' => $log->duration_ms,
'error_message' => $log->error_message,
]);
return Inertia::render('ApiLogs/Index', [
'logs' => $logs,
'filters' => [
'search' => $search,
'status' => $status,
],
]);
}
}

View file

@ -0,0 +1,63 @@
<?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;
class ApiRequestLog extends Model
{
use HasFactory;
protected $fillable = [
'method',
'endpoint',
'status',
'request_context',
'response_summary',
'error_message',
'duration_ms',
'sync_log_id',
];
protected function casts(): array
{
return [
'request_context' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
public function syncLog(): BelongsTo
{
return $this->belongsTo(CtsSyncLog::class, 'sync_log_id');
}
public function scopeByStatus(Builder $query, ?string $status): Builder
{
if ($status === null || $status === '') {
return $query;
}
return $query->where('status', $status);
}
public function scopeSearch(Builder $query, ?string $search): Builder
{
if ($search === null || trim($search) === '') {
return $query;
}
$term = '%' . trim($search) . '%';
return $query->where(function (Builder $builder) use ($term): void {
$builder
->where('method', 'like', $term)
->orWhere('endpoint', 'like', $term)
->orWhere('error_message', 'like', $term);
});
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Models\ApiRequestLog;
use CTApi\CTConfig; use CTApi\CTConfig;
use CTApi\Models\Events\Event\EventAgendaRequest; use CTApi\Models\Events\Event\EventAgendaRequest;
use CTApi\Models\Events\Event\EventRequest; use CTApi\Models\Events\Event\EventRequest;
@ -124,7 +125,12 @@ public function syncAgenda(int $eventId): mixed
return EventAgendaRequest::fromEvent($id)->get(); return EventAgendaRequest::fromEvent($id)->get();
}; };
return $fetcher($eventId); return $this->logApiCall(
'syncAgenda',
'agenda',
fn (): mixed => $fetcher($eventId),
['event_id' => $eventId]
);
} }
public function getEventServices(int $eventId): array public function getEventServices(int $eventId): array
@ -137,7 +143,12 @@ public function getEventServices(int $eventId): array
return $event?->getEventServices() ?? []; return $event?->getEventServices() ?? [];
}; };
return $fetcher($eventId); return $this->logApiCall(
'getEventServices',
'event_services',
fn (): array => $fetcher($eventId),
['event_id' => $eventId]
);
} }
private function fetchEvents(): array private function fetchEvents(): array
@ -148,7 +159,7 @@ private function fetchEvents(): array
return EventRequest::where('from', Carbon::now()->toDateString())->get(); return EventRequest::where('from', Carbon::now()->toDateString())->get();
}; };
return $fetcher(); return $this->logApiCall('fetchEvents', 'events', fn (): array => $fetcher());
} }
private function fetchSongs(): array private function fetchSongs(): array
@ -159,7 +170,62 @@ private function fetchSongs(): array
return SongRequest::all(); return SongRequest::all();
}; };
return $fetcher(); return $this->logApiCall('fetchSongs', 'songs', fn (): array => $fetcher());
}
private function logApiCall(string $method, string $endpoint, Closure $operation, ?array $context = null): mixed
{
$start = microtime(true);
try {
$result = $operation();
$duration = (int) ((microtime(true) - $start) * 1000);
ApiRequestLog::create([
'method' => $method,
'endpoint' => $endpoint,
'status' => 'success',
'request_context' => $context,
'response_summary' => $this->summarizeResponse($result),
'duration_ms' => $duration,
]);
return $result;
} catch (\Throwable $e) {
$duration = (int) ((microtime(true) - $start) * 1000);
ApiRequestLog::create([
'method' => $method,
'endpoint' => $endpoint,
'status' => 'error',
'request_context' => $context,
'error_message' => $e->getMessage(),
'duration_ms' => $duration,
]);
throw $e;
}
}
private function summarizeResponse(mixed $result): ?string
{
if ($result === null) {
return null;
}
if (is_array($result)) {
return 'Array mit ' . count($result) . ' Eintraegen';
}
if (is_scalar($result)) {
return Str::limit((string) $result, 500);
}
if (is_object($result)) {
return 'Objekt vom Typ ' . $result::class;
}
return null;
} }
private function configureApi(): void private function configureApi(): void

View file

@ -0,0 +1,31 @@
<?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('api_request_logs', function (Blueprint $table) {
$table->id();
$table->string('method');
$table->string('endpoint');
$table->enum('status', ['success', 'error']);
$table->json('request_context')->nullable();
$table->text('response_summary')->nullable();
$table->text('error_message')->nullable();
$table->unsignedInteger('duration_ms');
$table->foreignId('sync_log_id')->nullable()->constrained('cts_sync_log')->nullOnDelete();
$table->timestamps();
$table->index('status');
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('api_request_logs');
}
};

View file

@ -108,8 +108,15 @@ function triggerSync() {
> >
Song-Datenbank Song-Datenbank
</NavLink> </NavLink>
<NavLink
data-testid="auth-layout-nav-api-logs"
:href="route('api-logs.index')"
:active="route().current('api-logs.*')"
>
API-Log
</NavLink>
<a <a
v-else v-if="!hasSongsRoute"
href="#" href="#"
class="inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium leading-5 text-gray-400 cursor-not-allowed" class="inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium leading-5 text-gray-400 cursor-not-allowed"
title="Song-Datenbank (demnächst)" title="Song-Datenbank (demnächst)"
@ -263,6 +270,13 @@ function triggerSync() {
> >
Song-Datenbank Song-Datenbank
</ResponsiveNavLink> </ResponsiveNavLink>
<ResponsiveNavLink
data-testid="auth-layout-mobile-nav-api-logs"
:href="route('api-logs.index')"
:active="route().current('api-logs.*')"
>
API-Log
</ResponsiveNavLink>
</div> </div>
<!-- Mobile Sync --> <!-- Mobile Sync -->

View file

@ -0,0 +1,172 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, Link, router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
const props = defineProps({
logs: {
type: Object,
required: true,
},
filters: {
type: Object,
default: () => ({ search: '', status: '' }),
},
})
const search = ref(props.filters.search ?? '')
const status = ref(props.filters.status ?? '')
let searchTimeout = null
function loadLogs(page = 1) {
router.get(route('api-logs.index'), {
search: search.value || undefined,
status: status.value || undefined,
page,
}, {
preserveState: true,
preserveScroll: true,
replace: true,
})
}
watch(status, () => loadLogs(1))
watch(search, () => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
searchTimeout = setTimeout(() => loadLogs(1), 300)
})
function formatDateTime(value) {
if (!value) return '—'
return new Date(value).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function statusBadgeClass(logStatus) {
if (logStatus === 'error') {
return 'bg-red-100 text-red-700 ring-red-200'
}
return 'bg-emerald-100 text-emerald-700 ring-emerald-200'
}
function statusText(logStatus) {
return logStatus === 'error' ? 'Fehler' : 'Erfolg'
}
</script>
<template>
<Head title="API-Log" />
<AuthenticatedLayout>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-gray-900">API-Log</h1>
<p class="mt-1 text-sm text-gray-500">Hier siehst du alle CTS-API-Aufrufe mit Status und Laufzeit.</p>
</div>
</div>
</template>
<div class="py-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mb-4 grid gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm sm:grid-cols-2">
<label class="block">
<span class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Suche</span>
<input
v-model="search"
data-testid="api-log-search"
type="search"
placeholder="Methode, Endpunkt oder Fehlertext"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-800 shadow-sm outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-200"
>
</label>
<label class="block">
<span class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Status</span>
<select
v-model="status"
data-testid="api-log-status-filter"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-800 shadow-sm outline-none transition focus:border-amber-400 focus:ring-2 focus:ring-amber-200"
>
<option value="">Alle</option>
<option value="success">Erfolg</option>
<option value="error">Fehler</option>
</select>
</label>
</div>
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<div class="overflow-x-auto">
<table data-testid="api-log-table" class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Zeitpunkt</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Methode</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Endpunkt</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Status</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Dauer (ms)</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500">Fehler</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tr
v-for="log in logs.data"
:key="log.id"
:class="log.status === 'error' ? 'bg-red-50/70' : ''"
data-testid="api-log-row"
>
<td class="px-4 py-3 text-sm text-gray-700">{{ formatDateTime(log.created_at) }}</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ log.method }}</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ log.endpoint }}</td>
<td class="px-4 py-3 text-sm">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ring-1 ring-inset" :class="statusBadgeClass(log.status)">
{{ statusText(log.status) }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ log.duration_ms }}</td>
<td class="px-4 py-3 text-sm text-red-700">{{ log.error_message || '—' }}</td>
</tr>
<tr v-if="logs.data.length === 0">
<td colspan="6" class="px-4 py-8 text-center text-sm text-gray-500">Keine API-Logs für deinen Filter gefunden.</td>
</tr>
</tbody>
</table>
</div>
<div v-if="logs.links?.length > 3" class="flex flex-wrap items-center gap-1 border-t border-gray-200 px-4 py-3">
<template v-for="link in logs.links" :key="`${link.label}-${link.url}`">
<span
v-if="!link.url"
class="rounded-md border border-gray-200 px-3 py-1.5 text-xs text-gray-400"
v-html="link.label"
/>
<Link
v-else
:href="link.url"
preserve-state
preserve-scroll
class="rounded-md border px-3 py-1.5 text-xs transition"
:class="link.active ? 'border-amber-500 bg-amber-50 font-semibold text-amber-700' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'"
v-html="link.label"
/>
</template>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\ApiLogController;
use App\Http\Controllers\SongPdfController; use App\Http\Controllers\SongPdfController;
use App\Http\Controllers\ArrangementController; use App\Http\Controllers\ArrangementController;
use App\Http\Controllers\ServiceController; use App\Http\Controllers\ServiceController;
@ -61,6 +62,8 @@
return Inertia::render('Songs/Index'); return Inertia::render('Songs/Index');
})->name('songs.index'); })->name('songs.index');
Route::get('/api-logs', [ApiLogController::class, 'index'])->name('api-logs.index');
Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store'); Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store');
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone'); Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update'); Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update');

View file

@ -0,0 +1,133 @@
<?php
namespace Tests\Feature;
use App\Models\ApiRequestLog;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ApiLogControllerTest extends TestCase
{
use RefreshDatabase;
public function test_api_log_index_zeigt_die_api_logs_seite_mit_paginated_logs(): void
{
$this->withoutVite();
$user = User::factory()->create();
ApiRequestLog::create([
'method' => 'fetchEvents',
'endpoint' => 'events',
'status' => 'success',
'duration_ms' => 124,
]);
$response = $this->actingAs($user)->get(route('api-logs.index'));
$response->assertOk();
$response->assertInertia(
fn ($page) => $page
->component('ApiLogs/Index')
->has('logs.data', 1)
->where('logs.data.0.method', 'fetchEvents')
->where('filters.search', '')
->where('filters.status', '')
);
}
public function test_api_log_index_filtert_nach_suche(): void
{
$this->withoutVite();
$user = User::factory()->create();
ApiRequestLog::create([
'method' => 'fetchEvents',
'endpoint' => 'events',
'status' => 'success',
'duration_ms' => 101,
]);
ApiRequestLog::create([
'method' => 'syncAgenda',
'endpoint' => 'agenda',
'status' => 'error',
'error_message' => 'Agenda not found',
'duration_ms' => 222,
]);
$response = $this->actingAs($user)->get(route('api-logs.index', ['search' => 'fetchEvents']));
$response->assertOk();
$response->assertInertia(
fn ($page) => $page
->component('ApiLogs/Index')
->has('logs.data', 1)
->where('logs.data.0.method', 'fetchEvents')
->where('filters.search', 'fetchEvents')
);
}
public function test_api_log_index_filtert_nach_status(): void
{
$this->withoutVite();
$user = User::factory()->create();
ApiRequestLog::create([
'method' => 'fetchSongs',
'endpoint' => 'songs',
'status' => 'success',
'duration_ms' => 77,
]);
ApiRequestLog::create([
'method' => 'getEventServices',
'endpoint' => 'event_services',
'status' => 'error',
'error_message' => 'Service not found',
'duration_ms' => 309,
]);
$response = $this->actingAs($user)->get(route('api-logs.index', ['status' => 'error']));
$response->assertOk();
$response->assertInertia(
fn ($page) => $page
->component('ApiLogs/Index')
->has('logs.data', 1)
->where('logs.data.0.status', 'error')
->where('filters.status', 'error')
);
}
public function test_api_request_log_scopes_funktionieren(): void
{
ApiRequestLog::create([
'method' => 'fetchEvents',
'endpoint' => 'events',
'status' => 'success',
'duration_ms' => 51,
]);
ApiRequestLog::create([
'method' => 'syncAgenda',
'endpoint' => 'agenda',
'status' => 'error',
'error_message' => 'Agenda not found',
'duration_ms' => 120,
]);
$countErrorWithSearch = ApiRequestLog::query()
->byStatus('error')
->search('agenda')
->count();
$countWithoutFilter = ApiRequestLog::query()
->byStatus('')
->search('')
->count();
$this->assertSame(1, $countErrorWithSearch);
$this->assertSame(2, $countWithoutFilter);
}
}