diff --git a/server/mail-templates/K35 Buchungskalender abgeschaltet.html b/server/mail-templates/K35 Buchungskalender abgeschaltet.html new file mode 100644 index 0000000..66c6c5e --- /dev/null +++ b/server/mail-templates/K35 Buchungskalender abgeschaltet.html @@ -0,0 +1,52 @@ + + + + + + + + +Buchungskalender abgeschaltet – bitte sichere Deine Rechnungen + + + + +Bitte sichere Deine Rechnungen vor der Löschung. ͏ ͏ ͏ + + + + + diff --git a/server/services/unterkunft/abschaltHinweis.js b/server/services/unterkunft/abschaltHinweis.js new file mode 100644 index 0000000..d9f160a --- /dev/null +++ b/server/services/unterkunft/abschaltHinweis.js @@ -0,0 +1,80 @@ +import { config } from '../../config.js' +import { getDocument, listAllDocuments, Query, updateDocument } from '../appwriteAdmin.js' +import { sendCustomerMail } from '../mailer.js' +import { customerOf } from '../siteFeatures.js' +import { datumKurz, heute } from '../termine/zeit.js' +import { hatUkDaten } from './aufbewahrung.js' + +/** + * Hinweis-Mail K35 vor der Loeschung: Sobald der Tagesjob einen abgeschalteten Buchungskalender mit Daten erstmals sieht, geht EINMAL + * K35 an die E-Mail des Kundenkontos (Loeschdatum = disabledAt + loeschTageNachAbschalten Tage, Rechnungen bis dahin im Portal abrufbar). + * Marker in portalFeatures.adminNote: "K35 am YYYY-MM-DD" (gilt nur, wenn nicht vor disabledAt; ein spaeteres Abschalten setzt disabledAt neu + * und loest den Hinweis erneut aus). Scheitert der Versand, bleibt der Marker aus und der naechste Lauf wiederholt ihn. Ein Kunde ohne Adresse + * (z. B. Kontoloeschung) bekommt den Marker ohne Mail. Ist das Loeschdatum schon erreicht, geht keine Mail mehr raus (die Loeschung laeuft im + * selben Lauf). Logs ohne Personendaten. + */ + +const TAG_MS = 24 * 60 * 60 * 1000 +const MARKER_RE = /K35 am (\d{4}-\d{2}-\d{2})/ +const zeitMs = (v) => { + const t = Date.parse(v) + return Number.isNaN(t) ? NaN : t +} + +function markiere(notiz, tag) { + const marker = `K35 am ${tag}` + const alt = String(notiz ?? '').replace(/\s*(?:[-–|]\s*)?K35 am \d{4}-\d{2}-\d{2}/g, '').trim() + if (!alt) return marker + return `${alt.slice(0, Math.max(0, 300 - marker.length - 3))} – ${marker}` +} + +/** @returns {Promise<{ geprueft: number, gesendet: number, ohneAdresse: number, fehler: number }>} */ +export async function sendeHinweiseVorLoeschung(now = new Date()) { + const nowMs = now instanceof Date ? now.getTime() : Number(now) + const tage = config.unterkunft.loeschTageNachAbschalten ?? 90 + const FEATURES = config.collections.portalFeatures + const erg = { geprueft: 0, gesendet: 0, ohneAdresse: 0, fehler: 0 } + const funktionen = await listAllDocuments(FEATURES, [Query.equal('featureKey', 'unterkunft'), Query.equal('state', 'disabled')]) + for (const f of funktionen) { + const abgeschaltet = zeitMs(f.disabledAt) + if (!Number.isFinite(abgeschaltet)) continue + const m = MARKER_RE.exec(String(f.adminNote ?? '')) + if (m && m[1] >= String(f.disabledAt).slice(0, 10)) continue + const loeschAm = abgeschaltet + tage * TAG_MS + if (loeschAm <= nowMs) continue + erg.geprueft += 1 + try { + if (!(await hatUkDaten(f.$id))) continue + const kunde = f.customerId ? await customerOf(f.customerId).catch(() => null) : null + const an = typeof kunde?.email === 'string' ? kunde.email.trim() : '' + if (an) { + let projekt = '' + try { + const p = await getDocument(config.collections.websiteProjects, f.projectId) + projekt = p?.projectName || p?.subdomain || '' + } catch { + // ohne Projektname: die Vorlage nennt dann keinen + } + await sendCustomerMail(an, { + template: 'K35', + values: { + kunde_name: typeof kunde.name === 'string' ? kunde.name.trim() : '', + projekt, + loeschdatum: datumKurz(new Date(loeschAm)), + portal_url: `${config.publicBaseUrl}/dashboard.html#buchungen?projekt=${encodeURIComponent(f.projectId ?? '')}`, + }, + blocks: {}, + bccTeam: true, + }) + erg.gesendet += 1 + } else { + erg.ohneAdresse += 1 + } + await updateDocument(FEATURES, f.$id, { adminNote: markiere(f.adminNote, heute(new Date(nowMs))) }) + } catch (err) { + erg.fehler += 1 + console.warn(`[unterkunft-aufbewahrung] K35 für Funktion ${f.$id} nicht gesendet: ${[err?.name, err?.status].filter(Boolean).join(' ')}`) + } + } + return erg +} diff --git a/server/services/unterkunft/jobs.js b/server/services/unterkunft/jobs.js index bc4a7b0..431dc15 100644 --- a/server/services/unterkunft/jobs.js +++ b/server/services/unterkunft/jobs.js @@ -1,6 +1,7 @@ import { laufeFristenAb } from './ablauf.js' import { config } from '../../config.js' import { listDocuments, Query } from '../appwriteAdmin.js' +import { sendeHinweiseVorLoeschung } from './abschaltHinweis.js' import { anonymisiere, loescheAbgeschaltete } from './aufbewahrung.js' import { gleicheAlleAb } from './icalAbgleich.js' import { unterkunftModus } from './schalter.js' @@ -55,7 +56,7 @@ async function gibtDaten() { /** * Tagesjob: Aufbewahrung (ipHash, Verfall von `neu`, Anonymisierung, Rechnungen nach 8 Jahren) und Loeschung abgeschalteter Funktionen - * (90 Tage). Gemeinsamer Deckel config.unterkunft.aufbewahrungDeckel (500) je Lauf (die Loeschung bekommt, was die Aufbewahrung uebrig laesst). Bei UNTERKUNFT_ENABLED=off nur, wenn es noch + * (90 Tage; davor einmal die Hinweis-Mail K35). Gemeinsamer Deckel config.unterkunft.aufbewahrungDeckel (500) je Lauf (die Loeschung bekommt, was die Aufbewahrung uebrig laesst). Bei UNTERKUNFT_ENABLED=off nur, wenn es noch * Buchungsdaten gibt (die Fristen gelten auch dann). Ein Teil, der scheitert, stoppt den anderen nicht. */ export async function taeglicherJob(now = new Date()) { @@ -71,6 +72,12 @@ export async function taeglicherJob(now = new Date()) { } catch (err) { console.error(`[unterkunft-jobs] taeglicherJob: Aufbewahrung fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`) } + // Vor der Loeschung: einmaliger Hinweis K35 (Rechnungen sichern); ein Fehler stoppt die Loeschung nicht (Marker fehlt, naechster Lauf wiederholt) + try { + await sendeHinweiseVorLoeschung(now) + } catch (err) { + console.error(`[unterkunft-jobs] taeglicherJob: Hinweis vor der Löschung fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`) + } try { weg = await loescheAbgeschaltete(now, { deckel: rest }) } catch (err) { diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index a00eba8..bf38f85 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -8,7 +8,7 @@ const DIR = new URL('../server/mail-templates/', import.meta.url) // G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb; // K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9) const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`) -const EXPECTED = [...ids('G', 22), ...ids('K', 34), ...ids('T', 22)] +const EXPECTED = [...ids('G', 22), ...ids('K', 35), ...ids('T', 22)] // GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten) const GUEST = ids('G', 8) const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15'] @@ -1335,12 +1335,13 @@ test('G22: Erstattung „wird veranlasst“ nur im Block', () => { assert.ok(!renderMail('G22', sampleValues(tpl), { erstattung: false }).text.includes('Erstattung von')) }) -test('K31–K34, T22: Dateien, Platzhalter, Du-Form', () => { +test('K31–K35, T22: Dateien, Platzhalter, Du-Form', () => { const erwartet = { K31: ['kunde_name', 'gast_name', 'gast_email', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'nachricht_gast', 'portal_url'], K32: ['kunde_name', 'gast_name', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'gesamt', 'zahlart_text', 'portal_url'], K33: ['kunde_name', 'unterkunft', 'quelle', 'seit', 'portal_url'], K34: ['kunde_name', 'projekt', 'preis', 'ust_hinweis', 'begruendung', 'portal_url'], + K35: ['kunde_name', 'projekt', 'loeschdatum', 'portal_url'], T22: ['kunde_name', 'email', 'projekt', 'nachricht', 'woms_url'], } for (const [id, p] of Object.entries(erwartet)) { @@ -1350,5 +1351,7 @@ test('K31–K34, T22: Dateien, Platzhalter, Du-Form', () => { assert.deepEqual(mail.problems, [], id) } assert.equal(loadTemplate('K32').file, 'K32 Neue Buchung.html') + assert.equal(loadTemplate('K35').file, 'K35 Buchungskalender abgeschaltet.html') + assert.equal(loadTemplate('K35').subject, 'Buchungskalender abgeschaltet – bitte sichere Deine Rechnungen') assert.equal(loadTemplate('T22').subject, '[Buchungskalender] Neue Anfrage: {{kunde_name}}') }) diff --git a/test/unterkunftAufbewahrung.test.js b/test/unterkunftAufbewahrung.test.js index dace5fd..0a1d89d 100644 --- a/test/unterkunftAufbewahrung.test.js +++ b/test/unterkunftAufbewahrung.test.js @@ -15,13 +15,16 @@ after(() => aw.close()) useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' }) const { config } = await import('../server/config.js') -const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') const { anonymisiere, loescheAbgeschaltete, loescheUkDaten, schliesseUkVonKunde } = await import('../server/services/unterkunft/aufbewahrung.js') const jobs = await import('../server/services/unterkunft/jobs.js') const mails = await import('../server/services/unterkunft/mails.js') const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') const { withLocks } = await import('../server/services/keyedLock.js') const { raeumeAuf } = await import('../server/services/termine/outbox.js') +const { sendeHinweiseVorLoeschung } = await import('../server/services/unterkunft/abschaltHinweis.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') const TAG = 24 * 3600 * 1000 const JETZT = new Date('2026-10-07T10:00:00.000Z') @@ -183,6 +186,70 @@ test('Abgeschaltete Funktion: nach 89 Tagen alles da, nach 91 Tagen alle uk*-Dat assert.equal(anzahl('f3'), 6) }) +test('K35: einmal pro Abschaltung, mit Loeschdatum, nur mit Daten; Marker; ohne Adresse nur Marker; Fehler wird wiederholt; Tagesjob sendet vor der Loeschung', async () => { + const senke = mailSink() + setMailTransportForTests(senke) + try { + aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' }) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau Home' }) + funktion({ state: 'disabled', enabled: false, disabledAt: vor(1) }) + funktion({ $id: 'f2', state: 'disabled', enabled: false, disabledAt: vor(1), customerId: 'c9' }) // Kunde fehlt + funktion({ $id: 'f3', state: 'disabled', enabled: false, disabledAt: vor(1) }) // ohne Daten + funktion({ $id: 'f4', state: 'active' }) + for (const f of ['f1', 'f2', 'f4']) datenFuer(f) + const r = await sendeHinweiseVorLoeschung(JETZT) + assert.deepEqual([r.gesendet, r.ohneAdresse, r.fehler], [1, 1, 0]) + assert.equal(senke.sent.length, 1) + const m = senke.sent[0] + assert.equal(m.to[0].address, 'mimi@example.com') + assert.match(m.subject, /Buchungskalender abgeschaltet/) + assert.match(m.text, /Donau Home/) + assert.match(m.text, /04.01.2027/, 'disabledAt + 90 Tage') + assert.match(aw.docs('portalFeatures').get('f1').adminNote, /K35 am 2026-10-07/) + assert.match(aw.docs('portalFeatures').get('f2').adminNote, /K35 am 2026-10-07/) + assert.ok(!aw.docs('portalFeatures').get('f3').adminNote) + assert.ok(!aw.docs('portalFeatures').get('f4').adminNote) + // zweiter Lauf: nichts mehr + assert.equal((await sendeHinweiseVorLoeschung(new Date(JETZT.getTime() + TAG))).gesendet, 0) + assert.equal(senke.sent.length, 1) + // erneut abgeschaltet (alter Marker liegt vor disabledAt): wieder ein Hinweis + await updateDocument(config.collections.portalFeatures, 'f1', { disabledAt: vor(-5) }) + assert.equal((await sendeHinweiseVorLoeschung(new Date(JETZT.getTime() + 6 * TAG))).gesendet, 1) + // Versandfehler: kein Marker, naechster Lauf wiederholt + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get('f3'), adminNote: '' }) + datenFuer('f3') + setMailTransportForTests({ async sendMail() { throw new Error('smtp down') } }) + const log = console.warn + console.warn = () => {} + const f = await sendeHinweiseVorLoeschung(JETZT) + console.warn = log + assert.equal(f.fehler, 1) + assert.ok(!aw.docs('portalFeatures').get('f3').adminNote) + setMailTransportForTests(senke) + assert.equal((await sendeHinweiseVorLoeschung(JETZT)).gesendet, 1) + } finally { + setMailTransportForTests(null) + } +}) + +test('K35: Tagesjob schickt den Hinweis und loescht erst nach 90 Tagen', async () => { + const senke = mailSink() + setMailTransportForTests(senke) + try { + aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' }) + funktion({ state: 'disabled', enabled: false, disabledAt: vor(3) }) + datenFuer('f1') + await jobs.taeglicherJob(JETZT) + assert.equal(senke.sent.length, 1) + assert.equal(anzahl('f1'), 6) + await jobs.taeglicherJob(new Date(JETZT.getTime() + 88 * TAG)) + assert.equal(senke.sent.length, 1) + assert.equal(anzahl('f1'), 0) + } finally { + setMailTransportForTests(null) + } +}) + test('loescheUkDaten mit Deckel: unvollstaendig, Fortsetzung loescht den Rest', async () => { datenFuer('f1') const a = await loescheUkDaten({ featureId: 'f1', deckel: 2 }) diff --git a/test/unterkunftVerwaltung.test.js b/test/unterkunftVerwaltung.test.js index ac73a5f..351a8d8 100644 --- a/test/unterkunftVerwaltung.test.js +++ b/test/unterkunftVerwaltung.test.js @@ -474,6 +474,18 @@ test('Stornieren: Erstattung fehlgeschlagen -> 502 erstattung_fehlgeschlagen mit // ------------------------------------------------------------------------------------------------ Rechnungen +test('Rechnungen: im Zustand abgeschaltet bleiben Liste und PDF lesbar, Schreiben ist gesperrt (Rechnungen sichern vor der Loeschung)', async () => { + aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 100, inhalt: JSON.stringify({ anfrageNummer: 'A-X', empfaenger: { name: 'Eva' }, aussteller: { firma: 'Haus' }, posten: [], summen: { bruttoCent: 100, nettoCent: 93, ust: [] } }) }) + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'disabled', enabled: false, disabledAt: '2026-10-01T00:00:00.000Z' }) + const liste = await get('/rechnungen') + assert.equal(liste.status, 200) + assert.deepEqual(liste.body.rechnungen.map((r) => r.id), ['r1']) + const pdf = await get('/rechnungen/r1.pdf') + assert.equal(pdf.status, 200) + assert.equal(pdf.headers.get('content-type'), 'application/pdf') + assert.equal((await post('/unterkuenfte', { name: 'X', maxPersonen: 2 })).status, 409) +}) + test('Rechnungen: Liste nur des Projekts; PDF fremde Rechnung 404, eigene 200 als PDF, unsichere ID 404', async () => { aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 100, inhalt: JSON.stringify({ anfrageNummer: 'A-X', empfaenger: { name: 'Eva' } }) }) aw.seed('ukRechnungen', { $id: 'rfremd', featureId: 'anderes', anfrageId: 'a9', art: 'rechnung', nummer: 'XX-1', datum: '2026-11-01', bruttoCent: 100, inhalt: '{}' })