Kundenansicht P5: Shop-Anfrage im Abo-Modell (Unternehmer aus Kundenart)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b6f2df0d4a
commit
23252df359
3 files changed
+143
-6
No files matched your search
+16
-1
@@ -8,6 +8,8 @@ import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.j
|
|||||||
import { createWriteLimiter } from '../services/writeLimiter.js'
|
import { createWriteLimiter } from '../services/writeLimiter.js'
|
||||||
import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js'
|
import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js'
|
||||||
import { shopFuerKunde } from '../services/shop/schalter.js'
|
import { shopFuerKunde } from '../services/shop/schalter.js'
|
||||||
|
import { kundenartWirksam } from '../services/kundenart.js'
|
||||||
|
import { neueAnsicht } from '../services/kundenansicht/schalter.js'
|
||||||
import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js'
|
import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js'
|
||||||
import { bestellungKunde, bestellungLesen, csv as bestellungenCsv, erstatte, listeBestellungen, setzeStatus } from '../services/shop/bestellungen.js'
|
import { bestellungKunde, bestellungLesen, csv as bestellungenCsv, erstatte, listeBestellungen, setzeStatus } from '../services/shop/bestellungen.js'
|
||||||
import { listeWiderrufe, markiereBearbeitet } from '../services/shop/widerrufe.js'
|
import { listeWiderrufe, markiereBearbeitet } from '../services/shop/widerrufe.js'
|
||||||
@@ -210,12 +212,25 @@ router.get('/status', requireSession, route(async (req, res) => {
|
|||||||
res.json(antwort)
|
res.json(antwort)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
/** Kundenart des Kundendokuments (leer oder unbekannt = privat) - nur der Server entscheidet, nie der Client; ein unbekannter Kunde ist privat. */
|
||||||
|
async function kundenartDesKunden(customerId) {
|
||||||
|
try {
|
||||||
|
return kundenartWirksam(await getDocument(config.collections.customers, customerId))
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.status === 404) return 'privat'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/anfrage', requireSession, nurKunde, limitAnfrage, route(async (req, res) => {
|
router.post('/anfrage', requireSession, nurKunde, limitAnfrage, route(async (req, res) => {
|
||||||
|
const customerId = getSessionCustomerId(req)
|
||||||
const anfrage = await stelleAnfrage({
|
const anfrage = await stelleAnfrage({
|
||||||
customerId: getSessionCustomerId(req),
|
customerId,
|
||||||
projectId: projectIdOf(req),
|
projectId: projectIdOf(req),
|
||||||
eingabe: req.body?.anfrage,
|
eingabe: req.body?.anfrage,
|
||||||
person: person(req),
|
person: person(req),
|
||||||
|
// Abo-Modell (neue Ansicht): Unternehmer aus der Kundenart, privat nur_betrieb; sonst unveraendert (undefined)
|
||||||
|
kundenart: neueAnsicht(req) ? await kundenartDesKunden(customerId) : undefined,
|
||||||
})
|
})
|
||||||
res.json({ anfrage })
|
res.json({ anfrage })
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { withLocks } from '../keyedLock.js'
|
|||||||
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
|
import { sendAdminNotice, sendCustomerMail } from '../mailer.js'
|
||||||
import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js'
|
import { assertOwnProject, customerOf, ensureFeatureDoc, getFeatureDoc } from '../siteFeatures.js'
|
||||||
import { shopFuerKunde } from './schalter.js'
|
import { shopFuerKunde } from './schalter.js'
|
||||||
|
import { NUR_BETRIEB_TEXT } from '../kundenart.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Der Kunde stellt eine Anfrage, das Team
|
* Webshop-Anfrage (Spezifikation 2026-10-05-webshop-design.md, Abschnitt 4 "Auf Anfrage"). Der Kunde stellt eine Anfrage, das Team
|
||||||
@@ -163,15 +164,21 @@ async function pruefeZugang({ customerId, projectId, person, neu }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Eingabe (400 mit `fehler`), dann unter Sperre
|
* Anfrage stellen. Reihenfolge: Projekt (403/409 archiviert), Schalter (409), Kundenart (409 nur_betrieb, nur im Abo-Modell), Eingabe
|
||||||
* je Projekt: Funktion muss `off` sein (409), Anfrage-Status '' | abgelehnt | zurueckgezogen (409). Speichert JSON, Status `offen`
|
* (400 mit `fehler`), dann unter Sperre je Projekt: Funktion muss `off` sein (409), Anfrage-Status '' | abgelehnt | zurueckgezogen (409).
|
||||||
* und anfrageAt, loescht eine fruehere Entscheidung und schickt T21 ans Team (ein Fehler dabei wird nur geloggt).
|
* Speichert JSON, Status `offen` und anfrageAt, loescht eine fruehere Entscheidung und schickt T21 ans Team (ein Fehler dabei wird nur geloggt).
|
||||||
|
* kundenart (nur mit neuer Ansicht, kundenartWirksam des Kundendokuments; ohne Angabe wie bisher): nur Betriebe ('unternehmen') duerfen
|
||||||
|
* anfragen, jeder andere Wert ist 409 nur_betrieb. Fuer Betriebe setzt der Server `unternehmer` auf true (ein Wert des Clients zaehlt
|
||||||
|
* nicht); die gespeicherte Anfrage traegt zusaetzlich kundenart.
|
||||||
* @returns {Promise<object>} anfrageAnsicht des neuen Standes
|
* @returns {Promise<object>} anfrageAnsicht des neuen Standes
|
||||||
*/
|
*/
|
||||||
export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date() }) {
|
export async function stelleAnfrage({ customerId, projectId, eingabe, person = {}, now = new Date(), kundenart }) {
|
||||||
const project = await pruefeZugang({ customerId, projectId, person, neu: true })
|
const project = await pruefeZugang({ customerId, projectId, person, neu: true })
|
||||||
const { anfrage, fehler } = normalisiereAnfrage(eingabe)
|
const abo = kundenart !== undefined
|
||||||
|
if (abo && kundenart !== 'unternehmen') throw stop(409, NUR_BETRIEB_TEXT, 'nur_betrieb')
|
||||||
|
const { anfrage: geprueft, fehler } = normalisiereAnfrage(abo && istObjekt(eingabe) ? { ...eingabe, unternehmer: true } : eingabe)
|
||||||
if (fehler) throw Object.assign(stop(400, Object.values(fehler)[0]), { fehler })
|
if (fehler) throw Object.assign(stop(400, Object.values(fehler)[0]), { fehler })
|
||||||
|
const anfrage = abo ? { ...geprueft, kundenart } : geprueft
|
||||||
|
|
||||||
const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => {
|
const gespeichert = await withLocks([`shop-anfrage:${projectId}`], async () => {
|
||||||
const feature = await ensureFeatureDoc(customerId, projectId, 'shop')
|
const feature = await ensureFeatureDoc(customerId, projectId, 'shop')
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { test, after, beforeEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||||
|
import { startFakeStripe } from './support/fakeStripe.js'
|
||||||
|
import { mailSink } from './support/mailSink.js'
|
||||||
|
import { schemaKeys } from './support/schema.js'
|
||||||
|
import { useTestEnv } from './support/testEnv.js'
|
||||||
|
|
||||||
|
// Anfragen im Abo-Modell (P5 Task 12): die Shop-Anfrage nimmt Unternehmer aus der Kundenart (nur Betriebe), der Buchungskalender bleibt offen
|
||||||
|
const aw = await startAppwriteMock({
|
||||||
|
schema: {
|
||||||
|
portalFeatures: schemaKeys('portalFeatures'),
|
||||||
|
shopConfig: schemaKeys('shopConfig'),
|
||||||
|
ukEinstellungen: schemaKeys('ukEinstellungen'),
|
||||||
|
featureConsents: schemaKeys('featureConsents'),
|
||||||
|
},
|
||||||
|
datetimes: { ukEinstellungen: ['rechnungAuftragAt', 'anfrageAt', 'anfrageEntschiedenAt', 'icalFehlerMailAt'] },
|
||||||
|
})
|
||||||
|
const st = await startFakeStripe()
|
||||||
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
|
const { config } = await import('../server/config.js')
|
||||||
|
const { startTestApp, post } = await import('./support/testApp.js')
|
||||||
|
const { customerCookie, customers, seedFixtures } = await import('./support/fixtures.js')
|
||||||
|
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||||
|
const { NUR_BETRIEB_TEXT } = await import('../server/services/kundenart.js')
|
||||||
|
const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js')
|
||||||
|
const { default: ukRoutes, resetUnterkunftRoutesForTests } = await import('../server/routes/unterkunft.js')
|
||||||
|
|
||||||
|
const app = await startTestApp({ '/api/shop': shopRoutes, '/api/unterkunft': ukRoutes })
|
||||||
|
const sink = mailSink()
|
||||||
|
setMailTransportForTests(sink)
|
||||||
|
const vorher = {
|
||||||
|
neu: { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] },
|
||||||
|
shop: { mode: config.shop.mode, pilotEmails: [...config.shop.pilotEmails] },
|
||||||
|
unterkunft: { mode: config.unterkunft.mode, pilotEmails: [...config.unterkunft.pilotEmails] },
|
||||||
|
}
|
||||||
|
after(() => {
|
||||||
|
Object.assign(config.kundenansichtNeu, vorher.neu)
|
||||||
|
Object.assign(config.shop, vorher.shop)
|
||||||
|
Object.assign(config.unterkunft, vorher.unterkunft)
|
||||||
|
setMailTransportForTests(null)
|
||||||
|
return Promise.all([app.close(), aw.close(), st.close()])
|
||||||
|
})
|
||||||
|
|
||||||
|
const c1 = customerCookie('c1')
|
||||||
|
const anfrage = (extra = {}) => ({ warenart: 'waren', beschreibung: 'Backwaren aus eigener Herstellung', artikelzahl: 'bis20', versand: false, abholung: true, keineAusgeschlossenen: true, ...extra })
|
||||||
|
const senden = (a, cookie = c1) => post('/api/shop/anfrage', { projectId: 'p1', anfrage: a }, { cookie })
|
||||||
|
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
|
||||||
|
const leere = async (...collections) => {
|
||||||
|
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await leere('portalFeatures', 'shopConfig', 'ukEinstellungen', 'featureConsents', 'websiteProjects')
|
||||||
|
seedFixtures(aw)
|
||||||
|
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', archived: false })
|
||||||
|
config.kundenansichtNeu.mode = 'pilot'
|
||||||
|
config.kundenansichtNeu.pilotEmails = ['k@example.invalid', 'test@example.invalid']
|
||||||
|
config.shop.mode = config.unterkunft.mode = 'on'
|
||||||
|
resetShopRoutesForTests()
|
||||||
|
resetUnterkunftRoutesForTests()
|
||||||
|
sink.sent.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
test('privat (Kundenart leer): 409 nur_betrieb, auch mit gefälschtem unternehmer: true; nichts geschrieben, keine Mail', async () => {
|
||||||
|
const r = await senden(anfrage({ unternehmer: true }))
|
||||||
|
assert.deepEqual([r.status, r.body.code, r.body.error], [409, 'nur_betrieb', NUR_BETRIEB_TEXT])
|
||||||
|
assert.deepEqual([aw.docs('shopConfig').size, aw.docs('portalFeatures').size, sink.sent.length], [0, 0, 0])
|
||||||
|
for (const kundenart of ['', 'kaputt', 'Unternehmen', 'privat']) {
|
||||||
|
aw.seed('customers', { ...customers.c1, kundenart })
|
||||||
|
const x = await senden(anfrage({ unternehmer: true, kundenart: 'unternehmen' }))
|
||||||
|
assert.deepEqual([x.status, x.body.code], [409, 'nur_betrieb'], kundenart)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Betrieb: unternehmer kommt vom Server, Anfrage speichert die Kundenart', async () => {
|
||||||
|
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
|
||||||
|
const r = await senden(anfrage())
|
||||||
|
assert.deepEqual([r.status, r.body.anfrage.status], [200, 'offen'])
|
||||||
|
assert.deepEqual([r.body.anfrage.anfrage.unternehmer, r.body.anfrage.anfrage.kundenart], [true, 'unternehmen'])
|
||||||
|
assert.equal(sink.sent.length, 1, 'T21 ans Team')
|
||||||
|
await leere('shopConfig', 'portalFeatures')
|
||||||
|
const falsch = await senden(anfrage({ unternehmer: false })) // der Client-Wert zählt nicht
|
||||||
|
assert.deepEqual([falsch.status, falsch.body.anfrage.anfrage.unternehmer], [200, true])
|
||||||
|
await leere('shopConfig', 'portalFeatures')
|
||||||
|
const ohne = await senden(anfrage({ keineAusgeschlossenen: false }))
|
||||||
|
assert.deepEqual([ohne.status, Object.keys(ohne.body.fehler)], [400, ['keineAusgeschlossenen']])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('alte Ansicht unverändert: unternehmer bleibt Pflicht, privat darf anfragen', async () => {
|
||||||
|
config.kundenansichtNeu.mode = 'off'
|
||||||
|
const r = await senden(anfrage())
|
||||||
|
assert.deepEqual([r.status, Object.keys(r.body.fehler)], [400, ['unternehmer']])
|
||||||
|
const ok = await senden(anfrage({ unternehmer: true }))
|
||||||
|
assert.equal(ok.status, 200)
|
||||||
|
assert.equal(Object.hasOwn(ok.body.anfrage.anfrage, 'kundenart'), false, 'ohne neue Ansicht keine Kundenart in der Anfrage')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Reihenfolge: Projekt und Schalter vor der Kundenart, die Kundenart vor der Eingabe', async () => {
|
||||||
|
assert.equal((await post('/api/shop/anfrage', { projectId: 'pfremd', anfrage: anfrage() }, { cookie: c1 })).status, 403)
|
||||||
|
config.shop.mode = 'off'
|
||||||
|
assert.equal((await senden(anfrage())).body.code, 'nicht_verfuegbar')
|
||||||
|
config.shop.mode = 'on'
|
||||||
|
const leer = await senden({}) // ungültige Eingabe, aber privat: nur_betrieb kommt zuerst
|
||||||
|
assert.deepEqual([leer.status, leer.body.code], [409, 'nur_betrieb'])
|
||||||
|
aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' })
|
||||||
|
assert.equal((await senden({})).status, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Buchungskalender: privat darf in der neuen Ansicht anfragen (nurBetrieb false)', async () => {
|
||||||
|
const r = await post('/api/unterkunft/anfrage-funktion', { projectId: 'p1', art: 'ferienwohnung', einheiten: 2, portale: ['booking'] }, { cookie: c1 })
|
||||||
|
assert.equal(r.status, 200, JSON.stringify(r.body))
|
||||||
|
assert.equal(r.body.anfrage.status, 'offen')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user