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:
@@ -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;
|
||||
@@ -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 & 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 & 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 {'&':'&','<':'<','>':'>','"':'"'}[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 1–5000.'; 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 & 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
<?php renderPage('einloesen.html');
|
||||
+303
-8
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user