From 50c72fa8d81c91753005e3eabfe1367fb41f7a66 Mon Sep 17 00:00:00 2001
From: Thomas
Date: Wed, 26 Aug 2026 11:15:53 +0200
Subject: [PATCH] Lizenz-Vertrieb Feinschliff: Zugabe +10%, sichere
Datei-Einloesung, Mail-Config
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Umsetzung des Thomas-Feedbacks:
- Reihenfolge: freie Lizenzen beim Lehrer jetzt ORDER BY id (= Reihenfolge der
reinkopierten Liste); stornierte ausgeblendet.
- Gratis-Zugabe: Checkbox (Default an) "+10 % aufgerundet" -> geliefert =
ceil(bestellt*1.1) (14 -> 16). Batch speichert qty_ordered (verrechnet) und
qty (geliefert); Live-Vorschau im Formular.
- Einloeselink/QR ENTFERNT (Sicherheitsrisiko: falscher eingeloggter Account).
einloesen.html + pages/einloesen.php geloescht, Sammellink aus UI/Mail raus.
- CSV: Gueltig-bis + Metadaten nur in der KOPFZEILE, danach reine Code-Zeilen
(per Copy-Paste einloesbar). Dateiname: _geograsim_.
- Interne Bestellnummer = batch.id (laufend, nicht editierbar, storno-stabil).
- Lehrer-Einloesen: sichere .txt/.csv-Ablage (mehrstufig: Endung+MIME, Groessen-
limit, nur Text lesen, Whitelist-Regex extrahiert nur Code-Tokens, Server
validiert) — kein Upload, keine Ausfuehrung/Rendering.
- Mailsystem: zentrale Config in neuer app_settings (Provider server|aws,
Absender-Name/-Adresse, oeffentliche URL), pflegbar in Admin + Test-Versand.
Mailer liest Absender aus Config (SMTP-Zugang bleibt in .env). Mailtext nutzt
kanonische geograsim.at (nicht v3/localhost).
Migrationen: -2 (qty_ordered), -3 (app_settings). Beide additiv, auf v3 angewandt.
Co-Authored-By: Claude Opus 4.8
---
.../2026-08-26-lizenz-vertrieb-2.sql | 8 ++
...6-08-26-lizenz-vertrieb-3-mailsettings.sql | 19 +++
App/admin-licenses.html | 124 +++++++++++++++---
App/einloesen.html | 80 -----------
App/pages/einloesen.php | 1 -
App/php/api/licenses.php | 76 +++++++++--
App/php/lib/Mailer.php | 25 +++-
App/teacher.html | 37 +++++-
8 files changed, 253 insertions(+), 117 deletions(-)
create mode 100644 App/Don_t_Deploy/2026-08-26-lizenz-vertrieb-2.sql
create mode 100644 App/Don_t_Deploy/2026-08-26-lizenz-vertrieb-3-mailsettings.sql
delete mode 100644 App/einloesen.html
delete mode 100644 App/pages/einloesen.php
diff --git a/App/Don_t_Deploy/2026-08-26-lizenz-vertrieb-2.sql b/App/Don_t_Deploy/2026-08-26-lizenz-vertrieb-2.sql
new file mode 100644
index 0000000..5671e26
--- /dev/null
+++ b/App/Don_t_Deploy/2026-08-26-lizenz-vertrieb-2.sql
@@ -0,0 +1,8 @@
+-- =====================================================================
+-- Lizenz-Vertrieb — Nachtrag 2 (2026-08-26)
+-- * bestellte vs. gelieferte Menge (Zugabe +10 %)
+-- Additiv & non-breaking. (Mail-Config: siehe Nachtrag 3, app_settings.)
+-- =====================================================================
+
+ALTER TABLE license_batches
+ ADD COLUMN qty_ordered INT UNSIGNED NULL AFTER qty; -- bestellt (verrechnet); qty = geliefert inkl. Zugabe
diff --git a/App/Don_t_Deploy/2026-08-26-lizenz-vertrieb-3-mailsettings.sql b/App/Don_t_Deploy/2026-08-26-lizenz-vertrieb-3-mailsettings.sql
new file mode 100644
index 0000000..c6a4e4a
--- /dev/null
+++ b/App/Don_t_Deploy/2026-08-26-lizenz-vertrieb-3-mailsettings.sql
@@ -0,0 +1,19 @@
+-- =====================================================================
+-- Lizenz-Vertrieb — Nachtrag 3 (2026-08-26): portable App-Settings
+-- Eigene Key/Value-Tabelle fuer die Mail-Konfiguration (Provider/Absender/URL).
+-- Ersetzt die Nutzung der lokalen Orphan-Tabelle platform_config_v2 (existiert
+-- auf v3/Prod nicht). Additiv & non-breaking. MariaDB + MySQL 8 kompatibel.
+-- =====================================================================
+
+CREATE TABLE IF NOT EXISTS app_settings (
+ skey VARCHAR(64) NOT NULL PRIMARY KEY,
+ sval TEXT NULL,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ updated_by INT UNSIGNED NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+INSERT IGNORE INTO app_settings (skey, sval) VALUES
+ ('mail_provider', 'server'),
+ ('mail_from_email', 'support@geograsim.at'),
+ ('mail_from_name', 'GeoGraSim'),
+ ('mail_public_url', 'https://geograsim.at');
diff --git a/App/admin-licenses.html b/App/admin-licenses.html
index c4d5704..9db94fa 100644
--- a/App/admin-licenses.html
+++ b/App/admin-licenses.html
@@ -128,8 +128,8 @@
📦 Lizenzen ausgeben (Vertrieb)
-
-
+
+
@@ -168,6 +168,12 @@
+
+
+
+
@@ -192,6 +198,41 @@
+
+
+
✉️ Mail-Einstellungen
+
+
+
+
+
AWS wird aktiv, sobald konfiguriert.
+
+
+
+
+
+
+
+
+
SMTP-Zugang bleibt geheim in der .env.
+
+
+
+
+
Für Links in Mails (nicht v3/localhost).
+
+
+
+
+
+
+
+
+
+
Neue Lizenzen generieren
@@ -409,16 +450,18 @@ async function issueBatch(){
var name = document.getElementById('iss-name').value.trim();
var vf = document.getElementById('iss-from').value;
var vu = document.getElementById('iss-until').value;
+ var bonus = document.getElementById('iss-bonus').checked;
var msg = document.getElementById('iss-msg');
if(count<1||count>5000){ msg.textContent='Anzahl 1–5000.'; return; }
+ var delivered = calcDelivered(count, bonus);
var who = name||email||custno||'(kein Kunde)';
- if(count>=100 && !confirm(count+' Codes für „'+who+'"'+(order?' (Best.-Nr. '+order+')':'')+', gültig bis '+fmtDate(vu)+', Typ '+type+'.\n\nWirklich erzeugen?')) return;
+ if(count>=100 && !confirm(count+' bestellt → '+delivered+' Codes im Paket für „'+who+'"'+(order?' (Best.-Nr. '+order+')':'')+', gültig bis '+fmtDate(vu)+', Typ '+type+'.\n\nWirklich erzeugen?')) return;
msg.textContent = 'Erzeuge …';
- var r = await api('licenses', {action:'issue_batch', count:count, sales_type:type, order_no:order,
+ var r = await api('licenses', {action:'issue_batch', count:count, bonus:bonus, sales_type:type, order_no:order,
customer:{email:email, customer_no:custno, name:name}, valid_from:vf, valid_until:vu});
if(r.error){ msg.innerHTML='
'+esc(r.error)+''; return; }
- msg.innerHTML='
✓ '+r.count+' Codes erzeugt (Charge #'+r.batch_id+').';
- lastIssue = {batch_id:r.batch_id, codes:r.codes, count:r.count, valid_until:r.valid_until, token:r.redeem_token, order_no:order, email:email};
+ msg.innerHTML='
✓ '+r.count+' Codes erzeugt (Bestellung #'+r.internal_no+', bestellt '+r.ordered+').';
+ lastIssue = {batch_id:r.batch_id, internal_no:r.internal_no, ordered:r.ordered, codes:r.codes, count:r.count, valid_until:r.valid_until, token:r.redeem_token, order_no:order, email:email};
renderIssueResult();
loadBatches();
loadLicenses();
@@ -429,17 +472,15 @@ function renderIssueResult(){
var codesText = m.codes.join('\n');
var mailText = buildMailText(m);
var h = '
'
+ + '
Bestellung #'+m.internal_no+' · bestellt '+m.ordered+' · geliefert '+m.count+''+(m.count>m.ordered?' (+'+(m.count-m.ordered)+' gratis)':'')+' · gültig bis '+fmtDate(m.valid_until)+'
'
+ '
1 · Codes (' + m.count + ') — kopieren
'
+ '
'
+ '
'
+ ''
+ ''
- + ''
+ + ''
+ '
'
- + '
2 · Sammel-Einlöse-Link (alle auf einmal)
'
- + '
'
- + '
Pro Code funktioniert auch: '+esc(absUrl('/einloesen?code=CODE'))+'
'
- + '
3 · Per Mail senden
'
+ + '
2 · Per Mail senden
'
+ '
'
+ '
'
+ '
'
@@ -457,20 +498,65 @@ function buildMailText(m){
var order = m.order_no ? ('\nBestellnummer: '+m.order_no) : '';
return 'Guten Tag,\n\nvielen Dank für Ihre Bestellung. Anbei Ihre '+m.count+' GeoGraSim-Lizenzcodes, gültig bis '+fmtDate(m.valid_until)+'.'+order+'\n\n'
+ codesPart + '\n\n'
- + 'Einlösen: Als Lehrperson unter '+absUrl('/')+' anmelden und die Codes unter „Lizenzen" eingeben.\n'
- + 'Oder alle auf einmal über diesen Link: '+absUrl('/einloesen?charge='+m.token)+'\n\n'
+ + 'Einlösen: Als Lehrperson unter '+PUBLIC_URL+' anmelden und die Codes unter „Lizenzen" eingeben.\n\n'
+ 'Freundliche Grüße\nGeoGraSim';
}
function copyEl(id){ var el=document.getElementById(id); el.select(); navigator.clipboard.writeText(el.value).catch(function(){document.execCommand('copy');}); }
+// CSV: Metadaten (inkl. Gültig-bis) in die KOPFZEILE, danach reine Code-Zeilen
+// -> der Code-Block bleibt per Copy-Paste einlösbar (kein Datum je Zeile).
function downloadCodes(fmt){
var m = lastIssue; if(!m) return;
var content, mime;
- if(fmt==='csv'){ content='Code;Gueltig_bis\n'+m.codes.map(function(c){return c+';'+m.valid_until;}).join('\n')+'\n'; mime='text/csv'; }
- else { content=m.codes.join('\r\n')+'\r\n'; mime='text/plain'; }
- var base = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
- var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=base+'.'+fmt; a.click();
+ if(fmt==='csv'){
+ var head='Bestellung;#'+(m.internal_no||'')+'\nExterne Bestellnummer;'+(m.order_no||'')+'\nGültig bis;'+fmtDate(m.valid_until)+'\nAnzahl;'+m.count+'\n\n';
+ content=head+m.codes.join('\r\n')+'\r\n'; mime='text/csv';
+ } else { content=m.codes.join('\r\n')+'\r\n'; mime='text/plain'; }
+ var ext = m.order_no ? m.order_no.replace(/[^a-zA-Z0-9_\-]/g,'_') : (new Date().toISOString().slice(0,16).replace(/[:T]/g,'-'));
+ var fname = ext+'_geograsim_'+(m.internal_no||'')+'.'+fmt;
+ var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([content],{type:mime})); a.download=fname; a.click();
+}
+
+function calcDelivered(ordered, bonus){ return bonus ? Math.ceil(ordered*1.1) : ordered; }
+function updatePreview(){
+ var o=parseInt(document.getElementById('iss-count').value)||0;
+ var b=document.getElementById('iss-bonus').checked;
+ var el=document.getElementById('iss-preview'); if(!el) return;
+ if(o<1){ el.textContent=''; return; }
+ var d=calcDelivered(o,b);
+ el.innerHTML = b ? ('→ im Paket
'+d+' Codes (+'+(d-o)+' gratis)') : ('→
'+d+' Codes (keine Zugabe)');
+}
+
+// ── Mail-Einstellungen ──
+var PUBLIC_URL='https://geograsim.at';
+async function loadMailConfig(){
+ var r=await api('licenses?admin=1&mail_config=1');
+ if(!r || r.error) return;
+ PUBLIC_URL=r.public_url||PUBLIC_URL;
+ document.getElementById('mc-provider').value=r.provider||'server';
+ document.getElementById('mc-fromname').value=r.from_name||'';
+ document.getElementById('mc-fromemail').value=r.from_email||'';
+ document.getElementById('mc-publicurl').value=r.public_url||'';
+}
+async function saveMailConfig(){
+ var mm=document.getElementById('mc-msg'); mm.textContent='Speichere …';
+ var r=await api('licenses',{action:'set_mail_config',
+ provider:document.getElementById('mc-provider').value,
+ from_name:document.getElementById('mc-fromname').value.trim(),
+ from_email:document.getElementById('mc-fromemail').value.trim(),
+ public_url:document.getElementById('mc-publicurl').value.trim()});
+ if(r.error){ mm.innerHTML='
'+esc(r.error)+''; return; }
+ mm.innerHTML='
✓ Gespeichert.'; loadMailConfig();
+}
+async function testMail(){
+ var to=document.getElementById('mc-testto').value.trim();
+ var mm=document.getElementById('mc-msg');
+ if(!to){ mm.innerHTML='
Test-Adresse fehlt.'; return; }
+ mm.textContent='Sende Test …';
+ var r=await api('licenses',{action:'test_mail', to_email:to});
+ if(r.error){ mm.innerHTML='
'+esc(r.error)+''; return; }
+ mm.innerHTML='
✓ Test gesendet.';
}
async function sendMail(batchId){
@@ -542,8 +628,6 @@ async function openBatch(id){
+ ' · gültig '+fmtDate(b.default_valid_from)+'–'+fmtDate(b.default_valid_until)
+ (b.canceled_at?' ·
Charge storniert':'')+'
';
h+='
'
- + ''
- + ''
+ ''
+ '
';
h+='
| Code | Status | Gültig bis | Lehrperson | |
';
@@ -608,6 +692,8 @@ fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/
loadLicenses();
prefillDefaults();
loadBatches();
+ loadMailConfig();
+ updatePreview();
});
-
-
GeoGraSim
-
Lizenz einlösen
-
-
-
-
-
-
diff --git a/App/einloesen.html b/App/einloesen.html
deleted file mode 100644
index bdc7d08..0000000
--- a/App/einloesen.html
+++ /dev/null
@@ -1,80 +0,0 @@
-
-
-