M3 SaaS-Basis mit Default-Tenant vorbereiten
- Tenant/User/Membership/Participant-Tabellen additiv migrieren - Default-Tenant-Backfill fuer Legacy-Mitarbeiter und Admins ergaenzen - M3-Kontrollskript und Plan-Dokumentation aktualisieren
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
slug VARCHAR(100) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'active',
|
||||||
|
timezone VARCHAR(100) NOT NULL DEFAULT 'Europe/Berlin',
|
||||||
|
locale VARCHAR(20) NOT NULL DEFAULT 'de-DE',
|
||||||
|
currency_code CHAR(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_tenants_slug (slug),
|
||||||
|
KEY idx_tenants_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_settings (
|
||||||
|
tenant_id INT PRIMARY KEY,
|
||||||
|
mark_price_cents INT NOT NULL DEFAULT 20,
|
||||||
|
self_entry_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
paypal_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
paypal_url_template VARCHAR(1000) NOT NULL DEFAULT '',
|
||||||
|
sheet_window_days INT NOT NULL DEFAULT 100,
|
||||||
|
negative_warning_cents INT NOT NULL DEFAULT -1000,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_tenant_settings_tenant
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
email_norm VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(255) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NULL,
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'active',
|
||||||
|
email_verified_at DATETIME NULL,
|
||||||
|
mfa_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
disabled_at DATETIME NULL,
|
||||||
|
last_login_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_users_email_norm (email_norm),
|
||||||
|
KEY idx_users_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_memberships (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id INT NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
role VARCHAR(30) NOT NULL DEFAULT 'member',
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'active',
|
||||||
|
invited_at DATETIME NULL,
|
||||||
|
joined_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_tenant_memberships_tenant_user (tenant_id, user_id),
|
||||||
|
KEY idx_tenant_memberships_user (user_id),
|
||||||
|
KEY idx_tenant_memberships_tenant_role_status (tenant_id, role, status),
|
||||||
|
CONSTRAINT fk_tenant_memberships_tenant
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_tenant_memberships_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS participants (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id INT NOT NULL,
|
||||||
|
user_id INT NULL,
|
||||||
|
display_name VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(255) NULL,
|
||||||
|
email_norm VARCHAR(255) NULL,
|
||||||
|
paypal_name VARCHAR(255) NULL,
|
||||||
|
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
legacy_mitarbeiter_id INT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_participants_tenant_legacy (tenant_id, legacy_mitarbeiter_id),
|
||||||
|
UNIQUE KEY uq_participants_tenant_email_norm (tenant_id, email_norm),
|
||||||
|
UNIQUE KEY uq_participants_tenant_user (tenant_id, user_id),
|
||||||
|
KEY idx_participants_tenant_active_name (tenant_id, active, display_name),
|
||||||
|
KEY idx_participants_tenant_paypal_name (tenant_id, paypal_name),
|
||||||
|
KEY idx_participants_user (user_id),
|
||||||
|
CONSTRAINT fk_participants_tenant
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_participants_user
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -24,9 +24,9 @@ das neue Modell umgestellt.
|
|||||||
- Kein Billing, keine Tarife, keine E-Mail-Verifikation im ersten Schritt.
|
- Kein Billing, keine Tarife, keine E-Mail-Verifikation im ersten Schritt.
|
||||||
- Kein produktiver Tenant-Wechsel in bestehenden Legacy-Seiten.
|
- Kein produktiver Tenant-Wechsel in bestehenden Legacy-Seiten.
|
||||||
|
|
||||||
## Vorgeschlagene Migration
|
## Umgesetzter erster Schritt
|
||||||
|
|
||||||
Naechste Migration:
|
Umgesetzte Migration:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
database/migrations/0002_saas_identity_tenants.sql
|
database/migrations/0002_saas_identity_tenants.sql
|
||||||
@@ -40,6 +40,13 @@ Tabellen:
|
|||||||
- `tenant_memberships`
|
- `tenant_memberships`
|
||||||
- `participants`
|
- `participants`
|
||||||
|
|
||||||
|
Ergaenzende Skripte:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scripts/backfill-default-tenant.php
|
||||||
|
scripts/check-m3-saas-basis.php
|
||||||
|
```
|
||||||
|
|
||||||
Wichtige Regeln:
|
Wichtige Regeln:
|
||||||
|
|
||||||
- `users` sind Login-Konten.
|
- `users` sind Login-Konten.
|
||||||
@@ -49,6 +56,8 @@ Wichtige Regeln:
|
|||||||
- `participants.legacy_mitarbeiter_id` speichert die alte ID aus
|
- `participants.legacy_mitarbeiter_id` speichert die alte ID aus
|
||||||
`kl_Mitarbeiter`.
|
`kl_Mitarbeiter`.
|
||||||
- Geldwerte in neuen Settings als Cent-Integer speichern.
|
- Geldwerte in neuen Settings als Cent-Integer speichern.
|
||||||
|
- Die Migration ist rein additiv; bestehende Legacy-Seiten fragen weiter die
|
||||||
|
bisherigen `kl_*`-Tabellen ab.
|
||||||
|
|
||||||
## Default-Tenant
|
## Default-Tenant
|
||||||
|
|
||||||
@@ -65,9 +74,9 @@ currency_code: EUR
|
|||||||
|
|
||||||
Die Werte koennen spaeter in einer Tenant-Einstellungsseite angepasst werden.
|
Die Werte koennen spaeter in einer Tenant-Einstellungsseite angepasst werden.
|
||||||
|
|
||||||
## Backfill-Vorschlag
|
## Backfill
|
||||||
|
|
||||||
Ein separates Skript sollte die bestehenden Mitarbeiter spiegeln:
|
Ein separates Skript spiegelt die bestehenden Mitarbeiter:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
scripts/backfill-default-tenant.php
|
scripts/backfill-default-tenant.php
|
||||||
@@ -87,15 +96,24 @@ Vorgang:
|
|||||||
Der Backfill muss idempotent sein und darf keine bestehenden Legacy-Daten
|
Der Backfill muss idempotent sein und darf keine bestehenden Legacy-Daten
|
||||||
loeschen.
|
loeschen.
|
||||||
|
|
||||||
|
Ausgefuehrter Dev-Stand:
|
||||||
|
|
||||||
|
- Default-Tenant `default` wurde angelegt.
|
||||||
|
- 9 Legacy-Mitarbeiter wurden als `participants` gespiegelt.
|
||||||
|
- 2 aktive Admin-/Owner-Logins wurden als `users` und
|
||||||
|
`tenant_memberships` angelegt.
|
||||||
|
- Ein zweiter Backfill-Lauf blieb idempotent.
|
||||||
|
|
||||||
## Erste Akzeptanzkriterien
|
## Erste Akzeptanzkriterien
|
||||||
|
|
||||||
- Migrationen laufen mehrfach ohne Fehler.
|
- Migrationen laufen mehrfach ohne Fehler: erfuellt.
|
||||||
- Default-Tenant existiert genau einmal.
|
- Default-Tenant existiert genau einmal: erfuellt.
|
||||||
- `tenant_settings` enthalten Preis pro Strich und bestehende PayPal-Optionen.
|
- `tenant_settings` enthalten Preis pro Strich und bestehende PayPal-Optionen:
|
||||||
- Jeder bestehende `kl_Mitarbeiter` hat genau einen `participant`.
|
erfuellt.
|
||||||
- `participants.legacy_mitarbeiter_id` ist gesetzt.
|
- Jeder bestehende `kl_Mitarbeiter` hat genau einen `participant`: erfuellt.
|
||||||
- Admins werden als tenant-scoped Rolle abgebildet.
|
- `participants.legacy_mitarbeiter_id` ist gesetzt: erfuellt.
|
||||||
- Golden-Master und HTTP-Smoke bleiben gruen.
|
- Admins werden als tenant-scoped Rolle abgebildet: erfuellt.
|
||||||
|
- Golden-Master und HTTP-Smoke bleiben gruen: erfuellt.
|
||||||
|
|
||||||
## Risiken
|
## Risiken
|
||||||
|
|
||||||
@@ -109,10 +127,9 @@ loeschen.
|
|||||||
|
|
||||||
## Empfohlene Reihenfolge
|
## Empfohlene Reihenfolge
|
||||||
|
|
||||||
1. Migration `0002_saas_identity_tenants.sql` erstellen.
|
1. Migration `0002_saas_identity_tenants.sql` erstellen: erledigt.
|
||||||
2. `scripts/backfill-default-tenant.php` erstellen.
|
2. `scripts/backfill-default-tenant.php` erstellen: erledigt.
|
||||||
3. Backfill gegen die Dev-Datenbank ausfuehren.
|
3. Backfill gegen die Dev-Datenbank ausfuehren: erledigt.
|
||||||
4. Kontrollskript fuer Tenant/Participant/Role-Counts schreiben.
|
4. Kontrollskript fuer Tenant/Participant/Role-Counts schreiben: erledigt.
|
||||||
5. Golden-Master und HTTP-Smoke ausfuehren.
|
5. Golden-Master und HTTP-Smoke ausfuehren: erledigt.
|
||||||
6. Danach Login-/Registrierungsrouten planen.
|
6. Danach Login-/Registrierungsrouten planen: naechster Schritt.
|
||||||
|
|
||||||
|
|||||||
@@ -411,10 +411,12 @@ Schritte:
|
|||||||
|
|
||||||
- Tabellen fuer `tenants`, `users`, `tenant_memberships` und
|
- Tabellen fuer `tenants`, `users`, `tenant_memberships` und
|
||||||
`tenant_settings` anlegen. Vorbereitung dokumentiert in
|
`tenant_settings` anlegen. Vorbereitung dokumentiert in
|
||||||
`docs/m3-saas-basis-vorbereitung.md`.
|
`docs/m3-saas-basis-vorbereitung.md`: erster Schritt erledigt.
|
||||||
- Zusaetzlich `participants` als getrennte Kaffee-Teilnehmer-Tabelle anlegen.
|
- Zusaetzlich `participants` als getrennte Kaffee-Teilnehmer-Tabelle anlegen:
|
||||||
- Default-Tenant fuer den aktuellen Bestand anlegen.
|
erledigt.
|
||||||
- Bestehende `kl_Mitarbeiter` idempotent in `participants` spiegeln.
|
- Default-Tenant fuer den aktuellen Bestand anlegen: erledigt.
|
||||||
|
- Bestehende `kl_Mitarbeiter` idempotent in `participants` spiegeln:
|
||||||
|
erledigt.
|
||||||
- Registrierung: Tenant + Owner-User + Default-Settings erzeugen.
|
- Registrierung: Tenant + Owner-User + Default-Settings erzeugen.
|
||||||
- Login, Logout, Passwort-Reset und E-Mail-Verifikation bauen.
|
- Login, Logout, Passwort-Reset und E-Mail-Verifikation bauen.
|
||||||
- Tenant-Aufloesung definieren.
|
- Tenant-Aufloesung definieren.
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/dev-db.php';
|
||||||
|
|
||||||
|
function m3_env(string $name, string $default): string
|
||||||
|
{
|
||||||
|
$value = getenv($name);
|
||||||
|
if ($value === false || trim($value) === '') {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function m3_email_norm(?string $email): ?string
|
||||||
|
{
|
||||||
|
$normalized = strtolower(trim((string)$email));
|
||||||
|
|
||||||
|
return $normalized !== '' ? $normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function m3_decimal_to_cents(mixed $value): int
|
||||||
|
{
|
||||||
|
$normalized = str_replace(',', '.', trim((string)$value));
|
||||||
|
if ($normalized === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$negative = str_starts_with($normalized, '-');
|
||||||
|
if ($negative) {
|
||||||
|
$normalized = substr($normalized, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[$euros, $cents] = array_pad(explode('.', $normalized, 2), 2, '0');
|
||||||
|
$euros = preg_replace('/[^0-9]/', '', $euros) ?? '0';
|
||||||
|
$cents = preg_replace('/[^0-9]/', '', $cents) ?? '0';
|
||||||
|
$cents = str_pad(substr($cents, 0, 2), 2, '0');
|
||||||
|
|
||||||
|
$result = ((int)$euros * 100) + (int)$cents;
|
||||||
|
|
||||||
|
return $negative ? -$result : $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function m3_fetch_tenant_id(PDO $pdo, string $slug): int
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM tenants WHERE slug = ?');
|
||||||
|
$stmt->execute([$slug]);
|
||||||
|
$tenantId = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($tenantId === false) {
|
||||||
|
throw new RuntimeException("Tenant '{$slug}' was not created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)$tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function m3_upsert_user(PDO $pdo, string $email, string $emailNorm, string $displayName): int
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO users (email, email_norm, display_name, status)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
email = VALUES(email),
|
||||||
|
display_name = VALUES(display_name),
|
||||||
|
status = VALUES(status)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$email, $emailNorm, $displayName, 'active']);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE email_norm = ?');
|
||||||
|
$stmt->execute([$emailNorm]);
|
||||||
|
$userId = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($userId === false) {
|
||||||
|
throw new RuntimeException("User '{$emailNorm}' was not created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)$userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = dev_pdo();
|
||||||
|
$applied = dev_apply_migrations($pdo);
|
||||||
|
|
||||||
|
$tenantSlug = m3_env('M3_DEFAULT_TENANT_SLUG', 'default');
|
||||||
|
$tenantName = m3_env('M3_DEFAULT_TENANT_NAME', 'Kaffeeliste Bestand');
|
||||||
|
$ownerEmailNorm = m3_email_norm(getenv('M3_DEFAULT_TENANT_OWNER_EMAIL') ?: null);
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'applied_migrations' => count($applied),
|
||||||
|
'participants' => 0,
|
||||||
|
'users' => 0,
|
||||||
|
'memberships' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO tenants (slug, name, status, timezone, locale, currency_code)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
status = VALUES(status),
|
||||||
|
timezone = VALUES(timezone),
|
||||||
|
locale = VALUES(locale),
|
||||||
|
currency_code = VALUES(currency_code)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$tenantSlug, $tenantName, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
|
||||||
|
$tenantId = m3_fetch_tenant_id($pdo, $tenantSlug);
|
||||||
|
|
||||||
|
$config = $pdo->query(
|
||||||
|
'SELECT KostenproStrich, paypaluse, paypallink, strichperweb
|
||||||
|
FROM kl_config
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT 1'
|
||||||
|
)->fetch() ?: [
|
||||||
|
'KostenproStrich' => '0.20',
|
||||||
|
'paypaluse' => 0,
|
||||||
|
'paypallink' => '',
|
||||||
|
'strichperweb' => 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO tenant_settings
|
||||||
|
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled, paypal_url_template)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
mark_price_cents = VALUES(mark_price_cents),
|
||||||
|
self_entry_enabled = VALUES(self_entry_enabled),
|
||||||
|
paypal_enabled = VALUES(paypal_enabled),
|
||||||
|
paypal_url_template = VALUES(paypal_url_template)'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$tenantId,
|
||||||
|
m3_decimal_to_cents($config['KostenproStrich']),
|
||||||
|
(int)$config['strichperweb'] === 1 ? 1 : 0,
|
||||||
|
(int)$config['paypaluse'] === 1 ? 1 : 0,
|
||||||
|
(string)$config['paypallink'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$members = $pdo->query(
|
||||||
|
'SELECT MitarbeiterID, Name, Email, paypalname, aktiv, admin
|
||||||
|
FROM kl_Mitarbeiter
|
||||||
|
ORDER BY MitarbeiterID'
|
||||||
|
)->fetchAll();
|
||||||
|
|
||||||
|
$upsertParticipant = $pdo->prepare(
|
||||||
|
'INSERT INTO participants
|
||||||
|
(tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
display_name = VALUES(display_name),
|
||||||
|
email = VALUES(email),
|
||||||
|
email_norm = VALUES(email_norm),
|
||||||
|
paypal_name = VALUES(paypal_name),
|
||||||
|
active = VALUES(active)'
|
||||||
|
);
|
||||||
|
$upsertMembership = $pdo->prepare(
|
||||||
|
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at)
|
||||||
|
VALUES (?, ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
role = VALUES(role),
|
||||||
|
status = VALUES(status),
|
||||||
|
joined_at = COALESCE(tenant_memberships.joined_at, VALUES(joined_at))'
|
||||||
|
);
|
||||||
|
$linkParticipantUser = $pdo->prepare(
|
||||||
|
'UPDATE participants
|
||||||
|
SET user_id = ?
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
AND legacy_mitarbeiter_id = ?'
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($members as $member) {
|
||||||
|
$email = trim((string)$member['Email']);
|
||||||
|
$emailNorm = m3_email_norm($email);
|
||||||
|
$displayName = trim((string)$member['Name']);
|
||||||
|
if ($displayName === '') {
|
||||||
|
$displayName = $emailNorm ?? ('Mitarbeiter ' . (int)$member['MitarbeiterID']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$upsertParticipant->execute([
|
||||||
|
$tenantId,
|
||||||
|
$displayName,
|
||||||
|
$email !== '' ? $email : null,
|
||||||
|
$emailNorm,
|
||||||
|
$member['paypalname'] !== null && trim((string)$member['paypalname']) !== ''
|
||||||
|
? trim((string)$member['paypalname'])
|
||||||
|
: null,
|
||||||
|
(int)$member['aktiv'] === 1 ? 1 : 0,
|
||||||
|
(int)$member['MitarbeiterID'],
|
||||||
|
]);
|
||||||
|
$stats['participants']++;
|
||||||
|
|
||||||
|
$isAdmin = (int)$member['admin'] === 1 && (int)$member['aktiv'] === 1;
|
||||||
|
$isOwner = $ownerEmailNorm !== null && $emailNorm === $ownerEmailNorm && (int)$member['aktiv'] === 1;
|
||||||
|
if ($emailNorm === null || (!$isAdmin && !$isOwner)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = m3_upsert_user($pdo, $email, $emailNorm, $displayName);
|
||||||
|
$role = $isOwner ? 'owner' : 'admin';
|
||||||
|
$upsertMembership->execute([$tenantId, $userId, $role, 'active']);
|
||||||
|
$linkParticipantUser->execute([$userId, $tenantId, (int)$member['MitarbeiterID']]);
|
||||||
|
$stats['users']++;
|
||||||
|
$stats['memberships']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
fwrite(STDERR, "M3 default tenant backfill failed: {$e->getMessage()}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "M3 default tenant backfill complete.\n";
|
||||||
|
echo "Tenant: {$tenantSlug}\n";
|
||||||
|
echo "Applied migrations: {$stats['applied_migrations']}\n";
|
||||||
|
echo "Participants mirrored: {$stats['participants']}\n";
|
||||||
|
echo "Users upserted: {$stats['users']}\n";
|
||||||
|
echo "Memberships upserted: {$stats['memberships']}\n";
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/dev-db.php';
|
||||||
|
|
||||||
|
function m3_check_assert(string $label, bool $condition, array &$failures, int &$passes): void
|
||||||
|
{
|
||||||
|
if ($condition) {
|
||||||
|
$passes++;
|
||||||
|
echo "PASS {$label}\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$failures[] = $label;
|
||||||
|
echo "FAIL {$label}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = dev_pdo();
|
||||||
|
dev_apply_schema($pdo);
|
||||||
|
|
||||||
|
$tenantSlug = getenv('M3_DEFAULT_TENANT_SLUG');
|
||||||
|
$tenantSlug = $tenantSlug !== false && trim($tenantSlug) !== '' ? trim($tenantSlug) : 'default';
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$passes = 0;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM tenants WHERE slug = ?');
|
||||||
|
$stmt->execute([$tenantSlug]);
|
||||||
|
$tenants = $stmt->fetchAll();
|
||||||
|
|
||||||
|
m3_check_assert('default tenant exists exactly once', count($tenants) === 1, $failures, $passes);
|
||||||
|
if (count($tenants) !== 1) {
|
||||||
|
echo "\nM3 SaaS basis check failed: tenant '{$tenantSlug}' is missing or duplicated.\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantId = (int)$tenants[0]['id'];
|
||||||
|
|
||||||
|
$legacyMemberCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_Mitarbeiter')->fetchColumn();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM tenant_settings WHERE tenant_id = ?');
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
m3_check_assert('tenant settings exist exactly once', (int)$stmt->fetchColumn() === 1, $failures, $passes);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM participants WHERE tenant_id = ?');
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
m3_check_assert('participant count matches legacy members', (int)$stmt->fetchColumn() === $legacyMemberCount, $failures, $passes);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT COUNT(DISTINCT legacy_mitarbeiter_id)
|
||||||
|
FROM participants
|
||||||
|
WHERE tenant_id = ?
|
||||||
|
AND legacy_mitarbeiter_id IS NOT NULL'
|
||||||
|
);
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
m3_check_assert('legacy member ids are mapped one-to-one', (int)$stmt->fetchColumn() === $legacyMemberCount, $failures, $passes);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT COUNT(*)
|
||||||
|
FROM kl_Mitarbeiter m
|
||||||
|
LEFT JOIN participants p
|
||||||
|
ON p.tenant_id = ?
|
||||||
|
AND p.legacy_mitarbeiter_id = m.MitarbeiterID
|
||||||
|
WHERE p.id IS NULL'
|
||||||
|
);
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
m3_check_assert('no legacy member is missing a participant', (int)$stmt->fetchColumn() === 0, $failures, $passes);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT COUNT(*)
|
||||||
|
FROM kl_Mitarbeiter m
|
||||||
|
LEFT JOIN users u
|
||||||
|
ON u.email_norm = LOWER(TRIM(m.Email))
|
||||||
|
LEFT JOIN tenant_memberships tm
|
||||||
|
ON tm.tenant_id = ?
|
||||||
|
AND tm.user_id = u.id
|
||||||
|
WHERE m.aktiv = 1
|
||||||
|
AND m.admin = 1
|
||||||
|
AND (tm.id IS NULL OR tm.role NOT IN (\'admin\', \'owner\'))'
|
||||||
|
);
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
m3_check_assert('active legacy admins have tenant memberships', (int)$stmt->fetchColumn() === 0, $failures, $passes);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT COUNT(*)
|
||||||
|
FROM kl_Mitarbeiter m
|
||||||
|
JOIN users u
|
||||||
|
ON u.email_norm = LOWER(TRIM(m.Email))
|
||||||
|
JOIN tenant_memberships tm
|
||||||
|
ON tm.tenant_id = ?
|
||||||
|
AND tm.user_id = u.id
|
||||||
|
JOIN participants p
|
||||||
|
ON p.tenant_id = tm.tenant_id
|
||||||
|
AND p.legacy_mitarbeiter_id = m.MitarbeiterID
|
||||||
|
WHERE m.aktiv = 1
|
||||||
|
AND m.admin = 1
|
||||||
|
AND p.user_id <> u.id'
|
||||||
|
);
|
||||||
|
$stmt->execute([$tenantId]);
|
||||||
|
m3_check_assert('admin participants link to their login users', (int)$stmt->fetchColumn() === 0, $failures, $passes);
|
||||||
|
|
||||||
|
if ($failures !== []) {
|
||||||
|
echo "\nM3 SaaS basis check failed with " . count($failures) . " failure(s):\n";
|
||||||
|
foreach ($failures as $failure) {
|
||||||
|
echo "- {$failure}\n";
|
||||||
|
}
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nM3 SaaS basis check passed with {$passes} assertions.\n";
|
||||||
Reference in New Issue
Block a user