9 Commits

61 changed files with 2837 additions and 2187 deletions

View File

@@ -13,7 +13,6 @@ pnpm-debug.log*
.env.production.local .env.production.local
# Build outputs # Build outputs
dist
build build
.next .next
out out

View File

@@ -1,6 +1,7 @@
# Admin Account Configuration # Admin Account Configuration
ADMIN_USERNAME=owner ADMIN_USERNAME=owner
ADMIN_PASSWORD_HASH=YWRtaW4xMjM= # Base64 encoded password ADMIN_PASSWORD_HASH=$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy # bcrypt hashed 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
@@ -10,9 +11,7 @@ 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
# Social media profiles # Frontend URL (for E-Mail Links)
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,16 +8,6 @@ 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
@@ -40,7 +30,6 @@ 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,29 +1,26 @@
# Multi-stage build for production FROM node:22-alpine AS builder
FROM node:22-alpine AS base
# Install pnpm # Install pnpm
RUN npm install -g pnpm RUN npm install -g pnpm
# Set working directory
WORKDIR /app WORKDIR /app
# Copy package files # Install all deps for building server
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# Copy source code # Copy only server sources and tsconfig for server build
COPY . . COPY src/server ./src/server
COPY tsconfig.server.json ./tsconfig.server.json
COPY tsconfig.server.build.json ./tsconfig.server.build.json
# Build the application # Build server only (no client build)
RUN pnpm build RUN pnpm tsc -p tsconfig.server.build.json
# Production stage
FROM node:22-alpine AS production FROM node:22-alpine AS production
# Install pnpm and su-exec # Install pnpm and required runtime tools
RUN npm install -g pnpm ts-node && apk add --no-cache su-exec RUN npm install -g pnpm ts-node && apk add --no-cache su-exec curl
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
@@ -34,17 +31,13 @@ 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 built application from base stage # Copy prebuilt application artifacts from repository (no TS build in image)
COPY --from=base /app/dist ./dist COPY dist ./dist
COPY --from=base /app/server-dist ./server-dist # Use freshly built server from builder stage
COPY --from=base /app/public ./public COPY --from=builder /app/server-dist ./server-dist
COPY public ./public
# Copy necessary files for runtime # Copy necessary runtime files
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,6 +12,8 @@ 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
@@ -22,33 +24,28 @@ Kopiere die `.env.example` Datei zu `.env` und konfiguriere deine Umgebungsvaria
cp .env.example .env cp .env.example .env
``` ```
### 2. Admin-Passwort Hash generieren ### 2. Admin-Passwort Hash generieren (bcrypt)
Das Admin-Passwort wird als Base64-Hash in der `.env` Datei gespeichert. Hier sind verschiedene Methoden, um einen Hash zu generieren: Das Admin-Passwort wird als bcrypt-Hash in der `.env` Datei gespeichert. So erzeugst du einen Hash:
#### PowerShell (Windows) #### Node.js (empfohlen)
```powershell ```bash
# Einfache Methode mit Base64-Encoding node -e "require('bcrypt').hash('dein_sicheres_passwort', 10).then(console.log)"
$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))
``` ```
#### Node.js (falls verfügbar) Alternativ kannst du ein kleines Script verwenden (falls du es öfter brauchst):
```javascript ```javascript
// In der Node.js Konsole oder als separates Script // scripts/generate-hash.js
const password = "dein_sicheres_passwort"; require('bcrypt').hash(process.argv[2] || 'dein_sicheres_passwort', 10).then(h => {
const hash = Buffer.from(password).toString('base64'); console.log(h);
console.log("Password Hash:", hash); });
``` ```
#### Online-Tools (nur für Entwicklung) Ausführen:
- Verwende einen Base64-Encoder wie [base64encode.org](https://www.base64encode.org/) ```bash
node scripts/generate-hash.js "dein_sicheres_passwort"
```
### 3. .env Datei konfigurieren ### 3. .env Datei konfigurieren
@@ -57,7 +54,8 @@ Bearbeite deine `.env` Datei und setze die generierten Werte:
```env ```env
# Admin Account Configuration # Admin Account Configuration
ADMIN_USERNAME=owner ADMIN_USERNAME=owner
ADMIN_PASSWORD_HASH=ZGVpbl9zaWNoZXJlc19wYXNzd29ydA== # Dein generierter Hash # bcrypt-Hash des Admin-Passworts (kein Base64). Beispielwert:
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
@@ -209,10 +207,12 @@ 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 Base64-Hash in der `.env` Datei gespeichert - Das Passwort wird als bcrypt-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", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -36,6 +36,7 @@ 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:
@@ -49,6 +50,8 @@ 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:

121
docs/caldav-setup.md Normal file
View File

@@ -0,0 +1,121 @@
# 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,17 +37,38 @@ 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 **beide** Kriterien: Das Rate-Limiting prüft die passenden Kriterien je Endpoint. Für Admin-Operationen werden **beide** Limits geprüft:
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: Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers (unterstützt `Headers`-API und einfache Record-Objekte):
- `x-forwarded-for` - `x-forwarded-for`
- `x-real-ip` - `x-real-ip`
- `cf-connecting-ip` (Cloudflare) - `cf-connecting-ip` (Cloudflare)
@@ -60,7 +81,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` in der Funktion `checkBookingRateLimit()` angepasst werden: Die Limits können in `src/server/lib/rate-limiter.ts` angepasst werden. Beispiele:
```typescript ```typescript
// E-Mail-Limit anpassen // E-Mail-Limit anpassen
@@ -74,6 +95,23 @@ 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
@@ -91,3 +129,5 @@ 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.

355
docs/redis-migration.md Normal file
View File

@@ -0,0 +1,355 @@
# 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/)

251
docs/session-management.md Normal file
View File

@@ -0,0 +1,251 @@
# 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,13 +3,7 @@
<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.1.5.1", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"check:types": "tsc --noEmit", "check:types": "tsc --noEmit",
@@ -12,6 +12,8 @@
"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",
@@ -20,6 +22,7 @@
"@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

View File

@@ -1,45 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,32 +0,0 @@
{
"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,5 +45,20 @@ for i in {1..18}; do
sleep 5 sleep 5
done done
echo "[7/7] Tail recent logs (press Ctrl+C to exit)" echo "[6b/7] Verify HTTP /health via Caddy (localhost)"
sudo docker compose -f "$COMPOSE_FILE" logs --since=10m -f for i in {1..12}; do
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,9 +55,6 @@ 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,17 +28,13 @@ 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="${companyName}" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`} ${logo ? `<img src="${logo}" alt="Stargirlnails" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
<div style="margin:16px 0 4px 0; font-size:16px; font-weight:600; color:#64748b;">${companyName}</div> <h1 style="margin:16px 0 0 0; font-size:22px; color:#db2777;">${title}</h1>
<h1 style="margin:0; font-size:22px; color:#db2777;">${title}</h1>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -50,29 +46,6 @@ 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>
@@ -283,35 +256,3 @@ 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,33 +97,28 @@ 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(payload), body: JSON.stringify({
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: "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" }) })] })); 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" }) })] }));
} }

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, renderCustomerMessageHTML } from "../lib/email-templates.js"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } 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,63 +745,4 @@ 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,7 +7,6 @@ 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,
@@ -18,5 +17,4 @@ export const router = {
legal, legal,
gallery, gallery,
reviews, reviews,
social,
}; };

View File

@@ -1,10 +0,0 @@
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,6 +1,4 @@
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";
@@ -16,18 +14,11 @@ 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(() => {
@@ -37,15 +28,10 @@ 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 <> return <BookingStatusPage token={token} />;
{PwaPrompt}
<BookingStatusPage token={token} />
</>;
} }
} }
@@ -53,10 +39,7 @@ 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 <> return <ReviewSubmissionPage token={token} />;
{PwaPrompt}
<ReviewSubmissionPage token={token} />
</>;
} }
} }
@@ -390,44 +373,11 @@ 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 className="mb-4">&copy; 2025 Stargirlnails Kiel. Professional nail design & care with 🩷 and passion in Kiel 🌇.</p> <p>&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: { sessionId: localStorage.getItem("sessionId") || "" } input: {}
}) })
); );
const { data: timeOffPeriods } = useQuery( const { data: timeOffPeriods } = useQuery(
queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({ queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({
input: { sessionId: localStorage.getItem("sessionId") || "" } input: {}
}) })
); );
@@ -177,14 +177,9 @@ export function AdminAvailability() {
return; return;
} }
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createRule( createRule(
{ sessionId, dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime }, { dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`); setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`);
@@ -233,13 +228,8 @@ 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(
{ sessionId, id: rule.id }, { id: rule.id },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`); setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`);
@@ -256,13 +246,8 @@ 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(
{ sessionId, id: rule.id }, { id: rule.id },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg("Regel gelöscht."); setSuccessMsg("Regel gelöscht.");
@@ -348,14 +333,9 @@ export function AdminAvailability() {
return; return;
} }
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createTimeOff( createTimeOff(
{ sessionId, startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason }, { startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg("Urlaubszeit hinzugefügt."); setSuccessMsg("Urlaubszeit hinzugefügt.");
@@ -415,13 +395,8 @@ 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(
{ sessionId, id: period.id }, { id: period.id },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg("Urlaubszeit gelöscht."); setSuccessMsg("Urlaubszeit gelöscht.");

View File

@@ -7,8 +7,6 @@ export function AdminBookings() {
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>("");
@@ -51,33 +49,8 @@ export function AdminBookings() {
}) })
); );
const { mutate: sendMessage, isPending: isSendingMessage } = useMutation( const getTreatmentName = (treatmentId: string) => {
queryClient.bookings.sendCustomerMessage.mutationOptions({ return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
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) => {
@@ -110,35 +83,6 @@ export function AdminBookings() {
setSelectedPhoto(""); setSelectedPhoto("");
}; };
const openMessageModal = (bookingId: string) => {
setShowMessageModal(bookingId);
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;
};
const filteredBookings = bookings?.filter(booking => const filteredBookings = bookings?.filter(booking =>
selectedDate ? booking.appointmentDate === selectedDate : true selectedDate ? booking.appointmentDate === selectedDate : true
).sort((a, b) => { ).sort((a, b) => {
@@ -272,8 +216,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"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{getTreatmentNames(booking)}</div> <div className="text-sm text-gray-900">{getTreatmentName(booking.treatmentId)}</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>
)} )}
@@ -307,57 +251,45 @@ 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 flex-col space-y-2"> <div className="flex space-x-2">
<div className="flex space-x-2"> {booking.status === "pending" && (
{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({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })} onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900" className="text-green-600 hover:text-green-900"
> >
Reactivate Confirm
</button> </button>
)} <button
</div> onClick={() => setShowCancelConfirm(booking.id)}
{/* Show message button for future bookings with email */} className="text-red-600 hover:text-red-900"
{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={() => openMessageModal(booking.id)} onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
className="text-pink-600 hover:text-pink-900 text-left" className="text-green-600 hover:text-green-900"
title="Nachricht an Kunden senden"
> >
💬 Nachricht Reactivate
</button> </button>
)} )}
</div> </div>
@@ -420,8 +352,7 @@ export function AdminBookings() {
<div className="flex space-x-3"> <div className="flex space-x-3">
<button <button
onClick={() => { onClick={() => {
const sessionId = localStorage.getItem("sessionId") || ""; updateBookingStatus({ id: showCancelConfirm, status: "cancelled" });
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"
> >
@@ -437,116 +368,6 @@ 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,
treatmentIds: createFormData.treatmentId ? [createFormData.treatmentId] : [] treatmentId: createFormData.treatmentId
} }
}), }),
enabled: !!createFormData.appointmentDate && !!createFormData.treatmentId enabled: !!createFormData.appointmentDate && !!createFormData.treatmentId
@@ -58,16 +58,7 @@ export function AdminCalendar() {
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({ ...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: { input: {
date: rescheduleFormData.appointmentDate, date: rescheduleFormData.appointmentDate,
treatmentIds: (() => { treatmentId: (showRescheduleModal ? bookings?.find(b => b.id === showRescheduleModal)?.treatmentId : '') || ''
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
@@ -95,16 +86,8 @@ export function AdminCalendar() {
queryClient.bookings.generateCalDAVToken.mutationOptions() queryClient.bookings.generateCalDAVToken.mutationOptions()
); );
const getTreatmentNames = (booking: any) => { const getTreatmentName = (treatmentId: string) => {
// Handle new treatments array structure return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
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) => {
@@ -181,24 +164,18 @@ 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 = () => {
const sessionId = localStorage.getItem('sessionId'); if (!showDeleteConfirm) return;
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"
}, { }, {
@@ -214,7 +191,6 @@ 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,
}, { }, {
@@ -233,32 +209,9 @@ 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({
sessionId, ...createFormData
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);
@@ -299,13 +252,11 @@ export function AdminCalendar() {
}; };
const handleRescheduleBooking = () => { const handleRescheduleBooking = () => {
const sessionId = localStorage.getItem('sessionId'); if (!showRescheduleModal) return;
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,
@@ -322,11 +273,8 @@ 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);
@@ -506,7 +454,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} - ${getTreatmentNames(booking)} (${booking.appointmentTime})`} title={`${booking.customerName} - ${getTreatmentName(booking.treatmentId)} (${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>
@@ -563,7 +511,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> {getTreatmentNames(booking)} <strong>Behandlung:</strong> {getTreatmentName(booking.treatmentId)}
</div> </div>
<div> <div>
<strong>Uhrzeit:</strong> {booking.appointmentTime} <strong>Uhrzeit:</strong> {booking.appointmentTime}
@@ -879,7 +827,7 @@ export function AdminCalendar() {
{(() => { {(() => {
const booking = bookings?.find(b => b.id === showRescheduleModal); const booking = bookings?.find(b => b.id === showRescheduleModal);
const treatmentName = booking ? getTreatmentNames(booking) : ''; const treatmentName = booking ? getTreatmentName(booking.treatmentId) : '';
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: { sessionId: localStorage.getItem("sessionId") || "" } input: {}
}) })
); );
@@ -166,12 +166,6 @@ 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);
@@ -191,7 +185,6 @@ export function AdminGallery() {
updatePhotoOrder( updatePhotoOrder(
{ {
sessionId,
photoOrders photoOrders
}, },
{ {
@@ -304,15 +297,9 @@ 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
}, },
@@ -396,13 +383,8 @@ 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(
{ sessionId, id: photo.id }, { id: photo.id },
{ {
onSuccess: () => { onSuccess: () => {
setSuccessMsg("Foto gelöscht."); setSuccessMsg("Foto gelöscht.");
@@ -425,13 +407,8 @@ 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(
{ sessionId, id: photo.id }, { 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,18 +91,17 @@ 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: { sessionId, statusFilter: activeStatusTab }, input: { 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: { sessionId }, input: {},
}) })
); );
@@ -266,13 +265,13 @@ export function AdminReviews() {
{review.status === "pending" && ( {review.status === "pending" && (
<> <>
<button <button
onClick={() => approveReview({ sessionId, id: review.id })} onClick={() => approveReview({ 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({ sessionId, id: review.id })} onClick={() => rejectReview({ 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
@@ -289,7 +288,7 @@ export function AdminReviews() {
{review.status === "approved" && ( {review.status === "approved" && (
<> <>
<button <button
onClick={() => rejectReview({ sessionId, id: review.id })} onClick={() => rejectReview({ 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
@@ -306,7 +305,7 @@ export function AdminReviews() {
{review.status === "rejected" && ( {review.status === "rejected" && (
<> <>
<button <button
onClick={() => approveReview({ sessionId, id: review.id })} onClick={() => approveReview({ 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
@@ -334,7 +333,7 @@ export function AdminReviews() {
</p> </p>
<div className="flex space-x-3"> <div className="flex space-x-3">
<button <button
onClick={() => deleteReview({ sessionId, id: showDeleteConfirm })} onClick={() => deleteReview({ 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,7 +11,6 @@ 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;
@@ -34,7 +33,6 @@ 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(
@@ -50,56 +48,45 @@ export function AuthProvider({ children }: AuthProviderProps) {
); );
useEffect(() => { useEffect(() => {
// Check for existing session on app load // Check for existing session on app load - session comes from cookies
const storedSessionId = localStorage.getItem("sessionId"); verifySessionMutation({})
if (storedSessionId) { .then((result) => {
verifySessionMutation(storedSessionId) setUser(result.user);
.then((result) => { })
setUser(result.user); .catch(() => {
setSessionId(storedSessionId); // Session invalid or expired - user remains null
}) })
.catch(() => { .finally(() => {
localStorage.removeItem("sessionId"); setIsLoading(false);
}) });
.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);
setSessionId(result.sessionId); // Cookies are set automatically by the server
localStorage.setItem("sessionId", result.sessionId);
} catch (error) { } catch (error) {
throw error; throw error;
} }
}; };
const logout = async () => { const logout = async () => {
if (sessionId) { try {
try { await logoutMutation({});
await logoutMutation(sessionId); // Cookies are cleared automatically by the server
} 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,12 +1,9 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect } 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 [selectedTreatments, setSelectedTreatments] = useState<Array<{id: string, name: string, duration: number, price: number}>>([]); const [selectedTreatment, setSelectedTreatment] = useState("");
const [customerName, setCustomerName] = useState(""); const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState(""); const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState(""); const [customerPhone, setCustomerPhone] = useState("");
@@ -14,131 +11,37 @@ 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()
); );
// Comment 3: Compute total duration and price once per render // Dynamische Verfügbarkeitsabfrage für das gewählte Datum und die Behandlung
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: availabilityQueryInput as any input: {
date: appointmentDate,
treatmentId: selectedTreatment
}
}), }),
enabled: availabilityQueryEnabled enabled: !!appointmentDate && !!selectedTreatment
}); });
const { mutate: createBooking, isPending } = useMutation( const { mutate: createBooking, isPending } = useMutation(
queryClient.bookings.create.mutationOptions() queryClient.bookings.create.mutationOptions()
); );
// Comment 2: Handle treatment checkbox toggle with functional state updates const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
const handleTreatmentToggle = (treatment: {id: string, name: string, duration: number, price: number}) => {
setSelectedTreatments((prev) => { // Clear selectedTime when treatment changes
const isSelected = prev.some(t => t.id === treatment.id); const handleTreatmentChange = (treatmentId: string) => {
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)) {
@@ -227,7 +130,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:", {
// selectedTreatments, // selectedTreatment,
// customerName, // customerName,
// customerEmail, // customerEmail,
// customerPhone, // customerPhone,
@@ -236,27 +139,19 @@ export function BookingForm() {
// agbAccepted // agbAccepted
// }); // });
if (selectedTreatments.length === 0 || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedTime) { if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedTime) {
if (selectedTreatments.length === 0) { setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
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:", {
// treatments: selectedTreatments, // treatmentId: selectedTreatment,
// customerName, // customerName,
// customerEmail, // customerEmail,
// customerPhone, // customerPhone,
@@ -267,7 +162,7 @@ export function BookingForm() {
// }); // });
createBooking( createBooking(
{ {
treatments: selectedTreatments, treatmentId: selectedTreatment,
customerName, customerName,
customerEmail, customerEmail,
customerPhone, customerPhone,
@@ -278,7 +173,7 @@ export function BookingForm() {
}, },
{ {
onSuccess: () => { onSuccess: () => {
setSelectedTreatments([]); setSelectedTreatment("");
setCustomerName(""); setCustomerName("");
setCustomerEmail(""); setCustomerEmail("");
setCustomerPhone(""); setCustomerPhone("");
@@ -286,7 +181,6 @@ export function BookingForm() {
setSelectedTime(""); setSelectedTime("");
setNotes(""); setNotes("");
setAgbAccepted(false); setAgbAccepted(false);
setAgeConfirmed(false);
setInspirationPhoto(""); setInspirationPhoto("");
setPhotoPreview(""); setPhotoPreview("");
setErrorMessage(""); setErrorMessage("");
@@ -323,65 +217,24 @@ export function BookingForm() {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Treatment Selection */} {/* Treatment Selection */}
<div> <div>
<div className="flex justify-between items-center mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700"> Behandlung auswählen *
Behandlungen auswählen (1-3) * </label>
</label> <select
<span className="text-sm text-gray-600"> value={selectedTreatment}
{selectedTreatments.length} von 3 ausgewählt onChange={(e) => handleTreatmentChange(e.target.value)}
</span> className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
</div> required
>
{/* Checkbox List Container */} <option value="">Wähle eine Behandlung</option>
<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"> {treatments?.map((treatment) => (
{treatments?.map((treatment) => { <option key={treatment.id} value={treatment.id}>
const isSelected = selectedTreatments.some(t => t.id === treatment.id); {treatment.name} - {(treatment.price / 100).toFixed(2)} ({treatment.duration} Min)
const isDisabled = selectedTreatments.length >= 3 && !isSelected; </option>
))}
return ( </select>
<div key={treatment.id} className="flex items-start space-x-3"> {selectedTreatmentData && (
<input <p className="mt-2 text-sm text-gray-600">{selectedTreatmentData.description}</p>
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>
@@ -449,7 +302,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 || selectedTreatments.length === 0 || isLoading || isFetching} disabled={!appointmentDate || !selectedTreatment || isLoading || isFetching}
required required
> >
<option value="">Zeit auswählen</option> <option value="">Zeit auswählen</option>
@@ -459,23 +312,23 @@ export function BookingForm() {
</option> </option>
))} ))}
</select> </select>
{appointmentDate && selectedTreatments.length > 0 && isLoading && ( {appointmentDate && selectedTreatment && 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 && selectedTreatments.length > 0 && error && ( {appointmentDate && selectedTreatment && 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 && selectedTreatments.length > 0 && !isLoading && !isFetching && !error && (!availableTimes || availableTimes.length === 0) && ( {appointmentDate && selectedTreatment && !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>
)} )}
{selectedTreatments.length > 0 && ( {selectedTreatmentData && (
<p className="mt-1 text-xs text-gray-500">Gesamtdauer: {totalDuration} Minuten</p> <p className="mt-1 text-xs text-gray-500">Dauer: {selectedTreatmentData.duration} Minuten</p>
)} )}
</div> </div>
</div> </div>
@@ -530,7 +383,7 @@ export function BookingForm() {
</div> </div>
{/* AGB Acceptance */} {/* AGB Acceptance */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 space-y-4"> <div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<input <input
type="checkbox" type="checkbox"
@@ -556,22 +409,6 @@ 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,50 +8,6 @@ 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":
@@ -101,7 +57,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<RescheduleProposalDetails | null>(null); const [rescheduleProposal, setRescheduleProposal] = useState<any | 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);
@@ -115,7 +71,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<RescheduleProposalDetails>({ const rescheduleQuery = useQuery({
...queryClient.cancellation.getRescheduleProposal.queryOptions({ input: { token } }), ...queryClient.cancellation.getRescheduleProposal.queryOptions({ input: { token } }),
enabled: !!token && (!!bookingError || !booking), enabled: !!token && (!!bookingError || !booking),
}); });
@@ -203,7 +159,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 || 'TBD'} um ${rescheduleProposal.proposed.time || 'TBD'} Uhr akzeptieren?` `Möchtest du den neuen Termin am ${rescheduleProposal.proposed.date} um ${rescheduleProposal.proposed.time} Uhr akzeptieren?`
); );
if (confirmAccept) { if (confirmAccept) {
acceptMutation.mutate({ token }); acceptMutation.mutate({ token });
@@ -355,56 +311,12 @@ 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 mt-2"> <div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</div>
{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 || 'TBD'} um {rescheduleProposal.proposed.time || 'TBD'} Uhr</div> <div className="text-gray-900 font-medium">{rescheduleProposal.proposed.date} um {rescheduleProposal.proposed.time} Uhr</div>
<div className="text-gray-700 text-sm mt-2"> <div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</div>
{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">
@@ -566,44 +478,20 @@ 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">
{/* Treatments List */} <span className="text-gray-600">Behandlung:</span>
<div className="py-2 border-b border-gray-100"> <span className="font-medium text-gray-900">{booking?.treatmentName}</span>
<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, useEffect } from "react"; import { useState } from "react";
import { useAuth } from "@/client/components/auth-provider"; import { useAuth } from "@/client/components/auth-provider";
export function LoginForm() { export function LoginForm() {
@@ -6,27 +6,9 @@ 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,10 +57,6 @@ 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[] = [];
@@ -88,44 +84,12 @@ export function ProfileLanding({ onNavigateToBooking }: ProfileLandingProps) {
</p> </p>
<button <button
onClick={onNavigateToBooking} onClick={onNavigateToBooking}
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" 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"
> >
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

@@ -1,187 +0,0 @@
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,11 +139,7 @@ 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"> <span className="font-medium text-gray-900">{booking.treatmentName}</span>
{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, sessionId, logout } = useAuth(); const { user, 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,13 +31,7 @@ export function UserProfile() {
return; return;
} }
if (!sessionId) {
setError("Keine gültige Sitzung");
return;
}
changePassword({ changePassword({
sessionId,
currentPassword, currentPassword,
newPassword, newPassword,
}, { }, {

View File

@@ -5,8 +5,32 @@ import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import type { router } from "@/server/rpc"; import type { router } from "@/server/rpc";
const link = new RPCLink({ url: `${window.location.origin}/rpc` }); // Helper function to read CSRF token from cookie
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,6 +1,7 @@
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";
@@ -8,10 +9,58 @@ import { clientEntry } from "./routes/client-entry.js";
const app = new Hono(); const app = new Hono();
// Allow all hosts for Tailscale Funnel // CORS Configuration
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) => {
// Accept requests from any host const isDev = process.env.NODE_ENV === 'development';
return next(); const directives = [
"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
@@ -62,9 +111,6 @@ 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,17 +1,104 @@
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 }; type Session = { id: string; userId: string; expiresAt: string; createdAt: string; csrfToken?: 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");
export async function assertOwner(sessionId: string): Promise<void> { // Cookie configuration constants
const session = await sessionsKV.getItem(sessionId); export const SESSION_COOKIE_NAME = 'sessionId';
if (!session) throw new Error("Invalid session"); export const CSRF_COOKIE_NAME = 'csrf-token';
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired"); export const COOKIE_OPTIONS = {
const user = await usersKV.getItem(session.userId); httpOnly: true,
if (!user || user.role !== "owner") throw new Error("Forbidden"); 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);
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");
const user = await usersKV.getItem(session.userId);
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,4 +1,5 @@
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";
@@ -8,27 +9,6 @@ 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, 0);
const treatmentItems = treatments.map(t =>
options.showPrices
? `<li><strong>${t.name}</strong> - ${t.duration} Min - ${t.price.toFixed(2)} €</li>`
: `<li>${t.name} - ${t.duration} Min - ${t.price.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> {
@@ -52,18 +32,13 @@ 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="${companyName}" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`} ${logo ? `<img src="${logo}" alt="Stargirlnails" style="width:120px; height:auto; display:inline-block;" />` : `<div style=\"font-size:24px\">💅</div>`}
<div style="margin:16px 0 4px 0; font-size:16px; font-weight:600; color:#64748b;">${companyName}</div> <h1 style="margin:16px 0 0 0; font-size:22px; color:#db2777;">${title}</h1>
<h1 style="margin:0; font-size:22px; color:#db2777;">${title}</h1>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -75,29 +50,6 @@ 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>
@@ -107,22 +59,17 @@ async function renderBrandedEmail(title: string, bodyHtml: string): Promise<stri
</div>`; </div>`;
} }
export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string; treatments: Array<{id: string; name: string; duration: number; price: number}> }) { export async function renderBookingPendingHTML(params: { name: string; date: string; time: string; statusUrl?: string }) {
const { name, date, time, statusUrl, treatments } = params; const { name, date, time, statusUrl } = 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 ${name},</p> <p>Hallo ${safeName},</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;">
@@ -140,22 +87,17 @@ 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; treatments: Array<{id: string; name: string; duration: number; price: number}> }) { export async function renderBookingConfirmedHTML(params: { name: string; date: string; time: string; cancellationUrl?: string; reviewUrl?: string }) {
const { name, date, time, cancellationUrl, reviewUrl, treatments } = params; const { name, date, time, cancellationUrl, reviewUrl } = 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 ${name},</p> <p>Hallo ${safeName},</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>
@@ -185,22 +127,17 @@ 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; treatments: Array<{id: string; name: string; duration: number; price: number}> }) { export async function renderBookingCancelledHTML(params: { name: string; date: string; time: string }) {
const { name, date, time, treatments } = params; const { name, date, time } = 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 ${name},</p> <p>Hallo ${safeName},</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>
@@ -215,30 +152,29 @@ export async function renderAdminBookingNotificationHTML(params: {
name: string; name: string;
date: string; date: string;
time: string; time: string;
treatments: Array<{id: string; name: string; duration: number; price: number}>; treatment: string;
phone: string; phone: string;
notes?: string; notes?: string;
hasInspirationPhoto: boolean; hasInspirationPhoto: boolean;
}) { }) {
const { name, date, time, treatments, phone, notes, hasInspirationPhoto } = params; const { name, date, time, treatment, 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> ${name}</li> <li><strong>Name:</strong> ${safeName}</li>
<li><strong>Telefon:</strong> ${phone}</li> <li><strong>Telefon:</strong> ${safePhone}</li>
<li><strong>Behandlungen:</strong> <li><strong>Behandlung:</strong> ${safeTreatment}</li>
<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>
${notes ? `<li><strong>Notizen:</strong> ${notes}</li>` : ''} ${safeNotes ? `<li><strong>Notizen:</strong> ${safeNotes}</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>
@@ -260,13 +196,15 @@ 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 ${params.name},</p> <p>Hallo ${safeName},</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>
@@ -281,7 +219,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;">${params.treatmentName}</td> <td style="padding:6px 0;">${safeTreatment}</td>
</tr> </tr>
</table> </table>
</div> </div>
@@ -311,15 +249,19 @@ 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>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p> <p>der Kunde <strong>${safeCustomerName}</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> ${params.customerName}</li> <li><strong>Kunde:</strong> ${safeCustomerName}</li>
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''} ${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''} ${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${params.treatmentName}</li> <li><strong>Behandlung:</strong> ${safeTreatment}</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>
@@ -337,13 +279,15 @@ 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>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p> <p>der Kunde <strong>${safeCustomerName}</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> ${params.customerName}</li> <li><strong>Kunde:</strong> ${safeCustomerName}</li>
<li><strong>Behandlung:</strong> ${params.treatmentName}</li> <li><strong>Behandlung:</strong> ${safeTreatment}</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>
@@ -371,19 +315,24 @@ 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> ${proposal.customerName}</li> <li><strong>Kunde:</strong> ${safeName}</li>
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''} ${safeEmail ? `<li><strong>E-Mail:</strong> ${safeEmail}</li>` : ''}
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''} ${safePhone ? `<li><strong>Telefon:</strong> ${safePhone}</li>` : ''}
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li> <li><strong>Behandlung:</strong> ${safeTreatment}</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>
@@ -391,43 +340,3 @@ 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,7 +6,6 @@ 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
@@ -29,27 +28,15 @@ 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;
customerEmail?: string; treatmentName: string;
treatments: Array<{id: string; name: string; duration: number; price: number}>;
}): string { }): string {
const { date, time, customerName, customerEmail, treatments } = params; const { date, time, durationMinutes, customerName, treatmentName } = 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);
@@ -69,17 +56,6 @@ 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, 0);
const treatmentDetails = treatments.map(t =>
`${icsEscape(t.name)} (${t.duration} Min, ${t.price.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',
@@ -92,11 +68,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:${treatmentNames} - Stargirlnails Kiel`, `SUMMARY:${treatmentName} - Stargirlnails Kiel`,
`DESCRIPTION:${description}`, `DESCRIPTION:Termin für ${treatmentName} bei Stargirlnails Kiel`,
'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'}`,
...(customerEmail ? [`ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerEmail}`] : []), `ATTENDEE;CN=${customerName};RSVP=TRUE:mailto:${customerName}`,
'STATUS:CONFIRMED', 'STATUS:CONFIRMED',
'SEQUENCE:0', 'SEQUENCE:0',
'BEGIN:VALARM', 'BEGIN:VALARM',
@@ -154,27 +130,22 @@ 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(payload), body: JSON.stringify({
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) {
@@ -182,9 +153,6 @@ 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 };
} }
@@ -210,9 +178,9 @@ export async function sendEmailWithAGBAndCalendar(
calendarParams: { calendarParams: {
date: string; date: string;
time: string; time: string;
durationMinutes: number;
customerName: string; customerName: string;
customerEmail?: string; treatmentName: 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,20 +137,29 @@ 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: Record<string, string | undefined>): string | undefined { export function getClientIP(headers: Headers | Record<string, string | undefined>): string | undefined {
// Check common proxy headers // Check common proxy headers
const forwardedFor = headers['x-forwarded-for']; const get = (name: string): string | undefined => {
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 = headers['x-real-ip']; const realIP = get('x-real-ip');
if (realIP) { if (realIP) {
return realIP; return realIP;
} }
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare const cfConnectingIP = get('cf-connecting-ip'); // Cloudflare
if (cfConnectingIP) { if (cfConnectingIP) {
return cfConnectingIP; return cfConnectingIP;
} }
@@ -159,4 +168,117 @@ export function getClientIP(headers: Record<string, string | undefined>): string
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

@@ -0,0 +1,37 @@
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;
treatments?: Array<{id: string, name: string, duration: number, price: number}>; treatmentId: string;
customerName: string; customerName: string;
customerEmail?: string; customerEmail?: string;
customerPhone?: string; customerPhone?: string;
@@ -13,8 +13,6 @@ 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;
}; };
@@ -33,6 +31,12 @@ 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();
@@ -46,12 +50,13 @@ 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 totalMinutes = hours * 60 + minutes + minutesToAdd; const total = hours * 60 + minutes + minutesToAdd;
const newHours = Math.floor(totalMinutes / 60); const endHours = Math.floor(total / 60) % 24;
const newMinutes = totalMinutes % 60; const endMinutes = total % 60;
return `${String(newHours).padStart(2, '0')}:${String(newMinutes).padStart(2, '0')}`; return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
} }
function generateICSContent(bookings: Booking[], treatments: Treatment[]): string { function generateICSContent(bookings: Booking[], treatments: Treatment[]): string {
@@ -73,41 +78,13 @@ X-WR-TIMEZONE:Europe/Berlin
); );
for (const booking of activeBookings) { for (const booking of activeBookings) {
// Handle new treatments array structure const treatment = treatments.find(t => t.id === booking.treatmentId);
let treatmentNames: string; const treatmentName = treatment?.name || 'Unbekannte Behandlung';
let duration: number; const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
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), 0);
// Build detailed treatment list for description
treatmentDetails = booking.treatments
.map(t => `- ${t.name} (${t.duration} Min., ${t.price}€)`)
.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}€)`;
}
}
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime); const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
const endTimeStr = addMinutesToTime(booking.appointmentTime, duration); const computedEnd = addMinutesToTime(booking.appointmentTime, duration);
const endTime = formatDateTime(booking.appointmentDate, endTimeStr); const endTime = formatDateTime(booking.appointmentDate, computedEnd);
// 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`;
@@ -120,8 +97,8 @@ UID:${uid}
DTSTAMP:${now} DTSTAMP:${now}
DTSTART:${startTime} DTSTART:${startTime}
DTEND:${endTime} DTEND:${endTime}
SUMMARY:${treatmentNames} - ${booking.customerName} SUMMARY:${treatmentName} - ${booking.customerName}
DESCRIPTION:${treatmentDetails}\\n\\nKunde: ${booking.customerName}${booking.customerPhone ? `\\nTelefon: ${booking.customerPhone}` : ''}${booking.notes ? `\\nNotizen: ${booking.notes}` : ''} DESCRIPTION:Behandlung: ${treatmentName}\\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
@@ -133,6 +110,60 @@ 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') {
@@ -221,42 +252,54 @@ 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 {
// Authentifizierung über Token im Query-Parameter // Extract token from Authorization header (Bearer/Basic) or query parameter (legacy)
const token = c.req.query('token'); const tokenResult = extractCalDAVToken(c);
if (!token) { if (!tokenResult) {
return c.text('Unauthorized - Token required', 401); return c.text('Unauthorized - Token erforderlich via Authorization (Bearer oder Basic) oder (deprecated) ?token', 401, {
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
});
} }
// Token validieren // Validate token against caldavTokens KV store
const tokenData = await sessionsKV.getItem(token); const tokenData = await caldavTokensKV.getItem(tokenResult.token);
if (!tokenData) { if (!tokenData) {
return c.text('Unauthorized - Invalid token', 401); return c.text('Unauthorized - Invalid or expired token', 401, {
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
});
} }
// Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar) // Check token expiration
// 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()) {
return c.text('Unauthorized - Token expired', 401); // Clean up expired token
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);
return c.text(icsContent, 200, { const headers: Record<string, string> = {
"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,18 +30,12 @@ export function clientEntry(c: Context<BlankEnv>) {
} }
return c.html( return c.html(
<html lang="de"> <html lang="en">
<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,6 +11,7 @@ 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,8 +1,25 @@
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();
@@ -21,26 +38,68 @@ 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(),
}); });
type User = z.output<typeof UserSchema>; // Use shared KV stores from auth.ts to avoid duplication
type Session = z.output<typeof SessionSchema>;
const usersKV = createKV<User>("users"); // Password hashing using bcrypt
const sessionsKV = createKV<Session>("sessions"); const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y
// Simple password hashing (in production, use bcrypt or similar) const isBase64Hash = (hash: string): boolean => {
const hashPassword = (password: string): string => { if (hash.startsWith(BCRYPT_PREFIX)) return false;
return Buffer.from(password).toString('base64'); try {
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 verifyPassword = (password: string, hash: string): boolean => { const hashPassword = async (password: string): Promise<string> => {
return hashPassword(password) === hash; return bcrypt.hash(password, 10);
};
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();
@@ -49,7 +108,12 @@ 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";
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123"); let adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || await 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 = {
@@ -66,24 +130,52 @@ const initializeOwner = async () => {
} }
}; };
// Initialize on module load // Initialize on module load: first migrate legacy hashes, then ensure owner exists
initializeOwner(); (async () => {
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 }) => { .handler(async ({ input, context }) => {
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 || !verifyPassword(input.password, user.passwordHash)) { if (!user) {
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");
} }
// Create session const isValid = await verifyPassword(input.password, user.passwordHash);
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
@@ -92,12 +184,22 @@ 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,
@@ -108,25 +210,28 @@ const login = os
}); });
const logout = os const logout = os
.input(z.string()) // sessionId .input(z.object({})) // No input needed - session comes from cookies
.handler(async ({ input }) => { .handler(async ({ context }) => {
await sessionsKV.removeItem(input); const session = await getSessionFromCookies(context);
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.string()) // sessionId .input(z.object({})) // No input needed - session comes from cookies
.handler(async ({ input }) => { .handler(async ({ context }) => {
const session = await sessionsKV.getItem(input); const session = await getSessionFromCookies(context);
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");
@@ -144,12 +249,11 @@ 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 }) => { .handler(async ({ input, context }) => {
const session = await sessionsKV.getItem(input.sessionId); const session = await getSessionFromCookies(context);
if (!session) { if (!session) {
throw new Error("Invalid session"); throw new Error("Invalid session");
} }
@@ -159,16 +263,31 @@ const changePassword = os
throw new Error("User not found"); throw new Error("User not found");
} }
if (!verifyPassword(input.currentPassword, user.passwordHash)) { // Validate CSRF token for password change
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: hashPassword(input.newPassword), passwordHash: await 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,14 +1,17 @@
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 { 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, renderCustomerMessageHTML } from "../lib/email-templates.js"; import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
import { router as rootRouter } from "./index.js"; import { router as rootRouter, os, call } 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 } from "../lib/rate-limiter.js"; import { checkBookingRateLimit, getClientIP, checkAdminRateLimit, enforceAdminRateLimit } 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;
@@ -44,7 +47,7 @@ function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean
async function validateBookingAgainstRules( async function validateBookingAgainstRules(
date: string, date: string,
time: string, time: string,
totalDuration: number treatmentDuration: 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);
@@ -69,7 +72,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 + totalDuration; const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
const isWithinRules = matchingRules.some(rule => { const isWithinRules = matchingRules.some(rule => {
const ruleStartMinutes = parseTime(rule.startTime); const ruleStartMinutes = parseTime(rule.startTime);
@@ -88,7 +91,7 @@ async function validateBookingAgainstRules(
async function checkBookingConflicts( async function checkBookingConflicts(
date: string, date: string,
time: string, time: string,
totalDuration: number, treatmentDuration: number,
excludeBookingId?: string excludeBookingId?: string
): Promise<void> { ): Promise<void> {
const allBookings = await kv.getAllItems(); const allBookings = await kv.getAllItems();
@@ -99,10 +102,10 @@ async function checkBookingConflicts(
); );
const bookingStartMinutes = parseTime(time); const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + totalDuration; const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
// Cache treatment durations by ID to avoid N+1 lookups (for backward compatibility with old bookings) // Cache treatment durations by ID to avoid N+1 lookups
const uniqueTreatmentIds = [...new Set(dateBookings.filter(b => b.treatmentId).map(booking => booking.treatmentId!))]; const uniqueTreatmentIds = [...new Set(dateBookings.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) {
@@ -112,21 +115,10 @@ 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) {
let existingDuration: number; // Use cached duration or fallback to bookedDurationMinutes if available
let existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
// Handle both new bookings with treatments array and old bookings with treatmentId if (existingBooking.bookedDurationMinutes) {
if (existingBooking.treatments && existingBooking.treatments.length > 0) { existingDuration = existingBooking.bookedDurationMinutes;
// 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);
@@ -139,22 +131,8 @@ 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({
treatments: TreatmentsArraySchema, treatmentId: z.string(),
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(),
@@ -166,12 +144,7 @@ const CreateBookingInputSchema = z.object({
const BookingSchema = z.object({ const BookingSchema = z.object({
id: z.string(), id: z.string(),
treatments: z.array(z.object({ treatmentId: z.string(),
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(),
@@ -180,12 +153,10 @@ 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>;
@@ -304,49 +275,46 @@ 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.");
} }
} }
// Validate all treatments exist and snapshot them from KV // Get treatment duration for validation
const snapshottedTreatments = [] as Array<{id: string; name: string; duration: number; price: number}>; const treatment = await treatmentsKV.getItem(input.treatmentId);
for (const inputTreatment of input.treatments) { if (!treatment) {
const treatment = await treatmentsKV.getItem(inputTreatment.id); throw new Error("Behandlung nicht gefunden.");
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,
totalDuration treatment.duration
); );
// Check for booking conflicts // Check for booking conflicts
await checkBookingConflicts( await checkBookingConflicts(
input.appointmentDate, input.appointmentDate,
input.appointmentTime, input.appointmentTime,
totalDuration treatment.duration
); );
// 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,
treatments: snapshottedTreatments, treatmentId: input.treatmentId,
customerName: input.customerName, customerName: sanitizedName,
customerEmail: input.customerEmail, customerEmail: input.customerEmail,
customerPhone: input.customerPhone, customerPhone: sanitizedPhone,
appointmentDate: input.appointmentDate, appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime, appointmentTime: input.appointmentTime,
notes: input.notes, notes: sanitizedNotes,
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);
@@ -360,21 +328,15 @@ 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: input.customerName, name: sanitizedName,
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.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, 0);
await sendEmail({ await sendEmail({
to: input.customerEmail, to: input.customerEmail,
subject: "Deine Terminanfrage ist eingegangen", subject: "Deine Terminanfrage ist eingegangen",
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`, 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`,
html, html,
}).catch(() => {}); }).catch(() => {});
})(); })();
@@ -383,29 +345,30 @@ 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: input.customerName, name: sanitizedName,
date: input.appointmentDate, date: input.appointmentDate,
time: input.appointmentTime, time: input.appointmentTime,
treatments: input.treatments, treatment: treatmentName,
phone: input.customerPhone || "Nicht angegeben", phone: sanitizedPhone || "Nicht angegeben",
notes: input.notes, notes: sanitizedNotes,
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.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, 0);
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` + const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
`Name: ${input.customerName}\n` + `Name: ${sanitizedName}\n` +
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` + `Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` +
`Behandlungen:\n${treatmentsText}\n` + `Behandlung: ${treatmentName}\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` +
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` + `${sanitizedNotes ? `Notizen: ${sanitizedNotes}\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.`;
@@ -413,14 +376,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 - ${input.customerName}`, subject: `Neue Buchungsanfrage - ${sanitizedName}`,
text: adminText, text: adminText,
html: adminHtml, html: adminHtml,
}, input.inspirationPhoto, input.customerName).catch(() => {}); }, input.inspirationPhoto, sanitizedName).catch(() => {});
} else { } else {
await sendEmail({ await sendEmail({
to: process.env.ADMIN_EMAIL, to: process.env.ADMIN_EMAIL,
subject: `Neue Buchungsanfrage - ${input.customerName}`, subject: `Neue Buchungsanfrage - ${sanitizedName}`,
text: adminText, text: adminText,
html: adminHtml, html: adminHtml,
}).catch(() => {}); }).catch(() => {});
@@ -436,26 +399,16 @@ 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 }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context as unknown as Context);
// 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");
@@ -479,48 +432,40 @@ 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
}); });
const treatmentsText = booking.treatments.map(t => `- ${t.name} (${t.duration} Min, ${t.price.toFixed(2)} €)`).join('\n'); // Get treatment information for ICS file
const totalDuration = booking.treatments.reduce((sum, t) => sum + t.duration, 0); const allTreatments = await treatmentsKV.getAllItems();
const totalPrice = booking.treatments.reduce((sum, t) => sum + t.price, 0); const treatment = allTreatments.find(t => t.id === booking.treatmentId);
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 ${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`, 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`,
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,
customerEmail: booking.customerEmail, treatmentName: treatmentName
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({ const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
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.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, 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 ${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`, 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`,
html, html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}); });
@@ -534,12 +479,13 @@ 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 }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context as unknown as Context);
// 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");
@@ -564,21 +510,11 @@ 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({ const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
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.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, 0);
await sendEmail({ await sendEmail({
to: booking.customerEmail, to: booking.customerEmail,
subject: "Dein Termin wurde abgesagt", subject: "Dein Termin wurde abgesagt",
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`, 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`,
html, html,
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined, bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
}); });
@@ -593,8 +529,7 @@ 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({
sessionId: z.string(), 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").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(),
@@ -602,9 +537,11 @@ const createManual = os
appointmentTime: z.string(), appointmentTime: z.string(),
notes: z.string().optional(), notes: z.string().optional(),
})) }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
// Admin authentication // Admin authentication
await assertOwner(input.sessionId); await assertOwner(context as unknown as Context);
// 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);
@@ -627,45 +564,42 @@ const createManual = os
} }
} }
// Validate all treatments exist and snapshot them from KV // Get treatment duration for validation
const snapshottedTreatments = [] as Array<{id: string; name: string; duration: number; price: number}>; const treatment = await treatmentsKV.getItem(input.treatmentId);
for (const inputTreatment of input.treatments) { if (!treatment) {
const treatment = await treatmentsKV.getItem(inputTreatment.id); throw new Error("Behandlung nicht gefunden.");
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,
totalDuration treatment.duration
); );
// Check for booking conflicts // Check for booking conflicts
await checkBookingConflicts( await checkBookingConflicts(
input.appointmentDate, input.appointmentDate,
input.appointmentTime, input.appointmentTime,
totalDuration treatment.duration
); );
// 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,
treatments: snapshottedTreatments, treatmentId: input.treatmentId,
customerName: input.customerName, customerName: sanitizedName,
customerEmail: input.customerEmail, customerEmail: input.customerEmail,
customerPhone: input.customerPhone, customerPhone: sanitizedPhone,
appointmentDate: input.appointmentDate, appointmentDate: input.appointmentDate,
appointmentTime: input.appointmentTime, appointmentTime: input.appointmentTime,
notes: input.notes, notes: sanitizedNotes,
bookedDurationMinutes: treatment.duration,
status: "confirmed" as const, status: "confirmed" as const,
createdAt: new Date().toISOString() createdAt: new Date().toISOString()
} as Booking; } as Booking;
@@ -685,28 +619,24 @@ const createManual = os
const homepageUrl = generateUrl(); const homepageUrl = generateUrl();
const html = await renderBookingConfirmedHTML({ const html = await renderBookingConfirmedHTML({
name: input.customerName, name: sanitizedName,
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.toFixed(2)} €)`).join('\n');
const totalPrice = input.treatments.reduce((sum, t) => sum + t.price, 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 ${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`, 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`,
html, html,
}, { }, {
date: input.appointmentDate, date: input.appointmentDate,
time: input.appointmentTime, time: input.appointmentTime,
customerName: input.customerName, durationMinutes: treatment.duration,
customerEmail: input.customerEmail, customerName: sanitizedName,
treatments: input.treatments treatmentName: treatment.name
}); });
} catch (e) { } catch (e) {
console.error("Email send failed for manual booking:", e); console.error("Email send failed for manual booking:", e);
@@ -766,21 +696,18 @@ 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 }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context as unknown as Context);
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.");
// Calculate total duration from treatments array const treatment = await treatmentsKV.getItem(booking.treatmentId);
const totalDuration = booking.treatments && booking.treatments.length > 0 if (!treatment) throw new Error("Behandlung nicht gefunden.");
? 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);
@@ -799,8 +726,8 @@ export const router = {
} }
} }
await validateBookingAgainstRules(input.proposedDate, input.proposedTime, totalDuration); await validateBookingAgainstRules(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration);
await checkBookingConflicts(input.proposedDate, input.proposedTime, totalDuration, booking.id); await checkBookingConflicts(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration, 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({
@@ -813,16 +740,13 @@ 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: treatmentName, treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
acceptUrl, acceptUrl,
declineUrl, declineUrl,
expiresAt: res.expiresAt, expiresAt: res.expiresAt,
@@ -848,9 +772,8 @@ 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 duration = booking.treatments && booking.treatments.length > 0 const treatment = await treatmentsKV.getItem(booking.treatmentId);
? booking.treatments.reduce((sum, t) => sum + t.duration, 0) const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
: (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);
@@ -871,37 +794,29 @@ 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.toFixed(2)} €)`).join('\n');
const totalPrice = updated.treatments.reduce((sum, t) => sum + t.price, 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}.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${duration} Min, ${totalPrice.toFixed(2)}`, text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.`,
html, html,
}, { }, {
date: updated.appointmentDate, date: updated.appointmentDate,
time: updated.appointmentTime, time: updated.appointmentTime,
durationMinutes: duration,
customerName: updated.customerName, customerName: updated.customerName,
customerEmail: updated.customerEmail, treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
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: treatmentName, treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
}); });
await sendEmail({ await sendEmail({
to: process.env.ADMIN_EMAIL, to: process.env.ADMIN_EMAIL,
@@ -928,38 +843,23 @@ 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.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, 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.\n\nBehandlungen:\n${treatmentsText}\n\nGesamt: ${totalDuration} Min, ${totalPrice.toFixed(2)}`, text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
html: await renderBookingConfirmedHTML({ html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${bookingAccessToken.token}`), reviewUrl: generateUrl(`/review/${bookingAccessToken.token}`) }),
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: treatmentName, treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
customerEmail: booking.customerEmail, customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
}); });
@@ -976,32 +876,32 @@ export const router = {
// CalDAV Token für Admin generieren // CalDAV Token für Admin generieren
generateCalDAVToken: os generateCalDAVToken: os
.input(z.object({ sessionId: z.string() })) .input(z.object({}))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
// 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 für Token-Erstellung // Hole Session-Daten aus Cookies
const session = await sessionsKV.getItem(input.sessionId); const session = await getSessionFromCookies(context as unknown as Context);
if (!session) throw new Error("Session nicht gefunden"); if (!session) throw new Error("Invalid session");
// Speichere Token mit Ablaufzeit (24 Stunden) // Speichere Token mit Ablaufzeit (24 Stunden)
const tokenData = { const tokenData = {
id: token, id: token,
sessionId: input.sessionId, userId: session.userId,
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(),
}; };
// Verwende den sessionsKV Store für Token-Speicherung // Dedizierten KV-Store für CalDAV-Token verwenden
await sessionsKV.setItem(token, tokenData); const caldavTokensKV = createKV<typeof tokenData>("caldavTokens");
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?token=${token}`; const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics`;
return { return {
token, token,
@@ -1010,87 +910,45 @@ export const router = {
instructions: { instructions: {
title: "CalDAV-Kalender abonnieren", title: "CalDAV-Kalender abonnieren",
steps: [ steps: [
"Kopiere die CalDAV-URL unten", "⚠️ WICHTIG: Der Token darf NICHT in der URL stehen, sondern im Authorization-Header!",
"Füge sie in deiner Kalender-App als Abonnement hinzu:", "",
"- Outlook: Datei → Konto hinzufügen → Internetkalender", "📋 Dein CalDAV-Token (kopieren):",
"- Google Calendar: Andere Kalender hinzufügen → Von URL", token,
"- Apple Calendar: Abonnement → Neue Abonnements", "",
"- Thunderbird: Kalender hinzufügen → Im Netzwerk", "🔗 CalDAV-URL (ohne Token):",
"Der Kalender wird automatisch aktualisiert" caldavUrl,
"",
"📱 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: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du 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)."
} }
}; };
}), }),
// 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,15 +28,7 @@ const cancellationKV = createKV<BookingAccessToken>("cancellation_tokens");
// Types for booking and availability // Types for booking and availability
type Booking = { type Booking = {
id: string; id: string;
treatments: Array<{ treatmentId: string;
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;
@@ -128,42 +120,9 @@ const getBookingByToken = os
throw new Error("Booking not found"); throw new Error("Booking not found");
} }
// Handle treatments array // Get treatment details
let treatments: Array<{id: string; name: string; duration: number; price: number}>; const treatmentsKV = createKV<any>("treatments");
let totalDuration: number; const treatment = await treatmentsKV.getItem(booking.treatmentId);
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, 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;
} 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");
@@ -181,9 +140,10 @@ const getBookingByToken = os
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
appointmentDate: booking.appointmentDate, appointmentDate: booking.appointmentDate,
appointmentTime: booking.appointmentTime, appointmentTime: booking.appointmentTime,
treatments, treatmentId: booking.treatmentId,
totalDuration, treatmentName: treatment?.name || "Unbekannte Behandlung",
totalPrice, treatmentDuration: treatment?.duration || 60,
treatmentPrice: treatment?.price || 0,
status: booking.status, status: booking.status,
notes: booking.notes, notes: booking.notes,
formattedDate: formatDateGerman(booking.appointmentDate), formattedDate: formatDateGerman(booking.appointmentDate),
@@ -324,42 +284,8 @@ export const router = {
throw new Error("Booking not found"); throw new Error("Booking not found");
} }
// 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, 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;
} 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;
@@ -372,9 +298,8 @@ export const router = {
customerEmail: booking.customerEmail, customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
status: booking.status, status: booking.status,
treatments, treatmentId: booking.treatmentId,
totalDuration, treatmentName: treatment?.name || "Unbekannte Behandlung",
totalPrice,
}, },
original: { original: {
date: proposal.originalDate || booking.appointmentDate, date: proposal.originalDate || booking.appointmentDate,
@@ -433,22 +358,14 @@ 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");
// Get treatment name(s) from new treatments array or fallback to deprecated treatmentId const treatment = await treatmentsKV.getItem(booking.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: treatmentName, treatmentName: treatment?.name || "Unbekannte Behandlung",
customerEmail: booking.customerEmail, customerEmail: booking.customerEmail,
customerPhone: booking.customerPhone, customerPhone: booking.customerPhone,
expiredAt: proposal.expiresAt, expiredAt: proposal.expiresAt,

View File

@@ -2,7 +2,8 @@ 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 } from "../lib/auth.js"; import { assertOwner, getSessionFromCookies } 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({
@@ -25,16 +26,17 @@ 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 }) => { .handler(async ({ input, context }) => {
try { try {
await assertOwner(input.sessionId); await assertOwner(context);
// 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;
@@ -58,9 +60,11 @@ const uploadPhoto = os
}); });
const setCoverPhoto = os const setCoverPhoto = os
.input(z.object({ sessionId: z.string(), id: z.string() })) .input(z.object({ id: z.string() }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
// 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) {
@@ -73,21 +77,24 @@ const setCoverPhoto = os
}); });
const deletePhoto = os const deletePhoto = os
.input(z.object({ sessionId: z.string(), id: z.string() })) .input(z.object({ id: z.string() }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
// 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 }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
// 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);
@@ -106,9 +113,9 @@ const listPhotos = os.handler(async () => {
}); });
const adminListPhotos = os const adminListPhotos = os
.input(z.object({ sessionId: z.string() })) .input(z.object({}))
.handler(async ({ input }) => { .handler(async ({ context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
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));
}); });
@@ -123,9 +130,9 @@ const live = {
}), }),
adminListPhotos: os adminListPhotos: os
.input(z.object({ sessionId: z.string() })) .input(z.object({}))
.handler(async function* ({ input, signal }) { .handler(async function* ({ context, signal }) {
await assertOwner(input.sessionId); await assertOwner(context);
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,4 +1,6 @@
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";
@@ -7,7 +9,6 @@ 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,
@@ -19,5 +20,10 @@ 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,7 +2,8 @@ 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 } from "../lib/auth.js"; import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
// Datenmodelle // Datenmodelle
const RecurringRuleSchema = z.object({ const RecurringRuleSchema = z.object({
@@ -87,15 +88,23 @@ 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 }) => { .handler(async ({ input, context }) => {
try { try {
await assertOwner(input.sessionId); await assertOwner(context);
// 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);
@@ -132,9 +141,18 @@ const createRule = os
}); });
const updateRule = os const updateRule = os
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough()) .input(RecurringRuleSchema.passthrough())
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
// 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);
@@ -152,22 +170,40 @@ 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 { sessionId, ...rule } = input as any; const 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({ sessionId: z.string(), id: z.string() })) .input(z.object({ id: z.string() }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
// 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({ sessionId: z.string(), id: z.string() })) .input(z.object({ id: z.string() }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
// 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.");
@@ -185,9 +221,9 @@ const listRules = os.handler(async () => {
}); });
const adminListRules = os const adminListRules = os
.input(z.object({ sessionId: z.string() })) .input(z.object({}))
.handler(async ({ input }) => { .handler(async ({ context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
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;
@@ -199,15 +235,16 @@ 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 }) => { .handler(async ({ input, context }) => {
try { try {
await assertOwner(input.sessionId); await assertOwner(context);
// 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) {
@@ -232,24 +269,28 @@ const createTimeOff = os
}); });
const updateTimeOff = os const updateTimeOff = os
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough()) .input(TimeOffPeriodSchema.passthrough())
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
// 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 { sessionId, ...timeOff } = input as any; const 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({ sessionId: z.string(), id: z.string() })) .input(z.object({ id: z.string() }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
// Admin Rate Limiting direkt nach Owner-Check
await enforceAdminRateLimit(context as any);
await timeOffPeriodsKV.removeItem(input.id); await timeOffPeriodsKV.removeItem(input.id);
}); });
@@ -259,9 +300,9 @@ const listTimeOff = os.handler(async () => {
}); });
const adminListTimeOff = os const adminListTimeOff = os
.input(z.object({ sessionId: z.string() })) .input(z.object({}))
.handler(async ({ input }) => { .handler(async ({ context }) => {
await assertOwner(input.sessionId); await assertOwner(context);
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));
}); });
@@ -272,12 +313,7 @@ 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}$/),
treatmentIds: z.array(z.string()) treatmentId: 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 }) => {
@@ -292,22 +328,13 @@ const getAvailableTimes = os
return []; return [];
} }
// Get multiple treatments and calculate total duration // Get treatment duration
const treatments = await Promise.all( const treatment = await treatmentsKV.getItem(input.treatmentId);
input.treatmentIds.map(id => treatmentsKV.getItem(id)) if (!treatment) {
); 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(', ')}`);
} }
// Calculate total duration by summing all treatment durations const treatmentDuration = treatment.duration;
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);
@@ -358,38 +385,36 @@ const getAvailableTimes = os
['pending', 'confirmed', 'completed'].includes(booking.status) ['pending', 'confirmed', 'completed'].includes(booking.status)
); );
// Build cache only for legacy treatmentId bookings // Optimize treatment duration lookup with Map caching
const legacyTreatmentIds = [...new Set(dateBookings.filter(b => b.treatmentId).map(b => b.treatmentId as string))]; const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
const treatmentDurationMap = new Map<string, number>(); const treatmentDurationMap = new Map<string, number>();
// Only build cache if there are legacy bookings for (const treatmentId of uniqueTreatmentIds) {
if (legacyTreatmentIds.length > 0) { const treatment = await treatmentsKV.getItem(treatmentId);
for (const id of legacyTreatmentIds) { treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
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; // total from selected treatments const slotEndMinutes = slotStartMinutes + treatmentDuration;
// Check if this slot overlaps with any existing booking
const hasConflict = dateBookings.some(booking => { const hasConflict = dateBookings.some(booking => {
let bookingDuration: number; const bookingStartMinutes = parseTime(booking.appointmentTime);
if (booking.treatments && booking.treatments.length > 0) { const bookingDuration = bookingTreatments.get(booking.id) || 60;
bookingDuration = booking.treatments.reduce((sum: number, t: { duration: number }) => sum + t.duration, 0); const bookingEndMinutes = bookingStartMinutes + bookingDuration;
} else if (booking.bookedDurationMinutes) {
bookingDuration = booking.bookedDurationMinutes;
} else if (booking.treatmentId) {
bookingDuration = treatmentDurationMap.get(booking.treatmentId) || 60;
} else {
bookingDuration = 60;
}
const bookingStart = parseTime(booking.appointmentTime); // Check overlap: slotStart < bookingEnd && slotEnd > bookingStart
const bookingEnd = bookingStart + bookingDuration; return slotStartMinutes < bookingEndMinutes && slotEndMinutes > bookingStartMinutes;
return slotStartMinutes < bookingEnd && slotEndMinutes > bookingStart;
}); });
return !hasConflict; return !hasConflict;
@@ -434,9 +459,9 @@ const live = {
}), }),
adminListRules: os adminListRules: os
.input(z.object({ sessionId: z.string() })) .input(z.object({}))
.handler(async function* ({ input, signal }) { .handler(async function* ({ context, signal }) {
await assertOwner(input.sessionId); await assertOwner(context);
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;
@@ -454,9 +479,9 @@ const live = {
}), }),
adminListTimeOff: os adminListTimeOff: os
.input(z.object({ sessionId: z.string() })) .input(z.object({}))
.handler(async function* ({ input, signal }) { .handler(async function* ({ context, signal }) {
await assertOwner(input.sessionId); await assertOwner(context);
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,7 +2,8 @@ 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, sessionsKV } from "../lib/auth.js"; import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
import { checkAdminRateLimit, getClientIP } from "../lib/rate-limiter.js";
// Schema Definition // Schema Definition
const ReviewSchema = z.object({ const ReviewSchema = z.object({
@@ -133,22 +134,31 @@ const submitReview = os
// Admin Endpoint: approveReview // Admin Endpoint: approveReview
const approveReview = os const approveReview = os
.input(z.object({ sessionId: z.string(), id: z.string() })) .input(z.object({ id: z.string() }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
try { try {
await assertOwner(input.sessionId); await assertOwner(context);
// 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 session = await sessionsKV.getItem(input.sessionId).catch(() => undefined); const session2 = await getSessionFromCookies(context);
const updatedReview = { const updatedReview = {
...review, ...review,
status: "approved" as const, status: "approved" as const,
reviewedAt: new Date().toISOString(), reviewedAt: new Date().toISOString(),
reviewedBy: session?.userId || review.reviewedBy, reviewedBy: session2?.userId || review.reviewedBy,
}; };
await reviewsKV.setItem(input.id, updatedReview); await reviewsKV.setItem(input.id, updatedReview);
@@ -161,22 +171,31 @@ const approveReview = os
// Admin Endpoint: rejectReview // Admin Endpoint: rejectReview
const rejectReview = os const rejectReview = os
.input(z.object({ sessionId: z.string(), id: z.string() })) .input(z.object({ id: z.string() }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
try { try {
await assertOwner(input.sessionId); await assertOwner(context);
// 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 session = await sessionsKV.getItem(input.sessionId).catch(() => undefined); const session2 = await getSessionFromCookies(context);
const updatedReview = { const updatedReview = {
...review, ...review,
status: "rejected" as const, status: "rejected" as const,
reviewedAt: new Date().toISOString(), reviewedAt: new Date().toISOString(),
reviewedBy: session?.userId || review.reviewedBy, reviewedBy: session2?.userId || review.reviewedBy,
}; };
await reviewsKV.setItem(input.id, updatedReview); await reviewsKV.setItem(input.id, updatedReview);
@@ -189,10 +208,19 @@ const rejectReview = os
// Admin Endpoint: deleteReview // Admin Endpoint: deleteReview
const deleteReview = os const deleteReview = os
.input(z.object({ sessionId: z.string(), id: z.string() })) .input(z.object({ id: z.string() }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
try { try {
await assertOwner(input.sessionId); await assertOwner(context);
// 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);
@@ -225,13 +253,12 @@ 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 }) => { .handler(async ({ input, context }) => {
try { try {
await assertOwner(input.sessionId); await assertOwner(context);
const allReviews = await reviewsKV.getAllItems(); const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all" const filtered = input.statusFilter === "all"
@@ -258,12 +285,11 @@ 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, signal }) { .handler(async function* ({ input, context, signal }) {
await assertOwner(input.sessionId); await assertOwner(context);
const allReviews = await reviewsKV.getAllItems(); const allReviews = await reviewsKV.getAllItems();
const filtered = input.statusFilter === "all" const filtered = input.statusFilter === "all"

View File

@@ -1,13 +0,0 @@
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,6 +2,8 @@ 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(),
@@ -18,7 +20,10 @@ const kv = createKV<Treatment>("treatments");
const create = os const create = os
.input(TreatmentSchema.omit({ id: true })) .input(TreatmentSchema.omit({ id: true }))
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
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);
@@ -27,12 +32,18 @@ const create = os
const update = os const update = os
.input(TreatmentSchema) .input(TreatmentSchema)
.handler(async ({ input }) => { .handler(async ({ input, context }) => {
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 }) => { const remove = os.input(z.string()).handler(async ({ input, context }) => {
await assertOwner(context);
// Admin Rate Limiting
await enforceAdminRateLimit(context as any);
await kv.removeItem(input); await kv.removeItem(input);
}); });

View File

@@ -7,6 +7,7 @@
"declaration": false, "declaration": false,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"skipLibCheck": true,
"allowImportingTsExtensions": false, "allowImportingTsExtensions": false,
"target": "ES2022", "target": "ES2022",
"baseUrl": ".", "baseUrl": ".",

View File

@@ -1,10 +1,11 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "NodeNext", "module": "ESNext",
"moduleResolution": "NodeNext", "moduleResolution": "bundler",
"noEmit": true, "noEmit": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"jsx": "react-jsx",
"types": ["node"], "types": ["node"],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]