Buchungskalender: Abschluss – S5 Hinweis-Mail K35 vor der Loeschung, Rechnungen im Zustand abgeschaltet lesbar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2fc4eb676
commit
ab838d07bc
6 files changed
+225
-4
No files matched your search
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<!--
|
||||||
|
K35 · Buchungskalender abgeschaltet – Rechnungen sichern
|
||||||
|
Betreff: Buchungskalender abgeschaltet – bitte sichere Deine Rechnungen
|
||||||
|
Absender: „WEBklar“ <Portal-Postfach>
|
||||||
|
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||||
|
Platzhalter: {{kunde_name}}, {{projekt}}, {{loeschdatum}}, {{portal_url}}
|
||||||
|
Geht einmal an Kunden, deren Buchungskalender abgeschaltet ist und noch Daten (Buchungen, Rechnungen) enthält: Bis zum Löschdatum (90 Tage nach dem Abschalten) sind die Rechnungen im Kundenportal abrufbar, danach löschen wir alle Daten des Buchungskalenders.
|
||||||
|
loeschdatum = Tag, ab dem wir die Daten löschen (90 Tage nach dem Abschalten), als TT.MM.JJJJ.
|
||||||
|
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
|
||||||
|
-->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<meta name="supported-color-schemes" content="light dark">
|
||||||
|
<title>Buchungskalender abgeschaltet – bitte sichere Deine Rechnungen</title>
|
||||||
|
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||||
|
<style>
|
||||||
|
a{color:#111111;}
|
||||||
|
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||||
|
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||||
|
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Bitte sichere Deine Rechnungen vor der Löschung. ͏ ͏ ͏</span>
|
||||||
|
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||||
|
<tr><td align="center" style="padding:32px 12px;">
|
||||||
|
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||||
|
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||||
|
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||||
|
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Abgeschaltet</td>
|
||||||
|
</tr></table></td></tr>
|
||||||
|
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Buchungskalender</td></tr></table></td></tr>
|
||||||
|
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Buchungskalender abgeschaltet – Rechnungen sichern</td></tr>
|
||||||
|
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>der Buchungskalender für Dein Projekt {{projekt}} ist abgeschaltet. Gäste können auf Deiner Website nichts mehr anfragen oder buchen. Die Daten des Buchungskalenders (Buchungen, Gastdaten und Rechnungen) löschen wir am {{loeschdatum}}. Bitte sichere Deine Rechnungen vorher.</td></tr>
|
||||||
|
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Buchungskalender</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Daten gelöscht am</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{loeschdatum}}</td></tr></table></td></tr>
|
||||||
|
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Was jetzt gilt</div><div class="mut" style="color:#4a4843;">Bis zum {{loeschdatum}} kannst Du Deine Rechnungen und Stornorechnungen im Kundenportal ansehen und als PDF herunterladen. Danach löschen wir alle Daten des Buchungskalenders; sie lassen sich dann nicht wiederherstellen.</div><div class="mut" style="color:#4a4843;padding-top:8px;">Bitte lade die Rechnungen herunter und bewahre sie auf. Für Rechnungen gelten gesetzliche Aufbewahrungsfristen; diese Pflicht liegt bei Dir als Vermieter.</div></td></tr></table></td></tr>
|
||||||
|
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Rechnungen im Portal öffnen →</a></td></tr></table></td></tr>
|
||||||
|
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
|
||||||
|
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { laufeFristenAb } from './ablauf.js'
|
import { laufeFristenAb } from './ablauf.js'
|
||||||
import { config } from '../../config.js'
|
import { config } from '../../config.js'
|
||||||
import { listDocuments, Query } from '../appwriteAdmin.js'
|
import { listDocuments, Query } from '../appwriteAdmin.js'
|
||||||
|
import { sendeHinweiseVorLoeschung } from './abschaltHinweis.js'
|
||||||
import { anonymisiere, loescheAbgeschaltete } from './aufbewahrung.js'
|
import { anonymisiere, loescheAbgeschaltete } from './aufbewahrung.js'
|
||||||
import { gleicheAlleAb } from './icalAbgleich.js'
|
import { gleicheAlleAb } from './icalAbgleich.js'
|
||||||
import { unterkunftModus } from './schalter.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
|
* 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.
|
* Buchungsdaten gibt (die Fristen gelten auch dann). Ein Teil, der scheitert, stoppt den anderen nicht.
|
||||||
*/
|
*/
|
||||||
export async function taeglicherJob(now = new Date()) {
|
export async function taeglicherJob(now = new Date()) {
|
||||||
@@ -71,6 +72,12 @@ export async function taeglicherJob(now = new Date()) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[unterkunft-jobs] taeglicherJob: Aufbewahrung fehlgeschlagen: ${err?.name} ${err?.status ?? ''}`)
|
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 {
|
try {
|
||||||
weg = await loescheAbgeschaltete(now, { deckel: rest })
|
weg = await loescheAbgeschaltete(now, { deckel: rest })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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;
|
// 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)
|
// 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 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)
|
// 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 GUEST = ids('G', 8)
|
||||||
const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15']
|
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'))
|
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 = {
|
const erwartet = {
|
||||||
K31: ['kunde_name', 'gast_name', 'gast_email', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'nachricht_gast', 'portal_url'],
|
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'],
|
K32: ['kunde_name', 'gast_name', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'gesamt', 'zahlart_text', 'portal_url'],
|
||||||
K33: ['kunde_name', 'unterkunft', 'quelle', 'seit', 'portal_url'],
|
K33: ['kunde_name', 'unterkunft', 'quelle', 'seit', 'portal_url'],
|
||||||
K34: ['kunde_name', 'projekt', 'preis', 'ust_hinweis', 'begruendung', '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'],
|
T22: ['kunde_name', 'email', 'projekt', 'nachricht', 'woms_url'],
|
||||||
}
|
}
|
||||||
for (const [id, p] of Object.entries(erwartet)) {
|
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.deepEqual(mail.problems, [], id)
|
||||||
}
|
}
|
||||||
assert.equal(loadTemplate('K32').file, 'K32 Neue Buchung.html')
|
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}}')
|
assert.equal(loadTemplate('T22').subject, '[Buchungskalender] Neue Anfrage: {{kunde_name}}')
|
||||||
})
|
})
|
||||||
@@ -15,13 +15,16 @@ after(() => aw.close())
|
|||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' })
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' })
|
||||||
|
|
||||||
const { config } = await import('../server/config.js')
|
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 { anonymisiere, loescheAbgeschaltete, loescheUkDaten, schliesseUkVonKunde } = await import('../server/services/unterkunft/aufbewahrung.js')
|
||||||
const jobs = await import('../server/services/unterkunft/jobs.js')
|
const jobs = await import('../server/services/unterkunft/jobs.js')
|
||||||
const mails = await import('../server/services/unterkunft/mails.js')
|
const mails = await import('../server/services/unterkunft/mails.js')
|
||||||
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
|
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
|
||||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||||
const { raeumeAuf } = await import('../server/services/termine/outbox.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 TAG = 24 * 3600 * 1000
|
||||||
const JETZT = new Date('2026-10-07T10:00:00.000Z')
|
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)
|
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 () => {
|
test('loescheUkDaten mit Deckel: unvollstaendig, Fortsetzung loescht den Rest', async () => {
|
||||||
datenFuer('f1')
|
datenFuer('f1')
|
||||||
const a = await loescheUkDaten({ featureId: 'f1', deckel: 2 })
|
const a = await loescheUkDaten({ featureId: 'f1', deckel: 2 })
|
||||||
|
|||||||
@@ -474,6 +474,18 @@ test('Stornieren: Erstattung fehlgeschlagen -> 502 erstattung_fehlgeschlagen mit
|
|||||||
|
|
||||||
// ------------------------------------------------------------------------------------------------ Rechnungen
|
// ------------------------------------------------------------------------------------------------ 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 () => {
|
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: '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: '{}' })
|
aw.seed('ukRechnungen', { $id: 'rfremd', featureId: 'anderes', anfrageId: 'a9', art: 'rechnung', nummer: 'XX-1', datum: '2026-11-01', bruttoCent: 100, inhalt: '{}' })
|
||||||
|
|||||||
Reference in new issue
Block a user