diff --git a/backend/lib/expiryJob.js b/backend/lib/expiryJob.js new file mode 100644 index 0000000..5a65181 --- /dev/null +++ b/backend/lib/expiryJob.js @@ -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); +} diff --git a/backend/lib/mail.js b/backend/lib/mail.js index e7b0045..5218496 100644 --- a/backend/lib/mail.js +++ b/backend/lib/mail.js @@ -8,11 +8,8 @@ if (process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS) { transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST, port: parseInt(process.env.SMTP_PORT || '587'), - secure: (process.env.SMTP_SECURE || 'false') === 'true', // 587 = STARTTLS (secure=false) - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASS, - }, + secure: (process.env.SMTP_SECURE || 'false') === 'true', + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, }); console.log('[mail] SMTP transport aktiv:', process.env.SMTP_HOST); } @@ -23,20 +20,31 @@ async function sendMail({ to, subject, html }) { if (transporter) { const info = await transporter.sendMail({ from: FROM, to, subject, html }); 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) { const r = await resend.emails.send({ from: FROM, to, subject, html }); 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); return { dev: true }; } +const wrap = (inner) => ` +
+ ${inner} +
+

+ Dockly-Pfandsystem Demo · pfandsystem.dockly.de +

+
`; + export async function sendDemoInviteMail({ to, name, slug, password, loginUrl, days }) { - const html = ` -
+ return sendMail({ + to, + subject: `Dein Dockly-Pfandsystem-Demo-Zugang (${days} Tage)`, + html: wrap(`

Willkommen im Dockly-Pfandsystem-Demo

Hallo ${name},

dein ${days}-Tage Demo-Zugang ist bereit. Du kannst alle Funktionen @@ -48,20 +56,87 @@ export async function sendDemoInviteMail({ to, name, slug, password, loginUrl, d

Tenant-Kennung: ${slug}

Bitte ändere dein Passwort nach dem ersten Login.

-

Diese Mail wurde automatisch versandt vom Dockly-Pfandsystem-Demo-Portal.

- `; - 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(` +

Dein Demo-Zugang läuft bald ab

+

Hallo ${name},

+

nur noch ${daysLeft} Tag${daysLeft === 1 ? '' : 'e'} — danach wird dein + Demo-Zugang am ${dateStr} automatisch deaktiviert.

+

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.

+

+ + Jetzt einloggen + +

+ `), + }); +} + +// 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(` +

Letzter Tag für dein Demo

+

Hallo ${name},

+

morgen (${dateStr}) endet dein Demo-Zugang. Falls du noch Daten exportieren oder etwas + abschließend testen möchtest, schau heute nochmal rein.

+

Wir würden uns sehr freuen, wenn wir gemeinsam weitermachen. + Eine kurze Antwort auf diese Mail genügt — wir melden uns sofort zurück.

+

+ + Jetzt einloggen + +

+ `), + }); +} + +// Demo ist abgelaufen +export async function sendDemoExpiredMail({ to, name }) { + return sendMail({ + to, + subject: 'Dein Demo-Zugang ist abgelaufen', + html: wrap(` +

Dein Demo ist abgelaufen

+

Hallo ${name},

+

dein Demo-Zugang zum Dockly-Pfandsystem ist heute abgelaufen. + Deine Daten sind noch 14 Tage 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.

+

Vielen Dank fürs Testen!

+ `), + }); +} + +// SMTP-Selbsttest (zum Debuggen) export async function sendTestMail(to) { - const html = ` -
-

Dockly-Pfandsystem Demo - SMTP-Test

+ return sendMail({ + to, + subject: 'Dockly-Pfandsystem - SMTP-Test', + html: wrap(` +

SMTP-Test

Diese Test-Mail bestätigt, dass der Mail-Versand über ${process.env.SMTP_HOST || 'kein SMTP'} funktioniert.

Absender: ${FROM}

Zeitstempel: ${new Date().toISOString()}

-
`; - return sendMail({ to, subject: 'Dockly-Pfandsystem Demo - SMTP-Test', html }); + `), + }); } diff --git a/backend/package.json b/backend/package.json index d30390b..3e0cf80 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,7 +2,7 @@ "name": "pfandsystem-backend", "version": "1.0.0", "type": "module", - "description": "Backend API für Pfandsystem mit MySQL", + "description": "Backend API f\u00fcr Pfandsystem mit MySQL", "main": "server.js", "scripts": { "start": "node server.js", @@ -18,9 +18,10 @@ "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.16", "resend": "^3.0.0", - "express-validator": "^7.0.1" + "express-validator": "^7.0.1", + "node-cron": "^3.0.3" }, "devDependencies": { "nodemon": "^3.0.2" } -} +} \ No newline at end of file diff --git a/backend/scripts/bootstrap.js b/backend/scripts/bootstrap.js index e525834..5ad9586 100644 --- a/backend/scripts/bootstrap.js +++ b/backend/scripts/bootstrap.js @@ -1,25 +1,39 @@ import bcrypt from 'bcrypt'; import pool from '../config/database.js'; -// Stellt sicher dass mindestens ein SuperAdmin existiert. -// Nutzt env SUPERADMIN_EMAIL + SUPERADMIN_PASSWORD beim ersten Start. +const EXPIRY_COLS = ['notified_7d_at', 'notified_1d_at', 'notified_expired_at']; + +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() { + // 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 password = process.env.SUPERADMIN_PASSWORD; if (!email || !password) { console.warn('[bootstrap] SUPERADMIN_EMAIL/PASSWORD nicht gesetzt - skip'); 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( "SELECT id FROM mitarbeiter WHERE email = ? AND tenant_id IS NULL AND rolle = 'superadmin'", [email] diff --git a/backend/server.js b/backend/server.js index db3e09c..b2cf344 100644 --- a/backend/server.js +++ b/backend/server.js @@ -15,6 +15,7 @@ import mitarbeiterRoutes from './routes/mitarbeiter.js'; import geraeteRoutes from './routes/geraete.js'; import { bootstrapSuperAdmin } from './scripts/bootstrap.js'; +import { startExpiryCron } from './lib/expiryJob.js'; dotenv.config(); @@ -56,6 +57,7 @@ app.use((req, res) => res.status(404).json({ error: 'Route nicht gefunden' })); bootstrapSuperAdmin() .catch(err => console.error('Bootstrap-Fehler:', err)) .finally(() => { + startExpiryCron(); app.listen(PORT, '0.0.0.0', () => { console.log(`Backend läuft auf Port ${PORT}`); });