Abo-Angebote: Mail-Status in der Liste und E-Mail erneut senden
Bisher sah man nirgends, ob die Angebots-Mail rausging, Fehler landeten nicht im Log, und senden ließ sie sich nur beim Anlegen. - Angebotsliste: Kunden-Adresse unter dem Namen, im Status "E-Mail gesendet <Zeitpunkt>" bzw. "E-Mail nicht verschickt" / "Keine E-Mail-Adresse hinterlegt" - Knopf "E-Mail senden" / "Erneut senden" mit Rückfrage (Adresse, letzter Versand), solange das Angebot offen und gültig ist; bei laufendem Abo, solange die erste rabattierte Abbuchung noch bevorsteht (die Mail nennt sie). POST /api/portal-admin/offers/:id/notify setzt notifiedAt. - Server-Log: "[offers] Angebots-Mail <Ref> vom Mailserver angenommen: <Message-ID> 250 … queued as …" bzw. "… fehlgeschlagen: <Grund>" - Lehnt der Mailserver nur den Kunden ab (die BCC-Kopie ans Team geht trotzdem raus), gilt die Mail als nicht verschickt - sendCustomerMail gibt die nodemailer-Info zurück Tests: npm test (40), Server-Durchlauf gegen Appwrite-Mock, SMTP-Sink und Stripe-Double (28 Prüfungen), Oberfläche (18 Prüfungen). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81119c19bc
commit
32e441c8bc
5 files changed
+161
-18
No files matched your search
+10
-1
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||
import { adminOfferView, createOffer, listOffers, offerContext, withdrawOffer } from '../services/offers.js'
|
||||
import { adminOfferView, createOffer, listOffers, offerContext, resendOfferMail, withdrawOffer } from '../services/offers.js'
|
||||
|
||||
/**
|
||||
* Individuelle Abo-Angebote im Admin-Panel (Tab "Abo-Angebote").
|
||||
@@ -67,3 +67,12 @@ offersAdminRouter.post('/:id/withdraw', async (req, res) => {
|
||||
return sendError(res, err, 'Angebot konnte nicht zurückgezogen werden')
|
||||
}
|
||||
})
|
||||
|
||||
offersAdminRouter.post('/:id/notify', async (req, res) => {
|
||||
try {
|
||||
const doc = await resendOfferMail(req.params.id)
|
||||
return res.json({ sent: true, notifiedAt: doc.notifiedAt })
|
||||
} catch (err) {
|
||||
return sendError(res, err, 'E-Mail konnte nicht verschickt werden')
|
||||
}
|
||||
})
|
||||
@@ -80,11 +80,12 @@ export async function sendAdminNotice(subject, lines) {
|
||||
/**
|
||||
* Mail an Kunden. `attachments` fuer die Vertragsbestaetigung auf dauerhaftem
|
||||
* Datentraeger (§ 312f BGB): Bedingungen als HTML-Anhang.
|
||||
* Ergebnis: nodemailer-Info (messageId, Antwort des Mailservers, abgelehnte Empfaenger).
|
||||
*/
|
||||
export async function sendCustomerMail(to, subject, lines, attachments = []) {
|
||||
const mailer = getTransporter()
|
||||
if (!mailer) throw new Error('SMTP nicht konfiguriert')
|
||||
await mailer.sendMail({
|
||||
return mailer.sendMail({
|
||||
from: `"Webklar" <${config.smtp.from}>`,
|
||||
to,
|
||||
bcc: config.chatNotify.emails.join(', ') || undefined,
|
||||
|
||||
+61
-12
@@ -107,17 +107,27 @@ function stripeDashboardUrl(kind, id) {
|
||||
* Testkonten bekommen den Coupon erst nach dem Abschluss (activateOfferFromCheckout), wie ein laufendes Abo.
|
||||
*/
|
||||
export function adminOfferView(doc, customer, sub = null) {
|
||||
const view = offerView(doc)
|
||||
const testAccount = isTestEmail(customer?.email)
|
||||
const attachedLater = doc.scope === 'laufend' || testAccount
|
||||
const discount = doc.status === OFFER_STATUS.active
|
||||
? redeemedDiscount({ months: doc.months, appliedAt: doc.appliedAt, attachedLater }, sub)
|
||||
: null
|
||||
// Angebots-Mail (erneut) senden: solange das Angebot offen und gueltig ist, bei laufendem Abo solange
|
||||
// die erste rabattierte Abbuchung noch bevorsteht (die Mail nennt sie als "naechste Abbuchung")
|
||||
const mailSendable = Boolean(customer?.email) && (
|
||||
view.status === OFFER_STATUS.open ||
|
||||
(doc.status === OFFER_STATUS.active && doc.scope === 'laufend' && Boolean(discount?.known) && new Date(discount.firstChargeAt) > new Date())
|
||||
)
|
||||
return {
|
||||
...offerView(doc),
|
||||
...view,
|
||||
testAccount,
|
||||
discount: doc.status === OFFER_STATUS.active
|
||||
? redeemedDiscount({ months: doc.months, appliedAt: doc.appliedAt, attachedLater }, sub)
|
||||
: null,
|
||||
discount,
|
||||
mailSendable,
|
||||
customerId: doc.customerId,
|
||||
customerName: customer?.name || customer?.companyName || '(Kunde nicht gefunden)',
|
||||
customerCode: customer?.code || '',
|
||||
customerEmail: customer?.email || '',
|
||||
internalNote: doc.internalNote || '',
|
||||
createdBy: doc.createdBy || '',
|
||||
notifiedAt: doc.notifiedAt || null,
|
||||
@@ -239,13 +249,18 @@ export function validateOfferInput(body) {
|
||||
}
|
||||
}
|
||||
|
||||
async function mailOffer(doc, customer, sub) {
|
||||
/**
|
||||
* Angebots-Mail an den Kunden (Kopie per BCC ans Team). nextChargeAt: bei laufendem Abo die erste
|
||||
* rabattierte Abbuchung. Wirft mit Grund, wenn der Mailserver die Mail oder den Kunden als Empfaenger
|
||||
* nicht annimmt; Ergebnis und Fehler stehen im Server-Log.
|
||||
*/
|
||||
async function mailOffer(doc, customer, nextChargeAt = null) {
|
||||
const running = doc.scope === 'laufend'
|
||||
const lines = [
|
||||
`Guten Tag ${customer.name || ''}`.trim() + ',',
|
||||
'',
|
||||
running
|
||||
? `wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung${sub?.currentPeriodEnd ? ` am ${fmtDate(sub.currentPeriodEnd)}` : ''}:`
|
||||
? `wie besprochen gilt für Ihr WEBklar Wartungs-Abo ab der nächsten Abbuchung${nextChargeAt ? ` am ${fmtDate(nextChargeAt)}` : ''}:`
|
||||
: 'wie besprochen haben wir Ihnen im Kundenportal ein persönliches Angebot für das WEBklar Wartungs-Abo hinterlegt:',
|
||||
'',
|
||||
` Angebot: ${offerText(doc)}`,
|
||||
@@ -268,11 +283,23 @@ async function mailOffer(doc, customer, sub) {
|
||||
'',
|
||||
FOOTER,
|
||||
]
|
||||
await sendCustomerMail(
|
||||
customer.email,
|
||||
running ? 'Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet' : 'Ihr persönliches Angebot für das WEBklar Wartungs-Abo',
|
||||
lines
|
||||
)
|
||||
const ref = shortRef(doc.$id)
|
||||
try {
|
||||
const info = await sendCustomerMail(
|
||||
customer.email,
|
||||
running ? 'Ihr Angebot für das WEBklar Wartungs-Abo ist eingerichtet' : 'Ihr persönliches Angebot für das WEBklar Wartungs-Abo',
|
||||
lines
|
||||
)
|
||||
// Lehnt der Mailserver nur den Kunden ab, geht die BCC-Kopie trotzdem raus und sendMail meldet Erfolg
|
||||
const to = String(customer.email).toLowerCase()
|
||||
if ((info?.rejected || []).some((r) => String(r?.address || r).toLowerCase() === to)) {
|
||||
throw new Error(`Mailserver hat die Adresse abgelehnt (${info.rejectedErrors?.[0]?.response || info.response || 'ohne Antwort'})`)
|
||||
}
|
||||
console.log(`[offers] Angebots-Mail ${ref} vom Mailserver angenommen: ${info?.messageId || ''} ${info?.response || ''}`)
|
||||
} catch (err) {
|
||||
console.error(`[offers] Angebots-Mail ${ref} fehlgeschlagen:`, err.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -345,7 +372,7 @@ export async function createOffer(body, admin) {
|
||||
if (!customer.email) {
|
||||
mail = { sent: false, reason: 'Kunde hat keine E-Mail-Adresse' }
|
||||
} else {
|
||||
mail = await mailOffer(doc, customer, sub).then(
|
||||
mail = await mailOffer(doc, customer, running ? sub.currentPeriodEnd : null).then(
|
||||
() => ({ sent: true }),
|
||||
(e) => ({ sent: false, reason: e.message })
|
||||
)
|
||||
@@ -374,6 +401,28 @@ export async function withdrawOffer(id, admin) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Angebots-Mail (erneut) senden, z. B. wenn sie beim Kunden nicht angekommen ist. */
|
||||
export async function resendOfferMail(id) {
|
||||
const doc = await getDocument(COLLECTION(), id).catch(() => null)
|
||||
if (!doc) throw httpError(404, 'Angebot nicht gefunden')
|
||||
const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null)
|
||||
if (!customer) throw httpError(404, 'Kunde nicht gefunden')
|
||||
if (!customer.email) throw httpError(400, 'Beim Kunden ist keine E-Mail-Adresse hinterlegt.')
|
||||
const sub = doc.stripeSubscriptionId
|
||||
? await getDocument(config.collections.subscriptions, doc.stripeSubscriptionId).catch(() => null)
|
||||
: null
|
||||
const { mailSendable, discount } = adminOfferView(doc, customer, sub)
|
||||
if (!mailSendable) {
|
||||
throw httpError(409, 'Für dieses Angebot geht keine E-Mail mehr raus: Es ist abgelaufen, zurückgezogen oder schon eingelöst.')
|
||||
}
|
||||
try {
|
||||
await mailOffer(doc, customer, doc.scope === 'laufend' ? discount.firstChargeAt : null)
|
||||
} catch (err) {
|
||||
throw httpError(502, `E-Mail nicht verschickt: ${err.message}`)
|
||||
}
|
||||
return updateDocument(COLLECTION(), id, { notifiedAt: new Date().toISOString() })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checkout abgeschlossen (Stripe-Webhook)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user