Refactor lyric storage so each song owns its sections instead of sharing global labels. Adds song_sections (per song+label) owning song_slides; labels stay global ProPresenter group tags (name/color/macro). Arrangements now reference sections, so editing/importing one song no longer corrupts others that share a label name. - New: song_sections table + migration with safe backfill; SongSection, SongArrangementSection models; SongSectionController (edit/add/delete sections, immediate persistence) wired into SongEditModal. - Refactor writers/readers: CcliImport, ProImport, SongService, ArrangementController, SongController, ProExport, PDF, Translation (translation reset now section-scoped), CCLI pairing. - CCLI import fixes: parse SongSelect copy-icon format (German "Vers" abbrev + trailing author), fill empty CTS-synced songs instead of blocking as duplicate, distinct label colors per section kind, import&edit/existing-song open the edit modal (no 404/405), teleport paste dialog above assign dialog, preview shows section content, correct SongSelect search URL, copy-icon instructions. - Bookmarklet clicks #generalCopyLyricsButton and captures clipboard; serves correct host from request. - Export: embed key-visual/background under fixed bundle-relative names. - Tests updated for the section model; new section + isolation coverage.
406 lines
21 KiB
Markdown
406 lines
21 KiB
Markdown
ChurchService presenter software show creator
|
||
|
||
## Description
|
||
|
||
There is tool (churchtools - called cts from now on) to plan a church service, that contains all parts of the service (information, songs, sermon, prayer, etc.).
|
||
|
||
Use CTS API for services: <https://api.church.tools/package-CT.ChurchService.html>.
|
||
|
||
All the wording in the frontend and communication has to be in German with Du, not Sie.
|
||
|
||
## TechStack
|
||
|
||
- Laravel (Vue+Inertia) App, with Sqlite (switchable to MySQL)
|
||
- use DB for caching the API data and just "Update" the DB from the API
|
||
- use CTS API with existing env `CTS_API_TOKEN` (.env for LIVE, .env.example with same content for you) for auth - ONLY DO READS, NO WRITES OR CHANGES ARE ALLOWED
|
||
|
||
## General
|
||
|
||
- Login should be done via OAUTH for all churchtools users (<https://churchtools.academy/de/help/system-einstellungen/oauth-login-systemeinstellungen/oauth-zwischen-zwei-churchtools-systemen/>) and linked to an automatically created local user.
|
||
- There should be Button in the Top Bar, to refresh the Data from the CTS API and a timestamp with the latest refresh.
|
||
- LoggedIn User should be visible in the Top Bar
|
||
- every action should be immediately persistent, no separate "save" button required, unless explicitly described.
|
||
- ProPresenter `.pro` file parser/generator is implemented as a separate composer package (`propresenter/parser`) linked via path repository
|
||
|
||
## The Plan
|
||
|
||
We use the data from the API, to create a form to finalize the service setup and to create a file for the presenter software at the end.
|
||
|
||
1. Show all today or future services in a list, with details (Title, Preacher, 'beamer technican', qty of songs, last changed, finalized_at) and state of the setup (No or Yes at...):
|
||
- x/y songs found and mapped
|
||
- x/y songs verified arrangement
|
||
- sermon slides uploaded
|
||
- X info slides uploaded
|
||
- finalized at
|
||
|
||
2. for every service, show Action Buttons:
|
||
- if finalized: ReOpen and Download
|
||
- if NOT finalized: Edit and Finalize
|
||
|
||
3. - ReOpen and Finalize just change the status of the service
|
||
- Edit shows a form, with these Blocks to edit (details of these blocks are below)
|
||
- information
|
||
- moderation
|
||
- sermon
|
||
- songs
|
||
|
||
## Form Blocks
|
||
|
||
### Block: Information
|
||
|
||
- show list of thumbnails for all uploaded slides with a muted upload date field with uploader name, and a prominent Expiredate field
|
||
- each thumbnail could be delete (softdelete) or can inline change the date with a datepicker
|
||
- add big plus icon/area for drag'n'drop or click for fileupload new files with a datepicker for a date, that was added to all files as expire date
|
||
- automatically show these files to all future services, till the expire date is after the service date
|
||
|
||
### Block: Moderation
|
||
|
||
Same features as `Block Information` but without the datepicker and only relevant for this service.
|
||
|
||
### Block: Sermon
|
||
|
||
Same features as `Block Moderation`.
|
||
|
||
### Block: Songs
|
||
|
||
- Show all songs (Name, CCLI ID, has Translation ..) from the service in the right order.
|
||
- on every update from the CTS API, try to match the song with the CCLI-ID to an existing song from the DB
|
||
- if NOT matched
|
||
- show a button "request creation", which causes an EMAIL to a configured mail address, with the song and the CCLI Id and the ask for create the song
|
||
- and show a searchable select field of all songs in the DB (CCLI ID included and searchable) to manually assign a song from the DB to this service song
|
||
- if song found:
|
||
- if song is translated, show checkbox (default:true) for use the version with translation
|
||
- every song has a body for the arrangement selection/configuration
|
||
- select field with all existing arrangements
|
||
- "add" Button to create a new arangmengt (clone from master order) and ask for a name
|
||
- "clone" Button to clone the selected arragenement and ask for a name
|
||
- show the groups of the arrangement and make possible to rearange or add a group to the arrangement via drag'n'drop, like `rev/form-song-arangment-config.jpg`
|
||
- every song with a selected arrangement ("normal" should selected always as default) should have two buttons:
|
||
- preview: show the text of the song in the order of the arrangement configuration, prominent highlighted which textpart was with group
|
||
- download: download the preview as a nice pdf with header/footer and copyright footer from the Song DB.
|
||
|
||
## File Upload
|
||
|
||
- could be a zip file, contained multiple other files of types below, handle as mass upload
|
||
- could me multiple files, so handle each one as a single file for types below
|
||
- could be an image file (png, jpg, jpeg) -> always convert to jpg 1920x1080 (dont cut parts of an image, keep orientation and ratio)
|
||
- could be a ppt or pptx (powerpoint) -> convert to multiple JPG with 1920x1080 (see jpg convert)
|
||
|
||
## SongDB Import
|
||
|
||
There should be a menu item for songDB in the Top.
|
||
|
||
- it shows all songs from the DB, with created, last update, ccliID, last_used_in_service every song has a delete (soft_delete), download, translate and an edit button
|
||
- edit: shows a popup with Name, CCLI and copyright text (all that is available from song metadata) and the arrangement configurator from the service->song block
|
||
- download: download generated .pro file from the songDB for this song
|
||
- translate: allow add a full text or an URL to the Full text, and then start an editor, that shows two columns for every slide of every group. Left the original text, right a texteditor, with the imported text - always the same line qty of text from the original is used from the given translated text. Save this as translation for this song, and mark it as `with translation`.
|
||
- UploadArea for drag'n'drop and click for upload, to upload a .pro file, a zip file with multiple .pro files, or a bunch of .pro files, which should be parsed (this module was integrated later, so show an Exception here till this was finalized) and added into the song DB.
|
||
|
||
---
|
||
|
||
## CCLI Import
|
||
|
||
Songs can be imported from CCLI SongSelect via a paste-based flow (no server-side scraping — ToS-compliant).
|
||
|
||
### How it works
|
||
|
||
1. User opens a song on **songselect.ccli.com** in their browser (must be logged in with their CCL account)
|
||
2. User copies the full page text (Ctrl+A → Ctrl+C)
|
||
3. User clicks "Aus CCLI importieren" in the SongDB or service form, pastes the text, clicks "Vorschau", then "Importieren"
|
||
|
||
Alternatively, install the **browser bookmarklet** from Settings → CCLI Import. The bookmarklet automates step 2-3 in two clicks.
|
||
|
||
### Key files
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `app/Services/CcliPasteParser.php` | Pure-PHP parser: extracts title/author/CCLI-Nr/year/sections from pasted text |
|
||
| `app/Services/CcliImportService.php` | Upserts Song + Arrangement + Labels + Slides; handles duplicates and soft-delete restore |
|
||
| `app/Services/CcliTranslationPairingService.php` | Auto-pairs CCLI sections with local song labels for translation import |
|
||
| `app/Support/CcliLabels.php` | Section-label regex + EN↔DE name mapping (Verse↔Strophe, Chorus↔Refrain, etc.) |
|
||
| `app/Http/Controllers/CcliPasteController.php` | POST /api/ccli/preview + POST /api/songs/import-from-ccli-paste (3 modes) |
|
||
| `app/Http/Controllers/BookmarkletController.php` | GET /bookmarklets/ccli-import.js — serves the bookmarklet JS |
|
||
| `resources/js/Components/CcliPasteDialog.vue` | Modal: textarea → preview → import buttons (surface-aware) |
|
||
| `resources/js/Pages/Songs/ImportFromCcliPaste.vue` | Bookmarklet redirect landing page |
|
||
| `tests/Fixtures/ccli/` | 22 synthetic CCLI-format fixture files for parser tests |
|
||
|
||
### Test coverage
|
||
|
||
- `tests/Feature/CcliPasteParserTest.php` — parser against all 22 fixtures
|
||
- `tests/Feature/CcliImportServiceTest.php` — upsert, duplicate, restore, transaction
|
||
- `tests/Feature/CcliTranslationPairingServiceTest.php` — label pairing, line distribution
|
||
- `tests/Feature/CcliPasteControllerTest.php` — API endpoints, auth, throttle
|
||
- `tests/e2e/ccli-paste-import.spec.ts` — SongDB import flow
|
||
- `tests/e2e/ccli-bookmarklet.spec.ts` — bookmarklet endpoint + Settings page
|
||
- `tests/e2e/ccli-translation-pairing.spec.ts` — Translate.vue prefill
|
||
|
||
### Maintenance note
|
||
|
||
If SongSelect changes their HTML structure, update the bookmarklet DOM selectors in `resources/js/bookmarklet/ccli-import.ts` (or the inline JS in `BookmarkletController.php`). The server-side parser (`CcliPasteParser.php`) is independent of SongSelect's HTML — it only parses the plain-text lyrics format.
|
||
|
||
---
|
||
|
||
## KeyVisual & Background
|
||
|
||
Each service export can carry a key-visual image (shown as a standalone fallback slide) and a background image (rendered as a media layer behind every song/sermon slide).
|
||
|
||
Resolution is lazy and happens at export time:
|
||
|
||
1. Per-service column (`key_visual_filename` / `background_filename` on the `services` table)
|
||
2. Global default from Settings (`current_key_visual` / `current_background`)
|
||
3. None (slide omitted / no background layer)
|
||
|
||
When a service is finalized, the resolved filenames are snapshotted into the per-service columns so the export is stable even if the global default changes later.
|
||
|
||
### Export naming contract (portable bundles)
|
||
|
||
On export the key-visual and background images are **embedded into the archive** under fixed names and referenced **bundle-relative** inside the `.pro` file (never by absolute path), so exports are portable to the presenter PC:
|
||
|
||
| Image | Embedded filename + `.pro` reference |
|
||
|-------|--------------------------------------|
|
||
| Key-visual | `KEY_VISUAL.jpg` |
|
||
| Background | `BACKGROUND.jpg` |
|
||
|
||
The fixed names are defined as `ServiceImageResolver::KEY_VISUAL_EXPORT_NAME` / `ServiceImageResolver::BACKGROUND_EXPORT_NAME`. The `slideData['background']` array carries `'path' => '<FIXED_NAME>'` with `'bundleRelative' => true`; the image bytes are added to the archive's embedded/media files under that same name (deduplicated per archive). Applies to `.proplaylist` (`PlaylistExportService`) and `.probundle` (`ProBundleExportService`). The bare single-song `.pro` download has no service context and carries no background.
|
||
|
||
### Key files
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `app/Services/ServiceImageResolver.php` | Lazy resolution: per-service column → global Setting → null |
|
||
| `app/Http/Controllers/ServiceImageController.php` | `POST /services/{service}/key-visual` + `POST /services/{service}/background`; scope dialog ("Nur für diesen Service" / "Als Standard setzen") |
|
||
| `app/Services/FileConversionService.php` | Added `convertImageCover()` for COVER-mode 1920×1080 conversion |
|
||
| `app/Services/PlaylistExportService.php` | Injects keyvisual fallback slides and sermon sequence (keyvisual → nametag → slides) |
|
||
| `app/Services/ProExportService.php` | Adds background media layer on song/sermon slides |
|
||
| `resources/js/Components/ServiceImagePanel.vue` | Upload panel with scope dialog; used on the service Edit page |
|
||
| `resources/js/Pages/Services/Edit.vue` | Image panels rendered at the top of the edit form |
|
||
| `resources/js/Pages/Settings.vue` | Global default key-visual and background fields |
|
||
|
||
### Settings keys
|
||
|
||
| Key | Purpose |
|
||
|-----|---------|
|
||
| `current_key_visual` | Global default key-visual filename |
|
||
| `current_background` | Global default background filename |
|
||
|
||
### Routes
|
||
|
||
| Method | Route | Name |
|
||
|--------|-------|------|
|
||
| POST | `/services/{service}/key-visual` | `services.key-visual.store` |
|
||
| POST | `/services/{service}/background` | `services.background.store` |
|
||
|
||
### Parser package changes (commit `582ef85`)
|
||
|
||
- New `slideData['background']` contract: background-layer media action on any slide
|
||
- New `slideData['imageOnly']` flag: image-only slide (no text layer)
|
||
- New Slide read accessors: `hasBackgroundMedia()`, `getBackgroundMediaUrl()`, `getBackgroundMediaFormat()`
|
||
|
||
---
|
||
|
||
## NameTag (Namenseinblender)
|
||
|
||
A name-tag slide is injected into the sermon sequence (between the key-visual and the sermon slides) to display the moderator and/or preacher name on screen.
|
||
|
||
The slide is only generated when the Namenseinblender macro is fully configured in Settings. It renders plain white text and optionally triggers a ProPresenter macro.
|
||
|
||
### Name resolution order
|
||
|
||
1. `moderator_name` / `preacher_name_override` columns on the `services` table (manual override set via Edit form)
|
||
2. Responsible person from the CTS `responsible` JSON field matching the configured role
|
||
3. None (slide omitted)
|
||
|
||
### Key files
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `app/Services/NameTagResolver.php` | Resolves moderator/preacher name: responsible JSON → CTS role → manual override |
|
||
| `app/Services/NameTagSlideBuilder.php` | Builds `slideData` for the nametag slide (plain white text + optional macro) |
|
||
| `app/Http/Controllers/ServiceController.php` | `PATCH /services/{service}/name-overrides` — saves manual name overrides |
|
||
| `resources/js/Pages/Services/Edit.vue` | Name override input fields in the edit form |
|
||
| `resources/js/Pages/Settings.vue` | Namenseinblender submenu with macro name/UUID/collection fields |
|
||
|
||
### Settings keys
|
||
|
||
| Key | Purpose |
|
||
|-----|---------|
|
||
| `namenseinblender_macro_name` | ProPresenter macro name |
|
||
| `namenseinblender_macro_uuid` | ProPresenter macro UUID |
|
||
| `namenseinblender_macro_collection_name` | Macro collection name |
|
||
| `namenseinblender_macro_collection_uuid` | Macro collection UUID |
|
||
|
||
### Routes
|
||
|
||
| Method | Route | Name |
|
||
|--------|-------|------|
|
||
| PATCH | `/services/{service}/name-overrides` | `services.name-overrides.update` |
|
||
|
||
---
|
||
|
||
## Repository Structure
|
||
|
||
Two git repositories, both local (no remote):
|
||
|
||
| Repo | Path | Branch | Purpose |
|
||
|------|------|--------|---------|
|
||
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `cts-presenter-app` | Laravel app (main codebase) |
|
||
| **propresenter** | `/Users/thorsten/AI/propresenter` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
|
||
|
||
The parser is linked via `composer.json` path repository: `"url": "../propresenter"`.
|
||
|
||
## Build, Test, Lint Commands
|
||
|
||
### pp-planer (Laravel App)
|
||
|
||
Local dev runs in **DDEV** (Docker). Site URL: `https://pp-planer.ddev.site`.
|
||
|
||
```bash
|
||
# Easy onboarding wrappers (no DDEV knowledge required):
|
||
./start_dev.sh # ddev start + spawns dev workers in background
|
||
./stop_dev.sh # stops workers and DDEV (use --keep-ddev to leave DDEV running)
|
||
|
||
# Or use DDEV directly:
|
||
ddev start # composer install, key:generate, migrate, npm install, npm run build
|
||
ddev dev # queue + log tail + vite HMR (foreground)
|
||
ddev stop # stop the project
|
||
|
||
# Open a shell inside the web container
|
||
ddev ssh
|
||
|
||
# Frontend
|
||
ddev npm run build
|
||
ddev npm run dev
|
||
|
||
# Run ALL PHP tests (clears config cache first)
|
||
ddev composer test
|
||
ddev exec php artisan test
|
||
|
||
# Single test file
|
||
ddev exec php artisan test tests/Feature/ServiceControllerTest.php
|
||
|
||
# Single test method
|
||
ddev exec php artisan test --filter=test_service_kann_abgeschlossen_werden
|
||
|
||
# Test suite
|
||
ddev exec php artisan test --testsuite=Feature
|
||
ddev exec php artisan test --testsuite=Unit
|
||
|
||
# PHP formatting (Laravel Pint, default preset — no pint.json)
|
||
ddev exec ./vendor/bin/pint
|
||
ddev exec ./vendor/bin/pint --test # check only
|
||
|
||
# E2E tests (requires `ddev start` running; baseURL is https://pp-planer.ddev.site)
|
||
npx playwright test
|
||
npx playwright test tests/e2e/service-list.spec.ts
|
||
|
||
# Migrations
|
||
ddev exec php artisan migrate
|
||
```
|
||
|
||
### propresenter (Parser Module)
|
||
|
||
```bash
|
||
cd /Users/thorsten/AI/propresenter
|
||
|
||
# Run all tests (230 tests)
|
||
./vendor/bin/phpunit
|
||
|
||
# Single test file
|
||
./vendor/bin/phpunit tests/ProFileReaderTest.php
|
||
```
|
||
|
||
## Architecture
|
||
|
||
```
|
||
pp-planer/
|
||
app/Http/Controllers/ # Inertia controllers (Inertia::render or JSON)
|
||
app/Models/ # Eloquent models (factories in database/factories/)
|
||
app/Services/ # Business logic (ChurchToolsService, ProExportService, etc.)
|
||
app/Jobs/ # Queue jobs (PowerPoint conversion)
|
||
app/Mail/ # Mailable classes (German content)
|
||
resources/js/Pages/ # Vue page components (mapped via Inertia::render)
|
||
resources/js/Components/ # Reusable Vue components
|
||
resources/js/Composables/ # Vue composables (useAutoSave)
|
||
resources/js/Layouts/ # AuthenticatedLayout, GuestLayout
|
||
tests/Feature/ # Pest v4 / PHPUnit feature tests
|
||
tests/e2e/ # Playwright browser tests (TypeScript)
|
||
|
||
propresenter/
|
||
src/ # ProFileReader, ProFileGenerator, ProPlaylistGenerator, Song, Group, Slide, Arrangement
|
||
tests/ # PHPUnit 11 tests with #[Test] attributes
|
||
ref/ # .pro fixture files for testing
|
||
```
|
||
|
||
## Code Style -- PHP
|
||
|
||
- **Formatter**: Laravel Pint (default Laravel preset, no custom config)
|
||
- **Imports**: Fully qualified, one per line, alphabetical. App\ first, then Illuminate\, then external.
|
||
- **Constructors**: Promoted properties with `private readonly`. Empty body: `{}` on same line.
|
||
```php
|
||
public function __construct(
|
||
private readonly SongMatchingService $songMatchingService,
|
||
) {}
|
||
```
|
||
- **Return types**: Always present. Use union types for multiple returns: `Response|JsonResponse`.
|
||
- **String concat**: No spaces around `.` operator: `'Fehler: '.$e->getMessage()`
|
||
- **Null safety**: Nullsafe `?->` and null coalescing `??`. Never suppress with `@`.
|
||
- **Models**: `$fillable` array (never `$guarded`). `casts()` method (never `$casts` property). Typed relationship returns (`HasMany`, `BelongsTo`). `Attribute::get()` for computed accessors.
|
||
- **Migrations**: Anonymous class: `return new class extends Migration {`. Methods: `up(): void`, `down(): void`.
|
||
- **Error messages**: German, Du-form. Flash: `->with('success', '...')`. JSON: `'message'` key.
|
||
- **Validation**: Inline `$request->validate([...])` with `Rule::in()` for enums.
|
||
- **Transactions**: `DB::transaction(function () use (...): void { ... })` for multi-step writes.
|
||
- **Constants**: `private const NAME = [...]` for fixed value sets.
|
||
|
||
## Code Style -- Vue / Frontend
|
||
|
||
- **Vue 3 Composition API** only. Always `<script setup>`. No Options API.
|
||
- **Props**: `defineProps({ name: { type: Type, default: value } })`
|
||
- **Emits**: `defineEmits(['kebab-case-event'])`
|
||
- **Model**: `defineModel({ type: String, required: true })` for v-model binding.
|
||
- **Imports**: `@/` alias for `resources/js/`. Vue from `'vue'`, Inertia from `'@inertiajs/vue3'`.
|
||
- **Functions**: Prefer `function name() {}` declarations (not `const name = () => {}`).
|
||
- **State**: `ref()` for reactive, `computed()` for derived, `watch()` for side effects.
|
||
- **Routing**: `route('name', params)` via Ziggy. `router.post/get/delete` from Inertia with `{ preserveScroll: true }`.
|
||
- **Styling**: Tailwind CSS v4 utility classes inline. `<style scoped>` only when necessary (e.g. drag-and-drop).
|
||
- **Test IDs**: `data-testid="..."` on interactive elements for Playwright.
|
||
- **All UI text**: German, Du-form (not Sie).
|
||
|
||
## Code Style -- Tests
|
||
|
||
### PHP Tests (Pest v4 + PHPUnit)
|
||
|
||
Both styles are used:
|
||
- **Function-based Pest**: `test('snake_case german description', function () { ... });` with `beforeEach`
|
||
- **Class-based**: `final class NameTest extends TestCase { use RefreshDatabase; public function test_snake_case(): void {} }`
|
||
- **Auth**: `$this->actingAs(User::factory()->create())`
|
||
- **Vite**: Call `$this->withoutVite()` before Inertia page render tests
|
||
- **Time**: `Carbon::setTestNow('2026-03-01 10:00:00')` for deterministic dates
|
||
- **Inertia assertions**: `$response->assertInertia(fn ($page) => $page->component('...')->has('...')->where('...'))`
|
||
- **DB**: In-memory SQLite (`phpunit.xml`: `DB_CONNECTION=sqlite`, `DB_DATABASE=:memory:`)
|
||
- **Helper functions**: Define at file level for shared test setup (e.g. `function makePngUpload(...)`)
|
||
- **Storage**: `Storage::fake('public')` in `beforeEach` for file upload tests
|
||
|
||
### ProPresenter Parser Tests (PHPUnit 11)
|
||
|
||
- Uses `#[Test]` attribute (not `test_` prefix)
|
||
- `setUp()`/`tearDown()` for temp directory management
|
||
- Fixtures in `ref/` directory, loaded via `dirname(__DIR__, 2).'/ref/...'`
|
||
- Strict mode: `failOnRisky`, `failOnWarning`, `beStrictAboutOutputDuringTests`
|
||
|
||
### E2E Tests (Playwright)
|
||
|
||
- TypeScript in `tests/e2e/`, baseURL `https://pp-planer.ddev.site` (HTTPS, `ignoreHTTPSErrors: true` set in `playwright.config.ts`)
|
||
- Auth via `auth.setup.ts` (XSRF token + `/dev-login` endpoint, saves state to `.auth/user.json`)
|
||
- Selectors: `page.getByTestId('...')`, `page.getByText('...')`, `page.getByRole('...')`
|
||
- German text assertions: `await expect(page.getByText('Mit ChurchTools anmelden')).toBeVisible()`
|
||
|
||
## Key Constraints
|
||
|
||
- **CTS API is READ-ONLY** -- never write/modify data via ChurchTools API
|
||
- **Immediate persistence** -- all user actions save instantly (no separate save button)
|
||
- **German locale** -- `APP_LOCALE=de`, all UI text German Du-form
|
||
- **File uploads** -- images convert to JPG 1920x1080 (maintain aspect, no crop); PPT/PPTX to multiple JPGs
|
||
- **Named routes** -- all routes have names, use `route('name')` everywhere
|
||
- **No type suppression** -- never use `as any`, `@ts-ignore`, `@ts-expect-error` in frontend
|
||
- **SoftDeletes** -- used on Song and Slide models; use `whereNull('deleted_at')` in manual queries
|