Merge pull request #125 from Since-3/refactor/resend-mail

added resend button for not onboarded employees
This commit is contained in:
Rajpreet0 2026-06-23 11:16:33 +02:00 • committed by GitHub
commit d8c02d398e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2699 additions and 5896 deletions

7762
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -118,7 +118,7 @@ function SidebarProvider({
state,
open,
setOpen,
isMobile,
isMobile: isMobile ?? false,
openMobile,
setOpenMobile,
toggleSidebar,

View file

@ -3,17 +3,18 @@ import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(() => {
if (typeof window === "undefined") return undefined;
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`).matches;
});
React.useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
const onChange = () => setIsMobile(mql.matches);
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
return isMobile;
}

View file

@ -8,7 +8,7 @@ function getStripeClient(): Stripe {
throw new Error("STRIPE_SECRET_KEY ist nicht gesetzt");
}
_stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2026-04-22.dahlia",
apiVersion: "2026-05-27.dahlia",
typescript: true,
});
return _stripe;

View file

@ -8,23 +8,26 @@ const isValidPhoneNumber = (phone: string): boolean => {
};
// Step 1: Personal Info
export const adminRegisterStep1Schema = z
.object({
fullName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
email: z.string().email("Bitte eine gültige E-Mail-Adresse eingeben"),
tel: z
.string()
.min(1, "Telefonnummer ist erforderlich")
.refine((val) => isValidPhoneNumber(val), {
message: "Bitte eine gültige Telefonnummer eingeben",
}),
password: z.string().min(8, "Passwort muss mindestens 8 Zeichen lang sein"),
passwordConfirm: z.string(),
})
.refine((data) => data.password === data.passwordConfirm, {
const adminRegisterStep1Base = z.object({
fullName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
email: z.string().email("Bitte eine gültige E-Mail-Adresse eingeben"),
tel: z
.string()
.min(1, "Telefonnummer ist erforderlich")
.refine((val) => isValidPhoneNumber(val), {
message: "Bitte eine gültige Telefonnummer eingeben",
}),
password: z.string().min(8, "Passwort muss mindestens 8 Zeichen lang sein"),
passwordConfirm: z.string(),
});
export const adminRegisterStep1Schema = adminRegisterStep1Base.refine(
(data) => data.password === data.passwordConfirm,
{
message: "Passwürter müssen übereinstimmen",
path: ["passwordConfirm"],
});
}
);
// Step 2: Address Info
export const adminRegisterStep2Schema = z.object({
@ -34,7 +37,7 @@ export const adminRegisterStep2Schema = z.object({
});
// Step 3: Business Info
export const adminRegisterStep3Schema = z.object({
const adminRegisterStep3Base = z.object({
companyName: z.string().min(2, "Unternehmensname muss mindestens 2 Zeichen lang sein"),
companyPlace: z.string().min(2, "Unternehmenssitz muss mindestens 2 Zeichen lang sein"),
companyPLZ: z
@ -47,24 +50,37 @@ export const adminRegisterStep3Schema = z.object({
.email("Bitte eine gültige E-Mail-Adresse eingeben")
.optional()
.or(z.literal("")),
companyNumber: z
.string()
.optional()
.or(z.literal(""))
.refine(
(val) => {
if (!val || val === "") return true;
return isValidPhoneNumber(val);
},
{
message: "Bitte eine gültige Telefonnummer eingeben",
}
),
companyNumber: z.string().optional().or(z.literal("")),
});
// Full schema for complete validation
export const adminRegisterSchema = adminRegisterStep1Schema
.merge(adminRegisterStep2Schema)
.merge(adminRegisterStep3Schema);
export const adminRegisterStep3Schema = adminRegisterStep3Base.refine(
(data) => {
if (!data.companyNumber || data.companyNumber === "") return true;
return isValidPhoneNumber(data.companyNumber);
},
{
message: "Bitte eine gültige Telefonnummer eingeben",
path: ["companyNumber"],
}
);
// Full schema for complete validation — extend base shapes, then apply refinements
export const adminRegisterSchema = adminRegisterStep1Base
.extend(adminRegisterStep2Schema.shape)
.extend(adminRegisterStep3Base.shape)
.refine((data) => data.password === data.passwordConfirm, {
message: "Passwürter müssen übereinstimmen",
path: ["passwordConfirm"],
})
.refine(
(data) => {
if (!data.companyNumber || data.companyNumber === "") return true;
return isValidPhoneNumber(data.companyNumber);
},
{
message: "Bitte eine gültige Telefonnummer eingeben",
path: ["companyNumber"],
}
);
export type AdminRegisterFormData = z.infer<typeof adminRegisterSchema>;

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useTransition, useMemo, useEffect } from "react";
import { useState, useTransition, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import PublicCourseCard from "../components/PublicCourseCard";
import { getPublishedCoursesForBusiness } from "../../actions/booking-actions";
@ -53,26 +53,26 @@ const BusinessPublicView = ({
initialCourses,
initialWindow,
}: BusinessPublicViewProps) => {
const [loadedCourses, setLoadedCourses] = useState<PublicCourse[]>(initialCourses);
const [loadedWindow, setLoadedWindow] = useState(initialWindow);
const [selectedDate, setSelectedDate] = useState<Date>(TODAY);
const [activeSport, setActiveSport] = useState<string>("Alle");
const [isPending, startTransition] = useTransition();
// Deep-Link: ?course=<id> öffnet das Detail-Popup des Kurses und springt
// auf dessen Datum, damit die Karte sichtbar ist (z.B. geteilter Buchungslink).
// Deep-Link: ?course=<id> opens the course detail popup and jumps to its date
// (e.g. a shared booking link). Resolved once at mount via the lazy initializer below.
const searchParams = useSearchParams();
const deepLinkCourseId = searchParams.get("course");
useEffect(() => {
if (!deepLinkCourseId) return;
const target = loadedCourses.find((c) => c.id === deepLinkCourseId);
if (!target) return;
const day = new Date(target.date);
day.setHours(0, 0, 0, 0);
setSelectedDate(day);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deepLinkCourseId]);
const [loadedCourses, setLoadedCourses] = useState<PublicCourse[]>(initialCourses);
const [loadedWindow, setLoadedWindow] = useState(initialWindow);
const [selectedDate, setSelectedDate] = useState<Date>(() => {
if (deepLinkCourseId) {
const target = initialCourses.find((c) => c.id === deepLinkCourseId);
if (target) {
const day = new Date(target.date);
day.setHours(0, 0, 0, 0);
return day;
}
}
return TODAY;
});
const [activeSport, setActiveSport] = useState<string>("Alle");
const [isPending, startTransition] = useTransition();
const handleDateSelect = (date: Date | undefined) => {
if (!date) return;

View file

@ -57,22 +57,55 @@ const CourseCreateView = ({
customSports = [],
}: CourseCreateViewProps = {}) => {
const router = useRouter();
const [courseName, setCourseName] = useState("");
const [courseDate, setCourseDate] = useState("");
const [timeFrom, setTimeFrom] = useState("");
const [timeTo, setTimeTo] = useState("");
const [selectedSports, setSelectedSports] = useState<string[]>([]);
const [sports, setSports] = useState<Array<{ value: string; label: string }>>(customSports);
const isEdit = mode === "edit" && !!initialData;
const [courseName, setCourseName] = useState(isEdit ? initialData!.name || "" : "");
const [courseDate, setCourseDate] = useState(() => {
if (isEdit && initialData!.date) {
const d = new Date(initialData!.date);
return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, "0"),
String(d.getDate()).padStart(2, "0"),
].join("-");
}
return "";
});
const [timeFrom, setTimeFrom] = useState(isEdit ? initialData!.timeFrom || "" : "");
const [timeTo, setTimeTo] = useState(isEdit ? initialData!.timeTo || "" : "");
const [selectedSports, setSelectedSports] = useState<string[]>(
isEdit ? initialData!.sport || [] : []
);
const [sports, setSports] = useState<Array<{ value: string; label: string }>>(() => {
if (isEdit) {
const existingValues = new Set(customSports.map((s) => s.value));
const extra = (initialData!.sport || [])
.filter((s) => !existingValues.has(s))
.map((s) => ({ value: s, label: s }));
return [...customSports, ...extra];
}
return customSports;
});
const [newlyCreatedSports, setNewlyCreatedSports] = useState<string[]>([]);
const [selectedLevel, setSelectedLevel] = useState("");
const [selectedTrainers, setSelectedTrainers] = useState<string[]>([]);
const [selectedRoom, setSelectedRoom] = useState("");
const [description, setDescription] = useState("");
const [maxParticipants, setMaxParticipants] = useState("");
const [isStandingOrder, setIsStandingOrder] = useState(false);
const [selectedFrequency, setSelectedFrequency] = useState("");
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>([]);
const [price, setPrice] = useState("");
const [selectedLevel, setSelectedLevel] = useState(isEdit ? initialData!.level || "" : "");
const [selectedTrainers, setSelectedTrainers] = useState<string[]>(
isEdit ? initialData!.trainers || [] : []
);
const [selectedRoom, setSelectedRoom] = useState(isEdit ? initialData!.room || "" : "");
const [description, setDescription] = useState(isEdit ? initialData!.description || "" : "");
const [maxParticipants, setMaxParticipants] = useState(
isEdit ? initialData!.maxParticipants?.toString() || "" : ""
);
const [isStandingOrder, setIsStandingOrder] = useState(
isEdit ? initialData!.isStandingOrder || false : false
);
const [selectedFrequency, setSelectedFrequency] = useState(
isEdit ? initialData!.frequency || "" : ""
);
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(
isEdit ? initialData!.weekdays || [] : []
);
const [price, setPrice] = useState(isEdit ? initialData!.price?.toString() || "" : "");
const [priceDisplay, setPriceDisplay] = useState("");
const [isPriceFocused, setIsPriceFocused] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
@ -81,9 +114,13 @@ const CourseCreateView = ({
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [trainers, setTrainers] = useState<Array<{ value: string; label: string }>>([]);
const [isLoadingTrainers, setIsLoadingTrainers] = useState(true);
const [selectedBusinessId, setSelectedBusinessId] = useState("");
const [selectedBusinessId, setSelectedBusinessId] = useState(
isEdit ? (initialData!.businessId ?? "") : ""
);
const [businesses, setBusinesses] = useState<Array<{ value: string; label: string }>>([]);
const [coverImage, setCoverImage] = useState<string | null>(null);
const [coverImage, setCoverImage] = useState<string | null>(
isEdit ? (initialData!.coverImage ?? null) : null
);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const {
@ -145,46 +182,6 @@ const CourseCreateView = ({
loadTrainers();
}, []);
// Load initial data when in edit mode
useEffect(() => {
if (mode === "edit" && initialData) {
setCourseName(initialData.name || "");
const sportValues = initialData.sport || [];
setSelectedSports(sportValues);
// Merge initialData sports with customSports (no duplicates)
const existingValues = new Set(customSports.map((s) => s.value));
const newOptions = sportValues
.filter((s) => !existingValues.has(s))
.map((s) => ({ value: s, label: s }));
setSports([...customSports, ...newOptions]);
setSelectedLevel(initialData.level || "");
// Format date for input field (YYYY-MM-DD)
if (initialData.date) {
const date = new Date(initialData.date);
const formattedDate = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-");
setCourseDate(formattedDate);
}
setTimeFrom(initialData.timeFrom || "");
setTimeTo(initialData.timeTo || "");
setSelectedTrainers(initialData.trainers || []);
setSelectedRoom(initialData.room || "");
setDescription(initialData.description || "");
setMaxParticipants(initialData.maxParticipants?.toString() || "");
setPrice(initialData.price?.toString() || "");
setIsStandingOrder(initialData.isStandingOrder || false);
setSelectedFrequency(initialData.frequency || "");
setSelectedWeekdays(initialData.weekdays || []);
setSelectedBusinessId(initialData.businessId ?? "");
setCoverImage(initialData.coverImage ?? null);
}
}, [mode, initialData, customSports]);
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

View file

@ -8,7 +8,7 @@ import { Button } from "@/src/components/ui/button";
import { toast } from "sonner";
import { useRouter, useSearchParams } from "next/navigation";
import CourseListItem from "../components/CourseListItem";
import { Filter, ChevronUp, ChevronDown } from "lucide-react";
import { Filter } from "lucide-react";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
import CourseFilter from "../components/CourseFilter";
@ -77,18 +77,13 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
} = useDeleteCourse({
onSuccess: async () => {
if (loadedDateFrom && loadedDateTo) {
await loadCourses({ dateFrom: loadedDateFrom, dateTo: loadedDateTo }, "initial");
await loadCourses({ dateFrom: loadedDateFrom, dateTo: loadedDateTo });
}
},
});
// Date range state for infinite scroll
const [loadedDateFrom, setLoadedDateFrom] = useState<Date | null>(null);
const [loadedDateTo, setLoadedDateTo] = useState<Date | null>(null);
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
const [isLoadingNewer, setIsLoadingNewer] = useState(false);
const [hasOlderCourses, setHasOlderCourses] = useState(true);
const [hasNewerCourses, setHasNewerCourses] = useState(true);
// Load rooms once on mount
useEffect(() => {
@ -120,35 +115,10 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
loadTrainers();
}, []);
/**
* Load courses with optional date range
* @param options - Optional date range parameters
* @param mode - Loading mode: 'initial', 'older', or 'newer'
*/
const loadCourses = async (
options?: { dateFrom?: Date; dateTo?: Date },
mode: "initial" | "older" | "newer" = "initial"
) => {
const loadCourses = async (options?: { dateFrom?: Date; dateTo?: Date }) => {
const result = await getMyCourses(options);
if (result.success) {
if (mode === "initial") {
// Replace all courses on initial load
setCourses(result.courses);
} else if (mode === "older") {
// Prepend older courses to the beginning
setCourses((prev) => [...result.courses, ...prev]);
} else if (mode === "newer") {
// Append newer courses to the end
setCourses((prev) => [...prev, ...result.courses]);
}
// Check if there are more courses to load
if (mode === "older" && result.courses.length === 0) {
setHasOlderCourses(false);
}
if (mode === "newer" && result.courses.length === 0) {
setHasNewerCourses(false);
}
setCourses(result.courses);
} else {
toast.error(result.error || "Fehler beim Laden der Kurse");
}
@ -175,7 +145,7 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
setLoadedDateFrom(twoWeeksAgo);
setLoadedDateTo(sixWeeksLater);
await loadCourses({ dateFrom: twoWeeksAgo, dateTo: sixWeeksLater }, "initial");
await loadCourses({ dateFrom: twoWeeksAgo, dateTo: sixWeeksLater });
setIsLoading(false);
};
fetchCourses();
@ -216,7 +186,7 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
setIsLoading(true);
setLoadedDateFrom(newDateFrom);
setLoadedDateTo(newDateTo);
await loadCourses({ dateFrom: newDateFrom, dateTo: newDateTo }, "initial");
await loadCourses({ dateFrom: newDateFrom, dateTo: newDateTo });
setIsLoading(false);
}
};
@ -224,50 +194,6 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
extendRange();
}, [dateFrom, dateTo, loadedDateFrom, loadedDateTo, isLoading]);
/**
* Load older courses (extend the date range backwards by 4 weeks)
*/
const loadOlderCourses = async () => {
if (!loadedDateFrom || isLoadingOlder) return;
setIsLoadingOlder(true);
// Calculate new date range: 4 weeks before the current loadedDateFrom
const fourWeeksEarlier = new Date(loadedDateFrom);
fourWeeksEarlier.setDate(loadedDateFrom.getDate() - 28); // -4 weeks
const oneDayBeforeLoadedFrom = new Date(loadedDateFrom);
oneDayBeforeLoadedFrom.setDate(loadedDateFrom.getDate() - 1); // -1 day to avoid duplicates
await loadCourses({ dateFrom: fourWeeksEarlier, dateTo: oneDayBeforeLoadedFrom }, "older");
// Update the loaded date range
setLoadedDateFrom(fourWeeksEarlier);
setIsLoadingOlder(false);
};
/**
* Load newer courses (extend the date range forwards by 4 weeks)
*/
const loadNewerCourses = async () => {
if (!loadedDateTo || isLoadingNewer) return;
setIsLoadingNewer(true);
// Calculate new date range: 4 weeks after the current loadedDateTo
const oneDayAfterLoadedTo = new Date(loadedDateTo);
oneDayAfterLoadedTo.setDate(loadedDateTo.getDate() + 1); // +1 day to avoid duplicates
const fourWeeksLater = new Date(loadedDateTo);
fourWeeksLater.setDate(loadedDateTo.getDate() + 28); // +4 weeks
await loadCourses({ dateFrom: oneDayAfterLoadedTo, dateTo: fourWeeksLater }, "newer");
// Update the loaded date range
setLoadedDateTo(fourWeeksLater);
setIsLoadingNewer(false);
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("de-DE", {
weekday: "long",
@ -548,21 +474,6 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
</div>
</div>
{/* Load Older Courses Button */}
{hasOlderCourses && (
<div className="mb-4 flex justify-center">
<Button
variant="outline"
onClick={loadOlderCourses}
disabled={isLoadingOlder}
className="flex items-center gap-2"
>
<ChevronUp size={18} />
{isLoadingOlder ? "Lädt..." : "Ältere Kurse laden"}
</Button>
</div>
)}
{filteredCourses.length === 0 ? (
<div className="text-center py-16 border border-white/60 bg-white/55 backdrop-blur-xl rounded-2xl">
<p className="text-3xl font-black text-gray-800 mb-2">
@ -625,21 +536,6 @@ const CourseListView = ({ canCreate, canEdit, canDelete }: CourseListViewProps)
</div>
)}
{/* Load Newer Courses Button */}
{hasNewerCourses && filteredCourses.length > 0 && (
<div className="mt-4 flex justify-center">
<Button
variant="outline"
onClick={loadNewerCourses}
disabled={isLoadingNewer}
className="flex items-center gap-2"
>
<ChevronDown size={18} />
{isLoadingNewer ? "Lädt..." : "Neuere Kurse laden"}
</Button>
</div>
)}
<DeleteDialog
open={deleteDialogOpen}
onOpenChange={(open) => {

View file

@ -1,9 +1,9 @@
/**
* Employee Actions - Server-seitige Aktionen für Mitarbeiterverwaltung
* Employee Actions - Server-side actions for employee management
*
* Diese Datei enthält alle Server Actions für das Mitarbeiter-Management-System.
* Funktionen umfassen: Mitarbeiter erstellen, abrufen, aktualisieren und löschen.
* Alle Funktionen sind mit Authentifizierung und Autorisierung geschützt.
* This file contains all Server Actions for the employee management system.
* Functions include: creating, fetching, updating, and deleting employees.
* All functions are protected with authentication and authorization.
*/
"use server";
@ -24,9 +24,9 @@ import { generateOnboardingEmail } from "@/src/lib/mail/templates/employee-onboa
import { generateOnboardingCompleteEmail } from "@/src/lib/mail/templates/onboarding-complete";
/**
* Generiert einen sicheren Onboarding-Token
* Generates a secure onboarding token
*
* @returns Ein zufälliger 32-Byte Token als Hex-String
* @returns A random 32-byte token as a hex string
*/
function generateOnboardingToken(): string {
return randomBytes(32).toString("hex");
@ -40,8 +40,8 @@ type PermissionsObj = {
};
/**
* Begrenzt die zu speichernden Berechtigungen auf das, was der Aufrufer selbst besitzt.
* Verhindert Privilege-Escalation: ein Employee kann nur Rechte vergeben, die er selbst hat.
* Clamps the permissions to be stored to what the caller themselves possesses.
* Prevents privilege escalation: an employee can only grant permissions they already have.
*/
function clampPermissions(requested: PermissionsObj, ceiling: PermissionsObj): PermissionsObj {
const clamp = (req: PermissionGroup, ceil: PermissionGroup): PermissionGroup => ({
@ -58,54 +58,54 @@ function clampPermissions(requested: PermissionsObj, ceiling: PermissionsObj): P
}
/**
* Erstellt einen neuen Mitarbeiter in der Datenbank
* Creates a new employee in the database
*
* Diese Funktion validiert die Eingabedaten, überprüft die Berechtigungen des Benutzers
* und erstellt einen neuen Mitarbeiter. Der Mitarbeiter erhält einen Onboarding-Token,
* der später für den Onboarding-Prozess verwendet wird.
* Nur Manager können Mitarbeiter erstellen.
* This function validates the input data, checks the user's permissions,
* and creates a new employee. The employee receives an onboarding token
* that is later used for the onboarding process.
* Only managers can create employees.
*
* @param data - Die Mitarbeiterdaten aus dem Formular
* @param status - Der Status des Mitarbeiters ("draft" für Entwurf oder "published" für veröffentlicht)
* @returns Ein Objekt mit success-Flag, optionaler employeeId, onboardingToken und Nachricht oder Fehler
* @param data - The employee data from the form
* @param status - The employee's status ("draft" or "published")
* @returns An object with a success flag, optional employeeId, onboardingToken, and a message or error
*/
export async function createEmployee(data: EmployeeFormData, status: "draft" | "published") {
try {
// Schritt 1: Aktuellen Benutzer abrufen
// Step 1: Fetch the current user
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer angemeldet ist
// Step 2: Check if the user is logged in
if (!userData) {
return {
success: false,
error: "Sie müssen angemeldet sein, um einen Mitarbeiter zu erstellen",
error: "You must be logged in to create an employee",
};
}
// Schritt 3: Manager oder Employee mit Berechtigung
// Step 3: Manager or employee with the required permission
const canManageEmployees =
isManager(userData) || (isEmployee(userData) && userData.permissions.employees.create);
if (!canManageEmployees) {
return {
success: false,
error: "Keine Berechtigung zum Erstellen von Mitarbeitern",
error: "You do not have permission to create employees",
};
}
// Schritt 4: Formulardaten mit Zod-Schema validieren
// Step 4: Validate form data against the Zod schema
const validation = employeeSchema.safeParse(data);
if (!validation.success) {
return {
success: false,
error: "Validierungsfehler",
error: "Validation error",
fieldErrors: validation.error.flatten().fieldErrors,
};
}
const validatedData = validation.data;
// Schritt 5: Überprüfen, ob E-Mail bereits existiert
// Step 5: Check if the email already exists
const existingEmployee = await prisma.employee.findUnique({
where: { email: validatedData.email },
});
@ -113,32 +113,32 @@ export async function createEmployee(data: EmployeeFormData, status: "draft" | "
if (existingEmployee) {
return {
success: false,
error: "Ein Mitarbeiter mit dieser E-Mail-Adresse existiert bereits",
error: "An employee with this email address already exists",
};
}
// Schritt 6: Onboarding-Token generieren (nur wenn veröffentlicht)
// Step 6: Generate an onboarding token (only when publishing)
let onboardingToken: string | null = null;
let onboardingTokenExpiry: Date | null = null;
if (status === "published") {
onboardingToken = generateOnboardingToken();
// Token ist 7 Tage gültig
// Token is valid for 7 days
onboardingTokenExpiry = new Date();
onboardingTokenExpiry.setDate(onboardingTokenExpiry.getDate() + 7);
}
// Schritt 7: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
// Step 7: Determine the manager ID (an employee inherits createdBy from their own record)
const managerId = getEffectiveManagerId(userData);
if (!managerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
return { success: false, error: "Employee account is not assigned to a manager" };
// Schritt 8: Berechtigungen auf Caller-Ceiling begrenzen (Privilege-Escalation verhindern)
// Step 8: Clamp permissions to the caller's ceiling (prevent privilege escalation)
const finalPermissions = isEmployee(userData)
? clampPermissions(validatedData.permissions, userData.permissions)
: validatedData.permissions;
// Schritt 9: Mitarbeiter in der Datenbank erstellen
// Step 9: Create the employee in the database
const employee = await prisma.employee.create({
data: {
email: validatedData.email,
@ -153,7 +153,7 @@ export async function createEmployee(data: EmployeeFormData, status: "draft" | "
},
});
// Schritt 8: E-Mail versenden wenn veröffentlicht
// Step 10: Send email if status is published
if (status === "published" && onboardingToken) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const onboardingUrl = `${baseUrl}/employee/onboarding/${onboardingToken}`;
@ -164,20 +164,20 @@ export async function createEmployee(data: EmployeeFormData, status: "draft" | "
companyName: process.env.COMPANY_NAME || "S3 Kuma",
});
// E-Mail asynchron versenden (nicht auf Antwort warten)
// Send email asynchronously (fire and forget)
sendMail({
to: validatedData.email,
subject,
html,
text,
}).catch((error) => {
console.error("Fehler beim E-Mail-Versand:", error);
// E-Mail-Fehler wird nicht an den Benutzer weitergegeben
// Der Mitarbeiter wurde trotzdem erstellt
console.error("Error sending email:", error);
// Email errors are not surfaced to the user;
// the employee record has already been created
});
}
// Schritt 9: Next.js Cache für /employee Seite invalidieren, damit neue Daten angezeigt werden
// Step 11: Invalidate the Next.js cache for /employee so new data is shown
revalidatePath("/employee");
return {
@ -186,22 +186,22 @@ export async function createEmployee(data: EmployeeFormData, status: "draft" | "
onboardingToken: onboardingToken || undefined,
message:
status === "published"
? "Mitarbeiter erfolgreich veröffentlicht. Onboarding-E-Mail wurde versendet."
: "Entwurf erfolgreich gespeichert",
? "Employee successfully published. Onboarding email has been sent."
: "Draft successfully saved",
};
} catch (error) {
console.error("Error creating employee:", error);
return {
success: false,
error: "Ein Fehler ist beim Erstellen des Mitarbeiters aufgetreten",
error: "An error occurred while creating the employee",
};
}
}
/**
* Ruft alle Trainer (Mitarbeiter mit Rolle "trainer") ab, die vom eingeloggten Manager erstellt wurden
* Fetches all trainers (employees with the role "trainer") created by the logged-in manager
*
* @returns Ein Objekt mit success-Flag und einem Array von Trainern im Format {value, label}
* @returns An object with a success flag and an array of trainers in the format {value, label}
*/
export async function getMyTrainers() {
try {
@ -222,7 +222,7 @@ export async function getMyTrainers() {
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
error: "Employee account is not assigned to a manager",
employees: [],
trainers: [],
};
@ -254,26 +254,26 @@ export async function getMyTrainers() {
console.error("Error fetching trainers:", error);
return {
success: false,
error: "Fehler beim Laden der Trainer",
error: "Error loading trainers",
trainers: [],
};
}
}
/**
* Ruft alle Mitarbeiter ab, die vom eingeloggten Manager erstellt wurden
* Fetches all employees created by the logged-in manager
*
* Diese Funktion gibt alle Mitarbeiter zurück, die vom aktuellen Manager erstellt wurden,
* unabhängig vom Status (draft oder published).
* This function returns all employees created by the current manager,
* regardless of status (draft or published).
*
* @returns Ein Objekt mit success-Flag und einem Array von Mitarbeitern
* @returns An object with a success flag and an array of employees
*/
export async function getMyEmployees() {
try {
// Schritt 1: Aktuellen Benutzer abrufen
// Step 1: Fetch the current user
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer ein Manager oder Employee mit Berechtigung ist
// Step 2: Check if the user is a manager or an employee with the required permission
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.employees.view))
@ -285,14 +285,14 @@ export async function getMyEmployees() {
};
}
// Schritt 3: Mitarbeiter aus der Datenbank abrufen
// Step 3: Build the database query depending on the caller's role
let whereClause: Record<string, unknown>;
if (isManager(userData)) {
// Manager sieht alle Mitarbeiter unter seiner ID
// Manager sees all employees under their own ID
whereClause = { createdBy: userData.id };
} else {
// Employee: alle unter demselben Manager anzeigen (außer sich selbst)
// createdBy kann null sein (verwaister Account) — in dem Fall nichts zurückgeben
// Employee: show everyone under the same manager (excluding themselves)
// createdBy can be null (orphaned account) — return nothing in that case
const managerId = userData.createdBy;
if (!managerId) {
return { success: true, employees: [] };
@ -314,63 +314,71 @@ export async function getMyEmployees() {
console.error("Error fetching employees:", error);
return {
success: false,
error: "Fehler beim Laden der Mitarbeiter",
error: "Error loading employees",
employees: [],
};
}
}
/**
* Ruft einen einzelnen Mitarbeiter nach ID ab
* Fetches a single employee by ID
*
* Diese Funktion gibt einen Mitarbeiter zurück, wenn der eingeloggte Manager
* der Ersteller des Mitarbeiters ist.
* This function returns an employee only if the logged-in manager
* is the one who created that employee.
*
* @param employeeId - Die eindeutige ID des Mitarbeiters
* @returns Ein Objekt mit success-Flag und dem Mitarbeiter oder Fehler
* @param employeeId - The unique ID of the employee
* @returns An object with a success flag and the employee or an error
*/
export async function getEmployeeById(employeeId: string) {
try {
// Schritt 1: Aktuellen Benutzer abrufen
// Step 1: Fetch the current user
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer ein Manager oder Employee mit Berechtigung ist
// Step 2: Check if the user is a manager or an employee with the required permission
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.employees.view))
) {
return {
success: false,
error: "Nur Manager können Mitarbeiter bearbeiten",
error: "Only managers can edit employees",
};
}
// Schritt 3: Mitarbeiter aus der Datenbank abrufen
// Step 3: Fetch the employee from the database
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
});
// Schritt 4: Überprüfen, ob Mitarbeiter existiert
// Step 4: Check if the employee exists
if (!employee) {
return {
success: false,
error: "Mitarbeiter nicht gefunden",
error: "Employee not found",
};
}
// Schritt 4b: Employee darf eigenen Account nicht bearbeiten
// Step 4b: An employee may not edit their own account
if (isEmployee(userData) && employee.email === userData.email) {
return {
success: false,
error: "Sie können Ihren eigenen Account nicht bearbeiten",
error: "You cannot edit your own account",
};
}
// Schritt 5: Überprüfen, ob der Manager der Besitzer des Mitarbeiters ist
if (isManager(userData) && employee.createdBy !== userData.id) {
// Step 5: Check if the caller owns this employee record
const ownerManagerId = getEffectiveManagerId(userData);
if (!ownerManagerId) {
return {
success: false,
error: "Sie können nur Ihre eigenen Mitarbeiter bearbeiten",
error: "Employee account is not assigned to a manager",
};
}
if (employee.createdBy !== ownerManagerId) {
return {
success: false,
error: "You can only edit your own employees",
};
}
@ -382,22 +390,22 @@ export async function getEmployeeById(employeeId: string) {
console.error("Error fetching employee:", error);
return {
success: false,
error: "Fehler beim Laden des Mitarbeiters",
error: "Error loading employee",
};
}
}
/**
* Aktualisiert einen bestehenden Mitarbeiter
* Updates an existing employee
*
* Diese Funktion validiert die Eingabedaten, überprüft die Berechtigungen des Benutzers
* und aktualisiert einen bestehenden Mitarbeiter. Nur der Manager, der den Mitarbeiter erstellt hat,
* kann ihn bearbeiten.
* This function validates the input data, checks the user's permissions,
* and updates an existing employee. Only the manager who created the employee
* can edit them.
*
* @param employeeId - Die eindeutige ID des zu aktualisierenden Mitarbeiters
* @param data - Die aktualisierten Mitarbeiterdaten aus dem Formular
* @param status - Der Status des Mitarbeiters ("draft" für Entwurf oder "published" für veröffentlicht)
* @returns Ein Objekt mit success-Flag und Nachricht oder Fehler
* @param employeeId - The unique ID of the employee to update
* @param data - The updated employee data from the form
* @param status - The employee's status ("draft" or "published")
* @returns An object with a success flag and a message or error
*/
export async function updateEmployee(
employeeId: string,
@ -405,73 +413,73 @@ export async function updateEmployee(
status: "draft" | "published"
) {
try {
// Schritt 1: Aktuellen Benutzer abrufen
// Step 1: Fetch the current user
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer angemeldet ist
// Step 2: Check if the user is logged in
if (!userData) {
return {
success: false,
error: "Sie müssen angemeldet sein, um einen Mitarbeiter zu bearbeiten",
error: "You must be logged in to edit an employee",
};
}
// Schritt 3: Überprüfen, ob Benutzer ein Manager oder Employee mit Berechtigung ist
// Step 3: Check if the user is a manager or an employee with the required permission
const canManageEmployees =
isManager(userData) || (isEmployee(userData) && userData.permissions.employees.edit);
if (!canManageEmployees) {
return {
success: false,
error: "Nur Manager können Mitarbeiter bearbeiten",
error: "Only managers can edit employees",
};
}
// Schritt 4: Mitarbeiter aus der Datenbank abrufen
// Step 4: Fetch the existing employee from the database
const existingEmployee = await prisma.employee.findUnique({
where: { id: employeeId },
});
// Schritt 5: Überprüfen, ob Mitarbeiter existiert
// Step 5: Check if the employee exists
if (!existingEmployee) {
return {
success: false,
error: "Mitarbeiter nicht gefunden",
error: "Employee not found",
};
}
// Schritt 5b: Employee darf eigenen Account nicht bearbeiten
// Step 5b: An employee may not edit their own account
if (isEmployee(userData) && existingEmployee.email === userData.email) {
return {
success: false,
error: "Sie können Ihren eigenen Account nicht bearbeiten",
error: "You cannot edit your own account",
};
}
// Schritt 6: Überprüfen, ob der Benutzer der Besitzer des Mitarbeiters ist
// Step 6: Check if the caller owns this employee record
const ownerManagerId = getEffectiveManagerId(userData);
if (!ownerManagerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
return { success: false, error: "Employee account is not assigned to a manager" };
if (existingEmployee.createdBy !== ownerManagerId) {
return {
success: false,
error: "Sie können nur Mitarbeiter Ihres Managers bearbeiten",
error: "You can only edit employees belonging to your manager",
};
}
// Schritt 7: Formulardaten mit Zod-Schema validieren
// Step 7: Validate form data against the Zod schema
const validation = employeeSchema.safeParse(data);
if (!validation.success) {
return {
success: false,
error: "Validierungsfehler",
error: "Validation error",
fieldErrors: validation.error.flatten().fieldErrors,
};
}
const validatedData = validation.data;
// Schritt 8: Onboarding-Token generieren, falls noch nicht vorhanden und veröffentlicht
// Step 8: Generate a new onboarding token if the employee is being published for the first time
let onboardingToken = existingEmployee.onboardingToken;
let onboardingTokenExpiry = existingEmployee.onboardingTokenExpiry;
let shouldSendEmail = false;
@ -484,15 +492,15 @@ export async function updateEmployee(
onboardingToken = generateOnboardingToken();
onboardingTokenExpiry = new Date();
onboardingTokenExpiry.setDate(onboardingTokenExpiry.getDate() + 7);
shouldSendEmail = true; // E-Mail nur senden wenn neuer Token generiert wurde
shouldSendEmail = true; // Only send email when a new token was generated
}
// Schritt 9: Berechtigungen auf Caller-Ceiling begrenzen (Privilege-Escalation verhindern)
// Step 9: Clamp permissions to the caller's ceiling (prevent privilege escalation)
const finalPermissions = isEmployee(userData)
? clampPermissions(validatedData.permissions, userData.permissions)
: validatedData.permissions;
// Schritt 10: Mitarbeiter in der Datenbank aktualisieren
// Step 10: Update the employee in the database
await prisma.employee.update({
where: { id: employeeId },
data: {
@ -506,7 +514,7 @@ export async function updateEmployee(
},
});
// Schritt 10: E-Mail versenden wenn neuer Token generiert wurde
// Step 11: Send email if a new onboarding token was generated
if (shouldSendEmail && onboardingToken) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const onboardingUrl = `${baseUrl}/employee/onboarding/${onboardingToken}`;
@ -517,23 +525,23 @@ export async function updateEmployee(
companyName: process.env.COMPANY_NAME || "S3 Kuma",
});
// E-Mail asynchron versenden (nicht auf Antwort warten)
// Send email asynchronously (fire and forget)
sendMail({
to: validatedData.email,
subject,
html,
text,
}).catch((error) => {
console.error("Fehler beim E-Mail-Versand:", error);
// E-Mail-Fehler wird nicht an den Benutzer weitergegeben
console.error("Error sending email:", error);
// Email errors are not surfaced to the user
});
}
// Schritt 11: Next.js Cache invalidieren
// Step 12: Invalidate the Next.js cache
revalidatePath("/employee");
revalidatePath(`/employee/edit/${employeeId}`);
invalidateUserDataCache(validatedData.email);
// If email changed, also invalidate the old email's cache entry
// If the email changed, also invalidate the old email's cache entry
if (existingEmployee.email !== validatedData.email) {
invalidateUserDataCache(existingEmployee.email);
}
@ -543,34 +551,34 @@ export async function updateEmployee(
message:
status === "published"
? shouldSendEmail
? "Mitarbeiter erfolgreich aktualisiert. Onboarding-E-Mail wurde versendet."
: "Mitarbeiter erfolgreich aktualisiert und veröffentlicht"
: "Entwurf erfolgreich aktualisiert",
? "Employee successfully updated. Onboarding email has been sent."
: "Employee successfully updated and published"
: "Draft successfully updated",
};
} catch (error) {
console.error("Error updating employee:", error);
return {
success: false,
error: "Ein Fehler ist beim Aktualisieren des Mitarbeiters aufgetreten",
error: "An error occurred while updating the employee",
};
}
}
/**
* Ruft alle verwendeten Rollen des Managers ab
* Fetches all roles currently used by the manager's employees
*
* Diese Funktion extrahiert alle Rollen aus den Mitarbeitern des aktuellen Managers,
* dedupliziert sie und gibt sie zurück. Dies ermöglicht es, custom Rollen
* wiederzuverwenden ohne eine separate Rollen-Tabelle zu benötigen.
* This function extracts all roles from the current manager's employees,
* deduplicates them, and returns the result. This allows custom roles to be
* reused without requiring a separate roles table.
*
* @returns Ein Objekt mit success-Flag und einem Array von Rollen im Format {value, label}
* @returns An object with a success flag and an array of roles in the format {value, label}
*/
export async function getMyEmployeeRoles() {
try {
// Schritt 1: Aktuellen Benutzer abrufen
// Step 1: Fetch the current user
const userData = await getUserData();
// Schritt 2: Manager oder Employee mit Berechtigung
// Step 2: Manager or employee with the required permission
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.employees.view))
@ -582,16 +590,16 @@ export async function getMyEmployeeRoles() {
};
}
// Schritt 3: Effektive Manager-ID ermitteln
// Step 3: Determine the effective manager ID
const managerId = getEffectiveManagerId(userData);
if (!managerId)
return {
success: false,
error: "Mitarbeiter-Account ist keinem Manager zugeordnet",
error: "Employee account is not assigned to a manager",
roles: [],
};
// Schritt 4: Alle Mitarbeiter des Managers abrufen (nur roles Feld)
// Step 4: Fetch all of the manager's employees (roles field only)
const employees = await prisma.employee.findMany({
where: {
createdBy: managerId,
@ -601,11 +609,11 @@ export async function getMyEmployeeRoles() {
},
});
// Schritt 4: Alle Rollen extrahieren und deduplizieren
// Step 5: Extract all roles and deduplicate
const allRoles = employees.flatMap((employee) => employee.roles);
const uniqueRoles = [...new Set(allRoles)];
// Schritt 5: In das Format {value, label} konvertieren
// Step 6: Convert to {value, label} format
const formattedRoles = uniqueRoles.map((role) => ({
value: role,
label: role.charAt(0).toUpperCase() + role.slice(1), // Capitalize first letter
@ -619,77 +627,77 @@ export async function getMyEmployeeRoles() {
console.error("Error fetching employee roles:", error);
return {
success: false,
error: "Fehler beim Laden der Rollen",
error: "Error loading roles",
roles: [],
};
}
}
/**
* Löscht einen Mitarbeiter aus der Datenbank
* Deletes an employee from the database
*
* Diese Funktion löscht einen Mitarbeiter, überprüft aber vorher:
* - Ob der Benutzer ein Manager ist
* - Ob der Mitarbeiter existiert
* - Ob der Manager der Besitzer des Mitarbeiters ist (Manager können nur eigene Mitarbeiter löschen)
* Before deleting, this function verifies that:
* - The caller is a manager or has the delete permission
* - The employee exists
* - The caller owns the employee record (managers can only delete their own employees)
*
* @param employeeId - Die eindeutige ID des zu löschenden Mitarbeiters
* @returns Ein Objekt mit success-Flag und Nachricht oder Fehler
* @param employeeId - The unique ID of the employee to delete
* @returns An object with a success flag and a message or error
*/
export async function deleteEmployee(employeeId: string) {
try {
// Schritt 1: Aktuellen Benutzer abrufen
// Step 1: Fetch the current user
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer ein Manager oder Employee mit Berechtigung ist
// Step 2: Check if the user is a manager or an employee with the required permission
if (
!userData ||
(!isManager(userData) && !(isEmployee(userData) && userData.permissions.employees.delete))
) {
return {
success: false,
error: "Nur Manager können Mitarbeiter löschen",
error: "Only managers can delete employees",
};
}
// Schritt 3: Mitarbeiter aus der Datenbank abrufen
// Step 3: Fetch the employee from the database
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
});
// Schritt 4: Überprüfen, ob Mitarbeiter existiert
// Step 4: Check if the employee exists
if (!employee) {
return {
success: false,
error: "Mitarbeiter nicht gefunden",
error: "Employee not found",
};
}
// Schritt 4b: Employee darf eigenen Account nicht löschen
// Step 4b: An employee may not delete their own account
if (isEmployee(userData) && employee.email === userData.email) {
return {
success: false,
error: "Sie können Ihren eigenen Account nicht löschen",
error: "You cannot delete your own account",
};
}
// Schritt 5: Überprüfen, ob der Benutzer der Besitzer des Mitarbeiters ist
// Step 5: Check if the caller owns this employee record
const ownerManagerId = getEffectiveManagerId(userData);
if (!ownerManagerId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
return { success: false, error: "Employee account is not assigned to a manager" };
if (employee.createdBy !== ownerManagerId) {
return {
success: false,
error: "Sie können nur Mitarbeiter Ihres Managers löschen",
error: "You can only delete employees belonging to your manager",
};
}
// Schritt 6: Mitarbeiter aus der Datenbank löschen
// Step 6: Delete the employee from the database
await prisma.employee.delete({
where: { id: employeeId },
});
// Schritt 7: Supabase Auth User löschen (erzwingt automatisches Ausloggen)
// Step 7: Delete the Supabase Auth user (forces automatic sign-out)
if (employee.isOnboarded) {
try {
const adminClient = createAdminClient();
@ -699,31 +707,31 @@ export async function deleteEmployee(employeeId: string) {
await adminClient.auth.admin.deleteUser(authUser.id);
}
} catch (err) {
// Auth-Löschung schlägt fehl wenn SERVICE_ROLE_KEY fehlt — DB ist bereits gelöscht
console.error("Supabase Auth User konnte nicht gelöscht werden:", err);
// Auth deletion fails if SERVICE_ROLE_KEY is missing — the DB record is already gone
console.error("Could not delete Supabase Auth user:", err);
}
}
// Schritt 8: Next.js Cache invalidieren
// Step 8: Invalidate the Next.js cache
revalidatePath("/employee");
invalidateUserDataCache(employee.email);
return {
success: true,
message: "Mitarbeiter erfolgreich gelöscht",
message: "Employee successfully deleted",
};
} catch (error) {
console.error("Error deleting employee:", error);
return {
success: false,
error: "Fehler beim Löschen des Mitarbeiters",
error: "Error deleting employee",
};
}
}
/**
* Prüft, ob ein Trainer noch in aktiven (zukünftigen) Kursen eingetragen ist.
* Gibt die Anzahl der betroffenen Kurse zurück.
* Checks whether a trainer is still assigned to active (future) courses.
* Returns the number of affected courses.
*/
export async function getActiveCoursesCountByTrainer(employeeId: string) {
try {
@ -749,12 +757,12 @@ export async function getActiveCoursesCountByTrainer(employeeId: string) {
return { success: true, count };
} catch (error) {
console.error("Error checking active courses for trainer:", error);
return { success: false, error: "Fehler beim Prüfen der Kurse", count: 0 };
return { success: false, error: "Error checking courses", count: 0 };
}
}
/**
* Ruft einen Mitarbeiter anhand des Onboarding-Tokens ab (öffentlich, kein Login nötig)
* Fetches an employee by their onboarding token (public — no login required)
*/
export async function getEmployeeByOnboardingToken(token: string) {
try {
@ -763,26 +771,26 @@ export async function getEmployeeByOnboardingToken(token: string) {
});
if (!employee) {
return { success: false, error: "Ungültiger Onboarding-Link" };
return { success: false, error: "Invalid onboarding link" };
}
if (employee.isOnboarded) {
return { success: false, error: "Onboarding wurde bereits abgeschlossen" };
return { success: false, error: "Onboarding has already been completed" };
}
if (employee.onboardingTokenExpiry && employee.onboardingTokenExpiry < new Date()) {
return { success: false, error: "Dieser Onboarding-Link ist abgelaufen" };
return { success: false, error: "This onboarding link has expired" };
}
return { success: true, employee };
} catch (error) {
console.error("Error fetching employee by token:", error);
return { success: false, error: "Fehler beim Laden der Mitarbeiterdaten" };
return { success: false, error: "Error loading employee data" };
}
}
/**
* Schließt das Onboarding eines Mitarbeiters ab und erstellt einen Supabase-Account
* Completes an employee's onboarding and creates their Supabase account
*/
export async function completeEmployeeOnboarding(
token: string,
@ -802,10 +810,10 @@ export async function completeEmployeeOnboarding(
});
if (!employee) {
return { success: false, error: "Ungültiger Onboarding-Link" };
return { success: false, error: "Invalid onboarding link" };
}
// Supabase Account über API Route erstellen
// Create the Supabase account via the API route
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const res = await fetch(`${baseUrl}/api/auth/register/employee`, {
method: "POST",
@ -825,10 +833,10 @@ export async function completeEmployeeOnboarding(
const result = await res.json();
if (!res.ok) {
return { success: false, error: result.error || "Registrierung fehlgeschlagen" };
return { success: false, error: result.error || "Registration failed" };
}
// Mitarbeiter atomisch als onboarded markieren — verhindert TOCTOU race condition
// Atomically mark the employee as onboarded — prevents TOCTOU race condition
const updated = await prisma.employee.updateMany({
where: {
onboardingToken: token,
@ -849,13 +857,13 @@ export async function completeEmployeeOnboarding(
if (updated.count === 0) {
return {
success: false,
error: "Onboarding wurde bereits abgeschlossen oder der Link ist abgelaufen",
error: "Onboarding has already been completed or the link has expired",
};
}
invalidateUserDataCache(employee.email);
// Manager-Bestätigungs-E-Mail senden
// Send a confirmation email to the manager
if (employee.createdBy) {
const manager = await prisma.manager.findUnique({
where: { id: employee.createdBy },
@ -872,7 +880,7 @@ export async function completeEmployeeOnboarding(
});
sendMail({ to: manager.email, subject, html, text }).catch((error) => {
console.error("Fehler beim Senden der Manager-Benachrichtigung:", error);
console.error("Error sending manager notification:", error);
});
}
}
@ -880,6 +888,76 @@ export async function completeEmployeeOnboarding(
return { success: true };
} catch (error) {
console.error("Error completing onboarding:", error);
return { success: false, error: "Ein Fehler ist aufgetreten" };
return { success: false, error: "An error occurred" };
}
}
/**
* Resends the onboarding email to an employee who has not yet completed onboarding.
* Generates a fresh token with a new 7-day expiry before sending.
*/
export async function resendOnboardingEmail(employeeId: string) {
try {
const userData = await getUserData();
// Check if the caller has permission to manage employees
const canManage =
userData &&
(isManager(userData) || (isEmployee(userData) && userData.permissions.employees.edit));
if (!canManage) {
return { success: false, error: "You do not have permission to perform this action" };
}
const managerId = getEffectiveManagerId(userData);
if (!managerId) return { success: false, error: "Manager ID not found" };
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
select: { id: true, email: true, status: true, isOnboarded: true, createdBy: true },
});
// Verify the employee exists and belongs to this manager
if (!employee || employee.createdBy !== managerId) {
return { success: false, error: "Employee not found" };
}
if (employee.isOnboarded) {
return { success: false, error: "This employee has already completed onboarding" };
}
if (employee.status !== "published") {
return { success: false, error: "Only published employees can receive an onboarding email" };
}
// Generate a new token and reset its expiry
const newToken = generateOnboardingToken();
const newExpiry = new Date();
newExpiry.setDate(newExpiry.getDate() + 7);
await prisma.employee.update({
where: { id: employeeId },
data: { onboardingToken: newToken, onboardingTokenExpiry: newExpiry },
});
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const onboardingUrl = `${baseUrl}/employee/onboarding/${newToken}`;
const { subject, html, text } = generateOnboardingEmail({
email: employee.email,
onboardingUrl,
companyName: process.env.COMPANY_NAME || "S3 Kuma",
});
const mailResult = await sendMail({ to: employee.email, subject, html, text });
if (!mailResult.success) {
console.error("Error resending onboarding email:", mailResult.error);
return { success: false, error: "Error sending email" };
}
return { success: true, message: "Onboarding email has been resent" };
} catch (error) {
console.error("Error resending onboarding email:", error);
return { success: false, error: "Error sending email" };
}
}

View file

@ -6,7 +6,8 @@
* inklusive Rollen, Standorte, Onboarding-Status und Bearbeitungsoption.
*/
import { Pen, Trash2, Mail, MapPin, Shield, Lock } from "lucide-react";
import { Pen, Trash2, Mail, MapPin, Shield, Lock, Send, Loader2 } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/src/components/ui/tooltip";
/**
* Props für die EmployeeListItem Komponente
@ -26,6 +27,8 @@ interface EmployeeListItemProps {
isOnboarded: boolean; // Ob der Mitarbeiter das Onboarding abgeschlossen hat
onEdit?: () => void; // Callback-Funktion beim Klick auf Bearbeiten-Button
onDelete?: () => void; // Callback-Funktion beim Klick auf Löschen-Button
onResendEmail?: () => void; // Callback-Funktion für erneutes Senden der Onboarding-Mail
isResendingEmail?: boolean; // Ob die Mail gerade versendet wird
showDeleteIcon?: boolean; // Ob das Löschen-Icon angezeigt werden soll
}
@ -43,6 +46,8 @@ const EmployeeListItem: React.FC<EmployeeListItemProps> = ({
isOnboarded,
onEdit,
onDelete,
onResendEmail,
isResendingEmail,
showDeleteIcon,
}) => {
return (
@ -166,6 +171,31 @@ const EmployeeListItem: React.FC<EmployeeListItemProps> = ({
<Trash2 size={20} className="text-red-600" />
</button>
)}
{status === "published" && !isOnboarded && onResendEmail && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onResendEmail}
disabled={isResendingEmail}
aria-label={
isResendingEmail
? "Onboarding-Mail wird erneut gesendet"
: "Onboarding-Mail erneut senden"
}
aria-busy={isResendingEmail || undefined}
className="p-2 rounded-md hover:bg-blue-500/10 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{isResendingEmail ? (
<Loader2 size={20} className="text-blue-600 animate-spin" />
) : (
<Send size={20} className="text-blue-600" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">Onboarding-Mail erneut senden</TooltipContent>
</Tooltip>
)}
{onEdit && (
<button onClick={onEdit} className="p-2 rounded-md hover:bg-white/50 transition">
<Pen size={20} className="text-gray-700" />

View file

@ -1,7 +1,7 @@
import { Button } from "@/src/components/ui/button";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { getMyEmployees } from "../../actions/employee-actions";
import { getMyEmployees, resendOnboardingEmail } from "../../actions/employee-actions";
import EmployeeListItem from "../components/EmployeeListItem";
import { toast } from "sonner";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
@ -33,6 +33,7 @@ const EmployeeListView = ({ canCreate, canEdit, canDelete }: EmployeeListViewPro
const [employees, setEmployees] = useState<Employee[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [deleteMode, setDeleteMode] = useState(false);
const [resendingId, setResendingId] = useState<string | null>(null);
const {
deleteDialogOpen,
@ -62,6 +63,23 @@ const EmployeeListView = ({ canCreate, canEdit, canDelete }: EmployeeListViewPro
loadEmployees();
}, []);
const handleResendEmail = async (employeeId: string) => {
setResendingId(employeeId);
try {
const result = await resendOnboardingEmail(employeeId);
if (result.success) {
toast.success("Onboarding-Mail wurde erneut versendet");
} else {
toast.error(result.error || "Fehler beim Versenden der E-Mail");
}
} catch (error) {
console.error("Error resending onboarding email:", error);
toast.error("Fehler beim Versenden der E-Mail");
} finally {
setResendingId(null);
}
};
const getEmployeeName = (employee: Employee) => {
if (employee.firstName && employee.lastName) {
return `${employee.firstName} ${employee.lastName}`;
@ -131,6 +149,8 @@ const EmployeeListView = ({ canCreate, canEdit, canDelete }: EmployeeListViewPro
: undefined
}
onEdit={canEdit ? () => router.push(`/employee/edit/${employee.id}`) : undefined}
onResendEmail={canEdit ? () => handleResendEmail(employee.id) : undefined}
isResendingEmail={resendingId === employee.id}
/>
))}
</div>

View file

@ -1,5 +1,5 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import InputComponent from "@/src/components/layout/InputComponent";
import { Button } from "@/src/components/ui/button";
@ -18,7 +18,7 @@ interface RoomsCreateViewProps {
const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateViewProps = {}) => {
const router = useRouter();
const [roomName, setRoomName] = useState("");
const [roomName, setRoomName] = useState(mode === "edit" ? initialData?.name || "" : "");
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
@ -34,13 +34,6 @@ const RoomsCreateView = ({ mode = "create", roomId, initialData }: RoomsCreateVi
onSuccess: () => router.push("/rooms"),
});
// Load initial data when in edit mode
useEffect(() => {
if (mode === "edit" && initialData) {
setRoomName(initialData.name || "");
}
}, [mode, initialData]);
const validateForm = () => {
const newErrors: Record<string, string> = {};