From db47f135100feaefc150675be24a03f6c1fed48d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 16:47:12 +0000 Subject: [PATCH] Kundenansicht P6: Abo-Ende-Abgleich im Tagesjob Co-Authored-By: Claude Sonnet 5.5 --- server/index.js | 16 +++++++ server/services/aboFunktionenAbgleich.js | 22 ++++++++++ test/aboFunktionenTagesjob.test.js | 56 ++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 test/aboFunktionenTagesjob.test.js diff --git a/server/index.js b/server/index.js index d864d1e..f1913a7 100644 --- a/server/index.js +++ b/server/index.js @@ -33,6 +33,7 @@ import { purgeViewLog } from './services/customerViewLog.js' import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { runActivation } from './services/siteFeatures.js' import { runAboCheck, runAnalyseReconcile } from './services/analyse.js' +import { aboFunktionenTagesJob } from './services/aboFunktionenAbgleich.js' import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js' import { minutenJob as unterkunftMinutenJob, viertelstundenJob as unterkunftViertelstundenJob, taeglicherJob as unterkunftTaeglicherJob } from './services/unterkunft/jobs.js' import { minutenJob as shopMinutenJob, stuendlicherJob as shopStuendlicherJob, taeglicherJob as shopTaeglicherJob } from './services/shop/jobs.js' @@ -243,6 +244,21 @@ const server = app.listen(config.port, '0.0.0.0', () => { setTimeout(analyseAboCheck, 2 * 60 * 1000).unref() setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref() + // Funktionen des Abo-Modells (Kundenansicht neu, P6): taeglich der Abo-Ende-Abgleich fuer News, Terminplaner, Webshop und + // Buchungskalender (150 Sekunden nach dem Start beginnend, VOR dem Terminplaner-Tagesjob bei 180 s und den Loeschjobs von Shop + // und Buchungskalender, damit ein zurueckgekehrtes Abo nicht mehr geloescht wird). Laeuft unabhaengig von den Funktionsschaltern: ohne + // Dokumente des Abo-Modells kostet er eine Abfrage. Ein noch laufender Lauf wird nicht ueberlappt; im Log stehen keine Kundendaten. + const aboFunktionenAbgleich = () => + aboFunktionenTagesJob(new Date()) + .then((r) => { + if (r.abgeschaltet || r.wiederEin || r.geloescht || r.mails || r.fehler) { + console.log(`[funktionen-abo] ${r.abgeschaltet ?? 0} abgeschaltet, ${r.wiederEin ?? 0} wieder eingeschaltet, ${r.geloescht ?? 0} gelöscht, ${r.mails ?? 0} Mail(s), ${r.fehler ?? 0} Fehler`) + } + }) + .catch((err) => console.warn('[funktionen-abo] Abgleich fehlgeschlagen:', err.message)) + setTimeout(aboFunktionenAbgleich, 150 * 1000).unref() + setInterval(aboFunktionenAbgleich, 24 * 60 * 60 * 1000).unref() + // Terminplaner (Modus off: jeder Lauf kehrt sofort zurueck, ohne Abfrage; ein noch laufender Job wird nicht ueberlappt): // jede Minute abgelaufene Haltungen verfallen lassen und die Mail-Warteschlange senden, alle 5 Minuten Erinnerungen, erledigte // Termine und vergangene Anfragen, taeglich (3 Minuten nach dem Start beginnend) Loeschfristen und Aufraeumen. Die Jobs melden diff --git a/server/services/aboFunktionenAbgleich.js b/server/services/aboFunktionenAbgleich.js index 714335e..4fa3396 100644 --- a/server/services/aboFunktionenAbgleich.js +++ b/server/services/aboFunktionenAbgleich.js @@ -299,3 +299,25 @@ export async function gleicheAboFunktionenAb(now = new Date()) { } return erg } + +let tagesJobLaeuft = false + +/** + * Tagesjob (server/index.js): der Abo-Ende-Abgleich ohne Ueberlappung. Laeuft noch ein Lauf (Merker im Speicher des Prozesses, im finally + * geloescht), kehrt der Aufruf sofort mit { uebersprungen: true } zurueck. Wirft nie: ein unerwarteter Fehler steht im Log, das Ergebnis + * ist { fehler: 1 }. Das Log nennt weder Namen noch Adressen. + * @param {Date} [now] + * @returns {Promise | { uebersprungen: true } | { fehler: 1 }>} + */ +export async function aboFunktionenTagesJob(now = new Date()) { + if (tagesJobLaeuft) return { uebersprungen: true } + tagesJobLaeuft = true + try { + return await gleicheAboFunktionenAb(now) + } catch (err) { + console.error(`[abo-funktionen] Tagesjob fehlgeschlagen: ${fehlerText(err)}`) + return { fehler: 1 } + } finally { + tagesJobLaeuft = false + } +} diff --git a/test/aboFunktionenTagesjob.test.js b/test/aboFunktionenTagesjob.test.js new file mode 100644 index 0000000..9a36083 --- /dev/null +++ b/test/aboFunktionenTagesjob.test.js @@ -0,0 +1,56 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +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' + +// Tagesjob des Abo-Ende-Abgleichs (P6 Task 11): Überlappungsschutz, wirft nie, Einbindung in server/index.js vor den Löschjobs. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + contractEvents: schemaKeys('contractEvents'), + termineConfig: schemaKeys('termineConfig'), + newsPosts: schemaKeys('newsPosts'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { aboFunktionenTagesJob } = await import('../server/services/aboFunktionenAbgleich.js') +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([aw.close(), st.close()]) +}) + +const JETZT = new Date('2026-10-17T08:00:00.000Z') +const seed = (key, over = {}) => aw.seed('portalFeatures', { $id: `f_p1_${key}`, customerId: 'c1', projectId: 'p1', featureKey: key, siteKey: 'abcdefghijklmnop', state: 'active', enabled: true, activatedAt: '2026-10-01T09:00:00.000Z', unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo', ...over }) +const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc)) + +seedFixtures(aw) +aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei' }) +beforeEach(async () => { + for (const col of ['portalFeatures', 'subscriptions', 'contractEvents', 'newsPosts']) for (const id of aw.docs(col).keys()) await deleteDocument(col, id) + mails.sent.length = 0 +}) + +test('Überlappungsschutz: der zweite gleichzeitige Lauf wird übersprungen, danach läuft der Job wieder', async () => { + abo({ ...wartungDoc, status: 'canceled' }); seed('news') + const [a, b] = await Promise.all([aboFunktionenTagesJob(JETZT), aboFunktionenTagesJob(JETZT)]) + assert.deepEqual([a, b].filter((r) => r.uebersprungen).length, 1); assert.equal([a, b].find((r) => !r.uebersprungen).abgeschaltet, 1) + assert.equal((await aboFunktionenTagesJob(JETZT)).uebersprungen, undefined) +}) +test('Der Job wirft nie (Listenfehler → fehler 1) und index.js startet ihn vor den Löschjobs', async (t) => { + t.mock.method(console, 'error', () => {}); aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 }) + assert.equal((await aboFunktionenTagesJob(JETZT)).fehler, 1) + const src = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') + const start = src.indexOf('setTimeout(aboFunktionenAbgleich, 150 * 1000)'); assert.ok(start > src.indexOf('const analyseAboCheck')) + assert.ok(start < src.indexOf('setTimeout(termineTagesJob, 180 * 1000)')); assert.match(src, /setInterval\(aboFunktionenAbgleich, 24 \* 60 \* 60 \* 1000\)/) +})