From 3d3c80a48abc7aca5716d53b3e9b1cc655df2d63 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 1 Mar 2026 19:56:19 +0100 Subject: [PATCH] chore: mark Wave 2 tasks (T8-T13) as complete in plan --- .sisyphus/plans/cts-presenter-app.md | 2114 ++++++++++++++++++++++++++ 1 file changed, 2114 insertions(+) create mode 100644 .sisyphus/plans/cts-presenter-app.md diff --git a/.sisyphus/plans/cts-presenter-app.md b/.sisyphus/plans/cts-presenter-app.md new file mode 100644 index 0000000..f74b45c --- /dev/null +++ b/.sisyphus/plans/cts-presenter-app.md @@ -0,0 +1,2114 @@ +# CTS Presenter App — Church Service Show Creator + +## TL;DR + +> **Quick Summary**: Greenfield Laravel 11 + Vue 3 + Inertia.js app that syncs church service data from ChurchTools API, lets users prepare services (map songs, upload slides, configure arrangements), manage a song database with translations, and track finalization status. German UI, Docker deployment, TDD. +> +> **Deliverables**: +> - Docker-containerized Laravel+Vue app with ChurchTools OAuth login +> - Service list with status tracking (song mapping, slides uploaded, finalized) +> - Service edit form with 4 blocks: Information, Moderation, Sermon, Songs +> - Song database with arrangement configurator, translation editor, PDF export +> - File upload pipeline: images → JPG 1920×1080, PPT → slides, ZIP → extraction +> - CTS API sync with "Refresh" button and timestamp in nav bar +> +> **Estimated Effort**: XL +> **Parallel Execution**: YES — 5 waves + final verification +> **Critical Path**: T0 → T1 → T4 → T8 → T14/T18 → T24 → FINAL + +--- + +## Context + +### Original Request +Build a "ChurchService Presenter Software Show Creator" — a tool that reads church service data from ChurchTools API, provides a form to finalize service setup (songs, slides, arrangements), and manages a song database. German UI with "Du" form. All actions immediately persistent (auto-save). + +### Interview Summary +**Key Discussions**: +- **CTS API Client**: Use `5pm-HDH/churchtools-api` PHP package (confirmed) +- **Auth**: Only ChurchTools OAuth — no local password login +- **Arrangements**: Manually created in app. Later also from .pro import +- **Info-Slides**: Dynamic per query (expire_date > service_date), not copied into services +- **Translation**: URL to lyrics webpage (scrape) + manual text paste +- **Group Colors**: Freely choosable per group (color picker) +- **Song Preview**: HTML Modal/Overlay with formatted text +- **Download (finalized)**: Placeholder — future ProPresenter show generator tool +- **Deployment**: Docker container +- **Tests**: TDD strategy + +**Research Findings**: +- ChurchTools REST API at `/api/*` with Swagger docs at each instance +- OAuth2 Authorization Code flow, endpoints: `/oauth/authorize`, `/oauth/access_token`, `/oauth/userinfo` +- `5pm-HDH/churchtools-api` v2.1: EventRequest, SongRequest, EventAgendaRequest with fluent filtering +- Intervention Image v3 for image processing (letterbox to 1920×1080) +- LibreOffice headless → PDF → `spatie/pdf-to-image` → JPG for PPT conversion (must be queued) +- `vue-draggable-plus` for arrangement drag-and-drop (clone mode, groups repeat) +- `@jaxtheprime/vue3-dropzone` for file upload zones (drop/preview/edit modes) +- `barryvdh/laravel-dompdf` for PDF generation (no Tailwind in templates — old-school CSS only) +- ProPresenter .pro: Pro6=XML, Pro7=Protobuf. Parser deferred. +- `@vueuse/core` for `useDebounceFn` (auto-save debouncing) + +### Metis Review +**Identified Gaps** (addressed): +- CTS API may not include song lyrics text → Plan includes Wave 0 spike to verify. Songs get lyrics from manual entry or future .pro import. +- `5pm-HDH/churchtools-api` token auth unverified → Spike task verifies `CTConfig::setApiKey()` support +- PPT conversion must be async (queued Laravel Job) → Planned as Job with progress indicator +- DomPDF cannot render Tailwind → Song PDF template uses old-school CSS with `DejaVu Sans` font +- Lyrics URL scraping is fragile → Best-effort HTTP fetch with manual paste as primary fallback +- Arrangement Vue keys must use `${group.id}-${index}` not `group.id` (groups repeat) +- Docker image needs: PHP, LibreOffice, ImageMagick/Imagick, Node.js + +--- + +## Work Objectives + +### Core Objective +Build a complete service preparation tool that reads CTS data, enables song mapping/arrangement/translation, handles multi-format slide uploads, and tracks finalization status — all in German with auto-save and Docker deployment. + +### Concrete Deliverables +- Docker setup (Dockerfile + docker-compose.yml) +- ChurchTools OAuth login (only auth method) +- CTS API sync service with Refresh button + timestamp +- Service list page with status indicators +- Service edit form with 4 blocks (Information, Moderation, Sermon, Songs) +- Song database page with CRUD, arrangement config, translation editor +- File upload pipeline (image/PPT/ZIP processing) +- Song preview modal + PDF download +- Email notification for unmatched songs +- .pro upload/download placeholders (throw NotImplementedException) + +### Definition of Done +- [ ] `docker-compose up` starts working app on localhost +- [ ] Login via ChurchTools OAuth works end-to-end +- [ ] CTS API sync populates services and songs +- [ ] All 4 edit blocks functional with auto-save +- [ ] Song matching, arrangement config, translation all working +- [ ] File uploads convert to 1920×1080 JPGs correctly +- [ ] All tests pass (`php artisan test`) +- [ ] All UI text in German with "Du" form + +### Must Have +- ChurchTools OAuth as sole login method +- READ-ONLY CTS API access (no writes) +- Auto-save (every action immediately persistent) +- German UI with "Du" form throughout +- File upload: JPG 1920×1080 letterbox, PPT→slides, ZIP extraction +- Song matching by CCLI ID +- Arrangement configurator with drag-and-drop +- Info-slides with expire dates shown dynamically in future services +- Docker deployment + +### Must NOT Have (Guardrails) +- NO writes to ChurchTools API — READ ONLY +- NO local password login — OAuth only +- NO .pro file parser implementation (placeholder/exception only) +- NO finalized download generation (placeholder — future tool) +- NO upscaling of small images (letterbox with black bars, never stretch) +- NO Tailwind CSS in DomPDF templates (use old-school CSS + DejaVu Sans) +- NO site-specific lyrics scrapers (best-effort HTTP fetch only) +- NO over-engineered abstractions — keep it practical +- NO English UI text — everything in German with "Du" + +--- + +## Verification Strategy (MANDATORY) + +> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions. + +### Test Decision +- **Infrastructure exists**: YES (Breeze includes Pest/PHPUnit) +- **Automated tests**: TDD — Tests first, then implementation +- **Framework**: Pest (Laravel default with Breeze) +- **Each task**: RED (failing test) → GREEN (minimal impl) → REFACTOR + +### QA Policy +Every task MUST include agent-executed QA scenarios. +Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`. + +- **Frontend/UI**: Use Playwright — Navigate, interact, assert DOM, screenshot +- **API/Backend**: Use Bash (curl/artisan) — Commands, assertions +- **File Processing**: Use Bash — Upload test files, verify output dimensions/format + +--- + +## Execution Strategy + +### Parallel Execution Waves + +``` +Wave 0 (Spike — sequential, blocks everything): +└── Task 0: CTS API spike — verify token auth + package compat + API shape + +Wave 1 (Foundation — 7 parallel tasks): +├── Task 1: Laravel scaffolding + Breeze Vue + Docker [quick] +├── Task 2: Database schema + all migrations [deep] +├── Task 3: ChurchTools OAuth provider (replace Breeze login) [unspecified-high] +├── Task 4: CTS API service + sync command [deep] +├── Task 5: File conversion service (image/PPT/ZIP) [deep] +├── Task 6: Shared Vue components (layout, nav, auto-save) [visual-engineering] +├── Task 7: Email config + Mailable for missing songs [quick] + +Wave 2 (Core Features — 6 parallel tasks): +├── Task 8: Service list page (backend + frontend) [deep] +├── Task 9: Song model + SongDB CRUD backend [unspecified-high] +├── Task 10: Slide upload component (shared reusable) [visual-engineering] +├── Task 11: Arrangement model + configurator component [deep] +├── Task 12: Song matching service (CCLI ID) [unspecified-high] +├── Task 13: Translation service (URL scrape + manual) [unspecified-high] + +Wave 3 (Service Edit + Song UI — 6 parallel tasks): +├── Task 14: Service edit page layout + routing [visual-engineering] +├── Task 15: Information block (slides + expire dates) [visual-engineering] +├── Task 16: Moderation block (slides, service-specific) [quick] +├── Task 17: Sermon block (slides, service-specific) [quick] +├── Task 18: Songs block (matching + arrangement + translation) [deep] +├── Task 19: Song preview modal + PDF download [unspecified-high] + +Wave 4 (Song DB + Finalization — 5 parallel tasks): +├── Task 20: Song DB page (list + search + filters) [visual-engineering] +├── Task 21: Song DB edit popup (metadata + arrangement) [visual-engineering] +├── Task 22: Song DB translate page (two-column editor) [deep] +├── Task 23: Song DB .pro upload + download placeholders [quick] +├── Task 24: Service finalization + status management [unspecified-high] + +Wave FINAL (Verification — 4 parallel): +├── Task F1: Plan compliance audit [oracle] +├── Task F2: Code quality review [unspecified-high] +├── Task F3: Real manual QA with Playwright [unspecified-high] +└── Task F4: Scope fidelity check [deep] + +Critical Path: T0 → T1 → T4 → T8 → T14/T18 → T24 → FINAL +Parallel Speedup: ~65% faster than sequential +Max Concurrent: 7 (Wave 1) +``` + +### Dependency Matrix + +| Task | Depends On | Blocks | Wave | +|------|-----------|--------|------| +| T0 | — | ALL | 0 | +| T1 | T0 | T2-T7 | 1 | +| T2 | T1 | T8-T13 | 1 | +| T3 | T1 | T8 | 1 | +| T4 | T1 | T8, T12 | 1 | +| T5 | T1 | T10, T15-T17 | 1 | +| T6 | T1 | T8, T14-T18, T20 | 1 | +| T7 | T1 | T12 | 1 | +| T8 | T2, T3, T4, T6 | T14 | 2 | +| T9 | T2 | T11, T12, T18-T23 | 2 | +| T10 | T2, T5, T6 | T15-T17 | 2 | +| T11 | T2, T9 | T18, T21 | 2 | +| T12 | T2, T4, T7, T9 | T18 | 2 | +| T13 | T2, T9 | T22 | 2 | +| T14 | T8, T6 | T15-T19 | 3 | +| T15 | T10, T14 | T24 | 3 | +| T16 | T10, T14 | T24 | 3 | +| T17 | T10, T14 | T24 | 3 | +| T18 | T11, T12, T14 | T24 | 3 | +| T19 | T9, T11 | — | 3 | +| T20 | T9, T6 | — | 4 | +| T21 | T9, T11 | — | 4 | +| T22 | T13, T9 | — | 4 | +| T23 | T9 | — | 4 | +| T24 | T15-T18 | FINAL | 4 | +| F1-F4 | ALL | — | FINAL | + +### Agent Dispatch Summary + +- **Wave 0**: **1** — T0 → `deep` +- **Wave 1**: **7** — T1 → `quick`, T2 → `deep`, T3 → `unspecified-high`, T4 → `deep`, T5 → `deep`, T6 → `visual-engineering`, T7 → `quick` +- **Wave 2**: **6** — T8 → `deep`, T9 → `unspecified-high`, T10 → `visual-engineering`, T11 → `deep`, T12 → `unspecified-high`, T13 → `unspecified-high` +- **Wave 3**: **6** — T14 → `visual-engineering`, T15 → `visual-engineering`, T16 → `quick`, T17 → `quick`, T18 → `deep`, T19 → `unspecified-high` +- **Wave 4**: **5** — T20 → `visual-engineering`, T21 → `visual-engineering`, T22 → `deep`, T23 → `quick`, T24 → `unspecified-high` +- **FINAL**: **4** — F1 → `oracle`, F2 → `unspecified-high`, F3 → `unspecified-high`, F4 → `deep` + +--- + +## TODOs + +> TDD = RED → GREEN → REFACTOR for every task. +> EVERY task MUST have: Agent Profile + QA Scenarios. +> ALL UI text in German with "Du" form. + +### Wave 0: API Spike + +- [x] 0. CTS API Spike — Verify Token Auth + API Shape + + **What to do**: + - TEST: Write Pest test that creates a mock CTS API response and verifies the sync pipeline + - Install `5pm-HDH/churchtools-api` via composer + - Verify token-based auth works: `CTConfig::setApiUrl()` + `CTConfig::setApiKey($token)` from `CTS_API_TOKEN` env var + - If `setApiKey` doesn't exist, check `authWithLoginToken()` or similar methods in CTConfig + - Hit `GET /api/events` with date filter (today+future) and capture response shape + - Hit `GET /api/songs/1` (any song) and check if response includes lyrics text, arrangement data + - Download OpenAPI spec from `/system/runtime/swagger/openapi.json` and save to `docs/churchtools-openapi.json` for reference + - Document findings: which fields exist, what data shapes we get, confirm CCLI field presence on songs + - If package doesn't support token auth: document workaround (raw HTTP with `Authorization: Login TOKEN` header) + + **Must NOT do**: + - Do NOT write to any CTS API endpoint + - Do NOT commit actual API token to git + + **Recommended Agent Profile**: + - **Category**: `deep` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: NO + - **Parallel Group**: Wave 0 (sequential, blocks everything) + - **Blocks**: T1-T24 (all tasks) + - **Blocked By**: None + + **References**: + - `5pm-HDH/churchtools-api` docs: https://github.com/5pm-HDH/churchtools-api/blob/master/docs/out/CTConfig.md + - `5pm-HDH/churchtools-api` EventAPI: https://github.com/5pm-HDH/churchtools-api/blob/master/docs/out/EventAPI.md + - `5pm-HDH/churchtools-api` SongAPI: https://github.com/5pm-HDH/churchtools-api/blob/master/docs/out/SongAPI.md + - `.env.example` — contains `CTS_API_TOKEN=XXXXXX` + - CTS API docs at instance: `https://INSTANCE.church.tools/api` (Swagger UI) + + **Acceptance Criteria**: + - [ ] `5pm-HDH/churchtools-api` installed and configured with token from `.env` + - [ ] `php artisan test --filter=CtsApiSpikeTest` → PASS + - [ ] API response shapes documented in `docs/api-response-shapes.md` + - [ ] Confirmed: songs have/don't have lyrics text, CCLI field present + - [ ] Auth method documented (setApiKey vs authWithLoginToken vs raw HTTP) + + **QA Scenarios:** + ``` + Scenario: Verify CTS API authentication + Tool: Bash + Preconditions: .env has valid CTS_API_TOKEN and CTS_API_URL + Steps: + 1. Run: php artisan tinker --execute="CTApi\CTConfig::setApiUrl(env('CTS_API_URL')); CTApi\CTConfig::setApiKey(env('CTS_API_TOKEN')); dump(CTApi\Models\Groups\Person\PersonRequest::whoami()->getFirstName());" + 2. Assert: output contains a first name string (not null/error) + 3. Run: php artisan tinker --execute="dump(CTApi\Models\Events\Event\EventRequest::where('from', now()->format('Y-m-d'))->get()->count());" + 4. Assert: output is a number >= 0 + Expected Result: Both commands succeed without authentication errors + Evidence: .sisyphus/evidence/task-0-api-auth.txt + + Scenario: Verify song data includes CCLI + Tool: Bash + Preconditions: CTS API authenticated + Steps: + 1. Run: php artisan tinker to fetch first song and dump ccli field + 2. Assert: song object has getCcli() method that returns a value + Expected Result: CCLI field accessible on song model + Evidence: .sisyphus/evidence/task-0-song-ccli.txt + ``` + + **Commit**: YES + - Message: `chore: verify CTS API token auth and package compatibility` + - Files: `composer.json`, `composer.lock`, `docs/api-response-shapes.md` + - Pre-commit: `php artisan test --filter=CtsApiSpikeTest` + +### Wave 1: Foundation (7 parallel tasks) + +- [x] 1. Laravel Scaffolding + Breeze Vue + Docker + + **What to do**: + - TEST: Write Pest test that verifies the home route returns Inertia response + - Run `laravel new cts --breeze --stack=vue --pest --database=sqlite` (or equivalent composer commands) + - Configure `.env` with all needed vars: `CTS_API_URL`, `CTS_API_TOKEN`, `CHURCHTOOLS_URL`, `CHURCHTOOLS_CLIENT_ID`, `CHURCHTOOLS_REDIRECT_URI`, `MAIL_*` + - Update `.env.example` with all new vars (placeholders) + - Create `Dockerfile` for PHP 8.3 + required extensions (imagick, zip, pdo_sqlite, pdo_mysql) + - Install LibreOffice headless + ImageMagick in Docker image + - Create `docker-compose.yml` with app + node (for Vite) services + - Configure `vite.config.js` for Docker hot-reload + - Verify `docker-compose up` starts app successfully + - Set app locale to `de` in `config/app.php` + - Add `@vueuse/core`, `vue-draggable-plus`, `@jaxtheprime/vue3-dropzone` to package.json + + **Must NOT do**: + - Do NOT keep Breeze default login/register pages (will be replaced in T3) + - Do NOT add application-specific routes yet + + **Recommended Agent Profile**: + - **Category**: `quick` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: NO (first in Wave 1, all others depend on it) + - **Parallel Group**: Wave 1 (runs first, others start after) + - **Blocks**: T2-T7 + - **Blocked By**: T0 + + **References**: + - Laravel 11 docs: https://laravel.com/docs/11.x/installation + - Breeze docs: https://laravel.com/docs/11.x/starter-kits#laravel-breeze + - Intervention Image v3: `composer require intervention/image` + - `spatie/pdf-to-image`: `composer require spatie/pdf-to-image` + + **Acceptance Criteria**: + - [ ] `docker-compose up -d` → containers start without errors + - [ ] `http://localhost:8000` returns HTTP response + - [ ] `npm run build` completes without errors + - [ ] `php artisan test` → default Breeze tests pass + - [ ] `.env.example` contains all project-specific vars + + **QA Scenarios:** + ``` + Scenario: Docker containers start successfully + Tool: Bash + Preconditions: Docker installed, docker-compose.yml present + Steps: + 1. Run: docker-compose up -d + 2. Run: docker-compose ps + 3. Assert: all containers show 'Up' or 'healthy' status + 4. Run: curl -s -o /dev/null -w '%{http_code}' http://localhost:8000 + 5. Assert: HTTP status is 200 or 302 + Expected Result: All containers running, app reachable + Evidence: .sisyphus/evidence/task-1-docker-up.txt + + Scenario: Vite build succeeds + Tool: Bash + Preconditions: npm dependencies installed + Steps: + 1. Run: docker-compose exec app npm run build + 2. Assert: exit code 0, no errors in output + Expected Result: Build completes, assets generated in public/build + Evidence: .sisyphus/evidence/task-1-vite-build.txt + ``` + + **Commit**: YES + - Message: `feat: scaffold Laravel + Breeze Vue + Docker setup` + - Files: entire project scaffolding + - Pre-commit: `php artisan test` + +- [x] 2. Database Schema + All Migrations + + **What to do**: + - TEST: Write Pest test that verifies all tables exist after migration + - Create migrations for ALL tables in this order: + - `users` (extend: add churchtools_id, avatar, churchtools_groups, churchtools_roles columns) + - `services` (cts_event_id, title, date, preacher_name, beamer_tech_name, finalized_at, last_synced_at, cts_data JSON) + - `songs` (ccli_id unique nullable, title, author, copyright_text, copyright_year, publisher, has_translation bool, deleted_at soft-delete, last_used_at) + - `song_groups` (song_id FK, name, color hex, order int) + - `song_slides` (song_group_id FK, order int, text_content, text_content_translated nullable, notes nullable) + - `song_arrangements` (song_id FK, name, is_default bool) + - `song_arrangement_groups` (song_arrangement_id FK, song_group_id FK, order int) + - `service_songs` (service_id FK, song_id FK nullable, song_arrangement_id FK nullable, use_translation bool default false, order int, cts_song_name, cts_ccli_id nullable, matched_at nullable, request_sent_at nullable) + - `slides` (type enum[information|moderation|sermon], service_id FK nullable, original_filename, stored_filename, thumbnail_filename, expire_date nullable, uploader_name nullable, uploaded_at, deleted_at soft-delete) + - `cts_sync_log` (synced_at, events_count, songs_count, status, error nullable) + - Create Eloquent models with relationships for all tables + - Add factory classes for testing (Song, Service, SongGroup, etc.) + + **Must NOT do**: + - Do NOT add business logic to models (just relationships and casts) + - Do NOT create controllers or routes + + **Recommended Agent Profile**: + - **Category**: `deep` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (after T1) + - **Parallel Group**: Wave 1 (with T3-T7) + - **Blocks**: T8-T13 (all Wave 2 tasks) + - **Blocked By**: T1 + + **References**: + - ProPresenter schema research: songs → song_groups → song_slides hierarchy + - AGENTS.md: service list fields (Title, Preacher, beamer technician, qty of songs) + - AGENTS.md: Song block fields (CCLI ID, has Translation, arrangement) + - AGENTS.md: Information block (expire date, uploader name) + + **Acceptance Criteria**: + - [ ] `php artisan migrate:fresh` → all migrations run without errors + - [ ] `php artisan test --filter=DatabaseSchemaTest` → PASS (all tables exist with correct columns) + - [ ] All Eloquent models have correct relationships defined + - [ ] Factory classes generate valid test data + + **QA Scenarios:** + ``` + Scenario: All migrations run successfully + Tool: Bash + Steps: + 1. Run: php artisan migrate:fresh --force + 2. Assert: exit code 0, output shows all migrations ran + 3. Run: php artisan tinker --execute="dump(Schema::getTableListing());" + 4. Assert: output includes users, services, songs, song_groups, song_slides, song_arrangements, song_arrangement_groups, service_songs, slides, cts_sync_log + Expected Result: 10+ tables created + Evidence: .sisyphus/evidence/task-2-migrations.txt + ``` + + **Commit**: YES + - Message: `feat: add database schema for services, songs, arrangements, slides` + - Files: `database/migrations/*.php`, `app/Models/*.php`, `database/factories/*.php` + - Pre-commit: `php artisan test` + +- [x] 3. ChurchTools OAuth Provider (Replace Breeze Login) + + **What to do**: + - TEST: Write Pest test that mocks Socialite driver and verifies user creation from OAuth data + - Install `laravel/socialite` + - Create `App\Socialite\ChurchToolsProvider` extending `AbstractProvider` with: + - `getAuthUrl()` → `{CTS_URL}/oauth/authorize` + - `getTokenUrl()` → `{CTS_URL}/oauth/access_token` + - `getUserByToken()` → `GET {CTS_URL}/oauth/userinfo` + - `mapUserToObject()` → maps id, displayName, email, imageUrl, groups, roles + - Register provider in `AppServiceProvider::boot()` via `Socialite::extend('churchtools', ...)` + - Add `config/services.php` entry for `churchtools` (url, client_id, client_secret, redirect) + - Create `AuthController` with `redirect()` and `callback()` methods + - `callback()`: find-or-create User by email, store churchtools_id, avatar, groups, roles + - Add routes: `GET /auth/churchtools` (redirect), `GET /auth/churchtools/callback` + - Remove Breeze login/register pages, replace with single "Mit ChurchTools anmelden" button + - Create simple `Login.vue` page with the OAuth button + - Add logout route that clears session and redirects to login + - Protect all routes with `auth` middleware except login routes + - Update `.env.example` with `CHURCHTOOLS_URL`, `CHURCHTOOLS_CLIENT_ID`, `CHURCHTOOLS_CLIENT_SECRET`, `CHURCHTOOLS_REDIRECT_URI` + + **Must NOT do**: + - Do NOT keep local email/password registration or login + - Do NOT store ChurchTools access token (we use our own API token for API calls) + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (after T1) + - **Parallel Group**: Wave 1 (with T2, T4-T7) + - **Blocks**: T8 + - **Blocked By**: T1 + + **References**: + - `devdot/churchtools-oauth2-client` source: https://github.com/devdot/churchtools-oauth2-client — reference for endpoint URLs and user mapping + - Laravel Socialite docs: https://laravel.com/docs/11.x/socialite + - ChurchTools OAuth flow: `/oauth/authorize` → `/oauth/access_token` → `/oauth/userinfo` + - User data shape: `{ id, firstName, lastName, displayName, email, imageUrl, groups, roles }` + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=OAuthTest` → PASS + - [ ] Visiting `/` unauthenticated → redirect to login page + - [ ] Login page shows only "Mit ChurchTools anmelden" button (no email/password form) + - [ ] After OAuth callback → user created in DB, redirected to dashboard + - [ ] No Breeze login/register routes remain + + **QA Scenarios:** + ``` + Scenario: Unauthenticated user sees login page + Tool: Playwright + Steps: + 1. Navigate to http://localhost:8000/ + 2. Assert: page redirects to /login or shows login page + 3. Assert: page contains button or link with text 'Mit ChurchTools anmelden' + 4. Assert: NO email input field exists on the page + 5. Assert: NO password input field exists on the page + Expected Result: Clean OAuth-only login page in German + Evidence: .sisyphus/evidence/task-3-login-page.png + + Scenario: OAuth redirect works + Tool: Bash + Steps: + 1. Run: curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://localhost:8000/auth/churchtools + 2. Assert: HTTP 302 redirect to churchtools.church.tools/oauth/authorize + Expected Result: Redirect to ChurchTools OAuth authorize endpoint + Evidence: .sisyphus/evidence/task-3-oauth-redirect.txt + ``` + + **Commit**: YES + - Message: `feat: implement ChurchTools OAuth login via Socialite` + - Files: `app/Socialite/ChurchToolsProvider.php`, `app/Http/Controllers/AuthController.php`, `resources/js/Pages/Auth/Login.vue`, routes + - Pre-commit: `php artisan test` + +- [x] 4. CTS API Service + Sync Command + + **What to do**: + - TEST: Write Pest tests that mock API responses and verify sync creates correct DB records + - Create `App\Services\ChurchToolsService` that wraps `5pm-HDH/churchtools-api`: + - `syncEvents()`: fetch events from today forward, upsert into `services` table + - `syncSongs()`: fetch all songs, upsert basic metadata into local reference + - `syncAgenda($eventId)`: fetch agenda for event, create/update `service_songs` + - `getEventServices($eventId)`: get assigned people (preacher, beamer tech) + - Create `App\Console\Commands\SyncChurchToolsCommand` (`php artisan cts:sync`) + - Store sync timestamp in `cts_sync_log` table + - When syncing songs to services: try to match `cts_ccli_id` to existing `songs.ccli_id` + - If matched: set `service_songs.song_id`, set `matched_at` + - If not matched: leave `song_id` null (UI will show matching options later) + - Create `App\Http\Controllers\SyncController` with `sync()` action for the refresh button + - Sync action returns Inertia redirect with flash message: "Daten wurden aktualisiert" or error + + **Must NOT do**: + - Do NOT write/update anything on the CTS API + - Do NOT sync historical events (only today + future) + - Do NOT delete local songs when they're removed from CTS + + **Recommended Agent Profile**: + - **Category**: `deep` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (after T1) + - **Parallel Group**: Wave 1 (with T2, T3, T5-T7) + - **Blocks**: T8, T12 + - **Blocked By**: T1 (needs T0 findings for auth method) + + **References**: + - T0 findings: `docs/api-response-shapes.md` — confirmed API response structures + - `5pm-HDH/churchtools-api` EventAPI: `EventRequest::where('from', date)->get()` + - `5pm-HDH/churchtools-api` SongAPI: `SongRequest::all()`, `$song->getCcli()` + - `5pm-HDH/churchtools-api` EventAgendaRequest: `EventAgendaRequest::fromEvent($id)->get()` + - AGENTS.md: service list needs title, preacher, beamer tech, song count + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=ChurchToolsSyncTest` → PASS + - [ ] `php artisan cts:sync` → populates services and service_songs tables + - [ ] Sync log entry created with count and status + - [ ] CCLI matching works: matched songs have `song_id` set + - [ ] Unmatched songs have `song_id = null`, `cts_ccli_id` preserved + + **QA Scenarios:** + ``` + Scenario: Sync command populates database + Tool: Bash + Steps: + 1. Run: php artisan migrate:fresh --force + 2. Run: php artisan cts:sync + 3. Assert: exit code 0, output shows 'Sync abgeschlossen' + 4. Run: php artisan tinker --execute="dump(App\Models\Service::count());" + 5. Assert: count > 0 + 6. Run: php artisan tinker --execute="dump(App\Models\CtsSync Log::latest()->first()->status);" + 7. Assert: status is 'success' + Expected Result: Services populated from CTS API + Evidence: .sisyphus/evidence/task-4-sync-command.txt + ``` + + **Commit**: YES + - Message: `feat: add CTS API sync service and artisan command` + - Files: `app/Services/ChurchToolsService.php`, `app/Console/Commands/SyncChurchToolsCommand.php`, `app/Http/Controllers/SyncController.php` + - Pre-commit: `php artisan test` + +- [x] 5. File Conversion Service (Image/PPT/ZIP) + + **What to do**: + - TEST: Write Pest tests with sample image (400×300 PNG) and verify output is 1920×1080 JPG + - Create `App\Services\FileConversionService` with methods: + - `convertImage($file): array` — letterbox to 1920×1080 JPG (black bars, no crop, no upscale-stretch), return `['filename', 'thumbnail']` + - `convertPowerPoint($file): array` — LibreOffice headless → PDF → spatie/pdf-to-image → individual JPGs → letterbox each. Return array of slide data. + - `processZip($file): array` — extract ZIP, recursively process each file (images, PPTs, nested ZIPs) + - `generateThumbnail($path): string` — create 320×180 thumbnail + - PPT conversion MUST be a queued Laravel Job (`App\Jobs\ConvertPowerPointJob`) — NOT synchronous + - Job dispatches events for progress tracking (`PowerPointConversionProgress`) + - Image conversion uses Intervention Image v3: create 1920×1080 canvas → fill black → scale image to fit → place centered + - Handle edge cases: portrait images get pillarbox, square images get bars all around + - Store converted files in `storage/app/public/slides/` and thumbnails in `storage/app/public/slides/thumbnails/` + - Validate: only accept png, jpg, jpeg, ppt, pptx, zip file types + + **Must NOT do**: + - Do NOT upscale small images — always letterbox with black bars + - Do NOT crop any part of an image + - Do NOT process PPT synchronously (must be queued) + + **Recommended Agent Profile**: + - **Category**: `deep` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (after T1) + - **Parallel Group**: Wave 1 (with T2-T4, T6-T7) + - **Blocks**: T10, T15-T17 + - **Blocked By**: T1 + + **References**: + - Intervention Image v3 docs: `Image::create(1920, 1080)->fill('000000')`, `$image->scale()`, `$canvas->place($image, 'center')` + - `spatie/pdf-to-image` docs: `(new Pdf($path))->setPage($n)->saveImage($out)` + - LibreOffice headless: `soffice --headless --convert-to pdf --outdir $dir $input` + - Docker: LibreOffice path is `/usr/bin/libreoffice` in container + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=FileConversionTest` → PASS + - [ ] 400×300 PNG input → 1920×1080 JPG output with black letterbox + - [ ] 1080×1920 portrait PNG → 1920×1080 JPG with black pillarbox + - [ ] Thumbnail generated at 320×180 + - [ ] PPT conversion dispatches job, job processes to JPGs + + **QA Scenarios:** + ``` + Scenario: Image conversion with letterbox + Tool: Bash + Steps: + 1. Create test 400x300 PNG: convert -size 400x300 xc:red /tmp/test.png + 2. Run conversion via tinker or test endpoint + 3. Assert: output file is JPG + 4. Run: identify output.jpg (ImageMagick) + 5. Assert: dimensions are exactly 1920x1080 + Expected Result: Letterboxed JPG at 1920x1080 + Evidence: .sisyphus/evidence/task-5-letterbox.txt + + Scenario: Portrait image gets pillarbox + Tool: Bash + Steps: + 1. Create test 600x1200 PNG (portrait) + 2. Run conversion + 3. Assert: output is 1920x1080 with black bars left/right + Expected Result: Pillarboxed portrait image + Evidence: .sisyphus/evidence/task-5-pillarbox.txt + ``` + + **Commit**: YES + - Message: `feat: add file conversion service (image, PPT, ZIP)` + - Files: `app/Services/FileConversionService.php`, `app/Jobs/ConvertPowerPointJob.php`, tests + - Pre-commit: `php artisan test` + +- [x] 6. Shared Vue Components (Layout, Nav, Auto-Save) + + **What to do**: + - TEST: Write Pest test that verifies shared Inertia data includes auth user and last_synced_at + - Create `AuthenticatedLayout.vue` with: + - Top bar showing logged-in user name + avatar (from `$page.props.auth.user`) + - "Daten aktualisieren" refresh button that calls `POST /sync` and shows loading spinner + - Timestamp "Zuletzt aktualisiert: {date}" from last sync log + - Navigation: "Services" link, "Song-Datenbank" link + - Logout button/link + - Update `HandleInertiaRequests` middleware to share: `auth.user`, `flash`, `last_synced_at`, `app_name` + - Create `AutoSaveForm` composable using `@vueuse/core` `useDebounceFn`: + - Text inputs: debounce 500ms + - Selects/checkboxes: immediate save + - Uses Inertia `router.put/post` with `preserveScroll: true, preserveState: true` + - Shows subtle save indicator ("Gespeichert" / "Speichert...") + - Create shared components: `FlashMessage.vue`, `ConfirmDialog.vue`, `LoadingSpinner.vue` + - All text in German with "Du" form + + **Must NOT do**: + - Do NOT create page-specific components here (only shared/reusable) + - Do NOT add business logic to layout + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - **Skills**: [`frontend-ui-ux`] + + **Parallelization**: + - **Can Run In Parallel**: YES (after T1) + - **Parallel Group**: Wave 1 (with T2-T5, T7) + - **Blocks**: T8, T14-T18, T20 + - **Blocked By**: T1 + + **References**: + - Breeze `AuthenticatedLayout.vue` — extend this pattern + - `usePage()` from `@inertiajs/vue3` for accessing shared props + - `useDebounceFn` from `@vueuse/core` for auto-save debouncing + - AGENTS.md: "Button in Top Bar to refresh Data" + "timestamp with latest refresh" + "LoggedIn User visible" + + **Acceptance Criteria**: + - [ ] Layout shows user name and avatar in top bar + - [ ] Refresh button triggers sync and updates timestamp + - [ ] Navigation links to Services and Song-Datenbank + - [ ] Auto-save composable debounces text input at 500ms + - [ ] All text in German + + **QA Scenarios:** + ``` + Scenario: Top bar shows user info and navigation + Tool: Playwright + Steps: + 1. Login via OAuth (or mock auth state) + 2. Assert: top bar contains user display name + 3. Assert: top bar contains 'Daten aktualisieren' button + 4. Assert: top bar contains 'Zuletzt aktualisiert:' timestamp + 5. Assert: navigation contains 'Services' link + 6. Assert: navigation contains 'Song-Datenbank' link + Expected Result: Complete German top bar with all elements + Evidence: .sisyphus/evidence/task-6-topbar.png + ``` + + **Commit**: YES + - Message: `feat: create shared Vue layout with nav, user, refresh button` + - Files: `resources/js/Layouts/AuthenticatedLayout.vue`, `resources/js/Composables/useAutoSave.js`, shared components + - Pre-commit: `php artisan test` + +- [x] 7. Email Configuration + Missing Song Mailable + + **What to do**: + - TEST: Write Pest test that verifies MissingSongNotification mailable renders correct content + - Configure Laravel mail in `config/mail.php` (already done by default, uses MAIL_* env vars) + - Add to `.env.example`: `MAIL_MAILER=smtp`, `MAIL_HOST`, `MAIL_PORT`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM_ADDRESS`, `SONG_REQUEST_EMAIL` (recipient for missing song requests) + - Create `App\Mail\MissingSongRequest` Mailable: + - Subject: "Song-Anfrage: {songName} (CCLI: {ccliId})" + - Body: German text explaining which song is needed, which service it's for, CCLI ID, and link to service in the app + - Create `App\Notifications\MissingSongNotification` as alternative (using Mail channel) + - Add `SONG_REQUEST_EMAIL` to config/services.php for easy access + + **Must NOT do**: + - Do NOT send emails automatically — only when user clicks "Erstellung anfragen" button + - Do NOT include sensitive data in emails + + **Recommended Agent Profile**: + - **Category**: `quick` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES (after T1) + - **Parallel Group**: Wave 1 (with T2-T6) + - **Blocks**: T12 + - **Blocked By**: T1 + + **References**: + - Laravel Mail docs: https://laravel.com/docs/11.x/mail + - AGENTS.md: "button 'request creation' which causes an EMAIL to a configured mail address with the song and the CCLI Id" + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=MissingSongMailTest` → PASS + - [ ] Mailable renders German text with song name and CCLI ID + - [ ] `SONG_REQUEST_EMAIL` configurable via .env + + **QA Scenarios:** + ``` + Scenario: Missing song email renders correctly + Tool: Bash + Steps: + 1. Run: php artisan tinker to render MissingSongRequest mailable with test data + 2. Assert: subject contains 'Song-Anfrage' + 3. Assert: body contains CCLI ID + 4. Assert: body is in German + Expected Result: Well-formatted German email + Evidence: .sisyphus/evidence/task-7-email-render.txt + ``` + + **Commit**: YES + - Message: `feat: configure email and missing-song notification mailable` + - Files: `app/Mail/MissingSongRequest.php`, `resources/views/mail/missing-song.blade.php` + - Pre-commit: `php artisan test` + +### Wave 2: Core Features (6 parallel tasks) + +- [x] 8. Service List Page (Backend + Frontend) + + **What to do**: + - TEST: Write Pest tests for ServiceController index endpoint — verify it returns Inertia page with services, filters by date >= today, includes status counts + - Create `App\Http\Controllers\ServiceController` with `index()` method: + - Query services where `date >= today`, order by date ascending + - Include computed status fields: `songs_mapped` (x/y), `songs_arranged` (x/y), `has_sermon_slides`, `info_slides_count`, `finalized_at` + - Return Inertia render `Services/Index` with services collection + - Create `resources/js/Pages/Services/Index.vue`: + - Table/list showing: Titel, Prediger, Beamer-Techniker, Anzahl Songs, Letzte Änderung, Status + - Status indicators: "x/y Songs zugeordnet", "x/y Arrangements geprüft", "Predigtfolien", "Infofolien", "Abgeschlossen am" + - Color coding: green checkmark for complete, red/orange for incomplete + - Action buttons per service: + - If NOT finalized: "Bearbeiten" (link to edit page) + "Abschließen" button + - If finalized: "Wieder öffnen" + "Herunterladen" (placeholder — shows coming soon toast) + - Wire up Finalize/ReOpen as `POST /services/{id}/finalize` and `POST /services/{id}/reopen` + - Auto-refresh list after sync + + **Must NOT do**: + - Do NOT show past services (only today and future) + - Do NOT implement download content (placeholder only) + - Do NOT inline-edit services from the list page + + **Recommended Agent Profile**: + - **Category**: `deep` + - **Skills**: [`frontend-ui-ux`] + - `frontend-ui-ux`: Table/list layout with status indicators requires good UI judgment + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 2 (with T9-T13) + - **Blocks**: T14 + - **Blocked By**: T2, T3, T4, T6 + + **References**: + - `app/Models/Service.php` — Service model with relationships (from T2) + - `app/Models/ServiceSong.php` — for song mapping status counts + - `app/Models/Slide.php` — for slide counts + - `resources/js/Layouts/AuthenticatedLayout.vue` — layout wrapper (from T6) + - AGENTS.md lines 29-45: Service list fields and action buttons specification + - Pattern: follow Breeze `Dashboard.vue` for Inertia page structure + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=ServiceControllerTest` → PASS + - [ ] Service list page renders with correct columns + - [ ] Only today + future services shown + - [ ] Finalize/ReOpen buttons toggle `finalized_at` field + - [ ] Status indicators show correct x/y counts + - [ ] All text in German with "Du" form + + **QA Scenarios:** + ``` + Scenario: Service list shows upcoming services with status + Tool: Playwright + Preconditions: Database seeded with 3 future services (1 with mapped songs, 1 without, 1 finalized) + Steps: + 1. Navigate to http://localhost:8000/services + 2. Assert: page contains table/list with 3 service entries + 3. Assert: first service row contains 'Prediger' column with preacher name + 4. Assert: service with mapped songs shows green indicator or 'x/y' text + 5. Assert: finalized service shows 'Wieder öffnen' button + 6. Assert: non-finalized service shows 'Bearbeiten' and 'Abschließen' buttons + Expected Result: Service list with correct German labels and status indicators + Evidence: .sisyphus/evidence/task-8-service-list.png + + Scenario: Finalize and reopen service + Tool: Playwright + Preconditions: At least 1 non-finalized service in DB + Steps: + 1. Navigate to http://localhost:8000/services + 2. Click 'Abschließen' button on first non-finalized service + 3. Assert: service now shows 'Wieder öffnen' button instead + 4. Assert: 'Abgeschlossen am' shows current date + 5. Click 'Wieder öffnen' + 6. Assert: service returns to non-finalized state with 'Bearbeiten' button + Expected Result: Toggle finalization status works both ways + Evidence: .sisyphus/evidence/task-8-finalize-toggle.png + ``` + + **Commit**: YES + - Message: `feat: add service list page with status indicators and finalization` + - Files: `app/Http/Controllers/ServiceController.php`, `resources/js/Pages/Services/Index.vue`, routes + - Pre-commit: `php artisan test` + +- [x] 9. Song Model + SongDB CRUD Backend + + **What to do**: + - TEST: Write Pest tests for SongController — CRUD endpoints, soft delete, search by CCLI, search by name + - Create `App\Http\Controllers\SongController` with full resource methods: + - `index()`: list all songs (with soft-deleted excluded), searchable by name and CCLI ID, paginated + - `store()`: create new song with metadata (title, ccli_id, author, copyright_text) + - `show($id)`: return song with groups, slides, arrangements + - `update($id)`: update song metadata + - `destroy($id)`: soft-delete + - Create `App\Http\Requests\SongRequest` for validation (title required, ccli_id unique) + - Create `App\Services\SongService` for business logic: + - `createDefaultGroups($song)`: create default groups (Strophe 1, Refrain, Bridge, etc.) if none exist + - `createDefaultArrangement($song)`: create "Normal" arrangement referencing all groups in order + - `duplicateArrangement($arrangement, $name)`: clone arrangement with new name + - Add Song model accessors: `last_used_in_service` (computed from service_songs join) + - Add SongGroup model: color field with default palette + - Create API routes for JSON responses (used by Vue components via fetch/axios) + + **Must NOT do**: + - Do NOT create Vue pages here (only backend API + controller) + - Do NOT hard-delete songs (always soft-delete) + - Do NOT auto-create songs from CTS API (songs are manually created or imported) + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 2 (with T8, T10-T13) + - **Blocks**: T11, T12, T18-T23 + - **Blocked By**: T2 + + **References**: + - `app/Models/Song.php`, `app/Models/SongGroup.php`, `app/Models/SongSlide.php` — models from T2 + - `app/Models/SongArrangement.php`, `app/Models/SongArrangementGroup.php` — arrangement models from T2 + - AGENTS.md lines 89-97: SongDB page specification (CRUD, edit, download, translate, upload) + - AGENTS.md lines 66-80: Song block fields (CCLI ID, arrangement, translation, preview/download) + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=SongControllerTest` → PASS + - [ ] CRUD endpoints work: create, read, update, soft-delete + - [ ] Search by name returns matching songs + - [ ] Search by CCLI ID returns matching songs + - [ ] Default arrangement "Normal" created with new song + - [ ] `last_used_in_service` accessor returns correct date + + **QA Scenarios:** + ``` + Scenario: Create and retrieve a song via API + Tool: Bash (curl) + Preconditions: Authenticated session cookie + Steps: + 1. POST /api/songs with body: {"title": "Großer Gott wir loben dich", "ccli_id": "12345", "author": "Test"} + 2. Assert: HTTP 201, response contains song with id + 3. GET /api/songs/{id} + 4. Assert: response contains title, ccli_id, groups array, arrangements array + 5. Assert: arrangements contains at least one entry named "Normal" + Expected Result: Song created with default arrangement + Evidence: .sisyphus/evidence/task-9-song-crud.txt + + Scenario: Search songs by CCLI ID + Tool: Bash (curl) + Steps: + 1. GET /api/songs?search=12345 + 2. Assert: response contains the song with ccli_id=12345 + 3. GET /api/songs?search=99999 + 4. Assert: response is empty array + Expected Result: CCLI search works correctly + Evidence: .sisyphus/evidence/task-9-song-search.txt + ``` + + **Commit**: YES + - Message: `feat: add Song CRUD controller with search and default arrangements` + - Files: `app/Http/Controllers/SongController.php`, `app/Services/SongService.php`, `app/Http/Requests/SongRequest.php`, routes + - Pre-commit: `php artisan test` + +- [x] 10. Slide Upload Component (Shared Reusable) + + **What to do**: + - TEST: Write Pest tests for SlideController — upload image, upload ZIP, verify conversion, verify thumbnail generation + - Create `App\Http\Controllers\SlideController` with: + - `store(Request $request)`: accept file upload, determine type (image/ppt/zip), dispatch conversion + - `destroy($id)`: soft-delete slide + - `updateExpireDate($id, Request $request)`: update expire_date for info slides + - Create reusable Vue component `resources/js/Components/SlideUploader.vue`: + - Uses `@jaxtheprime/vue3-dropzone` for drag-and-drop upload area + - Big "+" icon / dotted border area for drag-and-drop or click-to-upload + - Accepts: png, jpg, jpeg, ppt, pptx, zip + - Shows upload progress bar + - Props: `type` (information|moderation|sermon), `serviceId` (nullable), `showExpireDate` (bool) + - If `showExpireDate=true`: show datepicker for expire date applied to all uploaded files + - After upload: emit event to refresh parent's slide list + - Create `resources/js/Components/SlideGrid.vue`: + - Grid of thumbnails for uploaded slides + - Each thumbnail shows: image preview, upload date (muted), uploader name (muted) + - If type=information: prominent expire date field with inline datepicker for editing + - Delete button (soft-delete with confirmation) + - For PPT uploads: show progress indicator while job is processing + - Use Inertia `router.post` with FormData for file uploads + - Handle PPT async: poll for job completion, then refresh slide grid + + **Must NOT do**: + - Do NOT process PPT synchronously in the request (use job from T5) + - Do NOT allow file types outside png/jpg/jpeg/ppt/pptx/zip + - Do NOT create page-level components (these are reusable building blocks) + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - **Skills**: [`frontend-ui-ux`] + - `frontend-ui-ux`: Upload area and thumbnail grid need polished UI + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 2 (with T8-T9, T11-T13) + - **Blocks**: T15, T16, T17 + - **Blocked By**: T2, T5, T6 + + **References**: + - `app/Services/FileConversionService.php` — conversion logic from T5 + - `app/Jobs/ConvertPowerPointJob.php` — async PPT processing from T5 + - `app/Models/Slide.php` — slide model from T2 + - `@jaxtheprime/vue3-dropzone` docs: https://github.com/jaxtheprime/vue3-dropzone + - AGENTS.md lines 51-54: "big plus icon/area for drag'n'drop", "thumbnails with upload date, uploader name, expire date" + - AGENTS.md lines 82-87: File upload specifications (image→JPG, PPT→slides, ZIP→extract) + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=SlideControllerTest` → PASS + - [ ] Image upload → converted to 1920×1080 JPG + thumbnail + - [ ] ZIP upload → all contained images processed + - [ ] PPT upload → job dispatched, slides created after completion + - [ ] SlideUploader component renders drop zone with '+' area + - [ ] SlideGrid shows thumbnails with metadata + - [ ] Expire date editable inline for information slides + + **QA Scenarios:** + ``` + Scenario: Upload image via drop zone + Tool: Playwright + Preconditions: Authenticated, on a page with SlideUploader component + Steps: + 1. Locate the upload area (dotted border / '+' icon area) + 2. Upload a test 400x300 PNG via file input + 3. Assert: progress bar appears and completes + 4. Assert: new thumbnail appears in SlideGrid + 5. Assert: thumbnail shows upload date and uploader name + Expected Result: Image uploaded, converted, and displayed as thumbnail + Evidence: .sisyphus/evidence/task-10-slide-upload.png + + Scenario: Reject invalid file type + Tool: Playwright + Steps: + 1. Try to upload a .txt file + 2. Assert: error message shown (e.g. 'Dateityp nicht erlaubt') + 3. Assert: no slide created in grid + Expected Result: Invalid files rejected with German error message + Evidence: .sisyphus/evidence/task-10-invalid-file.png + ``` + + **Commit**: YES + - Message: `feat: add reusable slide upload and grid components` + - Files: `app/Http/Controllers/SlideController.php`, `resources/js/Components/SlideUploader.vue`, `resources/js/Components/SlideGrid.vue` + - Pre-commit: `php artisan test` + +- [x] 11. Arrangement Model + Configurator Component + + **What to do**: + - TEST: Write Pest tests for ArrangementController — create, clone, update group order, delete + - Create `App\Http\Controllers\ArrangementController` with: + - `store(Song $song)`: create new arrangement (clone from default order), accept name via request + - `clone($id)`: duplicate an existing arrangement with new name + - `update($id)`: save reordered groups (accept array of `{song_group_id, order}`) + - `destroy($id)`: delete arrangement (prevent deleting last one) + - Create `resources/js/Components/ArrangementConfigurator.vue`: + - Select dropdown listing all arrangements for this song ("Normal" pre-selected as default) + - "Hinzufügen" button → prompt for name, creates new arrangement (clone from default) + - "Klonen" button → prompt for name, clones current arrangement + - Below select: show the groups of the selected arrangement as colored pills (like `ref/form-song-arangment-config.png`) + - Each pill shows group name with its color background + - Pool area showing all available groups from the song (source for drag) + - Drag-and-drop to reorder pills and add groups from pool + - Use `vue-draggable-plus` with clone mode for pool→sequence and sort mode for reordering + - CRITICAL: Vue key must be `${group.id}-${index}` not just `group.id` (groups can repeat) + - Auto-save on every drag-end via Inertia `router.put` with `preserveScroll: true` + - Each group pill should have: color background, name text, remove button (×) + - Color picker integration: clicking on a group in the pool lets you change its color (stored in `song_groups.color`) + + **Must NOT do**: + - Do NOT allow deleting the last arrangement of a song + - Do NOT sync arrangements from CTS API (manually created only) + - Do NOT use `group.id` alone as Vue key (groups repeat in arrangements) + + **Recommended Agent Profile**: + - **Category**: `deep` + - **Skills**: [`frontend-ui-ux`] + - `frontend-ui-ux`: Complex drag-and-drop interaction with colored pills requires careful UI work + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 2 (with T8-T10, T12-T13) + - **Blocks**: T18, T21 + - **Blocked By**: T2, T9 + + **References**: + - `ref/form-song-arangment-config.png` — reference UI screenshot showing colored pills, arrangement dropdown, group pool + - `vue-draggable-plus` docs: clone mode for pool→sequence drag + - `app/Models/SongArrangement.php`, `app/Models/SongArrangementGroup.php` — models from T2 + - `app/Models/SongGroup.php` — groups with color field from T2 + - AGENTS.md lines 73-77: arrangement select, add/clone buttons, drag-and-drop groups + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=ArrangementControllerTest` → PASS + - [ ] Create arrangement: new arrangement appears in select + - [ ] Clone arrangement: exact copy with new name + - [ ] Drag-and-drop reorder persists on drop-end + - [ ] Groups from pool can be dragged into arrangement (clone mode) + - [ ] Groups can be removed from arrangement via × button + - [ ] Color picker changes group color and persists + - [ ] Cannot delete last arrangement (error shown) + + **QA Scenarios:** + ``` + Scenario: Create and configure arrangement + Tool: Playwright + Preconditions: Song exists with 3 groups (Strophe 1, Refrain, Bridge) + Steps: + 1. Navigate to song arrangement area + 2. Click 'Hinzufügen' button + 3. Enter name 'Abend-Version' in prompt + 4. Assert: new arrangement appears in select dropdown + 5. Assert: arrangement shows all groups as colored pills + 6. Drag 'Refrain' pill to position after 'Bridge' + 7. Assert: order updated — Strophe 1, Bridge, Refrain + 8. Reload page + 9. Assert: new order persisted + Expected Result: Arrangement created and reorderable via drag-and-drop + Evidence: .sisyphus/evidence/task-11-arrangement-create.png + + Scenario: Clone arrangement + Tool: Playwright + Steps: + 1. Select 'Abend-Version' arrangement + 2. Click 'Klonen' button + 3. Enter name 'Spezial' + 4. Assert: new arrangement 'Spezial' appears in dropdown + 5. Assert: same group order as cloned source + Expected Result: Arrangement cloned successfully + Evidence: .sisyphus/evidence/task-11-arrangement-clone.png + ``` + + **Commit**: YES + - Message: `feat: add arrangement configurator with drag-and-drop group management` + - Files: `app/Http/Controllers/ArrangementController.php`, `resources/js/Components/ArrangementConfigurator.vue`, routes + - Pre-commit: `php artisan test` + +- [x] 12. Song Matching Service (CCLI ID) + + **What to do**: + - TEST: Write Pest tests for song matching — auto-match by CCLI, manual assign, request email + - Create `App\Services\SongMatchingService` with: + - `autoMatch(ServiceSong $serviceSong)`: look up `songs.ccli_id` matching `service_songs.cts_ccli_id`, set `song_id` and `matched_at` if found + - `manualAssign(ServiceSong $serviceSong, Song $song)`: manually assign a song to a service song, set `matched_at` + - `requestCreation(ServiceSong $serviceSong)`: send `MissingSongRequest` email (from T7), set `request_sent_at` + - Hook auto-matching into `ChurchToolsService::syncAgenda()` (from T4) — after creating/updating service_songs, run `autoMatch` on each + - Create `App\Http\Controllers\ServiceSongController` with: + - `assignSong($serviceSongId, Request $request)`: manually assign a song → accepts `song_id` + - `requestSong($serviceSongId)`: trigger email and set `request_sent_at` + - `unassign($serviceSongId)`: remove manual assignment + - Frontend will be built in T18 (Songs block) — this task is backend only + + **Must NOT do**: + - Do NOT build the frontend UI here (that's T18) + - Do NOT auto-create songs from CTS API data + - Do NOT send email automatically on sync — only on explicit user request + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 2 (with T8-T11, T13) + - **Blocks**: T18 + - **Blocked By**: T2, T4, T7, T9 + + **References**: + - `app/Services/ChurchToolsService.php` — sync pipeline from T4 where matching integrates + - `app/Mail/MissingSongRequest.php` — email mailable from T7 + - `app/Models/ServiceSong.php` — service_songs model from T2 + - `app/Models/Song.php` — songs model from T2 + - AGENTS.md lines 67-70: CCLI matching, 'request creation' button, manual select field + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=SongMatchingTest` → PASS + - [ ] Auto-match: service song with CCLI 12345 matches song with ccli_id 12345 + - [ ] Manual assign: endpoint sets song_id and matched_at + - [ ] Request email: sends MissingSongRequest email, sets request_sent_at + - [ ] Unmatched songs remain with song_id = null + + **QA Scenarios:** + ``` + Scenario: Auto-match song by CCLI ID + Tool: Bash + Preconditions: Song with ccli_id='12345' in DB, service_song with cts_ccli_id='12345' and song_id=null + Steps: + 1. Run: php artisan cts:sync (or call matching directly via tinker) + 2. Query: ServiceSong where cts_ccli_id='12345' + 3. Assert: song_id is now set to the matching Song's id + 4. Assert: matched_at is not null + Expected Result: Auto-matching links service song to DB song + Evidence: .sisyphus/evidence/task-12-auto-match.txt + + Scenario: Request missing song email + Tool: Bash + Preconditions: Unmatched service_song exists, MAIL config set to log driver + Steps: + 1. POST /api/service-songs/{id}/request + 2. Assert: HTTP 200 + 3. Check laravel.log or mail log for 'Song-Anfrage' subject + 4. Assert: service_song.request_sent_at is now set + Expected Result: Email sent and timestamp recorded + Evidence: .sisyphus/evidence/task-12-request-email.txt + ``` + + **Commit**: YES + - Message: `feat: add song matching service with CCLI auto-match and request email` + - Files: `app/Services/SongMatchingService.php`, `app/Http/Controllers/ServiceSongController.php`, routes + - Pre-commit: `php artisan test` + +- [x] 13. Translation Service (URL Scrape + Manual) + + **What to do**: + - TEST: Write Pest tests for TranslationService — mock HTTP scrape, manual text import, line-count matching + - Create `App\Services\TranslationService` with: + - `fetchFromUrl(string $url): ?string` — best-effort HTTP GET, extract text content (strip HTML tags), return raw text or null on failure + - `importTranslation(Song $song, string $text)`: distribute translated text across slides + - For each group's slides: take matching line count from translated text + - If original slide has 4 lines → take next 4 lines from translation + - Store in `song_slides.text_content_translated` + - `markAsTranslated(Song $song)`: set `songs.has_translation = true` + - `removeTranslation(Song $song)`: clear all `text_content_translated`, set `has_translation = false` + - Create `App\Http\Controllers\TranslationController` with: + - `fetchUrl(Request $request)`: accept URL, return scraped text for review before import + - `import(Song $song, Request $request)`: accept full text, run `importTranslation` + - URL scraping is best-effort only — gracefully handle failures (return null, show German error message) + + **Must NOT do**: + - Do NOT build site-specific scrapers (only generic HTTP fetch + strip tags) + - Do NOT auto-save URL fetch result (user reviews text first, then explicitly saves) + - Do NOT build the translation editor UI here (that's T22) + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 2 (with T8-T12) + - **Blocks**: T22 + - **Blocked By**: T2, T9 + + **References**: + - `app/Models/Song.php` — `has_translation` field from T2 + - `app/Models/SongSlide.php` — `text_content_translated` field from T2 + - AGENTS.md line 96: "allow add a full text or an URL to the Full text ... always the same line qty of text from the original" + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=TranslationServiceTest` → PASS + - [ ] URL fetch returns text content or null (no exception) + - [ ] Import distributes lines matching original slide line counts + - [ ] Song marked as `has_translation = true` after import + - [ ] Remove translation clears all translated text and sets flag to false + + **QA Scenarios:** + ``` + Scenario: Import translation with line-count matching + Tool: Bash + Preconditions: Song with 2 groups, group 1 has 2 slides (4 lines, 2 lines), group 2 has 1 slide (4 lines) + Steps: + 1. Prepare translated text: 10 lines total + 2. POST /api/songs/{id}/translation/import with {text: '10 lines...'} + 3. Query song slides + 4. Assert: slide 1 has 4 translated lines + 5. Assert: slide 2 has 2 translated lines + 6. Assert: slide 3 has 4 translated lines + 7. Assert: song.has_translation = true + Expected Result: Translation distributed by slide line counts + Evidence: .sisyphus/evidence/task-13-translation-import.txt + + Scenario: URL fetch failure handled gracefully + Tool: Bash + Steps: + 1. POST /api/translation/fetch-url with {url: 'https://nonexistent.invalid/lyrics'} + 2. Assert: HTTP 200 with {text: null, error: 'Konnte Text nicht abrufen'} + Expected Result: Graceful failure with German error message + Evidence: .sisyphus/evidence/task-13-url-failure.txt + ``` + + **Commit**: YES + - Message: `feat: add translation service with URL scrape and line-count distribution` + - Files: `app/Services/TranslationService.php`, `app/Http/Controllers/TranslationController.php`, routes + - Pre-commit: `php artisan test` + +### Wave 3: Service Edit + Song UI (6 parallel tasks) + +- [ ] 14. Service Edit Page Layout + Routing + + **What to do**: + - TEST: Write Pest test for ServiceController edit/update — verify Inertia render with service data and related models + - Add `edit($id)` method to `ServiceController`: + - Load service with: service_songs (with song, arrangement), slides (information/moderation/sermon), preacher, beamer tech + - Return Inertia render `Services/Edit` + - Create `resources/js/Pages/Services/Edit.vue`: + - Page title: service title + date + - 4 collapsible/tabbed blocks: Information, Moderation, Predigt, Songs + - Each block is a child component (from T15-T18) + - Auto-save behavior inherited from `useAutoSave` composable (T6) + - Back button: navigates to service list + - Add route: `GET /services/{id}/edit` + - Wire up block components with proper props (service, songs, slides) + + **Must NOT do**: + - Do NOT implement block content here (just the layout/routing shell) + - Do NOT add a save button (auto-save only) + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - **Skills**: [`frontend-ui-ux`] + - `frontend-ui-ux`: Page layout with collapsible sections requires good visual structure + + **Parallelization**: + - **Can Run In Parallel**: YES (first in Wave 3, but other Wave 3 tasks can start in parallel) + - **Parallel Group**: Wave 3 (with T15-T19) + - **Blocks**: T15, T16, T17, T18, T19 + - **Blocked By**: T8, T6 + + **References**: + - `app/Http/Controllers/ServiceController.php` — extend with edit method (from T8) + - `resources/js/Layouts/AuthenticatedLayout.vue` — layout wrapper (from T6) + - `resources/js/Composables/useAutoSave.js` — auto-save composable (from T6) + - AGENTS.md lines 40-45: Edit shows form with blocks (information, moderation, sermon, songs) + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=ServiceEditTest` → PASS + - [ ] `/services/{id}/edit` renders edit page with service data + - [ ] All 4 blocks visible (Information, Moderation, Predigt, Songs) + - [ ] Back button navigates to service list + - [ ] Page title shows service name and date + + **QA Scenarios:** + ``` + Scenario: Service edit page renders with all blocks + Tool: Playwright + Preconditions: Service with id=1 exists, has songs and slides + Steps: + 1. Navigate to http://localhost:8000/services/1/edit + 2. Assert: page title contains service title + 3. Assert: page contains section/tab labeled 'Information' + 4. Assert: page contains section/tab labeled 'Moderation' + 5. Assert: page contains section/tab labeled 'Predigt' + 6. Assert: page contains section/tab labeled 'Songs' + 7. Assert: back button/link exists pointing to /services + Expected Result: Edit page with all 4 German-labeled blocks + Evidence: .sisyphus/evidence/task-14-edit-layout.png + ``` + + **Commit**: YES + - Message: `feat: add service edit page layout with 4-block structure` + - Files: `resources/js/Pages/Services/Edit.vue`, routes + - Pre-commit: `php artisan test` + +- [ ] 15. Information Block (Slides + Expire Dates) + + **What to do**: + - TEST: Write Pest test that verifies info slides are dynamically queried (expire_date > service_date) + - Create `resources/js/Components/Blocks/InformationBlock.vue`: + - Uses `SlideGrid` component (from T10) to display information slides + - Uses `SlideUploader` component (from T10) with `showExpireDate=true` + - Dynamically shows all slides where `type='information' AND expire_date >= service.date` + - Each slide shows: thumbnail, upload date (muted), uploader name (muted), **prominent expire date** with inline datepicker + - Delete button (soft-delete) on each slide + - Editing expire date saves immediately (auto-save) + - Create backend support: `SlideController` method to query information slides by service date + - Information slides are NOT tied to a specific service — they appear in ALL services where `expire_date >= service.date` + + **Must NOT do**: + - Do NOT copy info slides into services (query dynamically by date) + - Do NOT show expired slides + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - **Skills**: [`frontend-ui-ux`] + - `frontend-ui-ux`: Thumbnail grid with prominent expire dates needs polished layout + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 3 (with T14, T16-T19) + - **Blocks**: T24 + - **Blocked By**: T10, T14 + + **References**: + - `resources/js/Components/SlideUploader.vue`, `resources/js/Components/SlideGrid.vue` — from T10 + - `app/Models/Slide.php` — slide model from T2 + - AGENTS.md lines 49-54: Information block specification (thumbnails, expire date, uploader, drag-and-drop upload) + - AGENTS.md line 54: "automatically show these files to all future services, till the expire date is after the service date" + + **Acceptance Criteria**: + - [ ] Info slides from other services shown if expire_date >= current service date + - [ ] Expired slides NOT shown + - [ ] Upload new slide with expire date → appears in slide grid + - [ ] Change expire date inline → saves immediately + - [ ] Delete slide → soft-deleted, disappears from grid + + **QA Scenarios:** + ``` + Scenario: Info slides shown dynamically by expire date + Tool: Playwright + Preconditions: 2 info slides in DB — one expires tomorrow, one expired yesterday. Service date = today. + Steps: + 1. Navigate to /services/{id}/edit + 2. Open Information block + 3. Assert: only the non-expired slide is visible (1 thumbnail) + 4. Assert: expired slide is NOT shown + 5. Assert: visible slide shows expire date prominently + Expected Result: Dynamic filtering by expire_date vs service date + Evidence: .sisyphus/evidence/task-15-info-dynamic.png + + Scenario: Upload new info slide with expire date + Tool: Playwright + Steps: + 1. In Information block, select expire date via datepicker (2 weeks from now) + 2. Upload a test PNG file + 3. Assert: new thumbnail appears in grid + 4. Assert: expire date shown matches selected date + Expected Result: New info slide uploaded with correct expire date + Evidence: .sisyphus/evidence/task-15-info-upload.png + ``` + + **Commit**: YES + - Message: `feat: add Information block with dynamic expire-date filtering` + - Files: `resources/js/Components/Blocks/InformationBlock.vue` + - Pre-commit: `php artisan test` + +- [ ] 16. Moderation Block (Slides, Service-Specific) + + **What to do**: + - TEST: Write Pest test verifying moderation slides are service-specific (not shared across services) + - Create `resources/js/Components/Blocks/ModerationBlock.vue`: + - Same UI as Information block BUT: + - NO expire date field (no datepicker) + - Slides are tied to THIS service only (service_id FK set) + - Uses `SlideGrid` component (from T10) with `showExpireDate=false` + - Uses `SlideUploader` component (from T10) with `showExpireDate=false` + - Upload, view thumbnails, delete — standard slide management + - Backend: SlideController already handles this — just pass `type='moderation'` and `service_id` + + **Must NOT do**: + - Do NOT add expire date functionality (that's Information block only) + - Do NOT share moderation slides across services + + **Recommended Agent Profile**: + - **Category**: `quick` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 3 (with T14-T15, T17-T19) + - **Blocks**: T24 + - **Blocked By**: T10, T14 + + **References**: + - `resources/js/Components/Blocks/InformationBlock.vue` — same pattern but simpler (from T15) + - `resources/js/Components/SlideUploader.vue`, `resources/js/Components/SlideGrid.vue` — from T10 + - AGENTS.md line 58: "Same features as Block Information but without the datepicker and only relevant for this service" + + **Acceptance Criteria**: + - [ ] Moderation slides belong to specific service (not shared) + - [ ] No expire date field visible + - [ ] Upload, view, delete works same as information block + + **QA Scenarios:** + ``` + Scenario: Moderation slides are service-specific + Tool: Playwright + Preconditions: Service A has 2 moderation slides. Service B has 0. + Steps: + 1. Navigate to /services/{A}/edit, open Moderation block + 2. Assert: 2 slides visible + 3. Navigate to /services/{B}/edit, open Moderation block + 4. Assert: 0 slides visible (no cross-service sharing) + 5. Assert: no datepicker / expire date field visible + Expected Result: Moderation slides scoped to individual service + Evidence: .sisyphus/evidence/task-16-moderation-scoped.png + ``` + + **Commit**: YES (group with T17) + - Message: `feat: add Moderation and Sermon slide blocks` + - Files: `resources/js/Components/Blocks/ModerationBlock.vue` + - Pre-commit: `php artisan test` + +- [ ] 17. Sermon Block (Slides, Service-Specific) + + **What to do**: + - TEST: Write Pest test verifying sermon slides are service-specific + - Create `resources/js/Components/Blocks/SermonBlock.vue`: + - Identical to Moderation block but with `type='sermon'` + - Service-specific slides, no expire date + - Uses same `SlideGrid` and `SlideUploader` components + - Essentially a thin wrapper passing `type='sermon'` to the shared components + + **Must NOT do**: + - Do NOT add any features beyond what Moderation block has + - Do NOT share sermon slides across services + + **Recommended Agent Profile**: + - **Category**: `quick` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 3 (with T14-T16, T18-T19) + - **Blocks**: T24 + - **Blocked By**: T10, T14 + + **References**: + - `resources/js/Components/Blocks/ModerationBlock.vue` — identical pattern (from T16) + - AGENTS.md line 62: "Same features as Block Moderation" + + **Acceptance Criteria**: + - [ ] Sermon slides are service-specific + - [ ] Same upload/view/delete functionality as Moderation block + - [ ] type='sermon' correctly set on new slides + + **QA Scenarios:** + ``` + Scenario: Sermon block functions identically to Moderation + Tool: Playwright + Preconditions: Service with no sermon slides + Steps: + 1. Navigate to /services/{id}/edit, open Predigt block + 2. Assert: empty state with upload area + 3. Upload a test image + 4. Assert: thumbnail appears with upload date and uploader name + 5. Assert: no expire date field + Expected Result: Sermon block works like Moderation + Evidence: .sisyphus/evidence/task-17-sermon-block.png + ``` + + **Commit**: YES (group with T16) + - Message: `feat: add Moderation and Sermon slide blocks` + - Files: `resources/js/Components/Blocks/SermonBlock.vue` + - Pre-commit: `php artisan test` + +- [ ] 18. Songs Block (Matching + Arrangement + Translation) + + **What to do**: + - TEST: Write Pest tests for the Songs block Inertia endpoint — verify service songs returned with match status, arrangement data + - Create `resources/js/Components/Blocks/SongsBlock.vue` — the most complex block: + - Show all service songs in order from CTS agenda + - Each song row shows: Name, CCLI ID, "Hat Übersetzung" indicator + - **If NOT matched** (song_id = null): + - Show "Erstellung anfragen" button → calls `POST /api/service-songs/{id}/request` (from T12) + - Show searchable select dropdown of all songs from DB (title + CCLI searchable) → calls manual assign (from T12) + - **If matched** (song_id set): + - If song `has_translation=true`: show checkbox "Übersetzung verwenden" (default: checked), auto-saves + - Show `ArrangementConfigurator` component (from T11) for this song in this service context + - Default arrangement "Normal" pre-selected + - Show "Vorschau" button → opens preview modal (from T19) + - Show "PDF herunterladen" button → triggers PDF download (from T19) + - Use auto-save for all changes (checkbox toggle, arrangement selection) + - Song order matches CTS agenda order (not draggable here) + + **Must NOT do**: + - Do NOT allow reordering songs (order comes from CTS API) + - Do NOT allow adding/removing songs (that happens in CTS) + - Do NOT build the preview modal here (that's T19) + + **Recommended Agent Profile**: + - **Category**: `deep` + - **Skills**: [`frontend-ui-ux`] + - `frontend-ui-ux`: Complex conditional UI (matched vs unmatched states) needs careful design + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 3 (with T14-T17, T19) + - **Blocks**: T24 + - **Blocked By**: T11, T12, T14 + + **References**: + - `resources/js/Components/ArrangementConfigurator.vue` — arrangement component (from T11) + - `app/Http/Controllers/ServiceSongController.php` — matching endpoints (from T12) + - `app/Models/ServiceSong.php` — service_songs model (from T2) + - AGENTS.md lines 64-80: Full Songs block specification (matching, arrangement, preview, download) + + **Acceptance Criteria**: + - [ ] Songs shown in CTS agenda order + - [ ] Unmatched songs show 'Erstellung anfragen' button and searchable select + - [ ] Matched songs show arrangement configurator + - [ ] Translation checkbox appears for translated songs + - [ ] Vorschau and PDF buttons visible for songs with arrangements + - [ ] All auto-save interactions work + + **QA Scenarios:** + ``` + Scenario: Unmatched song shows matching options + Tool: Playwright + Preconditions: Service with 1 unmatched song (song_id=null, cts_song_name='Amazing Grace') + Steps: + 1. Navigate to /services/{id}/edit, open Songs block + 2. Assert: song row shows 'Amazing Grace' + 3. Assert: 'Erstellung anfragen' button visible + 4. Assert: searchable select dropdown visible + 5. Assert: NO arrangement configurator shown (not matched yet) + Expected Result: Unmatched song shows request and manual assign options + Evidence: .sisyphus/evidence/task-18-unmatched-song.png + + Scenario: Matched song shows arrangement and controls + Tool: Playwright + Preconditions: Service with 1 matched song (has_translation=true, has arrangement 'Normal') + Steps: + 1. Navigate to /services/{id}/edit, open Songs block + 2. Assert: arrangement select shows 'Normal' selected + 3. Assert: 'Übersetzung verwenden' checkbox visible and checked + 4. Assert: 'Vorschau' button visible + 5. Assert: 'PDF herunterladen' button visible + Expected Result: Matched song shows full control panel + Evidence: .sisyphus/evidence/task-18-matched-song.png + ``` + + **Commit**: YES + - Message: `feat: add Songs block with matching UI and arrangement integration` + - Files: `resources/js/Components/Blocks/SongsBlock.vue` + - Pre-commit: `php artisan test` + +- [ ] 19. Song Preview Modal + PDF Download + + **What to do**: + - TEST: Write Pest tests for PDF generation endpoint — verify PDF returns correct content type and contains song text + - Create `resources/js/Components/SongPreviewModal.vue`: + - HTML overlay/modal showing song text in arrangement order + - Each group's slides shown sequentially + - Group name shown as header with group's color as background + - If `use_translation=true`: show original + translated text side by side (or translated below original) + - Copyright footer at bottom from song metadata + - Close button / click-outside to dismiss + - Create `App\Http\Controllers\SongPdfController` with `download($songId, $arrangementId)` method: + - Use `barryvdh/laravel-dompdf` to generate PDF + - Template: `resources/views/pdf/song.blade.php` + - MUST use old-school CSS (no Tailwind classes!) with `DejaVu Sans` font for German umlauts + - Layout: song title header, groups with colored headers, slide text, copyright footer + - If `use_translation=true`: include translation text + - Return PDF download with filename `{song-title}-{arrangement-name}.pdf` + - Wire up buttons: "Vorschau" opens modal, "PDF herunterladen" triggers download + + **Must NOT do**: + - Do NOT use Tailwind CSS in the DomPDF Blade template (DomPDF doesn't support it) + - Do NOT use web fonts in PDF (use DejaVu Sans which is bundled with DomPDF) + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - **Skills**: [`frontend-ui-ux`] + - `frontend-ui-ux`: Preview modal needs clean, readable formatting + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 3 (with T14-T18) + - **Blocks**: — + - **Blocked By**: T9, T11 + + **References**: + - `barryvdh/laravel-dompdf` docs: https://github.com/barryvdh/laravel-dompdf + - `app/Models/Song.php`, `app/Models/SongGroup.php`, `app/Models/SongSlide.php` — data models from T2 + - `app/Models/SongArrangement.php`, `app/Models/SongArrangementGroup.php` — arrangement models from T2 + - AGENTS.md line 79: "preview: show text of the song in the order of the arrangement configuration, prominent highlighted which textpart was with group" + - AGENTS.md line 80: "download: download the preview as a nice pdf with header/footer and copyright footer" + + **Acceptance Criteria**: + - [ ] Preview modal opens with song text in arrangement order + - [ ] Group headers show name with group color + - [ ] PDF downloads with correct filename + - [ ] PDF renders German umlauts correctly (DejaVu Sans) + - [ ] PDF includes copyright footer + - [ ] Translation text shown when use_translation=true + + **QA Scenarios:** + ``` + Scenario: Preview modal shows formatted song text + Tool: Playwright + Preconditions: Song with 3 groups (Strophe 1, Refrain, Bridge), arrangement 'Normal' ordering all 3 + Steps: + 1. Navigate to song's 'Vorschau' button and click it + 2. Assert: modal/overlay appears + 3. Assert: first section header contains 'Strophe 1' with colored background + 4. Assert: slide text content visible below each header + 5. Assert: copyright text shown at bottom + 6. Click outside modal or close button + 7. Assert: modal dismissed + Expected Result: Clean formatted preview with colored group headers + Evidence: .sisyphus/evidence/task-19-preview-modal.png + + Scenario: PDF download with German umlauts + Tool: Bash (curl) + Steps: + 1. GET /api/songs/{id}/pdf/{arrangementId} + 2. Assert: Content-Type is application/pdf + 3. Assert: Content-Disposition contains song title in filename + 4. Save to /tmp/song-test.pdf + 5. Run: pdftotext /tmp/song-test.pdf - | head -20 + 6. Assert: output contains song title and group names + 7. Assert: German umlauts (äöüß) render correctly (not garbled) + Expected Result: Valid PDF with correct text and umlauts + Evidence: .sisyphus/evidence/task-19-pdf-download.txt + ``` + + **Commit**: YES + - Message: `feat: add song preview modal and PDF download with DomPDF` + - Files: `resources/js/Components/SongPreviewModal.vue`, `app/Http/Controllers/SongPdfController.php`, `resources/views/pdf/song.blade.php`, routes + - Pre-commit: `php artisan test` + +### Wave 4: Song DB + Finalization (5 parallel tasks) + +- [ ] 20. Song DB Page (List + Search + Filters) + + **What to do**: + - TEST: Write Pest test for SongDB index page — verify Inertia render with paginated songs, search, soft-delete excluded + - Create `resources/js/Pages/Songs/Index.vue`: + - Table showing all songs: Titel, CCLI-ID, Erstellt, Letzte Änderung, Zuletzt verwendet, Hat Übersetzung + - Search bar: search by song name or CCLI ID + - Each row has action buttons: "Bearbeiten" (edit), "Löschen" (soft-delete), "Herunterladen" (.pro download), "Übersetzen" (translate) + - Upload area at top: drag-and-drop / click for .pro file upload (placeholder from T23) + - Soft-deleted songs not shown (can add 'Papierkorb anzeigen' toggle later) + - Pagination for large song lists + - Add route: `GET /songs` (handled by SongController index from T9) + - Wire up to navigation: "Song-Datenbank" link in top bar (from T6) + + **Must NOT do**: + - Do NOT implement .pro parser (placeholder from T23) + - Do NOT show soft-deleted songs by default + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - **Skills**: [`frontend-ui-ux`] + - `frontend-ui-ux`: Data table with search, actions, and upload area needs clean design + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 4 (with T21-T24) + - **Blocks**: — + - **Blocked By**: T9, T6 + + **References**: + - `app/Http/Controllers/SongController.php` — backend CRUD from T9 + - `resources/js/Layouts/AuthenticatedLayout.vue` — layout with navigation from T6 + - AGENTS.md lines 91-93: "shows all songs from DB with created, last update, ccliID, last_used_in_service" + action buttons + + **Acceptance Criteria**: + - [ ] Song list page renders with correct columns in German + - [ ] Search by name returns matching songs + - [ ] Search by CCLI ID returns matching songs + - [ ] Action buttons visible on each row + - [ ] Pagination works for large lists + - [ ] Upload area visible at top + + **QA Scenarios:** + ``` + Scenario: Song database page with search + Tool: Playwright + Preconditions: 5 songs in DB, one named 'Großer Gott' + Steps: + 1. Navigate to http://localhost:8000/songs + 2. Assert: table shows 5 song rows + 3. Assert: columns include 'Titel', 'CCLI-ID', 'Zuletzt verwendet' + 4. Type 'Großer' in search bar + 5. Assert: table filters to 1 result containing 'Großer Gott' + 6. Assert: row shows 'Bearbeiten', 'Löschen', 'Herunterladen', 'Übersetzen' buttons + Expected Result: Searchable song list with German UI + Evidence: .sisyphus/evidence/task-20-songdb-list.png + ``` + + **Commit**: YES + - Message: `feat: add Song Database page with search and action buttons` + - Files: `resources/js/Pages/Songs/Index.vue`, routes + - Pre-commit: `php artisan test` + +- [ ] 21. Song DB Edit Popup (Metadata + Arrangement) + + **What to do**: + - TEST: Write Pest test for song update endpoint — verify metadata update works + - Create `resources/js/Components/SongEditModal.vue`: + - Modal/popup triggered by "Bearbeiten" button on song list + - Fields: Titel (text input), CCLI-ID (text input), Copyright-Text (textarea) + - Show any additional metadata available from song model + - Below metadata: embed `ArrangementConfigurator` component (from T11) + - Auto-save on all field changes (use `useAutoSave` from T6) + - Close button / click-outside to dismiss + - Wire up to `SongController::update()` for metadata saves + + **Must NOT do**: + - Do NOT allow editing song groups/slides content here (that's for the translate page) + - Do NOT duplicate the arrangement logic (reuse component from T11) + + **Recommended Agent Profile**: + - **Category**: `visual-engineering` + - **Skills**: [`frontend-ui-ux`] + - `frontend-ui-ux`: Modal form with embedded arrangement configurator needs good UX flow + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 4 (with T20, T22-T24) + - **Blocks**: — + - **Blocked By**: T9, T11 + + **References**: + - `resources/js/Components/ArrangementConfigurator.vue` — arrangement component from T11 + - `app/Http/Controllers/SongController.php` — update endpoint from T9 + - `resources/js/Composables/useAutoSave.js` — auto-save composable from T6 + - AGENTS.md line 94: "edit: shows a popup with Name, CCLI and copyright text and the arrangement configurator" + + **Acceptance Criteria**: + - [ ] Modal opens with song metadata pre-filled + - [ ] Editing title auto-saves after debounce + - [ ] CCLI ID editable and saves + - [ ] Copyright text editable and saves + - [ ] Arrangement configurator rendered inside modal + - [ ] Modal closes on × or outside click + + **QA Scenarios:** + ``` + Scenario: Edit song metadata via popup + Tool: Playwright + Preconditions: Song 'Amazing Grace' with CCLI 12345 in DB + Steps: + 1. Navigate to /songs + 2. Click 'Bearbeiten' on 'Amazing Grace' row + 3. Assert: modal appears with title 'Amazing Grace' pre-filled + 4. Assert: CCLI field shows '12345' + 5. Change title to 'Amazing Grace (Neu)' + 6. Wait 600ms (debounce) + 7. Close modal + 8. Assert: song list shows updated title 'Amazing Grace (Neu)' + Expected Result: Auto-saving metadata edit in modal + Evidence: .sisyphus/evidence/task-21-song-edit.png + ``` + + **Commit**: YES + - Message: `feat: add Song DB edit popup with metadata and arrangement configurator` + - Files: `resources/js/Components/SongEditModal.vue` + - Pre-commit: `php artisan test` + +- [ ] 22. Song DB Translate Page (Two-Column Editor) + + **What to do**: + - TEST: Write Pest test for translation import endpoint — verify line-count distribution + - Create `resources/js/Pages/Songs/Translate.vue`: + - Page triggered by "Übersetzen" button on song list + - Top section: URL input field + "Text abrufen" button OR large textarea for manual paste + - After text is provided (via URL fetch or paste): show two-column editor + - Two-column editor for EACH slide of EACH group: + - Left column: original text (read-only, from `song_slides.text_content`) + - Right column: translation text editor (editable, from `song_slides.text_content_translated`) + - Each editor area has same line count as original (constrained by original line count) + - Group headers between sections (colored, matching group color) + - "Speichern" button saves all translations at once + - After save: song marked as `has_translation = true` + - Add route: `GET /songs/{id}/translate` + - Wire up to `TranslationController` (from T13) for URL fetch and import + - Use Inertia form for submission + + **Must NOT do**: + - Do NOT allow changing the original text (left column is read-only) + - Do NOT allow adding more lines than the original has per slide + + **Recommended Agent Profile**: + - **Category**: `deep` + - **Skills**: [`frontend-ui-ux`] + - `frontend-ui-ux`: Two-column editor with constrained line counts needs precise UI + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 4 (with T20-T21, T23-T24) + - **Blocks**: — + - **Blocked By**: T13, T9 + + **References**: + - `app/Services/TranslationService.php` — translation backend from T13 + - `app/Http/Controllers/TranslationController.php` — URL fetch and import endpoints from T13 + - `app/Models/SongSlide.php` — `text_content` and `text_content_translated` fields from T2 + - AGENTS.md line 96: "two columns for every slide of every group. Left the original text, right a texteditor, with the imported text — always the same line qty" + + **Acceptance Criteria**: + - [ ] Translate page shows URL input and paste area + - [ ] URL fetch returns text for review + - [ ] Two-column editor shows original (read-only) and translation (editable) + - [ ] Line count per slide matches original + - [ ] Save marks song as `has_translation = true` + - [ ] Group headers with colors shown between sections + + **QA Scenarios:** + ``` + Scenario: Two-column translation editor + Tool: Playwright + Preconditions: Song with 2 groups, 3 slides total (4 lines, 2 lines, 4 lines) + Steps: + 1. Navigate to /songs/{id}/translate + 2. Paste 10 lines of translated text into textarea + 3. Click import/distribute button + 4. Assert: two-column editor appears + 5. Assert: left columns show original text (read-only) + 6. Assert: right columns show distributed translation text + 7. Assert: slide 1 right column has exactly 4 lines + 8. Assert: slide 2 right column has exactly 2 lines + 9. Assert: slide 3 right column has exactly 4 lines + 10. Click 'Speichern' + 11. Navigate to /songs + 12. Assert: song shows 'Hat Übersetzung' indicator + Expected Result: Translation distributed correctly by line count + Evidence: .sisyphus/evidence/task-22-translate-editor.png + + Scenario: URL fetch for lyrics + Tool: Playwright + Steps: + 1. Navigate to /songs/{id}/translate + 2. Enter a valid URL in URL field + 3. Click 'Text abrufen' + 4. Assert: either text appears in textarea OR error message 'Konnte Text nicht abrufen' shown + Expected Result: URL fetch attempts text retrieval (best-effort) + Evidence: .sisyphus/evidence/task-22-url-fetch.png + ``` + + **Commit**: YES + - Message: `feat: add Song DB translation page with two-column editor` + - Files: `resources/js/Pages/Songs/Translate.vue`, routes + - Pre-commit: `php artisan test` + +- [ ] 23. Song DB .pro Upload + Download Placeholders + + **What to do**: + - TEST: Write Pest test that verifies .pro upload endpoint throws NotImplementedException, and .pro download endpoint returns a stub file + - Create upload endpoint `POST /api/songs/import-pro`: + - Accept .pro file(s) or ZIP containing .pro files + - Throw `App\Exceptions\ProParserNotImplementedException` with message "Der .pro-Parser wird später implementiert. Bitte warte auf die detaillierte Spezifikation." + - Return HTTP 501 with JSON error message + - Create download endpoint `GET /api/songs/{id}/download-pro`: + - Throw same `ProParserNotImplementedException` with message "Der .pro-Generator wird später implementiert." + - Return HTTP 501 + - Create custom exception class `App\Exceptions\ProParserNotImplementedException` + - Wire up upload area on Song DB page (from T20) to call the upload endpoint — show the error toast when it returns 501 + - Wire up "Herunterladen" button on Song DB page to call download endpoint — show error toast + + **Must NOT do**: + - Do NOT implement actual .pro file parsing (placeholder only!) + - Do NOT implement actual .pro file generation (placeholder only!) + + **Recommended Agent Profile**: + - **Category**: `quick` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 4 (with T20-T22, T24) + - **Blocks**: — + - **Blocked By**: T9 + + **References**: + - AGENTS.md line 23: "parser and generator of song files (.pro) are added later, to just add simple placeholder" + - AGENTS.md line 95: "download: download generated .pro file" — placeholder for now + - AGENTS.md line 97: "UploadArea for .pro file ... which should be parsed (this module was integrated later, so show an Exception here)" + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=ProPlaceholderTest` → PASS + - [ ] POST /api/songs/import-pro → HTTP 501 with German error message + - [ ] GET /api/songs/{id}/download-pro → HTTP 501 with German error message + - [ ] UI shows toast/alert with German error when clicking upload or download + + **QA Scenarios:** + ``` + Scenario: .pro upload returns not-implemented error + Tool: Bash (curl) + Steps: + 1. Create dummy file: echo 'test' > /tmp/test.pro + 2. POST /api/songs/import-pro with file upload + 3. Assert: HTTP 501 + 4. Assert: response contains 'später implementiert' + Expected Result: Placeholder error returned + Evidence: .sisyphus/evidence/task-23-pro-upload.txt + + Scenario: .pro download returns not-implemented error + Tool: Bash (curl) + Steps: + 1. GET /api/songs/1/download-pro + 2. Assert: HTTP 501 + 3. Assert: response contains 'später implementiert' + Expected Result: Placeholder error returned + Evidence: .sisyphus/evidence/task-23-pro-download.txt + ``` + + **Commit**: YES + - Message: `feat: add .pro file upload/download placeholders (NotImplementedException)` + - Files: `app/Exceptions/ProParserNotImplementedException.php`, routes + - Pre-commit: `php artisan test` + +- [ ] 24. Service Finalization + Status Management + + **What to do**: + - TEST: Write Pest tests for finalization — verify status transitions, computed status fields, download placeholder + - Review and finalize the finalization logic (partially in T8): + - `POST /services/{id}/finalize`: set `finalized_at = now()`, verify all prerequisites met + - `POST /services/{id}/reopen`: set `finalized_at = null` + - Prerequisite checks before finalization (warn, don't block): + - All songs should be matched (warn if not) + - All songs should have arrangements selected (warn if not) + - At least 1 sermon slide uploaded (warn if not) + - If prerequisites not met: show confirmation dialog in German listing missing items + - Implement download button behavior: + - Show toast/placeholder message: "Die Show-Erstellung wird in einem zukünftigen Update verfügbar sein." + - Return HTTP 501 from endpoint + - Update service list page (T8) status computation to be fully accurate: + - `songs_mapped`: count of service_songs with song_id vs total service_songs + - `songs_arranged`: count of service_songs with song_arrangement_id vs total matched + - `has_sermon_slides`: count of sermon slides for this service > 0 + - `info_slides_count`: count of info slides where expire_date >= service.date + - `finalized_at`: timestamp or null + - Add computed `isReadyToFinalize` accessor on Service model + + **Must NOT do**: + - Do NOT block finalization on missing prerequisites (warn only) + - Do NOT implement actual show download (placeholder only) + + **Recommended Agent Profile**: + - **Category**: `unspecified-high` + - **Skills**: [] + + **Parallelization**: + - **Can Run In Parallel**: YES + - **Parallel Group**: Wave 4 (with T20-T23) + - **Blocks**: FINAL + - **Blocked By**: T15, T16, T17, T18 + + **References**: + - `app/Http/Controllers/ServiceController.php` — finalize/reopen actions (from T8) + - `app/Models/Service.php` — service model from T2 + - AGENTS.md lines 36-38: "if finalized: ReOpen and Download", "if NOT finalized: Edit and Finalize" + - AGENTS.md line 40: "ReOpen and Finalize just change the status of the service" + + **Acceptance Criteria**: + - [ ] `php artisan test --filter=FinalizationTest` → PASS + - [ ] Finalize sets `finalized_at` timestamp + - [ ] ReOpen clears `finalized_at` + - [ ] Finalize with missing prerequisites shows German warning dialog + - [ ] Download button shows placeholder toast + - [ ] All status fields compute correctly on service list + + **QA Scenarios:** + ``` + Scenario: Finalize service with warnings + Tool: Playwright + Preconditions: Service with 2 songs — 1 matched, 1 unmatched. No sermon slides. + Steps: + 1. Navigate to /services + 2. Click 'Abschließen' on the service + 3. Assert: confirmation dialog appears with warnings: + - '1 Song ist noch nicht zugeordnet' + - 'Keine Predigtfolien hochgeladen' + 4. Confirm finalization + 5. Assert: service now shows finalized_at timestamp + 6. Assert: 'Wieder öffnen' and 'Herunterladen' buttons shown + Expected Result: Finalization with warning but not blocked + Evidence: .sisyphus/evidence/task-24-finalize-warnings.png + + Scenario: Download placeholder + Tool: Playwright + Preconditions: Service is finalized + Steps: + 1. Click 'Herunterladen' on finalized service + 2. Assert: toast/message appears with 'zukünftigen Update' text + Expected Result: Placeholder message shown + Evidence: .sisyphus/evidence/task-24-download-placeholder.png + ``` + + **Commit**: YES + - Message: `feat: add service finalization with status checks and download placeholder` + - Files: `app/Http/Controllers/ServiceController.php` (extend), `app/Models/Service.php` (accessors) + - Pre-commit: `php artisan test` +--- + +## Final Verification Wave (MANDATORY — after ALL implementation tasks) + +> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run. + +- [ ] F1. **Plan Compliance Audit** — `oracle` + Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan. Verify ALL UI text is German with "Du" form. + Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT` + +- [ ] F2. **Code Quality Review** — `unspecified-high` + Run `php artisan test` + linter. Review all changed files for: `as any`/`@ts-ignore`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names. Verify TDD: test files exist for every feature. Verify no Tailwind in DomPDF templates. + Output: `Build [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT` + +- [ ] F3. **Real Manual QA** — `unspecified-high` (+ `playwright` skill) + Start from clean state (`docker-compose up`). Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration. Test edge cases: empty state, invalid input, rapid actions. All in German UI. Save to `.sisyphus/evidence/final-qa/`. + Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT` + +- [ ] F4. **Scope Fidelity Check** — `deep` + For each task: read "What to do", read actual diff. Verify 1:1 match. Check "Must NOT do" compliance. Detect cross-task contamination. Flag unaccounted changes. Verify no CTS API writes. Verify .pro parser is placeholder only. + Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | VERDICT` + +--- + +## Commit Strategy + +- **T0**: `chore: verify CTS API token auth and package compatibility` +- **T1**: `feat: scaffold Laravel + Breeze Vue + Docker setup` +- **T2**: `feat: add database schema for services, songs, arrangements, slides` +- **T3**: `feat: implement ChurchTools OAuth login via Socialite` +- **T4**: `feat: add CTS API sync service and artisan command` +- **T5**: `feat: add file conversion service (image, PPT, ZIP)` +- **T6**: `feat: create shared Vue layout with nav, user, refresh button` +- **T7**: `feat: configure email and missing-song notification mailable` +- **T8**: `feat: implement service list page with status indicators` +- **T9**: `feat: add Song model with CRUD and relationships` +- **T10**: `feat: create reusable slide upload component` +- **T11**: `feat: add arrangement model and drag-drop configurator` +- **T12**: `feat: implement CCLI-based song matching service` +- **T13**: `feat: add translation service with URL scraping` +- **T14**: `feat: create service edit page layout with block navigation` +- **T15**: `feat: implement information block with expire dates` +- **T16**: `feat: implement moderation block for service slides` +- **T17**: `feat: implement sermon block for service slides` +- **T18**: `feat: implement songs block with matching and arrangements` +- **T19**: `feat: add song preview modal and PDF download` +- **T20**: `feat: implement Song DB page with list and search` +- **T21**: `feat: add Song DB edit popup with arrangement config` +- **T22**: `feat: implement song translation two-column editor` +- **T23**: `feat: add .pro file upload/download placeholders` +- **T24**: `feat: implement service finalization and status management` + +--- + +## Success Criteria + +### Verification Commands +```bash +docker-compose up -d # Expected: all containers healthy +docker-compose exec app php artisan test # Expected: all tests pass +docker-compose exec app php artisan migrate:status # Expected: all migrations ran +curl -I http://localhost:8000 # Expected: 302 redirect to OAuth login +``` + +### Final Checklist +- [ ] All "Must Have" requirements present and working +- [ ] All "Must NOT Have" guardrails respected +- [ ] All tests pass (TDD — comprehensive coverage) +- [ ] All UI text in German with "Du" form +- [ ] Docker deployment works end-to-end +- [ ] Auto-save functional on every interactive element +- [ ] .pro parser/generator throws NotImplementedException +- [ ] Finalized download is placeholder