Email Bestätigung

This commit is contained in:
cay 2026-03-28 08:20:24 +00:00
parent 193f46ba62
commit b2784e7b63
8 changed files with 248 additions and 37 deletions

View File

@ -319,4 +319,18 @@ router.post('/members/:id/update-nfc', requireAdmin, async (req, res) => {
} }
}); });
// Mitglied manuell bestätigen
router.post('/members/:id/confirm', requireAdmin, async (req, res) => {
try {
await db.query(
"UPDATE memberships SET status='active', confirmed_at=NOW() WHERE id=?",
[req.params.id]
);
res.redirect(`/admin/members/${req.params.id}?success=Mitglied+manuell+bestätigt`);
} catch (err) {
res.redirect(`/admin/members/${req.params.id}?error=Fehler+bei+Bestätigung`);
}
});
module.exports = router; module.exports = router;

View File

@ -1,8 +1,9 @@
const express = require('express'); const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const router = express.Router(); const router = express.Router();
const dns = require('dns').promises; const dns = require('dns').promises;
const db = require('../config/database'); const db = require('../config/database');
const mailer = require('../config/mailer');
// ============================================ // ============================================
// E-Mail Validierung via DNS MX-Record // E-Mail Validierung via DNS MX-Record
@ -53,7 +54,49 @@ function validateIBANServer(iban) {
return { valid: true }; return { valid: true };
} }
// ============================================
// Bestätigungs-E-Mail Template
// ============================================
function confirmationEmailHtml(member, confirmLink) {
return `<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"></head>
<body style="font-family:Outfit,Arial,sans-serif;background:#f8f9ff;margin:0;padding:20px">
<div style="max-width:600px;margin:0 auto;background:white;border-radius:16px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.08)">
<div style="background:#2d2dcc;padding:32px;text-align:center">
<h1 style="color:white;margin:0;font-size:1.8rem">Plusfit<span style="color:#a5b4fc">24</span></h1>
<p style="color:#c7d2fe;margin:8px 0 0">Mitgliedschaft bestätigen</p>
</div>
<div style="padding:32px">
<p>Hallo ${member.first_name} ${member.last_name},</p>
<p>vielen Dank für deine Anmeldung bei PlusFit24! Um deine Mitgliedschaft zu aktivieren, bestätige bitte deine E-Mail-Adresse:</p>
<div style="text-align:center;margin:32px 0">
<a href="${confirmLink}"
style="display:inline-block;background:#2d2dcc;color:white;padding:16px 40px;border-radius:12px;text-decoration:none;font-weight:700;font-size:1.05rem">
Mitgliedschaft jetzt bestätigen
</a>
</div>
<div style="background:#f8f9ff;border-radius:10px;padding:16px;margin-bottom:20px">
<p style="margin:0 0 8px;font-weight:700">Deine Vertragsdaten:</p>
<p style="margin:4px 0;color:#374151">📋 Tarif: ${member.tariff_name}</p>
<p style="margin:4px 0;color:#374151">💰 Monatsbeitrag: ${Number(member.agreed_price).toFixed(2).replace('.', ',')} </p>
<p style="margin:4px 0;color:#374151">📦 Startpaket: ${Number(member.start_package_price).toFixed(2).replace('.', ',')} </p>
</div>
<p style="color:#6b7280;font-size:0.85rem">
Dieser Link ist <strong>24 Stunden</strong> gültig. Falls du diese Anmeldung nicht durchgeführt hast, ignoriere diese E-Mail.<br><br>
<strong>PlusFit24 UG</strong> · Moosleiten 12 · 84089 Aiglsbach
</p>
</div>
</div>
</body></html>`;
}
// ============================================
// POST /api/verify-email // POST /api/verify-email
// ============================================
router.post('/verify-email', async (req, res) => { router.post('/verify-email', async (req, res) => {
const { email } = req.body; const { email } = req.body;
if (!email) return res.json({ valid: false, reason: 'Keine E-Mail angegeben' }); if (!email) return res.json({ valid: false, reason: 'Keine E-Mail angegeben' });
@ -61,7 +104,9 @@ router.post('/verify-email', async (req, res) => {
res.json(result); res.json(result);
}); });
// ============================================
// POST /api/submit-membership // POST /api/submit-membership
// ============================================
router.post('/submit-membership', async (req, res) => { router.post('/submit-membership', async (req, res) => {
try { try {
const { const {
@ -72,7 +117,7 @@ router.post('/submit-membership', async (req, res) => {
guardian_consent guardian_consent
} = req.body; } = req.body;
// Pflichtfelder prüfen // Pflichtfelder
if (!tariff_id || !first_name || !last_name || !birth_date || !email || !street || !zip || !city) { if (!tariff_id || !first_name || !last_name || !birth_date || !email || !street || !zip || !city) {
return res.json({ success: false, error: 'Bitte alle Pflichtfelder ausfüllen.' }); return res.json({ success: false, error: 'Bitte alle Pflichtfelder ausfüllen.' });
} }
@ -100,7 +145,6 @@ router.post('/submit-membership', async (req, res) => {
let age = today.getFullYear() - birthDateObj.getFullYear(); let age = today.getFullYear() - birthDateObj.getFullYear();
const mo = today.getMonth() - birthDateObj.getMonth(); const mo = today.getMonth() - birthDateObj.getMonth();
if (mo < 0 || (mo === 0 && today.getDate() < birthDateObj.getDate())) age--; if (mo < 0 || (mo === 0 && today.getDate() < birthDateObj.getDate())) age--;
const is_minor = age < 18 ? 1 : 0; const is_minor = age < 18 ? 1 : 0;
if (is_minor && !guardian_consent) { if (is_minor && !guardian_consent) {
@ -110,58 +154,52 @@ router.post('/submit-membership', async (req, res) => {
return res.json({ success: false, error: 'Das Mindestalter für eine Mitgliedschaft beträgt 14 Jahre.' }); return res.json({ success: false, error: 'Das Mindestalter für eine Mitgliedschaft beträgt 14 Jahre.' });
} }
// Tarif laden — MUSS vor Berechnungen stehen // Tarif laden
const [tariffs] = await db.query('SELECT * FROM tariffs WHERE id = ? AND active = 1', [tariff_id]); const [tariffs] = await db.query('SELECT * FROM tariffs WHERE id = ? AND active = 1', [tariff_id]);
if (tariffs.length === 0) { if (tariffs.length === 0) {
return res.json({ success: false, error: 'Ungültiger oder inaktiver Tarif.' }); return res.json({ success: false, error: 'Ungültiger oder inaktiver Tarif.' });
} }
const tariff = tariffs[0]; const tariff = tariffs[0];
// Startpaket-Preis // Berechnungen
const startPackagePrice = parseFloat(tariff.start_package_price) || 35.00; const startPackagePrice = parseFloat(tariff.start_package_price) || 35.00;
const contractStart = new Date(today);
// Vertragsbeginn = heute + 15 Tage
const contractStart = new Date();
contractStart.setDate(contractStart.getDate() + 15); contractStart.setDate(contractStart.getDate() + 15);
const daysInMonth = new Date(contractStart.getFullYear(), contractStart.getMonth() + 1, 0).getDate();
// Anteiligen ersten Monatsbeitrag berechnen const remainingDays = daysInMonth - contractStart.getDate() + 1;
const daysInMonth = new Date(contractStart.getFullYear(), contractStart.getMonth() + 1, 0).getDate(); const partialMonth = Math.round((parseFloat(tariff.price_monthly) / daysInMonth) * remainingDays * 100) / 100;
const remainingDays = daysInMonth - contractStart.getDate() + 1;
const dailyRate = parseFloat(tariff.price_monthly) / daysInMonth;
const partialMonth = Math.round(dailyRate * remainingDays * 100) / 100;
// Erste Zahlung = anteiliger Monat + Startpaket
const firstPaymentAmt = Math.round((partialMonth + startPackagePrice) * 100) / 100; const firstPaymentAmt = Math.round((partialMonth + startPackagePrice) * 100) / 100;
const contractEnd = new Date(contractStart);
// Vertragsende
const contractEnd = new Date(contractStart);
contractEnd.setMonth(contractEnd.getMonth() + tariff.duration_months); contractEnd.setMonth(contractEnd.getMonth() + tariff.duration_months);
contractEnd.setDate(contractEnd.getDate() - 1); contractEnd.setDate(contractEnd.getDate() - 1);
// Zugangstoken generieren // Tokens generieren
const access_token = crypto.randomBytes(16).toString('hex'); const access_token = crypto.randomBytes(16).toString('hex');
const confirmation_token = crypto.randomBytes(32).toString('hex');
// In DB speichern // In DB speichern — Status: pending
// Spalten: 26, Werte: 26
await db.query(` await db.query(`
INSERT INTO memberships INSERT INTO memberships
(tariff_id, salutation, title, first_name, last_name, birth_date, (tariff_id, salutation, title, first_name, last_name, birth_date,
email, phone, street, address_addition, zip, city, email, phone, street, address_addition, zip, city,
bank_name, account_holder, iban, bank_name, account_holder, iban,
sepa_accepted, agb_accepted, datenschutz_accepted, data_correct, sepa_accepted, agb_accepted, datenschutz_accepted, data_correct,
guardian_consent, is_minor, access_token, guardian_consent, is_minor,
access_token, confirmation_token,
agreed_price, agreed_duration, agreed_price, agreed_duration,
start_package_price, start_package_price,
signup_date, contract_start, contract_end, effective_end, signup_date, contract_start, contract_end, effective_end,
first_payment_date, first_payment_amt) first_payment_date, first_payment_amt,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) status)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`, [ `, [
tariff_id, salutation, title || '', first_name, last_name, birth_date, tariff_id, salutation, title || '', first_name, last_name, birth_date,
email, phone || '', street, address_addition || '', zip, city, email, phone || '', street, address_addition || '', zip, city,
bank_name || '', account_holder || '', iban || '', bank_name || '', account_holder || '', iban || '',
sepa_accepted ? 1 : 0, agb_accepted ? 1 : 0, sepa_accepted ? 1 : 0, agb_accepted ? 1 : 0,
datenschutz_accepted ? 1 : 0, data_correct ? 1 : 0, datenschutz_accepted ? 1 : 0, data_correct ? 1 : 0,
guardian_consent ? 1 : 0, is_minor, access_token, guardian_consent ? 1 : 0, is_minor,
access_token, confirmation_token,
tariff.price_monthly, tariff.duration_months, tariff.price_monthly, tariff.duration_months,
startPackagePrice, startPackagePrice,
today.toISOString().split('T')[0], today.toISOString().split('T')[0],
@ -169,10 +207,28 @@ router.post('/submit-membership', async (req, res) => {
contractEnd.toISOString().split('T')[0], contractEnd.toISOString().split('T')[0],
contractEnd.toISOString().split('T')[0], contractEnd.toISOString().split('T')[0],
contractStart.toISOString().split('T')[0], contractStart.toISOString().split('T')[0],
firstPaymentAmt firstPaymentAmt,
'pending'
]); ]);
res.json({ success: true }); // Bestätigungs-E-Mail senden
const baseUrl = process.env.APP_URL || 'https://plusfit24.software-joksch.com';
const confirmLink = `${baseUrl}/confirm/${confirmation_token}`;
const memberData = { first_name, last_name, agreed_price: tariff.price_monthly, start_package_price: startPackagePrice, tariff_name: tariff.name };
try {
await mailer.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: 'PlusFit24 Bitte bestätige deine Mitgliedschaft',
html: confirmationEmailHtml(memberData, confirmLink)
});
} catch (mailErr) {
console.error('E-Mail Fehler:', mailErr.message);
// Trotzdem Erfolg zurückgeben — Admin kann manuell bestätigen
}
res.json({ success: true, pending: true });
} catch (err) { } catch (err) {
console.error('Submit error:', err); console.error('Submit error:', err);
@ -180,4 +236,49 @@ router.post('/submit-membership', async (req, res) => {
} }
}); });
// ============================================
// GET /confirm/:token — E-Mail Bestätigung
// ============================================
router.get('/confirm/:token', async (req, res) => {
try {
const [rows] = await db.query(
"SELECT * FROM memberships WHERE confirmation_token = ? AND status = 'pending'",
[req.params.token]
);
if (rows.length === 0) {
// Prüfen ob bereits bestätigt
const [confirmed] = await db.query(
"SELECT * FROM memberships WHERE confirmation_token = ? AND status = 'active'",
[req.params.token]
);
if (confirmed.length > 0) {
return res.render('confirmation-success'); // Bereits bestätigt
}
return res.render('confirmation-invalid');
}
const member = rows[0];
// Prüfen ob Link abgelaufen (24h)
const createdAt = new Date(member.created_at);
const now = new Date();
const hoursDiff = (now - createdAt) / (1000 * 60 * 60);
if (hoursDiff > 24) {
return res.render('confirmation-invalid');
}
// Mitgliedschaft aktivieren
await db.query(
"UPDATE memberships SET status='active', confirmed_at=NOW() WHERE id=?",
[member.id]
);
res.render('confirmation-success');
} catch (err) {
console.error('Confirm error:', err);
res.render('error', { message: 'Fehler bei der Bestätigung.' });
}
});
module.exports = router; module.exports = router;

View File

@ -32,6 +32,11 @@ router.get('/anmelden/:tariffId', async (req, res) => {
} }
}); });
// Bestätigung ausstehend
router.get('/bestaetigung-ausstehend', (req, res) => {
res.render('confirmation-pending', { email: req.query.email || '' });
});
// Erfolgsseite // Erfolgsseite
router.get('/erfolg', (req, res) => { router.get('/erfolg', (req, res) => {
res.render('success'); res.render('success');

View File

@ -34,9 +34,14 @@
<div class="detail-header-title"> <div class="detail-header-title">
<h1><%= member.first_name %> <%= member.last_name %></h1> <h1><%= member.first_name %> <%= member.last_name %></h1>
<span class="member-id-badge">Mitglied #<%= member.id %></span> <span class="member-id-badge">Mitglied #<%= member.id %></span>
<span class="status-badge <%= member.status === 'active' ? 'active' : (member.status === 'paused' ? 'warning' : 'inactive') %>"> <span class="status-badge <%= member.status === 'active' ? 'active' : member.status === 'paused' ? 'warning' : member.status === 'pending' ? 'warning' : 'inactive' %>">
<%= member.status === 'active' ? '✅ Aktiv' : member.status === 'paused' ? '⏸ Pausiert' : '❌ Inaktiv' %> <%= member.status === 'active' ? '✅ Aktiv' : member.status === 'paused' ? '⏸ Pausiert' : member.status === 'pending' ? '⏳ Ausstehend' : '❌ Inaktiv' %>
</span> </span>
<% if (member.status === 'pending') { %>
<form method="POST" action="/admin/members/<%= member.id %>/confirm" style="display:inline">
<button type="submit" class="btn btn-sm btn-success">✅ Manuell bestätigen</button>
</form>
<% } %>
<% if (member.is_minor) { %> <% if (member.is_minor) { %>
<span class="status-badge warning">⚠️ Minderjährig</span> <span class="status-badge warning">⚠️ Minderjährig</span>
<% } %> <% } %>

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Link ungültig</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="site-header">
<div class="header-inner"><div class="logo">Plusfit<span>24</span></div></div>
</header>
<main class="success-main">
<div class="success-card">
<div class="success-icon">⚠️</div>
<h1>Link ungültig oder abgelaufen</h1>
<p>Dieser Bestätigungslink ist nicht mehr gültig.</p>
<p class="success-sub">Bitte kontaktiere uns direkt im Studio oder ruf uns an.</p>
<a href="/" class="btn btn-outline" style="margin-top:16px">Zur Startseite</a>
</div>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG</p>
</footer>
</body>
</html>

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 E-Mail bestätigen</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="site-header">
<div class="header-inner"><div class="logo">Plusfit<span>24</span></div></div>
</header>
<main class="success-main">
<div class="success-card">
<div class="success-icon">📧</div>
<h1>Fast geschafft!</h1>
<p>Wir haben eine Bestätigungs-E-Mail an</p>
<p><strong><%= email %></strong></p>
<p>gesendet. Bitte klicke auf den Link in der E-Mail um deine Mitgliedschaft zu aktivieren.</p>
<p class="success-sub">Der Link ist 24 Stunden gültig.</p>
<div class="info-box" style="margin-top:16px;text-align:left">
<strong> Keine E-Mail erhalten?</strong><br>
Bitte prüfe deinen Spam-Ordner oder kontaktiere uns direkt im Studio.
</div>
</div>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG</p>
</footer>
</body>
</html>

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlusFit24 Bestätigt!</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<header class="site-header">
<div class="header-inner"><div class="logo">Plusfit<span>24</span></div></div>
</header>
<main class="success-main">
<div class="success-card">
<div class="success-icon">🎉</div>
<h1>Herzlich Willkommen!</h1>
<p>Deine Mitgliedschaft bei PlusFit24 wurde erfolgreich bestätigt und ist jetzt aktiv.</p>
<p class="success-sub">Wir freuen uns auf dich im Studio!</p>
<a href="/" class="btn btn-primary" style="margin-top:16px">Zurück zur Startseite</a>
</div>
</main>
<footer class="site-footer">
<p>© 2024 PlusFit24 UG</p>
</footer>
</body>
</html>

View File

@ -457,7 +457,7 @@
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
window.location.href = '/erfolg'; window.location.href = data.pending ? '/bestaetigung-ausstehend?email=' + encodeURIComponent(document.getElementById('email').value) : '/erfolg';
} else { } else {
errorDiv.textContent = data.error || 'Fehler beim Absenden.'; errorDiv.textContent = data.error || 'Fehler beim Absenden.';
errorDiv.classList.remove('hidden'); errorDiv.classList.remove('hidden');