12-05-2026 Frontend dev
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
Kevin Adametz 2026-05-12 18:32:33 +02:00
parent 405df0a122
commit 5b8bdf4182
779 changed files with 480564 additions and 6241 deletions

View file

@ -0,0 +1,441 @@
# 04 Datenmodell (Mapping Doctrine → Eloquent)
Dieses Dokument beschreibt das **neue DB-Schema** und wie die Legacy-Tabellen dorthin gemappt werden. Ziel: Saubere Laravel-Konventionen, `portal`-Scoping und Legacy-IDs für Imports.
## Gemeinsame Konventionen
- Jede Tabelle hat: `id` (BIGINT UNSIGNED AI), `created_at`, `updated_at`.
- Fachliche Entitäten haben: `deleted_at` (Soft-Delete).
- Multi-Portal-Entitäten haben zusätzlich: `portal` (ENUM `presseecho`, `businessportal24`, `both` `both` nur für Stammdaten).
- Importierte Datensätze haben: `legacy_portal` (ENUM), `legacy_id` (BIGINT), UNIQUE `(legacy_portal, legacy_id)`.
- FK-Spalten: `*_id` BIGINT UNSIGNED, constrainted, i.d.R. `ON DELETE` wie in Legacy.
- UUIDs nur dort, wo externe Referenzen nötig (z.B. öffentliche PM-Links): Spalte `uuid`, indiziert.
---
## 1. Users & Profil
### `users` (erweitert das bestehende Schema)
| Spalte | Typ | Legacy-Feld | Bemerkung |
|---|---|---|---|
| id | BIGINT PK | — | Neu |
| name | VARCHAR(120) | — | Display-Name |
| email | VARCHAR(190) UNIQUE | `sfGuardUserProfile.email` | |
| email_verified_at | TIMESTAMP | | |
| password | VARCHAR(255) NULL | | bcrypt; **null erlaubt** (Magic-Link-Only-User wie viele Companies) |
| remember_token | VARCHAR(100) | sfGuardRememberKey | |
| two_factor_* | (bestehend) | | Fortify |
| registration_type | ENUM('agency','company','apiuser','existing_*') | `sfGuardUserProfile.registration_type` | |
| language | CHAR(2) DEFAULT 'de' | `sfGuardUserProfile.language` | |
| portal | ENUM('presseecho','businessportal24','both') | | Standard Portal |
| is_active | BOOL DEFAULT 1 | `sfGuardUser.is_active` | |
| is_super_admin | BOOL DEFAULT 0 | `sfGuardUser.is_super_admin` | nur Migrationshilfe; später via Spatie-Rolle |
| last_login_at | TIMESTAMP | `sfGuardUser.last_login` | |
| last_login_ip | VARCHAR(45) | `sfGuardUser.ip_address` | |
| gdpr_consent_at | TIMESTAMP NULL | | Neu, für Consent-Tracking |
| legacy_portal | ENUM | | Import-Spur |
| legacy_id | BIGINT | `sfGuardUser.id` | |
| deleted_at | TIMESTAMP | | |
> **Nicht übernommen (D-09):** Legacy-Password-Hash (`sha1+salt`) und Legacy-`api_key` wandern **nicht** in die neue `users`-Tabelle. Bei Go-Live bekommen alle User eine Reset-Mail; API-Clients müssen sich neuen Sanctum-Token generieren.
### `profiles` (1:1 zu User, entspricht Rest von `sfGuardUserProfile`)
| Spalte | Typ | Legacy-Feld |
|---|---|---|
| id, user_id (UK) | | `user_id` |
| salutation_key | VARCHAR(20) | `salutation_id` → Config-Key (z.B. `mr`,`mrs`) |
| title | VARCHAR(80) | `title` |
| first_name, last_name | VARCHAR(80) | |
| phone | VARCHAR(40) | |
| address | VARCHAR(1000) | |
| country_code | CHAR(2) | `country_id` → ISO Code aus Config |
| birthdate | DATE | |
| backlink_url | VARCHAR(255) | |
| show_stats | BOOL | |
| validation_date | TIMESTAMP | |
| contract_date | TIMESTAMP | |
| validate_token | VARCHAR(32) | `validate` |
| tax_id_number | VARCHAR(255) | |
| tax_exempt | BOOL | |
| tax_exempt_reason | VARCHAR(1000) | |
| disable_footer_code | BOOL | |
| timestamps | | |
### Rollen/Rechte (Spatie)
- Tabellen: `roles`, `permissions`, `model_has_roles`, `model_has_permissions`, `role_has_permissions` (von Spatie).
- Initial-Rollen: `admin`, `editor`, `customer`, `api-only`.
- Migration: `sfGuardGroup` → Mapping auf neue Rollen (nicht 1:1!), `sfGuardPermission` → Permissions.
### `magic_links` ⭐ NEU (D-10)
| Spalte | Typ | Bemerkung |
|---|---|---|
| id | BIGINT PK | |
| user_id | FK `users.id` | |
| token_hash | CHAR(64) UNIQUE | sha256 des ausgegebenen Tokens |
| purpose | ENUM('login','press_release_access','invoice_access','initial_setup') | |
| payload | JSON NULL | z. B. `{"press_release_id": 42}` |
| expires_at | TIMESTAMP | TTL typisch 15 min |
| consumed_at | TIMESTAMP NULL | Single-Use |
| ip_requested | VARCHAR(45) | |
| ip_consumed | VARCHAR(45) NULL | |
| timestamps | | |
Scheduler-Job `PurgeExpiredMagicLinks` räumt alte/konsumierte Einträge > 30 Tage auf.
---
## 2. Companies & Contacts
### `companies`
| Spalte | Typ | Legacy (`Company`) |
|---|---|---|
| id | BIGINT PK | |
| portal | ENUM | |
| owner_user_id | BIGINT NULL | `user_id` |
| type | ENUM('agency','company') DEFAULT 'company' | `ctype` |
| name | VARCHAR(255) | |
| slug | VARCHAR(255) UNIQUE WITHIN portal | (Sluggable) |
| address | VARCHAR(1000) | |
| country_code | CHAR(2) | `country_id` |
| phone | VARCHAR(40) | |
| fax | VARCHAR(40) | |
| email | VARCHAR(190) | |
| website | VARCHAR(190) | |
| logo_path | VARCHAR(255) | `logo` (Legacy, minimal) |
| logo_variants | JSON NULL | (neu, D-05: `{sq,wide,dark}`) |
| is_active | BOOL DEFAULT 1 | |
| disable_footer_code | BOOL | |
| legacy_portal, legacy_id | | |
| timestamps, deleted_at | | |
### `company_user` (Pivot)
| user_id (PK) | company_id (PK) | role ENUM('member','responsible','owner') |
→ Vereinigt `CompanyUser` + `ResponsibleCompanyUser` in eine Pivot-Tabelle mit `role`.
### `contact_user` (Pivot)
| user_id (PK) | contact_id (PK) |
→ Direkte User-Kontakt-Zuordnung für den Admin- und Customer-Kontext. Im Legacy-System wurde diese Zugehörigkeit indirekt aus `company_user`/`responsible_company_user` plus `contacts.company_id` abgeleitet. Der Import befüllt die Tabelle über `legacy:import --step=link-associations` idempotent aus bestehenden Firmenzuordnungen.
### `contacts`
| Spalte | Typ | Legacy (`Contact`) |
|---|---|---|
| id | BIGINT | |
| company_id | FK `companies.id` | |
| salutation_key | VARCHAR(20) | `salutation_id` |
| title | VARCHAR(80) | |
| first_name, last_name | VARCHAR(80) | |
| responsibility | VARCHAR(255) | |
| phone, fax | VARCHAR(40) | |
| email | VARCHAR(190) | |
| legacy_portal, legacy_id | | |
| timestamps, deleted_at | | |
---
## 3. Kategorien
### `categories`
| id | parent_id NULL | portal | is_active | legacy_*, timestamps |
### `category_translations`
| id | category_id FK | locale CHAR(2) | name VARCHAR(255) | slug VARCHAR(255) | description TEXT |
| UNIQUE (category_id, locale) |
---
## 4. Pressemitteilungen
### `press_releases`
| Spalte | Typ | Legacy (`PressRelease`) |
|---|---|---|
| id | BIGINT | |
| uuid | CHAR(36) UNIQUE | neu für öffentliche Links |
| portal | ENUM | |
| user_id | FK | |
| company_id | FK NULL | |
| category_id | FK | |
| language | CHAR(2) | |
| title | VARCHAR(255) | |
| slug | VARCHAR(255) | (unique: portal+language+slug) |
| text | MEDIUMTEXT | |
| backlink_url | VARCHAR(255) | |
| keywords | VARCHAR(255) | |
| status | ENUM('draft','review','published','rejected','archived') | `status` string |
| hits | INT UNSIGNED DEFAULT 0 | |
| teaser_begin | INT UNSIGNED | |
| teaser_end | INT UNSIGNED | |
| no_export | BOOL | |
| published_at | TIMESTAMP NULL | neu |
| legacy_portal, legacy_id | | |
| timestamps, deleted_at | | |
### `press_release_images` (erweitert, D-05)
Spalten: `id`, `press_release_id FK`, `disk VARCHAR(30) DEFAULT 'public'`, `path VARCHAR(512)`, `variants JSON` (thumb/medium/large), `title VARCHAR(120)`, `description VARCHAR(500)`, `copyright VARCHAR(255)`, `is_preview BOOL`, `sort_order INT UNSIGNED`, `width INT`, `height INT`, `mime VARCHAR(60)`, `legacy_*`, `timestamps`, `deleted_at`.
Legacy war minimalistisch künftig dürfen PMs mehrere, größere Bilder enthalten (kein hartes Limit, UI-Limit konfigurierbar).
### `press_release_contact` (Pivot, wie Legacy `PressReleaseContact`)
| press_release_id | contact_id |
---
## 5. Newsletter
### `newsletter_subscriptions`
| Spalte | Typ | Legacy (`NewsletterSubscription`) |
|---|---|---|
| id | | |
| portal | ENUM | |
| user_id | FK NULL | `user_id` |
| salutation_key | VARCHAR(20) | |
| first_name, last_name | VARCHAR(80) | |
| email | VARCHAR(190) (unique per portal) | |
| ip_address | VARCHAR(45) | |
| is_confirmed | BOOL | `is_active` |
| confirmation_token | VARCHAR(32) | `validate` |
| subscribed_at | TIMESTAMP | `subscribe_date` |
| unsubscribed_at | TIMESTAMP | `unsubscribe_date` |
| legacy_portal, legacy_id | | |
| timestamps | | |
---
## 6. Blacklist / FooterCode
### `blacklists`
| id | portal | title VARCHAR(255) | content MEDIUMTEXT | timestamps |
### `footer_codes` (bleibt, Q-08b)
| id | portal | language CHAR(2) NULL | title VARCHAR(255) | content TEXT | is_global BOOL | timestamps |
### `category_footer_code` (Pivot)
| footer_code_id | category_id |
> **Entfällt (D-14):** `promotion_links`, `promotion_link_category` werden nicht migriert und nicht neu angelegt.
---
## 7. Billing & Payment (komplett neu, D-03/D-12/D-13)
**Wichtig:** Legacy-PaymentOptions, -UserPaymentOptions, -UserPayments und -Coupons werden **nicht 1:1 in die neue Billing-Logik migriert**. Nur *aktive* Subscriptions werden als "grandfathered" übernommen. Legacy-Rechnungen werden dagegen vollständig als read-only Archivdaten importiert (`legacy_invoices`) und nicht in den neuen Rechnungskreis (`invoices`) umgebucht.
### `billing_addresses` (entspricht `UserBillingAddress`)
| id | user_id FK UNIQUE | salutation_key | title | name | address1 | address2 | postal_code | city | country_code | timestamps |
### `invoice_billing_addresses`
Wie `billing_addresses`, aber ohne `user_id`-FK; dafür Snapshot zum Zeitpunkt der Rechnung.
### `payment_options` (neu definiert)
| Spalte | Typ | Bemerkung |
|---|---|---|
| id | BIGINT PK | |
| article_number | VARCHAR(60) UNIQUE | Interne Artikelnummer |
| type | ENUM('recurring','onetime') | Stripe-Subscription oder One-Off |
| price_cents | INT UNSIGNED | |
| currency | CHAR(3) DEFAULT 'EUR' | |
| interval | ENUM('monthly','yearly','once') NULL | Für Recurring |
| is_hidden | BOOL | |
| stripe_product_id | VARCHAR(60) | |
| stripe_price_id | VARCHAR(60) | |
| timestamps, deleted_at | | |
### `payment_option_translations` (DE/EN)
| id | payment_option_id FK | locale | name | description |
### `user_payment_options` (aktive Abos)
| Spalte | Typ | Bemerkung |
|---|---|---|
| id | BIGINT PK | |
| user_id | FK | |
| payment_option_id | FK | |
| status | ENUM('active','past_due','cancelled','grandfathered') | |
| **grandfathered_until** | DATE NULL | ⭐ D-13: Alt-Konditionen bis zu diesem Datum |
| legacy_conditions | JSON NULL | Snapshot alter Konditionen für Grandfathering |
| current_period_start | DATE | |
| current_period_end | DATE | |
| stripe_subscription_id | VARCHAR(60) NULL | |
| cancelled_at | TIMESTAMP NULL | |
| timestamps | | |
### `user_payment_option_company` (Pivot)
| user_payment_option_id | company_id | is_active | timestamps |
### `user_payments` (nur noch Stripe-Charges)
| id | user_payment_option_id FK NULL | amount_cents | currency | status ENUM('pending','succeeded','failed','refunded') | stripe_charge_id | stripe_invoice_id NULL | timestamps |
### `invoices` (neuer Rechnungskreis)
| Spalte | Bemerkung |
|---|---|
| id | |
| user_id FK | |
| user_payment_id FK NULL | |
| invoice_billing_address_id FK | Snapshot |
| number VARCHAR(40) UNIQUE | neue Nummernkreis, z. B. `{YYYY}{MM}-{seq}` |
| status | ENUM('open','paid','void','uncollectible') (Stripe-nah) |
| amount_cents / tax_cents / total_cents | |
| currency CHAR(3) | |
| is_netto BOOL | |
| invoice_date / due_date / paid_at | |
| stripe_invoice_id VARCHAR(60) NULL | |
| pdf_path | |
| timestamps, deleted_at | |
> **Entfällt:** `payment_method`-Enum es gibt nur noch Stripe. Keine Rechnungszahlungs-Variante mehr.
### `legacy_invoices` ⭐ ARCHIV (D-12)
Separate, read-only Archivtabelle mit allen Rechnungen aus beiden Legacy-Portalen. Ziel ist der vollständige Import der vorhandenen Rechnungsdaten inkl. Status und User-Zuordnung; die PDF-Erzeugung für Legacy-Rechnungen bleibt DB-basiert und erfolgt bei Abruf/on demand.
| Spalte | Typ | Legacy-Quelle |
|---|---|---|
| id | BIGINT PK | (neu) |
| legacy_portal | ENUM | — |
| legacy_id | BIGINT | `invoice.id` |
| user_id | FK `users.id` NULL | Via Import-Map auflösbar |
| legacy_user_id | BIGINT | Original-User-ID im Legacy |
| number | VARCHAR(40) | Original-Rechnungsnummer |
| amount_cents / tax_cents / total_cents | | |
| status | VARCHAR(40) | Original-Status-String |
| invoice_date / due_date / paid_at | | |
| payment_method | VARCHAR(40) | Nur als Info (SPK, PayPal, Invoice …) |
| pdf_path | VARCHAR(512) NULL | Optionaler Cachepfad, falls ein generiertes PDF abgelegt wird; nicht primäre Quelle |
| raw_snapshot | JSON | Vollständige Original-Zeile bzw. alle für die Legacy-PDF-Erzeugung nötigen Daten für Audit/Rendering |
| pdf_payload | JSON NULL | Snapshot für PDF-Erzeugung (`invoice`, `invoice_billing_address`, `user_payment`) |
| pdf_generated_at | TIMESTAMP NULL | Zeitpunkt der letzten on-demand PDF-Erzeugung |
| imported_at | TIMESTAMP | |
UNIQUE `(legacy_portal, legacy_id)`.
**DB-Vorbereitung vor Go-Live:** `pdf_payload` enthält die für die on-demand PDF-Erzeugung nötigen Legacy-Snapshots. Der neue `invoices`-Kreislauf bleibt davon getrennt.
> **Coupons vertagt (D-16):** Keine Tabelle in Phase 1. Wird später, ggf. direkt als Stripe-Coupons, nachgezogen.
---
## 8. Import-Tracking
### `legacy_import_map`
| Spalte | Typ | Zweck |
|---|---|---|
| id | BIGINT | |
| legacy_portal | ENUM('presseecho','businessportal24') | |
| legacy_table | VARCHAR(80) | z. B. `sf_guard_user`, `press_release` |
| legacy_id | BIGINT | |
| target_table | VARCHAR(80) | |
| target_id | BIGINT | |
| imported_at | TIMESTAMP | |
| UNIQUE (legacy_portal, legacy_table, legacy_id) | | |
Diese Tabelle erlaubt idempotente Imports und Rückverfolgung.
---
## 9. Tabellen-Übersicht (Total)
| # | Tabelle | Quelle / Herkunft |
|---|---|---|
| 1 | users | sfGuardUser + sfGuardUserProfile (teilw.) |
| 2 | profiles | sfGuardUserProfile |
| 3 | magic_links | NEU (D-10) |
| 4 | roles / permissions / … | Spatie |
| 5 | companies | Company + Agency |
| 6 | company_user | CompanyUser + ResponsibleCompanyUser |
| 7 | contacts | Contact |
| 8 | categories | Category |
| 9 | category_translations | Category_Translation |
| 10 | press_releases | PressRelease |
| 11 | press_release_images | PressReleaseImage (erweitert) |
| 12 | press_release_contact | PressReleaseContact |
| 13 | newsletter_subscriptions | NewsletterSubscription (nur Daten, Workflow neu) |
| 14 | blacklists | Blacklist |
| 15 | footer_codes, category_footer_code | FooterCode (bleibt) |
| 16 | billing_addresses | UserBillingAddress |
| 17 | invoice_billing_addresses | InvoiceBillingAddress |
| 18 | payment_options | NEU (nicht aus Legacy) |
| 19 | payment_option_translations | NEU |
| 20 | user_payment_options | Teil-Übernahme aktiver Abos (Grandfathering) |
| 21 | user_payment_option_company | s.o. |
| 22 | user_payments | NEU (Stripe-Charges) |
| 23 | invoices | NEU (Stripe-getriebener Rechnungskreis) |
| 24 | **legacy_invoices** | ⭐ ARCHIV aller alten Rechnungen |
| 25 | legacy_import_map | NEU (technisch) |
**Raus aus der DB, rein in Config/Code:**
| Legacy-Tabelle | Ziel |
|---|---|
| `Country` | `config/countries.php` |
| `Salutation` + `Salutation_Translation` | `config/salutations.php` |
**Ganz raus / entfallen:**
| Legacy-Tabelle | Grund |
|---|---|
| `ApiUser` | Duplikat zu `users.registration_type = apiuser` / Rolle `api-only` |
| `sfGuardRememberKey` | Laravel `remember_token`-Spalte reicht |
| `sfGuardForgotPassword` | Fortify löst Reset selbst |
| `PromotionLink` / `PromotionLinkCategory` | Feature entfällt (D-14) |
| `Coupon` | Vertagt (D-16) |
| `PaymentOption_ExcludeGroup` / `_AccessGroup` | Ersetzt durch Spatie-Permissions + direkte Product-Whitelist |
| `Invoice` (als aktive Tabelle) | Ersetzt durch neuen Rechnungskreis + `legacy_invoices`-Archiv |
---
## 10. Indizes & Performance
- `press_releases`: Index `(portal, status, published_at DESC)` für Public-Listing; Unique `(portal, language, slug)`.
- `press_releases`: Index `uuid` (öffentliche Share-Links).
- `newsletter_subscriptions`: Unique `(portal, email)`.
- `magic_links`: Index `(user_id, purpose, consumed_at)`; Unique `token_hash`.
- `invoices`: Index `(user_id, invoice_date)`, `stripe_invoice_id`.
- `legacy_invoices`: Unique `(legacy_portal, legacy_id)`; Index `(user_id, invoice_date DESC)`.
- `legacy_import_map`: Unique-Index siehe oben.
---
## 11. Migrationen (Reihenfolge)
1. `users` (Alterung der bestehenden Tabelle durch Zusatz-Spalten, `password` NULLABLE)
2. `profiles`, `billing_addresses`
3. `magic_links`
4. Spatie-Migrationen (+ Seeder für Rollen `admin`, `editor`, `customer`, `api-only`)
5. `companies`, `company_user`, `contacts`
6. `categories`, `category_translations` (inkl. EN-Seed)
7. `blacklists`, `footer_codes`, `category_footer_code`
8. `press_releases`, `press_release_images`, `press_release_contact`
9. `newsletter_subscriptions`
10. `payment_options`, `payment_option_translations`
11. `user_payment_options`, `user_payment_option_company`, `user_payments`
12. `invoice_billing_addresses`, `invoices`
13. `legacy_invoices` (Archiv)
14. `legacy_import_map`
> Separate Phase (Phase 6): Import-Commands füllen diese Tabellen aus den Legacy-DBs.