Kundenansicht P6: Mail-Builder für K38, K39, T24 und die Zeiten-Varianten K40, K41
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
664043e8e9
commit
9489a70f0c
3 files changed
+184
-5
No files matched your search
@@ -0,0 +1,123 @@
|
|||||||
|
import { config } from '../config.js'
|
||||||
|
import { anleitungsLink, featureConfig, katalogFelder } from './featureRegistry.js'
|
||||||
|
import { unterlagenFuer } from './aboUnterlagen.js'
|
||||||
|
import { LOESCHFRIST_TAGE } from './termine/frist.js'
|
||||||
|
import { datumKurz } from './termine/zeit.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mails der Funktionen im Abo-Modell (Kundenansicht neu, P6; Vorlagen server/mail-templates/K38, K39, T24):
|
||||||
|
* K38 Deine Funktion wird eingerichtet (Einschalten aus dem Abo oder durch das Team; nicht für Öffnungszeiten live, dort bleibt K27)
|
||||||
|
* K39 Deine Funktion wurde beendet (der Tagesabgleich beendet sie mit dem Abo)
|
||||||
|
* T24 Team-Hinweis: Funktion eingeschaltet (Einrichtung geht aufs Stundenkonto, Block vorschuss)
|
||||||
|
* (K42 und K43, die Zusage-Mails der Webshop- und der Buchungskalender-Anfrage, folgen in Task 15.)
|
||||||
|
* Die Builder sind rein (kein Zugriff auf Appwrite, kein Versand) und liefern { template, values, blocks }, so wie
|
||||||
|
* zeiten/mails.js: die Werte decken genau die Platzhalter der Vorlage ab. Keine Preise, kein Wort „bestellen“.
|
||||||
|
*
|
||||||
|
* Anrede: `kunde` ist der Name oder das Kundendokument; ohne Namen bleibt kunde_name leer (renderMail macht aus „Hallo ,“ „Hallo,“).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Anrede-Wert: Name aus einem String oder einem Kundendokument, getrimmt; sonst ''. */
|
||||||
|
function kundenName(kunde) {
|
||||||
|
const name = typeof kunde === 'string' ? kunde : kunde?.name
|
||||||
|
return typeof name === 'string' ? name.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Date -> 'TT.MM.JJJJ' (Berliner Tag). Wirft bei etwas anderem als einem gültigen Date: ein leeres Datum in der Mail wäre falsch. */
|
||||||
|
function datum(wert, feld) {
|
||||||
|
if (!(wert instanceof Date) || Number.isNaN(wert.getTime())) throw new TypeError(`${feld}: gültiges Date erwartet`)
|
||||||
|
return datumKurz(wert)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registereintrag einer Funktion; ein unbekannter Schlüssel ist ein Programmfehler (TypeError). */
|
||||||
|
function funktion(key) {
|
||||||
|
const eintrag = featureConfig(key)
|
||||||
|
if (!eintrag) throw new TypeError(`Unbekannte Funktion: ${String(key)}`)
|
||||||
|
return eintrag
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adresse der Funktion im Kundenportal (returnHash des Registers). */
|
||||||
|
const portalUrl = (eintrag) => `${config.publicBaseUrl}/dashboard.html#${eintrag.returnHash}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* K38: Die Funktion wird eingerichtet.
|
||||||
|
* @param {{ kunde: string | { name?: string }, projekt: string, key: string, abrechnung: 'abo' | 'projekt', ticketNr?: string | number | null, anhang?: boolean }} p
|
||||||
|
* abrechnung: 'abo' (Einrichtung geht vom Stundenkonto ab, Block stundenkonto) oder 'projekt' (Teil der Projektkosten, Block projekt_kosten);
|
||||||
|
* ticketNr: Nummer des Einrichtungs-Tickets (mit oder ohne #), ohne sie steht kein Ticket in der Mail;
|
||||||
|
* anhang: Leistungsbeschreibung und Auftragsverarbeitung hängen als PDF an (Block anhang).
|
||||||
|
*/
|
||||||
|
export function k38Mail({ kunde, projekt, key, abrechnung, ticketNr = null, anhang = false }) {
|
||||||
|
const eintrag = funktion(key)
|
||||||
|
const nr = String(ticketNr ?? '').trim().replace(/^#/, '')
|
||||||
|
return {
|
||||||
|
template: 'K38',
|
||||||
|
values: {
|
||||||
|
kunde_name: kundenName(kunde),
|
||||||
|
projekt,
|
||||||
|
funktion: eintrag.mails.funktion,
|
||||||
|
funktion_akk: eintrag.mails.formen.akk,
|
||||||
|
einrichtung: katalogFelder(key).einrichtung,
|
||||||
|
// führendes Leerzeichen: steht direkt hinter dem Projektnamen, ohne Ticket bleibt nichts übrig
|
||||||
|
ticket: nr ? ` (Ticket #${nr})` : '',
|
||||||
|
fassung: unterlagenFuer(key).leistungsbeschreibung?.version ?? '',
|
||||||
|
portal_url: portalUrl(eintrag),
|
||||||
|
},
|
||||||
|
blocks: { stundenkonto: abrechnung === 'abo', projekt_kosten: abrechnung === 'projekt', anhang: anhang === true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* K39: Die Funktion wurde mit dem Abo beendet.
|
||||||
|
* @param {{ kunde: string | { name?: string }, projekt: string, key: string, loeschdatum: Date, art: string }} p
|
||||||
|
* loeschdatum: Tag, ab dem die Eingaben gelöscht werden; art: 'hosting' (das Abo ist ein Hosting-Abo), sonst (auch 'beendet') Abo beendet.
|
||||||
|
* Zusatzblöcke: termine nur beim Terminplaner, offene nur bei Webshop und Buchungskalender (unterkunft).
|
||||||
|
*/
|
||||||
|
export function k39Mail({ kunde, projekt, key, loeschdatum, art }) {
|
||||||
|
const eintrag = funktion(key)
|
||||||
|
return {
|
||||||
|
template: 'K39',
|
||||||
|
values: {
|
||||||
|
kunde_name: kundenName(kunde),
|
||||||
|
projekt,
|
||||||
|
funktion: eintrag.mails.funktion,
|
||||||
|
loeschtag: datum(loeschdatum, 'loeschdatum'),
|
||||||
|
portal_url: portalUrl(eintrag),
|
||||||
|
},
|
||||||
|
blocks: { abo_ende: art !== 'hosting', hosting: art === 'hosting', termine: key === 'termine', offene: key === 'shop' || key === 'unterkunft' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T24: Team-Hinweis, dass eine Funktion eingeschaltet wurde.
|
||||||
|
* @param {{ kunde: string | { name?: string }, email: string, projekt: string, key: string, customerId: string, projectId: string, woid: string,
|
||||||
|
* kontostand?: string, anhangFehlt?: boolean }} p
|
||||||
|
* kontostand: Text aus kontostandText(), nur gesetzt, wenn das Guthaben beim Einschalten nicht reicht (Block vorschuss);
|
||||||
|
* anhangFehlt: die Vertragsunterlagen sind nicht beim Kunden angekommen (Block anhang_fehlt).
|
||||||
|
*/
|
||||||
|
export function t24Mail({ kunde, email, projekt, key, customerId, projectId, woid, kontostand = '', anhangFehlt = false }) {
|
||||||
|
const eintrag = funktion(key)
|
||||||
|
return {
|
||||||
|
template: 'T24',
|
||||||
|
values: {
|
||||||
|
kunde_name: kundenName(kunde),
|
||||||
|
email: String(email ?? ''),
|
||||||
|
projekt,
|
||||||
|
funktion: eintrag.mails.funktion,
|
||||||
|
woid: String(woid ?? ''),
|
||||||
|
kontostand,
|
||||||
|
woms_url: anleitungsLink(customerId, projectId),
|
||||||
|
},
|
||||||
|
blocks: { vorschuss: kontostand !== '', anhang_fehlt: anhangFehlt === true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tage zwischen dem Abschalten und der Löschung der Eingaben: news 30, termine 30, shop und unterkunft aus der Konfiguration; sonst TypeError. */
|
||||||
|
export function loeschFristTage(key) {
|
||||||
|
if (key === 'news') return 30
|
||||||
|
if (key === 'termine') return LOESCHFRIST_TAGE
|
||||||
|
if (key === 'shop') return config.shop.loeschTageNachAbschalten
|
||||||
|
if (key === 'unterkunft') return config.unterkunft.loeschTageNachAbschalten
|
||||||
|
throw new TypeError(`Keine Löschfrist für die Funktion: ${String(key)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kontostand für T24, z. B. 'Guthaben 0 min, Vorschuss 90 min'. */
|
||||||
|
export const kontostandText = ({ balanceMinutes = 0, vorschussMinutes = 0 }) => `Guthaben ${balanceMinutes} min, Vorschuss ${vorschussMinutes} min`
|
||||||
@@ -8,6 +8,7 @@ import { datumKurz, wochentag } from '../termine/zeit.js'
|
|||||||
* K28 Feiertags-Erinnerung (eine Mail mit allen neuen Tagen, ohne BCC)
|
* K28 Feiertags-Erinnerung (eine Mail mit allen neuen Tagen, ohne BCC)
|
||||||
* K29 Wartungs-Abo beendet: Funktion läuft bis zur Frist, „für einmalig 30 € behalten“ (Varianten nach der Abo-Lage)
|
* K29 Wartungs-Abo beendet: Funktion läuft bis zur Frist, „für einmalig 30 € behalten“ (Varianten nach der Abo-Lage)
|
||||||
* K30 abgeschaltet: feste Zeiten auf der Seite, Eingaben noch bis zum Löschdatum
|
* K30 abgeschaltet: feste Zeiten auf der Seite, Eingaben noch bis zum Löschdatum
|
||||||
|
* K40, K41 dieselben Mails im Abo-Modell der Kundenansicht neu (k29Mail und k30Mail mit modell: true): ohne Preis und ohne Behalten
|
||||||
* Die Builder sind rein (kein Zugriff auf Appwrite, kein Versand) und liefern { template, values, blocks }, so wie
|
* Die Builder sind rein (kein Zugriff auf Appwrite, kein Versand) und liefern { template, values, blocks }, so wie
|
||||||
* siteFeatures.bestellMail: die Werte decken genau die Platzhalter der Vorlage ab. sendeZeitenMail verschickt sie.
|
* siteFeatures.bestellMail: die Werte decken genau die Platzhalter der Vorlage ab. sendeZeitenMail verschickt sie.
|
||||||
*
|
*
|
||||||
@@ -93,24 +94,33 @@ export function k28Mail({ kunde, projekt, tage }) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* K29: Das Wartungs-Abo ist beendet, die Funktion läuft noch bis zur Frist; für einmalig `preis` behalten.
|
* K29: Das Wartungs-Abo ist beendet, die Funktion läuft noch bis zur Frist; für einmalig `preis` behalten.
|
||||||
* @param {{ kunde: string | { name?: string }, projekt: string, art?: string, frist: Date, preis: string }} p
|
* @param {{ kunde: string | { name?: string }, projekt: string, art?: string, frist: Date, preis: string, modell?: boolean }} p
|
||||||
* art: Abo-Lage aus aboStatus.aboLage(): 'hosting' (das Abo ist ein Hosting-Abo), 'wechsel_widerrufen' (Wechsel ins Wartungs-Abo
|
* art: Abo-Lage aus aboStatus.aboLage(): 'hosting' (das Abo ist ein Hosting-Abo), 'wechsel_widerrufen' (Wechsel ins Wartungs-Abo
|
||||||
* widerrufen), sonst (auch 'beendet' und Unbekanntes) 'abo_ende'. frist: Tag des Abschaltens (aboEndeAt + 30 Tage).
|
* widerrufen), sonst (auch 'beendet' und Unbekanntes) 'abo_ende'. frist: Tag des Abschaltens (aboEndeAt + 30 Tage).
|
||||||
|
* modell: Abo-Modell der Kundenansicht neu (Task 10): Vorlage K40 ohne Preis und ohne Behalten, die Blöcke bleiben gleich.
|
||||||
*/
|
*/
|
||||||
export function k29Mail({ kunde, projekt, art, frist, preis }) {
|
export function k29Mail({ kunde, projekt, art, frist, preis, modell = false }) {
|
||||||
|
const blocks = { abo_ende: art !== 'hosting' && art !== 'wechsel_widerrufen', hosting: art === 'hosting', wechsel_widerrufen: art === 'wechsel_widerrufen' }
|
||||||
|
if (modell) {
|
||||||
|
return { template: 'K40', values: { kunde_name: kundenName(kunde), projekt, frist: datum(frist, 'frist'), portal_url: portalUrl() }, blocks }
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
template: 'K29',
|
template: 'K29',
|
||||||
values: { kunde_name: kundenName(kunde), projekt, frist: datum(frist, 'frist'), preis, portal_url: portalUrl() },
|
values: { kunde_name: kundenName(kunde), projekt, frist: datum(frist, 'frist'), preis, portal_url: portalUrl() },
|
||||||
blocks: { abo_ende: art !== 'hosting' && art !== 'wechsel_widerrufen', hosting: art === 'hosting', wechsel_widerrufen: art === 'wechsel_widerrufen' },
|
blocks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* K30: Öffnungszeiten live ist abgeschaltet (Wartungs-Abo beendet, Frist zum Behalten abgelaufen).
|
* K30: Öffnungszeiten live ist abgeschaltet (Wartungs-Abo beendet, Frist zum Behalten abgelaufen).
|
||||||
* @param {{ kunde: string | { name?: string }, projekt: string, loeschdatum: Date, preis: string }} p
|
* @param {{ kunde: string | { name?: string }, projekt: string, loeschdatum: Date, preis: string, modell?: boolean }} p
|
||||||
* loeschdatum: Tag, ab dem die Eingaben gelöscht werden (30 Tage nach dem Abschalten); preis: Preis zum Wiedereinschalten.
|
* loeschdatum: Tag, ab dem die Eingaben gelöscht werden (30 Tage nach dem Abschalten); preis: Preis zum Wiedereinschalten.
|
||||||
|
* modell: Abo-Modell der Kundenansicht neu (Task 10): Vorlage K41 ohne Preis.
|
||||||
*/
|
*/
|
||||||
export function k30Mail({ kunde, projekt, loeschdatum, preis }) {
|
export function k30Mail({ kunde, projekt, loeschdatum, preis, modell = false }) {
|
||||||
|
if (modell) {
|
||||||
|
return { template: 'K41', values: { kunde_name: kundenName(kunde), projekt, loeschdatum: datum(loeschdatum, 'loeschdatum'), portal_url: portalUrl() } }
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
template: 'K30',
|
template: 'K30',
|
||||||
values: { kunde_name: kundenName(kunde), projekt, loeschdatum: datum(loeschdatum, 'loeschdatum'), preis, portal_url: portalUrl() },
|
values: { kunde_name: kundenName(kunde), projekt, loeschdatum: datum(loeschdatum, 'loeschdatum'), preis, portal_url: portalUrl() },
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { test, after } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||||
|
import { startFakeStripe } from './support/fakeStripe.js'
|
||||||
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
|
|
||||||
|
// Builder der Abo-Modell-Mails K38, K39, T24 (server/services/aboFunktionenMails.js) und die Zeiten-Varianten K40, K41
|
||||||
|
// (k29Mail und k30Mail mit modell: true). Die Vorlagen prüft aboFunktionenVorlagen.test.js, der Versand folgt in Task 6 und 8.
|
||||||
|
const aw = await startAppwriteMock()
|
||||||
|
const st = await startFakeStripe()
|
||||||
|
after(() => Promise.all([aw.close(), st.close()]))
|
||||||
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
|
const { config } = await import('../server/config.js')
|
||||||
|
const { renderMail } = await import('../server/services/mailTemplates.js')
|
||||||
|
const { anleitungsLink } = await import('../server/services/featureRegistry.js')
|
||||||
|
const { k38Mail, k39Mail, t24Mail, loeschFristTage, kontostandText } = await import('../server/services/aboFunktionenMails.js')
|
||||||
|
const { k29Mail, k30Mail } = await import('../server/services/zeiten/mails.js')
|
||||||
|
|
||||||
|
test('k38Mail: Werte und Blöcke je Abrechnung', () => {
|
||||||
|
const m = k38Mail({ kunde: { name: 'Silke Müller' }, projekt: 'Bäckerei', key: 'termine', abrechnung: 'abo', ticketNr: '#100150', anhang: true })
|
||||||
|
assert.equal(m.template, 'K38'); assert.deepEqual(m.blocks, { stundenkonto: true, projekt_kosten: false, anhang: true })
|
||||||
|
assert.deepEqual(m.values, { kunde_name: 'Silke Müller', projekt: 'Bäckerei', funktion: 'Terminplaner', funktion_akk: 'den Terminplaner', einrichtung: 'meist in 1–2 Wochen', ticket: ' (Ticket #100150)', fassung: config.features.termine.leistungsbeschreibung.version, portal_url: `${config.publicBaseUrl}/dashboard.html#termine` })
|
||||||
|
assert.deepEqual(k38Mail({ kunde: 'K', projekt: 'P', key: 'shop', abrechnung: 'projekt' }).blocks, { stundenkonto: false, projekt_kosten: true, anhang: false })
|
||||||
|
assert.equal(k38Mail({ kunde: 'K', projekt: 'P', key: 'news', abrechnung: 'abo' }).values.ticket, '')
|
||||||
|
})
|
||||||
|
test('k39Mail: Frist, Grund und Zusatzblöcke je Funktion', () => {
|
||||||
|
const ende = new Date('2026-11-16T12:00:00.000Z')
|
||||||
|
assert.deepEqual(k39Mail({ kunde: 'K', projekt: 'P', key: 'termine', loeschdatum: ende, art: 'beendet' }).blocks, { abo_ende: true, hosting: false, termine: true, offene: false })
|
||||||
|
assert.deepEqual(k39Mail({ kunde: 'K', projekt: 'P', key: 'shop', loeschdatum: ende, art: 'hosting' }).blocks, { abo_ende: false, hosting: true, termine: false, offene: true })
|
||||||
|
assert.equal(k39Mail({ kunde: 'K', projekt: 'P', key: 'news', loeschdatum: ende, art: 'x' }).values.loeschtag, '16.11.2026')
|
||||||
|
assert.throws(() => k39Mail({ kunde: 'K', projekt: 'P', key: 'news', loeschdatum: 'morgen', art: 'x' }), TypeError)
|
||||||
|
})
|
||||||
|
test('t24Mail, loeschFristTage, kontostandText', () => {
|
||||||
|
const t = t24Mail({ kunde: 'Silke', email: 'k@example.invalid', projekt: 'P', key: 'news', customerId: 'c1', projectId: 'p1', woid: '100150', kontostand: kontostandText({ balanceMinutes: 0, vorschussMinutes: 90 }) })
|
||||||
|
assert.deepEqual([t.template, t.values.kontostand, t.blocks, t.values.woms_url], ['T24', 'Guthaben 0 min, Vorschuss 90 min', { vorschuss: true, anhang_fehlt: false }, anleitungsLink('c1', 'p1')])
|
||||||
|
assert.equal(t24Mail({ kunde: 'K', email: '', projekt: 'P', key: 'news', customerId: 'c1', projectId: 'p1', woid: '' }).blocks.vorschuss, false)
|
||||||
|
assert.deepEqual(['news', 'termine', 'shop', 'unterkunft'].map(loeschFristTage), [30, 30, 90, 90]); assert.throws(() => loeschFristTage('zeiten'), TypeError)
|
||||||
|
})
|
||||||
|
test('alle Builder-Ergebnisse rendern ohne Probleme und ohne €; Zeiten-Varianten', () => {
|
||||||
|
const bau = [k38Mail({ kunde: 'K', projekt: 'P', key: 'unterkunft', abrechnung: 'abo' }), k39Mail({ kunde: 'K', projekt: 'P', key: 'termine', loeschdatum: new Date(), art: 'x' }), t24Mail({ kunde: 'K', email: 'e', projekt: 'P', key: 'shop', customerId: 'c', projectId: 'p', woid: '1' })]
|
||||||
|
for (const m of bau) { const r = renderMail(m.template, m.values, m.blocks); assert.deepEqual(r.problems, []); assert.doesNotMatch(r.text, /€/) }
|
||||||
|
const frist = new Date('2026-11-16T12:00:00.000Z')
|
||||||
|
const k40 = k29Mail({ kunde: 'K', projekt: 'P', art: 'hosting', frist, preis: '30 €', modell: true }); assert.equal(k40.template, 'K40'); assert.deepEqual(Object.keys(k40.values).sort(), ['frist', 'kunde_name', 'portal_url', 'projekt']); assert.equal(k40.blocks.hosting, true)
|
||||||
|
const k41 = k30Mail({ kunde: 'K', projekt: 'P', loeschdatum: frist, preis: '30 €', modell: true }); assert.equal(k41.template, 'K41'); assert.equal(renderMail('K41', k41.values, k41.blocks).problems.length, 0)
|
||||||
|
assert.equal(k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist, preis: '30 €' }).template, 'K29') // Standard unverändert
|
||||||
|
})
|
||||||
Reference in new issue
Block a user