# 02 – Ziel-Architektur (Laravel 12) ## 1. Hohes Level ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ Pressekonto Backend (pressekonto.test) │ │ Laravel 12 · PHP 8.4 · Livewire 4 · Volt · Flux UI 2 │ │ │ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌─────────┐ │ │ │ Admin-UI │ │ Customer- │ │ Public API │ │ Console │ │ │ │ Livewire/Volt │ │ Portal │ │ v1 (Sanctum) │ │ Sched. │ │ │ │ (admin.*) │ │ (customer.*) │ │ │ │ + Jobs │ │ │ └──────┬────────┘ └──────┬────────┘ └──────┬────────┘ └────┬────┘ │ │ │ │ │ │ │ │ └──────────────────┴──────┬───────────┴──────────────────┘ │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ Application Services (app/Services/*) │ │ │ │ PressReleaseService · CompanyService · InvoiceService … │ │ │ └───────────────────────────────┬─────────────────────────────────┘ │ │ │ │ │ ┌────────────────────────┼─────────────────────────┐ │ │ ▼ ▼ ▼ │ │ ┌──────────────┐ ┌────────────────┐ ┌──────────────┐ │ │ │ Models │ │ Actions │ │ Integrations│ │ │ │ (Eloquent) │ │ (Invokable) │ │ Stripe, Mail│ │ │ └──────┬───────┘ └────────┬───────┘ └──────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ MySQL 8 (einheitliche DB, `portal` disambiguiert Tenants) │ │ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ │ Frontend-Apps (presseecho.test, businessportal24.test) nutzen entweder │ │ dieselbe DB (direkte Reads) oder /api/v1. │ └─────────────────────────────────────────────────────────────────────────┘ ``` --- ## 2. Verzeichnis-Layout (Ziel) ``` app/ ├── Actions/ ← Invokable Actions (1 Klasse = 1 Use-Case) │ ├── PressRelease/ │ │ ├── CreatePressRelease.php │ │ ├── PublishPressRelease.php │ │ ├── RejectPressRelease.php │ │ └── CheckForBlacklistedWords.php │ ├── Company/ │ ├── Invoice/ │ │ ├── GenerateInvoicesForPeriod.php │ │ └── SendInvoiceReminder.php │ ├── Newsletter/ │ └── Fortify/ ← bereits von Fortify angelegt │ ├── Console/ │ └── Commands/ │ ├── Migration/ ← einmalige Daten-Migrations-Commands │ │ ├── ImportLegacyUsers.php │ │ ├── ImportLegacyCompanies.php │ │ └── ImportLegacyPressReleases.php │ └── Maintenance/ │ └── PurgeExpiredTokens.php │ ├── Enums/ │ ├── Portal.php ← Presseecho | Businessportal24 │ ├── PressReleaseStatus.php │ ├── InvoiceStatus.php │ ├── PaymentMethod.php │ ├── PaymentStatus.php │ ├── UserPaymentOptionStatus.php │ ├── PaymentOptionType.php │ ├── CompanyType.php │ └── RegistrationType.php │ ├── Events/ │ ├── PressRelease/ │ │ ├── PressReleasePublished.php │ │ └── PressReleaseRejected.php │ └── Invoice/ │ ├── InvoiceCreated.php │ └── InvoicePaid.php │ ├── Http/ │ ├── Controllers/ │ │ └── Api/ │ │ └── V1/ │ │ ├── PressReleaseController.php │ │ ├── PressReleaseImageController.php │ │ ├── CompanyController.php │ │ └── NewsletterController.php │ ├── Middleware/ │ │ ├── SetCurrentPortal.php ← setzt default-Portal aus Domain │ │ └── EnsureCustomerRole.php ← für customer.*-Routen │ ├── Requests/ ← FormRequests │ │ ├── PressRelease/ │ │ ├── Company/ │ │ └── Api/V1/ │ └── Resources/V1/ │ ├── PressReleaseResource.php │ ├── CompanyResource.php │ └── ... │ ├── Jobs/ │ ├── GenerateInvoices.php │ ├── SendInvoiceReminders.php │ ├── CreateRecurringPayments.php │ └── ExpirePaymentOptions.php │ ├── Livewire/ │ ├── Actions/ ← existiert │ ├── Admin/ ← existiert (users) │ ├── Customer/ ← NEU: Self-Service-Komponenten │ │ ├── Dashboard.php │ │ ├── PressReleases/Index.php │ │ ├── PressReleases/Create.php │ │ ├── Invoices/Index.php │ │ ├── Tokens/Index.php │ │ └── Profile.php │ └── Auth/ │ ├── MagicLinkRequest.php ← NEU: "Login per Mail-Link" │ └── MagicLinkConsume.php ← NEU: prüft signed URL │ ├── Mail/ │ ├── PressReleasePublished.php │ ├── PressReleaseRejected.php │ ├── InvoiceCreated.php │ ├── InvoiceReminder.php │ ├── MagicLoginLink.php ← NEU │ ├── GoLivePasswordReset.php ← NEU: Kampagnen-Mail beim Go-Live │ └── NewsletterConfirmation.php │ ├── Models/ │ ├── User.php ← existiert (erweitern) │ ├── Profile.php ← entspr. sfGuardUserProfile │ ├── Company.php │ ├── Contact.php │ ├── Category.php │ ├── CategoryTranslation.php ← optional │ ├── PressRelease.php │ ├── PressReleaseImage.php │ ├── Newsletter/ │ │ └── Subscription.php │ ├── Invoice.php ← NEU-Billing (Stripe-getrieben) │ ├── InvoiceBillingAddress.php │ ├── LegacyInvoice.php ← ARCHIV (read-only) │ ├── BillingAddress.php ← am User │ ├── PaymentOption.php ← neue Produkte, Stripe-Price-Mapping │ ├── PaymentOptionTranslation.php ← optional │ ├── UserPaymentOption.php ← inkl. `grandfathered_until` │ ├── UserPayment.php │ ├── FooterCode.php ← bleibt (Q-08b) │ ├── MagicLink.php ← NEU: Magic-Link-Token (siehe §5.2) │ ├── Blacklist.php │ └── LegacyImport/ │ └── ImportMap.php ← persistente ID-Mapping-Tabelle │ │ (entfallen: Coupon, PromotionLink – D-14/D-16) │ ├── Policies/ │ ├── PressReleasePolicy.php │ ├── CompanyPolicy.php │ └── ... │ ├── Providers/ │ ├── AppServiceProvider.php │ ├── AuthServiceProvider.php │ ├── FortifyServiceProvider.php │ └── ThemeServiceProvider.php ← existiert │ ├── Scopes/ │ ├── CurrentPortalScope.php ← optional: Default-Tenant-Filter │ └── ActiveScope.php │ ├── Services/ ← domänenlogische Services │ ├── PressReleaseService.php │ ├── CompanyService.php │ ├── InvoiceService.php │ │ └── InvoiceNumberGenerator.php │ ├── NewsletterService.php │ ├── StripeService.php ← Wrapper um Laravel Cashier (optional) oder Stripe-PHP │ ├── ImageService.php ← Intervention-Image + Storage │ └── BlacklistService.php │ └── Support/ ├── Auth/ │ └── MagicLinkGenerator.php ← signed URLs mit kurzer TTL └── Slug/ └── SluggableTrait.php ``` --- ## 3. Konventionen - **Naming**: `snake_case` für DB-Spalten, `camelCase`/`StudlyCase` für PHP, englisch für Code, deutsch für User-Facing-Strings. - **Primary Keys**: `id` BIGINT unsigned, `ulid` optional für extern-sichtbare Ressourcen. - **Timestamps überall** (`$timestamps = true`), Soft Deletes dort, wo Datenhistorie wichtig ist (User, Company, PressRelease, Invoice). - **Portal-Scoping**: Spalte `portal` (`enum: presseecho | businessportal24 | both`) auf allen mandantenfähigen Entitäten. `CurrentPortalScope` filtert im Frontend; Admin-Panel zeigt beides (mit Filter-Dropdown). - **Legacy-IDs**: Jede importierte Entität hat `legacy_portal` + `legacy_id` + Unique-Index. So bleiben API-Clients kompatibel (Mapping-Layer). - **Enums**: Backed Enums mit Label-Methode: ```php enum PressReleaseStatus: string { case Draft = 'draft'; case Review = 'review'; case Published = 'published'; case Rejected = 'rejected'; case Archived = 'archived'; public function label(): string { /* … */ } } ``` - **Action-Pattern** über Controller hinaus: Controller/Livewire rufen Actions; Actions nutzen Services; Services kapseln Eloquent. - **FormRequests** für Validierung; in Livewire über `rules()`-Methode + `ValidatesWhenResolvedTrait`. --- ## 4. Datenbank-Setup - **Connection** `mysql` → Ziel-DB des neuen Backends. - Optionale **Legacy-Connections**: `legacy_presseecho` + `legacy_businessportal` (read-only) – nur während der Import-Phase. Definition in `config/database.php`: ```php 'connections' => [ 'mysql' => [/* Primary */], 'legacy_presseecho' => [/* read-only */], 'legacy_businessportal' => [/* read-only */], ], ``` --- ## 5. Auth-Konzept ### 5.1 Rollen (Spatie Permission) | Rolle | Wer | Zugang | |---|---|---| | `admin` | Interne Mitarbeiter | `admin.*`-Routen, alle Aktionen | | `editor` | Interne Redaktion | `admin.*`-Routen mit eingeschränkten Permissions | | `customer` | Company-Ansprechpartner | `customer.*`-Routen (Self-Service) | | `api-only` | Automatisierte API-Clients | nur `/api/v1/*` via Sanctum, kein Web-Login | ### 5.2 Login-Wege - **Passwort-Login (Admin + Customer)**: Fortify (bereits konfiguriert). - **2FA**: Fortify-Standard (Spalten bereits in `users`). Pflicht für Admins, optional für Customer. - **Magic-Link (Customer + Initial-Admin-Access)** ⭐ **NEU (D-10)**: - Nutzer gibt E-Mail ein → System versendet signed URL (TTL 15 min, Single-Use). - Tabelle `magic_links`: `id, user_id, token_hash, purpose (login|press_release_access|invoice_access), expires_at, consumed_at, ip_address`. - Insbesondere für Companies ohne bestehenden Login (Legacy: viele Companies haben API-Key aber kein Passwort). - Nach Verbrauch optional sofortiger Redirect zur Zielseite (z. B. bestimmte Pressemitteilung). - **Passwort-Reset**: Fortify-Standard; beim Go-Live **Massen-Mailing** via `GoLivePasswordReset`-Mailable. - **Legacy-Passwörter (sha1+salt)**: **werden NICHT übernommen (D-09)**. Beim ersten Login-Versuch mit Legacy-Account → Hinweis "Bitte Passwort zurücksetzen oder Magic-Link anfordern". ### 5.3 API-Auth - **Sanctum Personal Access Tokens only**. Scopes: `press-releases:write`, `press-releases:read`, `companies:read`, etc. - **Cut-over (D-05)**: Keine Kompatibilitätsschicht für Legacy `api_key`. Alte Clients bekommen Fehlermeldung mit Hinweis auf Migrations-URL. - Tokens erstellbar / löschbar unter: - Admin: `admin/users/{user}/tokens` - Customer: `account/tokens` --- ## 6. Multi-Domain & Portal-Scope - Pro Domain ein Frontend, aber alle Admin-Screens auf `pressekonto.test`. - Middleware `SetCurrentPortal`: - Web (presseecho.test / businessportal24.test) → `app()->instance('current_portal', Portal::Presseecho|Businessportal24)` - Admin → Portal-Auswahl via Dropdown, gespeichert in Session (`current_portal`) - Global Scope `CurrentPortalScope` auf `PressRelease`, `Company`, `Newsletter` etc. – abschaltbar via `->withoutGlobalScope(CurrentPortalScope::class)`. --- ## 7. Payment (Stripe only) – **komplett neu (D-03/D-12/D-13)** - Paket: **`laravel/cashier` (Stripe)** für Subscription-Lifecycle (entscheiden in Phase 8). - **Keine Rechnungs-Zahlungsweise mehr** – ausschließlich Stripe (Card, SEPA, optional Klarna). - **Keine Legacy-PaymentOption-Migration** – neue Produkte/Stripe-Prices werden vom Auftraggeber definiert. - **`PaymentOption`-Tabelle**: interne Produkt-Definition mit `stripe_product_id` + `stripe_price_id`. - **Grandfathering**: Spalte `grandfathered_until` auf `UserPaymentOption`. Aktive Alt-User behalten Konditionen bis zu diesem Datum (aus Legacy-`contract_date` + Laufzeit abgeleitet). Scheduler-Job `ExpireGrandfatheredSubscriptions` stößt automatisch Umstellung an. - Webhook-Endpoint `/webhooks/stripe` → Event-Handler aktualisieren `UserPaymentOption` + `UserPayment` + `Invoice`. --- ## 8. Invoicing (neu) + Legacy-Archiv ### Neuer Rechnungslauf - PDF: `barryvdh/laravel-dompdf`, Template: `resources/views/pdf/invoice.blade.php`. - Trigger: Stripe-Webhook `invoice.payment_succeeded` → eigene `Invoice` generieren, PDF erstellen, Mail senden. - `InvoiceNumberGenerator`: Format `{yyyy}{mm}-{seq}` (modern, getrennt von Legacy-Nummernkreis). - Mahnwesen: stark vereinfacht, nur für Stripe-Failed-Payments (automatische Stripe-Retry-Logik + zusätzliches Reminder-Mailing). ### Legacy-Archiv ⭐ D-12 - Separate Tabelle `legacy_invoices` (read-only). - Vollständiger DB-Import aller Legacy-Rechnungen inkl. Status, Beträge, Datum, Zahlart, Raw-Snapshot und User-Zuordnung. - PDFs werden bei Abruf/on demand aus den archivierten Legacy-Daten generiert. Ein abgelegter `pdf_path` ist nur Cache/Export, nicht die führende Quelle. - Zugriff: - Admin: volle Liste aller Legacy-Rechnungen - Customer: nur eigene (Filter über `user_id`/`company_id`-Mapping aus Legacy-Import) - **Keine Neuausstellung, kein Mahnwesen** auf Legacy-Rechnungen. --- ## 9. Images / Media - Storage-Disk `public` (lokal) für Dev, `s3` für Prod. - `ImageService` kapselt Upload, Thumbnail-Generierung (Intervention Image), Cleanup. - URL-Schema neu: `/storage/images/press-releases/{id}/{variant}.{ext}`. - **Alte URLs**: Redirect-Regel in `routes/web.php` oder dedizierte Middleware. --- ## 10. i18n-Strategie (D-07: DE + EN initial) Legacy nutzt Doctrine-I18n-Behavior → eigene `*_translation`-Tabellen (Salutation_Translation, Category_Translation, PaymentOption_Translation). Ziel: - **UI-Locales**: `de` (primär), `en` (initial aktiviert). - **Content-Locales**: `de`, `en` – mindestens für `Category`, optional für `PressRelease`. | Option | Für / Gegen | |---|---| | `spatie/laravel-translatable` (JSON-Spalten) | + schlank, – keine FTS/Sortierung per Sprache | | Klassische Translation-Tables (Modell `CategoryTranslation`) | + SQL-fähig, – mehr Code | **Empfehlung:** Klassische Translation-Tables für `Category` (weil Slug/Name pro Sprache gebraucht werden); JSON für Static-Master wie Salutation. Beim Seeding werden DE + EN direkt für Kategorien und Salutations angelegt. --- ## 11. Testing - **Pest 3**; Factories, Seeders, `RefreshDatabase`-Trait. - Feature-Tests für Livewire-Screens, Actions, API-Endpoints. - Architektur-Tests (Pest `arch()`) für Namenskonventionen. - Datenimport-Tests: Import-Command gegen Sample-SQL-Dumps in `database/legacy-fixtures/`. --- ## 12. Code-Qualität - **Laravel Pint** (bereits konfiguriert) für Code-Style. - **PHPStan/Larastan** Level 5–6 auf `app/` (empfohlen, kein Muss in Phase 1). - **PHP-CS-Fixer** nicht nötig (Pint deckt ab). - **Pre-commit-Hook**: `pint --dirty` + Tests.