159 lines
5.2 KiB
TypeScript
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(); |