Merge current features with security hardening and automatic safe initialization

This commit is contained in:
t.indorf
2026-10-09 23:29:19 +02:00
85 changed files with 13522 additions and 1326 deletions
+195 -153
View File
@@ -1,20 +1,25 @@
"use server";
import { UserFacingError, publicActionError } from "@/lib/action-error";
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
import { rateLimit } from "@/lib/rate-limit";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { NotdienstPlanStatus, UserRole } from "@prisma/client";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { dateOnlySchema, validPlanMonth } from "@/lib/date-schema";
import { rateLimit } from "@/lib/rate-limit";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
import { requireAlarmActor } from "@/lib/alarms/auth";
import {
validateAlarmActor,
assertAssignmentNotAlarmed,
} from "@/lib/alarms/service";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
@@ -158,42 +163,76 @@ export async function updateAssignmentAction(
date: string,
newChildId: string | null,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const actor = await requireAlarmActor();
const kitaId = actor.kitaId;
if (!dateOnlySchema.safeParse(date).success) return { error: "Ungültiges Datum." };
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
try {
const notifications = await prisma.$transaction(async (tx) => {
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
const notifications = await prisma.$transaction(
async (tx) => {
await validateAlarmActor(tx, actor);
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
if (!plan) {
throw new UserFacingError("Plan nicht gefunden.");
}
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
}
if (isPublished) {
const today = new Date();
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
if (parsedDate < todayUtc) {
throw new UserFacingError("Vergangene Notdienste können nicht geändert werden.");
if (!plan) {
throw new UserFacingError("Plan nicht gefunden.");
}
}
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
include: {
child: {
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
}
if (isPublished) {
const today = new Date();
const todayUtc = new Date(
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
);
if (parsedDate < todayUtc) {
throw new UserFacingError(
"Vergangene Notdienste können nicht geändert werden.",
);
}
}
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
include: {
child: {
include: {
family: {
include: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
},
},
},
},
});
let newChild: {
id: string;
family: {
users: { email: string; firstName: string; lastName: string }[];
};
} | null = null;
if (newChildId) {
newChild = await tx.child.findFirst({
where: {
id: newChildId,
kitaId,
active: true,
},
include: {
family: {
include: {
@@ -204,133 +243,118 @@ export async function updateAssignmentAction(
},
},
},
},
},
});
});
let newChild:
| {
id: string;
family: {
users: { email: string; firstName: string; lastName: string }[];
};
if (!newChild) {
throw new UserFacingError("Kind gehört nicht zu dieser Kita.");
}
| null = null;
if (newChildId) {
newChild = await tx.child.findFirst({
where: {
id: newChildId,
kitaId,
active: true,
},
include: {
family: {
include: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
select: { email: true, firstName: true, lastName: true },
},
},
},
},
});
if (!newChild) {
throw new UserFacingError("Kind gehört nicht zu dieser Kita.");
}
}
if (existingAssignment?.childId === newChildId) {
return [];
}
if (existingAssignment?.childId === newChildId) {
return [];
}
if (!newChildId) {
if (existingAssignment) {
await tx.notdienstAssignment.delete({
if (existingAssignment)
await assertAssignmentNotAlarmed(tx, existingAssignment.id, kitaId);
if (!newChildId) {
if (existingAssignment) {
await tx.notdienstAssignment.delete({
where: { id: existingAssignment.id },
});
}
} else if (existingAssignment) {
await tx.notdienstAssignment.update({
where: { id: existingAssignment.id },
data: { childId: newChildId },
});
} else {
await tx.notdienstAssignment.create({
data: {
kitaId,
planId,
childId: newChildId,
date: parsedDate,
},
});
}
} else if (existingAssignment) {
await tx.notdienstAssignment.update({
where: { id: existingAssignment.id },
data: { childId: newChildId },
});
} else {
await tx.notdienstAssignment.create({
data: {
kitaId,
planId,
childId: newChildId,
date: parsedDate,
},
});
}
if (existingAssignment) {
await tx.notdienstSwapRequest.updateMany({
where: {
kitaId,
status: "PENDING",
OR: [
{ targetAssignmentId: existingAssignment.id },
{ offeredAssignmentId: existingAssignment.id },
],
},
data: {
status: "CANCELLED",
respondedAt: new Date(),
},
});
}
if (!isPublished) {
return [];
}
const dateLabel = format(new Date(yr, mo - 1, dy), "EEEE, dd. MMMM yyyy", { locale: de });
const messages: {
parentEmails: string[];
parentNames: string[];
subject: string;
heading: string;
preview: string;
body: string;
dateLabel: string;
}[] = [];
if (existingAssignment) {
const oldParents = existingAssignment.child.family.users.filter((u) => !!u.email);
if (oldParents.length > 0) {
messages.push({
parentEmails: oldParents.map((p) => p.email),
parentNames: oldParents.map((p) => `${p.firstName} ${p.lastName}`),
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
heading: "Notdienst wurde geändert",
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
dateLabel,
if (existingAssignment) {
await tx.notdienstSwapRequest.updateMany({
where: {
kitaId,
status: "PENDING",
OR: [
{ targetAssignmentId: existingAssignment.id },
{ offeredAssignmentId: existingAssignment.id },
],
},
data: {
status: "CANCELLED",
respondedAt: new Date(),
},
});
}
}
if (newChild && existingAssignment?.childId !== newChild.id) {
const newParents = newChild.family.users.filter((u) => !!u.email);
if (newParents.length > 0) {
messages.push({
parentEmails: newParents.map((p) => p.email),
parentNames: newParents.map((p) => `${p.firstName} ${p.lastName}`),
subject: `Notdienst-Einteilung am ${dateLabel}`,
heading: "Neuer Notdienst-Termin zugewiesen",
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
dateLabel,
});
if (!isPublished) {
return [];
}
}
return messages;
});
const dateLabel = format(
new Date(yr, mo - 1, dy),
"EEEE, dd. MMMM yyyy",
{ locale: de },
);
const messages: {
parentEmails: string[];
parentNames: string[];
subject: string;
heading: string;
preview: string;
body: string;
dateLabel: string;
}[] = [];
if (existingAssignment) {
const oldParents = existingAssignment.child.family.users.filter(
(u) => !!u.email,
);
if (oldParents.length > 0) {
messages.push({
parentEmails: oldParents.map((p) => p.email),
parentNames: oldParents.map(
(p) => `${p.firstName} ${p.lastName}`,
),
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
heading: "Notdienst wurde geändert",
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
dateLabel,
});
}
}
if (newChild && existingAssignment?.childId !== newChild.id) {
const newParents = newChild.family.users.filter((u) => !!u.email);
if (newParents.length > 0) {
messages.push({
parentEmails: newParents.map((p) => p.email),
parentNames: newParents.map(
(p) => `${p.firstName} ${p.lastName}`,
),
subject: `Notdienst-Einteilung am ${dateLabel}`,
heading: "Neuer Notdienst-Termin zugewiesen",
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
dateLabel,
});
}
}
return messages;
},
{ isolationLevel: "Serializable" },
);
// E-Mails außerhalb der DB-Transaktion senden
if (notifications.length > 0) {
@@ -340,7 +364,10 @@ export async function updateAssignmentAction(
});
if (kita) {
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const BASE_URL =
process.env.NEXTAUTH_URL ??
process.env.AUTH_URL ??
"http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
for (const notification of notifications) {
@@ -385,8 +412,8 @@ export async function updateKitaNotdienstSettingsAction(
return { error: "Kein Mandant zugeordnet." };
}
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 ||
!Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) return { error: "Ungültige Einstellungen." };
try {
await prisma.kita.update({
where: { id: kitaId },
@@ -404,15 +431,18 @@ export async function updateKitaNotdienstSettingsAction(
}
}
export async function sendNotdienstRemindersAction(year: number, month: number) {
export async function sendNotdienstRemindersAction(
year: number,
month: number,
) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${session.user.kitaId}`, 3, 3600)).allowed) return { error: "Bitte versuche es später erneut." };
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
@@ -462,7 +492,9 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
},
});
const respondedFamilyIds = new Set(respondedFamilies.map((a) => a.child.familyId));
const respondedFamilyIds = new Set(
respondedFamilies.map((a) => a.child.familyId),
);
// 4. Find open families (not responded yet)
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
@@ -474,7 +506,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
// 5. Build emails list and send
const monthDate = new Date(year, month - 1, 1);
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const BASE_URL =
process.env.NEXTAUTH_URL ??
process.env.AUTH_URL ??
"http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
let sentCount = 0;
@@ -496,7 +531,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
if (result.success) {
sentCount++;
} else {
console.error(`E-Mail konnte nicht an ${parent.email} gesendet werden:`, result.error);
console.error(
`E-Mail konnte nicht an ${parent.email} gesendet werden:`,
result.error,
);
}
}
}
@@ -527,7 +565,9 @@ export async function resetPlanAction(planId: string) {
}
if (plan.status !== NotdienstPlanStatus.DRAFT) {
throw new UserFacingError("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
throw new UserFacingError(
"Nur Pläne im Entwurfsstatus können zurückgesetzt werden.",
);
}
// Delete all assignments
@@ -545,6 +585,8 @@ export async function resetPlanAction(planId: string) {
return { success: true };
} catch (error) {
console.error("Fehler beim Zurücksetzen des Plans:", error);
return { error: publicActionError(error, "Fehler beim Zurücksetzen des Plans.") };
return {
error: publicActionError(error, "Fehler beim Zurücksetzen des Plans."),
};
}
}
+87 -72
View File
@@ -1,14 +1,15 @@
import Link from "next/link";
import { addMonths, getYear, getMonth, format } from "date-fns";
import { de } from "date-fns/locale";
import { TerminType, UserRole } from "@prisma/client";
import { TerminType } from "@prisma/client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { PlanView } from "./plan-view";
import { SettingsModal } from "./_components/settings-modal";
import { requireAlarmActor } from "@/lib/alarms/auth";
import { todayKey } from "@/lib/alarms/format";
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
@@ -17,14 +18,18 @@ export default async function PlanungsZentralePage({
}: {
searchParams: Promise<{ year?: string; month?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
const actor = await requireAlarmActor();
const kitaId = actor.kitaId;
const resolvedSearchParams = await searchParams;
const now = new Date();
const nextMonth = addMonths(now, 1);
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
const targetYear = resolvedSearchParams.year
? parseInt(resolvedSearchParams.year, 10)
: getYear(nextMonth);
const targetMonth = resolvedSearchParams.month
? parseInt(resolvedSearchParams.month, 10)
: getMonth(nextMonth) + 1;
const monthDate = new Date(targetYear, targetMonth - 1, 1);
const monthName = format(monthDate, "MMMM", { locale: de });
@@ -33,79 +38,84 @@ export default async function PlanungsZentralePage({
const monthStart = new Date(targetYear, targetMonth - 1, 1);
const monthEnd = new Date(targetYear, targetMonth, 1);
const [plan, families, availabilities, closureCount, kita] = await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
select: {
id: true,
status: true,
publishedAt: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
createdAt: true,
const [plan, families, availabilities, closureCount, kita] =
await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
select: {
id: true,
status: true,
publishedAt: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
createdAt: true,
alarms: {
where: { activeAssignmentId: { not: null } },
select: { id: true },
},
},
},
},
},
}),
prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
select: {
id: true,
name: true,
children: {
where: { active: true },
select: { id: true },
}),
prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
},
orderBy: { name: "asc" },
}),
prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
date: true,
child: {
select: {
familyId: true,
select: {
id: true,
name: true,
children: {
where: { active: true },
select: { id: true },
},
},
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
orderBy: { name: "asc" },
}),
prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
},
}),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
}),
]);
select: {
date: true,
child: {
select: {
familyId: true,
},
},
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
},
},
}),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
notdienstMinPerChildPerMonth: true,
notdienstReminderDaysBefore: true,
},
}),
]);
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
// aber für MVP reichen alle aktiven Kinder.
const allChildrenRaw = await prisma.child.findMany({
where: { kitaId, active: true },
@@ -179,7 +189,7 @@ export default async function PlanungsZentralePage({
// Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime()
(a) => a.date.getTime() === day.getTime(),
);
const isOriginallyUnassigned =
!assignment ||
@@ -190,6 +200,11 @@ export default async function PlanungsZentralePage({
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
activeAlarmId: assignment?.alarms[0]?.id ?? null,
canAlarm:
!!assignment &&
plan?.status === "PUBLISHED" &&
day.toISOString().slice(0, 10) >= todayKey(),
};
});
+49 -6
View File
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
@@ -40,6 +41,8 @@ interface PlanViewProps {
days: {
date: string;
assignmentId: string | null;
activeAlarmId?: string | null;
canAlarm?: boolean;
childId: string | null;
isOriginallyUnassigned?: boolean;
}[];
@@ -92,7 +95,9 @@ export function PlanView({
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const responsePercent =
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
totalFamilies > 0
? Math.round((respondedFamilies / totalFamilies) * 100)
: 0;
const handleGenerate = () => {
startTransition(async () => {
@@ -123,7 +128,9 @@ export function PlanView({
if ("error" in result && result.error) {
toast.error(result.error as string);
} else if (result.success) {
toast.success(`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`);
toast.success(
`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`,
);
}
});
};
@@ -173,7 +180,11 @@ export function PlanView({
<Button disabled={isPending} onClick={handleGenerate}>
Trotzdem generieren
</Button>
<Button variant="ghost" onClick={handleSendReminders} disabled={isPending}>
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
) : (
@@ -408,7 +419,8 @@ export function PlanView({
<DialogHeader>
<DialogTitle>Plan zurücksetzen?</DialogTitle>
<DialogDescription>
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren.
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für
diesen Monat gehen unwiderruflich verloren.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -542,6 +554,21 @@ function PlanPreviewTable({
({assignedChild.name})
</span>
)}
{day.activeAlarmId ? (
<Link
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
href={`/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`}
>
Aktiven Alarm öffnen
</Link>
) : day.canAlarm && day.assignmentId ? (
<Link
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
href={`/dashboard/notdienst/alarm?assignment=${day.assignmentId}`}
>
Notdienstalarm auslösen
</Link>
) : null}
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
@@ -616,7 +643,7 @@ function ManualAssignmentTable({
<select
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"}
disabled={isPending}
disabled={isPending || !!day.activeAlarmId}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
@@ -624,7 +651,9 @@ function ManualAssignmentTable({
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
const count = famId
? (familyAssignmentCounts.get(famId) ?? 0)
: 0;
return (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name}) — {count}x
@@ -637,6 +666,20 @@ function ManualAssignmentTable({
Veröffentlicht
</span>
)}
{day.assignmentId && day.canAlarm && (
<Link
className="text-xs font-semibold text-[var(--accent-deep)] underline underline-offset-4"
href={
day.activeAlarmId
? `/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`
: `/dashboard/notdienst/alarm?assignment=${day.assignmentId}`
}
>
{day.activeAlarmId
? "Aktiven Alarm öffnen"
: "Notdienstalarm auslösen"}
</Link>
)}
</div>
</div>
);