presseportale/dev/migration 2026/04-DATA-MODEL.md
Kevin Adametz 5b8bdf4182
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
12-05-2026 Frontend dev
2026-05-12 18:32:33 +02:00

441 lines
17 KiB
Markdown
Raw Permalink 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.

# 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.