/** * Eingabevalidierung mit Zod (plan.md: "Jede Eingabe gilt als unsicher"). * Alle API-Routen validieren Request-Bodies ausschliesslich ueber diese Schemas. */ import { z } from 'zod'; /** Sichere E-Mail (Zod prueft Format, wir begrenzen die Laenge). */ export const emailSchema = z .string() .trim() .toLowerCase() .min(3, 'E-Mail ist erforderlich.') .max(254, 'E-Mail ist zu lang.') .email('Ungueltige E-Mail-Adresse.'); /** * Passwortregeln: mindestens 10 Zeichen, Gross-/Kleinbuchstabe und Ziffer. * Maximale Laenge begrenzen (DoS-Schutz bei bcrypt). */ export const passwordSchema = z .string() .min(10, 'Das Passwort muss mindestens 10 Zeichen lang sein.') .max(72, 'Das Passwort darf hoechstens 72 Zeichen lang sein.') .regex(/[a-z]/, 'Das Passwort muss einen Kleinbuchstaben enthalten.') .regex(/[A-Z]/, 'Das Passwort muss einen Grossbuchstaben enthalten.') .regex(/[0-9]/, 'Das Passwort muss eine Ziffer enthalten.'); /** * Username: 2-32 Zeichen, Buchstaben, Ziffern, Unterstrich, Bindestrich, * Punkt. Keine Leerzeichen oder Sonderzeichen (verhindert Spoofing und * Injection in der Anzeige). Case-insensitive Eindeutigkeit wird in der * DB sichergestellt; hier wird nur das Format geprueft. */ export const usernameSchema = z .string() .trim() .min(2, 'Der Benutzername muss mindestens 2 Zeichen lang sein.') .max(32, 'Der Benutzername darf hoechstens 32 Zeichen lang sein.') .regex( /^[a-zA-Z0-9_.-]+$/, 'Der Benutzername darf nur Buchstaben, Ziffern, Unterstrich, Bindestrich und Punkt enthalten.', ); export const registerSchema = z .object({ email: emailSchema, password: passwordSchema, username: usernameSchema.optional(), inviteToken: z.string().trim().min(10).max(200).optional(), }) .strict(); export const loginSchema = z .object({ email: emailSchema, password: z.string().min(1, 'Passwort ist erforderlich.').max(72), }) .strict(); export const createCalendarSchema = z .object({ title: z.string().trim().min(1, 'Titel ist erforderlich.').max(120), description: z.string().trim().max(2000).optional().nullable(), timezone: z.string().trim().min(1).max(64), }) .strict(); export const updateCalendarSchema = createCalendarSchema .partial() .extend({ isActive: z.boolean().optional(), /** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */ maxReservationMinutes: z .number() .int('Die maximale Dauer muss ein Vielfaches von 15 Minuten sein.') .min(0, 'Die maximale Dauer kann nicht negativ sein.') .max(72 * 60, 'Die maximale Dauer betraegt hoechstens 72 Stunden.') .multipleOf(15, 'Die maximale Dauer muss ein Vielfaches von 15 Minuten sein.') .optional(), /** Duerven Nicht-Admins die Titel fremder Reservierungen sehen? */ showTitleToMembers: z.boolean().optional(), /** Duerven Nicht-Admins die Notizen fremder Reservierungen sehen? */ showNotesToMembers: z.boolean().optional(), }) .strict(); /** Dauer in Minuten: 15 Minuten bis 24 Stunden, in 15-Minuten-Schritten. */ export const durationSchema = z .number() .int('Die Dauer muss ein Vielfaches von 15 Minuten sein.') .min(15, 'Die Mindestdauer betraegt 15 Minuten.') .max(24 * 60, 'Die Maximaldauer betraegt 24 Stunden.') .multipleOf(15, 'Die Dauer muss ein Vielfaches von 15 Minuten sein.'); /** * Reservierung anlegen: Start- UND Endzeit werden angegeben. Die * Dauer ergibt sich aus der Differenz; die maximale Dauer wird * serverseitig gegen die Kalender-Einstellung geprueft. */ export const createReservationSchema = z .object({ calendarId: z.string().trim().min(1), startAt: z.coerce.date(), endAt: z.coerce.date(), title: z.string().trim().min(1, 'Titel ist erforderlich.').max(120), notes: z.string().trim().max(2000).optional().nullable(), }) .strict(); export const updateReservationSchema = z .object({ startAt: z.coerce.date().optional(), endAt: z.coerce.date().optional(), title: z.string().trim().min(1).max(120).optional(), notes: z.string().trim().max(2000).optional().nullable(), status: z.enum(['CONFIRMED', 'CANCELLED']).optional(), }) .strict(); export const createInviteSchema = z .object({ calendarId: z.string().trim().min(1), expiresInDays: z.number().int().min(1).max(90).optional(), }) .strict(); /** * Mitglied per E-Mail zu einem Kalender hinzufuegen (DB-basiertes * Einladungssystem, ersetzt den Token-Link-Flow in der Verwaltung). */ export const addMemberSchema = z .object({ email: emailSchema, }) .strict(); /** Rollenaenderung (Userverwaltung): nur die beiden bekannten Rollen. */ export const updateRoleSchema = z .object({ role: z.enum(['ADMIN', 'USER'], { errorMap: () => ({ message: 'Rolle muss ADMIN oder USER sein.' }), }), }) .strict(); /** Berechnet die Endzeit aus Startzeit und Dauer in Minuten. */ export function computeEndAt(startAt: Date, durationMinutes: number): Date { return new Date(startAt.getTime() + durationMinutes * 60_000); } /** * Username im Profil aendern. Der Wert ist optional (null = loeschen), * aber wenn er gesetzt ist, muss er dem Format entsprechen. */ export const updateUsernameSchema = z .object({ username: usernameSchema.nullable(), }) .strict();