feat: add parent announcements and family or child event responses

This commit is contained in:
t.indorf
2026-10-09 21:44:19 +02:00
parent 2d3b82eb80
commit 979f795341
29 changed files with 2031 additions and 472 deletions
+22 -21
View File
@@ -5,13 +5,17 @@ import { mkdir, rm, writeFile } from "fs/promises";
import path from "path";
import { createElement } from "react";
import { revalidatePath } from "next/cache";
import { Prisma, UserRole } from "@prisma/client";
import { AnnouncementAudience, UserRole } from "@prisma/client";
import { z } from "zod";
import { NewsEmail } from "@/emails/NewsEmail";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { prisma } from "@/lib/prisma";
import {
announcementRecipientWhere,
visibleAnnouncementAudiences,
} from "@/lib/announcement-access";
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
@@ -55,25 +59,15 @@ function validateAttachment(file: File) {
return null;
}
async function getRecipients(kitaId: string, audience: z.infer<typeof audienceSchema>) {
const where: Prisma.UserWhereInput =
audience === "ELTERN"
? {
kitaId,
familyId: { not: null },
emailVerifiedAt: { not: null },
}
: audience === "ERZIEHER"
? {
kitaId,
role: UserRole.ERZIEHER,
emailVerifiedAt: { not: null },
}
: {
kitaId,
emailVerifiedAt: { not: null },
OR: [{ familyId: { not: null } }, { role: UserRole.ERZIEHER }],
};
async function getRecipients(
kitaId: string,
audience: z.infer<typeof audienceSchema>,
) {
const where = {
kitaId,
emailVerifiedAt: { not: null },
...announcementRecipientWhere(audience as AnnouncementAudience),
};
const users = await prisma.user.findMany({
where,
@@ -129,6 +123,7 @@ export async function createAnnouncement(formData: FormData) {
kitaId,
title: parsed.data.title,
content: parsed.data.content,
audience: parsed.data.audience,
authorId: session.user.id,
attachments: {
create: attachmentInputs.map((attachment) => ({
@@ -166,7 +161,10 @@ export async function createAnnouncement(formData: FormData) {
rm(path.join(uploadDir, attachment.id), { force: true }),
),
);
console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
console.error(
"Ankündigungs-Anhang konnte nicht gespeichert werden:",
error,
);
return { error: "Anhang konnte nicht gespeichert werden." };
}
}
@@ -198,6 +196,7 @@ export async function createAnnouncement(formData: FormData) {
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/news");
revalidatePath("/dashboard/mitteilungen");
if (emailError) {
return {
@@ -216,6 +215,7 @@ export async function markAnnouncementRead(announcementId: string) {
where: {
id: announcementId,
kitaId: session.user.kitaId,
audience: { in: visibleAnnouncementAudiences(session.user) },
},
select: { id: true },
});
@@ -241,5 +241,6 @@ export async function markAnnouncementRead(announcementId: string) {
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/mitteilungen");
return { success: true };
}
+114 -77
View File
@@ -3,20 +3,16 @@
import { revalidatePath } from "next/cache";
import {
EventParticipationStatus,
RsvpMode,
TerminStatus,
UserRole,
} from "@prisma/client";
import { z } from "zod";
import { rsvpSubmissionSchema } from "@/lib/rsvp";
import { lockEvent } from "@/lib/event-lock";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const submitRsvpSchema = z.object({
eventId: z.string().min(1),
childId: z.string().min(1),
status: z.nativeEnum(EventParticipationStatus),
});
export type EventParticipantDto = {
childId: string;
childName: string;
@@ -24,79 +20,120 @@ export type EventParticipantDto = {
status: EventParticipationStatus | null;
};
export async function submitEventRsvp(rawPayload: unknown) {
export async function submitEventRsvp(
rawPayload: unknown,
): Promise<{ error?: string; success?: boolean }> {
const session = await requireKitaSession();
const parsed = submitRsvpSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
const parsed = rsvpSubmissionSchema.safeParse(rawPayload);
if (!parsed.success)
return {
error:
"Bitte alle Antworten und bei Familienzusagen die Anzahl Erwachsener angeben.",
};
if (!session.user.familyId || session.user.role === UserRole.ERZIEHER) {
return {
error: "Für Rückmeldungen ist ein Elternkonto mit Haushalt erforderlich.",
};
}
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
const kitaId = session.user.kitaId;
const familyId = session.user.familyId;
const payload = parsed.data;
try {
const result = await prisma.$transaction(async (tx) => {
await lockEvent(tx, payload.eventId, kitaId);
const event = await tx.termin.findFirst({
where: { id: payload.eventId, kitaId },
select: {
status: true,
requiresRsvp: true,
rsvpMode: true,
rsvpDeadline: true,
startDate: true,
kita: { select: { terminModuleEnabled: true } },
},
});
if (
!event?.kita.terminModuleEnabled ||
!event.requiresRsvp ||
event.status !== TerminStatus.CONFIRMED
) {
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
}
if (event.rsvpMode !== payload.mode)
return {
error: "Die Antwortart wurde geändert. Bitte lade den Termin erneut.",
};
if (payload.mode === RsvpMode.FAMILY) {
const family = await tx.family.findFirst({
where: { id: familyId, kitaId },
select: { id: true },
});
if (!family) return { error: "Haushalt wurde nicht gefunden." };
if (
(event.rsvpDeadline ?? event.startDate) <= new Date() ||
event.startDate <= new Date()
) {
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
}
await tx.familyEventParticipation.upsert({
where: { eventId_familyId: { eventId: payload.eventId, familyId } },
create: {
kitaId,
eventId: payload.eventId,
familyId,
status: payload.status,
adultCount: payload.adultCount,
},
update: { status: payload.status, adultCount: payload.adultCount },
});
} else {
const children = await tx.child.findMany({
where: { kitaId, familyId, active: true },
select: { id: true },
});
const ids = new Set(children.map((child) => child.id));
if (
payload.responses.length !== ids.size ||
payload.responses.some((row) => !ids.has(row.childId))
) {
return {
error:
"Bitte antworte für alle eigenen aktiven Kinder. Lade den Termin bei Änderungen erneut.",
};
}
if (
(event.rsvpDeadline ?? event.startDate) <= new Date() ||
event.startDate <= new Date()
) {
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
}
for (const response of payload.responses) {
await tx.eventParticipation.upsert({
where: {
eventId_childId: {
eventId: payload.eventId,
childId: response.childId,
},
},
create: { kitaId, eventId: payload.eventId, ...response },
update: { status: response.status },
});
}
}
return { success: true };
});
if (result.error) return result;
revalidatePath("/dashboard");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return result;
} catch (error) {
console.error("Rückmeldung konnte nicht gespeichert werden:", error);
return {
error:
"Rückmeldung konnte nicht gespeichert werden. Bitte versuche es erneut.",
};
}
const { eventId, childId, status } = parsed.data;
const event = await prisma.termin.findFirst({
where: {
id: eventId,
kitaId: session.user.kitaId,
},
select: {
id: true,
status: true,
requiresRsvp: true,
rsvpDeadline: true,
startDate: true,
},
});
if (!event || !event.requiresRsvp || event.status !== TerminStatus.CONFIRMED) {
return { error: "Für diesen Termin ist keine Rückmeldung aktiv." };
}
const deadline = event.rsvpDeadline ?? event.startDate;
if (deadline < new Date()) {
return { error: "Die Rückmeldefrist ist bereits abgelaufen." };
}
const child = await prisma.child.findFirst({
where: {
id: childId,
kitaId: session.user.kitaId,
familyId: session.user.familyId,
active: true,
},
select: { id: true },
});
if (!child) {
return { error: "Dieses Kind gehört nicht zu deinem Haushalt." };
}
await prisma.eventParticipation.upsert({
where: {
eventId_childId: {
eventId,
childId,
},
},
create: {
kitaId: session.user.kitaId,
eventId,
childId,
status,
},
update: {
status,
},
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/kalender");
revalidatePath("/dashboard/admin/kalender");
return { success: true };
}
export async function getEventParticipants(