feat(demo): Mail-Benachrichtigungen bei Demo-Ablauf
Neue Mail-Templates (lib/mail.js): - sendDemoExpiryReminderMail: 7 Tage vor Ablauf - sendDemoLastDayMail: 1 Tag vor Ablauf (Letzter-Tag-Reminder) - sendDemoExpiredMail: am Ablauftag (setzt Tenant-Status abgelaufen) Cron-Job (lib/expiryJob.js): - node-cron taeglich 08:00 UTC (anpassbar via CRON_EXPIRY_AT) - 3 Windows pruefen, idempotent ueber notified_*_at Timestamp-Spalten - BETWEEN-Fenster [n-1, n] Tage fuer Robustheit DB-Migration (bootstrap.js): - Beim Backend-Start automatisches ALTER TABLE fuer notified_7d_at, notified_1d_at, notified_expired_at (idempotent) Manueller Test: docker exec pfandsystem-demo-backend node -e " import(\"./lib/expiryJob.js\").then(m => m.runExpiryCheck())"
This commit is contained in:
93
backend/lib/expiryJob.js
Normal file
93
backend/lib/expiryJob.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import pool from '../config/database.js';
|
||||||
|
import {
|
||||||
|
sendDemoExpiryReminderMail,
|
||||||
|
sendDemoLastDayMail,
|
||||||
|
sendDemoExpiredMail,
|
||||||
|
} from './mail.js';
|
||||||
|
|
||||||
|
// Findet Demo-Tenants, deren Ablauf in [from, to] liegt UND fuer die noch
|
||||||
|
// keine entsprechende Benachrichtigung versandt wurde, schickt die Mail
|
||||||
|
// und markiert die Spalte mit NOW().
|
||||||
|
async function processWindow({ flagColumn, daysFrom, daysTo, sender }) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT id, name, kontakt_email, slug, demo_expires_at
|
||||||
|
FROM tenants
|
||||||
|
WHERE typ = 'demo'
|
||||||
|
AND kontakt_email IS NOT NULL
|
||||||
|
AND ${flagColumn} IS NULL
|
||||||
|
AND demo_expires_at IS NOT NULL
|
||||||
|
AND demo_expires_at BETWEEN DATE_ADD(NOW(), INTERVAL ? DAY)
|
||||||
|
AND DATE_ADD(NOW(), INTERVAL ? DAY)`,
|
||||||
|
[daysFrom, daysTo]
|
||||||
|
);
|
||||||
|
for (const t of rows) {
|
||||||
|
try {
|
||||||
|
await sender(t);
|
||||||
|
await pool.query(`UPDATE tenants SET ${flagColumn} = NOW() WHERE id = ?`, [t.id]);
|
||||||
|
console.log(`[expiry] ${flagColumn} -> ${t.kontakt_email} (slug=${t.slug})`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[expiry] Mail-Fehler ${flagColumn} fuer ${t.kontakt_email}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processExpiredToday() {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT id, name, kontakt_email, slug, demo_expires_at
|
||||||
|
FROM tenants
|
||||||
|
WHERE typ = 'demo'
|
||||||
|
AND kontakt_email IS NOT NULL
|
||||||
|
AND notified_expired_at IS NULL
|
||||||
|
AND demo_expires_at IS NOT NULL
|
||||||
|
AND demo_expires_at <= NOW()`
|
||||||
|
);
|
||||||
|
for (const t of rows) {
|
||||||
|
try {
|
||||||
|
await sendDemoExpiredMail({ to: t.kontakt_email, name: t.name });
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE tenants SET notified_expired_at = NOW(),
|
||||||
|
status = 'abgelaufen'
|
||||||
|
WHERE id = ?`,
|
||||||
|
[t.id]
|
||||||
|
);
|
||||||
|
console.log(`[expiry] expired-mail -> ${t.kontakt_email} (slug=${t.slug})`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[expiry] expired-Mail-Fehler ${t.kontakt_email}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runExpiryCheck() {
|
||||||
|
console.log('[expiry] check start', new Date().toISOString());
|
||||||
|
// 7-Tage-Erinnerung: Ablauf in [6, 7] Tagen
|
||||||
|
await processWindow({
|
||||||
|
flagColumn: 'notified_7d_at',
|
||||||
|
daysFrom: 6, daysTo: 7,
|
||||||
|
sender: (t) => sendDemoExpiryReminderMail({
|
||||||
|
to: t.kontakt_email, name: t.name, slug: t.slug,
|
||||||
|
daysLeft: 7, expiresAt: t.demo_expires_at,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// 1-Tag-Erinnerung: Ablauf in [0, 1] Tagen (aber noch nicht abgelaufen)
|
||||||
|
await processWindow({
|
||||||
|
flagColumn: 'notified_1d_at',
|
||||||
|
daysFrom: 0, daysTo: 1,
|
||||||
|
sender: (t) => sendDemoLastDayMail({
|
||||||
|
to: t.kontakt_email, name: t.name, slug: t.slug,
|
||||||
|
expiresAt: t.demo_expires_at,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
// Heute (oder bereits) abgelaufen
|
||||||
|
await processExpiredToday();
|
||||||
|
console.log('[expiry] check done');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startExpiryCron() {
|
||||||
|
// Taeglich um 08:00 Uhr (Server-Zeit = UTC). Anpassbar via env CRON_EXPIRY_AT
|
||||||
|
const expr = process.env.CRON_EXPIRY_AT || '0 8 * * *';
|
||||||
|
cron.schedule(expr, () => {
|
||||||
|
runExpiryCheck().catch(e => console.error('[expiry] cron error:', e));
|
||||||
|
});
|
||||||
|
console.log('[expiry] cron registriert:', expr);
|
||||||
|
}
|
||||||
@@ -8,11 +8,8 @@ if (process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS) {
|
|||||||
transporter = nodemailer.createTransport({
|
transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.SMTP_HOST,
|
||||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||||
secure: (process.env.SMTP_SECURE || 'false') === 'true', // 587 = STARTTLS (secure=false)
|
secure: (process.env.SMTP_SECURE || 'false') === 'true',
|
||||||
auth: {
|
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
||||||
user: process.env.SMTP_USER,
|
|
||||||
pass: process.env.SMTP_PASS,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
console.log('[mail] SMTP transport aktiv:', process.env.SMTP_HOST);
|
console.log('[mail] SMTP transport aktiv:', process.env.SMTP_HOST);
|
||||||
}
|
}
|
||||||
@@ -23,20 +20,31 @@ async function sendMail({ to, subject, html }) {
|
|||||||
if (transporter) {
|
if (transporter) {
|
||||||
const info = await transporter.sendMail({ from: FROM, to, subject, html });
|
const info = await transporter.sendMail({ from: FROM, to, subject, html });
|
||||||
console.log('[mail] SMTP sent:', to, 'msgId:', info.messageId);
|
console.log('[mail] SMTP sent:', to, 'msgId:', info.messageId);
|
||||||
return { provider: 'smtp', messageId: info.messageId, accepted: info.accepted, rejected: info.rejected };
|
return { provider: 'smtp', messageId: info.messageId };
|
||||||
}
|
}
|
||||||
if (resend) {
|
if (resend) {
|
||||||
const r = await resend.emails.send({ from: FROM, to, subject, html });
|
const r = await resend.emails.send({ from: FROM, to, subject, html });
|
||||||
console.log('[mail] Resend sent:', to, 'id:', r.data?.id);
|
console.log('[mail] Resend sent:', to, 'id:', r.data?.id);
|
||||||
return { provider: 'resend', id: r.data?.id, error: r.error };
|
return { provider: 'resend', id: r.data?.id };
|
||||||
}
|
}
|
||||||
console.log('[mail-dev] (kein Provider konfiguriert) ->', to, subject);
|
console.log('[mail-dev] (kein Provider konfiguriert) ->', to, subject);
|
||||||
return { dev: true };
|
return { dev: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wrap = (inner) => `
|
||||||
|
<div style="font-family:system-ui,Segoe UI,Helvetica,Arial,sans-serif;max-width:560px;margin:auto;color:#0f172a">
|
||||||
|
${inner}
|
||||||
|
<hr style="border:none;border-top:1px solid #e2e8f0;margin:24px 0">
|
||||||
|
<p style="color:#64748b;font-size:12px">
|
||||||
|
Dockly-Pfandsystem Demo · <a href="https://pfandsystem.dockly.de" style="color:#1a4148">pfandsystem.dockly.de</a>
|
||||||
|
</p>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
export async function sendDemoInviteMail({ to, name, slug, password, loginUrl, days }) {
|
export async function sendDemoInviteMail({ to, name, slug, password, loginUrl, days }) {
|
||||||
const html = `
|
return sendMail({
|
||||||
<div style="font-family:system-ui,Segoe UI,Helvetica,Arial,sans-serif;max-width:560px;margin:auto">
|
to,
|
||||||
|
subject: `Dein Dockly-Pfandsystem-Demo-Zugang (${days} Tage)`,
|
||||||
|
html: wrap(`
|
||||||
<h2 style="color:#051e23">Willkommen im Dockly-Pfandsystem-Demo</h2>
|
<h2 style="color:#051e23">Willkommen im Dockly-Pfandsystem-Demo</h2>
|
||||||
<p>Hallo ${name},</p>
|
<p>Hallo ${name},</p>
|
||||||
<p>dein <strong>${days}-Tage Demo-Zugang</strong> ist bereit. Du kannst alle Funktionen
|
<p>dein <strong>${days}-Tage Demo-Zugang</strong> ist bereit. Du kannst alle Funktionen
|
||||||
@@ -48,20 +56,87 @@ export async function sendDemoInviteMail({ to, name, slug, password, loginUrl, d
|
|||||||
<p style="margin:4px 0"><strong>Tenant-Kennung:</strong> ${slug}</p>
|
<p style="margin:4px 0"><strong>Tenant-Kennung:</strong> ${slug}</p>
|
||||||
</div>
|
</div>
|
||||||
<p>Bitte ändere dein Passwort nach dem ersten Login.</p>
|
<p>Bitte ändere dein Passwort nach dem ersten Login.</p>
|
||||||
<p style="color:#64748b;font-size:12px">Diese Mail wurde automatisch versandt vom Dockly-Pfandsystem-Demo-Portal.</p>
|
`),
|
||||||
</div>`;
|
});
|
||||||
return sendMail({ to, subject: `Dein Dockly-Pfandsystem-Demo-Zugang (${days} Tage)`, html });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test-Endpoint Helper
|
// 7 Tage vor Ablauf
|
||||||
|
export async function sendDemoExpiryReminderMail({ to, name, slug, daysLeft, expiresAt }) {
|
||||||
|
const loginUrl = `https://pfandsystem.dockly.de/login?tenant=${slug}`;
|
||||||
|
const dateStr = new Date(expiresAt).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
return sendMail({
|
||||||
|
to,
|
||||||
|
subject: `Erinnerung: Dein Demo-Zugang läuft in ${daysLeft} Tagen ab`,
|
||||||
|
html: wrap(`
|
||||||
|
<h2 style="color:#051e23">Dein Demo-Zugang läuft bald ab</h2>
|
||||||
|
<p>Hallo ${name},</p>
|
||||||
|
<p>nur noch <strong>${daysLeft} Tag${daysLeft === 1 ? '' : 'e'}</strong> — danach wird dein
|
||||||
|
Demo-Zugang am <strong>${dateStr}</strong> automatisch deaktiviert.</p>
|
||||||
|
<p>Du willst weiter testen oder das System für deinen Betrieb live nehmen?
|
||||||
|
Antworte einfach auf diese Mail — wir verlängern den Zugang oder
|
||||||
|
besprechen die Live-Schaltung mit dir.</p>
|
||||||
|
<p style="margin-top:24px">
|
||||||
|
<a href="${loginUrl}"
|
||||||
|
style="background:#051e23;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600">
|
||||||
|
Jetzt einloggen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 Tag vor Ablauf (Letzter-Tag-Variante)
|
||||||
|
export async function sendDemoLastDayMail({ to, name, slug, expiresAt }) {
|
||||||
|
const loginUrl = `https://pfandsystem.dockly.de/login?tenant=${slug}`;
|
||||||
|
const dateStr = new Date(expiresAt).toLocaleDateString('de-DE');
|
||||||
|
return sendMail({
|
||||||
|
to,
|
||||||
|
subject: 'Letzter Tag: Dein Demo-Zugang läuft morgen ab',
|
||||||
|
html: wrap(`
|
||||||
|
<h2 style="color:#051e23">Letzter Tag für dein Demo</h2>
|
||||||
|
<p>Hallo ${name},</p>
|
||||||
|
<p>morgen (<strong>${dateStr}</strong>) endet dein Demo-Zugang. Falls du noch Daten exportieren oder etwas
|
||||||
|
abschließend testen möchtest, schau heute nochmal rein.</p>
|
||||||
|
<p>Wir würden uns sehr freuen, wenn wir gemeinsam weitermachen.
|
||||||
|
Eine kurze Antwort auf diese Mail genügt — wir melden uns sofort zurück.</p>
|
||||||
|
<p style="margin-top:24px">
|
||||||
|
<a href="${loginUrl}"
|
||||||
|
style="background:#051e23;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600">
|
||||||
|
Jetzt einloggen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo ist abgelaufen
|
||||||
|
export async function sendDemoExpiredMail({ to, name }) {
|
||||||
|
return sendMail({
|
||||||
|
to,
|
||||||
|
subject: 'Dein Demo-Zugang ist abgelaufen',
|
||||||
|
html: wrap(`
|
||||||
|
<h2 style="color:#051e23">Dein Demo ist abgelaufen</h2>
|
||||||
|
<p>Hallo ${name},</p>
|
||||||
|
<p>dein Demo-Zugang zum Dockly-Pfandsystem ist heute abgelaufen.
|
||||||
|
Deine Daten sind noch <strong>14 Tage</strong> bei uns gespeichert —
|
||||||
|
falls du jetzt verlängern oder live gehen möchtest, antworte einfach auf
|
||||||
|
diese Mail. Danach werden die Demo-Daten automatisch gelöscht.</p>
|
||||||
|
<p>Vielen Dank fürs Testen!</p>
|
||||||
|
`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP-Selbsttest (zum Debuggen)
|
||||||
export async function sendTestMail(to) {
|
export async function sendTestMail(to) {
|
||||||
const html = `
|
return sendMail({
|
||||||
<div style="font-family:system-ui,Segoe UI,Helvetica,Arial,sans-serif;max-width:560px;margin:auto">
|
to,
|
||||||
<h2 style="color:#051e23">Dockly-Pfandsystem Demo - SMTP-Test</h2>
|
subject: 'Dockly-Pfandsystem - SMTP-Test',
|
||||||
|
html: wrap(`
|
||||||
|
<h2 style="color:#051e23">SMTP-Test</h2>
|
||||||
<p>Diese Test-Mail bestätigt, dass der Mail-Versand über
|
<p>Diese Test-Mail bestätigt, dass der Mail-Versand über
|
||||||
<code>${process.env.SMTP_HOST || 'kein SMTP'}</code> funktioniert.</p>
|
<code>${process.env.SMTP_HOST || 'kein SMTP'}</code> funktioniert.</p>
|
||||||
<p>Absender: <code>${FROM}</code></p>
|
<p>Absender: <code>${FROM}</code></p>
|
||||||
<p>Zeitstempel: ${new Date().toISOString()}</p>
|
<p>Zeitstempel: ${new Date().toISOString()}</p>
|
||||||
</div>`;
|
`),
|
||||||
return sendMail({ to, subject: 'Dockly-Pfandsystem Demo - SMTP-Test', html });
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "pfandsystem-backend",
|
"name": "pfandsystem-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Backend API für Pfandsystem mit MySQL",
|
"description": "Backend API f\u00fcr Pfandsystem mit MySQL",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
@@ -18,9 +18,10 @@
|
|||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"resend": "^3.0.0",
|
"resend": "^3.0.0",
|
||||||
"express-validator": "^7.0.1"
|
"express-validator": "^7.0.1",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
38
backend/scripts/bootstrap.js
vendored
38
backend/scripts/bootstrap.js
vendored
@@ -1,25 +1,39 @@
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import pool from '../config/database.js';
|
import pool from '../config/database.js';
|
||||||
|
|
||||||
// Stellt sicher dass mindestens ein SuperAdmin existiert.
|
const EXPIRY_COLS = ['notified_7d_at', 'notified_1d_at', 'notified_expired_at'];
|
||||||
// Nutzt env SUPERADMIN_EMAIL + SUPERADMIN_PASSWORD beim ersten Start.
|
|
||||||
|
async function ensureExpiryColumns() {
|
||||||
|
const [cols] = await pool.query(
|
||||||
|
`SELECT COLUMN_NAME FROM information_schema.columns
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'tenants'`
|
||||||
|
);
|
||||||
|
const have = new Set(cols.map(c => c.COLUMN_NAME));
|
||||||
|
for (const col of EXPIRY_COLS) {
|
||||||
|
if (!have.has(col)) {
|
||||||
|
await pool.query(`ALTER TABLE tenants ADD COLUMN ${col} TIMESTAMP NULL`);
|
||||||
|
console.log('[bootstrap] tenants ALTER ADD', col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function bootstrapSuperAdmin() {
|
export async function bootstrapSuperAdmin() {
|
||||||
|
// Auf DB warten (max 30s)
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
try { await pool.query('SELECT 1'); break; }
|
||||||
|
catch { await new Promise(r => setTimeout(r, 1000)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrations
|
||||||
|
try { await ensureExpiryColumns(); }
|
||||||
|
catch (e) { console.error('[bootstrap] Migration-Fehler:', e.message); }
|
||||||
|
|
||||||
const email = process.env.SUPERADMIN_EMAIL;
|
const email = process.env.SUPERADMIN_EMAIL;
|
||||||
const password = process.env.SUPERADMIN_PASSWORD;
|
const password = process.env.SUPERADMIN_PASSWORD;
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
console.warn('[bootstrap] SUPERADMIN_EMAIL/PASSWORD nicht gesetzt - skip');
|
console.warn('[bootstrap] SUPERADMIN_EMAIL/PASSWORD nicht gesetzt - skip');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Auf Datenbank-Bereitschaft warten (max 30s)
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
try {
|
|
||||||
await pool.query('SELECT 1');
|
|
||||||
break;
|
|
||||||
} catch {
|
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
"SELECT id FROM mitarbeiter WHERE email = ? AND tenant_id IS NULL AND rolle = 'superadmin'",
|
"SELECT id FROM mitarbeiter WHERE email = ? AND tenant_id IS NULL AND rolle = 'superadmin'",
|
||||||
[email]
|
[email]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import mitarbeiterRoutes from './routes/mitarbeiter.js';
|
|||||||
import geraeteRoutes from './routes/geraete.js';
|
import geraeteRoutes from './routes/geraete.js';
|
||||||
|
|
||||||
import { bootstrapSuperAdmin } from './scripts/bootstrap.js';
|
import { bootstrapSuperAdmin } from './scripts/bootstrap.js';
|
||||||
|
import { startExpiryCron } from './lib/expiryJob.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ app.use((req, res) => res.status(404).json({ error: 'Route nicht gefunden' }));
|
|||||||
bootstrapSuperAdmin()
|
bootstrapSuperAdmin()
|
||||||
.catch(err => console.error('Bootstrap-Fehler:', err))
|
.catch(err => console.error('Bootstrap-Fehler:', err))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
startExpiryCron();
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Backend läuft auf Port ${PORT}`);
|
console.log(`Backend läuft auf Port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user