continued the kita-planer

This commit is contained in:
t.indorf
2026-05-08 14:32:14 +02:00
parent b686e714ff
commit 7aff691803
85 changed files with 9434 additions and 588 deletions
+198
View File
@@ -0,0 +1,198 @@
"use server";
import { revalidatePath } from "next/cache";
import { AbsenceReason, UserRole } from "@prisma/client";
import { z } from "zod";
import { requireKitaSession, requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
const reportAbsenceSchema = z.object({
childId: z.string().min(1),
startDate: z.string().min(1),
endDate: z.string().min(1),
reason: z.nativeEnum(AbsenceReason),
note: z.string().trim().max(500).optional(),
});
const dailyAbsenceSchema = z.object({
date: z.string().min(1),
});
export type DailyAbsenceDto = {
id: string;
childName: string;
familyName: string;
reason: AbsenceReason;
note: string | null;
startDate: string;
endDate: string;
};
function parseDateOnly(value: string) {
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
if (Number.isNaN(date.getTime())) {
throw new Error("Ungueltiges Datum.");
}
return date;
}
function toDateKey(date: Date) {
return date.toISOString().slice(0, 10);
}
export async function reportAbsence(rawPayload: unknown) {
const session = await requireKitaSession();
const parsed = reportAbsenceSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
const startDate = parseDateOnly(parsed.data.startDate);
const endDate = parseDateOnly(parsed.data.endDate);
if (endDate < startDate) {
return { error: "Das Enddatum darf nicht vor dem Startdatum liegen." };
}
const child = await prisma.child.findFirst({
where: {
id: parsed.data.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." };
}
const overlappingAbsence = await prisma.absence.findFirst({
where: {
kitaId: session.user.kitaId,
childId: child.id,
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { id: true },
});
if (overlappingAbsence) {
return { error: "Für diesen Zeitraum existiert bereits eine Abmeldung." };
}
await prisma.absence.create({
data: {
kitaId: session.user.kitaId,
childId: child.id,
startDate,
endDate,
reason: parsed.data.reason,
note: parsed.data.note || null,
},
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/abwesenheiten");
return { success: true };
}
export async function deleteAbsence(id: string) {
const session = await requireKitaSession();
if (!session.user.familyId) {
return { error: "Dein Account ist noch keinem Haushalt zugeordnet." };
}
const absence = await prisma.absence.findFirst({
where: {
id,
kitaId: session.user.kitaId,
child: {
familyId: session.user.familyId,
},
},
select: { id: true },
});
if (!absence) {
return { error: "Diese Abmeldung gehört nicht zu deinem Haushalt." };
}
await prisma.absence.delete({
where: { id: absence.id },
});
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/abwesenheiten");
return { success: true };
}
export async function getDailyAbsences(
rawPayload: unknown,
): Promise<{ absences?: DailyAbsenceDto[]; error?: string }> {
const session = await requireRole([
UserRole.ADMIN,
UserRole.KOORDINATOR,
UserRole.ERZIEHER,
]);
if (!session.user.kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = dailyAbsenceSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültiges Datum." };
}
const date = parseDateOnly(parsed.data.date);
const rows = await prisma.absence.findMany({
where: {
kitaId: session.user.kitaId,
startDate: { lte: date },
endDate: { gte: date },
},
select: {
id: true,
reason: true,
note: true,
startDate: true,
endDate: true,
child: {
select: {
firstName: true,
lastName: true,
family: {
select: {
name: true,
},
},
},
},
},
orderBy: [
{ child: { lastName: "asc" } },
{ child: { firstName: "asc" } },
],
});
return {
absences: rows.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
familyName: absence.child.family.name,
reason: absence.reason,
note: absence.note,
startDate: toDateKey(absence.startDate),
endDate: toDateKey(absence.endDate),
})),
};
}
+245
View File
@@ -0,0 +1,245 @@
"use server";
import { randomUUID } from "crypto";
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 { 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";
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
["application/pdf", ".pdf"],
["image/jpeg", ".jpg"],
["image/png", ".png"],
]);
const ALLOWED_EXTENSIONS = new Set([".pdf", ".jpg", ".jpeg", ".png"]);
const audienceSchema = z.enum(["ELTERN", "ERZIEHER", "ALLE"]);
const announcementSchema = z.object({
title: z.string().trim().min(3).max(160),
content: z.string().trim().min(3).max(20_000),
sendEmail: z.boolean(),
audience: audienceSchema,
});
function getUploadDir() {
return path.resolve(process.env.UPLOAD_DIR ?? "uploads", "announcements");
}
function getSafeFileName(name: string) {
return name.replace(/[^\w.\- äöüÄÖÜß]/g, "_").slice(0, 160);
}
function validateAttachment(file: File) {
const extension = path.extname(file.name).toLowerCase();
const typeAllowed = ALLOWED_TYPES.has(file.type);
const extensionAllowed = ALLOWED_EXTENSIONS.has(extension);
if (!typeAllowed && !extensionAllowed) {
return "Nur PDF, JPG und PNG sind erlaubt.";
}
if (file.size > MAX_ATTACHMENT_SIZE) {
return "Anhänge dürfen maximal 10 MB groß sein.";
}
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 }],
};
const users = await prisma.user.findMany({
where,
select: { email: true },
});
return Array.from(new Set(users.map((user) => user.email)));
}
export async function createAnnouncement(formData: FormData) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = announcementSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
sendEmail: formData.get("sendEmail") === "on",
audience: formData.get("audience") || "ELTERN",
});
if (!parsed.success) {
return { error: "Bitte Titel und Inhalt ausfüllen." };
}
const attachmentFiles = formData
.getAll("attachments")
.filter((value): value is File => value instanceof File && value.size > 0);
for (const file of attachmentFiles) {
const error = validateAttachment(file);
if (error) {
return { error };
}
}
const attachmentInputs = attachmentFiles.map((file) => {
const id = randomUUID();
return {
id,
file,
fileName: getSafeFileName(file.name),
fileType: file.type || "application/octet-stream",
fileUrl: `/api/announcements/attachments/${id}`,
};
});
const announcement = await prisma.announcement.create({
data: {
kitaId,
title: parsed.data.title,
content: parsed.data.content,
authorId: session.user.id,
attachments: {
create: attachmentInputs.map((attachment) => ({
id: attachment.id,
fileName: attachment.fileName,
fileType: attachment.fileType,
fileUrl: attachment.fileUrl,
})),
},
},
select: {
id: true,
title: true,
content: true,
},
});
if (attachmentInputs.length > 0) {
const uploadDir = getUploadDir();
try {
await mkdir(uploadDir, { recursive: true });
await Promise.all(
attachmentInputs.map(async (attachment) => {
const arrayBuffer = await attachment.file.arrayBuffer();
await writeFile(
path.join(uploadDir, attachment.id),
Buffer.from(arrayBuffer),
);
}),
);
} catch (error) {
await prisma.announcement.delete({ where: { id: announcement.id } });
await Promise.all(
attachmentInputs.map((attachment) =>
rm(path.join(uploadDir, attachment.id), { force: true }),
),
);
console.error("Ankündigungs-Anhang konnte nicht gespeichert werden:", error);
return { error: "Anhang konnte nicht gespeichert werden." };
}
}
let emailError: string | null = null;
if (parsed.data.sendEmail) {
const configError = getAppEmailConfigError();
if (configError) {
emailError = configError;
} else {
const recipients = await getRecipients(kitaId, parsed.data.audience);
if (recipients.length > 0) {
const result = await sendAppEmail({
to: recipients,
subject: `Neue Kita-Ankündigung: ${announcement.title}`,
react: createElement(NewsEmail, {
title: announcement.title,
content: announcement.content,
dashboardUrl: `${process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"}/dashboard`,
}),
});
if (!result.success) {
emailError = result.error;
}
}
}
}
revalidatePath("/dashboard");
revalidatePath("/dashboard/admin/news");
if (emailError) {
return {
success: true,
warning: `Ankündigung gespeichert, E-Mail-Versand fehlgeschlagen: ${emailError}`,
};
}
return { success: true };
}
export async function markAnnouncementRead(announcementId: string) {
const session = await requireKitaSession();
const announcement = await prisma.announcement.findFirst({
where: {
id: announcementId,
kitaId: session.user.kitaId,
},
select: { id: true },
});
if (!announcement) {
return { error: "Ankündigung wurde nicht gefunden." };
}
await prisma.announcementRead.upsert({
where: {
userId_announcementId: {
userId: session.user.id,
announcementId: announcement.id,
},
},
create: {
userId: session.user.id,
announcementId: announcement.id,
},
update: {
readAt: new Date(),
},
});
revalidatePath("/dashboard");
return { success: true };
}
+50
View File
@@ -0,0 +1,50 @@
"use server";
import { createElement } from "react";
import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
import {
contactRequestSchema,
contactRequestTypeLabels,
type ContactRequestInput,
} from "@/lib/contact-schema";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local";
export async function submitContactRequest(data: ContactRequestInput) {
const parsed = contactRequestSchema.safeParse(data);
if (!parsed.success) {
return {
success: false,
error: "Bitte prüfe deine Eingaben und versuche es erneut.",
};
}
const configError = getAppEmailConfigError();
if (configError) {
return {
success: false,
error: configError,
};
}
const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL;
const inquiryLabel = contactRequestTypeLabels[parsed.data.requestType];
const result = await sendAppEmail({
to: adminEmail,
subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`,
replyTo: parsed.data.email,
react: createElement(ContactRequestEmail, parsed.data),
});
if (!result.success) {
return {
success: false,
error: result.error,
};
}
return { success: true };
}