Files
kita-planer/src/app/dashboard/profil/actions.ts
T
t.indorf fa06da324c Fix mail layouts, /invite form-reset, profile validation
Mail templates (6 templates touched):
- New shared `_styles.ts` exports a robust font stack starting with
  Arial/Helvetica so mail readers that lack -apple-system and
  BlinkMacSystemFont don't fall back to Monospace (observed in at least
  one webmail client).
- `fontFamily` is now set on every text-bearing element, not only on
  `<Body>`, because some clients (Outlook, several webmail readers) do
  not inherit body CSS into nested sections.
- ASCII substitutions are gone: "Ueber" → "Über", "fuer" → "für",
  "bestaetigen" → "bestätigen", "uebernehmen" → "übernehmen",
  "verlaesslich" → "verlässlich".
- InviteEmail + NewsEmail buttons switch from the off-brand yellow
  (#f0b84b) to the brand dark green so the CTA matches the header.

/invite form:
- InviteState gains an `attempt` counter plus echoed checkbox `values`,
  mirroring the /register and /onboarding fix from earlier branches. A
  failed submit no longer wipes the Datenschutz / Adressbuch-Opt-In
  ticks. Passwords stay empty for security.
- Field error <p> elements gain role="alert" + aria-live="polite" so
  screen readers announce validation failures on submit. Inputs use
  aria-describedby to link to error / helper text.

Profile validation:
- Telephone numbers must match a permissive pattern that still rejects
  obvious garbage (previously "abc-123-not-a-phone" was accepted).
- Postal codes must match an alphanumeric pattern of 3–10 chars
  (previously "ABCDE" was accepted).
- Child date-of-birth is now validated via superRefine: it must be a
  parseable date, not in the future, and not before 1900. Previously a
  kid born in 2030 was accepted.
- The three Profil server actions now surface the first Zod issue
  message instead of generic "Ungültige Eingabedaten." toasts.

Misc:
- "fuer" → "für" in the Adressbuch page subtitle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:49:01 +02:00

334 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
// Telefonnummern: führendes "+" optional, dann Ziffern, Leerzeichen, Bindestriche,
// Klammern oder Schrägstriche. Mindestens 6 Ziffern damit es plausibel ist.
// Schließt damit "abc-123-not-a-phone" aus, lässt aber internationale Formate zu.
const phonePattern = /^[+]?[0-9][\d\s\-()/.]{4,}$/;
// PLZ: Ziffern/Buchstaben/Leerzeichen/Bindestriche, 310 Zeichen.
// Kein striktes DE-Format (5 Ziffern), damit auch AT/CH/UK denkbar sind.
const postalCodePattern = /^[0-9A-Za-z][0-9A-Za-z\s-]{2,9}$/;
const childSchema = z
.object({
firstName: z.string().min(1, "Vorname ist erforderlich.").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich.").max(100).trim(),
dateOfBirth: z.string().optional(),
})
.superRefine((data, ctx) => {
if (!data.dateOfBirth) return;
const parsed = new Date(`${data.dateOfBirth}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Bitte ein gültiges Datum angeben.",
});
return;
}
// In der Zukunft geborene Kinder existieren nicht.
const today = new Date();
today.setHours(23, 59, 59, 999);
if (parsed > today) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Geburtsdatum darf nicht in der Zukunft liegen.",
});
}
// Schutz vor offensichtlichen Tippfehlern (1899 statt 1989 o.ä.).
if (parsed.getFullYear() < 1900) {
ctx.addIssue({
path: ["dateOfBirth"],
code: "custom",
message: "Bitte ein realistisches Geburtsdatum angeben.",
});
}
});
const familySchema = z.object({
familyName: z.string().min(1, "Familienname ist erforderlich.").max(120).trim(),
});
const contactSchema = z.object({
phone: z
.string()
.trim()
.max(50)
.optional()
.refine(
(v) => !v || phonePattern.test(v),
"Bitte eine gültige Telefonnummer angeben.",
),
street: z.string().trim().max(120).optional(),
postalCode: z
.string()
.trim()
.max(20)
.optional()
.refine(
(v) => !v || postalCodePattern.test(v),
"Bitte eine gültige Postleitzahl angeben.",
),
city: z.string().trim().max(100).optional(),
});
function parseDateInput(value?: string) {
if (!value) return null;
const date = new Date(`${value}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date;
}
// Gibt die erste konkrete Field-Error-Message aus einem Zod-Fehler zurück,
// damit der User nicht nur "Ungültige Eingabedaten" sieht.
function firstZodMessage(
error: z.ZodError,
fallback: string,
): string {
const issue = error.issues[0];
return issue?.message || fallback;
}
async function requireOwnFamilyChild(
childId: string,
familyId: string | null,
kitaId: string,
) {
if (!familyId) return null;
return prisma.child.findFirst({
where: { id: childId, familyId, kitaId },
});
}
export async function createMyChild(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = childSchema.safeParse(rawPayload);
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
if (!parsed.success) {
return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
}
try {
await prisma.child.create({
data: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen des Kindes:", error);
return { error: "Das Kind konnte nicht angelegt werden." };
}
}
export async function createMyFamily(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = familySchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Bitte gib einen gültigen Familiennamen ein." };
}
if (session.user.familyId) {
return { error: "Dein Account ist bereits einem Haushalt zugeordnet." };
}
try {
await prisma.$transaction(async (tx) => {
const family = await tx.family.create({
data: {
kitaId: session.user.kitaId,
name: parsed.data.familyName,
},
});
await tx.user.update({
where: { id: session.user.id },
data: { familyId: family.id },
});
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
revalidatePath("/dashboard/adressbuch");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen des eigenen Haushalts:", error);
return { error: "Der Haushalt konnte nicht angelegt werden." };
}
}
export async function updateMyContact(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = contactSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: firstZodMessage(parsed.error, "Ungültige Kontaktdaten.") };
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: {
phone: parsed.data.phone || null,
street: parsed.data.street || null,
postalCode: parsed.data.postalCode || null,
city: parsed.data.city || null,
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard");
revalidatePath("/dashboard/adressbuch");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren der Kontaktdaten:", error);
return { error: "Kontaktdaten konnten nicht gespeichert werden." };
}
}
export async function updateMyChild(childId: string, rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = childSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: firstZodMessage(parsed.error, "Ungültige Eingabedaten.") };
}
try {
const child = await requireOwnFamilyChild(
childId,
session.user.familyId,
session.user.kitaId,
);
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.child.update({
where: { id: childId },
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
dateOfBirth: parseDateInput(parsed.data.dateOfBirth),
},
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren des Kindes:", error);
return { error: "Das Kind konnte nicht aktualisiert werden." };
}
}
export async function deleteMyChild(childId: string) {
const session = await requireKitaSession();
try {
const child = await requireOwnFamilyChild(
childId,
session.user.familyId,
session.user.kitaId,
);
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.child.delete({
where: { id: childId },
});
revalidatePath("/dashboard/profil");
revalidatePath("/dashboard/families");
return { success: true };
} catch (error) {
console.error("Fehler beim Entfernen des Kindes:", error);
return { error: "Das Kind konnte nicht entfernt werden." };
}
}
export async function deleteMyAccount() {
const session = await requireKitaSession();
try {
const user = await prisma.user.findFirst({
where: {
id: session.user.id,
kitaId: session.user.kitaId,
},
select: {
id: true,
familyId: true,
},
});
if (!user) {
return { error: "Account wurde nicht gefunden." };
}
if (!user.familyId) {
await prisma.user.delete({
where: { id: user.id },
});
return { success: true };
}
const family = await prisma.family.findFirst({
where: {
id: user.familyId,
kitaId: session.user.kitaId,
},
select: {
id: true,
users: {
select: { id: true },
},
},
});
if (!family) {
return { error: "Haushalt wurde nicht gefunden." };
}
if (family.users.length <= 1) {
await prisma.family.delete({
where: { id: family.id },
});
return { success: true };
}
await prisma.user.delete({
where: { id: user.id },
});
return { success: true };
} catch (error) {
console.error("Fehler beim Löschen des Accounts:", error);
return { error: "Ein Fehler ist beim Löschen aufgetreten." };
}
}