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 {