Lizenz-Vertrieb: Chargen-Vergabe, Storno, Verlaengerung, Mail, Einloeselink

Erweitert das Lizenzmodul um eine Vertriebs-/Vergabe-Schicht ueber dem
bestehenden Einloese-Flow (non-breaking, alle Alt-Codes + generate/redeem
/assign unveraendert).

Schema (Migration 2026-08-26-lizenz-vertrieb.sql, additiv):
- license_customers (Besteller: Kundennummer/Email/Name)
- license_batches (Bestellung/Charge: externe Bestellnummer, Typ, Menge,
  Gueltigkeit, Sammel-Einloesetoken, Storno)
- licenses += batch_id, issued_at, valid_from/until (pro Code -> Verlaengerung),
  canceled_at/reason; code auf VARCHAR(32) (laengere Codes)
- license_mails (Mail-Archiv, unabh. vom IMAP), license_redeem_attempts
  (Brute-Force-Schutz), license_events (Audit-Log)

Backend (php/api/licenses.php):
- issue_batch: N frische 100-Bit-Codes in Transaktion, sofort vergeben,
  an Kunde/Bestellung/Gueltigkeit gebunden; sticky Default Gueltig-bis
- Uebersicht (view=batches mit Aggregat ausgegeben/storniert/netto/eingeloest
  /abgelaufen), batch_detail, Kunden-Autocomplete, sales_default
- cancel_batch/cancel_license, extend_batch/extend_license
- send_batch_mail (Server-Treiber via Mailer, archiviert, Anhang bei >40)
- redeem_charge (Sammel-Einloeselink)
- Brute-Force-Bremse + Audit + Storno-Ablehnung im redeem/redeem_batch

UI (admin-licenses.html): Ausgabe-Formular (Bestaetigung bei Grossmengen),
Ergebnis mit Copy/Download(txt+csv)/Mail-Composer/Sammellink, Bestellungs-
Uebersicht mit Filtern + Detail-Modal (Storno/Verlaengern/Verlauf/Mails).

Einloese-Landingpage (einloesen.html + pages/einloesen.php): ?charge=Token
(alle auf einmal) und ?code=CODE, mit Lehrer-Login-Gate.

Lib: Database Transaktionen; Mailer sendWithAttachments + renderPlainBody.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 01:39:14 +02:00
parent 7691649f37
commit 96a73e22b2
7 changed files with 852 additions and 8 deletions
+303 -8
View File
@@ -9,6 +9,84 @@
$method = $_SERVER['REQUEST_METHOD'];
$db = Database::get();
// ─────────────────────────────────────────────────────────────────────
// Vertriebs-Helfer (Chargen-Vergabe, Storno, Verlaengerung, Mail, Schutz)
// ─────────────────────────────────────────────────────────────────────
const LIC_TYPES = ['marktplatz', 'direktvertrieb', 'privat'];
/** Neuer Lizenzcode: 5×4 aus 32er-Alphabet (ohne I,O,0,1) = ~100 Bit Entropie. */
function lic_new_code(): string {
$a = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $n = strlen($a);
$g = [];
for ($i = 0; $i < 5; $i++) {
$s = '';
for ($k = 0; $k < 4; $k++) $s .= $a[random_int(0, $n - 1)];
$g[] = $s;
}
return implode('-', $g); // XXXX-XXXX-XXXX-XXXX-XXXX (24 Zeichen)
}
/** Schuljahr-String "26/27" aus einem Datum (Schuljahr beginnt im August). */
function lic_school_year_from(?string $date): string {
$ts = $date ? strtotime($date) : time();
$y = (int)date('Y', $ts); $m = (int)date('n', $ts);
$start = ($m >= 8) ? $y : $y - 1;
return ($start % 100) . '/' . (($start % 100) + 1);
}
/** Sticky Default fuer Gueltig-bis: letzte verwendete, sonst 1. Okt End-Jahr Schuljahr. */
function lic_default_valid_until(Database $db): string {
$last = $db->fetchOne("SELECT default_valid_until FROM license_batches WHERE default_valid_until IS NOT NULL ORDER BY id DESC LIMIT 1");
if ($last && !empty($last['default_valid_until'])) return $last['default_valid_until'];
$y = (int)date('Y'); $m = (int)date('n');
$start = ($m >= 8) ? $y : $y - 1;
return ($start + 1) . '-10-01';
}
/** Kunde finden (nach Kundennummer, dann Email) oder anlegen. Ergaenzt fehlende Felder. */
function lic_find_or_create_customer(Database $db, ?string $email, ?string $customerNo, ?string $name): ?int {
$email = $email ? trim($email) : null;
$customerNo = $customerNo ? trim($customerNo) : null;
$name = $name ? trim($name) : null;
if (!$email && !$customerNo && !$name) return null;
$found = null;
if ($customerNo) $found = $db->fetchOne("SELECT id FROM license_customers WHERE customer_no = ? LIMIT 1", [$customerNo]);
if (!$found && $email) $found = $db->fetchOne("SELECT id FROM license_customers WHERE email = ? LIMIT 1", [$email]);
if ($found) {
$db->execute("UPDATE license_customers SET email = COALESCE(email, ?), customer_no = COALESCE(customer_no, ?), name = COALESCE(name, ?) WHERE id = ?",
[$email, $customerNo, $name, $found['id']]);
return (int)$found['id'];
}
$db->execute("INSERT INTO license_customers (customer_no, email, name) VALUES (?,?,?)", [$customerNo, $email, $name]);
return (int)$db->lastInsertId();
}
/** Audit-Eintrag (erzeugt/verlaengert/storniert/gemailt/eingeloest). */
function lic_event(Database $db, ?int $batchId, ?int $licenseId, string $event, ?string $detail, ?int $adminId): void {
$db->execute("INSERT INTO license_events (batch_id, license_id, event, detail, admin_id) VALUES (?,?,?,?,?)",
[$batchId, $licenseId, $event, $detail, $adminId]);
}
/** Brute-Force-Bremse beim Einloesen: 10 Fehlversuche/10min pro IP, 20 pro Lehrer. */
function lic_throttle_check(Database $db, ?int $teacherId): void {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ($ip !== '') {
$r = $db->fetchOne("SELECT COUNT(*) c FROM license_redeem_attempts WHERE ok=0 AND ip=INET6_ATON(?) AND created_at > (NOW() - INTERVAL 10 MINUTE)", [$ip]);
if ($r && (int)$r['c'] >= 10) Response::error('Zu viele Fehlversuche. Bitte in ein paar Minuten erneut.', 429);
}
if ($teacherId) {
$r = $db->fetchOne("SELECT COUNT(*) c FROM license_redeem_attempts WHERE ok=0 AND teacher_id=? AND created_at > (NOW() - INTERVAL 10 MINUTE)", [$teacherId]);
if ($r && (int)$r['c'] >= 20) Response::error('Zu viele Fehlversuche. Bitte spaeter erneut.', 429);
}
}
/** Einloese-Versuch protokollieren (fuer Schutz + Audit). */
function lic_attempt(Database $db, ?int $teacherId, string $code, bool $ok): void {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$db->execute("INSERT INTO license_redeem_attempts (ip, teacher_id, code_tried, ok) VALUES (INET6_ATON(?), ?, ?, ?)",
[$ip !== '' ? $ip : null, $teacherId, substr($code, 0, 32), $ok ? 1 : 0]);
}
// === GET ===
if ($method === 'GET') {
// Super-Admin: alle Lizenzen
@@ -18,6 +96,70 @@ if ($method === 'GET') {
Response::error('Nicht autorisiert', 401);
}
// Vorbelegung fuer das Ausgabe-Formular (Sticky Gueltig-bis + Typen)
if (isset($_GET['sales_default'])) {
Response::ok([
'valid_from' => date('Y-m-d'),
'valid_until' => lic_default_valid_until($db),
'types' => LIC_TYPES,
]);
}
// Kunden-Autocomplete
if (isset($_GET['customers'])) {
$like = '%' . trim($_GET['customers']) . '%';
$rows = $db->fetchAll(
"SELECT id, customer_no, email, name FROM license_customers
WHERE email LIKE ? OR name LIKE ? OR customer_no LIKE ?
ORDER BY name, email LIMIT 10", [$like, $like, $like]);
Response::ok(['customers' => $rows]);
}
// Chargen-Uebersicht (mit Aggregaten + Filter)
if (($_GET['view'] ?? '') === 'batches') {
$where = '1=1'; $p = [];
if (!empty($_GET['type'])) { $where .= ' AND b.sales_type = ?'; $p[] = $_GET['type']; }
if (!empty($_GET['order_no'])) { $where .= ' AND b.order_no LIKE ?'; $p[] = '%' . $_GET['order_no'] . '%'; }
if (!empty($_GET['customer'])) { $q = '%' . $_GET['customer'] . '%'; $where .= ' AND (c.email LIKE ? OR c.name LIKE ? OR c.customer_no LIKE ?)'; $p[] = $q; $p[] = $q; $p[] = $q; }
$batches = $db->fetchAll(
"SELECT b.id, b.order_no, b.sales_type, b.qty, b.default_valid_from, b.default_valid_until,
b.redeem_token, b.created_at, b.canceled_at, b.note,
c.email AS customer_email, c.name AS customer_name, c.customer_no,
COUNT(l.id) AS n_total,
SUM(l.canceled_at IS NOT NULL) AS n_canceled,
SUM(l.redeemed_at IS NOT NULL AND l.canceled_at IS NULL) AS n_redeemed,
SUM(l.canceled_at IS NULL AND l.valid_until IS NOT NULL AND l.valid_until < CURDATE()) AS n_expired
FROM license_batches b
LEFT JOIN license_customers c ON c.id = b.customer_id
LEFT JOIN licenses l ON l.batch_id = b.id
WHERE $where
GROUP BY b.id
ORDER BY b.id DESC
LIMIT 300", $p);
Response::ok(['batches' => $batches]);
}
// Detail einer Charge: Codes + Verlauf + Mails
if (($_GET['view'] ?? '') === 'batch_detail') {
$bid = (int)($_GET['batch_id'] ?? 0);
$batch = $db->fetchOne(
"SELECT b.*, c.email AS customer_email, c.name AS customer_name, c.customer_no
FROM license_batches b LEFT JOIN license_customers c ON c.id = b.customer_id
WHERE b.id = ?", [$bid]);
if (!$batch) Response::error('Charge nicht gefunden', 404);
$codes = $db->fetchAll(
"SELECT l.id, l.code, l.valid_from, l.valid_until, l.issued_at, l.redeemed_at,
l.student_id, l.teacher_id, l.canceled_at, l.canceled_reason,
t.display_name AS teacher_name, s.username AS student_name
FROM licenses l
LEFT JOIN teachers t ON t.id = l.teacher_id
LEFT JOIN students s ON s.id = l.student_id
WHERE l.batch_id = ? ORDER BY l.id", [$bid]);
$events = $db->fetchAll("SELECT event, detail, admin_id, created_at FROM license_events WHERE batch_id = ? ORDER BY id DESC LIMIT 100", [$bid]);
$mails = $db->fetchAll("SELECT id, to_email, subject, status, error, created_at, sent_at FROM license_mails WHERE batch_id = ? ORDER BY id DESC", [$bid]);
Response::ok(['batch' => $batch, 'codes' => $codes, 'events' => $events, 'mails' => $mails]);
}
$year = $_GET['year'] ?? null;
$status = $_GET['status'] ?? null; // 'free', 'used'
@@ -91,13 +233,17 @@ if ($method === 'POST') {
$teacherId = Session::requireTeacher();
$code = strtoupper(trim($body['code'] ?? ''));
if (!$code) Response::error('Lizenzcode erforderlich');
lic_throttle_check($db, $teacherId);
$license = $db->fetchOne('SELECT id, student_id, teacher_id FROM licenses WHERE code = ?', [$code]);
if (!$license) Response::error('Lizenzcode ungültig');
if ($license['student_id']) Response::error('Diese Lizenz ist bereits bereits zugewiesen');
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) Response::error('Diese Lizenz gehört einer anderen Lehrperson');
$license = $db->fetchOne('SELECT id, batch_id, student_id, teacher_id, canceled_at FROM licenses WHERE code = ?', [$code]);
if (!$license) { lic_attempt($db, $teacherId, $code, false); Response::error('Lizenzcode ungültig'); }
if ($license['canceled_at']) { lic_attempt($db, $teacherId, $code, false); Response::error('Dieser Lizenzcode wurde storniert'); }
if ($license['student_id']) { lic_attempt($db, $teacherId, $code, false); Response::error('Diese Lizenz ist bereits zugewiesen'); }
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) { lic_attempt($db, $teacherId, $code, false); Response::error('Diese Lizenz gehört einer anderen Lehrperson'); }
$db->execute('UPDATE licenses SET teacher_id = ?, redeemed_at = NOW() WHERE id = ?', [$teacherId, $license['id']]);
lic_attempt($db, $teacherId, $code, true);
lic_event($db, $license['batch_id'] ? (int)$license['batch_id'] : null, (int)$license['id'], 'redeemed', 'durch Lehrer #' . $teacherId, null);
Response::ok(['licenseId' => (int)$license['id']]);
}
@@ -106,17 +252,20 @@ if ($method === 'POST') {
$teacherId = Session::requireTeacher();
$codes = $body['codes'] ?? [];
if (!is_array($codes) || empty($codes)) Response::error('Lizenzcodes erforderlich');
lic_throttle_check($db, $teacherId);
$redeemed = 0;
$errors = [];
foreach ($codes as $code) {
$code = strtoupper(trim($code));
if (!$code) continue;
$license = $db->fetchOne('SELECT id, student_id, teacher_id FROM licenses WHERE code = ?', [$code]);
if (!$license) { $errors[] = "$code: ungültig"; continue; }
if ($license['student_id']) { $errors[] = "$code: bereits zugewiesen"; continue; }
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) { $errors[] = "$code: gehört anderer Lehrperson"; continue; }
$license = $db->fetchOne('SELECT id, student_id, teacher_id, canceled_at FROM licenses WHERE code = ?', [$code]);
if (!$license) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: ungültig"; continue; }
if ($license['canceled_at']) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: storniert"; continue; }
if ($license['student_id']) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: bereits zugewiesen"; continue; }
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: gehört anderer Lehrperson"; continue; }
$db->execute('UPDATE licenses SET teacher_id = ?, redeemed_at = NOW() WHERE id = ?', [$teacherId, $license['id']]);
lic_attempt($db, $teacherId, $code, true);
$redeemed++;
}
Response::ok(['redeemed' => $redeemed, 'errors' => $errors]);
@@ -194,6 +343,152 @@ if ($method === 'POST') {
Response::ok(['generated' => $generated]);
}
// ═══════════════ Vertrieb: Charge ausgeben ═══════════════
if ($action === 'issue_batch') {
$adminId = Session::adminId();
if (!$adminId) Response::error('Nur Admin', 403);
$count = (int)($body['count'] ?? 0);
if ($count < 1 || $count > 5000) Response::error('Anzahl muss zwischen 1 und 5000 liegen');
$type = in_array($body['sales_type'] ?? '', LIC_TYPES, true) ? $body['sales_type'] : 'privat';
$orderNo = trim((string)($body['order_no'] ?? '')) ?: null;
$note = trim((string)($body['note'] ?? '')) ?: null;
$validFrom = !empty($body['valid_from']) ? $body['valid_from'] : date('Y-m-d');
$validUntil = !empty($body['valid_until']) ? $body['valid_until'] : lic_default_valid_until($db);
if (!strtotime($validFrom) || !strtotime($validUntil)) Response::error('Ungültiges Datum');
if (strtotime($validUntil) < strtotime($validFrom)) Response::error('„Gültig bis" liegt vor „Gültig von"');
$cust = is_array($body['customer'] ?? null) ? $body['customer'] : [];
$customerId = lic_find_or_create_customer($db, $cust['email'] ?? null, $cust['customer_no'] ?? null, $cust['name'] ?? null);
$schoolYear = lic_school_year_from($validFrom);
$token = bin2hex(random_bytes(20));
$db->begin();
try {
$db->execute(
"INSERT INTO license_batches (customer_id, order_no, sales_type, qty, default_valid_from, default_valid_until, note, redeem_token, created_by)
VALUES (?,?,?,?,?,?,?,?,?)",
[$customerId, $orderNo, $type, $count, $validFrom, $validUntil, $note, $token, $adminId]);
$batchId = (int)$db->lastInsertId();
$codes = []; $made = 0; $tries = 0; $maxTries = $count * 8 + 50;
while ($made < $count && $tries < $maxTries) {
$tries++;
$code = lic_new_code();
try {
$db->execute(
"INSERT INTO licenses (code, school_year, batch_id, issued_at, valid_from, valid_until) VALUES (?,?,?,NOW(),?,?)",
[$code, $schoolYear, $batchId, $validFrom, $validUntil]);
$codes[] = $code; $made++;
} catch (\Throwable $e) { /* Code-Kollision (extrem selten) → neuer Versuch */ }
}
if ($made < $count) throw new \RuntimeException("Nur $made von $count Codes erzeugt");
lic_event($db, $batchId, null, 'issued', "$count Codes, gültig $validFrom…$validUntil, Typ $type" . ($orderNo ? ", Best.-Nr. $orderNo" : ''), $adminId);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
Response::error('Fehler beim Erzeugen: ' . $e->getMessage(), 500);
}
Response::ok([
'batch_id' => $batchId, 'redeem_token' => $token,
'valid_from' => $validFrom, 'valid_until' => $validUntil,
'count' => $made, 'codes' => $codes,
]);
}
// ═══════════════ Storno ═══════════════
if ($action === 'cancel_batch') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$bid = (int)($body['batch_id'] ?? 0); if (!$bid) Response::error('batch_id erforderlich');
$reason = trim((string)($body['reason'] ?? '')) ?: null;
$n = $db->execute("UPDATE licenses SET canceled_at = NOW(), canceled_reason = ? WHERE batch_id = ? AND canceled_at IS NULL", [$reason, $bid]);
$db->execute("UPDATE license_batches SET canceled_at = NOW(), canceled_reason = ? WHERE id = ?", [$reason, $bid]);
lic_event($db, $bid, null, 'canceled', "Charge storniert ($n Codes)" . ($reason ? ": $reason" : ''), $adminId);
Response::ok(['canceled' => $n]);
}
if ($action === 'cancel_license') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$lid = (int)($body['license_id'] ?? 0); if (!$lid) Response::error('license_id erforderlich');
$reason = trim((string)($body['reason'] ?? '')) ?: null;
$lic = $db->fetchOne("SELECT batch_id FROM licenses WHERE id = ?", [$lid]);
if (!$lic) Response::error('Code nicht gefunden', 404);
$db->execute("UPDATE licenses SET canceled_at = NOW(), canceled_reason = ? WHERE id = ? AND canceled_at IS NULL", [$reason, $lid]);
lic_event($db, $lic['batch_id'] ? (int)$lic['batch_id'] : null, $lid, 'canceled', 'Einzel-Storno' . ($reason ? ": $reason" : ''), $adminId);
Response::ok();
}
// ═══════════════ Verlängern ═══════════════
if ($action === 'extend_license') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$lid = (int)($body['license_id'] ?? 0); $vu = $body['valid_until'] ?? '';
if (!$lid || !strtotime($vu)) Response::error('license_id + valid_until erforderlich');
$lic = $db->fetchOne("SELECT batch_id, valid_until FROM licenses WHERE id = ?", [$lid]);
if (!$lic) Response::error('Code nicht gefunden', 404);
$db->execute("UPDATE licenses SET valid_until = ? WHERE id = ?", [$vu, $lid]);
lic_event($db, $lic['batch_id'] ? (int)$lic['batch_id'] : null, $lid, 'extended', 'valid_until ' . ($lic['valid_until'] ?? '—') . "$vu", $adminId);
Response::ok();
}
if ($action === 'extend_batch') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$bid = (int)($body['batch_id'] ?? 0); $vu = $body['valid_until'] ?? '';
if (!$bid || !strtotime($vu)) Response::error('batch_id + valid_until erforderlich');
$n = $db->execute("UPDATE licenses SET valid_until = ? WHERE batch_id = ? AND canceled_at IS NULL", [$vu, $bid]);
$db->execute("UPDATE license_batches SET default_valid_until = ? WHERE id = ?", [$vu, $bid]);
lic_event($db, $bid, null, 'extended', "Charge verlängert auf $vu ($n Codes)", $adminId);
Response::ok(['updated' => $n]);
}
// ═══════════════ Mailversand (Server-Treiber, archiviert) ═══════════════
if ($action === 'send_batch_mail') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$bid = (int)($body['batch_id'] ?? 0); if (!$bid) Response::error('batch_id erforderlich');
$to = trim((string)($body['to_email'] ?? ''));
$subject = trim((string)($body['subject'] ?? '')) ?: 'Ihre GeoGraSim-Lizenzcodes';
$textBody = (string)($body['body_text'] ?? '');
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) Response::error('Gültige Empfänger-Mailadresse erforderlich');
if (trim($textBody) === '') Response::error('Mailtext ist leer');
$codeRows = $db->fetchAll("SELECT code FROM licenses WHERE batch_id = ? AND canceled_at IS NULL ORDER BY id", [$bid]);
$codeList = array_map(fn($r) => $r['code'], $codeRows);
$html = Mailer::renderPlainBody($textBody);
$attach = [];
if (count($codeList) > 40) {
$attach[] = ['content' => implode("\r\n", $codeList) . "\r\n", 'name' => 'lizenzcodes.txt', 'type' => 'text/plain'];
}
$db->execute(
"INSERT INTO license_mails (batch_id, to_email, subject, body_text, body_html, provider, status, created_by) VALUES (?,?,?,?,?,?,?,?)",
[$bid, $to, $subject, $textBody, $html, 'server', 'queued', $adminId]);
$mailId = (int)$db->lastInsertId();
try {
Mailer::sendWithAttachments($to, $subject, $html, $textBody, $attach);
$db->execute("UPDATE license_mails SET status='sent', sent_at=NOW() WHERE id=?", [$mailId]);
lic_event($db, $bid, null, 'mailed', "an $to", $adminId);
Response::ok(['mail_id' => $mailId, 'status' => 'sent']);
} catch (\Throwable $e) {
$db->execute("UPDATE license_mails SET status='failed', error=? WHERE id=?", [substr($e->getMessage(), 0, 240), $mailId]);
Response::error('Versand fehlgeschlagen: ' . $e->getMessage(), 500);
}
}
// ═══════════════ Sammel-Einlösen einer Charge (ein Link → alle Codes) ═══════════════
if ($action === 'redeem_charge') {
$teacherId = Session::requireTeacher();
$token = trim((string)($body['token'] ?? ''));
if ($token === '') Response::error('Token erforderlich');
lic_throttle_check($db, $teacherId);
$batch = $db->fetchOne("SELECT id FROM license_batches WHERE redeem_token = ? AND canceled_at IS NULL", [$token]);
if (!$batch) { lic_attempt($db, $teacherId, 'charge:' . substr($token, 0, 24), false); Response::error('Einlöse-Link ungültig oder storniert'); }
$n = $db->execute(
"UPDATE licenses SET teacher_id = ?, redeemed_at = COALESCE(redeemed_at, NOW())
WHERE batch_id = ? AND canceled_at IS NULL AND (teacher_id IS NULL OR teacher_id = ?)",
[$teacherId, $batch['id'], $teacherId]);
lic_attempt($db, $teacherId, 'charge:' . substr($token, 0, 24), true);
lic_event($db, (int)$batch['id'], null, 'redeemed', "Charge-Sammellink durch Lehrer #$teacherId ($n Codes)", null);
Response::ok(['redeemed' => $n]);
}
Response::error('Unbekannte Aktion');
}
+6
View File
@@ -44,4 +44,10 @@ class Database {
public function lastInsertId(): string {
return $this->pdo->lastInsertId();
}
// ── Transaktionen (fuer Bulk-Operationen, z.B. Chargen-Vergabe) ──
public function begin(): void { $this->pdo->beginTransaction(); }
public function commit(): void { $this->pdo->commit(); }
public function rollBack(): void { if ($this->pdo->inTransaction()) $this->pdo->rollBack(); }
public function inTransaction(): bool { return $this->pdo->inTransaction(); }
}
+25
View File
@@ -39,6 +39,31 @@ class Mailer {
}
}
/**
* Versand mit optionalen Datei-Anhaengen (z. B. Lizenz-Codes als .txt/.csv
* bei grossen Chargen). $attachments: [ ['content'=>string,'name'=>string,'type'=>string], ... ]
* Wirft bei Fehler eine Exception (Aufrufer archiviert Status/Fehler selbst).
*/
public static function sendWithAttachments(string $to, string $subject, string $htmlBody, string $textBody = '', array $attachments = []): void {
$mail = self::create();
$mail->addAddress($to);
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = self::wrapHtml($htmlBody);
$mail->AltBody = $textBody ?: strip_tags(str_replace(['<br>','<br/>','</p>','</li>'], "\n", $htmlBody));
foreach ($attachments as $a) {
if (empty($a['content']) || empty($a['name'])) continue;
$mail->addStringAttachment($a['content'], $a['name'], \PHPMailer\PHPMailer\PHPMailer::ENCODING_BASE64, $a['type'] ?? 'text/plain');
}
$mail->send();
}
/** Freitext-Body (aus Admin-Textbaustein) in die Card-Optik giessen. */
public static function renderPlainBody(string $text): string {
$safe = nl2br(htmlspecialchars($text, ENT_QUOTES, 'UTF-8'));
return '<div style="font-size:14px;color:#333;line-height:1.6">' . $safe . '</div>';
}
// ─── Willkommen (Lehrperson registriert sich) ───
public static function sendWelcomeTeacher(string $email, string $name): bool {