Webshop B2: Team-Entscheidung über die Anfrage, Übersicht /api/team/shop und Kundenmail K24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
608ff61d3d
commit
0fb84021f8
6 files changed
+512
-4
No files matched your search
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K24 · Webshop-Anfrage entschieden (Annahme oder Ablehnung)
|
||||
Betreff: Deine Webshop-Anfrage für {{projekt}}
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{preis}}, {{ust_hinweis}}, {{begruendung}}, {{portal_url}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angenommen, abgelehnt
|
||||
Genau einer der Blöcke ist an. angenommen = Anfrage angenommen, der Kunde kann den Webshop jetzt bestellen; abgelehnt = Anfrage abgelehnt, mit Begründung des Teams.
|
||||
preis = Preis der Einrichtung, z. B. „299 €“; ust_hinweis = Umsatzsteuerhinweis zum Preis; portal_url = Webshop-Seite des Projekts im Kundenportal.
|
||||
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Deine Webshop-Anfrage für {{projekt}}</title>
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||
<style>
|
||||
a{color:#111111;}
|
||||
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Webshop-Anfrage wurde bearbeitet. ͏ ͏ ͏</span>
|
||||
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Webshop</td>
|
||||
</tr></table></td></tr>
|
||||
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Webshop</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;"><!-- WENN angenommen -->Deine Webshop-Anfrage wurde angenommen<!-- ENDE angenommen --><!-- WENN abgelehnt -->Zu Deiner Webshop-Anfrage<!-- ENDE abgelehnt --></td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br><!-- WENN angenommen -->wir haben Deine Webshop-Anfrage für {{projekt}} angenommen. Du kannst den Webshop jetzt im Kundenportal bestellen; danach richten wir ihn ein.<!-- ENDE angenommen --><!-- WENN abgelehnt -->leider können wir Deine Webshop-Anfrage für {{projekt}} nicht annehmen. Den Grund findest Du unten. Wenn Du Fragen hast, antworte einfach auf diese E-Mail.<!-- ENDE abgelehnt --></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;"><strong>{{projekt}}</strong></td></tr><!-- WENN angenommen --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-top:1px solid #ecebe6;color:#6b6964;">Preis</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-top:1px solid #ecebe6;color:#111111;">einmalig {{preis}} ({{ust_hinweis}})</td></tr><!-- ENDE angenommen --><!-- WENN abgelehnt --><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-top:1px solid #ecebe6;color:#6b6964;">Begründung</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-top:1px solid #ecebe6;color:#111111;">{{begruendung}}</td></tr><!-- ENDE abgelehnt --></table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;"><!-- WENN angenommen -->Webshop bestellen<!-- ENDE angenommen --><!-- WENN abgelehnt -->Zum Kundenportal<!-- ENDE abgelehnt --> →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
|
||||
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,6 +8,7 @@ import { takeoverAdminRouter } from '../takeoverCredits.js'
|
||||
import customersRoutes from './customers.js'
|
||||
import filesRoutes from './files.js'
|
||||
import previewRoutes from './preview.js'
|
||||
import shopTeamRoutes from './shop.js'
|
||||
import termineTeamRoutes from './termine.js'
|
||||
|
||||
// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem)
|
||||
@@ -25,5 +26,7 @@ router.use('/storage-tasks', storageTasksAdminRouter)
|
||||
router.use('/funktionen', funktionenAdminRouter)
|
||||
// Terminplaner-Betrieb (Status fuer Budget, Warteschlange, Anmeldepause, Tagesjob)
|
||||
router.use('/termine', termineTeamRoutes)
|
||||
// Webshop: Stand je Projekt und Entscheidung ueber die Anfrage
|
||||
router.use('/shop', shopTeamRoutes)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { Query, countDocuments, listDocuments } from '../../services/appwriteAdmin.js'
|
||||
import { anfrageAnsicht, entscheideAnfrage, leseShopConfig } from '../../services/shop/anfrage.js'
|
||||
import { listCustomerFeatureDocs } from '../../services/siteFeatures.js'
|
||||
|
||||
/**
|
||||
* Webshop im Ticketsystem (Kunden-Reiter "Funktionen"): Stand je Projekt und Entscheidung ueber die Anfrage.
|
||||
* Eingehaengt in routes/team/index.js unter /api/team/shop, dort hinter requireStaffJwt.
|
||||
*/
|
||||
const router = Router()
|
||||
|
||||
const sendError = (res, err) => {
|
||||
const status = err.status >= 400 && err.status < 600 ? err.status : 500
|
||||
if (status >= 500) console.error('[team-shop]', err.message)
|
||||
return res.status(status).json({ error: err.message || 'Interner Serverfehler', ...(err.body?.code ? { code: err.body.code } : {}) })
|
||||
}
|
||||
const route = (fn) => (req, res) => fn(req, res).catch((err) => sendError(res, err))
|
||||
const person = (req) => ({ name: req.session.adminName || req.session.name || '', email: req.session.email || '' })
|
||||
|
||||
/** Zaehler oder null, wenn die Abfrage scheitert (die Uebersicht bleibt lesbar). */
|
||||
async function zaehle(collection, queries, was) {
|
||||
try {
|
||||
return await countDocuments(config.collections[collection], queries)
|
||||
} catch (err) {
|
||||
console.warn(`[team-shop] ${was} nicht lesbar: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** stripeStatus (JSON-Text im shopConfig) als Objekt oder null, wenn leer oder nicht lesbar. */
|
||||
function stripeStatus(text) {
|
||||
try {
|
||||
const roh = typeof text === 'string' && text ? JSON.parse(text) : null
|
||||
return roh !== null && typeof roh === 'object' && !Array.isArray(roh) ? roh : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function projektStand(project, feature) {
|
||||
let shop = null
|
||||
if (feature) {
|
||||
try {
|
||||
shop = await leseShopConfig(feature.$id)
|
||||
} catch (err) {
|
||||
console.warn(`[team-shop] shopConfig ${feature.$id} nicht lesbar: ${err.message}`)
|
||||
}
|
||||
}
|
||||
const shopId = feature?.$id
|
||||
const [artikel, offeneBestellungen] = shopId
|
||||
? await Promise.all([
|
||||
zaehle('shopProducts', [Query.equal('shopId', shopId)], 'Artikelzahl'),
|
||||
zaehle('shopOrders', [Query.equal('shopId', shopId), Query.equal('status', 'bezahlt')], 'Bestellzahl'),
|
||||
])
|
||||
: [0, 0]
|
||||
return {
|
||||
projectId: project.$id,
|
||||
projectName: project.projectName || '',
|
||||
state: feature?.state || 'off',
|
||||
anfrage: shop ? anfrageAnsicht(shop) : null,
|
||||
stripe: { accountId: Boolean(shop?.stripeAccountId), status: stripeStatus(shop?.stripeStatus) },
|
||||
geoeffnet: shop?.geoeffnet === true,
|
||||
zahlen: { artikel, offeneBestellungen },
|
||||
}
|
||||
}
|
||||
|
||||
// Uebersicht je Projekt des Kunden; archivierte Projekte nur, wenn dort ein Shop-Dokument existiert
|
||||
router.get('/:customerId', route(async (req, res) => {
|
||||
const customerId = req.params.customerId
|
||||
const [docs, projects] = await Promise.all([
|
||||
listCustomerFeatureDocs(customerId),
|
||||
listDocuments(config.collections.websiteProjects, [Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000)]),
|
||||
])
|
||||
const shopDocs = new Map(docs.filter((d) => d.featureKey === 'shop').map((d) => [d.projectId, d]))
|
||||
const sichtbar = projects.filter((p) => !p.archived || shopDocs.has(p.$id))
|
||||
res.json({ projects: await Promise.all(sichtbar.map((p) => projektStand(p, shopDocs.get(p.$id)))) })
|
||||
}))
|
||||
|
||||
router.post('/:customerId/anfrage', route(async (req, res) => {
|
||||
const { projectId, entscheidung, begruendung } = req.body || {}
|
||||
const ergebnis = await entscheideAnfrage({
|
||||
customerId: req.params.customerId,
|
||||
projectId,
|
||||
entscheidung,
|
||||
begruendung,
|
||||
person: person(req),
|
||||
})
|
||||
res.json({ ok: true, anfrage: ergebnis.anfrage, ...(ergebnis.mailFehler ? { mailFehler: true } : {}) })
|
||||
}))
|
||||
|
||||
export default router
|
||||
@@ -1,7 +1,7 @@
|
||||
import { config } from '../../config.js'
|
||||
import { createDocument, getDocument, updateDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { sendAdminNotice } from '../mailer.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
|
||||
import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js'
|
||||
import { shopFuerKunde } from './schalter.js'
|
||||
|
||||
@@ -28,6 +28,9 @@ const MELDUNG = {
|
||||
vorhanden: 'Für dieses Projekt gibt es schon einen Webshop.',
|
||||
laeuft: 'Für dieses Projekt gibt es schon eine Webshop-Anfrage.',
|
||||
nichtOffen: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.',
|
||||
nichtEntscheidbar: 'Diese Anfrage ist nicht (mehr) offen und kann nicht entschieden werden.',
|
||||
entscheidung: 'Bitte wähle annehmen oder ablehnen.',
|
||||
begruendung: 'Bitte nenne eine Begründung für die Ablehnung (höchstens 1000 Zeichen).',
|
||||
ungueltig: 'Ungültige Eingabe.',
|
||||
}
|
||||
|
||||
@@ -47,7 +50,7 @@ export async function anfrageOffenePunkte({ customerId, projectId }) {
|
||||
}
|
||||
|
||||
/** shopConfig-Dokument zur ID oder null, wenn es (noch) keines gibt. Fehlt die Collection selbst, wird der Fehler weitergereicht. */
|
||||
async function leseShopConfig(id) {
|
||||
export async function leseShopConfig(id) {
|
||||
try {
|
||||
return await getDocument(config.collections.shopConfig, id)
|
||||
} catch (err) {
|
||||
@@ -230,3 +233,75 @@ export async function zieheZurueck({ customerId, projectId, person = {} }) {
|
||||
})
|
||||
return anfrageAnsicht(gespeichert)
|
||||
}
|
||||
|
||||
const BEGRUENDUNG_MAX = 1000
|
||||
|
||||
/** 'Name <email>' (wie createdBy der Angebote); ohne Name die Adresse, ohne beides 'Admin'. */
|
||||
const personLabel = (person) => (person?.name ? `${person.name}${person.email ? ` <${person.email}>` : ''}` : person?.email || 'Admin')
|
||||
|
||||
/**
|
||||
* Das Team entscheidet eine offene Anfrage (B2): annehmen oder ablehnen (Begruendung Pflicht, 1-1000 Zeichen). Unter der Sperre je
|
||||
* Projekt; das Projekt muss dem Kunden gehoeren (403), der Status muss `offen` sein (409). Speichert Status, Zeit und Person
|
||||
* (anfrageEntschiedenVon), bei annehmen eine leere Begruendung. Danach geht K24 an den Kunden (BCC Team); scheitert die Mail, bleibt
|
||||
* die Entscheidung bestehen, wird geloggt und die Antwort traegt `mailFehler: true`.
|
||||
* @returns {Promise<{ anfrage: object, mailFehler: boolean }>} anfrageAnsicht des neuen Standes
|
||||
*/
|
||||
export async function entscheideAnfrage({ customerId, projectId, entscheidung, begruendung, person = {}, now = new Date() }) {
|
||||
if (entscheidung !== 'annehmen' && entscheidung !== 'ablehnen') throw stop(400, MELDUNG.entscheidung)
|
||||
let grund = ''
|
||||
if (entscheidung === 'ablehnen') {
|
||||
grund = typeof begruendung === 'string' ? begruendung.toWellFormed().trim() : ''
|
||||
if (!grund || grund.length > BEGRUENDUNG_MAX) throw stop(400, MELDUNG.begruendung)
|
||||
}
|
||||
if (typeof projectId !== 'string' || !projectId) throw stop(400, MELDUNG.projekt)
|
||||
|
||||
const { gespeichert, project } = await withLocks([`shop-anfrage:${projectId}`], async () => {
|
||||
let besitz
|
||||
try {
|
||||
besitz = await assertOwnProject(customerId, projectId)
|
||||
} catch (err) {
|
||||
throw err?.status === 400 && err.type ? stop(403, 'Kein Zugriff auf dieses Projekt') : err
|
||||
}
|
||||
const feature = await getFeatureDoc(customerId, projectId, 'shop')
|
||||
const shop = feature ? await leseShopConfig(feature.$id) : null
|
||||
if (!shop || shop.anfrageStatus !== 'offen') throw stop(409, MELDUNG.nichtEntscheidbar, 'nicht_offen')
|
||||
const neu = await updateDocument(config.collections.shopConfig, shop.$id, {
|
||||
anfrageStatus: entscheidung === 'annehmen' ? 'angenommen' : 'abgelehnt',
|
||||
anfrageEntschiedenAt: now.toISOString(),
|
||||
anfrageEntschiedenVon: personLabel(person).slice(0, 255),
|
||||
anfrageBegruendung: grund,
|
||||
version: (Number.isInteger(shop.version) ? shop.version : 0) + 1,
|
||||
})
|
||||
return { gespeichert: neu, project: besitz }
|
||||
})
|
||||
|
||||
const mailFehler = !(await sendeEntscheidung({ customerId, projectId, project, angenommen: entscheidung === 'annehmen', begruendung: grund }))
|
||||
return { anfrage: anfrageAnsicht(gespeichert), mailFehler }
|
||||
}
|
||||
|
||||
/** K24 an den Kunden; true = gesendet. Jeder Fehler wird nur geloggt (die Entscheidung ist gespeichert). */
|
||||
async function sendeEntscheidung({ customerId, projectId, project, angenommen, begruendung }) {
|
||||
try {
|
||||
const kunde = await customerOf(customerId)
|
||||
if (!kunde?.email) {
|
||||
console.warn(`[shop] K24 nicht gesendet: Kunde ${customerId} ohne E-Mail-Adresse`)
|
||||
return false
|
||||
}
|
||||
await sendCustomerMail(kunde.email, {
|
||||
template: 'K24',
|
||||
values: {
|
||||
kunde_name: String(kunde.name || '').trim() || 'Kunde',
|
||||
projekt: project?.projectName || projectId,
|
||||
preis: config.features.shop.priceLabel,
|
||||
ust_hinweis: config.ustHinweis,
|
||||
begruendung,
|
||||
portal_url: `${config.publicBaseUrl}/dashboard.html#webshop?projekt=${encodeURIComponent(projectId)}`,
|
||||
},
|
||||
blocks: { angenommen, abgelehnt: !angenommen },
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn(`[shop] K24 nicht gesendet: ${err?.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user