30 Commits

Author SHA1 Message Date
c1aeb7c38b chore: Bump version to v0.1.5.2 2025-10-09 16:02:33 +02:00
889e110dd9 fix: Preisanzeige korrigieren - Cent zu Euro Konvertierung
Problem: Preise wurden in Cent gespeichert aber fälschlicherweise als Euro angezeigt
Lösung: Alle Backend-APIs konvertieren Preise korrekt von Cent zu Euro (/100)

Betroffene Dateien:
- bookings.ts: E-Mail-Templates und Preisberechnungen
- cancellation.ts: Preisberechnungen für Stornierungen
- email-templates.ts: HTML-E-Mail-Templates
- email.ts: ICS-Kalender-Integration
- caldav.ts: CalDAV-Kalender-Export

Jetzt werden Preise konsistent in Euro angezeigt (z.B. 50.00€ statt 5000.00€)
2025-10-09 15:59:00 +02:00
a603232ed8 feat: Erweiterte Filtermöglichkeiten für Buchungsverwaltung
- Neue Filter-Modi: Zukünftige (default), Alle, Datum
- Filter-Buttons mit visueller Hervorhebung des aktiven Filters
- Datumsauswahl nur sichtbar wenn Datum-Filter aktiv
- Default-Filter zeigt alle zukünftigen (nicht stornierten) Buchungen
- Angepasste Leermeldungen je nach aktivem Filter
2025-10-09 08:50:25 +02:00
f0037226a9 chore: Bump version to v0.1.5.1 2025-10-09 08:09:19 +02:00
12da9812df fix: Feature-Flag für Multi-Treatment-Verfügbarkeit aktivieren
Das Feature-Flag USE_MULTI_TREATMENTS_AVAILABILITY war noch auf false,
wodurch das Formular die alte treatmentId statt treatmentIds[] API verwendete.
Dies verhinderte das Laden verfügbarer Uhrzeiten.
2025-10-09 08:05:59 +02:00
ce019a2bd9 chore: Bump version to v0.1.5 2025-10-08 19:59:20 +02:00
63384aa209 Fix: TypeScript-Fehler für Multi-Treatment-Migration beheben
- admin-calendar.tsx: getTreatmentNames für treatments[] angepasst
- admin-calendar.tsx: getAvailableTimes für treatmentIds[] umgestellt
- admin-calendar.tsx: createManualBooking sendet treatments[] statt treatmentId
- cancellation.ts: treatmentId optional behandeln (Rückwärtskompatibilität)
- review-submission-page.tsx: treatmentName durch treatments[] ersetzt
- booking-status-page.tsx: proposed date/time als optional markiert

Docker-Build erfolgreich getestet.
2025-10-08 19:57:10 +02:00
ebd9d8a72e Refactor: Verbessere CalDAV und Bookings für Multi-Treatment-Support
- CalDAV SUMMARY zeigt jetzt alle Treatment-Namen als Liste statt Anzahl
- Treatments-Array im Booking-Type optional für Rückwärtskompatibilität
- Neue addMinutesToTime Helper-Funktion für saubere DTEND-Berechnung
- getTreatmentNames filtert leere Namen und liefert sicheren Fallback
2025-10-08 19:50:16 +02:00
ccba9d443b Fix ICS calendar issues and improve email template code quality
- Fix ATTENDEE mailto to use customerEmail instead of customerName
- Add icsEscape helper for proper RFC 5545 text escaping
- Calculate ICS duration from treatments array instead of separate param
- Add renderTreatmentList helper to reduce code duplication in email templates
2025-10-08 18:45:34 +02:00
9583148e02 Fix: Korrigiere Konflikt-Erkennung für Multi-Treatment-Buchungen in recurring-availability.ts - Berechne Dauer korrekt für neue Behandlungen-Arrays - Filtere undefined Treatment-IDs aus Legacy-Cache - Erstelle Treatment-Cache nur bei Bedarf 2025-10-08 18:26:09 +02:00
d153aad8b3 Refactor booking-form: Add compatibility fallback, functional state updates, memoized calculations, and treatment reconciliation 2025-10-08 18:17:59 +02:00
94e269697a Config: Caddy Log-Level auf INFO setzen
Unterdrückt WARN-Meldungen für normale Live-Subscription disconnects
2025-10-08 12:57:41 +02:00
ad79531f33 Release v0.1.4
Features:
- Admin kann Nachrichten an Kunden senden
- Email-System mit BCC an Admin
- UI: Nachricht-Button und Modal
- HTML-Escaping für sichere Nachrichtenanzeige
- Detailliertes Logging
2025-10-08 11:24:41 +02:00
db1a401230 Build: Update server-dist artifacts for v0.1.4 2025-10-08 11:24:03 +02:00
cceb4d4e60 Feature: Admin kann Nachrichten an Kunden senden
- Neues Email-Template für Kundennachrichten
- RPC-Funktion sendCustomerMessage für zukünftige Termine
- UI: Nachricht-Button und Modal in Admin-Buchungen
- Email mit BCC an Admin für Monitoring
- HTML-Escaping für sichere Nachrichtenanzeige
- Detailliertes Logging für Debugging
2025-10-08 11:13:59 +02:00
ca20516080 v0.1.3 2025-10-07 14:15:17 +02:00
f2963ca951 Android PWA-Installationshinweis mit direktem Install-Button hinzugefügt 2025-10-07 13:57:28 +02:00
8aea5bb400 Verbesserungen für PWA-Installations-Prompt: Mobile-Menü-Überlappung behoben, iOS safe-area Unterstützung, localStorage-Fehlerbehandlung und erweiterte standalone-Erkennung 2025-10-07 13:41:03 +02:00
14d0c2f9c3 Add PWA manifest and apple-touch-icon meta tag 2025-10-07 13:12:31 +02:00
eb9ddc535f Add static serving for /icons/* and manifest.json 2025-10-07 12:58:36 +02:00
8fa17f58c9 Add PWA icons (192x192, 512x512, apple-touch-icon) 2025-10-07 12:46:17 +02:00
92ed7a2c93 feat: Firmenname aus .env in E-Mail-Titeln anzeigen
- COMPANY_NAME wird aus .env gelesen
- Wird in separater Zeile über dem eigentlichen Titel angezeigt
- Format: Firmenname (grau, kleiner) + Titel (pink, größer)
- Fallback auf 'Stargirlnails Kiel' wenn nicht gesetzt
2025-10-07 10:50:50 +02:00
ce644c31e1 feat: Offizielle Social-Media-Icons in E-Mails
- Ersetze Emojis (📷 🎵) durch offizielle SVG-Icons
- Gleiche Icons wie auf der Startseite (Instagram & TikTok)
- Inline SVG für bessere Darstellung in E-Mail-Clients
- Icons sind 16x16px mit margin-right für besseren Abstand
2025-10-07 10:44:30 +02:00
3b67c26216 feat: Altersbestätigung (16+) im Buchungsformular
- Neue Pflicht-Checkbox: Mindestalter 16 Jahre
- Direkt unter der AGB-Checkbox platziert
- Validierung beim Absenden des Formulars
- Wird nach erfolgreicher Buchung zurückgesetzt
2025-10-07 10:19:43 +02:00
f2fed22ea1 fix: AGB.pdf Download funktioniert jetzt
- Route für /AGB.pdf hinzugefügt (serveStatic)
- AGB.pdf wird aus dem public-Verzeichnis bereitgestellt
- Behebt Problem, dass Link zur Startseite führte
2025-10-07 10:17:59 +02:00
ab5e5e67a6 feat: Login-Formular merkt sich Benutzername
- Benutzername wird in localStorage gespeichert
- Beim nächsten Login automatisch ausgefüllt
- Verbessert UX für wiederkehrende Logins
2025-10-07 10:14:15 +02:00
78a379546c feat: Buchungsformular merkt sich Benutzerdaten
- Name, E-Mail und Telefonnummer werden in localStorage gespeichert
- Beim nächsten Besuch werden die Felder automatisch ausgefüllt
- Verbessert die User Experience für wiederkehrende Buchungen
2025-10-07 10:13:06 +02:00
953a970220 fix: Caddy-Timeouts für Live-Queries deaktiviert
- read_timeout und write_timeout auf 0 gesetzt für unbegrenzte SSE-Verbindungen
- flush_interval -1 für sofortiges Flushen von Streaming-Daten
- Behebt 'context canceled' Fehler bei /rpc/recurringAvailability/live/listRules
2025-10-07 09:47:26 +02:00
c7d9fc689e style: Button 'Termin buchen' dunkler für besseren Kontrast (#790dc6) 2025-10-07 09:40:59 +02:00
f4593cd706 feat: Social-Media-Badges für TikTok und Instagram hinzugefügt
- Neue RPC-Route für Social-Media-URLs aus .env (social.ts)
- Social-Media-Badges auf der Startseite mit attraktiven Buttons
- Social-Media-Icons im Footer aller Seiten
- Social-Media-Links in allen E-Mail-Templates
- URLs aus .env: TIKTOK_PROFILE und INSTAGRAM_PROFILE
2025-10-07 09:32:06 +02:00
58 changed files with 2259 additions and 2839 deletions

View File

@@ -1,7 +1,6 @@
# Admin Account Configuration # Admin Account Configuration
ADMIN_USERNAME=owner ADMIN_USERNAME=owner
ADMIN_PASSWORD_HASH=$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy # bcrypt hashed password ADMIN_PASSWORD_HASH=YWRtaW4xMjM= # Base64 encoded password
# Legacy Base64 hashes are automatically migrated to bcrypt on server start/first login
# Domain Configuration # Domain Configuration
DOMAIN=localhost:5173 # For production: your-domain.com DOMAIN=localhost:5173 # For production: your-domain.com
@@ -11,7 +10,9 @@ RESEND_API_KEY=your_resend_api_key_here
EMAIL_FROM=noreply@yourdomain.com EMAIL_FROM=noreply@yourdomain.com
ADMIN_EMAIL=admin@yourdomain.com ADMIN_EMAIL=admin@yourdomain.com
# Frontend URL (for E-Mail Links) # Social media profiles
TIKTOK_PROFILE=https://www.tiktok.com/@<dein Tiktok Profil>
INSTAGRAM_PROFILE=https://www.instagram.com/<dein Instragram Profil>
# Cancellation Policy (in hours) # Cancellation Policy (in hours)
MIN_STORNO_TIMESPAN=24 MIN_STORNO_TIMESPAN=24

View File

@@ -8,6 +8,16 @@ stargirlnails.de {
health_uri /health health_uri /health
health_interval 30s health_interval 30s
health_timeout 5s health_timeout 5s
# Timeouts für lange laufende Verbindungen (Live-Queries)
transport http {
read_timeout 0
write_timeout 0
dial_timeout 30s
}
# Buffer-Flush für Server-Sent Events (SSE) aktivieren
flush_interval -1
} }
# Sicherheits-Header # Sicherheits-Header
@@ -30,6 +40,7 @@ stargirlnails.de {
log { log {
output file /var/log/caddy/access.log output file /var/log/caddy/access.log
format json format json
level INFO
} }
# Favicon-Konfiguration (innerhalb der Hauptdomain) # Favicon-Konfiguration (innerhalb der Hauptdomain)

View File

@@ -1,7 +1,29 @@
# Multi-stage build for production
FROM node:22-alpine AS base
# Install pnpm
RUN npm install -g pnpm
# Set working directory
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build the application
RUN pnpm build
# Production stage
FROM node:22-alpine AS production FROM node:22-alpine AS production
# Install pnpm and required runtime tools # Install pnpm and su-exec
RUN npm install -g pnpm ts-node && apk add --no-cache su-exec curl RUN npm install -g pnpm ts-node && apk add --no-cache su-exec
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -12,12 +34,17 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Install production dependencies only # Install production dependencies only
RUN pnpm install --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod
# Copy prebuilt application artifacts from repository (no TS build in image) # Copy built application from base stage
COPY dist ./dist COPY --from=base /app/dist ./dist
COPY server-dist ./server-dist COPY --from=base /app/server-dist ./server-dist
COPY public ./public COPY --from=base /app/public ./public
# Copy necessary runtime files # Copy necessary files for runtime
COPY --from=base /app/src/server/index.ts ./src/server/index.ts
COPY --from=base /app/src/server/routes ./src/server/routes
COPY --from=base /app/src/server/rpc ./src/server/rpc
COPY --from=base /app/src/server/lib ./src/server/lib
COPY --from=base /app/tsconfig.server.json ./tsconfig.server.json
COPY start.sh ./start.sh COPY start.sh ./start.sh
# Create non-root user for security # Create non-root user for security

View File

@@ -12,8 +12,6 @@ Ein vollständiges Buchungssystem für Nagelstudios mit Admin-Panel, Kalender un
- [Hono](https://hono.dev/) - [Hono](https://hono.dev/)
- [Zod](https://zod.dev/) - [Zod](https://zod.dev/)
> Hinweis zu DOMPurify: Wir nutzen `isomorphic-dompurify`, das DOMPurify bereits mitliefert und sowohl in Node.js als auch im Browser funktioniert. Eine zusätzliche Installation von `dompurify` ist daher nicht erforderlich und würde eine redundante Abhängigkeit erzeugen.
## Setup ## Setup
### 1. Umgebungsvariablen konfigurieren ### 1. Umgebungsvariablen konfigurieren
@@ -24,28 +22,33 @@ Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvaria
cp .env.example .env cp .env.example .env
``` ```
### 2. Admin-Passwort Hash generieren (bcrypt) ### 2. Admin-Passwort Hash generieren
Das Admin-Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert. So erzeugst du einen Hash: Das Admin-Passwort wird als Base64-Hash in der `.env` Datei gespeichert. Hier sind verschiedene Methoden, um einen Hash zu generieren:
#### Node.js (empfohlen) #### PowerShell (Windows)
```bash ```powershell
node -e "require('bcrypt').hash('dein_sicheres_passwort', 10).then(console.log)" # Einfache Methode mit Base64-Encoding
$password = "dein_sicheres_passwort"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($password)
$hash = [System.Convert]::ToBase64String($bytes)
Write-Host "Password Hash: $hash"
# Alternative mit PowerShell 7+ (kürzer)
$password = "dein_sicheres_passwort"
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($password))
``` ```
Alternativ kannst du ein kleines Script verwenden (falls du es öfter brauchst): #### Node.js (falls verfügbar)
```javascript ```javascript
// scripts/generate-hash.js // In der Node.js Konsole oder als separates Script
require('bcrypt').hash(process.argv[2] || 'dein_sicheres_passwort', 10).then(h => { const password = "dein_sicheres_passwort";
console.log(h); const hash = Buffer.from(password).toString('base64');
}); console.log("Password Hash:", hash);
``` ```
Ausführen: #### Online-Tools (nur für Entwicklung)
```bash - Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/)
node scripts/generate-hash.js "dein_sicheres_passwort"
```
### 3. .env Datei konfigurieren ### 3. .env Datei konfigurieren
@@ -54,8 +57,7 @@ Bearbeite deine `.env` Datei und setze die generierten Werte:
```env ```env
# Admin Account Configuration # Admin Account Configuration
ADMIN_USERNAME=owner ADMIN_USERNAME=owner
# bcrypt-Hash des Admin-Passworts (kein Base64). Beispielwert: ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash
ADMIN_PASSWORD_HASH=$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
# Domain Configuration # Domain Configuration
DOMAIN=localhost:5173 # Für Produktion: deine-domain.de DOMAIN=localhost:5173 # Für Produktion: deine-domain.de
@@ -207,12 +209,10 @@ Nach dem Setup kannst du dich mit den in der `.env` konfigurierten Admin-Credent
⚠️ **Wichtige Hinweise:** ⚠️ **Wichtige Hinweise:**
- Ändere das Standard-Passwort vor dem Produktionseinsatz - Ändere das Standard-Passwort vor dem Produktionseinsatz
- Das Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert - Das Passwort wird als Base64-Hash in der `.env` Datei gespeichert
- Verwende ein sicheres Passwort und generiere den entsprechenden Hash - Verwende ein sicheres Passwort und generiere den entsprechenden Hash
- Die `.env` Datei sollte niemals in das Repository committet werden - Die `.env` Datei sollte niemals in das Repository committet werden
Hinweis zur Migration: Vorhandene Base64-Hashes aus älteren Versionen werden beim Server-Start automatisch in bcrypt migriert. Zusätzlich erfolgt beim nächsten erfolgreichen Login ebenfalls eine Migration, falls noch erforderlich.
### Security.txt Endpoint ### Security.txt Endpoint
Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`: Die Anwendung bietet einen RFC 9116 konformen Security.txt Endpoint unter `/.well-known/security.txt`:

View File

@@ -17,7 +17,7 @@ services:
networks: networks:
- stargirlnails-network - stargirlnails-network
healthcheck: healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -36,7 +36,6 @@ services:
- ./Caddyfile:/etc/caddy/Caddyfile:ro - ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data - caddy-data:/data
- caddy-config:/config - caddy-config:/config
- caddy-logs:/var/log/caddy
networks: networks:
- stargirlnails-network - stargirlnails-network
depends_on: depends_on:
@@ -50,8 +49,6 @@ volumes:
driver: local driver: local
caddy-config: caddy-config:
driver: local driver: local
caddy-logs:
driver: local
# Netzwerk für interne Kommunikation # Netzwerk für interne Kommunikation
networks: networks:

View File

@@ -1,121 +0,0 @@
# CalDAV-Kalender Einrichtung
## Übersicht
Die App bietet einen CalDAV-Endpunkt, um Buchungen in externe Kalender-Apps zu synchronisieren. Aus Sicherheitsgründen erfolgt die Authentifizierung per Header. Unterstützt werden:
- Authorization: Bearer <TOKEN>
- Basic Auth, wobei der Token als Benutzername übergeben wird (Passwort leer/optional)
## Token generieren
1. Als Admin einloggen
2. Im Admin-Bereich den CalDAV-Token generieren
3. Token und URL werden angezeigt
**Wichtig:** Der Token ist 24 Stunden gültig. Danach muss ein neuer Token generiert werden.
## Endpunkt
```
GET /caldav/calendar/events.ics
Authorization: Bearer <TOKEN>
oder
Authorization: Basic <base64(token:)> # Token als Benutzername, Passwort leer
```
## Unterstützte Kalender-Apps
### ✅ Thunderbird (empfohlen)
1. Kalender → Neuer Kalender → Im Netzwerk
2. Format: CalDAV
3. Standort: CalDAV-URL eingeben
4. Benutzername: Den generierten Token eingeben (Basic Auth)
5. Passwort: Leer lassen
### ✅ Outlook (mit Einschränkungen)
1. Datei → Kontoeinstellungen → Internetkalender
2. URL eingeben
3. Erweiterte Einstellungen → Benutzerdefinierte Header:
```
Authorization: Bearer <TOKEN>
```
**Hinweis:** Nicht alle Outlook-Versionen unterstützen benutzerdefinierte Header.
### ⚠️ Apple Calendar (eingeschränkt)
Apple Calendar unterstützt keine Authorization-Header für Kalenderabonnements.
**Alternativen:**
- Manuelle ICS-Datei importieren (nicht automatisch aktualisiert)
- CalDAV-Bridge verwenden (z.B. über Proxy)
### ⚠️ Google Calendar (eingeschränkt)
Google Calendar unterstützt keine Authorization-Header für URL-Abonnements.
**Alternativen:**
- Google Apps Script als Bridge verwenden
- Manuelle ICS-Datei importieren
## Testen mit cURL
```bash
# Bearer
curl -H "Authorization: Bearer <DEIN_TOKEN>" \
https://deine-domain.de/caldav/calendar/events.ics
# Basic (Token als Benutzername, Passwort leer)
curl -H "Authorization: Basic $(printf "%s" "<DEIN_TOKEN>:" | base64)" \
https://deine-domain.de/caldav/calendar/events.ics
```
Erwartete Antwort: ICS-Datei mit allen bestätigten und ausstehenden Buchungen.
## Sicherheit
### Warum Authorization-Header?
- **Query-Parameter** (`?token=...`) werden in Browser-History, Server-Logs und Referrer-Headers gespeichert
- **Authorization-Header** werden nicht geloggt und nicht in der URL sichtbar
- Folgt REST-API Best Practices
### Token-Verwaltung
- Token sind 24 Stunden gültig
- Abgelaufene Token werden automatisch beim nächsten Zugriff gelöscht
- Bei Bedarf neuen Token generieren (alte Token werden nicht automatisch invalidiert)
### Backward Compatibility
Die alte Methode mit Query-Parameter (`?token=...`) wird noch unterstützt, aber als **deprecated** markiert. Der Server sendet zusätzlich Response-Header (`Deprecation: true` und `Warning: 299 ...`). Eine Warnung wird im Server-Log ausgegeben.
## Troubleshooting
### "Unauthorized - Token required"
- Prüfe, ob der Authorization-Header korrekt gesetzt ist
- Format: `Authorization: Bearer <TOKEN>` (mit Leerzeichen nach "Bearer")
### "Unauthorized - Invalid or expired token"
- Token ist abgelaufen (24h Gültigkeit)
- Generiere einen neuen Token im Admin-Bereich
### Kalender zeigt keine Termine
- Prüfe, ob Buchungen mit Status "confirmed" oder "pending" existieren
- Teste den Endpunkt mit cURL
- Prüfe Server-Logs auf Fehler
## Zukünftige Verbesserungen
- [ ] Langlebige Token mit Refresh-Mechanismus
- [ ] Token-Revocation-Endpoint
- [ ] CalDAV-Bridge für Apple Calendar und Google Calendar
- [ ] Webhook-basierte Push-Notifications statt Polling

View File

@@ -37,38 +37,17 @@ Das System verwendet ein Rate-Limiting, um Spam und Missbrauch des Buchungsformu
- **Zeitfenster:** 10 Minuten - **Zeitfenster:** 10 Minuten
- **Verhalten:** Nach 5 Anfragen muss der Nutzer 10 Minuten warten - **Verhalten:** Nach 5 Anfragen muss der Nutzer 10 Minuten warten
### Login (IP-basiert)
- **Limit:** 5 fehlgeschlagene Login-Versuche pro IP-Adresse
- **Zeitfenster:** 15 Minuten
- **Verhalten:** Das Limit zählt nur fehlgeschlagene Versuche. Nach erfolgreichem Login wird der Zähler zurückgesetzt. Bei Überschreitung muss der Nutzer 15 Minuten warten.
- **Zweck:** Schutz vor Brute-Force-Angriffen auf Admin-Accounts
### Admin-Operationen (Benutzer-basiert)
- **Limit:** 30 Anfragen pro Admin-Benutzer
- **Zeitfenster:** 5 Minuten
- **Verhalten:** Nach 30 Anfragen muss der Admin 5 Minuten warten
- **Zweck:** Verhindert versehentliches Spam durch Admin-UI (z.B. Doppelklicks, fehlerhafte Skripte)
- **Betroffene Endpoints:** Treatments (create/update/remove), Bookings (manualCreate/updateStatus/remove), Recurring Availability (alle Schreiboperationen), Gallery (alle Schreiboperationen), Reviews (approve/reject/delete)
### Admin-Operationen (IP-basiert)
- **Limit:** 50 Anfragen pro IP-Adresse
- **Zeitfenster:** 5 Minuten
- **Verhalten:** Nach 50 Anfragen muss 5 Minuten gewartet werden
- **Zweck:** Zusätzlicher Schutz gegen IP-basierte Angriffe auf Admin-Endpoints
## Wie es funktioniert ## Wie es funktioniert
Das Rate-Limiting prüft die passenden Kriterien je Endpoint. Für Admin-Operationen werden **beide** Limits geprüft: Das Rate-Limiting prüft **beide** Kriterien:
1. **E-Mail-Adresse:** Verhindert, dass dieselbe Person mit derselben E-Mail zu viele Anfragen stellt 1. **E-Mail-Adresse:** Verhindert, dass dieselbe Person mit derselben E-Mail zu viele Anfragen stellt
2. **IP-Adresse:** Verhindert, dass jemand mit verschiedenen E-Mail-Adressen von derselben IP aus spammt 2. **IP-Adresse:** Verhindert, dass jemand mit verschiedenen E-Mail-Adressen von derselben IP aus spammt
3. **Benutzer-basiert (Admin):** Limitiert Anfragen je Admin-Benutzer
4. **IP-basiert (Admin):** Limitiert Anfragen je IP zusätzlich
Wenn eines der Limits überschritten wird, erhält der Nutzer eine Fehlermeldung mit Angabe der Wartezeit. Wenn eines der Limits überschritten wird, erhält der Nutzer eine Fehlermeldung mit Angabe der Wartezeit.
## IP-Erkennung ## IP-Erkennung
Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers (unterstützt `Headers`-API und einfache Record-Objekte): Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers:
- `x-forwarded-for` - `x-forwarded-for`
- `x-real-ip` - `x-real-ip`
- `cf-connecting-ip` (Cloudflare) - `cf-connecting-ip` (Cloudflare)
@@ -81,7 +60,7 @@ Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch fo
## Anpassung ## Anpassung
Die Limits können in `src/server/lib/rate-limiter.ts` angepasst werden. Beispiele: Die Limits können in `src/server/lib/rate-limiter.ts` in der Funktion `checkBookingRateLimit()` angepasst werden:
```typescript ```typescript
// E-Mail-Limit anpassen // E-Mail-Limit anpassen
@@ -95,23 +74,6 @@ const ipConfig: RateLimitConfig = {
maxRequests: 5, // Anzahl der Anfragen maxRequests: 5, // Anzahl der Anfragen
windowMs: 10 * 60 * 1000, // Zeitfenster in Millisekunden windowMs: 10 * 60 * 1000, // Zeitfenster in Millisekunden
}; };
// Login-Bruteforce-Schutz
const loginConfig: RateLimitConfig = {
maxRequests: 5,
windowMs: 15 * 60 * 1000,
};
// Admin-Operationen
const adminUserConfig: RateLimitConfig = {
maxRequests: 30,
windowMs: 5 * 60 * 1000,
};
const adminIpConfig: RateLimitConfig = {
maxRequests: 50,
windowMs: 5 * 60 * 1000,
};
``` ```
## Fehlermeldungen ## Fehlermeldungen
@@ -129,5 +91,3 @@ Für Produktionsumgebungen empfehlen sich:
- ✅ Whitelist für vertrauenswürdige IPs (z.B. Admin-Zugang) - ✅ Whitelist für vertrauenswürdige IPs (z.B. Admin-Zugang)
- ✅ Anpassung der Limits basierend auf tatsächlichem Nutzungsverhalten - ✅ Anpassung der Limits basierend auf tatsächlichem Nutzungsverhalten
Siehe auch `docs/redis-migration.md` für Hinweise zur Migration auf Redis in Multi-Instance-Setups.

View File

@@ -1,355 +0,0 @@
# Redis Migration Guide
## Overview
This guide covers migrating from in-memory KV storage to Redis for multi-instance SaaS deployments. The current in-memory session storage works well for single-instance deployments but requires centralized storage for horizontal scaling.
### When to Migrate
**Current Setup (Sufficient For):**
- Single-instance deployments
- Development environments
- Small-scale production deployments
**Redis Required For:**
- Multiple server instances behind load balancer
- Container orchestration (Kubernetes, Docker Swarm)
- High-availability SaaS deployments
- Session persistence across server restarts
## What Needs Migration
### Critical Data (Must Migrate)
- **Sessions** (`sessionsKV`): Essential for authentication across instances
- **CSRF tokens**: Stored within session objects
- **Rate limiting data**: Currently in-memory Map in `rate-limiter.ts`
### Optional Data (Can Remain File-based)
- Bookings, treatments, reviews, gallery photos
- Can stay in file-based KV for now
- Migrate later if performance becomes an issue
## Redis Setup
### Installation Options
#### Self-hosted Redis
```bash
# Docker
docker run -d -p 6379:6379 redis:alpine
# Ubuntu/Debian
sudo apt update
sudo apt install redis-server
# macOS (Homebrew)
brew install redis
brew services start redis
```
#### Managed Services
- **Redis Cloud**: Managed Redis service
- **AWS ElastiCache**: Redis-compatible cache
- **Azure Cache for Redis**: Microsoft's managed service
- **DigitalOcean Managed Databases**: Redis option available
### Configuration
Add to `.env`:
```bash
# Redis Configuration (required for multi-instance deployments)
REDIS_URL=redis://localhost:6379
REDIS_TLS_ENABLED=false # Set to true for production
REDIS_PASSWORD=your_redis_password # Optional
```
## Code Changes
### 1. Install Redis Client
```bash
pnpm add ioredis
pnpm add -D @types/ioredis
```
### 2. Create Redis KV Adapter
Create `src/server/lib/create-redis-kv.ts`:
```typescript
import Redis from 'ioredis';
interface RedisKVStore<T> {
getItem(key: string): Promise<T | null>;
setItem(key: string, value: T): Promise<void>;
removeItem(key: string): Promise<void>;
getAllItems(): Promise<T[]>;
subscribe(): AsyncIterable<void>;
}
export function createRedisKV<T>(namespace: string): RedisKVStore<T> {
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
retryDelayOnFailover: 100,
enableReadyCheck: false,
maxRetriesPerRequest: null,
lazyConnect: true,
});
const prefix = `${namespace}:`;
return {
async getItem(key: string): Promise<T | null> {
const value = await redis.get(`${prefix}${key}`);
return value ? JSON.parse(value) : null;
},
async setItem(key: string, value: T): Promise<void> {
await redis.set(`${prefix}${key}`, JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
await redis.del(`${prefix}${key}`);
},
async getAllItems(): Promise<T[]> {
const keys = await redis.keys(`${prefix}*`);
if (keys.length === 0) return [];
const values = await redis.mget(...keys);
return values
.filter(v => v !== null)
.map(v => JSON.parse(v!));
},
async* subscribe(): AsyncIterable<void> {
const pubsub = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
await pubsub.subscribe(`${namespace}:changes`);
for await (const message of pubsub) {
if (message[0] === 'message' && message[1] === `${namespace}:changes`) {
yield;
}
}
}
};
}
// Helper to notify subscribers of changes
export async function notifyRedisChanges(namespace: string): Promise<void> {
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
await redis.publish(`${namespace}:changes`, 'update');
await redis.quit();
}
```
### 3. Update Session Storage
In `src/server/lib/auth.ts`:
```typescript
import { createRedisKV } from './create-redis-kv.js';
// Replace this line:
// export const sessionsKV = createKV<Session>("sessions");
// With this:
export const sessionsKV = createRedisKV<Session>("sessions");
```
### 4. Update Rate Limiter
In `src/server/lib/rate-limiter.ts`:
```typescript
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
retryDelayOnFailover: 100,
enableReadyCheck: false,
maxRetriesPerRequest: null,
lazyConnect: true,
});
export async function checkBookingRateLimit(
email?: string,
ip?: string
): Promise<{ allowed: boolean; resetTime?: number }> {
const now = Date.now();
const windowMs = 15 * 60 * 1000; // 15 minutes
const maxRequests = 3;
const results = await Promise.all([
email ? checkRateLimit(`booking:email:${email}`, maxRequests, windowMs) : { allowed: true },
ip ? checkRateLimit(`booking:ip:${ip}`, maxRequests, windowMs) : { allowed: true }
]);
return {
allowed: results.every(r => r.allowed),
resetTime: results.find(r => !r.allowed)?.resetTime
};
}
async function checkRateLimit(
key: string,
maxRequests: number,
windowMs: number
): Promise<{ allowed: boolean; resetTime?: number }> {
const now = Date.now();
const windowStart = now - windowMs;
// Use Redis sorted set for sliding window
const pipeline = redis.pipeline();
// Remove old entries
pipeline.zremrangebyscore(key, 0, windowStart);
// Count current entries
pipeline.zcard(key);
// Add current request
pipeline.zadd(key, now, `${now}-${Math.random()}`);
// Set expiry
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const currentCount = results?.[1]?.[1] as number || 0;
return {
allowed: currentCount < maxRequests,
resetTime: currentCount >= maxRequests ? windowStart + windowMs : undefined
};
}
```
### 5. Environment Configuration
Update `.env.example`:
```bash
# Redis Configuration (optional - required for multi-instance SaaS deployments)
# For single-instance deployments, the default in-memory storage is sufficient
# See docs/redis-migration.md for migration guide
REDIS_URL=redis://localhost:6379
REDIS_TLS_ENABLED=false # Enable for production
REDIS_PASSWORD=your_redis_password # Optional
```
## Migration Strategy
### Development Testing
1. **Start Redis locally**:
```bash
docker run -d -p 6379:6379 redis:alpine
```
2. **Test session functionality**:
- Login/logout
- Session validation
- CSRF token generation/validation
- Session rotation
3. **Test rate limiting**:
- Verify rate limit enforcement
- Check sliding window behavior
4. **Simulate multi-instance**:
- Run multiple server instances
- Verify session sharing works
### Staging Deployment
1. **Deploy Redis in staging**
2. **Run single instance first** to verify functionality
3. **Scale to multiple instances** and test session sharing
4. **Monitor Redis memory usage** and connection pool
### Production Rollout
#### Option A: Blue/Green Deployment
1. Deploy new version with Redis to green environment
2. Test thoroughly with production-like data
3. Switch traffic to green environment
4. **Note**: Existing sessions will be lost (users need to re-login)
#### Option B: Gradual Migration
1. Deploy Redis alongside existing system
2. Write to both stores temporarily (dual-write)
3. Read from Redis first, fallback to in-memory
4. After verification period, remove in-memory store
## Monitoring & Maintenance
### Key Metrics
- **Redis memory usage**: Monitor `used_memory` and `used_memory_peak`
- **Connection pool**: Track active connections and pool utilization
- **Session operations**: Monitor session creation/validation latency
- **Rate limit hits**: Track rate limit enforcement effectiveness
### Backup Strategy
- **Enable Redis persistence**: Configure RDB snapshots or AOF logging
- **Regular backups**: Backup Redis data regularly
- **Session data**: Ephemeral, but rate limit data should be backed up
### Scaling Considerations
- **Redis Cluster**: For horizontal scaling across multiple nodes
- **Redis Sentinel**: For high availability with automatic failover
- **Connection pooling**: Configure appropriate pool sizes
## Rollback Plan
If issues arise, you can revert to in-memory storage:
1. **Remove Redis imports** and revert to `createKV`
2. **Remove Redis environment variables**
3. **Redeploy** with in-memory storage
4. **Note**: All sessions will be lost (users need to re-login)
## Cost Considerations
### Redis Hosting Costs
- **Self-hosted**: Server costs + maintenance
- **Managed services**:
- Redis Cloud: ~$7/month for 30MB
- AWS ElastiCache: ~$15/month for t3.micro
- Azure Cache: ~$16/month for Basic tier
### Memory Requirements
**Session storage**: Minimal
- Example: 1000 concurrent users × 1KB per session = ~1MB
- CSRF tokens add minimal overhead
**Rate limiting**: Negligible
- Sliding window data is automatically cleaned up
- Minimal memory footprint per IP/email
## Alternative Solutions
### Sticky Sessions
- Load balancer routes user to same instance
- **Pros**: Simpler implementation
- **Cons**: Less resilient, harder to scale
### Database-backed Sessions
- Use PostgreSQL/MySQL instead of Redis
- **Pros**: No additional infrastructure
- **Cons**: Higher latency, more database load
### JWT Tokens
- Stateless authentication tokens
- **Pros**: No server-side session storage needed
- **Cons**: No server-side session invalidation, different security model
## References
- [Session Management Guide](session-management.md)
- [Rate Limiting Documentation](rate-limiting.md)
- [Redis Documentation](https://redis.io/documentation)
- [ioredis Library](https://github.com/luin/ioredis)
- [Redis Best Practices](https://redis.io/docs/manual/admin/)

View File

@@ -1,251 +0,0 @@
# Session Management & CSRF Protection
## Overview
This application uses **HttpOnly cookie-based session management** with CSRF protection to provide secure authentication while protecting against common web vulnerabilities like XSS and CSRF attacks.
### Security Benefits
- **XSS Protection**: SessionId stored in HttpOnly cookies is not accessible to malicious JavaScript
- **CSRF Protection**: Double-submit cookie pattern prevents cross-site request forgery
- **Session Rotation**: New sessions created after login and password changes prevent session fixation
- **GDPR Compliance**: HttpOnly cookies provide better privacy protection than localStorage
## Architecture
### Session Storage
Sessions are stored in an in-memory KV store with the following structure:
```typescript
type Session = {
id: string;
userId: string;
expiresAt: string;
createdAt: string;
csrfToken?: string;
}
```
- **Expiration**: 24 hours
- **Storage**: In-memory KV store (single-instance deployment)
- **CSRF Token**: Cryptographically secure 64-character hex string
### Cookie Configuration
#### Session Cookie (`sessionId`)
- **Type**: HttpOnly, Secure (production), SameSite=Lax
- **Path**: `/`
- **MaxAge**: 86400 seconds (24 hours)
- **Purpose**: Authenticates user across requests
#### CSRF Cookie (`csrf-token`)
- **Type**: Non-HttpOnly, Secure (production), SameSite=Lax
- **Path**: `/`
- **MaxAge**: 86400 seconds (24 hours)
- **Purpose**: Provides CSRF token for JavaScript to include in requests
### CSRF Protection
The application uses the **double-submit cookie pattern**:
1. **Server-side**: CSRF token generated and stored in session
2. **Client-side**: Same token stored in non-HttpOnly cookie
3. **Validation**: Token from `X-CSRF-Token` header must match session token
4. **Timing-safe comparison**: Prevents timing attacks
### Session Rotation
Sessions are automatically rotated (new session created, old invalidated) after:
- Successful login
- Password changes
This prevents session fixation attacks.
## Implementation Details
### Server-side
#### Cookie Parsing Middleware (`src/server/routes/rpc.ts`)
```typescript
// Cookie parsing middleware - extracts sessionId from cookies
rpcApp.use("/*", async (c, next) => {
try {
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
c.set('sessionId', sessionId || null);
await next();
} catch (error) {
console.error("Cookie parsing error:", error);
c.set('sessionId', null);
await next();
}
});
```
#### Authentication Helper (`src/server/lib/auth.ts`)
Key functions:
- `generateCSRFToken()`: Creates cryptographically secure token
- `getSessionFromCookies(c)`: Extracts and validates session from cookies
- `validateCSRFToken(c, sessionId)`: Validates CSRF token from header
- `assertOwner(c)`: Validates owner role with session and CSRF checks
- `rotateSession(oldSessionId, userId)`: Creates new session, invalidates old
#### RPC Handler Updates
All admin-only RPC handlers now:
- Accept Hono `context` parameter
- Use `assertOwner(context)` for authentication
- Remove `sessionId` from input schemas
- Automatically get session from cookies
### Client-side
#### RPC Client Configuration (`src/client/rpc-client.ts`)
```typescript
const link = new RPCLink({
url: `${window.location.origin}/rpc`,
headers: () => {
const csrfToken = getCSRFToken();
return csrfToken ? { 'X-CSRF-Token': csrfToken } : {};
},
fetch: (request, init) => {
return fetch(request, {
...init,
credentials: 'include' // Include cookies with all requests
});
}
});
```
#### AuthProvider Updates (`src/client/components/auth-provider.tsx`)
- Removed all localStorage usage
- Sessions managed entirely server-side
- No client-side sessionId storage
## Security Features
### XSS Protection
- SessionId not accessible to JavaScript (HttpOnly cookie)
- Malicious scripts cannot steal session tokens
### CSRF Protection
- Token validation on all state-changing operations (non-GET requests)
- Double-submit cookie pattern prevents CSRF attacks
- Timing-safe comparison prevents timing attacks
### Session Fixation Prevention
- Session rotation after authentication events
- Old sessions invalidated when new ones created
### Secure Defaults
- Secure flag enabled in production (requires HTTPS)
- SameSite=Lax prevents most CSRF attacks
- HttpOnly cookies prevent XSS token theft
## Development vs Production
### Development
- `secure: false` - allows cookies over HTTP (localhost)
- `NODE_ENV !== 'production'` detection
### Production
- `secure: true` - requires HTTPS
- All security flags enabled
## API Reference
### Key Functions (`src/server/lib/auth.ts`)
#### `generateCSRFToken(): string`
Creates a cryptographically secure random token using `crypto.randomBytes(32).toString('hex')`.
#### `getSessionFromCookies(c: Context): Promise<Session | null>`
Extracts sessionId from cookies, validates session exists and hasn't expired.
#### `validateCSRFToken(c: Context, sessionId: string): Promise<void>`
Validates CSRF token from `X-CSRF-Token` header against session token using timing-safe comparison.
#### `assertOwner(c: Context): Promise<void>`
Validates user has owner role and session is valid. Automatically validates CSRF token for non-GET requests.
#### `rotateSession(oldSessionId: string, userId: string): Promise<Session>`
Creates new session with new ID and CSRF token, deletes old session.
## Migration Guide
### Existing Password Hashes
- Base64 password hashes automatically migrated to bcrypt on server startup
- No manual intervention required
### Session Invalidation
- Old localStorage sessions will be invalidated
- Users need to re-login once after deployment
### Testing Migration
1. Deploy new version
2. Verify login creates cookies (check browser DevTools)
3. Test CSRF protection by manually calling API without token
4. Verify session rotation after password change
## Troubleshooting
### Cookies Not Being Sent
- Check `credentials: 'include'` in fetch configuration
- Verify CORS settings allow credentials
- Check cookie domain/path configuration
### CSRF Validation Failing
- Ensure `X-CSRF-Token` header is set
- Verify CSRF cookie is accessible to JavaScript
- Check token format (64-character hex string)
### Session Expired Errors
- Check cookie expiration settings
- Verify server time synchronization
- Check session cleanup logic
### Cross-Origin Issues
- Review CORS configuration for credentials
- Ensure domain configuration matches deployment
- Check SameSite cookie settings
## Future Scaling
For multi-instance deployments, see [Redis Migration Guide](redis-migration.md) for migrating to centralized session storage.
## Environment Configuration
### Required Environment Variables
```bash
# Domain Configuration
DOMAIN=localhost:5173 # For production: your-domain.com
# Note: Session cookies are scoped to this domain
# Server Configuration
NODE_ENV=development # Set to 'production' for production deployment
PORT=3000
```
### Optional Environment Variables
```bash
# Redis Configuration (for multi-instance deployments)
# See docs/redis-migration.md for migration guide
REDIS_URL=redis://localhost:6379
REDIS_TLS_ENABLED=false # Set to true for production
REDIS_PASSWORD=your_redis_password # Optional
```
### Cookie Behavior by Environment
- **Development** (`NODE_ENV !== 'production'`):
- `secure: false` - cookies work over HTTP
- `sameSite: 'Lax'` - allows cross-site navigation
- **Production** (`NODE_ENV === 'production'`):
- `secure: true` - requires HTTPS
- `sameSite: 'Lax'` - prevents most CSRF attacks
## References
- [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [MDN HttpOnly Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies)
- [RFC 6265 - HTTP State Management](https://tools.ietf.org/html/rfc6265)

View File

@@ -3,7 +3,13 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ec4899" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Stargirlnails" />
<title>Stargirlnails Kiel - Terminbuchung</title> <title>Stargirlnails Kiel - Terminbuchung</title>
</head> </head>
<body> <body>

View File

@@ -1,7 +1,7 @@
{ {
"name": "quests-template-basic", "name": "quests-template-basic",
"private": true, "private": true,
"version": "0.0.0", "version": "0.1.5.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"check:types": "tsc --noEmit", "check:types": "tsc --noEmit",
@@ -12,8 +12,6 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@types/bcrypt": "^5.0.2",
"bcrypt": "^5.1.1",
"@hono/node-server": "^1.19.5", "@hono/node-server": "^1.19.5",
"@orpc/client": "^1.8.8", "@orpc/client": "^1.8.8",
"@orpc/server": "^1.8.8", "@orpc/server": "^1.8.8",
@@ -22,7 +20,6 @@
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"hono": "^4.9.4", "hono": "^4.9.4",
"isomorphic-dompurify": "^2.16.0",
"jsonrepair": "^3.13.0", "jsonrepair": "^3.13.0",
"openai": "^5.17.0", "openai": "^5.17.0",
"react": "^19.1.1", "react": "^19.1.1",

791
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

45
public/icons/README.md Normal file
View File

@@ -0,0 +1,45 @@
# PWA Icons Required
This directory must contain the following icon files for proper PWA installation on iOS and Android devices:
## Required Icon Files
### Android Icons
- **icon-192x192.png** (192×192 pixels)
- Used for Android home screen and app drawer
- Should have transparent background or match theme color
- Include safe zone padding for maskable icons (40px margin)
- **icon-512x512.png** (512×512 pixels)
- Used for Android splash screens and high-resolution displays
- Should have transparent background or match theme color
- Include safe zone padding for maskable icons (102px margin)
### iOS Icon
- **apple-touch-icon.png** (180×180 pixels)
- Used for iOS home screen
- Should NOT have transparent background (iOS adds its own rounded corners)
- Fill entire canvas with brand colors/logo
- iOS automatically applies rounded corners and shadow
## Design Guidelines
1. **Brand consistency**: Use Stargirlnails logo and brand colors
2. **Theme color**: Primary pink (#ec4899) matches manifest theme_color
3. **Contrast**: Ensure icon is visible on various backgrounds
4. **Simplicity**: Icons should be recognizable at small sizes
5. **No text**: Avoid small text that becomes unreadable when scaled
## Testing
After adding icons:
- Test on Android: Check home screen icon appearance
- Test on iOS Safari: Add to home screen and verify icon quality
- Validate with Lighthouse PWA audit
## Placeholder
Until actual icons are created, you can use a favicon.png (if available) or generate placeholder icons using tools like:
- https://realfavicongenerator.net/
- https://www.pwabuilder.com/imageGenerator

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

32
public/manifest.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "Stargirlnails Kiel - Terminbuchung",
"short_name": "Stargirlnails",
"description": "Online Terminbuchung für Nagelstudio Stargirlnails in Kiel",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ec4899",
"orientation": "portrait",
"lang": "de",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}

View File

@@ -45,20 +45,5 @@ for i in {1..18}; do
sleep 5 sleep 5
done done
echo "[6b/7] Verify HTTP /health via Caddy (localhost)" echo "[7/7] Tail recent logs (press Ctrl+C to exit)"
for i in {1..12}; do sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f
if curl -fsS http://localhost/health >/dev/null 2>&1; then
echo "Caddy proxy responds OK on /health."
break
fi
sleep 5
done
echo "[7/7] Tail recent logs for app and caddy (press Ctrl+C to exit)"
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f stargirlnails &
APP_LOG_PID=$!
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f caddy &
CADDY_LOG_PID=$!
trap "echo 'Stopping log tails...'; kill $APP_LOG_PID $CADDY_LOG_PID 2>/dev/null || true" INT TERM
wait $APP_LOG_PID $CADDY_LOG_PID || true

View File

@@ -55,6 +55,9 @@ if (process.env.NODE_ENV === 'production') {
app.use('/assets/*', serveStatic({ root: './dist' })); app.use('/assets/*', serveStatic({ root: './dist' }));
} }
app.use('/favicon.png', serveStatic({ path: './public/favicon.png' })); app.use('/favicon.png', serveStatic({ path: './public/favicon.png' }));
app.use('/AGB.pdf', serveStatic({ path: './public/AGB.pdf' }));
app.use('/icons/*', serveStatic({ root: './public' }));
app.use('/manifest.json', serveStatic({ path: './public/manifest.json' }));
app.route("/rpc", rpcApp); app.route("/rpc", rpcApp);
app.route("/caldav", caldavApp); app.route("/caldav", caldavApp);
app.get("/*", clientEntry); app.get("/*", clientEntry);

View File

@@ -28,13 +28,17 @@ async function renderBrandedEmail(title, bodyHtml) {
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const homepageUrl = `${protocol}://${domain}`; const homepageUrl = `${protocol}://${domain}`;
const instagramProfile = process.env.INSTAGRAM_PROFILE;
const tiktokProfile = process.env.TIKTOK_PROFILE;
const companyName = process.env.COMPANY_NAME || 'Stargirlnails Kiel';
return ` return `
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;"> <div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<tr> <tr>
<td style="padding:24px 24px 0 24px; text-align:center;"> <td style="padding:24px 24px 0 24px; text-align:center;">
${logo ? `<img src="${logo}" alt="Stargirlnails" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`} ${logo ? `<img src="${logo}" alt="${companyName}" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
<h1 style="margin:16px 0 0 0; font-size:22px; color:#db2777;">${title}</h1> <div style="margin:16px 0 4px 0; font-size:16px; font-weight:600; color:#64748b;">${companyName}</div>
<h1 style="margin:0; font-size:22px; color:#db2777;">${title}</h1>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -46,6 +50,29 @@ async function renderBrandedEmail(title, bodyHtml) {
<div style="text-align:center; margin-bottom:16px;"> <div style="text-align:center; margin-bottom:16px;">
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a> <a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
</div> </div>
${(instagramProfile || tiktokProfile) ? `
<div style="text-align:center; margin-bottom:16px;">
<p style="font-size:14px; color:#64748b; margin:0 0 8px 0;">Folge uns auf Social Media:</p>
<div style="display:inline-block;">
${instagramProfile ? `
<a href="${instagramProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%); color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
Instagram
</a>
` : ''}
${tiktokProfile ? `
<a href="${tiktokProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:#000000; color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
TikTok
</a>
` : ''}
</div>
</div>
` : ''}
<div style="font-size:12px; color:#64748b; text-align:center;"> <div style="font-size:12px; color:#64748b; text-align:center;">
&copy; ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care &copy; ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
</div> </div>
@@ -256,3 +283,35 @@ export async function renderAdminRescheduleExpiredHTML(params) {
`; `;
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner); return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
} }
export async function renderCustomerMessageHTML(params) {
const { customerName, message, appointmentDate, appointmentTime, treatmentName } = params;
const formattedDate = appointmentDate ? formatDateGerman(appointmentDate) : null;
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const ownerName = process.env.OWNER_NAME || 'Stargirlnails Kiel';
const inner = `
<p>Hallo ${customerName},</p>
${(appointmentDate && appointmentTime && treatmentName) ? `
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Zu deinem Termin:</p>
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Behandlung:</strong> ${treatmentName}</li>
<li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${appointmentTime}</li>
</ul>
</div>
` : ''}
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #f59e0b;">💬 Nachricht von ${ownerName}:</p>
<div style="margin: 12px 0 0 0; color: #475569; white-space: pre-wrap; line-height: 1.6;">${message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
</div>
<p>Bei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>${ownerName}</p>
`;
return renderBrandedEmail("Nachricht zu deinem Termin", inner);
}

View File

@@ -97,28 +97,33 @@ export async function sendEmail(params) {
console.warn("Resend API key not configured. Skipping email send."); console.warn("Resend API key not configured. Skipping email send.");
return { success: false }; return { success: false };
} }
const payload = {
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
reply_to: params.replyTo ? (Array.isArray(params.replyTo) ? params.replyTo : [params.replyTo]) : undefined,
attachments: params.attachments,
};
console.log(`Sending email via Resend: to=${JSON.stringify(payload.to)}, subject="${params.subject}"`);
const response = await fetch("https://api.resend.com/emails", { const response = await fetch("https://api.resend.com/emails", {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${RESEND_API_KEY}`, "Authorization": `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify(payload),
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
attachments: params.attachments,
}),
}); });
if (!response.ok) { if (!response.ok) {
const body = await response.text().catch(() => ""); const body = await response.text().catch(() => "");
console.error("Resend send error:", response.status, body); console.error("Resend send error:", response.status, body);
return { success: false }; return { success: false };
} }
const responseData = await response.json().catch(() => ({}));
console.log("Resend email sent successfully:", responseData);
return { success: true }; return { success: true };
} }
export async function sendEmailWithAGB(params) { export async function sendEmailWithAGB(params) {

View File

@@ -24,5 +24,5 @@ export function clientEntry(c) {
cssFiles = ["/assets/index-RdX4PbOO.css"]; cssFiles = ["/assets/index-RdX4PbOO.css"];
} }
} }
return c.html(_jsxs("html", { lang: "en", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { content: "width=device-width, initial-scale=1", name: "viewport" }), _jsx("title", { children: "Stargirlnails Kiel" }), _jsx("link", { rel: "icon", type: "image/png", href: "/favicon.png" }), cssFiles && cssFiles.map((css) => (_jsx("link", { rel: "stylesheet", href: css }, css))), process.env.NODE_ENV === 'production' ? (_jsx("script", { src: jsFile, type: "module" })) : (_jsxs(_Fragment, { children: [_jsx("script", { src: "/@vite/client", type: "module" }), _jsx("script", { src: jsFile, type: "module" })] }))] }), _jsx("body", { children: _jsx("div", { id: "root" }) })] })); return c.html(_jsxs("html", { lang: "de", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { content: "width=device-width, initial-scale=1", name: "viewport" }), _jsx("meta", { name: "theme-color", content: "#ec4899" }), _jsx("meta", { name: "apple-mobile-web-app-capable", content: "yes" }), _jsx("meta", { name: "apple-mobile-web-app-status-bar-style", content: "default" }), _jsx("meta", { name: "apple-mobile-web-app-title", content: "Stargirlnails" }), _jsx("title", { children: "Stargirlnails Kiel" }), _jsx("link", { rel: "icon", type: "image/png", href: "/favicon.png" }), _jsx("link", { rel: "apple-touch-icon", href: "/icons/apple-touch-icon.png" }), _jsx("link", { rel: "manifest", href: "/manifest.json" }), cssFiles && cssFiles.map((css) => (_jsx("link", { rel: "stylesheet", href: css }, css))), process.env.NODE_ENV === 'production' ? (_jsx("script", { src: jsFile, type: "module" })) : (_jsxs(_Fragment, { children: [_jsx("script", { src: "/@vite/client", type: "module" }), _jsx("script", { src: jsFile, type: "module" })] }))] }), _jsx("body", { children: _jsx("div", { id: "root" }) })] }));
} }

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js"; import { createKV } from "../lib/create-kv.js";
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js"; import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML, renderCustomerMessageHTML } from "../lib/email-templates.js";
import { createORPCClient } from "@orpc/client"; import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch"; import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit } from "../lib/rate-limiter.js"; import { checkBookingRateLimit } from "../lib/rate-limiter.js";
@@ -745,4 +745,63 @@ export const router = {
} }
}; };
}), }),
// Admin sendet Nachricht an Kunden
sendCustomerMessage: os
.input(z.object({
sessionId: z.string(),
bookingId: z.string(),
message: z.string().min(1, "Nachricht darf nicht leer sein").max(5000, "Nachricht ist zu lang (max. 5000 Zeichen)"),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.bookingId);
if (!booking)
throw new Error("Buchung nicht gefunden");
// Check if booking has customer email
if (!booking.customerEmail) {
throw new Error("Diese Buchung hat keine E-Mail-Adresse. Bitte kontaktiere den Kunden telefonisch.");
}
// Check if booking is in the future
const today = new Date().toISOString().split("T")[0];
const bookingDate = booking.appointmentDate;
if (bookingDate < today) {
throw new Error("Nachrichten können nur für zukünftige Termine gesendet werden.");
}
// Get treatment name for context
const treatment = await treatmentsKV.getItem(booking.treatmentId);
const treatmentName = treatment?.name || "Behandlung";
// Prepare email with Reply-To header
const ownerName = process.env.OWNER_NAME || "Stargirlnails Kiel";
const emailFrom = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
const replyToEmail = process.env.ADMIN_EMAIL;
const formattedDate = formatDateGerman(bookingDate);
const html = await renderCustomerMessageHTML({
customerName: booking.customerName,
message: input.message,
appointmentDate: bookingDate,
appointmentTime: booking.appointmentTime,
treatmentName: treatmentName,
});
const textContent = `Hallo ${booking.customerName},\n\nZu deinem Termin:\nBehandlung: ${treatmentName}\nDatum: ${formattedDate}\nUhrzeit: ${booking.appointmentTime}\n\nNachricht von ${ownerName}:\n${input.message}\n\nBei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!\n\nLiebe Grüße,\n${ownerName}`;
// Send email with BCC to admin for monitoring
// Note: Not using explicit 'from' or 'replyTo' to match behavior of other system emails
console.log(`Sending customer message to ${booking.customerEmail} for booking ${input.bookingId}`);
console.log(`Email config: from=${emailFrom}, replyTo=${replyToEmail}, bcc=${replyToEmail}`);
const emailResult = await sendEmail({
to: booking.customerEmail,
subject: `Nachricht zu deinem Termin am ${formattedDate}`,
text: textContent,
html: html,
bcc: replyToEmail ? [replyToEmail] : undefined,
});
if (!emailResult.success) {
console.error(`Failed to send customer message to ${booking.customerEmail}`);
throw new Error("E-Mail konnte nicht versendet werden. Bitte überprüfe die E-Mail-Konfiguration oder versuche es später erneut.");
}
console.log(`Successfully sent customer message to ${booking.customerEmail}`);
return {
success: true,
message: `Nachricht wurde erfolgreich an ${booking.customerEmail} gesendet.`
};
}),
}; };

View File

@@ -7,6 +7,7 @@ import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal.js"; import { router as legal } from "./legal.js";
import { router as gallery } from "./gallery.js"; import { router as gallery } from "./gallery.js";
import { router as reviews } from "./reviews.js"; import { router as reviews } from "./reviews.js";
import { router as social } from "./social.js";
export const router = { export const router = {
demo, demo,
treatments, treatments,
@@ -17,4 +18,5 @@ export const router = {
legal, legal,
gallery, gallery,
reviews, reviews,
social,
}; };

10
server-dist/rpc/social.js Normal file
View File

@@ -0,0 +1,10 @@
import { os } from "@orpc/server";
const getSocialMedia = os.handler(async () => {
return {
tiktokProfile: process.env.TIKTOK_PROFILE,
instagramProfile: process.env.INSTAGRAM_PROFILE,
};
});
export const router = os.router({
getSocialMedia,
});

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
import { useAuth } from "@/client/components/auth-provider"; import { useAuth } from "@/client/components/auth-provider";
import { LoginForm } from "@/client/components/login-form"; import { LoginForm } from "@/client/components/login-form";
import { UserProfile } from "@/client/components/user-profile"; import { UserProfile } from "@/client/components/user-profile";
@@ -14,11 +16,18 @@ import BookingStatusPage from "@/client/components/booking-status-page";
import ReviewSubmissionPage from "@/client/components/review-submission-page"; import ReviewSubmissionPage from "@/client/components/review-submission-page";
import LegalPage from "@/client/components/legal-page"; import LegalPage from "@/client/components/legal-page";
import { ProfileLanding } from "@/client/components/profile-landing"; import { ProfileLanding } from "@/client/components/profile-landing";
import { PWAInstallPrompt } from "@/client/components/pwa-install-prompt";
function App() { function App() {
const { user, isLoading, isOwner } = useAuth(); const { user, isLoading, isOwner } = useAuth();
const [activeTab, setActiveTab] = useState<"profile-landing" | "booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "admin-gallery" | "admin-reviews" | "profile" | "legal">("profile-landing"); const [activeTab, setActiveTab] = useState<"profile-landing" | "booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "admin-gallery" | "admin-reviews" | "profile" | "legal">("profile-landing");
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { data: socialMedia } = useQuery(
queryClient.social.getSocialMedia.queryOptions()
);
const hasSocialMedia = (socialMedia as any)?.tiktokProfile || (socialMedia as any)?.instagramProfile;
// Prevent background scroll when menu is open // Prevent background scroll when menu is open
useEffect(() => { useEffect(() => {
@@ -28,10 +37,15 @@ function App() {
// Handle booking status page // Handle booking status page
const path = window.location.pathname; const path = window.location.pathname;
const PwaPrompt = <PWAInstallPrompt />;
if (path.startsWith('/booking/')) { if (path.startsWith('/booking/')) {
const token = path.split('/booking/')[1]; const token = path.split('/booking/')[1];
if (token) { if (token) {
return <BookingStatusPage token={token} />; return <>
{PwaPrompt}
<BookingStatusPage token={token} />
</>;
} }
} }
@@ -39,7 +53,10 @@ function App() {
if (path.startsWith('/review/')) { if (path.startsWith('/review/')) {
const token = path.split('/review/')[1]; const token = path.split('/review/')[1];
if (token) { if (token) {
return <ReviewSubmissionPage token={token} />; return <>
{PwaPrompt}
<ReviewSubmissionPage token={token} />
</>;
} }
} }
@@ -373,11 +390,44 @@ function App() {
)} )}
</main> </main>
{/* PWA Installation Prompt for iOS */}
<PWAInstallPrompt hidden={isMobileMenuOpen} />
{/* Footer */} {/* Footer */}
<footer className="bg-white border-t border-pink-100 mt-16"> <footer className="bg-white border-t border-pink-100 mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-600"> <div className="text-center text-gray-600">
<p>&copy; 2025 Stargirlnails Kiel. Professional nail design & care with 🩷 and passion in Kiel 🌇.</p> <p className="mb-4">&copy; 2025 Stargirlnails Kiel. Professional nail design & care with 🩷 and passion in Kiel 🌇.</p>
{hasSocialMedia && (
<div className="flex justify-center items-center gap-3 mt-4">
{(socialMedia as any)?.instagramProfile && (
<a
href={(socialMedia as any).instagramProfile}
target="_blank"
rel="noopener noreferrer"
className="text-pink-600 hover:text-pink-700 transition-colors"
aria-label="Instagram"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
</a>
)}
{(socialMedia as any)?.tiktokProfile && (
<a
href={(socialMedia as any).tiktokProfile}
target="_blank"
rel="noopener noreferrer"
className="text-gray-800 hover:text-gray-900 transition-colors"
aria-label="TikTok"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
</a>
)}
</div>
)}
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -24,12 +24,12 @@ export function AdminAvailability() {
// Neue Queries für wiederkehrende Verfügbarkeiten (mit Authentifizierung) // Neue Queries für wiederkehrende Verfügbarkeiten (mit Authentifizierung)
const { data: recurringRules, refetch: refetchRecurringRules } = useQuery( const { data: recurringRules, refetch: refetchRecurringRules } = useQuery(
queryClient.recurringAvailability.live.adminListRules.experimental_liveOptions({ queryClient.recurringAvailability.live.adminListRules.experimental_liveOptions({
input: {} input: { sessionId: localStorage.getItem("sessionId") || "" }
}) })
); );
const { data: timeOffPeriods } = useQuery( const { data: timeOffPeriods } = useQuery(
queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({ queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({
input: {} input: { sessionId: localStorage.getItem("sessionId") || "" }
}) })
); );
@@ -177,9 +177,14 @@ export function AdminAvailability() {
return; return;
} }
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createRule( createRule(
{ dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime }, { sessionId, dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`); setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`);
@@ -228,8 +233,13 @@ export function AdminAvailability() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
toggleRuleActive( toggleRuleActive(
{ id: rule.id }, { sessionId, id: rule.id },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`); setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`);
@@ -246,8 +256,13 @@ export function AdminAvailability() {
</button> </button>
<button <button
onClick={() => { onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deleteRule( deleteRule(
{ id: rule.id }, { sessionId, id: rule.id },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg("Regel gelöscht."); setSuccessMsg("Regel gelöscht.");
@@ -333,9 +348,14 @@ export function AdminAvailability() {
return; return;
} }
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createTimeOff( createTimeOff(
{ startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason }, { sessionId, startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg("Urlaubszeit hinzugefügt."); setSuccessMsg("Urlaubszeit hinzugefügt.");
@@ -395,8 +415,13 @@ export function AdminAvailability() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deleteTimeOff( deleteTimeOff(
{ id: period.id }, { sessionId, id: period.id },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg("Urlaubszeit gelöscht."); setSuccessMsg("Urlaubszeit gelöscht.");

View File

@@ -3,10 +3,13 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client"; import { queryClient } from "@/client/rpc-client";
export function AdminBookings() { export function AdminBookings() {
const [filterMode, setFilterMode] = useState<"upcoming" | "all" | "date">("upcoming");
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const [selectedPhoto, setSelectedPhoto] = useState<string>(""); const [selectedPhoto, setSelectedPhoto] = useState<string>("");
const [showPhotoModal, setShowPhotoModal] = useState(false); const [showPhotoModal, setShowPhotoModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState<string | null>(null); const [showCancelConfirm, setShowCancelConfirm] = useState<string | null>(null);
const [showMessageModal, setShowMessageModal] = useState<string | null>(null);
const [messageText, setMessageText] = useState<string>("");
const [successMsg, setSuccessMsg] = useState<string>(""); const [successMsg, setSuccessMsg] = useState<string>("");
const [errorMsg, setErrorMsg] = useState<string>(""); const [errorMsg, setErrorMsg] = useState<string>("");
@@ -49,8 +52,33 @@ export function AdminBookings() {
}) })
); );
const getTreatmentName = (treatmentId: string) => { const { mutate: sendMessage, isPending: isSendingMessage } = useMutation(
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung"; queryClient.bookings.sendCustomerMessage.mutationOptions({
onSuccess: () => {
setSuccessMsg("Nachricht wurde erfolgreich gesendet.");
setShowMessageModal(null);
setMessageText("");
},
onError: (error: any) => {
setErrorMsg(error?.message || "Fehler beim Senden der Nachricht.");
}
})
);
const getTreatmentNames = (booking: any) => {
// Handle new treatments array structure
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
const names = booking.treatments
.map((t: any) => t.name)
.filter((name: string) => name && name.trim())
.join(", ");
return names || "Keine Behandlung";
}
// Fallback to deprecated treatmentId for backward compatibility
if (booking.treatmentId) {
return treatments?.find(t => t.id === booking.treatmentId)?.name || "Unbekannte Behandlung";
}
return "Keine Behandlung";
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -83,9 +111,52 @@ export function AdminBookings() {
setSelectedPhoto(""); setSelectedPhoto("");
}; };
const filteredBookings = bookings?.filter(booking => const openMessageModal = (bookingId: string) => {
selectedDate ? booking.appointmentDate === selectedDate : true setShowMessageModal(bookingId);
).sort((a, b) => { setMessageText("");
};
const closeMessageModal = () => {
setShowMessageModal(null);
setMessageText("");
};
const handleSendMessage = () => {
if (!showMessageModal || !messageText.trim()) {
setErrorMsg("Bitte gib eine Nachricht ein.");
return;
}
sendMessage({
sessionId: localStorage.getItem("sessionId") || "",
bookingId: showMessageModal,
message: messageText.trim(),
});
};
// Check if booking is in the future
const isFutureBooking = (appointmentDate: string) => {
const today = new Date().toISOString().split("T")[0];
return appointmentDate >= today;
};
// Filter bookings based on selected filter mode
const filteredBookings = bookings?.filter(booking => {
if (filterMode === "upcoming") {
// Show all future bookings (not cancelled)
const bookingDate = new Date(booking.appointmentDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
bookingDate.setHours(0, 0, 0, 0);
return bookingDate >= today && booking.status !== "cancelled";
} else if (filterMode === "date") {
// Show bookings for specific date
return booking.appointmentDate === selectedDate;
} else {
// Show all bookings
return true;
}
}).sort((a, b) => {
if (a.appointmentDate === b.appointmentDate) { if (a.appointmentDate === b.appointmentDate) {
return a.appointmentTime.localeCompare(b.appointmentTime); return a.appointmentTime.localeCompare(b.appointmentTime);
} }
@@ -162,22 +233,54 @@ export function AdminBookings() {
</div> </div>
</div> </div>
{/* Date Filter */} {/* Filter Section */}
<div className="bg-white rounded-lg shadow p-4 mb-6"> <div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex items-center space-x-4"> <div className="flex flex-col space-y-4">
<label className="text-sm font-medium text-gray-700">Filter by date:</label> <div className="flex items-center space-x-2">
<input <label className="text-sm font-medium text-gray-700">Filter:</label>
type="date" <button
value={selectedDate} onClick={() => setFilterMode("upcoming")}
onChange={(e) => setSelectedDate(e.target.value)} className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
className="p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500" filterMode === "upcoming"
/> ? "bg-pink-600 text-white"
<button : "bg-gray-100 text-gray-700 hover:bg-gray-200"
onClick={() => setSelectedDate("")} }`}
className="text-sm text-pink-600 hover:text-pink-800" >
> Zukünftige
Show All </button>
</button> <button
onClick={() => setFilterMode("all")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
filterMode === "all"
? "bg-pink-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
Alle
</button>
<button
onClick={() => setFilterMode("date")}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
filterMode === "date"
? "bg-pink-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
Datum
</button>
</div>
{filterMode === "date" && (
<div className="flex items-center space-x-4 pl-16">
<label className="text-sm font-medium text-gray-700">Wähle Datum:</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
/>
</div>
)}
</div> </div>
</div> </div>
@@ -216,8 +319,8 @@ export function AdminBookings() {
<div className="text-sm text-gray-500">{booking.customerPhone || '—'}</div> <div className="text-sm text-gray-500">{booking.customerPhone || '—'}</div>
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4">
<div className="text-sm text-gray-900">{getTreatmentName(booking.treatmentId)}</div> <div className="text-sm text-gray-900">{getTreatmentNames(booking)}</div>
{booking.notes && ( {booking.notes && (
<div className="text-sm text-gray-500">Notizen: {booking.notes}</div> <div className="text-sm text-gray-500">Notizen: {booking.notes}</div>
)} )}
@@ -251,45 +354,57 @@ export function AdminBookings() {
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2"> <div className="flex flex-col space-y-2">
{booking.status === "pending" && ( <div className="flex space-x-2">
<> {booking.status === "pending" && (
<>
<button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900"
>
Confirm
</button>
<button
onClick={() => setShowCancelConfirm(booking.id)}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{booking.status === "confirmed" && (
<>
<button
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "completed" })}
className="text-blue-600 hover:text-blue-900"
>
Complete
</button>
<button
onClick={() => setShowCancelConfirm(booking.id)}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{(booking.status === "cancelled" || booking.status === "completed") && (
<button <button
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })} onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900" className="text-green-600 hover:text-green-900"
> >
Confirm Reactivate
</button> </button>
<button )}
onClick={() => setShowCancelConfirm(booking.id)} </div>
className="text-red-600 hover:text-red-900" {/* Show message button for future bookings with email */}
> {isFutureBooking(booking.appointmentDate) && booking.customerEmail && (
Cancel
</button>
</>
)}
{booking.status === "confirmed" && (
<>
<button
onClick={() => updateBookingStatus({ id: booking.id, status: "completed" })}
className="text-blue-600 hover:text-blue-900"
>
Complete
</button>
<button
onClick={() => setShowCancelConfirm(booking.id)}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{(booking.status === "cancelled" || booking.status === "completed") && (
<button <button
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })} onClick={() => openMessageModal(booking.id)}
className="text-green-600 hover:text-green-900" className="text-pink-600 hover:text-pink-900 text-left"
title="Nachricht an Kunden senden"
> >
Reactivate 💬 Nachricht
</button> </button>
)} )}
</div> </div>
@@ -301,8 +416,10 @@ export function AdminBookings() {
{!filteredBookings?.length && ( {!filteredBookings?.length && (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500">
{selectedDate {filterMode === "date"
? `Keine Buchungen für ${new Date(selectedDate).toLocaleDateString()} gefunden` ? `Keine Buchungen für ${new Date(selectedDate).toLocaleDateString()} gefunden`
: filterMode === "upcoming"
? "Keine zukünftigen Buchungen verfügbar."
: "Keine Buchungen verfügbar." : "Keine Buchungen verfügbar."
} }
</div> </div>
@@ -352,7 +469,8 @@ export function AdminBookings() {
<div className="flex space-x-3"> <div className="flex space-x-3">
<button <button
onClick={() => { onClick={() => {
updateBookingStatus({ id: showCancelConfirm, status: "cancelled" }); const sessionId = localStorage.getItem("sessionId") || "";
updateBookingStatus({ sessionId, id: showCancelConfirm, status: "cancelled" });
}} }}
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors" className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
> >
@@ -368,6 +486,116 @@ export function AdminBookings() {
</div> </div>
</div> </div>
)} )}
{/* Message Modal */}
{showMessageModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">Nachricht an Kunden senden</h3>
<button
onClick={closeMessageModal}
className="text-gray-400 hover:text-gray-600 text-2xl"
disabled={isSendingMessage}
>
×
</button>
</div>
{(() => {
const booking = bookings?.find(b => b.id === showMessageModal);
if (!booking) return null;
// Calculate totals for multiple treatments
const hasTreatments = booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0;
const totalDuration = hasTreatments
? booking.treatments.reduce((sum: number, t: any) => sum + (t.duration || 0), 0)
: (booking.bookedDurationMinutes || 0);
const totalPrice = hasTreatments
? booking.treatments.reduce((sum: number, t: any) => sum + (t.price || 0), 0)
: 0;
return (
<div className="mb-4 bg-gray-50 p-4 rounded-md">
<p className="text-sm text-gray-700">
<strong>Kunde:</strong> {booking.customerName}
</p>
<p className="text-sm text-gray-700">
<strong>E-Mail:</strong> {booking.customerEmail}
</p>
<p className="text-sm text-gray-700">
<strong>Termin:</strong> {new Date(booking.appointmentDate).toLocaleDateString()} um {booking.appointmentTime}
</p>
<div className="text-sm text-gray-700 mt-2">
<strong>Behandlungen:</strong>
{hasTreatments ? (
<div className="mt-1 ml-2">
{booking.treatments.map((treatment: any, index: number) => (
<div key={index} className="mb-1">
{treatment.name} ({treatment.duration} Min., {treatment.price})
</div>
))}
{booking.treatments.length > 1 && (
<div className="mt-2 pt-2 border-t border-gray-300 font-semibold">
Gesamt: {totalDuration} Min., {totalPrice.toFixed(2)}
</div>
)}
</div>
) : booking.treatmentId ? (
<div className="mt-1 ml-2">
{treatments?.find(t => t.id === booking.treatmentId)?.name || "Unbekannte Behandlung"}
</div>
) : (
<span className="ml-2 text-gray-500">Keine Behandlung</span>
)}
</div>
</div>
);
})()}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Deine Nachricht
</label>
<textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder="Schreibe hier deine Nachricht an den Kunden..."
rows={6}
maxLength={5000}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
disabled={isSendingMessage}
/>
<p className="text-xs text-gray-500 mt-1">
{messageText.length} / 5000 Zeichen
</p>
</div>
<div className="bg-blue-50 border-l-4 border-blue-400 p-3 mb-4">
<p className="text-sm text-blue-700">
💡 <strong>Hinweis:</strong> Der Kunde kann direkt auf diese E-Mail antworten. Die Antwort geht an die in den Einstellungen hinterlegte Admin-E-Mail-Adresse.
</p>
</div>
<div className="flex space-x-3">
<button
onClick={handleSendMessage}
disabled={isSendingMessage || !messageText.trim()}
className="flex-1 bg-pink-600 text-white py-2 px-4 rounded-md hover:bg-pink-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{isSendingMessage ? "Wird gesendet..." : "Nachricht senden"}
</button>
<button
onClick={closeMessageModal}
disabled={isSendingMessage}
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors disabled:bg-gray-100 disabled:cursor-not-allowed"
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -47,7 +47,7 @@ export function AdminCalendar() {
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({ ...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: { input: {
date: createFormData.appointmentDate, date: createFormData.appointmentDate,
treatmentId: createFormData.treatmentId treatmentIds: createFormData.treatmentId ? [createFormData.treatmentId] : []
} }
}), }),
enabled: !!createFormData.appointmentDate && !!createFormData.treatmentId enabled: !!createFormData.appointmentDate && !!createFormData.treatmentId
@@ -58,7 +58,16 @@ export function AdminCalendar() {
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({ ...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: { input: {
date: rescheduleFormData.appointmentDate, date: rescheduleFormData.appointmentDate,
treatmentId: (showRescheduleModal ? bookings?.find(b => b.id === showRescheduleModal)?.treatmentId : '') || '' treatmentIds: (() => {
const booking = showRescheduleModal ? bookings?.find(b => b.id === showRescheduleModal) : null;
if (!booking) return [];
// Use new treatments array if available
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
return booking.treatments.map((t: any) => t.id);
}
// Fallback to deprecated treatmentId for backward compatibility
return booking.treatmentId ? [booking.treatmentId] : [];
})()
} }
}), }),
enabled: !!showRescheduleModal && !!rescheduleFormData.appointmentDate enabled: !!showRescheduleModal && !!rescheduleFormData.appointmentDate
@@ -86,8 +95,16 @@ export function AdminCalendar() {
queryClient.bookings.generateCalDAVToken.mutationOptions() queryClient.bookings.generateCalDAVToken.mutationOptions()
); );
const getTreatmentName = (treatmentId: string) => { const getTreatmentNames = (booking: any) => {
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung"; // Handle new treatments array structure
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
return booking.treatments.map((t: any) => t.name).join(", ");
}
// Fallback to deprecated treatmentId for backward compatibility
if (booking.treatmentId) {
return treatments?.find(t => t.id === booking.treatmentId)?.name || "Unbekannte Behandlung";
}
return "Keine Behandlung";
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -164,18 +181,24 @@ export function AdminCalendar() {
}; };
const handleStatusUpdate = (bookingId: string, newStatus: string) => { const handleStatusUpdate = (bookingId: string, newStatus: string) => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId) return;
updateBookingStatus({ updateBookingStatus({
sessionId,
id: bookingId, id: bookingId,
status: newStatus as "pending" | "confirmed" | "cancelled" | "completed" status: newStatus as "pending" | "confirmed" | "cancelled" | "completed"
}); });
}; };
const handleDeleteBooking = () => { const handleDeleteBooking = () => {
if (!showDeleteConfirm) return; const sessionId = localStorage.getItem('sessionId');
if (!sessionId || !showDeleteConfirm) return;
if (deleteActionType === 'cancel') { if (deleteActionType === 'cancel') {
// For cancel action, use updateStatus instead of remove // For cancel action, use updateStatus instead of remove
updateBookingStatus({ updateBookingStatus({
sessionId,
id: showDeleteConfirm, id: showDeleteConfirm,
status: "cancelled" status: "cancelled"
}, { }, {
@@ -191,6 +214,7 @@ export function AdminCalendar() {
} else { } else {
// For delete action, use remove with email option // For delete action, use remove with email option
removeBooking({ removeBooking({
sessionId,
id: showDeleteConfirm, id: showDeleteConfirm,
sendEmail: sendDeleteEmail, sendEmail: sendDeleteEmail,
}, { }, {
@@ -209,9 +233,32 @@ export function AdminCalendar() {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const handleCreateBooking = () => { const handleCreateBooking = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId) return;
// Convert treatmentId to treatments array
const selectedTreatment = treatments?.find(t => t.id === createFormData.treatmentId);
if (!selectedTreatment) {
setCreateError('Bitte wähle eine Behandlung aus.');
return;
}
const treatmentsArray = [{
id: selectedTreatment.id,
name: selectedTreatment.name,
duration: selectedTreatment.duration,
price: selectedTreatment.price
}];
createManualBooking({ createManualBooking({
...createFormData sessionId,
treatments: treatmentsArray,
customerName: createFormData.customerName,
appointmentDate: createFormData.appointmentDate,
appointmentTime: createFormData.appointmentTime,
customerEmail: createFormData.customerEmail,
customerPhone: createFormData.customerPhone,
notes: createFormData.notes
}, { }, {
onSuccess: () => { onSuccess: () => {
setShowCreateModal(false); setShowCreateModal(false);
@@ -252,11 +299,13 @@ export function AdminCalendar() {
}; };
const handleRescheduleBooking = () => { const handleRescheduleBooking = () => {
if (!showRescheduleModal) return; const sessionId = localStorage.getItem('sessionId');
if (!sessionId || !showRescheduleModal) return;
const booking = bookings?.find(b => b.id === showRescheduleModal); const booking = bookings?.find(b => b.id === showRescheduleModal);
if (!booking) return; if (!booking) return;
proposeReschedule({ proposeReschedule({
sessionId,
bookingId: booking.id, bookingId: booking.id,
proposedDate: rescheduleFormData.appointmentDate, proposedDate: rescheduleFormData.appointmentDate,
proposedTime: rescheduleFormData.appointmentTime, proposedTime: rescheduleFormData.appointmentTime,
@@ -273,8 +322,11 @@ export function AdminCalendar() {
}; };
const handleGenerateCalDAVToken = () => { const handleGenerateCalDAVToken = () => {
const sessionId = localStorage.getItem('sessionId');
if (!sessionId) return;
generateCalDAVToken({ generateCalDAVToken({
sessionId
}, { }, {
onSuccess: (data) => { onSuccess: (data) => {
setCaldavData(data); setCaldavData(data);
@@ -454,7 +506,7 @@ export function AdminCalendar() {
<div <div
key={booking.id} key={booking.id}
className={`text-xs p-1 rounded border-l-2 ${getStatusColor(booking.status)} truncate`} className={`text-xs p-1 rounded border-l-2 ${getStatusColor(booking.status)} truncate`}
title={`${booking.customerName} - ${getTreatmentName(booking.treatmentId)} (${booking.appointmentTime})`} title={`${booking.customerName} - ${getTreatmentNames(booking)} (${booking.appointmentTime})`}
> >
<div className="font-medium">{booking.appointmentTime}</div> <div className="font-medium">{booking.appointmentTime}</div>
<div className="truncate">{booking.customerName}</div> <div className="truncate">{booking.customerName}</div>
@@ -511,7 +563,7 @@ export function AdminCalendar() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
<div> <div>
<strong>Behandlung:</strong> {getTreatmentName(booking.treatmentId)} <strong>Behandlung:</strong> {getTreatmentNames(booking)}
</div> </div>
<div> <div>
<strong>Uhrzeit:</strong> {booking.appointmentTime} <strong>Uhrzeit:</strong> {booking.appointmentTime}
@@ -827,7 +879,7 @@ export function AdminCalendar() {
{(() => { {(() => {
const booking = bookings?.find(b => b.id === showRescheduleModal); const booking = bookings?.find(b => b.id === showRescheduleModal);
const treatmentName = booking ? getTreatmentName(booking.treatmentId) : ''; const treatmentName = booking ? getTreatmentNames(booking) : '';
return booking ? ( return booking ? (
<div className="mb-4 text-sm text-gray-700"> <div className="mb-4 text-sm text-gray-700">
<div className="mb-2"><strong>Kunde:</strong> {booking.customerName}</div> <div className="mb-2"><strong>Kunde:</strong> {booking.customerName}</div>

View File

@@ -14,7 +14,7 @@ export function AdminGallery() {
// Data fetching with live query // Data fetching with live query
const { data: photos, refetch: refetchPhotos } = useQuery( const { data: photos, refetch: refetchPhotos } = useQuery(
queryClient.gallery.live.adminListPhotos.experimental_liveOptions({ queryClient.gallery.live.adminListPhotos.experimental_liveOptions({
input: {} input: { sessionId: localStorage.getItem("sessionId") || "" }
}) })
); );
@@ -166,6 +166,12 @@ export function AdminGallery() {
return; return;
} }
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
setDraggedPhotoId(null);
return;
}
// Build a fully reordered list based on the current sorted order // Build a fully reordered list based on the current sorted order
const sorted = [...(photos || [])].sort((a, b) => a.order - b.order); const sorted = [...(photos || [])].sort((a, b) => a.order - b.order);
@@ -185,6 +191,7 @@ export function AdminGallery() {
updatePhotoOrder( updatePhotoOrder(
{ {
sessionId,
photoOrders photoOrders
}, },
{ {
@@ -297,9 +304,15 @@ export function AdminGallery() {
return; return;
} }
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
uploadPhoto( uploadPhoto(
{ {
sessionId,
base64Data: photoBase64, base64Data: photoBase64,
title: photoTitle || undefined title: photoTitle || undefined
}, },
@@ -383,8 +396,13 @@ export function AdminGallery() {
<button <button
onClick={() => { onClick={() => {
if (confirm("Möchtest du dieses Foto wirklich löschen?")) { if (confirm("Möchtest du dieses Foto wirklich löschen?")) {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deletePhoto( deletePhoto(
{ id: photo.id }, { sessionId, id: photo.id },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg("Foto gelöscht."); setSuccessMsg("Foto gelöscht.");
@@ -407,8 +425,13 @@ export function AdminGallery() {
</button> </button>
<button <button
onClick={() => { onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
setCoverPhoto( setCoverPhoto(
{ id: photo.id }, { sessionId, id: photo.id },
{ {
onSuccess: () => setSuccessMsg("Als Cover-Bild gesetzt."), onSuccess: () => setSuccessMsg("Als Cover-Bild gesetzt."),
onError: (err: any) => setErrorMsg(err?.message || "Fehler beim Setzen des Cover-Bildes."), onError: (err: any) => setErrorMsg(err?.message || "Fehler beim Setzen des Cover-Bildes."),

View File

@@ -91,17 +91,18 @@ export function AdminReviews() {
} }
}, [successMsg]); }, [successMsg]);
const sessionId = typeof window !== "undefined" ? localStorage.getItem("sessionId") || "" : "";
const { data: reviews } = useQuery( const { data: reviews } = useQuery(
queryClient.reviews.live.adminListReviews.experimental_liveOptions({ queryClient.reviews.live.adminListReviews.experimental_liveOptions({
input: { statusFilter: activeStatusTab }, input: { sessionId, statusFilter: activeStatusTab },
}) })
); );
// Separate queries for quick stats calculation // Separate queries for quick stats calculation
const { data: allReviews } = useQuery( const { data: allReviews } = useQuery(
queryClient.reviews.live.adminListReviews.experimental_liveOptions({ queryClient.reviews.live.adminListReviews.experimental_liveOptions({
input: {}, input: { sessionId },
}) })
); );
@@ -265,13 +266,13 @@ export function AdminReviews() {
{review.status === "pending" && ( {review.status === "pending" && (
<> <>
<button <button
onClick={() => approveReview({ id: review.id })} onClick={() => approveReview({ sessionId, id: review.id })}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm" className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
> >
Genehmigen Genehmigen
</button> </button>
<button <button
onClick={() => rejectReview({ id: review.id })} onClick={() => rejectReview({ sessionId, id: review.id })}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm" className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
> >
Ablehnen Ablehnen
@@ -288,7 +289,7 @@ export function AdminReviews() {
{review.status === "approved" && ( {review.status === "approved" && (
<> <>
<button <button
onClick={() => rejectReview({ id: review.id })} onClick={() => rejectReview({ sessionId, id: review.id })}
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm" className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
> >
Ablehnen Ablehnen
@@ -305,7 +306,7 @@ export function AdminReviews() {
{review.status === "rejected" && ( {review.status === "rejected" && (
<> <>
<button <button
onClick={() => approveReview({ id: review.id })} onClick={() => approveReview({ sessionId, id: review.id })}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm" className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
> >
Genehmigen Genehmigen
@@ -333,7 +334,7 @@ export function AdminReviews() {
</p> </p>
<div className="flex space-x-3"> <div className="flex space-x-3">
<button <button
onClick={() => deleteReview({ id: showDeleteConfirm })} onClick={() => deleteReview({ sessionId, id: showDeleteConfirm })}
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors" className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
> >
Ja, löschen Ja, löschen

View File

@@ -11,6 +11,7 @@ interface User {
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
sessionId: string | null;
isLoading: boolean; isLoading: boolean;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => void; logout: () => void;
@@ -33,6 +34,7 @@ interface AuthProviderProps {
export function AuthProvider({ children }: AuthProviderProps) { export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const { mutateAsync: loginMutation } = useMutation( const { mutateAsync: loginMutation } = useMutation(
@@ -48,45 +50,56 @@ export function AuthProvider({ children }: AuthProviderProps) {
); );
useEffect(() => { useEffect(() => {
// Check for existing session on app load - session comes from cookies // Check for existing session on app load
verifySessionMutation({}) const storedSessionId = localStorage.getItem("sessionId");
.then((result) => { if (storedSessionId) {
setUser(result.user); verifySessionMutation(storedSessionId)
}) .then((result) => {
.catch(() => { setUser(result.user);
// Session invalid or expired - user remains null setSessionId(storedSessionId);
}) })
.finally(() => { .catch(() => {
setIsLoading(false); localStorage.removeItem("sessionId");
}); })
.finally(() => {
setIsLoading(false);
});
} else {
setIsLoading(false);
}
}, [verifySessionMutation]); }, [verifySessionMutation]);
const login = async (username: string, password: string) => { const login = async (username: string, password: string) => {
try { try {
const result = await loginMutation({ username, password }); const result = await loginMutation({ username, password });
setUser(result.user); setUser(result.user);
// Cookies are set automatically by the server setSessionId(result.sessionId);
localStorage.setItem("sessionId", result.sessionId);
} catch (error) { } catch (error) {
throw error; throw error;
} }
}; };
const logout = async () => { const logout = async () => {
try { if (sessionId) {
await logoutMutation({}); try {
// Cookies are cleared automatically by the server await logoutMutation(sessionId);
} catch (error) { } catch (error) {
// Continue with logout even if server call fails // Continue with logout even if server call fails
console.error("Logout error:", error); console.error("Logout error:", error);
}
} }
setUser(null); setUser(null);
setSessionId(null);
localStorage.removeItem("sessionId");
}; };
const isOwner = user?.role === "owner"; const isOwner = user?.role === "owner";
const value: AuthContextType = { const value: AuthContextType = {
user, user,
sessionId,
isLoading, isLoading,
login, login,
logout, logout,

View File

@@ -1,9 +1,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client"; import { queryClient } from "@/client/rpc-client";
// Feature flag for multi-treatments availability API compatibility
const USE_MULTI_TREATMENTS_AVAILABILITY = true;
export function BookingForm() { export function BookingForm() {
const [selectedTreatment, setSelectedTreatment] = useState(""); const [selectedTreatments, setSelectedTreatments] = useState<Array<{id: string, name: string, duration: number, price: number}>>([]);
const [customerName, setCustomerName] = useState(""); const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState(""); const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState(""); const [customerPhone, setCustomerPhone] = useState("");
@@ -11,37 +14,131 @@ export function BookingForm() {
const [selectedTime, setSelectedTime] = useState(""); const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [agbAccepted, setAgbAccepted] = useState(false); const [agbAccepted, setAgbAccepted] = useState(false);
const [ageConfirmed, setAgeConfirmed] = useState(false);
const [inspirationPhoto, setInspirationPhoto] = useState<string>(""); const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
const [photoPreview, setPhotoPreview] = useState<string>(""); const [photoPreview, setPhotoPreview] = useState<string>("");
const [errorMessage, setErrorMessage] = useState<string>(""); const [errorMessage, setErrorMessage] = useState<string>("");
const [isInitialized, setIsInitialized] = useState(false);
// Load saved customer data from localStorage on mount
useEffect(() => {
const savedName = localStorage.getItem("bookingForm_customerName");
const savedEmail = localStorage.getItem("bookingForm_customerEmail");
const savedPhone = localStorage.getItem("bookingForm_customerPhone");
if (savedName) setCustomerName(savedName);
if (savedEmail) setCustomerEmail(savedEmail);
if (savedPhone) setCustomerPhone(savedPhone);
setIsInitialized(true);
}, []);
// Save customer data to localStorage when it changes (after initial load)
useEffect(() => {
if (!isInitialized) return;
if (customerName) {
localStorage.setItem("bookingForm_customerName", customerName);
} else {
localStorage.removeItem("bookingForm_customerName");
}
}, [customerName, isInitialized]);
useEffect(() => {
if (!isInitialized) return;
if (customerEmail) {
localStorage.setItem("bookingForm_customerEmail", customerEmail);
} else {
localStorage.removeItem("bookingForm_customerEmail");
}
}, [customerEmail, isInitialized]);
useEffect(() => {
if (!isInitialized) return;
if (customerPhone) {
localStorage.setItem("bookingForm_customerPhone", customerPhone);
} else {
localStorage.removeItem("bookingForm_customerPhone");
}
}, [customerPhone, isInitialized]);
const { data: treatments } = useQuery( const { data: treatments } = useQuery(
queryClient.treatments.live.list.experimental_liveOptions() queryClient.treatments.live.list.experimental_liveOptions()
); );
// Dynamische Verfügbarkeitsabfrage für das gewählte Datum und die Behandlung // Comment 3: Compute total duration and price once per render
const totalDuration = useMemo(
() => selectedTreatments.reduce((sum, t) => sum + t.duration, 0),
[selectedTreatments]
);
const totalPrice = useMemo(
() => selectedTreatments.reduce((sum, t) => sum + t.price, 0),
[selectedTreatments]
);
// Comment 1: Dynamische Verfügbarkeitsabfrage mit Kompatibilitäts-Fallback
const availabilityQueryInput = USE_MULTI_TREATMENTS_AVAILABILITY
? { date: appointmentDate, treatmentIds: selectedTreatments.map(t => t.id) }
: { date: appointmentDate, treatmentId: selectedTreatments[0]?.id ?? "" };
const availabilityQueryEnabled = USE_MULTI_TREATMENTS_AVAILABILITY
? !!appointmentDate && selectedTreatments.length > 0
: !!appointmentDate && selectedTreatments.length > 0;
const { data: availableTimes, isLoading, isFetching, error } = useQuery({ const { data: availableTimes, isLoading, isFetching, error } = useQuery({
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({ ...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: { input: availabilityQueryInput as any
date: appointmentDate,
treatmentId: selectedTreatment
}
}), }),
enabled: !!appointmentDate && !!selectedTreatment enabled: availabilityQueryEnabled
}); });
const { mutate: createBooking, isPending } = useMutation( const { mutate: createBooking, isPending } = useMutation(
queryClient.bookings.create.mutationOptions() queryClient.bookings.create.mutationOptions()
); );
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment); // Comment 2: Handle treatment checkbox toggle with functional state updates
const handleTreatmentToggle = (treatment: {id: string, name: string, duration: number, price: number}) => {
// Clear selectedTime when treatment changes setSelectedTreatments((prev) => {
const handleTreatmentChange = (treatmentId: string) => { const isSelected = prev.some(t => t.id === treatment.id);
setSelectedTreatment(treatmentId);
if (isSelected) {
// Remove from selection
return prev.filter(t => t.id !== treatment.id);
} else if (prev.length < 3) {
// Add to selection (only if limit not reached)
return [...prev, {
id: treatment.id,
name: treatment.name,
duration: treatment.duration,
price: treatment.price
}];
}
// Return unchanged if limit reached
return prev;
});
// Clear selected time when treatments change
setSelectedTime(""); setSelectedTime("");
}; };
// Comment 4: Reconcile selectedTreatments when treatments list changes
useEffect(() => {
if (!treatments) return;
setSelectedTreatments((prev) => {
const validTreatments = prev.filter((selected) =>
treatments.some((t) => t.id === selected.id)
);
// Only update state if something changed to avoid unnecessary re-renders
if (validTreatments.length !== prev.length) {
return validTreatments;
}
return prev;
});
}, [treatments]);
// Clear selectedTime when it becomes invalid // Clear selectedTime when it becomes invalid
useEffect(() => { useEffect(() => {
if (selectedTime && availableTimes && !availableTimes.includes(selectedTime)) { if (selectedTime && availableTimes && !availableTimes.includes(selectedTime)) {
@@ -130,7 +227,7 @@ export function BookingForm() {
setErrorMessage(""); // Clear any previous error messages setErrorMessage(""); // Clear any previous error messages
// console.log("Form submitted with data:", { // console.log("Form submitted with data:", {
// selectedTreatment, // selectedTreatments,
// customerName, // customerName,
// customerEmail, // customerEmail,
// customerPhone, // customerPhone,
@@ -139,19 +236,27 @@ export function BookingForm() {
// agbAccepted // agbAccepted
// }); // });
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedTime) { if (selectedTreatments.length === 0 || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedTime) {
setErrorMessage("Bitte fülle alle erforderlichen Felder aus."); if (selectedTreatments.length === 0) {
setErrorMessage("Bitte wähle mindestens eine Behandlung aus.");
} else {
setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
}
return; return;
} }
if (!agbAccepted) { if (!agbAccepted) {
setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen."); setErrorMessage("Bitte bestätige die Kenntnisnahme der Allgemeinen Geschäftsbedingungen.");
return; return;
} }
if (!ageConfirmed) {
setErrorMessage("Bitte bestätige, dass du mindestens 16 Jahre alt bist.");
return;
}
// Email validation now handled in backend before booking creation // Email validation now handled in backend before booking creation
const appointmentTime = selectedTime; const appointmentTime = selectedTime;
// console.log("Creating booking with data:", { // console.log("Creating booking with data:", {
// treatmentId: selectedTreatment, // treatments: selectedTreatments,
// customerName, // customerName,
// customerEmail, // customerEmail,
// customerPhone, // customerPhone,
@@ -162,7 +267,7 @@ export function BookingForm() {
// }); // });
createBooking( createBooking(
{ {
treatmentId: selectedTreatment, treatments: selectedTreatments,
customerName, customerName,
customerEmail, customerEmail,
customerPhone, customerPhone,
@@ -173,7 +278,7 @@ export function BookingForm() {
}, },
{ {
onSuccess: () => { onSuccess: () => {
setSelectedTreatment(""); setSelectedTreatments([]);
setCustomerName(""); setCustomerName("");
setCustomerEmail(""); setCustomerEmail("");
setCustomerPhone(""); setCustomerPhone("");
@@ -181,6 +286,7 @@ export function BookingForm() {
setSelectedTime(""); setSelectedTime("");
setNotes(""); setNotes("");
setAgbAccepted(false); setAgbAccepted(false);
setAgeConfirmed(false);
setInspirationPhoto(""); setInspirationPhoto("");
setPhotoPreview(""); setPhotoPreview("");
setErrorMessage(""); setErrorMessage("");
@@ -217,24 +323,65 @@ export function BookingForm() {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Treatment Selection */} {/* Treatment Selection */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <div className="flex justify-between items-center mb-2">
Behandlung auswählen * <label className="block text-sm font-medium text-gray-700">
</label> Behandlungen auswählen (1-3) *
<select </label>
value={selectedTreatment} <span className="text-sm text-gray-600">
onChange={(e) => handleTreatmentChange(e.target.value)} {selectedTreatments.length} von 3 ausgewählt
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500" </span>
required </div>
>
<option value="">Wähle eine Behandlung</option> {/* Checkbox List Container */}
{treatments?.map((treatment) => ( <div className="max-h-96 overflow-y-auto border border-gray-300 rounded-md p-3 space-y-2" aria-label="Wähle bis zu 3 Behandlungen">
<option key={treatment.id} value={treatment.id}> {treatments?.map((treatment) => {
{treatment.name} - {(treatment.price / 100).toFixed(2)} ({treatment.duration} Min) const isSelected = selectedTreatments.some(t => t.id === treatment.id);
</option> const isDisabled = selectedTreatments.length >= 3 && !isSelected;
))}
</select> return (
{selectedTreatmentData && ( <div key={treatment.id} className="flex items-start space-x-3">
<p className="mt-2 text-sm text-gray-600">{selectedTreatmentData.description}</p> <input
type="checkbox"
id={`treatment-${treatment.id}`}
checked={isSelected}
disabled={isDisabled}
onChange={() => handleTreatmentToggle({
id: treatment.id,
name: treatment.name,
duration: treatment.duration,
price: treatment.price
})}
className="h-4 w-4 text-pink-600 border-gray-300 rounded flex-shrink-0 mt-1"
/>
<label htmlFor={`treatment-${treatment.id}`} className={`flex-1 text-sm cursor-pointer ${isDisabled ? 'text-gray-400' : 'text-gray-700'}`}>
{treatment.name} - {treatment.duration} Min - {(treatment.price / 100).toFixed(2)}
</label>
</div>
);
})}
</div>
{/* Treatment Descriptions */}
{selectedTreatments.length > 0 && (
<div className="mt-3 space-y-2">
{selectedTreatments.map((selectedTreatment) => {
const fullTreatment = treatments?.find(t => t.id === selectedTreatment.id);
return fullTreatment ? (
<p key={selectedTreatment.id} className="text-sm text-gray-600">
<span className="font-medium">{fullTreatment.name}:</span> {fullTreatment.description}
</p>
) : null;
})}
</div>
)}
{/* Live Calculation Display */}
{selectedTreatments.length > 0 && (
<div className="mt-3 bg-pink-50 border border-pink-200 rounded-lg p-4">
<p className="font-semibold text-pink-700">
📊 Gesamt: {totalDuration} Min | {(totalPrice / 100).toFixed(2)}
</p>
</div>
)} )}
</div> </div>
@@ -302,7 +449,7 @@ export function BookingForm() {
value={selectedTime} value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)} onChange={(e) => setSelectedTime(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500" className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
disabled={!appointmentDate || !selectedTreatment || isLoading || isFetching} disabled={!appointmentDate || selectedTreatments.length === 0 || isLoading || isFetching}
required required
> >
<option value="">Zeit auswählen</option> <option value="">Zeit auswählen</option>
@@ -312,23 +459,23 @@ export function BookingForm() {
</option> </option>
))} ))}
</select> </select>
{appointmentDate && selectedTreatment && isLoading && ( {appointmentDate && selectedTreatments.length > 0 && isLoading && (
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500">
Lade verfügbare Zeiten... Lade verfügbare Zeiten...
</p> </p>
)} )}
{appointmentDate && selectedTreatment && error && ( {appointmentDate && selectedTreatments.length > 0 && error && (
<p className="mt-2 text-sm text-red-500"> <p className="mt-2 text-sm text-red-500">
Fehler beim Laden der verfügbaren Zeiten. Bitte versuche es erneut. Fehler beim Laden der verfügbaren Zeiten. Bitte versuche es erneut.
</p> </p>
)} )}
{appointmentDate && selectedTreatment && !isLoading && !isFetching && !error && (!availableTimes || availableTimes.length === 0) && ( {appointmentDate && selectedTreatments.length > 0 && !isLoading && !isFetching && !error && (!availableTimes || availableTimes.length === 0) && (
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500">
Keine verfügbaren Zeiten für dieses Datum. Bitte wähle ein anderes Datum. Keine verfügbaren Zeiten für dieses Datum. Bitte wähle ein anderes Datum.
</p> </p>
)} )}
{selectedTreatmentData && ( {selectedTreatments.length > 0 && (
<p className="mt-1 text-xs text-gray-500">Dauer: {selectedTreatmentData.duration} Minuten</p> <p className="mt-1 text-xs text-gray-500">Gesamtdauer: {totalDuration} Minuten</p>
)} )}
</div> </div>
</div> </div>
@@ -383,7 +530,7 @@ export function BookingForm() {
</div> </div>
{/* AGB Acceptance */} {/* AGB Acceptance */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4"> <div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-4">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<input <input
type="checkbox" type="checkbox"
@@ -409,6 +556,22 @@ export function BookingForm() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="age-confirmation"
checked={ageConfirmed}
onChange={(e) => setAgeConfirmed(e.target.checked)}
className="mt-1 h-4 w-4 text-pink-600 focus:ring-pink-500 border-gray-300 rounded"
required
/>
<div className="flex-1">
<label htmlFor="age-confirmation" className="text-sm font-medium text-gray-700 cursor-pointer">
Ich bestätige, dass ich mindestens 16 Jahre alt bin *
</label>
</div>
</div>
</div> </div>
{/* Error Message */} {/* Error Message */}

View File

@@ -8,6 +8,50 @@ interface BookingStatusPageProps {
type BookingStatus = "pending" | "confirmed" | "cancelled" | "completed"; type BookingStatus = "pending" | "confirmed" | "cancelled" | "completed";
interface Treatment {
id: string;
name: string;
duration: number;
price: number;
}
interface BookingDetails {
id: string;
customerName: string;
customerEmail?: string;
customerPhone?: string;
appointmentDate: string;
appointmentTime: string;
treatments: Treatment[];
totalDuration: number;
totalPrice: number;
status: BookingStatus;
notes?: string;
formattedDate: string;
createdAt: string;
canCancel: boolean;
hoursUntilAppointment: number;
}
interface RescheduleProposalDetails {
booking: {
id: string;
customerName: string;
customerEmail?: string;
customerPhone?: string;
status: BookingStatus;
treatments: Treatment[];
totalDuration: number;
totalPrice: number;
};
original: { date: string; time: string };
proposed: { date?: string; time?: string };
expiresAt: string;
hoursUntilExpiry: number;
isExpired: boolean;
canRespond: boolean;
}
function getStatusInfo(status: BookingStatus) { function getStatusInfo(status: BookingStatus) {
switch (status) { switch (status) {
case "pending": case "pending":
@@ -57,7 +101,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
const [showCancelConfirm, setShowCancelConfirm] = useState(false); const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [isCancelling, setIsCancelling] = useState(false); const [isCancelling, setIsCancelling] = useState(false);
const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null); const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null);
const [rescheduleProposal, setRescheduleProposal] = useState<any | null>(null); const [rescheduleProposal, setRescheduleProposal] = useState<RescheduleProposalDetails | null>(null);
const [rescheduleResult, setRescheduleResult] = useState<{ success: boolean; message: string } | null>(null); const [rescheduleResult, setRescheduleResult] = useState<{ success: boolean; message: string } | null>(null);
const [isAccepting, setIsAccepting] = useState(false); const [isAccepting, setIsAccepting] = useState(false);
const [isDeclining, setIsDeclining] = useState(false); const [isDeclining, setIsDeclining] = useState(false);
@@ -71,7 +115,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
); );
// Try fetching reschedule proposal if booking not found or error // Try fetching reschedule proposal if booking not found or error
const rescheduleQuery = useQuery({ const rescheduleQuery = useQuery<RescheduleProposalDetails>({
...queryClient.cancellation.getRescheduleProposal.queryOptions({ input: { token } }), ...queryClient.cancellation.getRescheduleProposal.queryOptions({ input: { token } }),
enabled: !!token && (!!bookingError || !booking), enabled: !!token && (!!bookingError || !booking),
}); });
@@ -159,7 +203,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
if (oneClickAction === 'accept') { if (oneClickAction === 'accept') {
const confirmAccept = window.confirm( const confirmAccept = window.confirm(
`Möchtest du den neuen Termin am ${rescheduleProposal.proposed.date} um ${rescheduleProposal.proposed.time} Uhr akzeptieren?` `Möchtest du den neuen Termin am ${rescheduleProposal.proposed.date || 'TBD'} um ${rescheduleProposal.proposed.time || 'TBD'} Uhr akzeptieren?`
); );
if (confirmAccept) { if (confirmAccept) {
acceptMutation.mutate({ token }); acceptMutation.mutate({ token });
@@ -311,12 +355,56 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
<div className="border rounded-lg p-4 bg-gray-50"> <div className="border rounded-lg p-4 bg-gray-50">
<div className="text-sm text-gray-500 font-semibold mb-1">Aktueller Termin</div> <div className="text-sm text-gray-500 font-semibold mb-1">Aktueller Termin</div>
<div className="text-gray-900 font-medium">{rescheduleProposal.original.date} um {rescheduleProposal.original.time} Uhr</div> <div className="text-gray-900 font-medium">{rescheduleProposal.original.date} um {rescheduleProposal.original.time} Uhr</div>
<div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</div> <div className="text-gray-700 text-sm mt-2">
{rescheduleProposal.booking.treatments && rescheduleProposal.booking.treatments.length > 0 ? (
<>
{rescheduleProposal.booking.treatments.length <= 2 ? (
rescheduleProposal.booking.treatments.map((t, i) => (
<div key={i}>{t.name}</div>
))
) : (
<>
{rescheduleProposal.booking.treatments.slice(0, 2).map((t, i) => (
<div key={i}>{t.name}</div>
))}
<div className="text-gray-500 italic">+{rescheduleProposal.booking.treatments.length - 2} weitere</div>
</>
)}
<div className="text-gray-600 mt-1 text-xs">
{rescheduleProposal.booking.totalDuration} Min
</div>
</>
) : (
<span className="text-gray-400 italic">Keine Behandlungen</span>
)}
</div>
</div> </div>
<div className="border rounded-lg p-4 bg-orange-50"> <div className="border rounded-lg p-4 bg-orange-50">
<div className="text-sm text-orange-700 font-semibold mb-1">Neuer Vorschlag</div> <div className="text-sm text-orange-700 font-semibold mb-1">Neuer Vorschlag</div>
<div className="text-gray-900 font-medium">{rescheduleProposal.proposed.date} um {rescheduleProposal.proposed.time} Uhr</div> <div className="text-gray-900 font-medium">{rescheduleProposal.proposed.date || 'TBD'} um {rescheduleProposal.proposed.time || 'TBD'} Uhr</div>
<div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</div> <div className="text-gray-700 text-sm mt-2">
{rescheduleProposal.booking.treatments && rescheduleProposal.booking.treatments.length > 0 ? (
<>
{rescheduleProposal.booking.treatments.length <= 2 ? (
rescheduleProposal.booking.treatments.map((t, i) => (
<div key={i}>{t.name}</div>
))
) : (
<>
{rescheduleProposal.booking.treatments.slice(0, 2).map((t, i) => (
<div key={i}>{t.name}</div>
))}
<div className="text-gray-500 italic">+{rescheduleProposal.booking.treatments.length - 2} weitere</div>
</>
)}
<div className="text-gray-600 mt-1 text-xs">
{rescheduleProposal.booking.totalDuration} Min
</div>
</>
) : (
<span className="text-gray-400 italic">Keine Behandlungen</span>
)}
</div>
</div> </div>
</div> </div>
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800"> <div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
@@ -478,20 +566,44 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
<span className="text-gray-600">Uhrzeit:</span> <span className="text-gray-600">Uhrzeit:</span>
<span className="font-medium text-gray-900">{booking?.appointmentTime} Uhr</span> <span className="font-medium text-gray-900">{booking?.appointmentTime} Uhr</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Behandlung:</span> {/* Treatments List */}
<span className="font-medium text-gray-900">{booking?.treatmentName}</span> <div className="py-2 border-b border-gray-100">
<div className="text-gray-600 mb-2">Behandlungen:</div>
{booking?.treatments && booking.treatments.length > 0 ? (
<div className="bg-gray-50 rounded-lg p-3 space-y-2">
{booking.treatments.map((treatment, index) => (
<div key={index} className="flex justify-between items-center text-sm">
<span className="font-medium text-gray-900"> {treatment.name}</span>
<span className="text-gray-600">
{treatment.duration} Min - {treatment.price.toFixed(2)}
</span>
</div>
))}
<div className="flex justify-between items-center pt-2 mt-2 border-t border-gray-200 font-semibold">
<span className="text-gray-900">Gesamt:</span>
<span className="text-gray-900">
{booking.totalDuration} Min - {booking.totalPrice.toFixed(2)}
</span>
</div>
</div>
) : (
<div className="space-y-2">
<span className="text-gray-400 text-sm italic">Keine Behandlungen angegeben</span>
{((booking?.totalDuration ?? 0) > 0 || (booking?.totalPrice ?? 0) > 0) && (
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex justify-between items-center font-semibold text-sm">
<span className="text-gray-900">Gesamt:</span>
<span className="text-gray-900">
{booking?.totalDuration ?? 0} Min - {(booking?.totalPrice ?? 0).toFixed(2)}
</span>
</div>
</div>
)}
</div>
)}
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Dauer:</span>
<span className="font-medium text-gray-900">{booking?.treatmentDuration} Minuten</span>
</div>
{booking?.treatmentPrice && booking.treatmentPrice > 0 && (
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Preis:</span>
<span className="font-medium text-gray-900">{booking.treatmentPrice.toFixed(2)} </span>
</div>
)}
{booking?.hoursUntilAppointment && booking.hoursUntilAppointment > 0 && booking.status !== "cancelled" && booking.status !== "completed" && ( {booking?.hoursUntilAppointment && booking.hoursUntilAppointment > 0 && booking.status !== "cancelled" && booking.status !== "completed" && (
<div className="flex justify-between py-2"> <div className="flex justify-between py-2">
<span className="text-gray-600">Verbleibende Zeit:</span> <span className="text-gray-600">Verbleibende Zeit:</span>

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { useAuth } from "@/client/components/auth-provider"; import { useAuth } from "@/client/components/auth-provider";
export function LoginForm() { export function LoginForm() {
@@ -6,9 +6,27 @@ export function LoginForm() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const { login } = useAuth(); const { login } = useAuth();
// Load saved username from localStorage on mount
useEffect(() => {
const savedUsername = localStorage.getItem("loginForm_username");
if (savedUsername) setUsername(savedUsername);
setIsInitialized(true);
}, []);
// Save username to localStorage when it changes (after initial load)
useEffect(() => {
if (!isInitialized) return;
if (username) {
localStorage.setItem("loginForm_username", username);
} else {
localStorage.removeItem("loginForm_username");
}
}, [username, isInitialized]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");

View File

@@ -57,6 +57,10 @@ export function ProfileLanding({ onNavigateToBooking }: ProfileLandingProps) {
queryClient.recurringAvailability.live.listRules.experimental_liveOptions() queryClient.recurringAvailability.live.listRules.experimental_liveOptions()
); );
const { data: socialMedia } = useQuery(
queryClient.social.getSocialMedia.queryOptions()
);
// Calculate next 7 days for opening hours // Calculate next 7 days for opening hours
const getNext7Days = () => { const getNext7Days = () => {
const days: Date[] = []; const days: Date[] = [];
@@ -84,12 +88,44 @@ export function ProfileLanding({ onNavigateToBooking }: ProfileLandingProps) {
</p> </p>
<button <button
onClick={onNavigateToBooking} onClick={onNavigateToBooking}
className="bg-pink-600 text-white py-4 px-8 rounded-lg hover:bg-pink-700 text-lg font-semibold shadow-lg transition-colors w-full md:w-auto" className="bg-[#790dc6] text-white py-4 px-8 rounded-lg hover:bg-[#6609ad] text-lg font-semibold shadow-lg transition-colors w-full md:w-auto"
> >
Termin buchen Termin buchen
</button> </button>
</div> </div>
{/* Social Media Badges */}
{((socialMedia as any)?.tiktokProfile || (socialMedia as any)?.instagramProfile) && (
<div className="flex justify-center items-center gap-4">
{(socialMedia as any)?.instagramProfile && (
<a
href={(socialMedia as any).instagramProfile}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500 text-white px-6 py-3 rounded-full hover:shadow-lg transition-all hover:scale-105 font-semibold"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
Instagram
</a>
)}
{(socialMedia as any)?.tiktokProfile && (
<a
href={(socialMedia as any).tiktokProfile}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 bg-black text-white px-6 py-3 rounded-full hover:shadow-lg transition-all hover:scale-105 font-semibold"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
TikTok
</a>
)}
</div>
)}
{/* Featured Section: Erstes Foto (Reihenfolge 0) */} {/* Featured Section: Erstes Foto (Reihenfolge 0) */}
{featuredPhoto && ( {featuredPhoto && (
<div className="bg-white rounded-lg shadow-lg p-0 overflow-hidden"> <div className="bg-white rounded-lg shadow-lg p-0 overflow-hidden">

View File

@@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
declare global {
interface Navigator { standalone?: boolean }
interface WindowEventMap {
beforeinstallprompt: BeforeInstallPromptEvent;
}
}
const LAST_SHOWN_KEY = 'pwaInstallPrompt_lastShown';
const ANDROID_DISMISSED_KEY = 'pwaInstallPrompt_androidDismissed';
function isIOS(): boolean {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
const iOS = /iPhone|iPad|iPod/i.test(ua);
const iPadOS13Plus = /Macintosh/.test(ua) && 'ontouchend' in document;
return iOS || iPadOS13Plus;
}
function isSafari(): boolean {
const ua = navigator.userAgent || '';
const isSafari = /Safari/i.test(ua) && !/CriOS|FxiOS|EdgiOS/i.test(ua);
return isSafari;
}
function isStandalone(): boolean {
const navStandalone = (navigator as any)?.standalone === true;
const mm = typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(display-mode: standalone)').matches
: false;
return Boolean(navStandalone || mm);
}
export function PWAInstallPrompt({ hidden = false }: { hidden?: boolean }) {
if (hidden) return null;
const [show, setShow] = useState(false);
const [promptType, setPromptType] = useState<'ios' | 'android' | null>(null);
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [initialized, setInitialized] = useState(false);
useEffect(() => {
// Only run on client
if (typeof window === 'undefined') return;
// Check if already in standalone mode
if (isStandalone()) {
setInitialized(true);
return;
}
// Handle iOS
const isIOSDevice = isIOS() && isSafari();
if (isIOSDevice) {
let lastShown = 0;
try {
lastShown = Number(localStorage.getItem(LAST_SHOWN_KEY) || 0);
} catch {}
const oneWeek = 7 * 24 * 60 * 60 * 1000;
const shouldShow = !lastShown || Date.now() - lastShown > oneWeek;
if (shouldShow) {
setPromptType('ios');
setShow(true);
}
setInitialized(true);
return;
}
// Handle Android (beforeinstallprompt)
const handleBeforeInstall = (e: BeforeInstallPromptEvent) => {
e.preventDefault();
// Check if user has dismissed Android prompt before
let dismissed = false;
try {
dismissed = localStorage.getItem(ANDROID_DISMISSED_KEY) === 'true';
} catch {}
if (!dismissed) {
setDeferredPrompt(e);
setPromptType('android');
setShow(true);
}
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
setInitialized(true);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
};
}, []);
const dismiss = () => {
if (promptType === 'ios') {
try { localStorage.setItem(LAST_SHOWN_KEY, String(Date.now())); } catch {}
} else if (promptType === 'android') {
try { localStorage.setItem(ANDROID_DISMISSED_KEY, 'true'); } catch {}
}
setShow(false);
};
const handleAndroidInstall = async () => {
if (!deferredPrompt) return;
try {
await deferredPrompt.prompt();
const choiceResult = await deferredPrompt.userChoice;
if (choiceResult.outcome === 'accepted') {
console.log('PWA installation accepted');
}
setDeferredPrompt(null);
setShow(false);
} catch (error) {
console.error('Error during PWA installation:', error);
}
};
if (!initialized || !show || !promptType) return null;
return (
<div
role="dialog"
aria-label={promptType === 'android' ? 'PWA Installation' : 'PWA Installation Anleitung'}
className="fixed bottom-0 left-0 right-0 z-50 px-4 pb-4"
style={{ paddingBottom: `max(env(safe-area-inset-bottom), 1rem)` }}
>
<div className="relative max-w-3xl mx-auto rounded-xl shadow-lg bg-gradient-to-r from-pink-500 to-purple-600 text-white p-4 sm:p-6">
<button aria-label="Hinweis schließen" onClick={dismiss} className="absolute top-2 right-2 text-white/90 hover:text-white text-2xl leading-none">×</button>
{promptType === 'android' ? (
// Android: Direct install button
<div className="flex items-center gap-4">
<div className="text-3xl">📱</div>
<div className="flex-1">
<h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3>
<p className="text-white/90 mb-3">
Installiere Stargirlnails als App für schnellen Zugriff direkt vom Startbildschirm!
</p>
<div className="flex gap-2">
<button
onClick={handleAndroidInstall}
className="bg-white text-pink-600 px-4 py-2 rounded-lg font-semibold hover:bg-pink-50 transition-colors"
>
Jetzt installieren
</button>
<button
onClick={dismiss}
className="bg-white/20 text-white px-4 py-2 rounded-lg font-semibold hover:bg-white/30 transition-colors"
>
Später
</button>
</div>
</div>
</div>
) : (
// iOS: Manual instructions
<div className="flex items-start gap-3">
<div className="text-2xl">📱</div>
<div className="flex-1">
<h3 className="text-lg sm:text-xl font-bold mb-2">App installieren</h3>
<p className="text-white/90 mb-2">So installierst du Stargirlnails als App auf deinem iPhone/iPad:</p>
<ol className="list-decimal pl-5 space-y-1 text-white/95">
<li>Öffne diese Seite in Safari (nicht Chrome oder andere Browser).</li>
<li>Tippe auf das Teilen-Symbol () unten in der Mitte.</li>
<li>Scrolle nach unten und wähle Zum Home-Bildschirm".</li>
<li>Tippe auf „Hinzufügen".</li>
</ol>
<p className="mt-3 text-sm text-white/90"> Schneller Zugriff, keine App-Store-Installation nötig, automatische Updates.</p>
</div>
</div>
)}
</div>
</div>
);
}
export default PWAInstallPrompt;

View File

@@ -139,7 +139,11 @@ export default function ReviewSubmissionPage({ token }: ReviewSubmissionPageProp
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-100"> <div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Behandlung:</span> <span className="text-gray-600">Behandlung:</span>
<span className="font-medium text-gray-900">{booking.treatmentName}</span> <span className="font-medium text-gray-900">
{booking.treatments && booking.treatments.length > 0
? booking.treatments.map((t: any) => t.name).join(", ")
: "Keine Behandlung"}
</span>
</div> </div>
<div className="flex justify-between py-2"> <div className="flex justify-between py-2">
<span className="text-gray-600">Name:</span> <span className="text-gray-600">Name:</span>

View File

@@ -4,7 +4,7 @@ import { useAuth } from "@/client/components/auth-provider";
import { queryClient } from "@/client/rpc-client"; import { queryClient } from "@/client/rpc-client";
export function UserProfile() { export function UserProfile() {
const { user, logout } = useAuth(); const { user, sessionId, logout } = useAuth();
const [showPasswordChange, setShowPasswordChange] = useState(false); const [showPasswordChange, setShowPasswordChange] = useState(false);
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
@@ -31,7 +31,13 @@ export function UserProfile() {
return; return;
} }
if (!sessionId) {
setError("Keine gültige Sitzung");
return;
}
changePassword({ changePassword({
sessionId,
currentPassword, currentPassword,
newPassword, newPassword,
}, { }, {

View File

@@ -5,32 +5,8 @@ import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import type { router } from "@/server/rpc"; import type { router } from "@/server/rpc";
// Helper function to read CSRF token from cookie const link = new RPCLink({ url: `${window.location.origin}/rpc` });
function getCSRFToken(): string {
const cookieValue = document.cookie
.split('; ')
.find(row => row.startsWith('csrf-token='))
?.split('=')[1];
return cookieValue || '';
}
const link = new RPCLink({
url: `${window.location.origin}/rpc`,
headers: () => {
const csrfToken = getCSRFToken();
return csrfToken ? { 'X-CSRF-Token': csrfToken } : {};
},
fetch: (request, init) => {
return fetch(request, {
...init,
credentials: 'include' // Include cookies with all requests
});
}
});
export const rpcClient: RouterClient<typeof router> = createORPCClient(link); export const rpcClient: RouterClient<typeof router> = createORPCClient(link);
export const queryClient = createTanstackQueryUtils(rpcClient); export const queryClient = createTanstackQueryUtils(rpcClient);
// Export helper for potential use in other parts of the client code
export { getCSRFToken };

View File

@@ -1,7 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { serve } from '@hono/node-server'; import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static'; import { serveStatic } from '@hono/node-server/serve-static';
import { cors } from 'hono/cors';
import { rpcApp } from "./routes/rpc.js"; import { rpcApp } from "./routes/rpc.js";
import { caldavApp } from "./routes/caldav.js"; import { caldavApp } from "./routes/caldav.js";
@@ -9,58 +8,10 @@ import { clientEntry } from "./routes/client-entry.js";
const app = new Hono(); const app = new Hono();
// CORS Configuration // Allow all hosts for Tailscale Funnel
const isDev = process.env.NODE_ENV === 'development';
const domain = process.env.DOMAIN || 'localhost:5173';
// Build allowed origins list
const allowedOrigins: string[] = [
`https://${domain}`,
isDev ? `http://${domain}` : null,
isDev ? 'http://localhost:5173' : null,
isDev ? 'http://localhost:3000' : null,
].filter((origin): origin is string => origin !== null);
app.use('*', cors({
origin: (origin) => {
// Allow requests with no origin (e.g., mobile apps, curl, Postman)
if (!origin) return null;
// Check if origin is in whitelist
if (allowedOrigins.includes(origin)) {
return origin;
}
// Reject all other origins
return null;
},
credentials: true, // Enable cookies for authentication
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
exposeHeaders: ['Set-Cookie'],
maxAge: 86400, // Cache preflight requests for 24 hours
}));
// Content-Security-Policy and other security headers
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
const isDev = process.env.NODE_ENV === 'development'; // Accept requests from any host
const directives = [ return next();
"default-src 'self'",
`script-src 'self'${isDev ? " 'unsafe-inline'" : ''}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
];
const csp = directives.join('; ');
c.header('Content-Security-Policy', csp);
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
await next();
}); });
// Health check endpoint // Health check endpoint
@@ -111,6 +62,9 @@ if (process.env.NODE_ENV === 'production') {
app.use('/assets/*', serveStatic({ root: './dist' })); app.use('/assets/*', serveStatic({ root: './dist' }));
} }
app.use('/favicon.png', serveStatic({ path: './public/favicon.png' })); app.use('/favicon.png', serveStatic({ path: './public/favicon.png' }));
app.use('/AGB.pdf', serveStatic({ path: './public/AGB.pdf' }));
app.use('/icons/*', serveStatic({ root: './public' }));
app.use('/manifest.json', serveStatic({ path: './public/manifest.json' }));
app.route("/rpc", rpcApp); app.route("/rpc", rpcApp);
app.route("/caldav", caldavApp); app.route("/caldav", caldavApp);

View File

@@ -1,104 +1,17 @@
import { createKV } from "./create-kv.js"; import { createKV } from "./create-kv.js";
import { getCookie } from "hono/cookie";
import type { Context } from "hono";
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
type Session = { id: string; userId: string; expiresAt: string; createdAt: string; csrfToken?: string }; type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string }; type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
export const sessionsKV = createKV<Session>("sessions"); export const sessionsKV = createKV<Session>("sessions");
export const usersKV = createKV<User>("users"); export const usersKV = createKV<User>("users");
// Cookie configuration constants export async function assertOwner(sessionId: string): Promise<void> {
export const SESSION_COOKIE_NAME = 'sessionId';
export const CSRF_COOKIE_NAME = 'csrf-token';
export const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax' as const,
path: '/',
maxAge: 86400 // 24 hours
};
// CSRF token generation
export function generateCSRFToken(): string {
return randomBytes(32).toString('hex');
}
// Session extraction from cookies
export async function getSessionFromCookies(c: Context): Promise<Session | null> {
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
if (!sessionId) return null;
const session = await sessionsKV.getItem(sessionId); const session = await sessionsKV.getItem(sessionId);
if (!session) return null;
// Check expiration
if (new Date(session.expiresAt) < new Date()) {
// Clean up expired session
await sessionsKV.removeItem(sessionId);
return null;
}
return session;
}
// CSRF token validation
export async function validateCSRFToken(c: Context, sessionId: string): Promise<void> {
const headerToken = c.req.header('X-CSRF-Token');
if (!headerToken) throw new Error("CSRF token missing");
const session = await sessionsKV.getItem(sessionId);
if (!session?.csrfToken) throw new Error("Invalid session");
// Use timing-safe comparison to prevent timing attacks
const sessionTokenBuffer = Buffer.from(session.csrfToken, 'hex');
const headerTokenBuffer = Buffer.from(headerToken, 'hex');
if (sessionTokenBuffer.length !== headerTokenBuffer.length || !timingSafeEqual(sessionTokenBuffer, headerTokenBuffer)) {
throw new Error("CSRF token mismatch");
}
}
// Session rotation helper
export async function rotateSession(oldSessionId: string, userId: string): Promise<Session> {
// Delete old session
await sessionsKV.removeItem(oldSessionId);
// Create new session with CSRF token
const newSessionId = randomUUID();
const csrfToken = generateCSRFToken();
const now = new Date();
const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours
const newSession: Session = {
id: newSessionId,
userId,
expiresAt: expiresAt.toISOString(),
createdAt: now.toISOString(),
csrfToken
};
await sessionsKV.setItem(newSessionId, newSession);
return newSession;
}
// Updated assertOwner function with CSRF validation
export async function assertOwner(c: Context): Promise<void> {
const session = await getSessionFromCookies(c);
if (!session) throw new Error("Invalid session"); if (!session) throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
const user = await usersKV.getItem(session.userId); const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner") throw new Error("Forbidden"); if (!user || user.role !== "owner") throw new Error("Forbidden");
// Validate CSRF token for non-GET requests
const method = c.req.method;
if (method !== 'GET' && method !== 'HEAD') {
await validateCSRFToken(c, session.id);
}
} }
// Export types for use in other modules
export type { Session, User };

View File

@@ -1,5 +1,4 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { sanitizeText, sanitizeHtml, sanitizePhone } from "./sanitize.js";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
@@ -9,6 +8,27 @@ function formatDateGerman(dateString: string): string {
return `${day}.${month}.${year}`; return `${day}.${month}.${year}`;
} }
// Helper function to render treatment list HTML
function renderTreatmentList(
treatments: Array<{id: string; name: string; duration: number; price: number}>,
options: { showPrices: boolean } = { showPrices: true }
): string {
const totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = treatments.reduce((sum, t) => sum + (t.price / 100), 0);
const treatmentItems = treatments.map(t =>
options.showPrices
? `<li><strong>${t.name}</strong> - ${t.duration} Min - ${(t.price / 100).toFixed(2)} €</li>`
: `<li>${t.name} - ${t.duration} Min - ${(t.price / 100).toFixed(2)} €</li>`
).join('');
const totalLine = options.showPrices
? `<li style="border-top: 1px solid #e2e8f0; margin-top: 8px; padding-top: 8px;"><strong>Gesamt:</strong> ${totalDuration} Min - ${totalPrice.toFixed(2)} €</li>`
: `<li style="font-weight: 600; margin-top: 4px;">Gesamt: ${totalDuration} Min - ${totalPrice.toFixed(2)} €</li>`;
return `${treatmentItems}${totalLine}`;
}
let cachedLogoDataUrl: string | null = null; let cachedLogoDataUrl: string | null = null;
async function getLogoDataUrl(): Promise<string | null> { async function getLogoDataUrl(): Promise<string | null> {
@@ -32,13 +52,18 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const homepageUrl = `${protocol}://${domain}`; const homepageUrl = `${protocol}://${domain}`;
const instagramProfile = process.env.INSTAGRAM_PROFILE;
const tiktokProfile = process.env.TIKTOK_PROFILE;
const companyName = process.env.COMPANY_NAME || 'Stargirlnails Kiel';
return ` return `
<div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;"> <div style="font-family: Arial, sans-serif; color: #0f172a; background:#fdf2f8; padding:24px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px; margin:0 auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<tr> <tr>
<td style="padding:24px 24px 0 24px; text-align:center;"> <td style="padding:24px 24px 0 24px; text-align:center;">
${logo ? `<img src="${logo}" alt="Stargirlnails" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`} ${logo ? `<img src="${logo}" alt="${companyName}" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
<h1 style="margin:16px 0 0 0; font-size:22px; color:#db2777;">${title}</h1> <div style="margin:16px 0 4px 0; font-size:16px; font-weight:600; color:#64748b;">${companyName}</div>
<h1 style="margin:0; font-size:22px; color:#db2777;">${title}</h1>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -50,6 +75,29 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
<div style="text-align:center; margin-bottom:16px;"> <div style="text-align:center; margin-bottom:16px;">
<a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a> <a href="${homepageUrl}" style="display: inline-block; background-color: #db2777; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">Zur Website</a>
</div> </div>
${(instagramProfile || tiktokProfile) ? `
<div style="text-align:center; margin-bottom:16px;">
<p style="font-size:14px; color:#64748b; margin:0 0 8px 0;">Folge uns auf Social Media:</p>
<div style="display:inline-block;">
${instagramProfile ? `
<a href="${instagramProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%); color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
Instagram
</a>
` : ''}
${tiktokProfile ? `
<a href="${tiktokProfile}" target="_blank" rel="noopener noreferrer" style="display:inline-block; margin:0 6px; background:#000000; color:white; padding:10px 20px; text-decoration:none; border-radius:20px; font-size:14px; font-weight:600;">
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="vertical-align:middle; margin-right:6px;">
<path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"/>
</svg>
TikTok
</a>
` : ''}
</div>
</div>
` : ''}
<div style="font-size:12px; color:#64748b; text-align:center;"> <div style="font-size:12px; color:#64748b; text-align:center;">
&copy; ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care &copy; ${new Date().getFullYear()} Stargirlnails Kiel • Professional Nail Care
</div> </div>
@@ -59,17 +107,22 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
</div>`; </div>`;
} }
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) { export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string; treatments: Array<{id: string; name: string; duration: number; price: number}> }) {
const { name, date, time, statusUrl } = params; const { name, date, time, statusUrl, treatments } = params;
const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`; const legalUrl = `${protocol}://${domain}/legal`;
const inner = ` const inner = `
<p>Hallo ${safeName},</p> <p>Hallo ${name},</p>
<p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p> <p>wir haben deine Anfrage für <strong>${formattedDate}</strong> um <strong>${time}</strong> erhalten.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 8px 0; font-weight: 600; color: #db2777;">💅 Deine Behandlungen:</p>
<ul style="margin: 0; color: #475569; list-style: none; padding: 0;">
${renderTreatmentList(treatments, { showPrices: true })}
</ul>
</div>
<p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p> <p>Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.</p>
${statusUrl ? ` ${statusUrl ? `
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
@@ -87,17 +140,22 @@ export async function renderBookingPendingHTML(params: { name: string; date: str
return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner); return renderBrandedEmail("Deine Terminanfrage ist eingegangen", inner);
} }
export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) { export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string; treatments: Array<{id: string; name: string; duration: number; price: number}> }) {
const { name, date, time, cancellationUrl, reviewUrl } = params; const { name, date, time, cancellationUrl, reviewUrl, treatments } = params;
const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`; const legalUrl = `${protocol}://${domain}/legal`;
const inner = ` const inner = `
<p>Hallo ${safeName},</p> <p>Hallo ${name},</p>
<p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p> <p>wir haben deinen Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> bestätigt.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 8px 0; font-weight: 600; color: #db2777;">💅 Deine Behandlungen:</p>
<ul style="margin: 0; color: #475569; list-style: none; padding: 0;">
${renderTreatmentList(treatments, { showPrices: true })}
</ul>
</div>
<p>Wir freuen uns auf dich!</p> <p>Wir freuen uns auf dich!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p> <p style="margin: 0; font-weight: 600; color: #db2777;">📋 Wichtiger Hinweis:</p>
@@ -127,17 +185,22 @@ export async function renderBookingConfirmedHTML(params: { name: string; date: s
return renderBrandedEmail("Termin bestätigt", inner); return renderBrandedEmail("Termin bestätigt", inner);
} }
export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) { export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string; treatments: Array<{id: string; name: string; duration: number; price: number}> }) {
const { name, date, time } = params; const { name, date, time, treatments } = params;
const safeName = sanitizeText(name);
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const domain = process.env.DOMAIN || 'localhost:5173'; const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`; const legalUrl = `${protocol}://${domain}/legal`;
const inner = ` const inner = `
<p>Hallo ${safeName},</p> <p>Hallo ${name},</p>
<p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p> <p>dein Termin am <strong>${formattedDate}</strong> um <strong>${time}</strong> wurde abgesagt.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 8px 0; font-weight: 600; color: #db2777;">💅 Abgesagte Behandlungen:</p>
<ul style="margin: 0; color: #475569; list-style: none; padding: 0;">
${renderTreatmentList(treatments, { showPrices: true })}
</ul>
</div>
<p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p> <p>Bitte buche einen neuen Termin. Bei Fragen helfen wir dir gerne weiter.</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p> <p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
@@ -152,29 +215,30 @@ export async function renderAdminBookingNotificationHTML(params: {
name: string; name: string;
date: string; date: string;
time: string; time: string;
treatment: string; treatments: Array<{id: string; name: string; duration: number; price: number}>;
phone: string; phone: string;
notes?: string; notes?: string;
hasInspirationPhoto: boolean; hasInspirationPhoto: boolean;
}) { }) {
const { name, date, time, treatment, phone, notes, hasInspirationPhoto } = params; const { name, date, time, treatments, phone, notes, hasInspirationPhoto } = params;
const safeName = sanitizeText(name);
const safeTreatment = sanitizeText(treatment);
const safePhone = sanitizePhone(phone);
const safeNotes = sanitizeHtml(notes);
const formattedDate = formatDateGerman(date); const formattedDate = formatDateGerman(date);
const inner = ` const inner = `
<p>Hallo Admin,</p> <p>Hallo Admin,</p>
<p>eine neue Buchungsanfrage ist eingegangen:</p> <p>eine neue Buchungsanfrage ist eingegangen:</p>
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p> <p style="margin: 0; font-weight: 600; color: #db2777;">📅 Buchungsdetails:</p>
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;"> <ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Name:</strong> ${safeName}</li> <li><strong>Name:</strong> ${name}</li>
<li><strong>Telefon:</strong> ${safePhone}</li> <li><strong>Telefon:</strong> ${phone}</li>
<li><strong>Behandlung:</strong> ${safeTreatment}</li> <li><strong>Behandlungen:</strong>
<ul style="margin: 4px 0 0 0; list-style: none; padding: 0 0 0 16px;">
${renderTreatmentList(treatments, { showPrices: false })}
</ul>
</li>
<li><strong>Datum:</strong> ${formattedDate}</li> <li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${time}</li> <li><strong>Uhrzeit:</strong> ${time}</li>
${safeNotes ? `<li><strong>Notizen:</strong> ${safeNotes}</li>` : ''} ${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''}
<li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li> <li><strong>Inspiration-Foto:</strong> ${hasInspirationPhoto ? '✅ Im Anhang verfügbar' : '❌ Kein Foto hochgeladen'}</li>
</ul> </ul>
</div> </div>
@@ -196,15 +260,13 @@ export async function renderBookingRescheduleProposalHTML(params: {
declineUrl: string; declineUrl: string;
expiresAt: string; expiresAt: string;
}) { }) {
const safeName = sanitizeText(params.name);
const safeTreatment = sanitizeText(params.treatmentName);
const formattedOriginalDate = formatDateGerman(params.originalDate); const formattedOriginalDate = formatDateGerman(params.originalDate);
const formattedProposedDate = formatDateGerman(params.proposedDate); const formattedProposedDate = formatDateGerman(params.proposedDate);
const expiryDate = new Date(params.expiresAt); const expiryDate = new Date(params.expiresAt);
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`; const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
const inner = ` const inner = `
<p>Hallo ${safeName},</p> <p>Hallo ${params.name},</p>
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p> <p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;"> <div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p> <p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
@@ -219,7 +281,7 @@ export async function renderBookingRescheduleProposalHTML(params: {
</tr> </tr>
<tr> <tr>
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td> <td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
<td style="padding:6px 0;">${safeTreatment}</td> <td style="padding:6px 0;">${params.treatmentName}</td>
</tr> </tr>
</table> </table>
</div> </div>
@@ -249,19 +311,15 @@ export async function renderAdminRescheduleDeclinedHTML(params: {
customerEmail?: string; customerEmail?: string;
customerPhone?: string; customerPhone?: string;
}) { }) {
const safeCustomerName = sanitizeText(params.customerName);
const safeTreatment = sanitizeText(params.treatmentName);
const safeEmail = params.customerEmail ? sanitizeText(params.customerEmail) : undefined;
const safePhone = params.customerPhone ? sanitizeText(params.customerPhone) : undefined;
const inner = ` const inner = `
<p>Hallo Admin,</p> <p>Hallo Admin,</p>
<p>der Kunde <strong>${safeCustomerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p> <p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;"> <div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;"> <ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${safeCustomerName}</li> <li><strong>Kunde:</strong> ${params.customerName}</li>
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''} ${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''} ${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${safeTreatment}</li> <li><strong>Behandlung:</strong> ${params.treatmentName}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li> <li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li> <li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
</ul> </ul>
@@ -279,15 +337,13 @@ export async function renderAdminRescheduleAcceptedHTML(params: {
newTime: string; newTime: string;
treatmentName: string; treatmentName: string;
}) { }) {
const safeCustomerName = sanitizeText(params.customerName);
const safeTreatment = sanitizeText(params.treatmentName);
const inner = ` const inner = `
<p>Hallo Admin,</p> <p>Hallo Admin,</p>
<p>der Kunde <strong>${safeCustomerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p> <p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;"> <div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;"> <ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
<li><strong>Kunde:</strong> ${safeCustomerName}</li> <li><strong>Kunde:</strong> ${params.customerName}</li>
<li><strong>Behandlung:</strong> ${safeTreatment}</li> <li><strong>Behandlung:</strong> ${params.treatmentName}</li>
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li> <li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li> <li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
</ul> </ul>
@@ -315,24 +371,19 @@ export async function renderAdminRescheduleExpiredHTML(params: {
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p> <p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;"> <div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p> <p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
${params.expiredProposals.map(proposal => { ${params.expiredProposals.map(proposal => `
const safeName = sanitizeText(proposal.customerName);
const safeTreatment = sanitizeText(proposal.treatmentName);
const safeEmail = proposal.customerEmail ? sanitizeText(proposal.customerEmail) : undefined;
const safePhone = proposal.customerPhone ? sanitizeText(proposal.customerPhone) : undefined;
return `
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;"> <div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;"> <ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
<li><strong>Kunde:</strong> ${safeName}</li> <li><strong>Kunde:</strong> ${proposal.customerName}</li>
${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''} ${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''} ${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${safeTreatment}</li> <li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li> <li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li> <li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li> <li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
</ul> </ul>
</div> </div>
`;}).join('')} `).join('')}
</div> </div>
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p> <p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
<p>Die ursprünglichen Termine bleiben bestehen.</p> <p>Die ursprünglichen Termine bleiben bestehen.</p>
@@ -340,3 +391,43 @@ export async function renderAdminRescheduleExpiredHTML(params: {
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner); return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
} }
export async function renderCustomerMessageHTML(params: {
customerName: string;
message: string;
appointmentDate?: string;
appointmentTime?: string;
treatmentName?: string;
}) {
const { customerName, message, appointmentDate, appointmentTime, treatmentName } = params;
const formattedDate = appointmentDate ? formatDateGerman(appointmentDate) : null;
const domain = process.env.DOMAIN || 'localhost:5173';
const protocol = domain.includes('localhost') ? 'http' : 'https';
const legalUrl = `${protocol}://${domain}/legal`;
const ownerName = process.env.OWNER_NAME || 'Stargirlnails Kiel';
const inner = `
<p>Hallo ${customerName},</p>
${(appointmentDate && appointmentTime && treatmentName) ? `
<div style="background-color: #f8fafc; border-left: 4px solid #db2777; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #db2777;">📅 Zu deinem Termin:</p>
<ul style="margin: 8px 0 0 0; color: #475569; list-style: none; padding: 0;">
<li><strong>Behandlung:</strong> ${treatmentName}</li>
<li><strong>Datum:</strong> ${formattedDate}</li>
<li><strong>Uhrzeit:</strong> ${appointmentTime}</li>
</ul>
</div>
` : ''}
<div style="background-color: #fef9f5; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #f59e0b;">💬 Nachricht von ${ownerName}:</p>
<div style="margin: 12px 0 0 0; color: #475569; white-space: pre-wrap; line-height: 1.6;">${message.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
</div>
<p>Bei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!</p>
<div style="background-color: #f8fafc; border-left: 4px solid #3b82f6; padding: 16px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: 600; color: #3b82f6;">📋 Rechtliche Informationen:</p>
<p style="margin: 8px 0 12px 0; color: #475569;">Weitere Informationen findest du in unserem <a href="${legalUrl}" style="color: #3b82f6; text-decoration: underline;">Impressum und Datenschutz</a>.</p>
</div>
<p>Liebe Grüße,<br/>${ownerName}</p>
`;
return renderBrandedEmail("Nachricht zu deinem Termin", inner);
}

View File

@@ -6,6 +6,7 @@ type SendEmailParams = {
from?: string; from?: string;
cc?: string | string[]; cc?: string | string[];
bcc?: string | string[]; bcc?: string | string[];
replyTo?: string | string[];
attachments?: Array<{ attachments?: Array<{
filename: string; filename: string;
content: string; // base64 encoded content: string; // base64 encoded
@@ -28,15 +29,27 @@ function formatDateForICS(date: string, time: string): string {
return `${year}${month}${day}T${hours}${minutes}00`; return `${year}${month}${day}T${hours}${minutes}00`;
} }
// Helper function to escape text values for ICS files (RFC 5545)
function icsEscape(text: string): string {
return text
.replace(/\\/g, '\\\\') // Backslash must be escaped first
.replace(/;/g, '\\;') // Semicolon
.replace(/,/g, '\\,') // Comma
.replace(/\n/g, '\\n'); // Newline
}
// Helper function to create ICS (iCalendar) file content // Helper function to create ICS (iCalendar) file content
function createICSFile(params: { function createICSFile(params: {
date: string; // YYYY-MM-DD date: string; // YYYY-MM-DD
time: string; // HH:MM time: string; // HH:MM
durationMinutes: number;
customerName: string; customerName: string;
treatmentName: string; customerEmail?: string;
treatments: Array<{id: string; name: string; duration: number; price: number}>;
}): string { }): string {
const { date, time, durationMinutes, customerName, treatmentName } = params; const { date, time, customerName, customerEmail, treatments } = params;
// Calculate duration from treatments
const durationMinutes = treatments.reduce((sum, t) => sum + t.duration, 0);
// Calculate start and end times in Europe/Berlin timezone // Calculate start and end times in Europe/Berlin timezone
const dtStart = formatDateForICS(date, time); const dtStart = formatDateForICS(date, time);
@@ -56,6 +69,17 @@ function createICSFile(params: {
const now = new Date(); const now = new Date();
const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; const dtstamp = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
// Build treatments list for SUMMARY and DESCRIPTION
const treatmentNames = icsEscape(treatments.map(t => t.name).join(', '));
const totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = treatments.reduce((sum, t) => sum + (t.price / 100), 0);
const treatmentDetails = treatments.map(t =>
`${icsEscape(t.name)} (${t.duration} Min, ${(t.price / 100).toFixed(2)} EUR)`
).join('\\n');
const description = `Behandlungen:\\n${treatmentDetails}\\n\\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)} EUR\\n\\nTermin bei Stargirlnails Kiel`;
// ICS content // ICS content
const icsContent = [ const icsContent = [
'BEGIN:VCALENDAR', 'BEGIN:VCALENDAR',
@@ -68,11 +92,11 @@ function createICSFile(params: {
`DTSTAMP:${dtstamp}`, `DTSTAMP:${dtstamp}`,
`DTSTART;TZID=Europe/Berlin:${dtStart}`, `DTSTART;TZID=Europe/Berlin:${dtStart}`,
`DTEND;TZID=Europe/Berlin:${dtEnd}`, `DTEND;TZID=Europe/Berlin:${dtEnd}`,
`SUMMARY:${treatmentName} - Stargirlnails Kiel`, `SUMMARY:${treatmentNames} - Stargirlnails Kiel`,
`DESCRIPTION:Termin für ${treatmentName} bei Stargirlnails Kiel`, `DESCRIPTION:${description}`,
'LOCATION:Stargirlnails Kiel', 'LOCATION:Stargirlnails Kiel',
`ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`, `ORGANIZER;CN=Stargirlnails Kiel:mailto:${process.env.EMAIL_FROM?.match(/<(.+)>/)?.[1] || 'no-reply@stargirlnails.de'}`,
`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerName}`, ...(customerEmail ? [`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerEmail}`] : []),
'STATUS:CONFIRMED', 'STATUS:CONFIRMED',
'SEQUENCE:0', 'SEQUENCE:0',
'BEGIN:VALARM', 'BEGIN:VALARM',
@@ -130,22 +154,27 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
return { success: false }; return { success: false };
} }
const payload = {
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
reply_to: params.replyTo ? (Array.isArray(params.replyTo) ? params.replyTo : [params.replyTo]) : undefined,
attachments: params.attachments,
};
console.log(`Sending email via Resend: to=${JSON.stringify(payload.to)}, subject="${params.subject}"`);
const response = await fetch("https://api.resend.com/emails", { const response = await fetch("https://api.resend.com/emails", {
method: "POST", method: "POST",
headers: { headers: {
"Authorization": `Bearer ${RESEND_API_KEY}`, "Authorization": `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify(payload),
from: params.from || DEFAULT_FROM,
to: Array.isArray(params.to) ? params.to : [params.to],
subject: params.subject,
text: params.text,
html: params.html,
cc: params.cc ? (Array.isArray(params.cc) ? params.cc : [params.cc]) : undefined,
bcc: params.bcc ? (Array.isArray(params.bcc) ? params.bcc : [params.bcc]) : undefined,
attachments: params.attachments,
}),
}); });
if (!response.ok) { if (!response.ok) {
@@ -153,6 +182,9 @@ export async function sendEmail(params: SendEmailParams): Promise<{ success: boo
console.error("Resend send error:", response.status, body); console.error("Resend send error:", response.status, body);
return { success: false }; return { success: false };
} }
const responseData = await response.json().catch(() => ({}));
console.log("Resend email sent successfully:", responseData);
return { success: true }; return { success: true };
} }
@@ -178,9 +210,9 @@ export async function sendEmailWithAGBAndCalendar(
calendarParams: { calendarParams: {
date: string; date: string;
time: string; time: string;
durationMinutes: number;
customerName: string; customerName: string;
treatmentName: string; customerEmail?: string;
treatments: Array<{id: string; name: string; duration: number; price: number}>;
} }
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
const agbBase64 = await getAGBPDFBase64(); const agbBase64 = await getAGBPDFBase64();

View File

@@ -137,29 +137,20 @@ export function checkBookingRateLimit(params: {
/** /**
* Get client IP from various headers (for proxy/load balancer support) * Get client IP from various headers (for proxy/load balancer support)
*/ */
export function getClientIP(headers: Headers | Record<string, string | undefined>): string | undefined { export function getClientIP(headers: Record<string, string | undefined>): string | undefined {
// Check common proxy headers // Check common proxy headers
const get = (name: string): string | undefined => { const forwardedFor = headers['x-forwarded-for'];
if (typeof (headers as any).get === 'function') {
// Headers interface
const v = (headers as Headers).get(name);
return v === null ? undefined : v;
}
return (headers as Record<string, string | undefined>)[name];
};
const forwardedFor = get('x-forwarded-for');
if (forwardedFor) { if (forwardedFor) {
// x-forwarded-for can contain multiple IPs, take the first one // x-forwarded-for can contain multiple IPs, take the first one
return forwardedFor.split(',')[0].trim(); return forwardedFor.split(',')[0].trim();
} }
const realIP = get('x-real-ip'); const realIP = headers['x-real-ip'];
if (realIP) { if (realIP) {
return realIP; return realIP;
} }
const cfConnectingIP = get('cf-connecting-ip'); // Cloudflare const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
if (cfConnectingIP) { if (cfConnectingIP) {
return cfConnectingIP; return cfConnectingIP;
} }
@@ -168,117 +159,4 @@ export function getClientIP(headers: Headers | Record<string, string | undefined
return undefined; return undefined;
} }
/**
* Reset a rate limit entry immediately (e.g., after successful login)
*/
export function resetRateLimit(key: string): void {
rateLimitStore.delete(key);
}
/**
* Convenience helper to reset login attempts for an IP
*/
export function resetLoginRateLimit(ip: string | undefined): void {
if (!ip) return;
resetRateLimit(`login:ip:${ip}`);
}
import type { Context } from "hono";
import { getSessionFromCookies } from "./auth.js";
/**
* Enforce admin rate limiting by IP and user. Throws standardized German error on exceed.
*/
export async function enforceAdminRateLimit(context: Context): Promise<void> {
const ip = getClientIP((context.req as any).raw.headers as Headers);
const session = await getSessionFromCookies(context);
if (!session) return; // No session -> owner assertion elsewhere; no per-user throttling
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
/**
* Brute-Force-Schutz für Logins (IP-basiert)
*
* Konfiguration:
* - max. 5 Versuche je IP in 15 Minuten
*
* Schlüssel: "login:ip:${ip}"
*/
export function checkLoginRateLimit(ip: string | undefined): RateLimitResult {
// Wenn keine IP ermittelbar ist, erlauben (kein Tracking möglich)
if (!ip) {
return {
allowed: true,
remaining: 5,
resetAt: Date.now() + 15 * 60 * 1000,
};
}
const loginConfig: RateLimitConfig = {
maxRequests: 5,
windowMs: 15 * 60 * 1000, // 15 Minuten
};
const key = `login:ip:${ip}`;
return checkRateLimit(key, loginConfig);
}
/**
* Rate Limiting für Admin-Operationen
*
* Konfigurationen (beide Checks werden geprüft, restriktiverer gewinnt):
* - Benutzer-basiert: 30 Anfragen je Benutzer in 5 Minuten
* - IP-basiert: 50 Anfragen je IP in 5 Minuten
*
* Schlüssel:
* - "admin:user:${userId}"
* - "admin:ip:${ip}"
*/
export function checkAdminRateLimit(params: { ip?: string; userId: string }): RateLimitResult {
const { ip, userId } = params;
const userConfig: RateLimitConfig = {
maxRequests: 30,
windowMs: 5 * 60 * 1000, // 5 Minuten
};
const ipConfig: RateLimitConfig = {
maxRequests: 50,
windowMs: 5 * 60 * 1000, // 5 Minuten
};
const userKey = `admin:user:${userId}`;
const userResult = checkRateLimit(userKey, userConfig);
// Wenn Benutzerlimit bereits überschritten ist, direkt zurückgeben
if (!userResult.allowed) {
return { ...userResult, allowed: false };
}
// Falls IP verfügbar, zusätzlich prüfen
if (ip) {
const ipKey = `admin:ip:${ip}`;
const ipResult = checkRateLimit(ipKey, ipConfig);
if (!ipResult.allowed) {
return { ...ipResult, allowed: false };
}
// Beide Checks erlaubt: restriktivere Restwerte/Reset nehmen
return {
allowed: true,
remaining: Math.min(userResult.remaining, ipResult.remaining),
resetAt: Math.min(userResult.resetAt, ipResult.resetAt),
};
}
// Kein IP-Check möglich
return {
allowed: true,
remaining: userResult.remaining,
resetAt: userResult.resetAt,
};
}

View File

@@ -1,37 +0,0 @@
import DOMPurify from "isomorphic-dompurify";
/**
* Sanitize plain text inputs by stripping all HTML tags.
* Use for names, phone numbers, and simple text fields.
*/
export function sanitizeText(input: string | undefined): string {
if (!input) return "";
const cleaned = DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] });
return cleaned.trim();
}
/**
* Sanitize rich text notes allowing only a minimal, safe subset of tags.
* Use for free-form notes or comments where basic formatting is acceptable.
*/
export function sanitizeHtml(input: string | undefined): string {
if (!input) return "";
const cleaned = DOMPurify.sanitize(input, {
ALLOWED_TAGS: ["br", "p", "strong", "em", "u", "a", "ul", "li"],
ALLOWED_ATTR: ["href", "title", "target", "rel"],
ALLOWED_URI_REGEXP: /^(?:https?:)?\/\//i,
KEEP_CONTENT: true,
});
return cleaned.trim();
}
/**
* Sanitize phone numbers by stripping HTML and keeping only digits and a few symbols.
* Allowed characters: digits, +, -, (, ), and spaces.
*/
export function sanitizePhone(input: string | undefined): string {
const text = sanitizeText(input);
return text.replace(/[^0-9+\-()\s]/g, "");
}

View File

@@ -5,7 +5,7 @@ import { assertOwner } from "../lib/auth.js";
// Types für Buchungen (vereinfacht für CalDAV) // Types für Buchungen (vereinfacht für CalDAV)
type Booking = { type Booking = {
id: string; id: string;
treatmentId: string; treatments?: Array<{id: string, name: string, duration: number, price: number}>;
customerName: string; customerName: string;
customerEmail?: string; customerEmail?: string;
customerPhone?: string; customerPhone?: string;
@@ -13,6 +13,8 @@ type Booking = {
appointmentTime: string; // HH:MM appointmentTime: string; // HH:MM
status: "pending" | "confirmed" | "cancelled" | "completed"; status: "pending" | "confirmed" | "cancelled" | "completed";
notes?: string; notes?: string;
// Deprecated fields for backward compatibility
treatmentId?: string;
bookedDurationMinutes?: number; bookedDurationMinutes?: number;
createdAt: string; createdAt: string;
}; };
@@ -31,12 +33,6 @@ type Treatment = {
const bookingsKV = createKV<Booking>("bookings"); const bookingsKV = createKV<Booking>("bookings");
const treatmentsKV = createKV<Treatment>("treatments"); const treatmentsKV = createKV<Treatment>("treatments");
const sessionsKV = createKV<any>("sessions"); const sessionsKV = createKV<any>("sessions");
const caldavTokensKV = createKV<{
id: string;
userId: string;
expiresAt: string;
createdAt: string;
}>("caldavTokens");
export const caldavApp = new Hono(); export const caldavApp = new Hono();
@@ -50,13 +46,12 @@ function formatDateTime(dateStr: string, timeStr: string): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
} }
// Helper to add minutes to an HH:MM time string and return HH:MM
function addMinutesToTime(timeStr: string, minutesToAdd: number): string { function addMinutesToTime(timeStr: string, minutesToAdd: number): string {
const [hours, minutes] = timeStr.split(':').map(Number); const [hours, minutes] = timeStr.split(':').map(Number);
const total = hours * 60 + minutes + minutesToAdd; const totalMinutes = hours * 60 + minutes + minutesToAdd;
const endHours = Math.floor(total / 60) % 24; const newHours = Math.floor(totalMinutes / 60);
const endMinutes = total % 60; const newMinutes = totalMinutes % 60;
return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`; return `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`;
} }
function generateICSContent(bookings: Booking[], treatments: Treatment[]): string { function generateICSContent(bookings: Booking[], treatments: Treatment[]): string {
@@ -78,13 +73,41 @@ X-WR-TIMEZONE:Europe/Berlin
); );
for (const booking of activeBookings) { for (const booking of activeBookings) {
const treatment = treatments.find(t => t.id === booking.treatmentId); // Handle new treatments array structure
const treatmentName = treatment?.name || 'Unbekannte Behandlung'; let treatmentNames: string;
const duration = booking.bookedDurationMinutes || treatment?.duration || 60; let duration: number;
let treatmentDetails: string;
let totalPrice = 0;
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
// Use new treatments array
treatmentNames = booking.treatments.map(t => t.name).join(', ');
duration = booking.treatments.reduce((sum, t) => sum + (t.duration || 0), 0);
totalPrice = booking.treatments.reduce((sum, t) => sum + ((t.price || 0) / 100), 0);
// Build detailed treatment list for description
treatmentDetails = booking.treatments
.map(t => `- ${t.name} (${t.duration} Min., ${(t.price / 100).toFixed(2)}€)`)
.join('\\n');
if (booking.treatments.length > 1) {
treatmentDetails += `\\n\\nGesamt: ${duration} Min., ${totalPrice.toFixed(2)}`;
}
} else {
// Fallback to deprecated treatmentId for backward compatibility
const treatment = booking.treatmentId ? treatments.find(t => t.id === booking.treatmentId) : null;
treatmentNames = treatment?.name || 'Unbekannte Behandlung';
duration = booking.bookedDurationMinutes || treatment?.duration || 60;
treatmentDetails = `Behandlung: ${treatmentNames}`;
if (treatment?.price) {
treatmentDetails += ` (${duration} Min., ${(treatment.price / 100).toFixed(2)}€)`;
}
}
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime); const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
const computedEnd = addMinutesToTime(booking.appointmentTime, duration); const endTimeStr = addMinutesToTime(booking.appointmentTime, duration);
const endTime = formatDateTime(booking.appointmentDate, computedEnd); const endTime = formatDateTime(booking.appointmentDate, endTimeStr);
// UID für jeden Termin (eindeutig) // UID für jeden Termin (eindeutig)
const uid = `booking-${booking.id}@stargirlnails.de`; const uid = `booking-${booking.id}@stargirlnails.de`;
@@ -97,8 +120,8 @@ UID:${uid}
DTSTAMP:${now} DTSTAMP:${now}
DTSTART:${startTime} DTSTART:${startTime}
DTEND:${endTime} DTEND:${endTime}
SUMMARY:${treatmentName} - ${booking.customerName} SUMMARY:${treatmentNames} - ${booking.customerName}
DESCRIPTION:Behandlung: ${treatmentName}\\nKunde: ${booking.customerName}${booking.customerPhone ? `\\nTelefon: ${booking.customerPhone}` : ''}${booking.notes ? `\\nNotizen: ${booking.notes}` : ''} DESCRIPTION:${treatmentDetails}\\n\\nKunde: ${booking.customerName}${booking.customerPhone ? `\\nTelefon: ${booking.customerPhone}` : ''}${booking.notes ? `\\nNotizen: ${booking.notes}` : ''}
STATUS:${status} STATUS:${status}
TRANSP:OPAQUE TRANSP:OPAQUE
END:VEVENT END:VEVENT
@@ -110,60 +133,6 @@ END:VEVENT
return ics; return ics;
} }
/**
* Extract and validate CalDAV token from Authorization header or query parameter (legacy)
* @param c Hono context
* @returns { token: string; source: 'bearer'|'basic'|'query' } | null
*/
function extractCalDAVToken(c: any): { token: string; source: 'bearer'|'basic'|'query' } | null {
// UUID v4 pattern for hardening (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
// Prefer Authorization header (new secure methods: Bearer or Basic)
const authHeader = c.req.header('Authorization');
if (authHeader) {
// Bearer
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
if (bearerMatch) {
const token = bearerMatch[1].trim();
if (!uuidV4Regex.test(token)) {
console.warn('CalDAV: Bearer token does not match UUID v4 format.');
return null;
}
return { token, source: 'bearer' };
}
// Basic (use username or password as token)
const basicMatch = authHeader.match(/^Basic\s+(.+)$/i);
if (basicMatch) {
try {
const decoded = Buffer.from(basicMatch[1], 'base64').toString('utf8');
// Format: username:password (password optional)
const [username, password] = decoded.split(':');
const candidate = (username && username.trim().length > 0)
? username.trim()
: (password ? password.trim() : '');
if (candidate && uuidV4Regex.test(candidate)) {
return { token: candidate, source: 'basic' };
}
console.warn('CalDAV: Basic auth credential does not contain a valid UUID v4 token.');
} catch (e) {
console.warn('CalDAV: Failed to decode Basic auth header');
}
return null;
}
}
// Fallback to query parameter (legacy, will be deprecated)
const queryToken = c.req.query('token');
if (queryToken) {
console.warn('CalDAV: Token passed via query parameter (deprecated). Please use Authorization header.');
return { token: queryToken, source: 'query' };
}
return null;
}
// CalDAV Discovery (PROPFIND auf Root) // CalDAV Discovery (PROPFIND auf Root)
caldavApp.all("/", async (c) => { caldavApp.all("/", async (c) => {
if (c.req.method !== 'PROPFIND') { if (c.req.method !== 'PROPFIND') {
@@ -252,54 +221,42 @@ caldavApp.all("/calendar/events.ics", async (c) => {
// GET Calendar Data (ICS-Datei) // GET Calendar Data (ICS-Datei)
caldavApp.get("/calendar/events.ics", async (c) => { caldavApp.get("/calendar/events.ics", async (c) => {
try { try {
// Extract token from Authorization header (Bearer/Basic) or query parameter (legacy) // Authentifizierung über Token im Query-Parameter
const tokenResult = extractCalDAVToken(c); const token = c.req.query('token');
if (!tokenResult) { if (!token) {
return c.text('Unauthorized - Token erforderlich via Authorization (Bearer oder Basic) oder (deprecated) ?token', 401, { return c.text('Unauthorized - Token required', 401);
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
});
} }
// Validate token against caldavTokens KV store // Token validieren
const tokenData = await caldavTokensKV.getItem(tokenResult.token); const tokenData = await sessionsKV.getItem(token);
if (!tokenData) { if (!tokenData) {
return c.text('Unauthorized - Invalid or expired token', 401, { return c.text('Unauthorized - Invalid token', 401);
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
});
} }
// Check token expiration // Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
// CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
return c.text('Unauthorized - Token expired', 401);
}
// Token-Ablaufzeit prüfen
if (new Date(tokenData.expiresAt) < new Date()) { if (new Date(tokenData.expiresAt) < new Date()) {
// Clean up expired token return c.text('Unauthorized - Token expired', 401);
await caldavTokensKV.removeItem(tokenResult.token);
return c.text('Unauthorized - Token expired', 401, {
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
});
} }
// Note: Token is valid for 24 hours from creation.
// Expired tokens are cleaned up on access attempt.
const bookings = await bookingsKV.getAllItems(); const bookings = await bookingsKV.getAllItems();
const treatments = await treatmentsKV.getAllItems(); const treatments = await treatmentsKV.getAllItems();
const icsContent = generateICSContent(bookings, treatments); const icsContent = generateICSContent(bookings, treatments);
const headers: Record<string, string> = { return c.text(icsContent, 200, {
"Content-Type": "text/calendar; charset=utf-8", "Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"", "Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
"Cache-Control": "no-cache, no-store, must-revalidate", "Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache", "Pragma": "no-cache",
"Expires": "0", "Expires": "0",
}; });
// If legacy query token was used, inform clients about deprecation
if (tokenResult.source === 'query') {
headers["Deprecation"] = "true";
headers["Warning"] = "299 - \"Query parameter token authentication is deprecated. Use Authorization header (Bearer or Basic).\"";
}
return c.text(icsContent, 200, headers);
} catch (error) { } catch (error) {
console.error("CalDAV GET error:", error); console.error("CalDAV GET error:", error);
return c.text('Internal Server Error', 500); return c.text('Internal Server Error', 500);

View File

@@ -30,12 +30,18 @@ export function clientEntry(c: Context<BlankEnv>) {
} }
return c.html( return c.html(
<html lang="en"> <html lang="de">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" /> <meta content="width=device-width, initial-scale=1" name="viewport" />
<meta name="theme-color" content="#ec4899" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Stargirlnails" />
<title>Stargirlnails Kiel</title> <title>Stargirlnails Kiel</title>
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
{cssFiles && cssFiles.map((css: string) => ( {cssFiles && cssFiles.map((css: string) => (
<link key={css} rel="stylesheet" href={css} /> <link key={css} rel="stylesheet" href={css} />
))} ))}

View File

@@ -11,7 +11,6 @@ rpcApp.all("/*", async (c) => {
try { try {
const { matched, response } = await handler.handle(c.req.raw, { const { matched, response } = await handler.handle(c.req.raw, {
prefix: "/rpc", prefix: "/rpc",
context: c,
}); });
if (matched) { if (matched) {

View File

@@ -1,25 +1,8 @@
import { call, os } from "@orpc/server"; import { call, os } from "@orpc/server";
import type { Context } from "hono";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js"; import { createKV } from "../lib/create-kv.js";
import { config } from "dotenv"; import { config } from "dotenv";
import bcrypt from "bcrypt";
import { setCookie } from "hono/cookie";
import { checkLoginRateLimit, getClientIP, resetLoginRateLimit } from "../lib/rate-limiter.js";
import {
generateCSRFToken,
getSessionFromCookies,
validateCSRFToken,
rotateSession,
COOKIE_OPTIONS,
SESSION_COOKIE_NAME,
CSRF_COOKIE_NAME,
sessionsKV,
usersKV,
type Session,
type User
} from "../lib/auth.js";
// Load environment variables from .env file // Load environment variables from .env file
config(); config();
@@ -38,68 +21,26 @@ const SessionSchema = z.object({
userId: z.string(), userId: z.string(),
expiresAt: z.string(), expiresAt: z.string(),
createdAt: z.string(), createdAt: z.string(),
csrfToken: z.string().optional(),
}); });
// Use shared KV stores from auth.ts to avoid duplication type User = z.output<typeof UserSchema>;
type Session = z.output<typeof SessionSchema>;
// Password hashing using bcrypt const usersKV = createKV<User>("users");
const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y const sessionsKV = createKV<Session>("sessions");
const isBase64Hash = (hash: string): boolean => { // Simple password hashing (in production, use bcrypt or similar)
if (hash.startsWith(BCRYPT_PREFIX)) return false; const hashPassword = (password: string): string => {
try { return Buffer.from(password).toString('base64');
const decoded = Buffer.from(hash, 'base64');
// If re-encoding yields the same string and the decoded buffer is valid UTF-8, treat as base64
const reencoded = decoded.toString('base64');
// Additionally ensure that decoding does not produce too short/empty unless original was empty
return reencoded === hash && decoded.toString('utf8').length > 0;
} catch {
return false;
}
}; };
const hashPassword = async (password: string): Promise<string> => { const verifyPassword = (password: string, hash: string): boolean => {
return bcrypt.hash(password, 10); return hashPassword(password) === hash;
};
const verifyPassword = async (password: string, hash: string): Promise<boolean> => {
if (hash.startsWith(BCRYPT_PREFIX)) {
return bcrypt.compare(password, hash);
}
if (isBase64Hash(hash)) {
const base64OfPassword = Buffer.from(password).toString('base64');
return base64OfPassword === hash;
}
// Unknown format -> fail closed
return false;
}; };
// Export hashPassword for external use (e.g., generating hashes for .env) // Export hashPassword for external use (e.g., generating hashes for .env)
export const generatePasswordHash = hashPassword; export const generatePasswordHash = hashPassword;
// Migrate all legacy Base64 password hashes to bcrypt on server startup
const migrateLegacyHashesOnStartup = async (): Promise<void> => {
const users = await usersKV.getAllItems();
let migratedCount = 0;
for (const user of users) {
if (isBase64Hash(user.passwordHash)) {
try {
const plaintext = Buffer.from(user.passwordHash, 'base64').toString('utf8');
const bcryptHash = await hashPassword(plaintext);
const updatedUser: User = { ...user, passwordHash: bcryptHash };
await usersKV.setItem(user.id, updatedUser);
migratedCount += 1;
} catch {
// ignore individual failures; continue with others
}
}
}
if (migratedCount > 0) {
console.log(`🔄 Migrated ${migratedCount} legacy Base64 password hash(es) to bcrypt at startup.`);
}
};
// Initialize default owner account // Initialize default owner account
const initializeOwner = async () => { const initializeOwner = async () => {
const existingUsers = await usersKV.getAllItems(); const existingUsers = await usersKV.getAllItems();
@@ -108,12 +49,7 @@ const initializeOwner = async () => {
// Get admin credentials from environment variables // Get admin credentials from environment variables
const adminUsername = process.env.ADMIN_USERNAME || "owner"; const adminUsername = process.env.ADMIN_USERNAME || "owner";
let adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || await hashPassword("admin123"); const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
// If provided hash looks like legacy Base64, decode to plaintext and re-hash with bcrypt
if (process.env.ADMIN_PASSWORD_HASH && isBase64Hash(process.env.ADMIN_PASSWORD_HASH)) {
const plaintext = Buffer.from(process.env.ADMIN_PASSWORD_HASH, 'base64').toString('utf8');
adminPasswordHash = await hashPassword(plaintext);
}
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de"; const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
const owner: User = { const owner: User = {
@@ -130,52 +66,24 @@ const initializeOwner = async () => {
} }
}; };
// Initialize on module load: first migrate legacy hashes, then ensure owner exists // Initialize on module load
(async () => { initializeOwner();
try {
await migrateLegacyHashesOnStartup();
} finally {
await initializeOwner();
}
})();
const login = os const login = os
.input(z.object({ .input(z.object({
username: z.string(), username: z.string(),
password: z.string(), password: z.string(),
})) }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
const ip = getClientIP((context.req as any).raw.headers as Headers);
const users = await usersKV.getAllItems(); const users = await usersKV.getAllItems();
const user = users.find(u => u.username === input.username); const user = users.find(u => u.username === input.username);
if (!user) { if (!user || !verifyPassword(input.password, user.passwordHash)) {
const rl = checkLoginRateLimit(ip);
if (!rl.allowed) {
throw new Error(`Zu viele Login-Versuche. Bitte versuche es in ${rl.retryAfterSeconds} Sekunden erneut.`);
}
throw new Error("Invalid credentials"); throw new Error("Invalid credentials");
} }
const isValid = await verifyPassword(input.password, user.passwordHash); // Create session
if (!isValid) {
const rl = checkLoginRateLimit(ip);
if (!rl.allowed) {
throw new Error(`Zu viele Login-Versuche. Bitte versuche es in ${rl.retryAfterSeconds} Sekunden erneut.`);
}
throw new Error("Invalid credentials");
}
// Seamless migration: if stored hash is legacy Base64, upgrade to bcrypt
if (isBase64Hash(user.passwordHash)) {
const migratedHash = await hashPassword(input.password);
const migratedUser = { ...user, passwordHash: migratedHash } as User;
await usersKV.setItem(user.id, migratedUser);
}
// Create session with CSRF token
const sessionId = randomUUID(); const sessionId = randomUUID();
const csrfToken = generateCSRFToken();
const expiresAt = new Date(); const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
@@ -184,22 +92,12 @@ const login = os
userId: user.id, userId: user.id,
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
csrfToken,
}; };
await sessionsKV.setItem(sessionId, session); await sessionsKV.setItem(sessionId, session);
// Optional: Reset login attempts on successful login
resetLoginRateLimit(ip);
// Set cookies in response
setCookie(context, SESSION_COOKIE_NAME, sessionId, COOKIE_OPTIONS);
setCookie(context, CSRF_COOKIE_NAME, csrfToken, {
...COOKIE_OPTIONS,
httpOnly: false, // CSRF token needs to be readable by JavaScript
});
// Return only user object (no sessionId in response)
return { return {
sessionId,
user: { user: {
id: user.id, id: user.id,
username: user.username, username: user.username,
@@ -210,28 +108,25 @@ const login = os
}); });
const logout = os const logout = os
.input(z.object({})) // No input needed - session comes from cookies .input(z.string()) // sessionId
.handler(async ({ context }) => { .handler(async ({ input }) => {
const session = await getSessionFromCookies(context); await sessionsKV.removeItem(input);
if (session) {
await sessionsKV.removeItem(session.id);
}
// Clear both cookies with correct options
setCookie(context, SESSION_COOKIE_NAME, '', { ...COOKIE_OPTIONS, maxAge: 0 });
setCookie(context, CSRF_COOKIE_NAME, '', { ...COOKIE_OPTIONS, httpOnly: false, maxAge: 0 });
return { success: true }; return { success: true };
}); });
const verifySession = os const verifySession = os
.input(z.object({})) // No input needed - session comes from cookies .input(z.string()) // sessionId
.handler(async ({ context }) => { .handler(async ({ input }) => {
const session = await getSessionFromCookies(context); const session = await sessionsKV.getItem(input);
if (!session) { if (!session) {
throw new Error("Invalid session"); throw new Error("Invalid session");
} }
if (new Date(session.expiresAt) < new Date()) {
await sessionsKV.removeItem(input);
throw new Error("Session expired");
}
const user = await usersKV.getItem(session.userId); const user = await usersKV.getItem(session.userId);
if (!user) { if (!user) {
throw new Error("User not found"); throw new Error("User not found");
@@ -249,11 +144,12 @@ const verifySession = os
const changePassword = os const changePassword = os
.input(z.object({ .input(z.object({
sessionId: z.string(),
currentPassword: z.string(), currentPassword: z.string(),
newPassword: z.string(), newPassword: z.string(),
})) }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
const session = await getSessionFromCookies(context); const session = await sessionsKV.getItem(input.sessionId);
if (!session) { if (!session) {
throw new Error("Invalid session"); throw new Error("Invalid session");
} }
@@ -263,31 +159,16 @@ const changePassword = os
throw new Error("User not found"); throw new Error("User not found");
} }
// Validate CSRF token for password change if (!verifyPassword(input.currentPassword, user.passwordHash)) {
await validateCSRFToken(context, session.id);
const currentOk = await verifyPassword(input.currentPassword, user.passwordHash);
if (!currentOk) {
throw new Error("Current password is incorrect"); throw new Error("Current password is incorrect");
} }
const updatedUser = { const updatedUser = {
...user, ...user,
passwordHash: await hashPassword(input.newPassword), passwordHash: hashPassword(input.newPassword),
}; };
await usersKV.setItem(user.id, updatedUser); await usersKV.setItem(user.id, updatedUser);
// Implement session rotation after password change
const newSession = await rotateSession(session.id, user.id);
// Set new session and CSRF cookies
setCookie(context, SESSION_COOKIE_NAME, newSession.id, COOKIE_OPTIONS);
setCookie(context, CSRF_COOKIE_NAME, newSession.csrfToken!, {
...COOKIE_OPTIONS,
httpOnly: false,
});
return { success: true }; return { success: true };
}); });

View File

@@ -1,17 +1,14 @@
import type { Context } from "hono"; import { call, os } from "@orpc/server";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js"; import { createKV } from "../lib/create-kv.js";
import { sanitizeText, sanitizeHtml, sanitizePhone } from "../lib/sanitize.js";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js"; import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML, renderCustomerMessageHTML } from "../lib/email-templates.js";
import { router as rootRouter, os, call } from "./index.js"; import { router as rootRouter } from "./index.js";
import { createORPCClient } from "@orpc/client"; import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch"; import { RPCLink } from "@orpc/client/fetch";
import { checkBookingRateLimit, getClientIP, checkAdminRateLimit, enforceAdminRateLimit } from "../lib/rate-limiter.js"; import { checkBookingRateLimit, getClientIP } from "../lib/rate-limiter.js";
import { validateEmail } from "../lib/email-validator.js"; import { validateEmail } from "../lib/email-validator.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
// Using centrally typed os and call from rpc/index
// Create a server-side client to call other RPC endpoints // Create a server-side client to call other RPC endpoints
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000; const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
@@ -47,7 +44,7 @@ function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean
async function validateBookingAgainstRules( async function validateBookingAgainstRules(
date: string, date: string,
time: string, time: string,
treatmentDuration: number totalDuration: number
): Promise<void> { ): Promise<void> {
// Parse date to get day of week // Parse date to get day of week
const [year, month, day] = date.split('-').map(Number); const [year, month, day] = date.split('-').map(Number);
@@ -72,7 +69,7 @@ async function validateBookingAgainstRules(
// Check if booking time falls within any rule's time span // Check if booking time falls within any rule's time span
const bookingStartMinutes = parseTime(time); const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + treatmentDuration; const bookingEndMinutes = bookingStartMinutes + totalDuration;
const isWithinRules = matchingRules.some(rule => { const isWithinRules = matchingRules.some(rule => {
const ruleStartMinutes = parseTime(rule.startTime); const ruleStartMinutes = parseTime(rule.startTime);
@@ -91,7 +88,7 @@ async function validateBookingAgainstRules(
async function checkBookingConflicts( async function checkBookingConflicts(
date: string, date: string,
time: string, time: string,
treatmentDuration: number, totalDuration: number,
excludeBookingId?: string excludeBookingId?: string
): Promise<void> { ): Promise<void> {
const allBookings = await kv.getAllItems(); const allBookings = await kv.getAllItems();
@@ -102,10 +99,10 @@ async function checkBookingConflicts(
); );
const bookingStartMinutes = parseTime(time); const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + treatmentDuration; const bookingEndMinutes = bookingStartMinutes + totalDuration;
// Cache treatment durations by ID to avoid N+1 lookups // Cache treatment durations by ID to avoid N+1 lookups (for backward compatibility with old bookings)
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))]; const uniqueTreatmentIds = [...new Set(dateBookings.filter(b => b.treatmentId).map(booking => booking.treatmentId!))];
const treatmentDurationMap = new Map<string, number>(); const treatmentDurationMap = new Map<string, number>();
for (const treatmentId of uniqueTreatmentIds) { for (const treatmentId of uniqueTreatmentIds) {
@@ -115,10 +112,21 @@ async function checkBookingConflicts(
// Check for overlaps with existing bookings // Check for overlaps with existing bookings
for (const existingBooking of dateBookings) { for (const existingBooking of dateBookings) {
// Use cached duration or fallback to bookedDurationMinutes if available let existingDuration: number;
let existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
if (existingBooking.bookedDurationMinutes) { // Handle both new bookings with treatments array and old bookings with treatmentId
existingDuration = existingBooking.bookedDurationMinutes; if (existingBooking.treatments && existingBooking.treatments.length > 0) {
// New format: calculate duration from treatments array
existingDuration = existingBooking.treatments.reduce((sum, t) => sum + t.duration, 0);
} else if (existingBooking.treatmentId) {
// Old format: use cached duration or fallback to bookedDurationMinutes if available
existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
if (existingBooking.bookedDurationMinutes) {
existingDuration = existingBooking.bookedDurationMinutes;
}
} else {
// Fallback for bookings without treatment info
existingDuration = existingBooking.bookedDurationMinutes || 60;
} }
const existingStartMinutes = parseTime(existingBooking.appointmentTime); const existingStartMinutes = parseTime(existingBooking.appointmentTime);
@@ -131,8 +139,22 @@ async function checkBookingConflicts(
} }
} }
// Reusable treatments array schema with duplicate validation
const TreatmentsArraySchema = z.array(z.object({
id: z.string(),
name: z.string(),
duration: z.number().positive(),
price: z.number().nonnegative(),
}))
.min(1, "Mindestens eine Behandlung muss ausgewählt werden")
.max(3, "Maximal 3 Behandlungen können ausgewählt werden")
.refine(list => {
const ids = list.map(t => t.id);
return ids.length === new Set(ids).size;
}, { message: "Doppelte Behandlungen sind nicht erlaubt" });
const CreateBookingInputSchema = z.object({ const CreateBookingInputSchema = z.object({
treatmentId: z.string(), treatments: TreatmentsArraySchema,
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"), customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse"), customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(), customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
@@ -144,7 +166,12 @@ const CreateBookingInputSchema = z.object({
const BookingSchema = z.object({ const BookingSchema = z.object({
id: z.string(), id: z.string(),
treatmentId: z.string(), treatments: z.array(z.object({
id: z.string(),
name: z.string(),
duration: z.number().positive(),
price: z.number().nonnegative()
})),
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"), customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(), customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(), customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
@@ -153,10 +180,12 @@ const BookingSchema = z.object({
status: z.enum(["pending", "confirmed", "cancelled", "completed"]), status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
notes: z.string().optional(), notes: z.string().optional(),
inspirationPhoto: z.string().optional(), // Base64 encoded image data inspirationPhoto: z.string().optional(), // Base64 encoded image data
bookedDurationMinutes: z.number().optional(), // Snapshot of treatment duration at booking time
createdAt: z.string(), createdAt: z.string(),
// DEPRECATED: slotId is no longer used for validation, kept for backward compatibility // DEPRECATED: slotId is no longer used for validation, kept for backward compatibility
slotId: z.string().optional(), slotId: z.string().optional(),
// DEPRECATED: treatmentId and bookedDurationMinutes kept for backward compatibility
treatmentId: z.string().optional(),
bookedDurationMinutes: z.number().optional(),
}); });
type Booking = z.output<typeof BookingSchema>; type Booking = z.output<typeof BookingSchema>;
@@ -275,46 +304,49 @@ const create = os
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst."); throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst.");
} }
} }
// Get treatment duration for validation // Validate all treatments exist and snapshot them from KV
const treatment = await treatmentsKV.getItem(input.treatmentId); const snapshottedTreatments = [] as Array<{id: string; name: string; duration: number; price: number}>;
if (!treatment) { for (const inputTreatment of input.treatments) {
throw new Error("Behandlung nicht gefunden."); const treatment = await treatmentsKV.getItem(inputTreatment.id);
if (!treatment) {
throw new Error(`Behandlung "${inputTreatment.name}" nicht gefunden.`);
}
// Verify snapshot data matches current treatment data
if (treatment.name !== inputTreatment.name || treatment.duration !== inputTreatment.duration || treatment.price !== inputTreatment.price) {
throw new Error(`Behandlungsdaten für "${inputTreatment.name}" stimmen nicht überein. Bitte lade die Seite neu.`);
}
snapshottedTreatments.push({ id: treatment.id, name: treatment.name, duration: treatment.duration, price: treatment.price });
} }
const totalDuration = snapshottedTreatments.reduce((sum, t) => sum + t.duration, 0);
// Validate booking time against recurring rules // Validate booking time against recurring rules
await validateBookingAgainstRules( await validateBookingAgainstRules(
input.appointmentDate, input.appointmentDate,
input.appointmentTime, input.appointmentTime,
treatment.duration totalDuration
); );
// Check for booking conflicts // Check for booking conflicts
await checkBookingConflicts( await checkBookingConflicts(
input.appointmentDate, input.appointmentDate,
input.appointmentTime, input.appointmentTime,
treatment.duration totalDuration
); );
// Sanitize user-provided fields before storage
const sanitizedName = sanitizeText(input.customerName);
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
const id = randomUUID(); const id = randomUUID();
const booking = { const booking = {
id, id,
treatmentId: input.treatmentId, treatments: snapshottedTreatments,
customerName: sanitizedName, customerName: input.customerName,
customerEmail: input.customerEmail, customerEmail: input.customerEmail,
customerPhone: sanitizedPhone, customerPhone: input.customerPhone,
appointmentDate: input.appointmentDate, appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime, appointmentTime: input.appointmentTime,
notes: sanitizedNotes, notes: input.notes,
inspirationPhoto: input.inspirationPhoto, inspirationPhoto: input.inspirationPhoto,
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
status: "pending" as const, status: "pending" as const,
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
} as Booking; };
// Save the booking // Save the booking
await kv.setItem(id, booking); await kv.setItem(id, booking);
@@ -328,15 +360,21 @@ const create = os
const formattedDate = formatDateGerman(input.appointmentDate); const formattedDate = formatDateGerman(input.appointmentDate);
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const html = await renderBookingPendingHTML({ const html = await renderBookingPendingHTML({
name: sanitizedName, name: input.customerName,
date: input.appointmentDate, date: input.appointmentDate,
time: input.appointmentTime, time: input.appointmentTime,
statusUrl: bookingUrl statusUrl: bookingUrl,
treatments: input.treatments
}); });
const treatmentsText = input.treatments.map(t => `- ${t.name} (${t.duration} Min, ${(t.price / 100).toFixed(2)} €)`).join('\n');
const totalDuration = input.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = input.treatments.reduce((sum, t) => sum + (t.price / 100), 0);
await sendEmail({ await sendEmail({
to: input.customerEmail, to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen", subject: "Deine Terminanfrage ist eingegangen",
text: `Hallo ${sanitizedName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\n\nWir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html, html,
}).catch(() => {}); }).catch(() => {});
})(); })();
@@ -345,30 +383,29 @@ const create = os
void (async () => { void (async () => {
if (!process.env.ADMIN_EMAIL) return; if (!process.env.ADMIN_EMAIL) return;
// Get treatment name from KV
const allTreatments = await treatmentsKV.getAllItems();
const treatment = allTreatments.find(t => t.id === input.treatmentId);
const treatmentName = treatment?.name || "Unbekannte Behandlung";
const adminHtml = await renderAdminBookingNotificationHTML({ const adminHtml = await renderAdminBookingNotificationHTML({
name: sanitizedName, name: input.customerName,
date: input.appointmentDate, date: input.appointmentDate,
time: input.appointmentTime, time: input.appointmentTime,
treatment: treatmentName, treatments: input.treatments,
phone: sanitizedPhone || "Nicht angegeben", phone: input.customerPhone || "Nicht angegeben",
notes: sanitizedNotes, notes: input.notes,
hasInspirationPhoto: !!input.inspirationPhoto hasInspirationPhoto: !!input.inspirationPhoto
}); });
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const treatmentsText = input.treatments.map(t => ` - ${t.name} (${t.duration} Min, ${(t.price / 100).toFixed(2)} €)`).join('\n');
const totalDuration = input.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = input.treatments.reduce((sum, t) => sum + (t.price / 100), 0);
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` + const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${sanitizedName}\n` + `Name: ${input.customerName}\n` +
`Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` + `Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
`Behandlung: ${treatmentName}\n` + `Behandlungen:\n${treatmentsText}\n` +
`Gesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\n` +
`Datum: ${formatDateGerman(input.appointmentDate)}\n` + `Datum: ${formatDateGerman(input.appointmentDate)}\n` +
`Uhrzeit: ${input.appointmentTime}\n` + `Uhrzeit: ${input.appointmentTime}\n` +
`${sanitizedNotes ? `Notizen: ${sanitizedNotes}\n` : ''}` + `${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` + `Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
`Zur Website: ${homepageUrl}\n\n` + `Zur Website: ${homepageUrl}\n\n` +
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`; `Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
@@ -376,14 +413,14 @@ const create = os
if (input.inspirationPhoto) { if (input.inspirationPhoto) {
await sendEmailWithInspirationPhoto({ await sendEmailWithInspirationPhoto({
to: process.env.ADMIN_EMAIL, to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${sanitizedName}`, subject: `Neue Buchungsanfrage - ${input.customerName}`,
text: adminText, text: adminText,
html: adminHtml, html: adminHtml,
}, input.inspirationPhoto, sanitizedName).catch(() => {}); }, input.inspirationPhoto, input.customerName).catch(() => {});
} else { } else {
await sendEmail({ await sendEmail({
to: process.env.ADMIN_EMAIL, to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${sanitizedName}`, subject: `Neue Buchungsanfrage - ${input.customerName}`,
text: adminText, text: adminText,
html: adminHtml, html: adminHtml,
}).catch(() => {}); }).catch(() => {});
@@ -399,16 +436,26 @@ const create = os
}); });
// Owner check reuse (simple inline version) // Owner check reuse (simple inline version)
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
const sessionsKV = createKV<Session>("sessions");
const usersKV = createKV<User>("users");
async function assertOwner(sessionId: string): Promise<void> {
const session = await sessionsKV.getItem(sessionId);
if (!session) throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner") throw new Error("Forbidden");
}
const updateStatus = os const updateStatus = os
.input(z.object({ .input(z.object({
sessionId: z.string(),
id: z.string(), id: z.string(),
status: z.enum(["pending", "confirmed", "cancelled", "completed"]) status: z.enum(["pending", "confirmed", "cancelled", "completed"])
})) }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context as unknown as Context); await assertOwner(input.sessionId);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as unknown as Context);
const booking = await kv.getItem(input.id); const booking = await kv.getItem(input.id);
if (!booking) throw new Error("Booking not found"); if (!booking) throw new Error("Booking not found");
@@ -432,40 +479,48 @@ const updateStatus = os
date: booking.appointmentDate, date: booking.appointmentDate,
time: booking.appointmentTime, time: booking.appointmentTime,
cancellationUrl: bookingUrl, // Now points to booking status page cancellationUrl: bookingUrl, // Now points to booking status page
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
treatments: booking.treatments
}); });
// Get treatment information for ICS file const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${(t.price / 100).toFixed(2)} €)`).join('\n');
const allTreatments = await treatmentsKV.getAllItems(); const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0);
const treatment = allTreatments.find(t => t.id === booking.treatmentId); const totalPrice = booking.treatments.reduce((sum, t) => sum + (t.price / 100), 0);
const treatmentName = treatment?.name || "Behandlung";
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
if (booking.customerEmail) { if (booking.customerEmail) {
await sendEmailWithAGBAndCalendar({ await sendEmailWithAGBAndCalendar({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Dein Termin wurde bestätigt - AGB im Anhang", subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${sanitizeText(booking.customerName)},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`, text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html, html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}, { }, {
date: booking.appointmentDate, date: booking.appointmentDate,
time: booking.appointmentTime, time: booking.appointmentTime,
durationMinutes: treatmentDuration,
customerName: booking.customerName, customerName: booking.customerName,
treatmentName: treatmentName customerEmail: booking.customerEmail,
treatments: booking.treatments
}); });
} }
} else if (input.status === "cancelled") { } else if (input.status === "cancelled") {
const formattedDate = formatDateGerman(booking.appointmentDate); const formattedDate = formatDateGerman(booking.appointmentDate);
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime }); const html = await renderBookingCancelledHTML({
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
treatments: booking.treatments
});
const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${(t.price / 100).toFixed(2)} €)`).join('\n');
const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = booking.treatments.reduce((sum, t) => sum + (t.price / 100), 0);
if (booking.customerEmail) { if (booking.customerEmail) {
await sendEmail({ await sendEmail({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt", subject: "Dein Termin wurde abgesagt",
text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\n\nBitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html, html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}); });
@@ -479,13 +534,12 @@ const updateStatus = os
const remove = os const remove = os
.input(z.object({ .input(z.object({
sessionId: z.string(),
id: z.string(), id: z.string(),
sendEmail: z.boolean().optional().default(false) sendEmail: z.boolean().optional().default(false)
})) }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context as unknown as Context); await assertOwner(input.sessionId);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as unknown as Context);
const booking = await kv.getItem(input.id); const booking = await kv.getItem(input.id);
if (!booking) throw new Error("Booking not found"); if (!booking) throw new Error("Booking not found");
@@ -510,11 +564,21 @@ const remove = os
try { try {
const formattedDate = formatDateGerman(booking.appointmentDate); const formattedDate = formatDateGerman(booking.appointmentDate);
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime }); const html = await renderBookingCancelledHTML({
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
treatments: booking.treatments
});
const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${(t.price / 100).toFixed(2)} €)`).join('\n');
const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = booking.treatments.reduce((sum, t) => sum + (t.price / 100), 0);
await sendEmail({ await sendEmail({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt", subject: "Dein Termin wurde abgesagt",
text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`, text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\n\nBitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
html, html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}); });
@@ -529,7 +593,8 @@ const remove = os
// Admin-only manual booking creation (immediately confirmed) // Admin-only manual booking creation (immediately confirmed)
const createManual = os const createManual = os
.input(z.object({ .input(z.object({
treatmentId: z.string(), sessionId: z.string(),
treatments: TreatmentsArraySchema,
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"), customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(), customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(), customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
@@ -537,11 +602,9 @@ const createManual = os
appointmentTime: z.string(), appointmentTime: z.string(),
notes: z.string().optional(), notes: z.string().optional(),
})) }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
// Admin authentication // Admin authentication
await assertOwner(context as unknown as Context); await assertOwner(input.sessionId);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as unknown as Context);
// Validate appointment time is on 15-minute grid // Validate appointment time is on 15-minute grid
const appointmentMinutes = parseTime(input.appointmentTime); const appointmentMinutes = parseTime(input.appointmentTime);
@@ -564,42 +627,45 @@ const createManual = os
} }
} }
// Get treatment duration for validation // Validate all treatments exist and snapshot them from KV
const treatment = await treatmentsKV.getItem(input.treatmentId); const snapshottedTreatments = [] as Array<{id: string; name: string; duration: number; price: number}>;
if (!treatment) { for (const inputTreatment of input.treatments) {
throw new Error("Behandlung nicht gefunden."); const treatment = await treatmentsKV.getItem(inputTreatment.id);
if (!treatment) {
throw new Error(`Behandlung "${inputTreatment.name}" nicht gefunden.`);
}
// Verify snapshot data matches current treatment data
if (treatment.name !== inputTreatment.name || treatment.duration !== inputTreatment.duration || treatment.price !== inputTreatment.price) {
throw new Error(`Behandlungsdaten für "${inputTreatment.name}" stimmen nicht überein. Bitte lade die Seite neu.`);
}
snapshottedTreatments.push({ id: treatment.id, name: treatment.name, duration: treatment.duration, price: treatment.price });
} }
const totalDuration = snapshottedTreatments.reduce((sum, t) => sum + t.duration, 0);
// Validate booking time against recurring rules // Validate booking time against recurring rules
await validateBookingAgainstRules( await validateBookingAgainstRules(
input.appointmentDate, input.appointmentDate,
input.appointmentTime, input.appointmentTime,
treatment.duration totalDuration
); );
// Check for booking conflicts // Check for booking conflicts
await checkBookingConflicts( await checkBookingConflicts(
input.appointmentDate, input.appointmentDate,
input.appointmentTime, input.appointmentTime,
treatment.duration totalDuration
); );
// Sanitize user-provided fields before storage (admin manual booking)
const sanitizedName = sanitizeText(input.customerName);
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
const id = randomUUID(); const id = randomUUID();
const booking = { const booking = {
id, id,
treatmentId: input.treatmentId, treatments: snapshottedTreatments,
customerName: sanitizedName, customerName: input.customerName,
customerEmail: input.customerEmail, customerEmail: input.customerEmail,
customerPhone: sanitizedPhone, customerPhone: input.customerPhone,
appointmentDate: input.appointmentDate, appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime, appointmentTime: input.appointmentTime,
notes: sanitizedNotes, notes: input.notes,
bookedDurationMinutes: treatment.duration,
status: "confirmed" as const, status: "confirmed" as const,
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
} as Booking; } as Booking;
@@ -619,24 +685,28 @@ const createManual = os
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({ const html = await renderBookingConfirmedHTML({
name: sanitizedName, name: input.customerName,
date: input.appointmentDate, date: input.appointmentDate,
time: input.appointmentTime, time: input.appointmentTime,
cancellationUrl: bookingUrl, cancellationUrl: bookingUrl,
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
treatments: input.treatments
}); });
const treatmentsText = input.treatments.map(t => `- ${t.name} (${t.duration} Min, ${(t.price / 100).toFixed(2)} €)`).join('\n');
const totalPrice = input.treatments.reduce((sum, t) => sum + (t.price / 100), 0);
await sendEmailWithAGBAndCalendar({ await sendEmailWithAGBAndCalendar({
to: input.customerEmail!, to: input.customerEmail!,
subject: "Dein Termin wurde bestätigt - AGB im Anhang", subject: "Dein Termin wurde bestätigt - AGB im Anhang",
text: `Hallo ${sanitizedName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`, text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
html, html,
}, { }, {
date: input.appointmentDate, date: input.appointmentDate,
time: input.appointmentTime, time: input.appointmentTime,
durationMinutes: treatment.duration, customerName: input.customerName,
customerName: sanitizedName, customerEmail: input.customerEmail,
treatmentName: treatment.name treatments: input.treatments
}); });
} catch (e) { } catch (e) {
console.error("Email send failed for manual booking:", e); console.error("Email send failed for manual booking:", e);
@@ -696,18 +766,21 @@ export const router = {
// Admin proposes a reschedule for a confirmed booking // Admin proposes a reschedule for a confirmed booking
proposeReschedule: os proposeReschedule: os
.input(z.object({ .input(z.object({
sessionId: z.string(),
bookingId: z.string(), bookingId: z.string(),
proposedDate: z.string(), proposedDate: z.string(),
proposedTime: z.string(), proposedTime: z.string(),
})) }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context as unknown as Context); await assertOwner(input.sessionId);
const booking = await kv.getItem(input.bookingId); const booking = await kv.getItem(input.bookingId);
if (!booking) throw new Error("Booking not found"); if (!booking) throw new Error("Booking not found");
if (booking.status !== "confirmed") throw new Error("Nur bestätigte Termine können umgebucht werden."); if (booking.status !== "confirmed") throw new Error("Nur bestätigte Termine können umgebucht werden.");
const treatment = await treatmentsKV.getItem(booking.treatmentId); // Calculate total duration from treatments array
if (!treatment) throw new Error("Behandlung nicht gefunden."); const totalDuration = booking.treatments && booking.treatments.length > 0
? booking.treatments.reduce((sum, t) => sum + t.duration, 0)
: (booking.bookedDurationMinutes || 60);
// Validate grid and not in past // Validate grid and not in past
const appointmentMinutes = parseTime(input.proposedTime); const appointmentMinutes = parseTime(input.proposedTime);
@@ -726,8 +799,8 @@ export const router = {
} }
} }
await validateBookingAgainstRules(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration); await validateBookingAgainstRules(input.proposedDate, input.proposedTime, totalDuration);
await checkBookingConflicts(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration, booking.id); await checkBookingConflicts(input.proposedDate, input.proposedTime, totalDuration, booking.id);
// Invalidate and create new reschedule token via cancellation router // Invalidate and create new reschedule token via cancellation router
const res = await queryClient.cancellation.createRescheduleToken({ const res = await queryClient.cancellation.createRescheduleToken({
@@ -740,13 +813,16 @@ export const router = {
// Send proposal email to customer // Send proposal email to customer
if (booking.customerEmail) { if (booking.customerEmail) {
const treatmentName = booking.treatments && booking.treatments.length > 0
? booking.treatments.map(t => t.name).join(', ')
: "Behandlung";
const html = await renderBookingRescheduleProposalHTML({ const html = await renderBookingRescheduleProposalHTML({
name: booking.customerName, name: booking.customerName,
originalDate: booking.appointmentDate, originalDate: booking.appointmentDate,
originalTime: booking.appointmentTime, originalTime: booking.appointmentTime,
proposedDate: input.proposedDate, proposedDate: input.proposedDate,
proposedTime: input.proposedTime, proposedTime: input.proposedTime,
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung", treatmentName: treatmentName,
acceptUrl, acceptUrl,
declineUrl, declineUrl,
expiresAt: res.expiresAt, expiresAt: res.expiresAt,
@@ -772,8 +848,9 @@ export const router = {
if (!booking) throw new Error("Booking not found"); if (!booking) throw new Error("Booking not found");
if (booking.status !== "confirmed") throw new Error("Buchung ist nicht mehr in bestätigtem Zustand."); if (booking.status !== "confirmed") throw new Error("Buchung ist nicht mehr in bestätigtem Zustand.");
const treatment = await treatmentsKV.getItem(booking.treatmentId); const duration = booking.treatments && booking.treatments.length > 0
const duration = booking.bookedDurationMinutes || treatment?.duration || 60; ? booking.treatments.reduce((sum, t) => sum + t.duration, 0)
: (booking.bookedDurationMinutes || 60);
// Re-validate slot to ensure still available // Re-validate slot to ensure still available
await validateBookingAgainstRules(proposal.proposed.date, proposal.proposed.time, duration); await validateBookingAgainstRules(proposal.proposed.date, proposal.proposed.time, duration);
@@ -794,29 +871,37 @@ export const router = {
time: updated.appointmentTime, time: updated.appointmentTime,
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
treatments: updated.treatments,
}); });
const treatmentsText = updated.treatments.map(t => `- ${t.name} (${t.duration} Min, ${(t.price / 100).toFixed(2)} €)`).join('\n');
const totalPrice = updated.treatments.reduce((sum, t) => sum + (t.price / 100), 0);
await sendEmailWithAGBAndCalendar({ await sendEmailWithAGBAndCalendar({
to: updated.customerEmail, to: updated.customerEmail,
subject: "Terminänderung bestätigt", subject: "Terminänderung bestätigt",
text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.`, text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${duration} Min, ${totalPrice.toFixed(2)}`,
html, html,
}, { }, {
date: updated.appointmentDate, date: updated.appointmentDate,
time: updated.appointmentTime, time: updated.appointmentTime,
durationMinutes: duration,
customerName: updated.customerName, customerName: updated.customerName,
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung", customerEmail: updated.customerEmail,
treatments: updated.treatments,
}).catch(() => {}); }).catch(() => {});
} }
if (process.env.ADMIN_EMAIL) { if (process.env.ADMIN_EMAIL) {
const treatmentName = updated.treatments && updated.treatments.length > 0
? updated.treatments.map(t => t.name).join(', ')
: "Behandlung";
const adminHtml = await renderAdminRescheduleAcceptedHTML({ const adminHtml = await renderAdminRescheduleAcceptedHTML({
customerName: updated.customerName, customerName: updated.customerName,
originalDate: proposal.original.date, originalDate: proposal.original.date,
originalTime: proposal.original.time, originalTime: proposal.original.time,
newDate: updated.appointmentDate, newDate: updated.appointmentDate,
newTime: updated.appointmentTime, newTime: updated.appointmentTime,
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung", treatmentName: treatmentName,
}); });
await sendEmail({ await sendEmail({
to: process.env.ADMIN_EMAIL, to: process.env.ADMIN_EMAIL,
@@ -843,23 +928,38 @@ export const router = {
// Notify customer that original stays // Notify customer that original stays
if (booking.customerEmail) { if (booking.customerEmail) {
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id }); const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: booking.id });
const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${(t.price / 100).toFixed(2)} €)`).join('\n');
const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0);
const totalPrice = booking.treatments.reduce((sum, t) => sum + (t.price / 100), 0);
await sendEmail({ await sendEmail({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Terminänderung abgelehnt", subject: "Terminänderung abgelehnt",
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`, text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}`,
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }), html: await renderBookingConfirmedHTML({
name: booking.customerName,
date: booking.appointmentDate,
time: booking.appointmentTime,
cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`),
reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`),
treatments: booking.treatments
}),
}).catch(() => {}); }).catch(() => {});
} }
// Notify admin // Notify admin
if (process.env.ADMIN_EMAIL) { if (process.env.ADMIN_EMAIL) {
const treatmentName = booking.treatments && booking.treatments.length > 0
? booking.treatments.map(t => t.name).join(', ')
: "Behandlung";
const html = await renderAdminRescheduleDeclinedHTML({ const html = await renderAdminRescheduleDeclinedHTML({
customerName: booking.customerName, customerName: booking.customerName,
originalDate: proposal.original.date, originalDate: proposal.original.date,
originalTime: proposal.original.time, originalTime: proposal.original.time,
proposedDate: proposal.proposed.date!, proposedDate: proposal.proposed.date!,
proposedTime: proposal.proposed.time!, proposedTime: proposal.proposed.time!,
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung", treatmentName: treatmentName,
customerEmail: booking.customerEmail, customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
}); });
@@ -876,32 +976,32 @@ export const router = {
// CalDAV Token für Admin generieren // CalDAV Token für Admin generieren
generateCalDAVToken: os generateCalDAVToken: os
.input(z.object({})) .input(z.object({ sessionId: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
// Generiere einen sicheren Token für CalDAV-Zugriff // Generiere einen sicheren Token für CalDAV-Zugriff
const token = randomUUID(); const token = randomUUID();
// Hole Session-Daten aus Cookies // Hole Session-Daten für Token-Erstellung
const session = await getSessionFromCookies(context as unknown as Context); const session = await sessionsKV.getItem(input.sessionId);
if (!session) throw new Error("Invalid session"); if (!session) throw new Error("Session nicht gefunden");
// Speichere Token mit Ablaufzeit (24 Stunden) // Speichere Token mit Ablaufzeit (24 Stunden)
const tokenData = { const tokenData = {
id: token, id: token,
userId: session.userId, sessionId: input.sessionId,
userId: session.userId, // Benötigt für Session-Typ
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
// Dedizierten KV-Store für CalDAV-Token verwenden // Verwende den sessionsKV Store für Token-Speicherung
const caldavTokensKV = createKV<typeof tokenData>("caldavTokens"); await sessionsKV.setItem(token, tokenData);
await caldavTokensKV.setItem(token, tokenData);
const domain = process.env.DOMAIN || 'localhost:3000'; const domain = process.env.DOMAIN || 'localhost:3000';
const protocol = domain.includes('localhost') ? 'http' : 'https'; const protocol = domain.includes('localhost') ? 'http' : 'https';
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics`; const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`;
return { return {
token, token,
@@ -910,45 +1010,87 @@ export const router = {
instructions: { instructions: {
title: "CalDAV-Kalender abonnieren", title: "CalDAV-Kalender abonnieren",
steps: [ steps: [
"⚠️ WICHTIG: Der Token darf NICHT in der URL stehen, sondern im Authorization-Header!", "Kopiere die CalDAV-URL unten",
"", "Füge sie in deiner Kalender-App als Abonnement hinzu:",
"📋 Dein CalDAV-Token (kopieren):", "- Outlook: Datei → Konto hinzufügen → Internetkalender",
token, "- Google Calendar: Andere Kalender hinzufügen → Von URL",
"", "- Apple Calendar: Abonnement → Neue Abonnements",
"🔗 CalDAV-URL (ohne Token):", "- Thunderbird: Kalender hinzufügen → Im Netzwerk",
caldavUrl, "Der Kalender wird automatisch aktualisiert"
"",
"📱 Einrichtung nach Kalender-App:",
"",
"🍎 Apple Calendar (macOS/iOS):",
"- Leider keine native Unterstützung für Authorization-Header",
"- Alternative: Verwende eine CalDAV-Bridge oder importiere die ICS-Datei manuell",
"",
"📧 Outlook:",
"- Datei → Kontoeinstellungen → Internetkalender",
"- URL eingeben (ohne Token)",
"- Erweiterte Einstellungen → Benutzerdefinierte Header hinzufügen:",
" Authorization: Bearer <DEIN_TOKEN>",
"",
"🌐 Google Calendar:",
"- Andere Kalender → Von URL hinzufügen",
"- Hinweis: Google Calendar unterstützt keine Authorization-Header",
"- Alternative: Verwende Google Apps Script oder importiere manuell",
"",
"🦅 Thunderbird:",
"- Kalender → Neuer Kalender → Im Netzwerk",
"- Format: CalDAV",
"- URL eingeben",
"- Anmeldung: Basic Auth mit Token als Benutzername (Passwort leer/optional)",
"",
"💻 cURL-Beispiel zum Testen:",
`# Bearer\ncurl -H "Authorization: Bearer ${token}" ${caldavUrl}\n\n# Basic (Token als Benutzername, Passwort leer)\ncurl -H "Authorization: Basic $(printf \"%s\" \"${token}:\" | base64)" ${caldavUrl}`,
"",
"⏰ Token-Gültigkeit: 24 Stunden",
"🔄 Bei Bedarf kannst du jederzeit einen neuen Token generieren."
], ],
note: "Aus Sicherheitsgründen wird der Token NICHT in der URL übertragen. Verwende den Authorization-Header: Bearer oder Basic (Token als Benutzername)." note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
} }
}; };
}), }),
// Admin sendet Nachricht an Kunden
sendCustomerMessage: os
.input(z.object({
sessionId: z.string(),
bookingId: z.string(),
message: z.string().min(1, "Nachricht darf nicht leer sein").max(5000, "Nachricht ist zu lang (max. 5000 Zeichen)"),
}))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const booking = await kv.getItem(input.bookingId);
if (!booking) throw new Error("Buchung nicht gefunden");
// Check if booking has customer email
if (!booking.customerEmail) {
throw new Error("Diese Buchung hat keine E-Mail-Adresse. Bitte kontaktiere den Kunden telefonisch.");
}
// Check if booking is in the future
const today = new Date().toISOString().split("T")[0];
const bookingDate = booking.appointmentDate;
if (bookingDate < today) {
throw new Error("Nachrichten können nur für zukünftige Termine gesendet werden.");
}
// Get treatment name for context
const treatmentName = booking.treatments && booking.treatments.length > 0
? booking.treatments.map(t => t.name).join(', ')
: "Behandlung";
// Prepare email with Reply-To header
const ownerName = process.env.OWNER_NAME || "Stargirlnails Kiel";
const emailFrom = process.env.EMAIL_FROM || "Stargirlnails <no-reply@stargirlnails.de>";
const replyToEmail = process.env.ADMIN_EMAIL;
const formattedDate = formatDateGerman(bookingDate);
const html = await renderCustomerMessageHTML({
customerName: booking.customerName,
message: input.message,
appointmentDate: bookingDate,
appointmentTime: booking.appointmentTime,
treatmentName: treatmentName,
});
const textContent = `Hallo ${booking.customerName},\n\nZu deinem Termin:\nBehandlung: ${treatmentName}\nDatum: ${formattedDate}\nUhrzeit: ${booking.appointmentTime}\n\nNachricht von ${ownerName}:\n${input.message}\n\nBei Fragen oder Anliegen kannst du einfach auf diese E-Mail antworten wir helfen dir gerne weiter!\n\nLiebe Grüße,\n${ownerName}`;
// Send email with BCC to admin for monitoring
// Note: Not using explicit 'from' or 'replyTo' to match behavior of other system emails
console.log(`Sending customer message to ${booking.customerEmail} for booking ${input.bookingId}`);
console.log(`Email config: from=${emailFrom}, replyTo=${replyToEmail}, bcc=${replyToEmail}`);
const emailResult = await sendEmail({
to: booking.customerEmail,
subject: `Nachricht zu deinem Termin am ${formattedDate}`,
text: textContent,
html: html,
bcc: replyToEmail ? [replyToEmail] : undefined,
});
if (!emailResult.success) {
console.error(`Failed to send customer message to ${booking.customerEmail}`);
throw new Error("E-Mail konnte nicht versendet werden. Bitte überprüfe die E-Mail-Konfiguration oder versuche es später erneut.");
}
console.log(`Successfully sent customer message to ${booking.customerEmail}`);
return {
success: true,
message: `Nachricht wurde erfolgreich an ${booking.customerEmail} gesendet.`
};
}),
}; };

View File

@@ -28,7 +28,15 @@ const cancellationKV = createKV<BookingAccessToken>("cancellation_tokens");
// Types for booking and availability // Types for booking and availability
type Booking = { type Booking = {
id: string; id: string;
treatmentId: string; treatments: Array<{
id: string;
name: string;
duration: number;
price: number;
}>;
// Deprecated fields for backward compatibility
treatmentId?: string;
bookedDurationMinutes?: number;
customerName: string; customerName: string;
customerEmail?: string; customerEmail?: string;
customerPhone?: string; customerPhone?: string;
@@ -120,9 +128,42 @@ const getBookingByToken = os
throw new Error("Booking not found"); throw new Error("Booking not found");
} }
// Get treatment details // Handle treatments array
const treatmentsKV = createKV<any>("treatments"); let treatments: Array<{id: string; name: string; duration: number; price: number}>;
const treatment = await treatmentsKV.getItem(booking.treatmentId); let totalDuration: number;
let totalPrice: number;
if (booking.treatments && booking.treatments.length > 0) {
// New bookings with treatments array
treatments = booking.treatments;
totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
totalPrice = treatments.reduce((sum, t) => sum + (t.price / 100), 0);
} else if (booking.treatmentId) {
// Old bookings with single treatmentId (backward compatibility)
const treatmentsKV = createKV<any>("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
if (treatment) {
treatments = [{
id: treatment.id,
name: treatment.name,
duration: treatment.duration,
price: treatment.price,
}];
totalDuration = treatment.duration;
totalPrice = treatment.price / 100;
} else {
// Fallback if treatment not found
treatments = [];
totalDuration = booking.bookedDurationMinutes || 60;
totalPrice = 0;
}
} else {
// Edge case: no treatments and no treatmentId
treatments = [];
totalDuration = 0;
totalPrice = 0;
}
// Calculate if cancellation is still possible // Calculate if cancellation is still possible
const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24"); const minStornoTimespan = parseInt(process.env.MIN_STORNO_TIMESPAN || "24");
@@ -140,10 +181,9 @@ const getBookingByToken = os
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
appointmentDate: booking.appointmentDate, appointmentDate: booking.appointmentDate,
appointmentTime: booking.appointmentTime, appointmentTime: booking.appointmentTime,
treatmentId: booking.treatmentId, treatments,
treatmentName: treatment?.name || "Unbekannte Behandlung", totalDuration,
treatmentDuration: treatment?.duration || 60, totalPrice,
treatmentPrice: treatment?.price || 0,
status: booking.status, status: booking.status,
notes: booking.notes, notes: booking.notes,
formattedDate: formatDateGerman(booking.appointmentDate), formattedDate: formatDateGerman(booking.appointmentDate),
@@ -284,8 +324,42 @@ export const router = {
throw new Error("Booking not found"); throw new Error("Booking not found");
} }
const treatmentsKV = createKV<any>("treatments"); // Handle treatments array
const treatment = await treatmentsKV.getItem(booking.treatmentId); let treatments: Array<{id: string; name: string; duration: number; price: number}>;
let totalDuration: number;
let totalPrice: number;
if (booking.treatments && booking.treatments.length > 0) {
// New bookings with treatments array
treatments = booking.treatments;
totalDuration = treatments.reduce((sum, t) => sum + t.duration, 0);
totalPrice = treatments.reduce((sum, t) => sum + (t.price / 100), 0);
} else if (booking.treatmentId) {
// Old bookings with single treatmentId (backward compatibility)
const treatmentsKV = createKV<any>("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId);
if (treatment) {
treatments = [{
id: treatment.id,
name: treatment.name,
duration: treatment.duration,
price: treatment.price,
}];
totalDuration = treatment.duration;
totalPrice = treatment.price / 100;
} else {
// Fallback if treatment not found
treatments = [];
totalDuration = booking.bookedDurationMinutes || 60;
totalPrice = 0;
}
} else {
// Edge case: no treatments and no treatmentId
treatments = [];
totalDuration = 0;
totalPrice = 0;
}
const now = new Date(); const now = new Date();
const isExpired = new Date(proposal.expiresAt) <= now; const isExpired = new Date(proposal.expiresAt) <= now;
@@ -298,8 +372,9 @@ export const router = {
customerEmail: booking.customerEmail, customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
status: booking.status, status: booking.status,
treatmentId: booking.treatmentId, treatments,
treatmentName: treatment?.name || "Unbekannte Behandlung", totalDuration,
totalPrice,
}, },
original: { original: {
date: proposal.originalDate || booking.appointmentDate, date: proposal.originalDate || booking.appointmentDate,
@@ -358,14 +433,22 @@ export const router = {
const booking = await bookingsKV.getItem(proposal.bookingId); const booking = await bookingsKV.getItem(proposal.bookingId);
if (booking) { if (booking) {
const treatmentsKV = createKV<any>("treatments"); const treatmentsKV = createKV<any>("treatments");
const treatment = await treatmentsKV.getItem(booking.treatmentId); // Get treatment name(s) from new treatments array or fallback to deprecated treatmentId
let treatmentName = "Unbekannte Behandlung";
if (booking.treatments && Array.isArray(booking.treatments) && booking.treatments.length > 0) {
treatmentName = booking.treatments.map((t: any) => t.name).join(", ");
} else if (booking.treatmentId) {
const treatment = await treatmentsKV.getItem(booking.treatmentId);
treatmentName = treatment?.name || "Unbekannte Behandlung";
}
expiredDetails.push({ expiredDetails.push({
customerName: booking.customerName, customerName: booking.customerName,
originalDate: proposal.originalDate || booking.appointmentDate, originalDate: proposal.originalDate || booking.appointmentDate,
originalTime: proposal.originalTime || booking.appointmentTime, originalTime: proposal.originalTime || booking.appointmentTime,
proposedDate: proposal.proposedDate!, proposedDate: proposal.proposedDate!,
proposedTime: proposal.proposedTime!, proposedTime: proposal.proposedTime!,
treatmentName: treatment?.name || "Unbekannte Behandlung", treatmentName: treatmentName,
customerEmail: booking.customerEmail, customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
expiredAt: proposal.expiresAt, expiredAt: proposal.expiresAt,

View File

@@ -2,8 +2,7 @@ import { call, os } from "@orpc/server";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js"; import { createKV } from "../lib/create-kv.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js"; import { assertOwner } from "../lib/auth.js";
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
// Schema Definition // Schema Definition
const GalleryPhotoSchema = z.object({ const GalleryPhotoSchema = z.object({
@@ -26,17 +25,16 @@ const galleryPhotosKV = createKV<GalleryPhoto>("galleryPhotos");
const uploadPhoto = os const uploadPhoto = os
.input( .input(
z.object({ z.object({
sessionId: z.string(),
base64Data: z base64Data: z
.string() .string()
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'), .regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
title: z.string().optional().default(""), title: z.string().optional().default(""),
}) })
) )
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
try { try {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
const id = randomUUID(); const id = randomUUID();
const existing = await galleryPhotosKV.getAllItems(); const existing = await galleryPhotosKV.getAllItems();
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1; const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
@@ -60,11 +58,9 @@ const uploadPhoto = os
}); });
const setCoverPhoto = os const setCoverPhoto = os
.input(z.object({ id: z.string() })) .input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
const all = await galleryPhotosKV.getAllItems(); const all = await galleryPhotosKV.getAllItems();
let updatedCover: GalleryPhoto | null = null; let updatedCover: GalleryPhoto | null = null;
for (const p of all) { for (const p of all) {
@@ -77,24 +73,21 @@ const setCoverPhoto = os
}); });
const deletePhoto = os const deletePhoto = os
.input(z.object({ id: z.string() })) .input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
await galleryPhotosKV.removeItem(input.id); await galleryPhotosKV.removeItem(input.id);
}); });
const updatePhotoOrder = os const updatePhotoOrder = os
.input( .input(
z.object({ z.object({
sessionId: z.string(),
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })), photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
}) })
) )
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
const updated: GalleryPhoto[] = []; const updated: GalleryPhoto[] = [];
for (const { id, order } of input.photoOrders) { for (const { id, order } of input.photoOrders) {
const existing = await galleryPhotosKV.getItem(id); const existing = await galleryPhotosKV.getItem(id);
@@ -113,9 +106,9 @@ const listPhotos = os.handler(async () => {
}); });
const adminListPhotos = os const adminListPhotos = os
.input(z.object({})) .input(z.object({ sessionId: z.string() }))
.handler(async ({ context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems(); const all = await galleryPhotosKV.getAllItems();
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
}); });
@@ -130,9 +123,9 @@ const live = {
}), }),
adminListPhotos: os adminListPhotos: os
.input(z.object({})) .input(z.object({ sessionId: z.string() }))
.handler(async function* ({ context, signal }) { .handler(async function* ({ input, signal }) {
await assertOwner(context); await assertOwner(input.sessionId);
const all = await galleryPhotosKV.getAllItems(); const all = await galleryPhotosKV.getAllItems();
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)); const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
yield sorted; yield sorted;

View File

@@ -1,6 +1,4 @@
import { demo } from "./demo/index.js"; import { demo } from "./demo/index.js";
import { os as baseOs, call as baseCall } from "@orpc/server";
import type { Context } from "hono";
import { router as treatments } from "./treatments.js"; import { router as treatments } from "./treatments.js";
import { router as bookings } from "./bookings.js"; import { router as bookings } from "./bookings.js";
import { router as auth } from "./auth.js"; import { router as auth } from "./auth.js";
@@ -9,6 +7,7 @@ import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal.js"; import { router as legal } from "./legal.js";
import { router as gallery } from "./gallery.js"; import { router as gallery } from "./gallery.js";
import { router as reviews } from "./reviews.js"; import { router as reviews } from "./reviews.js";
import { router as social } from "./social.js";
export const router = { export const router = {
demo, demo,
@@ -20,10 +19,5 @@ export const router = {
legal, legal,
gallery, gallery,
reviews, reviews,
social,
}; };
// Export centrally typed oRPC helpers so all modules share the same Hono Context typing
const osAny = baseOs as any;
export const os = osAny.withContext?.<Context>() ?? osAny.context?.<Context>() ?? baseOs;
export const call = baseCall;

View File

@@ -2,8 +2,7 @@ import { call, os } from "@orpc/server";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js"; import { createKV } from "../lib/create-kv.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js"; import { assertOwner } from "../lib/auth.js";
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
// Datenmodelle // Datenmodelle
const RecurringRuleSchema = z.object({ const RecurringRuleSchema = z.object({
@@ -88,23 +87,15 @@ function detectOverlappingRules(newRule: { dayOfWeek: number; startTime: string;
const createRule = os const createRule = os
.input( .input(
z.object({ z.object({
sessionId: z.string(),
dayOfWeek: z.number().int().min(0).max(6), dayOfWeek: z.number().int().min(0).max(6),
startTime: z.string().regex(/^\d{2}:\d{2}$/), startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/), endTime: z.string().regex(/^\d{2}:\d{2}$/),
}).passthrough() }).passthrough()
) )
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
try { try {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
// Validierung: startTime < endTime // Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime); const startMinutes = parseTime(input.startTime);
@@ -141,18 +132,9 @@ const createRule = os
}); });
const updateRule = os const updateRule = os
.input(RecurringRuleSchema.passthrough()) .input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
// Validierung: startTime < endTime // Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime); const startMinutes = parseTime(input.startTime);
@@ -170,40 +152,22 @@ const updateRule = os
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`); throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
} }
const rule = input as any; const { sessionId, ...rule } = input as any;
await recurringRulesKV.setItem(rule.id, rule as RecurringRule); await recurringRulesKV.setItem(rule.id, rule as RecurringRule);
return rule as RecurringRule; return rule as RecurringRule;
}); });
const deleteRule = os const deleteRule = os
.input(z.object({ id: z.string() })) .input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
await recurringRulesKV.removeItem(input.id); await recurringRulesKV.removeItem(input.id);
}); });
const toggleRuleActive = os const toggleRuleActive = os
.input(z.object({ id: z.string() })) .input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
const rule = await recurringRulesKV.getItem(input.id); const rule = await recurringRulesKV.getItem(input.id);
if (!rule) throw new Error("Regel nicht gefunden."); if (!rule) throw new Error("Regel nicht gefunden.");
@@ -221,9 +185,9 @@ const listRules = os.handler(async () => {
}); });
const adminListRules = os const adminListRules = os
.input(z.object({})) .input(z.object({ sessionId: z.string() }))
.handler(async ({ context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems(); const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => { return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek; if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
@@ -235,16 +199,15 @@ const adminListRules = os
const createTimeOff = os const createTimeOff = os
.input( .input(
z.object({ z.object({
sessionId: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
reason: z.string(), reason: z.string(),
}) })
) )
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
try { try {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting direkt nach Owner-Check
await enforceAdminRateLimit(context as any);
// Validierung: startDate <= endDate // Validierung: startDate <= endDate
if (input.startDate > input.endDate) { if (input.startDate > input.endDate) {
@@ -269,28 +232,24 @@ const createTimeOff = os
}); });
const updateTimeOff = os const updateTimeOff = os
.input(TimeOffPeriodSchema.passthrough()) .input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting direkt nach Owner-Check
await enforceAdminRateLimit(context as any);
// Validierung: startDate <= endDate // Validierung: startDate <= endDate
if (input.startDate > input.endDate) { if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen."); throw new Error("Startdatum muss vor oder am Enddatum liegen.");
} }
const timeOff = input as any; const { sessionId, ...timeOff } = input as any;
await timeOffPeriodsKV.setItem(timeOff.id, timeOff as TimeOffPeriod); await timeOffPeriodsKV.setItem(timeOff.id, timeOff as TimeOffPeriod);
return timeOff as TimeOffPeriod; return timeOff as TimeOffPeriod;
}); });
const deleteTimeOff = os const deleteTimeOff = os
.input(z.object({ id: z.string() })) .input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting direkt nach Owner-Check
await enforceAdminRateLimit(context as any);
await timeOffPeriodsKV.removeItem(input.id); await timeOffPeriodsKV.removeItem(input.id);
}); });
@@ -300,9 +259,9 @@ const listTimeOff = os.handler(async () => {
}); });
const adminListTimeOff = os const adminListTimeOff = os
.input(z.object({})) .input(z.object({ sessionId: z.string() }))
.handler(async ({ context }) => { .handler(async ({ input }) => {
await assertOwner(context); await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems(); const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate)); return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
}); });
@@ -313,7 +272,12 @@ const getAvailableTimes = os
.input( .input(
z.object({ z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
treatmentId: z.string(), treatmentIds: z.array(z.string())
.min(1, "Mindestens eine Behandlung muss ausgewählt werden")
.max(3, "Maximal 3 Behandlungen können ausgewählt werden")
.refine(list => {
return list.length === new Set(list).size;
}, { message: "Doppelte Behandlungen sind nicht erlaubt" }),
}) })
) )
.handler(async ({ input }) => { .handler(async ({ input }) => {
@@ -328,13 +292,22 @@ const getAvailableTimes = os
return []; return [];
} }
// Get treatment duration // Get multiple treatments and calculate total duration
const treatment = await treatmentsKV.getItem(input.treatmentId); const treatments = await Promise.all(
if (!treatment) { input.treatmentIds.map(id => treatmentsKV.getItem(id))
throw new Error("Behandlung nicht gefunden."); );
// Validate that all treatments exist
const missingTreatments = treatments
.map((t, i) => t ? null : input.treatmentIds[i])
.filter(id => id !== null);
if (missingTreatments.length > 0) {
throw new Error(`Behandlung(en) nicht gefunden: ${missingTreatments.join(', ')}`);
} }
const treatmentDuration = treatment.duration; // Calculate total duration by summing all treatment durations
const treatmentDuration = treatments.reduce((sum, t) => sum + (t?.duration || 0), 0);
// Parse the date to get day of week // Parse the date to get day of week
const [year, month, day] = input.date.split('-').map(Number); const [year, month, day] = input.date.split('-').map(Number);
@@ -385,36 +358,38 @@ const getAvailableTimes = os
['pending', 'confirmed', 'completed'].includes(booking.status) ['pending', 'confirmed', 'completed'].includes(booking.status)
); );
// Optimize treatment duration lookup with Map caching // Build cache only for legacy treatmentId bookings
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))]; const legacyTreatmentIds = [...new Set(dateBookings.filter(b => b.treatmentId).map(b => b.treatmentId as string))];
const treatmentDurationMap = new Map<string, number>(); const treatmentDurationMap = new Map<string, number>();
for (const treatmentId of uniqueTreatmentIds) { // Only build cache if there are legacy bookings
const treatment = await treatmentsKV.getItem(treatmentId); if (legacyTreatmentIds.length > 0) {
treatmentDurationMap.set(treatmentId, treatment?.duration || 60); for (const id of legacyTreatmentIds) {
} const t = await treatmentsKV.getItem(id);
treatmentDurationMap.set(id, t?.duration || 60);
// Get treatment durations for all bookings using the cached map }
const bookingTreatments = new Map();
for (const booking of dateBookings) {
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
const duration = booking.bookedDurationMinutes || treatmentDurationMap.get(booking.treatmentId) || 60;
bookingTreatments.set(booking.id, duration);
} }
// Filter out booking conflicts // Filter out booking conflicts
const availableTimesFiltered = availableTimes.filter(slotTime => { const availableTimesFiltered = availableTimes.filter(slotTime => {
const slotStartMinutes = parseTime(slotTime); const slotStartMinutes = parseTime(slotTime);
const slotEndMinutes = slotStartMinutes + treatmentDuration; const slotEndMinutes = slotStartMinutes + treatmentDuration; // total from selected treatments
// Check if this slot overlaps with any existing booking
const hasConflict = dateBookings.some(booking => { const hasConflict = dateBookings.some(booking => {
const bookingStartMinutes = parseTime(booking.appointmentTime); let bookingDuration: number;
const bookingDuration = bookingTreatments.get(booking.id) || 60; if (booking.treatments && booking.treatments.length > 0) {
const bookingEndMinutes = bookingStartMinutes + bookingDuration; bookingDuration = booking.treatments.reduce((sum: number, t: { duration: number }) => sum + t.duration, 0);
} else if (booking.bookedDurationMinutes) {
bookingDuration = booking.bookedDurationMinutes;
} else if (booking.treatmentId) {
bookingDuration = treatmentDurationMap.get(booking.treatmentId) || 60;
} else {
bookingDuration = 60;
}
// Check overlap: slotStart < bookingEnd && slotEnd > bookingStart const bookingStart = parseTime(booking.appointmentTime);
return slotStartMinutes < bookingEndMinutes && slotEndMinutes > bookingStartMinutes; const bookingEnd = bookingStart + bookingDuration;
return slotStartMinutes < bookingEnd && slotEndMinutes > bookingStart;
}); });
return !hasConflict; return !hasConflict;
@@ -459,9 +434,9 @@ const live = {
}), }),
adminListRules: os adminListRules: os
.input(z.object({})) .input(z.object({ sessionId: z.string() }))
.handler(async function* ({ context, signal }) { .handler(async function* ({ input, signal }) {
await assertOwner(context); await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems(); const allRules = await recurringRulesKV.getAllItems();
const sortedRules = allRules.sort((a, b) => { const sortedRules = allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek; if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
@@ -479,9 +454,9 @@ const live = {
}), }),
adminListTimeOff: os adminListTimeOff: os
.input(z.object({})) .input(z.object({ sessionId: z.string() }))
.handler(async function* ({ context, signal }) { .handler(async function* ({ input, signal }) {
await assertOwner(context); await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems(); const allTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate)); const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedTimeOff; yield sortedTimeOff;

View File

@@ -2,8 +2,7 @@ import { call, os } from "@orpc/server";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js"; import { createKV } from "../lib/create-kv.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js"; import { assertOwner, sessionsKV } from "../lib/auth.js";
import { checkAdminRateLimit, getClientIP } from "../lib/rate-limiter.js";
// Schema Definition // Schema Definition
const ReviewSchema = z.object({ const ReviewSchema = z.object({
@@ -134,31 +133,22 @@ const submitReview = os
// Admin Endpoint: approveReview // Admin Endpoint: approveReview
const approveReview = os const approveReview = os
.input(z.object({ id: z.string() })) .input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
try { try {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
const review = await reviewsKV.getItem(input.id); const review = await reviewsKV.getItem(input.id);
if (!review) { if (!review) {
throw new Error("Bewertung nicht gefunden"); throw new Error("Bewertung nicht gefunden");
} }
const session2 = await getSessionFromCookies(context); const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = { const updatedReview = {
...review, ...review,
status: "approved" as const, status: "approved" as const,
reviewedAt: new Date().toISOString(), reviewedAt: new Date().toISOString(),
reviewedBy: session2?.userId || review.reviewedBy, reviewedBy: session?.userId || review.reviewedBy,
}; };
await reviewsKV.setItem(input.id, updatedReview); await reviewsKV.setItem(input.id, updatedReview);
@@ -171,31 +161,22 @@ const approveReview = os
// Admin Endpoint: rejectReview // Admin Endpoint: rejectReview
const rejectReview = os const rejectReview = os
.input(z.object({ id: z.string() })) .input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
try { try {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
const review = await reviewsKV.getItem(input.id); const review = await reviewsKV.getItem(input.id);
if (!review) { if (!review) {
throw new Error("Bewertung nicht gefunden"); throw new Error("Bewertung nicht gefunden");
} }
const session2 = await getSessionFromCookies(context); const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
const updatedReview = { const updatedReview = {
...review, ...review,
status: "rejected" as const, status: "rejected" as const,
reviewedAt: new Date().toISOString(), reviewedAt: new Date().toISOString(),
reviewedBy: session2?.userId || review.reviewedBy, reviewedBy: session?.userId || review.reviewedBy,
}; };
await reviewsKV.setItem(input.id, updatedReview); await reviewsKV.setItem(input.id, updatedReview);
@@ -208,19 +189,10 @@ const rejectReview = os
// Admin Endpoint: deleteReview // Admin Endpoint: deleteReview
const deleteReview = os const deleteReview = os
.input(z.object({ id: z.string() })) .input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
try { try {
await assertOwner(context); await assertOwner(input.sessionId);
// Admin Rate Limiting
const ip = getClientIP((context.req as any).raw.headers as any);
const session = await getSessionFromCookies(context);
if (session) {
const result = checkAdminRateLimit({ ip, userId: session.userId });
if (!result.allowed) {
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
}
}
await reviewsKV.removeItem(input.id); await reviewsKV.removeItem(input.id);
} catch (err) { } catch (err) {
console.error("reviews.deleteReview error", err); console.error("reviews.deleteReview error", err);
@@ -253,12 +225,13 @@ const listPublishedReviews = os.handler(async (): Promise<PublicReview[]> => {
const adminListReviews = os const adminListReviews = os
.input( .input(
z.object({ z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"), statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
}) })
) )
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
try { try {
await assertOwner(context); await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems(); const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all" const filtered = input.statusFilter === "all"
@@ -285,11 +258,12 @@ const live = {
adminListReviews: os adminListReviews: os
.input( .input(
z.object({ z.object({
sessionId: z.string(),
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"), statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
}) })
) )
.handler(async function* ({ input, context, signal }) { .handler(async function* ({ input, signal }) {
await assertOwner(context); await assertOwner(input.sessionId);
const allReviews = await reviewsKV.getAllItems(); const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all" const filtered = input.statusFilter === "all"

13
src/server/rpc/social.ts Normal file
View File

@@ -0,0 +1,13 @@
import { os } from "@orpc/server";
const getSocialMedia = os.handler(async () => {
return {
tiktokProfile: process.env.TIKTOK_PROFILE,
instagramProfile: process.env.INSTAGRAM_PROFILE,
};
});
export const router = os.router({
getSocialMedia,
});

View File

@@ -2,8 +2,6 @@ import { call, os } from "@orpc/server";
import { z } from "zod"; import { z } from "zod";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js"; import { createKV } from "../lib/create-kv.js";
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
const TreatmentSchema = z.object({ const TreatmentSchema = z.object({
id: z.string(), id: z.string(),
@@ -20,10 +18,7 @@ const kv = createKV<Treatment>("treatments");
const create = os const create = os
.input(TreatmentSchema.omit({ id: true })) .input(TreatmentSchema.omit({ id: true }))
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context);
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
await enforceAdminRateLimit(context as any);
const id = randomUUID(); const id = randomUUID();
const treatment = { id, ...input }; const treatment = { id, ...input };
await kv.setItem(id, treatment); await kv.setItem(id, treatment);
@@ -32,18 +27,12 @@ const create = os
const update = os const update = os
.input(TreatmentSchema) .input(TreatmentSchema)
.handler(async ({ input, context }) => { .handler(async ({ input }) => {
await assertOwner(context);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
await kv.setItem(input.id, input); await kv.setItem(input.id, input);
return input; return input;
}); });
const remove = os.input(z.string()).handler(async ({ input, context }) => { const remove = os.input(z.string()).handler(async ({ input }) => {
await assertOwner(context);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
await kv.removeItem(input); await kv.removeItem(input);
}); });