continued the kita-planer
This commit is contained in:
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user