diff --git a/.sisyphus/evidence/task-6-api-log-filter.png b/.sisyphus/evidence/task-6-api-log-filter.png new file mode 100644 index 0000000..01af55c Binary files /dev/null and b/.sisyphus/evidence/task-6-api-log-filter.png differ diff --git a/.sisyphus/evidence/task-6-api-log-nav.png b/.sisyphus/evidence/task-6-api-log-nav.png new file mode 100644 index 0000000..4db2d51 Binary files /dev/null and b/.sisyphus/evidence/task-6-api-log-nav.png differ diff --git a/.sisyphus/evidence/task-6-api-log-page.png b/.sisyphus/evidence/task-6-api-log-page.png new file mode 100644 index 0000000..0fdaf35 Binary files /dev/null and b/.sisyphus/evidence/task-6-api-log-page.png differ diff --git a/.sisyphus/evidence/task-6-migration-tests.txt b/.sisyphus/evidence/task-6-migration-tests.txt new file mode 100644 index 0000000..2dc3dde --- /dev/null +++ b/.sisyphus/evidence/task-6-migration-tests.txt @@ -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 + diff --git a/.sisyphus/notepads/cts-bugfix-features/learnings.md b/.sisyphus/notepads/cts-bugfix-features/learnings.md new file mode 100644 index 0000000..ac12d9f --- /dev/null +++ b/.sisyphus/notepads/cts-bugfix-features/learnings.md @@ -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 `` tags when replacement ends with `` and the next existing line is also `` +- 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 diff --git a/app/Http/Controllers/ApiLogController.php b/app/Http/Controllers/ApiLogController.php new file mode 100644 index 0000000..faf7664 --- /dev/null +++ b/app/Http/Controllers/ApiLogController.php @@ -0,0 +1,40 @@ +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, + ], + ]); + } +} diff --git a/app/Models/ApiRequestLog.php b/app/Models/ApiRequestLog.php new file mode 100644 index 0000000..3e93803 --- /dev/null +++ b/app/Models/ApiRequestLog.php @@ -0,0 +1,63 @@ + '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); + }); + } +} diff --git a/app/Services/ChurchToolsService.php b/app/Services/ChurchToolsService.php index 97dfde5..ddd94d9 100644 --- a/app/Services/ChurchToolsService.php +++ b/app/Services/ChurchToolsService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\ApiRequestLog; use CTApi\CTConfig; use CTApi\Models\Events\Event\EventAgendaRequest; use CTApi\Models\Events\Event\EventRequest; @@ -124,7 +125,12 @@ public function syncAgenda(int $eventId): mixed 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 @@ -137,7 +143,12 @@ public function getEventServices(int $eventId): array return $event?->getEventServices() ?? []; }; - return $fetcher($eventId); + return $this->logApiCall( + 'getEventServices', + 'event_services', + fn (): array => $fetcher($eventId), + ['event_id' => $eventId] + ); } private function fetchEvents(): array @@ -148,7 +159,7 @@ private function fetchEvents(): array return EventRequest::where('from', Carbon::now()->toDateString())->get(); }; - return $fetcher(); + return $this->logApiCall('fetchEvents', 'events', fn (): array => $fetcher()); } private function fetchSongs(): array @@ -159,7 +170,62 @@ private function fetchSongs(): array 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 diff --git a/database/migrations/2026_03_02_100000_create_api_request_logs_table.php b/database/migrations/2026_03_02_100000_create_api_request_logs_table.php new file mode 100644 index 0000000..a23bb5f --- /dev/null +++ b/database/migrations/2026_03_02_100000_create_api_request_logs_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/resources/js/Layouts/AuthenticatedLayout.vue b/resources/js/Layouts/AuthenticatedLayout.vue index a32d1b7..5db0d83 100644 --- a/resources/js/Layouts/AuthenticatedLayout.vue +++ b/resources/js/Layouts/AuthenticatedLayout.vue @@ -108,8 +108,15 @@ function triggerSync() { > Song-Datenbank + + API-Log + Song-Datenbank + + API-Log + diff --git a/resources/js/Pages/ApiLogs/Index.vue b/resources/js/Pages/ApiLogs/Index.vue new file mode 100644 index 0000000..9a13d3c --- /dev/null +++ b/resources/js/Pages/ApiLogs/Index.vue @@ -0,0 +1,172 @@ + + + diff --git a/routes/web.php b/routes/web.php index dce4193..fa483f8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ 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('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone'); Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update'); diff --git a/tests/Feature/ApiLogControllerTest.php b/tests/Feature/ApiLogControllerTest.php new file mode 100644 index 0000000..a256955 --- /dev/null +++ b/tests/Feature/ApiLogControllerTest.php @@ -0,0 +1,133 @@ +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); + } +}