M5: Hinweise auf tenant-scoped Notices umziehen, Mitgliederzugang auf Rollen umstellen

Hinweise:
- Neue Tabelle notices (tenant-scoped, Soft-Delete via deleted_at) loest
  die global unscoped kl_hinweise als aktive Datenquelle ab; Migration
  uebernimmt einmalig aktuell gueltige kl_hinweise-Eintraege fuer den
  Default-Mandanten. kl_hinweise bleibt als Golden-Master-Referenz stehen.
- hinweise.php und der Banner in header.php sind tenant-scoped umgestellt.

Mitgliederverwaltung:
- mitarbeiterverwalten.php verwaltet jetzt participants (tenant-scoped)
  statt der global unscoped kl_Mitarbeiter-Tabelle als primaere Quelle.
  Das behebt nebenbei ein Mandanten-Datenleck: jeder SaaS-Mandant mit
  Owner/Admin-Rolle haette zuvor die komplette Default-Mandanten-
  Mitgliederliste sehen und bearbeiten koennen.
- Fuer den Default-Mandanten bleibt Dual-Write nach kl_Mitarbeiter
  bestehen, damit stricheintragen.php/einzahlung.php weiter funktionieren;
  andere Mandanten werden rein participant-nativ verwaltet.
- Die Legacy-Administrator-Checkbox ist raus. Stattdessen kann ein Admin
  je Mitglied unabhaengig von Name/E-Mail einen Login-Zugang mit Rolle
  (member/treasurer/admin) gewaehren oder entziehen
  (saas_grant_participant_access / saas_revoke_participant_access).
  Einladung laeuft ueber den bestehenden Passwort-Reset-Mechanismus,
  Entzug setzt die Mitgliedschaft auf revoked statt sie zu loeschen.
- Kompletter Flow live getestet: anlegen, Zugang gewaehren, Einladungsmail,
  Passwort setzen, Login, Rollenschutz, Zugang entziehen, Login-Sperre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:18:55 +02:00
co-authored by Claude Sonnet 5
parent dcc8dc3421
commit aeb687f42e
10 changed files with 790 additions and 273 deletions
+170 -23
View File
@@ -319,34 +319,181 @@ function ledger_mirror_legacy_consumption(PDO $pdo, int $tenantId, int $legacyCo
}
}
function ledger_is_default_tenant(PDO $pdo, int $tenantId): bool
{
$tenant = ledger_fetch_default_tenant($pdo);
return $tenant !== null && (int)$tenant['id'] === $tenantId;
}
/**
* Mirrors a single kl_Mitarbeiter row into participants, so member changes
* made through the legacy admin UI are immediately visible to ledger-backed
* reads instead of waiting for the next scripts/backfill-default-tenant.php run.
* @return list<array{participant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, legacy_mitarbeiter_id: ?int, user_id: ?int, role: ?string, membership_status: ?string}>
*/
function ledger_mirror_legacy_participant(PDO $pdo, int $tenantId, int $legacyMitarbeiterId): void
function ledger_fetch_participants_for_admin(PDO $pdo, int $tenantId): array
{
$stmt = $pdo->prepare(
"INSERT INTO participants
(tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id)
SELECT
?,
NULLIF(TRIM(m.Name), ''),
NULLIF(TRIM(m.Email), ''),
NULLIF(LOWER(TRIM(m.Email)), ''),
NULLIF(TRIM(m.paypalname), ''),
m.aktiv,
m.MitarbeiterID
FROM kl_Mitarbeiter m
WHERE m.MitarbeiterID = ?
ON DUPLICATE KEY UPDATE
display_name = COALESCE(VALUES(display_name), participants.display_name),
email = VALUES(email),
email_norm = VALUES(email_norm),
paypal_name = VALUES(paypal_name),
active = VALUES(active)"
"SELECT
p.id AS participant_id,
p.display_name,
p.email,
p.email_norm,
p.paypal_name,
p.active,
p.legacy_mitarbeiter_id,
p.user_id,
tm.role,
tm.status AS membership_status
FROM participants p
LEFT JOIN tenant_memberships tm
ON tm.tenant_id = p.tenant_id
AND tm.user_id = p.user_id
WHERE p.tenant_id = ?
ORDER BY p.display_name, p.id"
);
$stmt->execute([$tenantId, $legacyMitarbeiterId]);
$stmt->execute([$tenantId]);
$rows = [];
while ($row = $stmt->fetch()) {
$rows[] = [
'participant_id' => (int)$row['participant_id'],
'display_name' => (string)$row['display_name'],
'email' => $row['email'] !== null ? (string)$row['email'] : null,
'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null,
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
'active' => (int)$row['active'] === 1,
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
'user_id' => $row['user_id'] !== null ? (int)$row['user_id'] : null,
'role' => $row['role'] !== null ? (string)$row['role'] : null,
'membership_status' => $row['membership_status'] !== null ? (string)$row['membership_status'] : null,
];
}
return $rows;
}
/**
* Creates a tenant-scoped participant. For the default tenant, a matching
* kl_Mitarbeiter row is dual-written too, so the still-legacy bulk entry
* pages (stricheintragen.php, einzahlung.php) keep listing the member; other
* tenants have no legacy shadow table and get a participants-only row.
*
* @throws Throwable on constraint violations (e.g. duplicate email)
*/
function ledger_create_participant(PDO $pdo, int $tenantId, string $displayName, string $email, ?string $paypalName, bool $active): int
{
$displayName = trim($displayName);
$email = trim($email);
$emailNorm = strtolower($email);
$paypalName = $paypalName !== null ? trim($paypalName) : null;
$paypalName = $paypalName !== '' ? $paypalName : null;
$pdo->beginTransaction();
try {
$legacyMitarbeiterId = null;
if (ledger_is_default_tenant($pdo, $tenantId)) {
$stmt = $pdo->prepare(
'INSERT INTO kl_Mitarbeiter (Name, Email, paypalname, aktiv, admin) VALUES (?, ?, ?, ?, 0)'
);
$stmt->execute([$displayName, $email, $paypalName, $active ? 1 : 0]);
$legacyMitarbeiterId = (int)$pdo->lastInsertId();
}
$stmt = $pdo->prepare(
'INSERT INTO participants
(tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id)
VALUES (?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$tenantId, $displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $legacyMitarbeiterId]);
$participantId = (int)$pdo->lastInsertId();
$pdo->commit();
return $participantId;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function ledger_update_participant(PDO $pdo, int $tenantId, int $participantId, string $displayName, string $email, ?string $paypalName, bool $active): bool
{
$displayName = trim($displayName);
$email = trim($email);
$emailNorm = strtolower($email);
$paypalName = $paypalName !== null ? trim($paypalName) : null;
$paypalName = $paypalName !== '' ? $paypalName : null;
$stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
$stmt->execute([$participantId, $tenantId]);
$row = $stmt->fetch();
if ($row === false) {
return false;
}
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
try {
$pdo->beginTransaction();
if ($legacyMitarbeiterId !== null) {
$stmt = $pdo->prepare(
'UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, paypalname = ?, aktiv = ? WHERE MitarbeiterID = ?'
);
$stmt->execute([$displayName, $email, $paypalName, $active ? 1 : 0, $legacyMitarbeiterId]);
}
$stmt = $pdo->prepare(
'UPDATE participants
SET display_name = ?, email = ?, email_norm = ?, paypal_name = ?, active = ?
WHERE id = ? AND tenant_id = ?'
);
$stmt->execute([$displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $participantId, $tenantId]);
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return false;
}
}
function ledger_set_participant_active(PDO $pdo, int $tenantId, int $participantId, bool $active): bool
{
$stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
$stmt->execute([$participantId, $tenantId]);
$row = $stmt->fetch();
if ($row === false) {
return false;
}
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
try {
$pdo->beginTransaction();
if ($legacyMitarbeiterId !== null) {
$pdo->prepare('UPDATE kl_Mitarbeiter SET aktiv = ? WHERE MitarbeiterID = ?')
->execute([$active ? 1 : 0, $legacyMitarbeiterId]);
}
$pdo->prepare('UPDATE participants SET active = ? WHERE id = ? AND tenant_id = ?')
->execute([$active ? 1 : 0, $participantId, $tenantId]);
$pdo->commit();
return true;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return false;
}
}
function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymentId): void