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:
+303
-8
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user