Öffnungszeiten: Mails K24–K27 heißen jetzt K27–K30, Feld k24At heißt einschaltMailAt

K24–K26 sind seit dem Webshop-Livegang (main) vergeben; der Mail-Lader sucht die Vorlage über den
Dateianfang, doppelte Nummern hätten die falsche Mail geschickt. Das Feld ist noch nicht in der
Produktion angelegt (zeiten-setup.mjs lief noch nicht), daher jetzt ein sprechender Name.
Spec und Plan behalten die alten Nummern (Hinweis oben im Dokument).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:00:17 +00:00
1 parent ea4457e6a4
commit c7cd436ec2
34 files changed
+359 -354

No files matched your search

+25 -25
View File
@@ -21,7 +21,7 @@ const aw = await startAppwriteMock({
// Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null)
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'],
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
featureConsents: ['acceptedAt'],
},
defaultLimit: 25,
@@ -127,7 +127,7 @@ test('Einschalten mit laufendem Abo: setup, unlockedBy abo, Nachweis ohne Sitzun
assert.match(doc.siteKey, /^[a-z2-7]{16}$/)
assert.equal(doc.einbau, 'angestossen')
assert.ok(doc.ticketId)
assert.ok(doc.k24At)
assert.ok(doc.einschaltMailAt)
assert.equal(featureDocs().length, 1, 'nur das Dokument der Zeiten')
// Nachweis: Fassung und Texte der Gratis-Zustimmung, ohne Stripe-Sitzung, mit dem handelnden Konto
@@ -142,12 +142,12 @@ test('Einschalten mit laufendem Abo: setup, unlockedBy abo, Nachweis ohne Sitzun
])
})
test('Reihenfolge: Neubau vor Ticket, Ticket-Text nennt einbau, dann K24, dann k24At', async () => {
test('Reihenfolge: Neubau vor Ticket, Ticket-Text nennt einbau, dann K27, dann einschaltMailAt', async () => {
const beimVersand = []
setMailTransportForTests({
sent: sink.sent,
async sendMail(mail) {
beimVersand.push({ k24At: zeitenDoc()?.k24At ?? null, tickets: aw.docs('workorders').size })
beimVersand.push({ einschaltMailAt: zeitenDoc()?.einschaltMailAt ?? null, tickets: aw.docs('workorders').size })
return sink.sendMail(mail)
},
})
@@ -163,19 +163,19 @@ test('Reihenfolge: Neubau vor Ticket, Ticket-Text nennt einbau, dann K24, dann k
assert.match(ticket.details, /Einbau: automatisch eingesetzt \(nach dem nächsten Veröffentlichen prüfen\)/)
assert.ok(ticket.details.includes(`data-site="${zeitenDoc().siteKey}"`))
assert.equal(zeitenDoc().ticketId, ticket.$id)
// K24: genau eine Mail, nach dem Ticket, k24At erst danach
// K27: genau eine Mail, nach dem Ticket, einschaltMailAt erst danach
assert.equal(sink.sent.length, 1)
assert.deepEqual(beimVersand, [{ k24At: null, tickets: 1 }])
assert.deepEqual(beimVersand, [{ einschaltMailAt: null, tickets: 1 }])
const mail = sink.sent[0]
assert.equal(mail.subject, 'Öffnungszeiten live ist eingeschaltet')
assert.deepEqual(mail.to.map((a) => a.address), ['k@example.invalid'])
assert.match(mail.text, /erscheinen nach dem nächsten Veröffentlichen/)
assert.ok(mail.text.includes(`(Ticket #${ticket.woid})`))
assert.doesNotMatch(mail.text, /nicht automatisch aktualisieren/)
assert.ok(Date.parse(zeitenDoc().k24At) > 0)
assert.ok(Date.parse(zeitenDoc().einschaltMailAt) > 0)
})
test('Neubau nicht möglich: einbau manuell, Ticket und K24 sagen „Team baut ein“', async () => {
test('Neubau nicht möglich: einbau manuell, Ticket und K27 sagen „Team baut ein“', async () => {
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Ohne Repo' }) // kein Repo ⇒ manuell
const res = await einschalten(koerper())
assert.equal(res.status, 200, JSON.stringify(res.body))
@@ -333,7 +333,7 @@ test('Nachweis nicht speicherbar ⇒ 500 ohne Rohtext, Zustand bleibt off, kein
// ------------------------------------------------------------------------------------------- Zustaende
test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau ein K24', async () => {
test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau ein K27', async () => {
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' })
const erster = await einschalten(koerper())
// Zustand ist gespeichert, das Ticket fehlt: der Kunde bekommt einen Hinweis zum erneuten Versuch, nicht den Rohtext
@@ -342,7 +342,7 @@ test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau e
assert.equal(zeitenDoc().state, 'setup')
assert.equal(zeitenDoc().unlockedBy, 'abo')
assert.ok(!zeitenDoc().ticketId)
assert.ok(!zeitenDoc().k24At)
assert.ok(!zeitenDoc().einschaltMailAt)
assert.equal(tickets().length, 0)
assert.equal(sink.sent.length, 0)
assert.equal(nachweise().length, 1)
@@ -353,7 +353,7 @@ test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau e
assert.equal(tickets().length, 1)
assert.equal(zeitenDoc().ticketId, tickets()[0].$id)
assert.equal(sink.sent.length, 1)
assert.ok(zeitenDoc().k24At)
assert.ok(zeitenDoc().einschaltMailAt)
assert.equal(nachweise().length, 1, 'kein zweiter Nachweis')
assert.equal(rebuilds.length, 1, 'einbau steht schon, kein zweiter Neubau')
assert.equal(zweiter.body.einbau, 'angestossen')
@@ -364,21 +364,21 @@ test('zweiter Klick ist idempotent und holt Fehlendes nach: Ticket, dann genau e
assert.deepEqual([tickets().length, sink.sent.length, nachweise().length, rebuilds.length], [1, 1, 1, 1])
})
test('K24 scheitert: Zustand und Ticket bleiben, k24At leer, der nächste Klick sendet nach (ein Ticket, eine Mail)', async () => {
test('K27 scheitert: Zustand und Ticket bleiben, einschaltMailAt leer, der nächste Klick sendet nach (ein Ticket, eine Mail)', async () => {
setMailTransportForTests({ sent: [], async sendMail() { throw new Error('SMTP down') } })
const erster = await einschalten(koerper())
assert.equal(erster.status, 200, 'Mail-Fehler kosten die Antwort nichts')
assert.equal(tickets().length, 1)
assert.ok(!zeitenDoc().k24At)
assert.ok(!zeitenDoc().einschaltMailAt)
setMailTransportForTests(sink)
assert.equal((await einschalten(koerper())).status, 200)
assert.equal(tickets().length, 1)
assert.equal(sink.sent.length, 1)
assert.ok(zeitenDoc().k24At)
assert.ok(zeitenDoc().einschaltMailAt)
assert.match(sink.sent[0].text, new RegExp(`Ticket #${tickets()[0].woid}`))
})
test('zwei gleichzeitige Klicks: ein Nachweis, ein Neubau, ein Ticket, eine K24', async () => {
test('zwei gleichzeitige Klicks: ein Nachweis, ein Neubau, ein Ticket, eine K27', async () => {
const [a, b] = await Promise.all([einschalten(koerper()), einschalten(koerper())])
assert.deepEqual([a.status, b.status], [200, 200], JSON.stringify([a.body, b.body]))
assert.deepEqual([nachweise().length, rebuilds.length, tickets().length, sink.sent.length], [1, 1, 1, 1])
@@ -438,7 +438,7 @@ test('Dokument im Zustand off mit Resten wird sauber auf setup gestellt', async
// --------------------------------------------------------------------------------------------- Team und Nachholen
test('Team: kein Abo nötig, unlockedBy team, kein Nachweis, Neubau, Ticket, keine K24, Notiz', async () => {
test('Team: kein Abo nötig, unlockedBy team, kein Nachweis, Neubau, Ticket, keine K27, Notiz', async () => {
aw.seed('subscriptions', hostingDoc)
const { doc, changed } = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT })
assert.equal(changed, true)
@@ -447,7 +447,7 @@ test('Team: kein Abo nötig, unlockedBy team, kein Nachweis, Neubau, Ticket, kei
assert.equal(nachweise().length, 0)
assert.equal(tickets().length, 1)
assert.equal(sink.sent.length, 0)
assert.ok(!zeitenDoc().k24At)
assert.ok(!zeitenDoc().einschaltMailAt)
assert.equal(rebuilds.length, 1)
// ein zweiter Aufruf aendert nichts
const nochmal = await einschaltenZeiten({ customerId: 'c1', projectId: 'p1', by: 'team', staff: { name: 'Andrej' }, now: JETZT })
@@ -488,12 +488,12 @@ test('Team aus disabled bei einem gekauften Dokument: kauf und unlockedByPurchas
assert.deepEqual([team.doc.unlockedBy, team.doc.unlockedByPurchase], ['team', false])
})
test('holeZeitenNach: Ticket fehlt in setup ⇒ anlegen; K24 nur bei unlockedBy abo und leerem k24At, danach Marker', async () => {
test('holeZeitenNach: Ticket fehlt in setup ⇒ anlegen; K27 nur bei unlockedBy abo und leerem einschaltMailAt, danach Marker', async () => {
aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'manuell' })
const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT })
assert.equal(tickets().length, 1)
assert.equal(doc.ticketId, tickets()[0].$id)
assert.equal(Date.parse(doc.k24At), JETZT.getTime())
assert.equal(Date.parse(doc.einschaltMailAt), JETZT.getTime())
assert.equal(sink.sent.length, 1)
assert.deepEqual(rebuilds, [], 'einbau steht schon: kein Neubau')
// noch einmal: nichts
@@ -501,7 +501,7 @@ test('holeZeitenNach: Ticket fehlt in setup ⇒ anlegen; K24 nur bei unlockedBy
assert.deepEqual([tickets().length, sink.sent.length], [1, 1])
})
test('holeZeitenNach: Neubau nur bei leerem einbau; kauf, active und off bleiben unberührt; team bekommt keine K24', async () => {
test('holeZeitenNach: Neubau nur bei leerem einbau; kauf, active und off bleiben unberührt; team bekommt keine K27', async () => {
const seed = (id, felder) => {
projekt(id)
aw.seed('portalFeatures', { $id: `f_${id}_zeiten`, customerId: 'c1', projectId: id, featureKey: 'zeiten', siteKey: 'abcdefghijklmnop', enabled: false, ...felder })
@@ -522,22 +522,22 @@ test('holeZeitenNach: Neubau nur bei leerem einbau; kauf, active und off bleiben
const fertig = await holeZeitenNach(team, { now: JETZT })
assert.deepEqual([rebuilds.map((r) => r.projektId), tickets().length, sink.sent.length], [['p_team'], 1, 0])
assert.equal(fertig.einbau, 'angestossen')
assert.ok(!fertig.k24At)
assert.ok(!fertig.einschaltMailAt)
assert.match(tickets()[0].details, /automatisch eingesetzt/, 'das Ticket kennt das frische einbau')
})
test('holeZeitenNach: Kunde ohne Adresse oder gelöscht ⇒ k24At wird gesetzt (nichts zu senden), Lesefehler ⇒ wirft und lässt den Marker leer', async () => {
test('holeZeitenNach: Kunde ohne Adresse oder gelöscht ⇒ einschaltMailAt wird gesetzt (nichts zu senden), Lesefehler ⇒ wirft und lässt den Marker leer', async () => {
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: '', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
aw.seed('portalFeatures', { $id: KEY_P1, customerId: 'c1', projectId: 'p1', featureKey: 'zeiten', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', unlockedBy: 'abo', einbau: 'manuell', ticketId: 'ticket_da' })
const doc = await holeZeitenNach(zeitenDoc(), { now: JETZT })
assert.ok(doc.k24At)
assert.ok(doc.einschaltMailAt)
assert.equal(sink.sent.length, 0)
await updateDocument('portalFeatures', KEY_P1, { k24At: null })
await updateDocument('portalFeatures', KEY_P1, { einschaltMailAt: null })
aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true })
aw.failNext({ collection: 'customers', status: 500 })
await assert.rejects(holeZeitenNach(zeitenDoc(), { now: JETZT }))
assert.ok(!zeitenDoc().k24At)
assert.ok(!zeitenDoc().einschaltMailAt)
})
// ----------------------------------------------------------------------------------------------------- Angebot