From 96a73e22b2f478638c7ecc6b31929d5bffaf7ac2 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 26 Aug 2026 01:39:14 +0200 Subject: [PATCH] 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 --- .../2026-08-26-lizenz-vertrieb.sql | 103 ++++++ App/admin-licenses.html | 334 ++++++++++++++++++ App/einloesen.html | 80 +++++ App/pages/einloesen.php | 1 + App/php/api/licenses.php | 311 +++++++++++++++- App/php/lib/Database.php | 6 + App/php/lib/Mailer.php | 25 ++ 7 files changed, 852 insertions(+), 8 deletions(-) create mode 100644 App/Don_t_Deploy/2026-08-26-lizenz-vertrieb.sql create mode 100644 App/einloesen.html create mode 100644 App/pages/einloesen.php diff --git a/App/Don_t_Deploy/2026-08-26-lizenz-vertrieb.sql b/App/Don_t_Deploy/2026-08-26-lizenz-vertrieb.sql new file mode 100644 index 0000000..127a83a --- /dev/null +++ b/App/Don_t_Deploy/2026-08-26-lizenz-vertrieb.sql @@ -0,0 +1,103 @@ +-- ===================================================================== +-- Lizenz-Vergabe / Vertrieb (2026-08-26) +-- Additiv & non-breaking: bestehende `licenses`-Zeilen (batch_id NULL) und +-- die Flows generate/redeem/assign bleiben unveraendert funktionsfaehig. +-- Neu: Kunden, Chargen (Bestellungen), Mail-Archiv, Storno, Verlaengerung, +-- Brute-Force-Schutz + Audit-Log, laengere Codes, Charge-Einloeselink. +-- ===================================================================== + +-- 1) Kunden (Besteller) ------------------------------------------------ +CREATE TABLE IF NOT EXISTS license_customers ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + customer_no VARCHAR(40) NULL, -- INTERNE Kundennummer (optional) + email VARCHAR(190) NULL, + name VARCHAR(190) NULL, -- Besteller / Verlag / Schule + note VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_customer_no (customer_no), + KEY idx_email (email), + KEY idx_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2) Chargen (Bestellungen) ------------------------------------------- +CREATE TABLE IF NOT EXISTS license_batches ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + customer_id INT UNSIGNED NULL, + order_no VARCHAR(120) NULL, -- EXTERNE Bestellnummer (Marktplatz/Ministerium), Freitext -> geht in die Mail + sales_type VARCHAR(30) NOT NULL DEFAULT 'privat', -- marktplatz | direktvertrieb | privat (spaeter erweiterbar) + qty INT UNSIGNED NOT NULL DEFAULT 0, + default_valid_from DATE NULL, + default_valid_until DATE NULL, + note VARCHAR(255) NULL, + redeem_token CHAR(40) NULL, -- Sammel-Einloeselink der ganzen Charge + created_by INT UNSIGNED NULL, -- admin_users.id + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + canceled_at TIMESTAMP NULL, + canceled_reason VARCHAR(255) NULL, + UNIQUE KEY uq_redeem_token (redeem_token), + KEY idx_customer (customer_id), + KEY idx_order (order_no), + KEY idx_type (sales_type), + KEY idx_created (created_at), + CONSTRAINT fk_batch_customer FOREIGN KEY (customer_id) REFERENCES license_customers(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 3) licenses erweitern ------------------------------------------------ +-- code auf 32 Zeichen verbreitern (laengere Codes), Vertriebsfelder ergaenzen. +ALTER TABLE licenses + MODIFY COLUMN code VARCHAR(32) NOT NULL, + ADD COLUMN batch_id INT UNSIGNED NULL AFTER school_year, + ADD COLUMN issued_at TIMESTAMP NULL AFTER redeemed_at, + ADD COLUMN valid_from DATE NULL AFTER issued_at, + ADD COLUMN valid_until DATE NULL AFTER valid_from, + ADD COLUMN canceled_at TIMESTAMP NULL AFTER valid_until, + ADD COLUMN canceled_reason VARCHAR(255) NULL AFTER canceled_at, + ADD KEY idx_batch (batch_id), + ADD KEY idx_valid_until (valid_until), + ADD KEY idx_issued (issued_at), + ADD CONSTRAINT fk_license_batch FOREIGN KEY (batch_id) REFERENCES license_batches(id) ON DELETE SET NULL; + +-- 4) Mail-Archiv / Outbox (unabhaengig vom IMAP-Postausgang) ----------- +CREATE TABLE IF NOT EXISTS license_mails ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + batch_id INT UNSIGNED NULL, + to_email VARCHAR(190) NOT NULL, + subject VARCHAR(255) NOT NULL, + body_text MEDIUMTEXT NULL, -- gerenderter Klartext (kopierbar + Archiv) + body_html MEDIUMTEXT NULL, + provider VARCHAR(20) NOT NULL DEFAULT 'server', -- server | ses + status VARCHAR(12) NOT NULL DEFAULT 'queued', -- queued | sent | failed + error VARCHAR(255) NULL, + created_by INT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + sent_at TIMESTAMP NULL, + KEY idx_batch (batch_id), + KEY idx_status (status), + CONSTRAINT fk_mail_batch FOREIGN KEY (batch_id) REFERENCES license_batches(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 5) Brute-Force-Schutz + Audit beim Einloesen ------------------------- +CREATE TABLE IF NOT EXISTS license_redeem_attempts ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ip VARBINARY(16) NULL, -- INET6_ATON(...) + teacher_id INT UNSIGNED NULL, + code_tried VARCHAR(32) NULL, + ok TINYINT(1) NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_ip_time (ip, created_at), + KEY idx_teacher_time (teacher_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 6) Audit-Log fuer Chargen/Codes ------------------------------------- +CREATE TABLE IF NOT EXISTS license_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + batch_id INT UNSIGNED NULL, + license_id INT UNSIGNED NULL, + event VARCHAR(30) NOT NULL, -- issued|extended|canceled|mailed|redeemed + detail VARCHAR(255) NULL, + admin_id INT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_batch (batch_id), + KEY idx_license (license_id), + KEY idx_event (event) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/App/admin-licenses.html b/App/admin-licenses.html index 1c5ff97..c4d5704 100644 --- a/App/admin-licenses.html +++ b/App/admin-licenses.html @@ -62,6 +62,31 @@ .dm-derive .d-code{font-family:'Courier New',monospace;font-weight:800;color:#4a7c8a;letter-spacing:.04em} .dm-derive .d-hi{background:#e8c547;color:#4a4a4a;padding:.02rem .28rem;border-radius:4px;font-weight:800} td.mono{font-family:'Courier New',monospace;font-size:.68rem;color:#c85c4a} + /* ---- Vertrieb / Ausgabe ---- */ + .frm{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.6rem} + .frm label{font-size:.6rem;font-weight:700;color:#6a6a6a;text-transform:uppercase;letter-spacing:.03em;display:block;margin-bottom:.2rem} + .frm input,.frm select,.frm textarea{width:100%;padding:.4rem .55rem;border:1.5px solid rgba(0,0,0,.12);border-radius:6px;font-size:.78rem;font-family:inherit;background:#fff} + .frm textarea{resize:vertical;line-height:1.5} + .hint-s{font-size:.62rem;color:#8a8a8a;margin-top:.2rem} + .badge{display:inline-block;padding:.1rem .45rem;border-radius:20px;font-size:.58rem;font-weight:800;letter-spacing:.02em;color:#fff} + .b-issued{background:#4a7c8a}.b-redeemed{background:#5a8a5e}.b-assigned{background:#3f7a45} + .b-canceled{background:#b0453a}.b-expired{background:#c98a2a}.b-free{background:#9a9a9a} + .kpi{display:inline-flex;flex-direction:column;align-items:center;min-width:52px} + .kpi b{font-size:.9rem;font-weight:900;color:#3a4a4f}.kpi span{font-size:.52rem;color:#8a8a8a;text-transform:uppercase} + .out{background:#f2f8f9;border:1px solid #cfe3e8;border-radius:8px;padding:.8rem;margin-top:.8rem} + .out h4{font-size:.66rem;color:#2f6373;margin-bottom:.4rem;text-transform:uppercase;letter-spacing:.04em} + .row-act{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center} + .lnk{font-family:'Courier New',monospace;font-size:.66rem;background:#fff;border:1px solid rgba(0,0,0,.12);border-radius:6px;padding:.35rem .5rem;flex:1;min-width:180px;color:#3a4a4f} + .ovl{position:fixed;inset:0;background:rgba(20,30,35,.55);display:none;align-items:flex-start;justify-content:center;z-index:100;overflow-y:auto;padding:2rem 1rem} + .ovl.open{display:flex} + .modal{background:#fff;border-radius:12px;max-width:820px;width:100%;padding:1.1rem;box-shadow:0 12px 40px rgba(0,0,0,.25)} + .modal-h{display:flex;align-items:center;gap:.6rem;margin-bottom:.6rem} + .modal-h h3{font-size:.9rem;font-weight:800;color:#2f3b3f;flex:1} + .x{cursor:pointer;font-size:1.1rem;color:#8a8a8a;background:none;border:none;padding:.2rem .5rem} + .mini{font-size:.66rem;color:#8a8a8a} + .chip-copy{cursor:pointer} + details.batchdet{border:1px solid rgba(0,0,0,.06);border-radius:8px;margin-bottom:.4rem;padding:.2rem .5rem} + details.batchdet summary{cursor:pointer;font-size:.72rem;font-weight:600;padding:.3rem 0} @@ -98,6 +123,75 @@ + +
+
📦 Lizenzen ausgeben (Vertrieb)
+
+
+ + +
+
+ + +
+
+ + +
Geht in die Mail (für die Rechnung des Kunden).
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Standard: 1. Okt. Folgejahr (merkt sich die letzte Wahl).
+
+
+ + +
+ + +
+
+
+ + +
+
🧾 Bestellungen & Gültigkeit
+
+ + + + +
+
+
+
Neue Lizenzen generieren
@@ -200,6 +294,17 @@
+ + + diff --git a/App/einloesen.html b/App/einloesen.html new file mode 100644 index 0000000..bdc7d08 --- /dev/null +++ b/App/einloesen.html @@ -0,0 +1,80 @@ + + + + + + Lizenz einlösen — GeoGraSim + + + + +
+
GeoGraSim
+
Lizenz einlösen
+
+ +
+ + + + diff --git a/App/pages/einloesen.php b/App/pages/einloesen.php new file mode 100644 index 0000000..21a0704 --- /dev/null +++ b/App/pages/einloesen.php @@ -0,0 +1 @@ += 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'); } diff --git a/App/php/lib/Database.php b/App/php/lib/Database.php index f1750af..d6185ea 100644 --- a/App/php/lib/Database.php +++ b/App/php/lib/Database.php @@ -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(); } } diff --git a/App/php/lib/Mailer.php b/App/php/lib/Mailer.php index 7250773..d8a0a79 100644 --- a/App/php/lib/Mailer.php +++ b/App/php/lib/Mailer.php @@ -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(['
','
','

',''], "\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 '
' . $safe . '
'; + } + // ─── Willkommen (Lehrperson registriert sich) ─── public static function sendWelcomeTeacher(string $email, string $name): bool {