From 4993fbd88644dceb2046aeb838d34d9b615c936f Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 6 Mar 2026 20:10:13 +0100 Subject: [PATCH] =?UTF-8?q?Storno-Link=20in=20Best=C3=A4tigungsmail=20+=20?= =?UTF-8?q?Erinnerung=20n=C3=A4chstes=20Jahr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bestätigungsmail enthält roten Storno-Button mit sicherem Token - /storno?token=XXX Seite zeigt Buchungsdetails + Stornierung - Storno-Bestätigungsmail mit Neu-Buchen-Button - Checkbox im Buchungsformular für Erinnerung nächstes Jahr - Neue DB-Felder: storno_token (unique), erinnerung_naechstes_jahr Co-Authored-By: Claude Opus 4.6 --- src/app/api/pool/route.ts | 17 ++- src/app/api/storno/route.ts | 173 ++++++++++++++++++++++++ src/app/pool/page.tsx | 16 +++ src/app/storno/page.tsx | 262 ++++++++++++++++++++++++++++++++++++ src/types/index.ts | 2 + 5 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 src/app/api/storno/route.ts create mode 100644 src/app/storno/page.tsx diff --git a/src/app/api/pool/route.ts b/src/app/api/pool/route.ts index 82aa0c5..68234e6 100644 --- a/src/app/api/pool/route.ts +++ b/src/app/api/pool/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { createServiceClient, createServerSupabaseClient } from '@/lib/supabase/server'; import { Resend } from 'resend'; +import crypto from 'crypto'; function getResend() { return new Resend(process.env.RESEND_API_KEY); @@ -9,7 +10,7 @@ function getResend() { export async function POST(request: NextRequest) { try { const body = await request.json(); - const { name, strasse, telefon, email, wasserquelle, wassermenge_m3, wunschdatum, captchaToken } = body; + const { name, strasse, telefon, email, wasserquelle, wassermenge_m3, wunschdatum, captchaToken, erinnerung_naechstes_jahr } = body; // Prüfen ob Admin eingeloggt ist (kein CAPTCHA nötig) const authClient = await createServerSupabaseClient(); @@ -108,6 +109,9 @@ export async function POST(request: NextRequest) { } } + // Storno-Token generieren + const stornoToken = crypto.randomBytes(32).toString('hex'); + // Buchung speichern const { data: buchung, error: insertError } = await supabase .from('buchungen') @@ -121,6 +125,8 @@ export async function POST(request: NextRequest) { wunschdatum, status: 'aktiv', erstellt_von: isAdmin ? 'admin' : 'buerger', + storno_token: stornoToken, + erinnerung_naechstes_jahr: !!erinnerung_naechstes_jahr, }) .select() .single(); @@ -141,6 +147,9 @@ export async function POST(request: NextRequest) { year: 'numeric', }); + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app'; + const stornoLink = `${baseUrl}/storno?token=${stornoToken}`; + try { await getResend().emails.send({ from: 'Gemeindeamt Weißkirchen ', @@ -177,6 +186,12 @@ export async function POST(request: NextRequest) { +
+

Termin stornieren?

+ + Buchung stornieren + +

Bei Fragen wenden Sie sich bitte an:
gemeinde@weisskirchen.ooe.gv.at
diff --git a/src/app/api/storno/route.ts b/src/app/api/storno/route.ts new file mode 100644 index 0000000..64f36ea --- /dev/null +++ b/src/app/api/storno/route.ts @@ -0,0 +1,173 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createServiceClient } from '@/lib/supabase/server'; +import { Resend } from 'resend'; + +function getResend() { + return new Resend(process.env.RESEND_API_KEY); +} + +// GET: Buchung per Storno-Token laden +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const token = searchParams.get('token'); + + if (!token) { + return NextResponse.json( + { error: 'Token fehlt.' }, + { status: 400 } + ); + } + + const supabase = createServiceClient(); + + const { data, error } = await supabase + .from('buchungen') + .select('id, name, wunschdatum, wasserquelle, wassermenge_m3, status') + .eq('storno_token', token) + .single(); + + if (error || !data) { + return NextResponse.json( + { error: 'Ungültiger Storno-Link.' }, + { status: 404 } + ); + } + + return NextResponse.json(data); + } catch (err) { + console.error('Storno GET Error:', err); + return NextResponse.json( + { error: 'Interner Serverfehler.' }, + { status: 500 } + ); + } +} + +// POST: Buchung stornieren +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { token } = body; + + if (!token) { + return NextResponse.json( + { error: 'Token fehlt.' }, + { status: 400 } + ); + } + + const supabase = createServiceClient(); + + // Buchung laden + const { data: buchung, error: fetchError } = await supabase + .from('buchungen') + .select('id, name, email, wunschdatum, wasserquelle, wassermenge_m3, status') + .eq('storno_token', token) + .single(); + + if (fetchError || !buchung) { + return NextResponse.json( + { error: 'Ungültiger Storno-Link.' }, + { status: 404 } + ); + } + + if (buchung.status === 'storniert') { + return NextResponse.json( + { error: 'Diese Buchung wurde bereits storniert.' }, + { status: 409 } + ); + } + + // Status auf storniert setzen + const { error: updateError } = await supabase + .from('buchungen') + .update({ status: 'storniert' }) + .eq('id', buchung.id); + + if (updateError) { + console.error('Storno Update Error:', updateError); + return NextResponse.json( + { error: 'Fehler beim Stornieren.' }, + { status: 500 } + ); + } + + // Storno-Bestätigungsmail senden + const datumFormatiert = new Date(buchung.wunschdatum + 'T00:00:00').toLocaleDateString('de-AT', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + }); + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://gemeindeportal.vercel.app'; + + try { + await getResend().emails.send({ + from: 'Gemeindeamt Weißkirchen ', + to: buchung.email, + subject: `Stornierung Ihrer Pool-Befüllung — ${datumFormatiert}`, + html: ` +

+
+

Gemeindeamt Weißkirchen

+

an der Traun

+
+
+

Sehr geehrte/r ${buchung.name},

+

Ihre Pool-Befüllung wurde erfolgreich storniert.

+
+ + + + + + + + + + + + + + ${buchung.wassermenge_m3 ? ` + + + + + ` : ''} +
Status:Storniert
Termin:${datumFormatiert}
Wasserquelle:${buchung.wasserquelle === 'brunnen' ? 'Eigener Brunnen' : 'Ortswasserleitung'}
Wassermenge:${buchung.wassermenge_m3} m³
+
+ +

+ Bei Fragen wenden Sie sich bitte an:
+ gemeinde@weisskirchen.ooe.gv.at
+ Tel: +43 7243 50600 +

+

Mit freundlichen Grüßen,
Gemeindeamt Weißkirchen an der Traun

+
+
+ Gemeindeplatz 1, 4616 Weißkirchen an der Traun +
+
+ `, + }); + } catch (emailError) { + console.error('Storno-Mail Versand fehlgeschlagen:', emailError); + } + + return NextResponse.json({ success: true }); + } catch (err) { + console.error('Storno POST Error:', err); + return NextResponse.json( + { error: 'Interner Serverfehler.' }, + { status: 500 } + ); + } +} diff --git a/src/app/pool/page.tsx b/src/app/pool/page.tsx index c9beff0..2edb22b 100644 --- a/src/app/pool/page.tsx +++ b/src/app/pool/page.tsx @@ -37,6 +37,7 @@ export default function PoolBuchungPage() { telefon: '', email: '', }); + const [erinnerung, setErinnerung] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); const [showConfirmation, setShowConfirmation] = useState(false); @@ -143,6 +144,7 @@ export default function PoolBuchungPage() { wassermenge_m3: wassermenge ? parseFloat(wassermenge) : null, wunschdatum: selectedDate, captchaToken, + erinnerung_naechstes_jahr: erinnerung, }), }); const data = await res.json(); @@ -167,6 +169,7 @@ export default function PoolBuchungPage() { setWassermenge(''); setSelectedDate(null); setCaptchaToken(''); + setErinnerung(false); setFieldErrors({}); setStep('wasser'); setShowConfirmation(false); @@ -472,6 +475,19 @@ export default function PoolBuchungPage() { {fieldErrors.email &&

{fieldErrors.email}

} + {/* Erinnerung */} + + {/* CAPTCHA */}
diff --git a/src/app/storno/page.tsx b/src/app/storno/page.tsx new file mode 100644 index 0000000..169efdf --- /dev/null +++ b/src/app/storno/page.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import Header from '@/components/Header'; +import Footer from '@/components/Footer'; + +interface StornoBuchung { + id: string; + name: string; + wunschdatum: string; + wasserquelle: 'brunnen' | 'ortswasserleitung'; + wassermenge_m3: number | null; + status: 'aktiv' | 'storniert' | 'erledigt'; +} + +export default function StornoPage() { + return ( + +
+
+
+
+
+
+
+
+
+
+ }> + + + ); +} + +function StornoContent() { + const searchParams = useSearchParams(); + const token = searchParams.get('token'); + + const [buchung, setBuchung] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [tokenError, setTokenError] = useState(false); + const [storniert, setStorniert] = useState(false); + + useEffect(() => { + if (!token) { + setTokenError(true); + setLoading(false); + return; + } + + async function loadBuchung() { + try { + const res = await fetch(`/api/storno?token=${token}`); + if (!res.ok) { + setTokenError(true); + return; + } + const data = await res.json(); + setBuchung(data); + if (data.status === 'storniert') { + setStorniert(true); + } + } catch { + setTokenError(true); + } finally { + setLoading(false); + } + } + + loadBuchung(); + }, [token]); + + const handleStorno = async () => { + setError(''); + setSubmitting(true); + try { + const res = await fetch('/api/storno', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + if (res.status === 409) { + setStorniert(true); + return; + } + + if (!res.ok) { + const data = await res.json(); + setError(data.error || 'Fehler beim Stornieren.'); + return; + } + + setStorniert(true); + } catch { + setError('Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } finally { + setSubmitting(false); + } + }; + + const datumFormatiert = buchung + ? new Date(buchung.wunschdatum + 'T00:00:00').toLocaleDateString('de-AT', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + }) + : ''; + + const wasserquelleLabel = buchung?.wasserquelle === 'brunnen' ? 'Eigener Brunnen' : 'Ortswasserleitung'; + + // Loading + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); + } + + // Invalid token + if (tokenError || !buchung) { + return ( +
+
+
+
+
+ + + +
+

Ungültiger Link

+

+ Dieser Storno-Link ist ungültig oder abgelaufen. Bitte kontaktieren Sie das Gemeindeamt. +

+
+
+
+
+ ); + } + + // Already cancelled + if (storniert) { + return ( +
+
+
+
+
+ + + +
+

Buchung storniert

+

+ Ihre Pool-Befüllung am {datumFormatiert} wurde erfolgreich storniert. +

+

+ Sie erhalten eine Bestätigung per E-Mail. +

+ + Neuen Termin buchen + +
+
+
+
+ ); + } + + // Show booking details + cancel button + return ( +
+
+
+
+

Buchung stornieren

+

+ Möchten Sie Ihre Pool-Befüllung wirklich stornieren? +

+
+ + {/* Booking details */} +
+
+ Termin + {datumFormatiert} +
+
+ Wasserquelle + {wasserquelleLabel} +
+ {buchung.wassermenge_m3 && ( +
+ Wassermenge + {buchung.wassermenge_m3} m³ +
+ )} +
+ Status + Aktiv +
+
+ +
+ Nach der Stornierung wird der Termin freigegeben und kann von anderen gebucht werden. +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + Abbrechen + +
+
+
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 6a8f014..5c25c78 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,8 @@ export interface Buchung { wunschdatum: string; status: 'aktiv' | 'storniert' | 'erledigt'; notiz: string | null; + storno_token: string | null; + erinnerung_naechstes_jahr: boolean; erstellt_am: string; erstellt_von: 'buerger' | 'admin'; }