Add non-destructive dev seed on startup
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user