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