Lizenz-Vertrieb: Chargen-Vergabe, Storno, Verlaengerung, Mail, Einloeselink

Erweitert das Lizenzmodul um eine Vertriebs-/Vergabe-Schicht ueber dem
bestehenden Einloese-Flow (non-breaking, alle Alt-Codes + generate/redeem
/assign unveraendert).

Schema (Migration 2026-08-26-lizenz-vertrieb.sql, additiv):
- license_customers (Besteller: Kundennummer/Email/Name)
- license_batches (Bestellung/Charge: externe Bestellnummer, Typ, Menge,
  Gueltigkeit, Sammel-Einloesetoken, Storno)
- licenses += batch_id, issued_at, valid_from/until (pro Code -> Verlaengerung),
  canceled_at/reason; code auf VARCHAR(32) (laengere Codes)
- license_mails (Mail-Archiv, unabh. vom IMAP), license_redeem_attempts
  (Brute-Force-Schutz), license_events (Audit-Log)

Backend (php/api/licenses.php):
- issue_batch: N frische 100-Bit-Codes in Transaktion, sofort vergeben,
  an Kunde/Bestellung/Gueltigkeit gebunden; sticky Default Gueltig-bis
- Uebersicht (view=batches mit Aggregat ausgegeben/storniert/netto/eingeloest
  /abgelaufen), batch_detail, Kunden-Autocomplete, sales_default
- cancel_batch/cancel_license, extend_batch/extend_license
- send_batch_mail (Server-Treiber via Mailer, archiviert, Anhang bei >40)
- redeem_charge (Sammel-Einloeselink)
- Brute-Force-Bremse + Audit + Storno-Ablehnung im redeem/redeem_batch

UI (admin-licenses.html): Ausgabe-Formular (Bestaetigung bei Grossmengen),
Ergebnis mit Copy/Download(txt+csv)/Mail-Composer/Sammellink, Bestellungs-
Uebersicht mit Filtern + Detail-Modal (Storno/Verlaengern/Verlauf/Mails).

Einloese-Landingpage (einloesen.html + pages/einloesen.php): ?charge=Token
(alle auf einmal) und ?code=CODE, mit Lehrer-Login-Gate.

Lib: Database Transaktionen; Mailer sendWithAttachments + renderPlainBody.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 01:39:14 +02:00
parent 7691649f37
commit 96a73e22b2
7 changed files with 852 additions and 8 deletions
@@ -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;
+334
View File
@@ -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}
</style>
</head>
<body>
@@ -98,6 +123,75 @@
</div>
</div>
<!-- ============ Vertrieb: Charge ausgeben ============ -->
<div class="card">
<div class="card-h">📦 Lizenzen ausgeben (Vertrieb)</div>
<div class="frm">
<div>
<label>Anzahl</label>
<input type="number" id="iss-count" value="12" min="1" max="5000">
</div>
<div>
<label>Typ</label>
<select id="iss-type">
<option value="marktplatz">Marktplatz Lernapps</option>
<option value="direktvertrieb">Direktvertrieb Schule</option>
<option value="privat" selected>Privat</option>
</select>
</div>
<div>
<label>Externe Bestellnummer</label>
<input type="text" id="iss-order" placeholder="z. B. Marktplatz/Ministerium" list="order-hints">
<div class="hint-s">Geht in die Mail (für die Rechnung des Kunden).</div>
</div>
<div>
<label>Kunde — E-Mail</label>
<input type="email" id="iss-email" placeholder="kunde@schule.at" list="cust-hints" oninput="custSuggest(this.value)">
</div>
<div>
<label>Kundennummer (intern)</label>
<input type="text" id="iss-custno" placeholder="optional">
</div>
<div>
<label>Name / Verlag / Schule</label>
<input type="text" id="iss-name" placeholder="optional">
</div>
<div>
<label>Gültig von</label>
<input type="date" id="iss-from">
</div>
<div>
<label>Gültig bis</label>
<input type="date" id="iss-until">
<div class="hint-s">Standard: 1. Okt. Folgejahr (merkt sich die letzte Wahl).</div>
</div>
</div>
<datalist id="cust-hints"></datalist>
<datalist id="order-hints"></datalist>
<div style="margin-top:.7rem" class="row-act">
<button class="btn" onclick="issueBatch()">Codes erzeugen &amp; ausgeben</button>
<span id="iss-msg" class="mini"></span>
</div>
<div id="iss-result"></div>
</div>
<!-- ============ Bestellungen / Chargen-Übersicht ============ -->
<div class="card">
<div class="card-h">🧾 Bestellungen &amp; Gültigkeit</div>
<div class="filter-bar">
<select id="bf-type" onchange="loadBatches()">
<option value="">Alle Typen</option>
<option value="marktplatz">Marktplatz Lernapps</option>
<option value="direktvertrieb">Direktvertrieb Schule</option>
<option value="privat">Privat</option>
</select>
<input type="text" id="bf-customer" placeholder="Kunde (E-Mail/Name/Nr.)" oninput="debouncedBatches()">
<input type="text" id="bf-order" placeholder="Bestellnummer" oninput="debouncedBatches()">
<button class="btn btn-outline" onclick="loadBatches()">Aktualisieren</button>
</div>
<div id="batch-table" style="overflow-x:auto"></div>
</div>
<div class="card">
<div class="card-h">Neue Lizenzen generieren</div>
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap">
@@ -200,6 +294,17 @@
</div>
</div>
<!-- Detail-Modal einer Bestellung -->
<div class="ovl" id="modal-ovl">
<div class="modal">
<div class="modal-h">
<h3 id="m-title">Bestellung</h3>
<button class="x" onclick="closeModal()"></button>
</div>
<div id="m-body"></div>
</div>
</div>
<script>
var BASE = (window.__GGS__?.baseUrl) || '';
if (!BASE) { var p = location.pathname, i = p.indexOf('/geograsim'); if (i !== -1) { var rest = p.substring(i + '/geograsim'.length); BASE = rest.indexOf('/App/') === 0 ? p.substring(0, i) + '/geograsim/App' : p.substring(0, i) + '/geograsim'; } }
@@ -268,12 +373,241 @@ function exportFree() {
});
}
// ═══════════════ Vertrieb: Ausgeben ═══════════════
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function today(){ return new Date().toISOString().slice(0,10); }
function fmtDate(d){ if(!d) return '—'; var p=String(d).slice(0,10).split('-'); return p.length===3 ? p[2]+'.'+p[1]+'.'+p[0] : d; }
function absUrl(path){ return location.origin + BASE + path; }
var lastIssue = null;
async function prefillDefaults(){
var r = await api('licenses?admin=1&sales_default=1');
if (r && r.valid_from) {
if(!document.getElementById('iss-from').value) document.getElementById('iss-from').value = r.valid_from;
if(!document.getElementById('iss-until').value) document.getElementById('iss-until').value = r.valid_until;
}
}
var custTimer=null;
function custSuggest(v){
if(custTimer) clearTimeout(custTimer);
if(!v || v.length<2) return;
custTimer = setTimeout(async function(){
var r = await api('licenses?admin=1&customers='+encodeURIComponent(v));
if(!r || !r.customers) return;
var dl = document.getElementById('cust-hints');
dl.innerHTML = r.customers.map(function(c){ return '<option value="'+esc(c.email||'')+'">'+esc((c.name||'')+(c.customer_no?' · '+c.customer_no:''))+'</option>'; }).join('');
}, 250);
}
async function issueBatch(){
var count = parseInt(document.getElementById('iss-count').value)||0;
var type = document.getElementById('iss-type').value;
var order = document.getElementById('iss-order').value.trim();
var email = document.getElementById('iss-email').value.trim();
var custno = document.getElementById('iss-custno').value.trim();
var name = document.getElementById('iss-name').value.trim();
var vf = document.getElementById('iss-from').value;
var vu = document.getElementById('iss-until').value;
var msg = document.getElementById('iss-msg');
if(count<1||count>5000){ msg.textContent='Anzahl 15000.'; return; }
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;
msg.textContent = 'Erzeuge …';
var r = await api('licenses', {action:'issue_batch', count:count, 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='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
msg.innerHTML='<span style="color:#5a8a5e">✓ '+r.count+' Codes erzeugt (Charge #'+r.batch_id+').</span>';
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};
renderIssueResult();
loadBatches();
loadLicenses();
}
function renderIssueResult(){
var m = lastIssue; if(!m) return;
var codesText = m.codes.join('\n');
var mailText = buildMailText(m);
var h = '<div class="out">'
+ '<h4>1 · Codes (' + m.count + ') — kopieren</h4>'
+ '<textarea id="codes-ta" rows="'+Math.min(10,Math.max(3,m.codes.length))+'" readonly>'+esc(codesText)+'</textarea>'
+ '<div class="row-act" style="margin-top:.4rem">'
+ '<button class="btn btn-outline" onclick="copyEl(\'codes-ta\')">📋 Kopieren</button>'
+ '<button class="btn btn-outline" onclick="downloadCodes(\'txt\')">⬇ .txt</button>'
+ '<button class="btn btn-outline" onclick="downloadCodes(\'csv\')">⬇ .csv (mit Gültig-bis)</button>'
+ '</div>'
+ '<h4 style="margin-top:.9rem">2 · Sammel-Einlöse-Link (alle auf einmal)</h4>'
+ '<div class="row-act"><input class="lnk" id="charge-link" readonly value="'+esc(absUrl('/einloesen?charge='+m.token))+'"><button class="btn btn-outline" onclick="copyEl(\'charge-link\')">📋</button></div>'
+ '<div class="hint-s">Pro Code funktioniert auch: '+esc(absUrl('/einloesen?code=CODE'))+'</div>'
+ '<h4 style="margin-top:.9rem">3 · Per Mail senden</h4>'
+ '<div class="frm" style="grid-template-columns:1fr 2fr">'
+ '<div><label>An</label><input type="email" id="mail-to" value="'+esc(m.email||'')+'"></div>'
+ '<div><label>Betreff</label><input type="text" id="mail-subj" value="Ihre GeoGraSim-Lizenzcodes'+(m.order_no?' — Best.-Nr. '+esc(m.order_no):'')+'"></div>'
+ '</div>'
+ '<label style="margin-top:.5rem;display:block">Mailtext (editierbar)</label>'
+ '<textarea id="mail-body" rows="10">'+esc(mailText)+'</textarea>'
+ '<div class="hint-s">'+(m.count>40?'Große Charge: die Codes gehen automatisch als Datei-Anhang mit.':'Die Codes stehen im Text.')+' Der Text ist auch für den Selbstversand kopierbar.</div>'
+ '<div class="row-act" style="margin-top:.4rem"><button class="btn" onclick="sendMail('+m.batch_id+')">✉ Absenden</button><span id="mail-msg" class="mini"></span></div>'
+ '</div>';
document.getElementById('iss-result').innerHTML = h;
}
function buildMailText(m){
var codesPart = (m.count>40) ? 'Die '+m.count+' Codes finden Sie in der angehängten Datei „lizenzcodes.txt".' : m.codes.join('\n');
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'
+ 'Freundliche Grüße\nGeoGraSim';
}
function copyEl(id){ var el=document.getElementById(id); el.select(); navigator.clipboard.writeText(el.value).catch(function(){document.execCommand('copy');}); }
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();
}
async function sendMail(batchId){
var to=document.getElementById('mail-to').value.trim();
var subj=document.getElementById('mail-subj').value.trim();
var body=document.getElementById('mail-body').value;
var mm=document.getElementById('mail-msg');
if(!to){ mm.innerHTML='<span style="color:#c07a6b">Empfänger fehlt.</span>'; return; }
mm.textContent='Sende …';
var r=await api('licenses',{action:'send_batch_mail', batch_id:batchId, to_email:to, subject:subj, body_text:body});
if(r.error){ mm.innerHTML='<span style="color:#c07a6b">'+esc(r.error)+'</span>'; return; }
mm.innerHTML='<span style="color:#5a8a5e">✓ Gesendet &amp; archiviert.</span>';
}
// ═══════════════ Bestellungen / Übersicht ═══════════════
var batchTimer=null;
function debouncedBatches(){ if(batchTimer)clearTimeout(batchTimer); batchTimer=setTimeout(loadBatches,300); }
async function loadBatches(){
var qs = 'licenses?admin=1&view=batches';
var t=document.getElementById('bf-type').value; if(t) qs+='&type='+encodeURIComponent(t);
var c=document.getElementById('bf-customer').value.trim(); if(c) qs+='&customer='+encodeURIComponent(c);
var o=document.getElementById('bf-order').value.trim(); if(o) qs+='&order_no='+encodeURIComponent(o);
var r=await api(qs);
if(!r || !r.batches){ document.getElementById('batch-table').innerHTML='<div class="mini">—</div>'; return; }
var typeLabel={marktplatz:'Marktplatz',direktvertrieb:'Direktvertrieb',privat:'Privat'};
var html='<table><tr><th>#</th><th>Bestellnr.</th><th>Kunde</th><th>Typ</th><th>Erstellt</th><th>Gültig bis</th>'
+ '<th>Ausgeg.</th><th>Storno</th><th>Netto</th><th>Eingelöst</th><th>Abgel.</th><th></th></tr>';
r.batches.forEach(function(b){
var net=(b.n_total|0)-(b.n_canceled|0);
var cust=esc(b.customer_name||b.customer_email||b.customer_no||'—');
var canceled=b.canceled_at?' <span class="badge b-canceled">storniert</span>':'';
html+='<tr>'
+'<td>'+b.id+'</td>'
+'<td class="mono">'+esc(b.order_no||'—')+'</td>'
+'<td>'+cust+canceled+'</td>'
+'<td>'+esc(typeLabel[b.sales_type]||b.sales_type)+'</td>'
+'<td>'+fmtDate(b.created_at)+'</td>'
+'<td>'+fmtDate(b.default_valid_until)+'</td>'
+'<td>'+(b.n_total|0)+'</td>'
+'<td>'+((b.n_canceled|0)?'<b style="color:#b0453a">'+b.n_canceled+'</b>':'0')+'</td>'
+'<td><b>'+net+'</b></td>'
+'<td>'+(b.n_redeemed|0)+'</td>'
+'<td>'+((b.n_expired|0)?'<b style="color:#c98a2a">'+b.n_expired+'</b>':'0')+'</td>'
+'<td><button class="btn-sm" onclick="openBatch('+b.id+')">Details</button></td>'
+'</tr>';
});
html+='</table>';
document.getElementById('batch-table').innerHTML=html;
}
function codeStatus(l){
if(l.canceled_at) return '<span class="badge b-canceled">storniert</span>';
var base = l.student_id ? '<span class="badge b-assigned">zugewiesen</span>'
: l.redeemed_at ? '<span class="badge b-redeemed">eingelöst</span>'
: l.issued_at ? '<span class="badge b-issued">vergeben</span>'
: '<span class="badge b-free">frei</span>';
var exp = l.valid_until && String(l.valid_until).slice(0,10) < today();
return base + (exp?' <span class="badge b-expired">abgelaufen</span>':'');
}
async function openBatch(id){
var r=await api('licenses?admin=1&view=batch_detail&batch_id='+id);
if(r.error){ alert(r.error); return; }
var b=r.batch;
var cust=esc(b.customer_name||'')+(b.customer_email?' · '+esc(b.customer_email):'')+(b.customer_no?' · Nr. '+esc(b.customer_no):'');
var h='<div class="mini" style="margin-bottom:.6rem">'
+ 'Typ <b>'+esc(b.sales_type)+'</b> · Bestellnr. <b>'+esc(b.order_no||'—')+'</b> · Kunde '+(cust||'—')
+ ' · gültig '+fmtDate(b.default_valid_from)+''+fmtDate(b.default_valid_until)
+ (b.canceled_at?' · <span class="badge b-canceled">Charge storniert</span>':'')+'</div>';
h+='<div class="row-act" style="margin-bottom:.6rem">'
+ '<input class="lnk" readonly value="'+esc(absUrl('/einloesen?charge='+b.redeem_token))+'">'
+ '<button class="btn-sm" onclick="copyEl2(this)">📋 Sammellink</button>'
+ '<button class="btn-sm" onclick="extendBatch('+b.id+',\''+esc(b.default_valid_until||'')+'\')">📅 Charge verlängern</button>'
+ '<button class="btn-sm" onclick="cancelBatch('+b.id+')">✖ Charge stornieren</button></div>';
h+='<div style="max-height:320px;overflow:auto"><table><tr><th>Code</th><th>Status</th><th>Gültig bis</th><th>Lehrperson</th><th></th></tr>';
r.codes.forEach(function(l){
h+='<tr><td class="code">'+esc(l.code)+'</td><td>'+codeStatus(l)+'</td><td>'+fmtDate(l.valid_until)+'</td>'
+'<td class="mini">'+esc(l.teacher_name||'—')+'</td>'
+'<td class="row-act">'
+ (l.canceled_at?'':'<button class="btn-sm" onclick="extendLicense('+l.id+',\''+esc(l.valid_until||'')+'\')">📅</button>'
+ '<button class="btn-sm" onclick="cancelLicense('+l.id+')">✖</button>')
+'</td></tr>';
});
h+='</table></div>';
if(r.mails && r.mails.length){
h+='<div class="dm-sub">Mail-Verlauf</div><table>';
r.mails.forEach(function(mm){ h+='<tr><td class="mini">'+fmtDate(mm.created_at)+'</td><td class="mini">'+esc(mm.to_email)+'</td><td><span class="badge '+(mm.status==='sent'?'b-redeemed':mm.status==='failed'?'b-canceled':'b-free')+'">'+esc(mm.status)+'</span></td><td class="mini">'+esc(mm.error||'')+'</td></tr>'; });
h+='</table>';
}
if(r.events && r.events.length){
h+='<div class="dm-sub">Verlauf</div><div class="mini">';
r.events.forEach(function(e){ h+=fmtDate(e.created_at)+' · <b>'+esc(e.event)+'</b> '+esc(e.detail||'')+'<br>'; });
h+='</div>';
}
document.getElementById('m-title').textContent='Bestellung #'+b.id;
document.getElementById('m-body').innerHTML=h;
document.getElementById('modal-ovl').classList.add('open');
}
function closeModal(){ document.getElementById('modal-ovl').classList.remove('open'); }
function copyEl2(btn){ var inp=btn.previousElementSibling; inp.select(); navigator.clipboard.writeText(inp.value).catch(function(){document.execCommand('copy');}); }
async function cancelBatch(id){
var reason=prompt('Ganze Charge stornieren? Grund (optional):'); if(reason===null) return;
var r=await api('licenses',{action:'cancel_batch', batch_id:id, reason:reason});
if(r.error){ alert(r.error); return; }
openBatch(id); loadBatches();
}
async function cancelLicense(id){
var reason=prompt('Diesen Code stornieren? Grund (optional):'); if(reason===null) return;
var r=await api('licenses',{action:'cancel_license', license_id:id, reason:reason});
if(r.error){ alert(r.error); return; }
reopenCurrent(); loadBatches();
}
async function extendBatch(id,cur){
var vu=prompt('Charge verlängern — neues „Gültig bis" (JJJJ-MM-TT):', cur||''); if(!vu) return;
var r=await api('licenses',{action:'extend_batch', batch_id:id, valid_until:vu});
if(r.error){ alert(r.error); return; }
openBatch(id); loadBatches();
}
async function extendLicense(id,cur){
var vu=prompt('Code verlängern — neues „Gültig bis" (JJJJ-MM-TT):', cur||''); if(!vu) return;
var r=await api('licenses',{action:'extend_license', license_id:id, valid_until:vu});
if(r.error){ alert(r.error); return; }
reopenCurrent(); loadBatches();
}
var _curBatch=null;
function reopenCurrent(){ var t=document.getElementById('m-title').textContent.match(/#(\d+)/); if(t) openBatch(parseInt(t[1])); }
// Admin auth check (2FA required)
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
.then(function(r){return r.json()})
.then(function(r) {
if (!r.authenticated) { location.href = 'admin.html'; return; }
loadLicenses();
prefillDefaults();
loadBatches();
});
</script>
</body>
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lizenz einlösen — GeoGraSim</title>
<link rel="stylesheet" href="assets/fonts/inter.css">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',system-ui,sans-serif;background:#f0eee6;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1.5rem;color:#2a2a2a}
.card{background:#fff;border-radius:18px;max-width:480px;width:100%;padding:2.2rem 2rem;box-shadow:0 6px 30px rgba(31,78,90,.1);border:1px solid #e6e0d0;text-align:center}
.wm{font-family:'Arial Black','Helvetica Neue',Arial,sans-serif;font-weight:900;font-size:26px;letter-spacing:-.02em;margin-bottom:.3rem}
.wm .g{color:#143b4b}.wm .r{color:#1d707b}.wm .a{color:#f17f13}
.sub{font-size:.62rem;font-weight:700;letter-spacing:.28em;color:#83a3a0;text-transform:uppercase;margin-bottom:1.6rem}
h1{font-size:1.2rem;color:#1f4e5a;margin-bottom:.6rem}
p{font-size:.9rem;color:#4a5a5f;line-height:1.6;margin-bottom:1rem}
.big{font-size:2.6rem;font-weight:900;color:#5a8a5e;line-height:1;margin:.4rem 0}
.btn{display:inline-block;padding:.7rem 1.6rem;background:#4a7c8a;color:#fff;text-decoration:none;border-radius:9px;font-weight:700;font-size:.9rem;border:none;cursor:pointer;margin-top:.4rem}
.btn:hover{background:#3a6470}
.spin{width:34px;height:34px;border:3px solid #dfe8ea;border-top-color:#4a7c8a;border-radius:50%;animation:sp 1s linear infinite;margin:1rem auto}
@keyframes sp{to{transform:rotate(360deg)}}
.err{color:#b0453a}
.foot{font-size:.72rem;color:#9aa5a9;margin-top:1.4rem}
.code{font-family:'Courier New',monospace;font-weight:700;color:#1f4e5a}
</style>
</head>
<body>
<div class="card">
<div class="wm"><span class="g">Geo</span><span class="r">Gra</span><span class="a">Sim</span></div>
<div class="sub">Lizenz einlösen</div>
<div id="content"><div class="spin"></div></div>
<div class="foot">Bei Fragen: <a href="mailto:support@geograsim.at" style="color:#4a7c8a">support@geograsim.at</a></div>
</div>
<script>
(function(){
var G = window.__GGS__ || {};
var BASE = G.basePath || '';
var content = document.getElementById('content');
var params = new URLSearchParams(location.search);
var charge = params.get('charge');
var code = params.get('code');
function html(h){ content.innerHTML = h; }
function loginPrompt(){
html('<h1>Bitte anmelden</h1>'
+ '<p>Melde dich als <b>Lehrperson</b> an, um '+(charge?'diese Lizenzen':'diesen Lizenzcode')+' einzulösen. Öffne danach diesen Link erneut.</p>'
+ '<a class="btn" href="'+BASE+'/login">Als Lehrperson anmelden</a>');
}
if (!charge && !code) { html('<h1>Ungültiger Link</h1><p>Es fehlt ein Lizenzcode oder eine Charge.</p>'); return; }
if (!G.teacherId) { loginPrompt(); return; }
var body = charge ? {action:'redeem_charge', token:charge} : {action:'redeem', code:code};
fetch(BASE + '/api/licenses', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify(body)})
.then(function(r){ return r.json(); })
.then(function(r){
if (r.error) {
html('<h1 class="err">Nicht eingelöst</h1><p class="err">'+String(r.error)+'</p>'
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
return;
}
if (charge) {
var n = r.redeemed || 0;
html('<h1>Geschafft!</h1><div class="big">'+n+'</div>'
+ '<p>'+(n===1?'Lizenz wurde':'Lizenzen wurden')+' deinem Konto gutgeschrieben. Du kannst sie jetzt im Dashboard deinen Schüler:innen zuweisen.</p>'
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
} else {
html('<h1>Code eingelöst ✓</h1>'
+ '<p>Der Lizenzcode <span class="code">'+String(code)+'</span> wurde deinem Konto gutgeschrieben.</p>'
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
}
})
.catch(function(){
html('<h1 class="err">Fehler</h1><p>Verbindung fehlgeschlagen. Bitte später erneut versuchen.</p>');
});
})();
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<?php renderPage('einloesen.html');
+303 -8
View File
@@ -9,6 +9,84 @@
$method = $_SERVER['REQUEST_METHOD'];
$db = Database::get();
// ─────────────────────────────────────────────────────────────────────
// Vertriebs-Helfer (Chargen-Vergabe, Storno, Verlaengerung, Mail, Schutz)
// ─────────────────────────────────────────────────────────────────────
const LIC_TYPES = ['marktplatz', 'direktvertrieb', 'privat'];
/** Neuer Lizenzcode: 5×4 aus 32er-Alphabet (ohne I,O,0,1) = ~100 Bit Entropie. */
function lic_new_code(): string {
$a = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $n = strlen($a);
$g = [];
for ($i = 0; $i < 5; $i++) {
$s = '';
for ($k = 0; $k < 4; $k++) $s .= $a[random_int(0, $n - 1)];
$g[] = $s;
}
return implode('-', $g); // XXXX-XXXX-XXXX-XXXX-XXXX (24 Zeichen)
}
/** Schuljahr-String "26/27" aus einem Datum (Schuljahr beginnt im August). */
function lic_school_year_from(?string $date): string {
$ts = $date ? strtotime($date) : time();
$y = (int)date('Y', $ts); $m = (int)date('n', $ts);
$start = ($m >= 8) ? $y : $y - 1;
return ($start % 100) . '/' . (($start % 100) + 1);
}
/** Sticky Default fuer Gueltig-bis: letzte verwendete, sonst 1. Okt End-Jahr Schuljahr. */
function lic_default_valid_until(Database $db): string {
$last = $db->fetchOne("SELECT default_valid_until FROM license_batches WHERE default_valid_until IS NOT NULL ORDER BY id DESC LIMIT 1");
if ($last && !empty($last['default_valid_until'])) return $last['default_valid_until'];
$y = (int)date('Y'); $m = (int)date('n');
$start = ($m >= 8) ? $y : $y - 1;
return ($start + 1) . '-10-01';
}
/** Kunde finden (nach Kundennummer, dann Email) oder anlegen. Ergaenzt fehlende Felder. */
function lic_find_or_create_customer(Database $db, ?string $email, ?string $customerNo, ?string $name): ?int {
$email = $email ? trim($email) : null;
$customerNo = $customerNo ? trim($customerNo) : null;
$name = $name ? trim($name) : null;
if (!$email && !$customerNo && !$name) return null;
$found = null;
if ($customerNo) $found = $db->fetchOne("SELECT id FROM license_customers WHERE customer_no = ? LIMIT 1", [$customerNo]);
if (!$found && $email) $found = $db->fetchOne("SELECT id FROM license_customers WHERE email = ? LIMIT 1", [$email]);
if ($found) {
$db->execute("UPDATE license_customers SET email = COALESCE(email, ?), customer_no = COALESCE(customer_no, ?), name = COALESCE(name, ?) WHERE id = ?",
[$email, $customerNo, $name, $found['id']]);
return (int)$found['id'];
}
$db->execute("INSERT INTO license_customers (customer_no, email, name) VALUES (?,?,?)", [$customerNo, $email, $name]);
return (int)$db->lastInsertId();
}
/** Audit-Eintrag (erzeugt/verlaengert/storniert/gemailt/eingeloest). */
function lic_event(Database $db, ?int $batchId, ?int $licenseId, string $event, ?string $detail, ?int $adminId): void {
$db->execute("INSERT INTO license_events (batch_id, license_id, event, detail, admin_id) VALUES (?,?,?,?,?)",
[$batchId, $licenseId, $event, $detail, $adminId]);
}
/** Brute-Force-Bremse beim Einloesen: 10 Fehlversuche/10min pro IP, 20 pro Lehrer. */
function lic_throttle_check(Database $db, ?int $teacherId): void {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if ($ip !== '') {
$r = $db->fetchOne("SELECT COUNT(*) c FROM license_redeem_attempts WHERE ok=0 AND ip=INET6_ATON(?) AND created_at > (NOW() - INTERVAL 10 MINUTE)", [$ip]);
if ($r && (int)$r['c'] >= 10) Response::error('Zu viele Fehlversuche. Bitte in ein paar Minuten erneut.', 429);
}
if ($teacherId) {
$r = $db->fetchOne("SELECT COUNT(*) c FROM license_redeem_attempts WHERE ok=0 AND teacher_id=? AND created_at > (NOW() - INTERVAL 10 MINUTE)", [$teacherId]);
if ($r && (int)$r['c'] >= 20) Response::error('Zu viele Fehlversuche. Bitte spaeter erneut.', 429);
}
}
/** Einloese-Versuch protokollieren (fuer Schutz + Audit). */
function lic_attempt(Database $db, ?int $teacherId, string $code, bool $ok): void {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$db->execute("INSERT INTO license_redeem_attempts (ip, teacher_id, code_tried, ok) VALUES (INET6_ATON(?), ?, ?, ?)",
[$ip !== '' ? $ip : null, $teacherId, substr($code, 0, 32), $ok ? 1 : 0]);
}
// === GET ===
if ($method === 'GET') {
// Super-Admin: alle Lizenzen
@@ -18,6 +96,70 @@ if ($method === 'GET') {
Response::error('Nicht autorisiert', 401);
}
// Vorbelegung fuer das Ausgabe-Formular (Sticky Gueltig-bis + Typen)
if (isset($_GET['sales_default'])) {
Response::ok([
'valid_from' => date('Y-m-d'),
'valid_until' => lic_default_valid_until($db),
'types' => LIC_TYPES,
]);
}
// Kunden-Autocomplete
if (isset($_GET['customers'])) {
$like = '%' . trim($_GET['customers']) . '%';
$rows = $db->fetchAll(
"SELECT id, customer_no, email, name FROM license_customers
WHERE email LIKE ? OR name LIKE ? OR customer_no LIKE ?
ORDER BY name, email LIMIT 10", [$like, $like, $like]);
Response::ok(['customers' => $rows]);
}
// Chargen-Uebersicht (mit Aggregaten + Filter)
if (($_GET['view'] ?? '') === 'batches') {
$where = '1=1'; $p = [];
if (!empty($_GET['type'])) { $where .= ' AND b.sales_type = ?'; $p[] = $_GET['type']; }
if (!empty($_GET['order_no'])) { $where .= ' AND b.order_no LIKE ?'; $p[] = '%' . $_GET['order_no'] . '%'; }
if (!empty($_GET['customer'])) { $q = '%' . $_GET['customer'] . '%'; $where .= ' AND (c.email LIKE ? OR c.name LIKE ? OR c.customer_no LIKE ?)'; $p[] = $q; $p[] = $q; $p[] = $q; }
$batches = $db->fetchAll(
"SELECT b.id, b.order_no, b.sales_type, b.qty, b.default_valid_from, b.default_valid_until,
b.redeem_token, b.created_at, b.canceled_at, b.note,
c.email AS customer_email, c.name AS customer_name, c.customer_no,
COUNT(l.id) AS n_total,
SUM(l.canceled_at IS NOT NULL) AS n_canceled,
SUM(l.redeemed_at IS NOT NULL AND l.canceled_at IS NULL) AS n_redeemed,
SUM(l.canceled_at IS NULL AND l.valid_until IS NOT NULL AND l.valid_until < CURDATE()) AS n_expired
FROM license_batches b
LEFT JOIN license_customers c ON c.id = b.customer_id
LEFT JOIN licenses l ON l.batch_id = b.id
WHERE $where
GROUP BY b.id
ORDER BY b.id DESC
LIMIT 300", $p);
Response::ok(['batches' => $batches]);
}
// Detail einer Charge: Codes + Verlauf + Mails
if (($_GET['view'] ?? '') === 'batch_detail') {
$bid = (int)($_GET['batch_id'] ?? 0);
$batch = $db->fetchOne(
"SELECT b.*, c.email AS customer_email, c.name AS customer_name, c.customer_no
FROM license_batches b LEFT JOIN license_customers c ON c.id = b.customer_id
WHERE b.id = ?", [$bid]);
if (!$batch) Response::error('Charge nicht gefunden', 404);
$codes = $db->fetchAll(
"SELECT l.id, l.code, l.valid_from, l.valid_until, l.issued_at, l.redeemed_at,
l.student_id, l.teacher_id, l.canceled_at, l.canceled_reason,
t.display_name AS teacher_name, s.username AS student_name
FROM licenses l
LEFT JOIN teachers t ON t.id = l.teacher_id
LEFT JOIN students s ON s.id = l.student_id
WHERE l.batch_id = ? ORDER BY l.id", [$bid]);
$events = $db->fetchAll("SELECT event, detail, admin_id, created_at FROM license_events WHERE batch_id = ? ORDER BY id DESC LIMIT 100", [$bid]);
$mails = $db->fetchAll("SELECT id, to_email, subject, status, error, created_at, sent_at FROM license_mails WHERE batch_id = ? ORDER BY id DESC", [$bid]);
Response::ok(['batch' => $batch, 'codes' => $codes, 'events' => $events, 'mails' => $mails]);
}
$year = $_GET['year'] ?? null;
$status = $_GET['status'] ?? null; // 'free', 'used'
@@ -91,13 +233,17 @@ if ($method === 'POST') {
$teacherId = Session::requireTeacher();
$code = strtoupper(trim($body['code'] ?? ''));
if (!$code) Response::error('Lizenzcode erforderlich');
lic_throttle_check($db, $teacherId);
$license = $db->fetchOne('SELECT id, student_id, teacher_id FROM licenses WHERE code = ?', [$code]);
if (!$license) Response::error('Lizenzcode ungültig');
if ($license['student_id']) Response::error('Diese Lizenz ist bereits bereits zugewiesen');
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) Response::error('Diese Lizenz gehört einer anderen Lehrperson');
$license = $db->fetchOne('SELECT id, batch_id, student_id, teacher_id, canceled_at FROM licenses WHERE code = ?', [$code]);
if (!$license) { lic_attempt($db, $teacherId, $code, false); Response::error('Lizenzcode ungültig'); }
if ($license['canceled_at']) { lic_attempt($db, $teacherId, $code, false); Response::error('Dieser Lizenzcode wurde storniert'); }
if ($license['student_id']) { lic_attempt($db, $teacherId, $code, false); Response::error('Diese Lizenz ist bereits zugewiesen'); }
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) { lic_attempt($db, $teacherId, $code, false); Response::error('Diese Lizenz gehört einer anderen Lehrperson'); }
$db->execute('UPDATE licenses SET teacher_id = ?, redeemed_at = NOW() WHERE id = ?', [$teacherId, $license['id']]);
lic_attempt($db, $teacherId, $code, true);
lic_event($db, $license['batch_id'] ? (int)$license['batch_id'] : null, (int)$license['id'], 'redeemed', 'durch Lehrer #' . $teacherId, null);
Response::ok(['licenseId' => (int)$license['id']]);
}
@@ -106,17 +252,20 @@ if ($method === 'POST') {
$teacherId = Session::requireTeacher();
$codes = $body['codes'] ?? [];
if (!is_array($codes) || empty($codes)) Response::error('Lizenzcodes erforderlich');
lic_throttle_check($db, $teacherId);
$redeemed = 0;
$errors = [];
foreach ($codes as $code) {
$code = strtoupper(trim($code));
if (!$code) continue;
$license = $db->fetchOne('SELECT id, student_id, teacher_id FROM licenses WHERE code = ?', [$code]);
if (!$license) { $errors[] = "$code: ungültig"; continue; }
if ($license['student_id']) { $errors[] = "$code: bereits zugewiesen"; continue; }
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) { $errors[] = "$code: gehört anderer Lehrperson"; continue; }
$license = $db->fetchOne('SELECT id, student_id, teacher_id, canceled_at FROM licenses WHERE code = ?', [$code]);
if (!$license) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: ungültig"; continue; }
if ($license['canceled_at']) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: storniert"; continue; }
if ($license['student_id']) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: bereits zugewiesen"; continue; }
if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: gehört anderer Lehrperson"; continue; }
$db->execute('UPDATE licenses SET teacher_id = ?, redeemed_at = NOW() WHERE id = ?', [$teacherId, $license['id']]);
lic_attempt($db, $teacherId, $code, true);
$redeemed++;
}
Response::ok(['redeemed' => $redeemed, 'errors' => $errors]);
@@ -194,6 +343,152 @@ if ($method === 'POST') {
Response::ok(['generated' => $generated]);
}
// ═══════════════ Vertrieb: Charge ausgeben ═══════════════
if ($action === 'issue_batch') {
$adminId = Session::adminId();
if (!$adminId) Response::error('Nur Admin', 403);
$count = (int)($body['count'] ?? 0);
if ($count < 1 || $count > 5000) Response::error('Anzahl muss zwischen 1 und 5000 liegen');
$type = in_array($body['sales_type'] ?? '', LIC_TYPES, true) ? $body['sales_type'] : 'privat';
$orderNo = trim((string)($body['order_no'] ?? '')) ?: null;
$note = trim((string)($body['note'] ?? '')) ?: null;
$validFrom = !empty($body['valid_from']) ? $body['valid_from'] : date('Y-m-d');
$validUntil = !empty($body['valid_until']) ? $body['valid_until'] : lic_default_valid_until($db);
if (!strtotime($validFrom) || !strtotime($validUntil)) Response::error('Ungültiges Datum');
if (strtotime($validUntil) < strtotime($validFrom)) Response::error('„Gültig bis" liegt vor „Gültig von"');
$cust = is_array($body['customer'] ?? null) ? $body['customer'] : [];
$customerId = lic_find_or_create_customer($db, $cust['email'] ?? null, $cust['customer_no'] ?? null, $cust['name'] ?? null);
$schoolYear = lic_school_year_from($validFrom);
$token = bin2hex(random_bytes(20));
$db->begin();
try {
$db->execute(
"INSERT INTO license_batches (customer_id, order_no, sales_type, qty, default_valid_from, default_valid_until, note, redeem_token, created_by)
VALUES (?,?,?,?,?,?,?,?,?)",
[$customerId, $orderNo, $type, $count, $validFrom, $validUntil, $note, $token, $adminId]);
$batchId = (int)$db->lastInsertId();
$codes = []; $made = 0; $tries = 0; $maxTries = $count * 8 + 50;
while ($made < $count && $tries < $maxTries) {
$tries++;
$code = lic_new_code();
try {
$db->execute(
"INSERT INTO licenses (code, school_year, batch_id, issued_at, valid_from, valid_until) VALUES (?,?,?,NOW(),?,?)",
[$code, $schoolYear, $batchId, $validFrom, $validUntil]);
$codes[] = $code; $made++;
} catch (\Throwable $e) { /* Code-Kollision (extrem selten) → neuer Versuch */ }
}
if ($made < $count) throw new \RuntimeException("Nur $made von $count Codes erzeugt");
lic_event($db, $batchId, null, 'issued', "$count Codes, gültig $validFrom…$validUntil, Typ $type" . ($orderNo ? ", Best.-Nr. $orderNo" : ''), $adminId);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
Response::error('Fehler beim Erzeugen: ' . $e->getMessage(), 500);
}
Response::ok([
'batch_id' => $batchId, 'redeem_token' => $token,
'valid_from' => $validFrom, 'valid_until' => $validUntil,
'count' => $made, 'codes' => $codes,
]);
}
// ═══════════════ Storno ═══════════════
if ($action === 'cancel_batch') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$bid = (int)($body['batch_id'] ?? 0); if (!$bid) Response::error('batch_id erforderlich');
$reason = trim((string)($body['reason'] ?? '')) ?: null;
$n = $db->execute("UPDATE licenses SET canceled_at = NOW(), canceled_reason = ? WHERE batch_id = ? AND canceled_at IS NULL", [$reason, $bid]);
$db->execute("UPDATE license_batches SET canceled_at = NOW(), canceled_reason = ? WHERE id = ?", [$reason, $bid]);
lic_event($db, $bid, null, 'canceled', "Charge storniert ($n Codes)" . ($reason ? ": $reason" : ''), $adminId);
Response::ok(['canceled' => $n]);
}
if ($action === 'cancel_license') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$lid = (int)($body['license_id'] ?? 0); if (!$lid) Response::error('license_id erforderlich');
$reason = trim((string)($body['reason'] ?? '')) ?: null;
$lic = $db->fetchOne("SELECT batch_id FROM licenses WHERE id = ?", [$lid]);
if (!$lic) Response::error('Code nicht gefunden', 404);
$db->execute("UPDATE licenses SET canceled_at = NOW(), canceled_reason = ? WHERE id = ? AND canceled_at IS NULL", [$reason, $lid]);
lic_event($db, $lic['batch_id'] ? (int)$lic['batch_id'] : null, $lid, 'canceled', 'Einzel-Storno' . ($reason ? ": $reason" : ''), $adminId);
Response::ok();
}
// ═══════════════ Verlängern ═══════════════
if ($action === 'extend_license') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$lid = (int)($body['license_id'] ?? 0); $vu = $body['valid_until'] ?? '';
if (!$lid || !strtotime($vu)) Response::error('license_id + valid_until erforderlich');
$lic = $db->fetchOne("SELECT batch_id, valid_until FROM licenses WHERE id = ?", [$lid]);
if (!$lic) Response::error('Code nicht gefunden', 404);
$db->execute("UPDATE licenses SET valid_until = ? WHERE id = ?", [$vu, $lid]);
lic_event($db, $lic['batch_id'] ? (int)$lic['batch_id'] : null, $lid, 'extended', 'valid_until ' . ($lic['valid_until'] ?? '—') . "$vu", $adminId);
Response::ok();
}
if ($action === 'extend_batch') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$bid = (int)($body['batch_id'] ?? 0); $vu = $body['valid_until'] ?? '';
if (!$bid || !strtotime($vu)) Response::error('batch_id + valid_until erforderlich');
$n = $db->execute("UPDATE licenses SET valid_until = ? WHERE batch_id = ? AND canceled_at IS NULL", [$vu, $bid]);
$db->execute("UPDATE license_batches SET default_valid_until = ? WHERE id = ?", [$vu, $bid]);
lic_event($db, $bid, null, 'extended', "Charge verlängert auf $vu ($n Codes)", $adminId);
Response::ok(['updated' => $n]);
}
// ═══════════════ Mailversand (Server-Treiber, archiviert) ═══════════════
if ($action === 'send_batch_mail') {
$adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403);
$bid = (int)($body['batch_id'] ?? 0); if (!$bid) Response::error('batch_id erforderlich');
$to = trim((string)($body['to_email'] ?? ''));
$subject = trim((string)($body['subject'] ?? '')) ?: 'Ihre GeoGraSim-Lizenzcodes';
$textBody = (string)($body['body_text'] ?? '');
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) Response::error('Gültige Empfänger-Mailadresse erforderlich');
if (trim($textBody) === '') Response::error('Mailtext ist leer');
$codeRows = $db->fetchAll("SELECT code FROM licenses WHERE batch_id = ? AND canceled_at IS NULL ORDER BY id", [$bid]);
$codeList = array_map(fn($r) => $r['code'], $codeRows);
$html = Mailer::renderPlainBody($textBody);
$attach = [];
if (count($codeList) > 40) {
$attach[] = ['content' => implode("\r\n", $codeList) . "\r\n", 'name' => 'lizenzcodes.txt', 'type' => 'text/plain'];
}
$db->execute(
"INSERT INTO license_mails (batch_id, to_email, subject, body_text, body_html, provider, status, created_by) VALUES (?,?,?,?,?,?,?,?)",
[$bid, $to, $subject, $textBody, $html, 'server', 'queued', $adminId]);
$mailId = (int)$db->lastInsertId();
try {
Mailer::sendWithAttachments($to, $subject, $html, $textBody, $attach);
$db->execute("UPDATE license_mails SET status='sent', sent_at=NOW() WHERE id=?", [$mailId]);
lic_event($db, $bid, null, 'mailed', "an $to", $adminId);
Response::ok(['mail_id' => $mailId, 'status' => 'sent']);
} catch (\Throwable $e) {
$db->execute("UPDATE license_mails SET status='failed', error=? WHERE id=?", [substr($e->getMessage(), 0, 240), $mailId]);
Response::error('Versand fehlgeschlagen: ' . $e->getMessage(), 500);
}
}
// ═══════════════ Sammel-Einlösen einer Charge (ein Link → alle Codes) ═══════════════
if ($action === 'redeem_charge') {
$teacherId = Session::requireTeacher();
$token = trim((string)($body['token'] ?? ''));
if ($token === '') Response::error('Token erforderlich');
lic_throttle_check($db, $teacherId);
$batch = $db->fetchOne("SELECT id FROM license_batches WHERE redeem_token = ? AND canceled_at IS NULL", [$token]);
if (!$batch) { lic_attempt($db, $teacherId, 'charge:' . substr($token, 0, 24), false); Response::error('Einlöse-Link ungültig oder storniert'); }
$n = $db->execute(
"UPDATE licenses SET teacher_id = ?, redeemed_at = COALESCE(redeemed_at, NOW())
WHERE batch_id = ? AND canceled_at IS NULL AND (teacher_id IS NULL OR teacher_id = ?)",
[$teacherId, $batch['id'], $teacherId]);
lic_attempt($db, $teacherId, 'charge:' . substr($token, 0, 24), true);
lic_event($db, (int)$batch['id'], null, 'redeemed', "Charge-Sammellink durch Lehrer #$teacherId ($n Codes)", null);
Response::ok(['redeemed' => $n]);
}
Response::error('Unbekannte Aktion');
}
+6
View File
@@ -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(); }
}
+25
View File
@@ -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(['<br>','<br/>','</p>','</li>'], "\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 '<div style="font-size:14px;color:#333;line-height:1.6">' . $safe . '</div>';
}
// ─── Willkommen (Lehrperson registriert sich) ───
public static function sendWelcomeTeacher(string $email, string $name): bool {