Files
kalendartool/lib/validation.ts

159 lines
5.2 KiB
TypeScript

/**
* 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();