Files
geograsim/App/php/api/licenses.php
T
Adminator 96a73e22b2 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>
2026-08-26 01:39:14 +02:00

496 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* API: Lizenzverwaltung
* GET /api/licenses?class_id=X → Lizenzen der Klasse (Lehrer)
* GET /api/licenses?admin=1 → Alle Lizenzen (Super-Admin)
* POST /api/licenses {action} → Lizenz einlösen/zuweisen/generieren
*/
$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
if (isset($_GET['admin']) && $_GET['admin'] === '1') {
$adminId = Session::adminId();
if (!$adminId) {
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'
$where = '1=1';
$params = [];
if ($year) { $where .= ' AND l.school_year = ?'; $params[] = $year; }
if ($status === 'free') { $where .= ' AND l.student_id IS NULL'; }
if ($status === 'used') { $where .= ' AND l.student_id IS NOT NULL'; }
$total = $db->fetchOne("SELECT COUNT(*) as c FROM licenses l WHERE $where", $params);
$free = $db->fetchOne("SELECT COUNT(*) as c FROM licenses l WHERE student_id IS NULL" . ($year ? " AND school_year = ?" : ""), $year ? [$year] : []);
$used = $db->fetchOne("SELECT COUNT(*) as c FROM licenses l WHERE student_id IS NOT NULL" . ($year ? " AND school_year = ?" : ""), $year ? [$year] : []);
$limit = (int)($_GET['limit'] ?? 100);
$offset = (int)($_GET['offset'] ?? 0);
$licenses = $db->fetchAll(
"SELECT l.id, l.code, l.school_year, l.student_id, l.teacher_id, l.redeemed_at, l.created_at,
s.username as student_name, t.display_name as teacher_name
FROM licenses l
LEFT JOIN students s ON s.id = l.student_id
LEFT JOIN teachers t ON t.id = l.teacher_id
WHERE $where
ORDER BY l.id DESC
LIMIT $limit OFFSET $offset",
$params
);
Response::ok([
'total' => (int)$total['c'],
'free' => (int)$free['c'],
'used' => (int)$used['c'],
'licenses' => $licenses
]);
}
// Lehrer: Lizenzen einer Klasse
$teacherId = Session::requireTeacher();
$classId = (int)($_GET['class_id'] ?? 0);
if (!$classId) Response::error('class_id erforderlich');
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
$licenses = $db->fetchAll(
'SELECT l.id, l.code, l.school_year, l.student_id, l.redeemed_at,
s.username as student_name, s.display_name as student_display
FROM licenses l
JOIN students s ON s.id = l.student_id
WHERE s.class_id = ?
ORDER BY s.username',
[$classId]
);
// Auch freie Lizenzen des Lehrers (noch nicht zugewiesen)
$freeLicenses = $db->fetchAll(
'SELECT id, code, school_year FROM licenses WHERE teacher_id = ? AND student_id IS NULL ORDER BY code',
[$teacherId]
);
Response::ok(['assigned' => $licenses, 'free' => $freeLicenses]);
}
// === POST ===
if ($method === 'POST') {
$body = json_decode(file_get_contents('php://input'), true);
$action = $body['action'] ?? '';
// Lizenz einlösen (Lehrer gibt Code ein → wird ihm zugeordnet)
if ($action === 'redeem') {
$teacherId = Session::requireTeacher();
$code = strtoupper(trim($body['code'] ?? ''));
if (!$code) Response::error('Lizenzcode erforderlich');
lic_throttle_check($db, $teacherId);
$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']]);
}
// Mehrere Lizenzen auf einmal einlösen
if ($action === 'redeem_batch') {
$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, 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]);
}
// Lizenz einem Schüler zuweisen
if ($action === 'assign') {
$teacherId = Session::requireTeacher();
$licenseId = (int)($body['licenseId'] ?? 0);
$studentId = (int)($body['studentId'] ?? 0);
if (!$licenseId || !$studentId) Response::error('licenseId und studentId erforderlich');
$license = $db->fetchOne('SELECT id, teacher_id FROM licenses WHERE id = ? AND student_id IS NULL', [$licenseId]);
if (!$license) Response::error('Lizenz nicht gefunden oder bereits zugewiesen');
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) Response::error('Lizenz gehört anderer Lehrperson');
// Prüfen dass Schüler dem Lehrer gehört
$student = $db->fetchOne(
'SELECT s.id FROM students s JOIN classes c ON c.id = s.class_id WHERE s.id = ? AND c.teacher_id = ?',
[$studentId, $teacherId]
);
if (!$student) Response::error('Schüler*in nicht gefunden');
$db->execute('UPDATE licenses SET student_id = ?, teacher_id = ? WHERE id = ?', [$studentId, $teacherId, $licenseId]);
Response::ok();
}
// Auto-Assign: nächste freie Lizenz einem Schüler zuweisen
if ($action === 'auto_assign') {
$teacherId = Session::requireTeacher();
$studentId = (int)($body['studentId'] ?? 0);
if (!$studentId) Response::error('studentId erforderlich');
$student = $db->fetchOne(
'SELECT s.id FROM students s JOIN classes c ON c.id = s.class_id WHERE s.id = ? AND c.teacher_id = ?',
[$studentId, $teacherId]
);
if (!$student) Response::error('Schüler*in nicht gefunden');
// Nächste freie Lizenz des Lehrers
$license = $db->fetchOne(
'SELECT id FROM licenses WHERE teacher_id = ? AND student_id IS NULL ORDER BY id LIMIT 1',
[$teacherId]
);
if (!$license) Response::error('Keine freien Lizenzen verfügbar');
$db->execute('UPDATE licenses SET student_id = ? WHERE id = ?', [$studentId, $license['id']]);
Response::ok(['licenseId' => (int)$license['id']]);
}
// Super-Admin: neue Lizenzen generieren (NUR echter Admin)
if ($action === 'generate') {
if (!Session::adminId()) Response::error('Nur Admin', 403);
$count = min(1000, max(1, (int)($body['count'] ?? 100)));
$year = (int)($body['year'] ?? date('Y'));
$schoolYear = ($year % 100) . '/' . (($year % 100) + 1);
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$generated = 0; $tries = 0; $maxTries = $count * 10 + 100;
while ($generated < $count && $tries < $maxTries) {
$tries++;
$code = '';
for ($j = 0; $j < 3; $j++) {
if ($j > 0) $code .= '-';
for ($k = 0; $k < 4; $k++) $code .= $chars[random_int(0, strlen($chars) - 1)];
}
$code .= '-' . $year;
try {
$db->execute('INSERT INTO licenses (code, school_year) VALUES (?, ?)', [$code, $schoolYear]);
$generated++;
} catch (\Exception $e) {
// Kollision (unwahrscheinlich) → nächster Versuch; $maxTries schützt vor Endlosschleife
}
}
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');
}
Response::error('Methode nicht erlaubt', 405);