Merge auftraege/l5-bestaetigung: Bestätigung und Mails (Strang L5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
5d5f6333a4
21 files changed
+2040
-30
No files matched your search
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Bestätigen und Widerrufen einer Auftragsbestätigung (Übersicht V2/V4, Strang L5): idempotent, unter der Sperre `sperreAuftrag(id)`,
|
||||
* Stand innerhalb der Sperre neu gelesen. K32 und T22 gehen erst nach der Freigabe der Sperre raus; Mailfehler stehen
|
||||
* nur im Protokoll. Die erste Bestätigung zählt, jede weitere ändert nichts.
|
||||
*/
|
||||
import { withdrawalDeadline } from '../deadlines.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { getAuftrag, updateAuftrag } from './auftragStore.js'
|
||||
import { AuftragError } from './fehler.js'
|
||||
import { sperreAuftrag } from './konstanten.js'
|
||||
import { sendeBestaetigtMails } from './mails.js'
|
||||
import { FRUEHBEGINN_FASSUNG } from './texte.js'
|
||||
|
||||
export const GESPERRT_MELDUNG = {
|
||||
ersetzt: 'Diese Auftragsbestätigung wurde ersetzt.',
|
||||
storniert: 'Diese Auftragsbestätigung wurde storniert.',
|
||||
widerrufen: 'Diese Auftragsbestätigung wurde widerrufen.',
|
||||
}
|
||||
const NICHT_BESTAETIGBAR = 'Diese Auftragsbestätigung kann nicht bestätigt werden.'
|
||||
const NICHT_GEFUNDEN = 'Auftragsbestätigung nicht gefunden.'
|
||||
const GESPERRT_STATUS = ['entwurf', 'ersetzt', 'storniert', 'widerrufen']
|
||||
|
||||
/** 'offen' (bestätigbar), 'bestaetigt' (schon bestätigt) oder 'gesperrt' (Entwurf, ersetzt, storniert, widerrufen). */
|
||||
export function bestaetigungsLage(auftrag) {
|
||||
if (GESPERRT_STATUS.includes(auftrag.status)) return 'gesperrt'
|
||||
return auftrag.bestaetigtAm || auftrag.status === 'bestaetigt' ? 'bestaetigt' : 'offen'
|
||||
}
|
||||
|
||||
function pruefeZeitpunkt(jetzt) {
|
||||
if (!(jetzt instanceof Date) || Number.isNaN(jetzt.getTime())) throw new TypeError('jetzt muss ein gültiges Date sein')
|
||||
}
|
||||
|
||||
export async function bestaetigeAuftrag(auftrag, { ueber, fruehbeginn, jetzt = new Date() } = {}) {
|
||||
// synchron vor der Sperre: der zuerst gestartete Aufruf bekommt die Sperre zuerst
|
||||
if (ueber !== 'link' && ueber !== 'portal') throw new TypeError('bestaetigeAuftrag: ueber muss „link“ oder „portal“ sein')
|
||||
pruefeZeitpunkt(jetzt)
|
||||
const { aktuell, bestaetigtJetzt } = await withLocks(sperreAuftrag(auftrag.id), async () => {
|
||||
const frisch = await getAuftrag(auftrag.id)
|
||||
if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN)
|
||||
const lage = bestaetigungsLage(frisch)
|
||||
if (lage === 'gesperrt') throw new AuftragError(409, GESPERRT_MELDUNG[frisch.status] ?? NICHT_BESTAETIGBAR)
|
||||
if (lage === 'bestaetigt') return { aktuell: frisch, bestaetigtJetzt: false }
|
||||
const privat = frisch.kunde?.kundenart === 'privat'
|
||||
const patch = {
|
||||
bestaetigtAm: jetzt.toISOString(),
|
||||
bestaetigtUeber: ueber,
|
||||
bestaetigtPdfSha256: frisch.pdfSha256,
|
||||
fruehbeginn: privat && fruehbeginn === true,
|
||||
fruehbeginnFassung: privat ? FRUEHBEGINN_FASSUNG : '',
|
||||
}
|
||||
if (frisch.status === 'versendet') patch.status = 'bestaetigt'
|
||||
return { aktuell: await updateAuftrag(frisch.id, patch), bestaetigtJetzt: true }
|
||||
})
|
||||
if (bestaetigtJetzt) await sendeBestaetigtMails(aktuell)
|
||||
return aktuell
|
||||
}
|
||||
|
||||
/**
|
||||
* Widerruf einer Privatperson innerhalb der Frist (V2: versendet|bestaetigt → widerrufen), unter derselben Sperre.
|
||||
* Verschickt selbst keine Mails (das tut abo.js nach der Rückkehr, also nach der Freigabe der Sperre).
|
||||
* Ergebnis: 'widerrufen' | 'bereits_widerrufen' | 'kein_widerrufsrecht' | 'nach_frist' | 'nicht_widerrufbar'.
|
||||
*/
|
||||
export async function widerrufeAuftrag(auftrag, { jetzt = new Date() } = {}) {
|
||||
pruefeZeitpunkt(jetzt)
|
||||
return withLocks(sperreAuftrag(auftrag.id), async () => {
|
||||
const frisch = await getAuftrag(auftrag.id)
|
||||
if (!frisch) throw new AuftragError(404, NICHT_GEFUNDEN)
|
||||
const fristEnde = frisch.gesendetAm ? withdrawalDeadline(new Date(frisch.gesendetAm)).endsAt : null
|
||||
const ergebnis = (e, a = frisch) => ({ auftrag: a, ergebnis: e, fristEnde })
|
||||
if (frisch.status === 'widerrufen') return ergebnis('bereits_widerrufen')
|
||||
if (frisch.kunde?.kundenart !== 'privat') return ergebnis('kein_widerrufsrecht')
|
||||
if (!['versendet', 'bestaetigt'].includes(frisch.status) || fristEnde === null) return ergebnis('nicht_widerrufbar')
|
||||
if (jetzt.getTime() > fristEnde.getTime()) return ergebnis('nach_frist')
|
||||
return ergebnis('widerrufen', await updateAuftrag(frisch.id, { status: 'widerrufen', widerrufenAm: jetzt.toISOString() }))
|
||||
})
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Bestätigungs-Token einer Auftragsbestätigung (Übersicht V4 „L5“): 32 Zufallsbytes als base64url (43 Zeichen),
|
||||
* gespeichert wird nur der SHA-256-Hash in Hex. Gültig 60 Tage ab dem letzten Versand.
|
||||
* Importiert bewusst nur node:crypto und konstanten.js (nie server/config.js).
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { TOKEN_GUELTIG_TAGE } from './konstanten.js'
|
||||
|
||||
const TAG_MS = 86_400_000
|
||||
const TOKEN_FORMAT = /^[A-Za-z0-9_-]{43}$/
|
||||
|
||||
const sha256Hex = (text) => createHash('sha256').update(text).digest('hex')
|
||||
|
||||
export function neuerToken(jetzt = new Date()) {
|
||||
if (!(jetzt instanceof Date) || !Number.isFinite(jetzt.getTime())) throw new TypeError('neuerToken: jetzt muss ein gültiges Date sein')
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
return { token, hash: sha256Hex(token), gueltigBis: new Date(jetzt.getTime() + TOKEN_GUELTIG_TAGE * TAG_MS) }
|
||||
}
|
||||
|
||||
/** Hash eines Tokens aus einem Link; `null`, wenn der Text nicht das Format eines Tokens hat. */
|
||||
export function hashToken(token) {
|
||||
return typeof token === 'string' && TOKEN_FORMAT.test(token) ? sha256Hex(token) : null
|
||||
}
|
||||
Reference in new issue
Block a user