diff --git a/server/routes/termine.js b/server/routes/termine.js index cd8dde8..ffeef30 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -8,7 +8,7 @@ import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js' import { createWriteLimiter } from '../services/writeLimiter.js' import { verschiebeZeiten } from '../services/termine/ablauf.js' import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js' -import { hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js' +import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js' import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js' import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js' import { zustellFehler } from '../services/termine/outbox.js' @@ -447,34 +447,65 @@ function csvZelle(wert) { return /[;"\r\n]/.test(zelle) ? `"${zelle.replace(/"/g, '""')}"` : zelle } +/** Eine Datenzeile der CSV-Datei (ohne Zeilenende). */ +function csvZeile(b) { + const start = new Date(b.startsAt) + return [ + buchungsNummer(b.$id), + STATUS_TEXT[b.status] ?? b.status, + datumKurz(start), + uhrzeit(start), + b.leistungName, + b.ressourceName, + b.gastName, + b.gastEmail, + b.gastTelefon, + b.gastNachricht, + ].map(csvZelle).join(';') +} + +/** Schreibt einen Teil der Antwort; ist der Puffer voll, wartet es auf 'drain' (oder das Ende der Verbindung), damit ein langsamer Browser den Speicher nicht füllt. */ +function schreibe(res, text) { + if (text === '' || res.destroyed || res.write(text)) return Promise.resolve() + return new Promise((resolve) => { + const weiter = () => { + res.off('drain', weiter) + res.off('close', weiter) + resolve() + } + res.once('drain', weiter) + res.once('close', weiter) + }) +} + +/** + * CSV-Export, seitenweise gestreamt: je Seite höchstens EXPORT_SEITE Zeilen (nach Beginn, Cursor = letzte Zeile der + * ungefilterten Seite), Sperren werden je Seite herausgefiltert. Die erste Seite wird gelesen, bevor ein Header geht: ein Fehler + * dort ist ein normales JSON-500. Danach bricht ein Fehler die Verbindung ab (res.destroy), damit der Browser einen abgebrochenen + * Download statt einer still gekürzten Datei zeigt. Löscht der Tagesjob zwischen zwei Seiten genau die Buchung, die als Cursor + * dient (selten), lehnt Appwrite den Cursor ab: der Export bricht ab und wird neu geladen. + */ router.get('/export.csv', requireSession, withTermine(), route(async (req, res) => { - const ctx = req.ctx - // alle Buchungen der Funktion (die Aufbewahrungsfristen halten die Menge klein); keine Sperren - const liste = (await imZeitraum(ctx.feature.$id, new Date(0), new Date('2100-01-01T00:00:00.000Z'))) - .filter((b) => b.quelle !== 'sperre') - .sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt)) - const zeilen = [CSV_KOPF, ...liste.map((b) => { - const start = new Date(b.startsAt) - return [ - buchungsNummer(b.$id), - STATUS_TEXT[b.status] ?? b.status, - datumKurz(start), - uhrzeit(start), - b.leistungName, - b.ressourceName, - b.gastName, - b.gastEmail, - b.gastTelefon, - b.gastNachricht, - ] - })] - const csv = `\uFEFF${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n` + const featureId = req.ctx.feature.$id + let seite = await exportSeite(featureId) res.set({ 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': 'attachment; filename="termine.csv"', 'X-Content-Type-Options': 'nosniff', }) - res.send(csv) + try { + await schreibe(res, `\uFEFF${CSV_KOPF.map(csvZelle).join(';')}\r\n`) + for (;;) { + await schreibe(res, seite.filter((b) => b.quelle !== 'sperre').map((b) => `${csvZeile(b)}\r\n`).join('')) + if (seite.length < EXPORT_SEITE || res.destroyed) break + seite = await exportSeite(featureId, { nach: seite.at(-1).$id }) + } + res.end() + } catch (err) { + // nach dem ersten Byte geht kein JSON mehr; ins Log nur Art und Status, nie den Text (er könnte Gastdaten enthalten) + console.error(`[termine] Export abgebrochen: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.type ?? ''}`.trim()) + res.destroy(err) + } })) export default router diff --git a/server/services/termine/buchungen.js b/server/services/termine/buchungen.js index 81dc875..1827623 100644 --- a/server/services/termine/buchungen.js +++ b/server/services/termine/buchungen.js @@ -222,6 +222,28 @@ export async function ersteBuchungen(featureId, limit = LOESCH_SEITE) { return listDocuments(col(), [Query.equal('featureId', featureId), Query.limit(limit), Query.select(['$id'])]) } +/** Zeilen je Seite des CSV-Exports (exportSeite). */ +export const EXPORT_SEITE = 500 + +/** + * Eine Seite Buchungen einer Funktion fuer den CSV-Export (jeder Status, auch Sperren; die filtert der Aufrufer je Seite + * heraus): hoechstens `limit` (Standard 500), nach Beginn (startsAt) aufsteigend, ab dem Dokument `nach` (dessen ID, null = + * erste Seite). Der Cursor ist immer die letzte Zeile der ungefilterten Seite. Sortiert wird nur nach startsAt (Index + * idx_feature_start; Appwrite haengt die interne Reihenfolge fuer gleiche Beginne selbst an), nie nach $id. + * Ohne Funktion, mit ungueltigem Cursor oder ungueltiger Grenze: TypeError (nie eine Abfrage ohne Funktionsfilter: Gastdaten). + */ +export async function exportSeite(featureId, { nach = null, limit = EXPORT_SEITE } = {}) { + if (typeof featureId !== 'string' || featureId === '') throw new TypeError('exportSeite: featureId muss ein nicht leerer Text sein') + if (nach !== null && (typeof nach !== 'string' || nach === '')) throw new TypeError('exportSeite: nach muss null oder eine Dokument-ID sein') + if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`exportSeite: limit muss eine ganze Zahl ab 1 sein, nicht ${limit}`) + return listDocuments(col(), [ + Query.equal('featureId', featureId), + Query.orderAsc('startsAt'), + Query.limit(limit), + ...(nach === null ? [] : [Query.cursorAfter(nach)]), + ]) +} + /** Zahl der offenen Anfragen (Status angefragt) einer Funktion. */ export async function zaehleAnfragen(featureId) { return (await listAllDocuments(col(), [Query.equal('featureId', featureId), Query.equal('status', 'angefragt')])).length diff --git a/test/termineRoutes.test.js b/test/termineRoutes.test.js index 7de4444..ad704ae 100644 --- a/test/termineRoutes.test.js +++ b/test/termineRoutes.test.js @@ -38,6 +38,8 @@ const { speichereEinstellung, ladeEinstellung, resetKontextForTests } = await im const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') const { buchungsNummer } = await import('../server/services/termine/token.js') +const { datumKurz, uhrzeit } = await import('../server/services/termine/zeit.js') +const { exportSeite } = await import('../server/services/termine/buchungen.js') const app = await startTestApp({ '/': kundenansichtGuard, @@ -989,6 +991,115 @@ test('GET export.csv: Zellen mit = + - @ am Anfang (auch nach Leerraum, Tab, CR) } }) +// Export seitenweise (Aufgabe A5): Grosse Mengen kommen Seite fuer Seite (limit 500, nach Beginn, Cursor = letzte Rohzeile) +// und werden gestreamt; Fehler vor dem ersten Byte sind ein normales JSON-500, danach bricht die Verbindung ab. +const START_MS = Date.UTC(2026, 9, 12, 6, 0) +const startZeit = (minuten) => new Date(START_MS + minuten * 60_000).toISOString().replace('Z', '+00:00') +const nr4 = (i) => String(i).padStart(5, '0') +/** Eine Buchung zum Index i (alle 30 Minuten eine, deshalb nie zwei mit gleichem Beginn); gleiche Form wie im Export erwartet. */ +function massenBuchung(i, over = {}) { + return buchungsDoc(`b_m${nr4(i)}`, { + startsAt: startZeit(i * 30), + endsAt: startZeit(i * 30 + 25), + blockEndsAt: startZeit(i * 30 + 25), + gastName: `Gast ${nr4(i)}`, + gastEmail: `gast${nr4(i)}@example.com`, + gastTelefon: '', + gastNachricht: '', + ...over, + }) +} +const erwarteteZeile = (i) => { + const start = new Date(START_MS + i * 30 * 60_000) + return [buchungsNummer(`b_m${nr4(i)}`), 'Bestätigt', datumKurz(start), uhrzeit(start), 'Damenschnitt', 'Anna', `Gast ${nr4(i)}`, `gast${nr4(i)}@example.com`, '', ''].join(';') +} + +test('GET export.csv: 1200 Buchungen + 40 Sperren + fremde Funktion kommen in drei Seiten zu je hoechstens 500, sortiert und im alten Format', async () => { + await speichere() + const reihenfolge = Array.from({ length: 1200 }, (_, i) => 1199 - i) // absteigend angelegt: die Sortierung kommt aus der Abfrage + for (const i of reihenfolge) aw.seed('termineBookings', massenBuchung(i)) + for (let k = 0; k < 40; k += 1) { + aw.seed('termineBookings', massenBuchung(k, { $id: `s_m${nr4(k)}`, quelle: 'sperre', leistungName: 'Pause', gastName: '', gastEmail: '', startsAt: startZeit(k * 30 * 30 + 5), endsAt: startZeit(k * 30 * 30 + 15), blockEndsAt: startZeit(k * 30 * 30 + 15) })) + } + for (let k = 0; k < 30; k += 1) aw.seed('termineBookings', massenBuchung(k, { $id: `b_f${nr4(k)}`, featureId: 'f_anderer_termine', gastName: 'Fremder Gast' })) + + aw.resetListCalls() + const { res, bytes, text } = await rohExport() + assert.equal(res.status, 200) + assert.equal(res.headers.get('content-type'), 'text/csv; charset=utf-8') + assert.match(res.headers.get('content-disposition'), /^attachment; filename="termine\.csv"$/) + assert.equal(res.headers.get('cache-control'), 'no-store') + assert.equal(res.headers.get('x-content-type-options'), 'nosniff') + assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM') + + const erwartet = `${['Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht', ...Array.from({ length: 1200 }, (_, i) => erwarteteZeile(i))].join('\r\n')}\r\n` + assert.equal(text.length, erwartet.length) + assert.ok(text === erwartet, '1200 Datenzeilen nach Beginn sortiert, Zeile fuer Zeile wie bisher') + assert.ok(!text.includes('Pause') && !text.includes('Fremder Gast')) + + const abfragen = aw.listCalls('termineBookings') + assert.equal(abfragen.length, 3, '1240 Rohzeilen = 500 + 500 + 240 in genau drei Abfragen') + const sortiert = [...buchungen()].filter((b) => b.featureId === FEATURE).sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt)) + abfragen.forEach((abfrage, nr) => { + const q = abfrage.queries + assert.deepEqual(q.find((x) => x.method === 'limit').values, [500], `Seite ${nr + 1}: limit 500`) + assert.deepEqual(q.find((x) => x.method === 'equal'), { method: 'equal', attribute: 'featureId', values: [FEATURE] }) + assert.deepEqual(q.filter((x) => x.method === 'orderAsc'), [{ method: 'orderAsc', attribute: 'startsAt' }], 'nur nach Beginn sortiert, nie nach $id') + const cursor = q.find((x) => x.method === 'cursorAfter') + if (nr === 0) assert.equal(cursor, undefined) + else assert.deepEqual(cursor.values, [sortiert[nr * 500 - 1].$id], `Seite ${nr + 1}: Cursor = letzte Rohzeile der Seite davor (auch eine Sperre)`) + }) +}) + +test('GET export.csv: 20100 Buchungen (ueber dem alten Deckel von 20000) werden vollstaendig geliefert, ohne list_limit_exceeded', async () => { + await speichere() + for (let i = 0; i < 20100; i += 1) aw.seed('termineBookings', massenBuchung(i)) + aw.resetListCalls() + const { res, text } = await rohExport() + assert.equal(res.status, 200) + const zeilen = text.replace(/^/, '').split('\r\n') + assert.equal(zeilen.length, 20100 + 2, 'Kopf + 20100 Zeilen + leeres Ende') + assert.equal(zeilen[1], erwarteteZeile(0)) + assert.equal(zeilen[20100], erwarteteZeile(20099)) + assert.equal(zeilen[20101], '') + assert.equal(aw.listCalls('termineBookings').length, 41, '40 volle Seiten + 1 Restseite') +}) + +test('GET export.csv: Fehler bei der ersten Seite = normales JSON-500 ohne CSV-Anfang, Fehler bei der zweiten bricht den Download ab', async () => { + await speichere() + for (let i = 0; i < 600; i += 1) aw.seed('termineBookings', massenBuchung(i)) + + aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500 }) + const erste = await rohExport() + assert.equal(erste.res.status, 500) + assert.match(erste.res.headers.get('content-type'), /application\/json/) + assert.deepEqual(JSON.parse(erste.text), { error: 'Interner Serverfehler' }) + assert.ok(!erste.text.includes('Nummer;') && !erste.text.includes(''), 'kein CSV-Anfang') + assert.equal(erste.res.headers.get('content-disposition'), null, 'kein Download-Kopf') + + console.error.mock.resetCalls() + aw.failNext({ collection: 'termineBookings', method: 'GET', status: 500, after: 1 }) + const res = await fetch(`${app.url}/api/termine/export.csv?projectId=${P}`, { headers: { cookie } }) + assert.equal(res.status, 200, 'die Kopfzeilen sind schon raus') + assert.match(res.headers.get('content-type'), /text\/csv/) + await assert.rejects(res.arrayBuffer(), 'die Antwort bricht ab statt als vollstaendige, aber gekuerzte Datei zu enden') + const log = JSON.stringify(console.error.mock.calls.map((c) => c.arguments)) + assert.ok(console.error.mock.calls.length >= 1, 'der Abbruch steht im Log') + assert.ok(!/Gast \d|@example\.com|Max|Hauptstr/.test(log), `im Log kein Gastname, keine Adresse: ${log}`) + + // danach geht der Export wieder + assert.equal((await rohExport()).text.split('\r\n').length, 600 + 2) +}) + +test('exportSeite: ohne Funktion, mit ungueltigem Cursor oder ungueltiger Grenze TypeError (nie eine Abfrage ohne Funktionsfilter)', async () => { + await assert.rejects(exportSeite(''), TypeError) + await assert.rejects(exportSeite(undefined), TypeError) + await assert.rejects(exportSeite(FEATURE, { limit: 0 }), TypeError) + await assert.rejects(exportSeite(FEATURE, { limit: 1.5 }), TypeError) + await assert.rejects(exportSeite(FEATURE, { nach: '' }), TypeError) + await assert.rejects(exportSeite(FEATURE, { nach: 7 }), TypeError) +}) + // ---------------------------------------------------------------- Abgeschaltet (disabled) test('abgeschaltete Funktion: Lesen und Export gehen, jedes Schreiben ist 409', async () => {