17 KiB
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(ENUMpresseecho,businessportal24,both–bothnur für Stammdaten). - Importierte Datensätze haben:
legacy_portal(ENUM),legacy_id(BIGINT), UNIQUE(legacy_portal, legacy_id). - FK-Spalten:
*_idBIGINT UNSIGNED, constrainted, i.d.R.ON DELETEwie 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 |
| 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_keywandern nicht in die neueusers-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) | |
| 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) | |
| 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) | |
| 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: Indexuuid(öffentliche Share-Links).newsletter_subscriptions: Unique(portal, email).magic_links: Index(user_id, purpose, consumed_at); Uniquetoken_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)
users(Alterung der bestehenden Tabelle durch Zusatz-Spalten,passwordNULLABLE)profiles,billing_addressesmagic_links- Spatie-Migrationen (+ Seeder für Rollen
admin,editor,customer,api-only) companies,company_user,contactscategories,category_translations(inkl. EN-Seed)blacklists,footer_codes,category_footer_codepress_releases,press_release_images,press_release_contactnewsletter_subscriptionspayment_options,payment_option_translationsuser_payment_options,user_payment_option_company,user_paymentsinvoice_billing_addresses,invoiceslegacy_invoices(Archiv)legacy_import_map
Separate Phase (Phase 6): Import-Commands füllen diese Tabellen aus den Legacy-DBs.