Add non-destructive dev seed on startup

This commit is contained in:
t.indorf
2026-05-06 22:31:07 +02:00
parent 9f452fccac
commit b686e714ff
77 changed files with 10862 additions and 87 deletions
+93
View File
@@ -0,0 +1,93 @@
import { redirect } from "next/navigation";
import type { Session } from "next-auth";
import { UserRole } from "@prisma/client";
import { auth } from "@/auth";
// =====================================================================
// Auth-Utils für Server Components, Server Actions, Route Handlers
// ---------------------------------------------------------------------
// Wir kapseln die Session-Auflösung an einer Stelle, damit jede
// geschützte Route denselben Sicherheits-Pfad geht:
//
// 1. `getSession()` → optional, gibt `null` zurück
// 2. `requireSession()` → eingeloggt, sonst Redirect /login
// 3. `requireKitaSession()` → eingeloggt + kitaId vorhanden + Privacy-Consent
// 4. `requireRole()` → zusätzlich Rollen-Whitelist
//
// Mandanten-Isolation: NIEMALS `session.user.kitaId` ungeprüft an Prisma
// reichen. Über `requireKitaSession` ist garantiert, dass der Wert
// nicht-null ist und der Consent-Zeitstempel gesetzt wurde.
// =====================================================================
export type AuthenticatedSession = Session & {
user: NonNullable<Session["user"]>;
};
export type KitaSession = AuthenticatedSession & {
user: AuthenticatedSession["user"] & { kitaId: string };
};
export async function getSession(): Promise<Session | null> {
return auth();
}
export async function requireSession(): Promise<AuthenticatedSession> {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return session as AuthenticatedSession;
}
/**
* Garantiert: eingeloggter User MIT Mandantenzuordnung UND akzeptierter
* Datenschutzerklärung. Genau diese Funktion ist der Single-Point-of-Truth
* für alle Tenant-gebundenen Server Actions / Page Components.
*/
export async function requireKitaSession(): Promise<KitaSession> {
const session = await requireSession();
if (!session.user.kitaId) {
// Superadmins haben keine Kita → eigene Oberfläche.
if (session.user.role === UserRole.SUPERADMIN) {
redirect("/admin");
}
// Frisch registrierte Gründer → in den Onboarding-Wizard.
redirect("/onboarding");
}
// DSGVO-Gate: Ohne Privacy-Consent → erst Consent einholen.
// Wir prüfen das hier zentral statt in jeder Route einzeln.
const consent = await consentCheck(session.user.id);
if (!consent) {
redirect("/onboarding/consent");
}
return session as KitaSession;
}
export async function requireRole(
allowed: UserRole[],
): Promise<AuthenticatedSession> {
const session = await requireSession();
if (!allowed.includes(session.user.role)) {
redirect("/forbidden");
}
return session;
}
// ---------------------------------------------------------------------
// interne Helfer
// ---------------------------------------------------------------------
async function consentCheck(userId: string): Promise<boolean> {
// Lazy-Import, damit `auth-utils` selbst Edge-kompatibel bleibt
// (Prisma läuft nur in Node-Runtime).
const { prisma } = await import("@/lib/prisma");
const user = await prisma.user.findUnique({
where: { id: userId },
select: { privacyPolicyAcceptedAt: true },
});
return !!user?.privacyPolicyAcceptedAt;
}
+48
View File
@@ -0,0 +1,48 @@
import {
addMonths,
endOfMonth,
getDate,
getYear,
getMonth,
isWeekend,
eachDayOfInterval,
startOfMonth,
startOfDay,
format,
} from "date-fns";
import { de } from "date-fns/locale";
/**
* Der Notdienst-Plan wird immer für den nächsten Monat erstellt.
* Nach dem 26. des aktuellen Monats ist der Plan gesperrt.
*/
const LOCK_DAY = 26;
export function getTargetMonthData(now = new Date()) {
const currentDay = getDate(now);
const nextMonthDate = addMonths(now, 1);
const targetYear = getYear(nextMonthDate);
const targetMonth = getMonth(nextMonthDate) + 1; // 1-12
// Sperre aktiv, sobald der aktuelle Tag (im aktuellen Monat) > LOCK_DAY ist.
const isLocked = currentDay > LOCK_DAY;
return {
targetYear,
targetMonth,
isLocked,
monthName: format(nextMonthDate, "MMMM yyyy", { locale: de }),
};
}
export function getWorkingDaysOfMonth(year: number, month: number) {
// month ist 1-12, Date erwartet 0-11
const start = startOfMonth(new Date(year, month - 1));
const end = endOfMonth(start);
const days = eachDayOfInterval({ start, end });
return days
.filter((day) => !isWeekend(day))
.map((day) => startOfDay(day));
}
+76
View File
@@ -0,0 +1,76 @@
import type { ReactNode } from "react";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
type SendAppEmailInput = {
to: string | string[];
subject: string;
react: ReactNode;
from?: string;
};
type SendAppEmailResult =
| { success: true; id?: string }
| { success: false; error: string };
export function getAppEmailConfigError() {
if (!process.env.RESEND_API_KEY) {
return "RESEND_API_KEY ist nicht konfiguriert.";
}
if (!process.env.EMAIL_FROM) {
return "EMAIL_FROM ist nicht konfiguriert.";
}
return null;
}
export async function sendAppEmail({
to,
subject,
react,
from = process.env.EMAIL_FROM,
}: SendAppEmailInput): Promise<SendAppEmailResult> {
if (!process.env.RESEND_API_KEY) {
return {
success: false,
error: "RESEND_API_KEY ist nicht konfiguriert.",
};
}
if (!from) {
return {
success: false,
error: "EMAIL_FROM ist nicht konfiguriert.",
};
}
try {
const { data, error } = await resend.emails.send({
from: from!,
to,
subject,
react,
});
if (error) {
console.error("Resend konnte die E-Mail nicht versenden:", error);
return {
success: false,
error: error.message ?? "E-Mail-Versand fehlgeschlagen.",
};
}
return { success: true, id: data?.id };
} catch (error) {
console.error("Unerwarteter Fehler beim E-Mail-Versand:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Unerwarteter Fehler beim E-Mail-Versand.",
};
}
}
+21
View File
@@ -0,0 +1,21 @@
import { PrismaClient } from "@prisma/client";
// Singleton-Pattern: Im Dev-Modus erzeugt Next.js bei jedem Hot-Reload
// neue Modul-Instanzen → ohne Cache landen schnell zu viele DB-Connections
// im Pool ("Too many connections"). Wir hängen den Client an `globalThis`.
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}