Aufträge L5: Mails zur Auftragsbestätigung (K31 ohne BCC mit Teamkopie ohne Link, K32, T22)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:13:46 +00:00
1 parent f0402a7ea9
commit 19abac0373
3 files changed
+409

No files matched your search

+161
View File
@@ -0,0 +1,161 @@
/**
* Mails zur Auftragsbestätigung (Übersicht V4/V6, Strang L5): K31 (AB mit PDF und AGB an den Kunden, Teamkopie ohne
* Link), K32 (Eingangsbestätigung an den Kunden) und T22 (Hinweis ans Team). Der Bestätigungslink steht nur in der
* Mail an den Kunden: K31 geht ohne BCC raus, das Team bekommt eine Kopie ohne Link, Token und Anhänge.
* Kein Datenbankzugriff (außer lesePdf als Rückfall für die AB-PDF).
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from '../../config.js'
import { berlinDate, formatDe } from '../finance/dates.js'
import { euroDe } from '../finance/format.js'
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
import { lesePdf } from './ablage.js'
import { fruehesterBeginn } from './auftragSicht.js'
import { AGB_FASSUNG } from './konstanten.js'
import { FRUEHBEGINN_TEXT } from './texte.js'
import { hashToken } from './token.js'
// server/services/auftraege/ -> drei Ebenen nach oben = Repo
const STANDARD_AGB_VERZEICHNIS = fileURLToPath(new URL('../../../public/rechtliches/', import.meta.url))
let agbVerzeichnis = STANDARD_AGB_VERZEICHNIS
const FASSUNG_FORMAT = /^[A-Za-z0-9._-]{1,20}$/
/** Nur für Tests: anderes Verzeichnis für die AGB-PDFs (`null` = Vorgabe). */
export function setzeAgbVerzeichnisFuerTests(verzeichnis) {
agbVerzeichnis = verzeichnis || STANDARD_AGB_VERZEICHNIS
}
export function agbPdfPfad(fassung = AGB_FASSUNG) {
if (typeof fassung !== 'string' || !FASSUNG_FORMAT.test(fassung) || fassung.includes('..')) throw new TypeError('agbPdfPfad: ungültige AGB-Fassung')
return path.join(agbVerzeichnis, `agb-${fassung}.pdf`)
}
const zeitFormat = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
function zeitpunkt(iso) {
const datum = new Date(iso)
if (!iso || Number.isNaN(datum.getTime())) throw new TypeError('Zeitpunkt fehlt oder ist ungültig')
return datum
}
const tagText = (iso) => formatDe(berlinDate(zeitpunkt(iso)))
const zeitpunktText = (iso) => `${tagText(iso)}, ${zeitFormat.format(zeitpunkt(iso))}`
const preisText = (auftrag) => (auftrag.art === 'aufwand' ? `${euroDe(auftrag.stundensatzCents)} pro Stunde` : euroDe(auftrag.summeCents))
const empfaenger = (auftrag) => String(auftrag.gesendetAn || auftrag.kunde?.email || '').trim()
export function k31Mail(auftrag, { token, erinnerung = false, teamkopie = false } = {}) {
const kunde = auftrag.kunde ?? {}
return {
template: 'K31',
values: {
name: kunde.name,
nummer: auftrag.nummer,
titel: auftrag.titel,
datum: tagText(auftrag.gesendetAm),
betrag: preisText(auftrag),
// die Teamkopie enthält den Link nie, auch wenn ein Token mitgegeben wird
link: teamkopie ? '' : `${config.publicBaseUrl}/auftrag.html#t=${token}`,
gueltig_bis: tagText(auftrag.tokenGueltigBis),
},
blocks: {
erinnerung: Boolean(erinnerung),
privat: kunde.kundenart === 'privat' && !teamkopie,
festpreis: auftrag.art === 'festpreis',
aufwand: auftrag.art === 'aufwand',
teamkopie: Boolean(teamkopie),
kundenmail: !teamkopie,
},
}
}
function frueherBeginn(auftrag) {
const privat = auftrag.kunde?.kundenart === 'privat'
const tag = fruehesterBeginn(auftrag)
return { privat, fruehbeginn: privat && Boolean(auftrag.fruehbeginn), ohne: privat && !auftrag.fruehbeginn, beginnText: tag ? formatDe(tag) : '' }
}
export function k32Mail(auftrag) {
const kunde = auftrag.kunde ?? {}
const f = frueherBeginn(auftrag)
return {
template: 'K32',
values: {
name: kunde.name,
nummer: auftrag.nummer,
titel: auftrag.titel,
zeitpunkt: zeitpunktText(auftrag.bestaetigtAm),
fruehester_beginn: f.beginnText,
fruehbeginn_text: f.fruehbeginn ? FRUEHBEGINN_TEXT : '',
},
blocks: { fruehbeginn: f.fruehbeginn, ohne_fruehbeginn: f.ohne },
}
}
const UEBER = { link: 'Bestätigungslink', portal: 'Kundenportal' }
export function t22Mail(auftrag) {
const kunde = auftrag.kunde ?? {}
const f = frueherBeginn(auftrag)
return {
template: 'T22',
values: {
kunde: kunde.firma || kunde.name || kunde.email || '',
nummer: auftrag.nummer,
titel: auftrag.titel,
zeitpunkt: zeitpunktText(auftrag.bestaetigtAm),
ueber: UEBER[auftrag.bestaetigtUeber] ?? '',
woms_link: `${config.womsBaseUrl}/customers/${auftrag.kundenId}?tab=invoices`,
fruehester_beginn: f.beginnText,
},
blocks: { privat_fruehbeginn: f.fruehbeginn, privat_ohne_fruehbeginn: f.ohne },
}
}
/**
* K31 an den Kunden (ohne BCC, mit AB-PDF und AGB), danach die Teamkopie ohne Link und Anhänge. Wirft bei jedem Fehler
* der Kundenmail (L3 setzt dann mailStatus „fehler“); eine gescheiterte Teamkopie gilt nicht als Fehler.
*/
export async function sendeAuftragMail(auftrag, { pdf, token, erinnerung = false } = {}) {
const adresse = empfaenger(auftrag)
if (!adresse) throw new Error('Keine E-Mail-Adresse für die Auftragsbestätigung')
if (hashToken(token) === null) throw new TypeError('sendeAuftragMail: token fehlt oder hat ein falsches Format')
const abPdf = Buffer.isBuffer(pdf) ? pdf : await lesePdf(auftrag.pdfDatei)
const fassung = auftrag.agbFassung || AGB_FASSUNG
let agbPdf
try {
agbPdf = await readFile(agbPdfPfad(fassung))
} catch (err) {
throw new Error(`AGB-PDF ${fassung} nicht lesbar: ${err.code || err.message}`)
}
await sendCustomerMail(adresse, {
...k31Mail(auftrag, { token, erinnerung }),
attachments: [
{ filename: `${auftrag.nummer}.pdf`, content: abPdf, contentType: 'application/pdf' },
{ filename: `WEBklar-AGB-${fassung}.pdf`, content: agbPdf, contentType: 'application/pdf' },
],
bccTeam: false,
})
const kopie = await sendAdminNotice(k31Mail(auftrag, { erinnerung, teamkopie: true }))
if (!kopie.sent) console.error(`[auftraege] Teamkopie der Auftragsbestätigung ${auftrag.nummer} nicht gesendet: ${kopie.reason}`)
}
/** K32 an den Kunden (Kopie ans Team wie üblich: die Mail trägt keinen Link), danach T22 ans Team. Wirft nie. */
export async function sendeBestaetigtMails(auftrag) {
const adresse = empfaenger(auftrag)
if (!adresse) {
console.error(`[auftraege] K32 für ${auftrag.nummer} entfällt: keine E-Mail-Adresse`)
} else {
try {
await sendCustomerMail(adresse, k32Mail(auftrag))
} catch (err) {
console.error(`[auftraege] K32 für ${auftrag.nummer} fehlgeschlagen: ${err.message}`)
}
}
try {
const t22 = await sendAdminNotice(t22Mail(auftrag))
if (!t22.sent) console.error(`[auftraege] T22 für ${auftrag.nummer} nicht gesendet: ${t22.reason}`)
} catch (err) {
console.error(`[auftraege] T22 für ${auftrag.nummer} fehlgeschlagen: ${err.message}`)
}
}