pp-planer/.sisyphus/plans/cts-presenter-app.md

2115 lines
98 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
- [x] 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`
- [x] 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`
- [x] 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`
- [x] 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`
- [x] 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`
- [x] 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)
- [x] 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`
- [x] 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`
- [x] 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`
- [x] 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`
- [x] 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