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:
1 parent
f0402a7ea9
commit
19abac0373
3 files changed
+409
No files matched your search
@@ -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}`)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user