feat: create and invite educators in a single form

This commit is contained in:
t.indorf
2026-10-09 21:59:04 +02:00
parent 979f795341
commit 4702bf5abb
8 changed files with 785 additions and 177 deletions
@@ -2,6 +2,7 @@
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
@@ -30,16 +31,108 @@ import {
TableRow,
} from "@/components/ui/table";
import { createEducator, updateEducator, deleteEducator } from "../actions";
import {
createEducator,
updateEducator,
deleteEducator,
resendEducatorInvitation,
} from "../actions";
type EducatorDto = {
id: string;
firstName: string;
lastName: string;
active: boolean;
hasProfile: boolean;
email: string | null;
verified: boolean;
inviteFailed: boolean;
};
export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
function AccessFields({
prefix,
today,
educator,
}: {
prefix: string;
today: string;
educator?: EducatorDto;
}) {
return (
<>
<div className="grid gap-2">
<Label htmlFor={`${prefix}-email`}>E-Mail-Adresse</Label>
<Input
id={`${prefix}-email`}
name="email"
type="email"
autoComplete="email"
defaultValue={educator?.email ?? ""}
readOnly={!!educator?.email}
required
maxLength={254}
/>
<p className="text-xs leading-5 text-[var(--ink-soft)]">
{educator?.email
? "Diese Adresse gehört zum verknüpften Account."
: "An diese Adresse senden wir den persönlichen Aktivierungslink."}
</p>
</div>
{!educator?.hasProfile && (
<div className="grid gap-4 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`${prefix}-employmentStart`}>
Beschäftigungsbeginn
</Label>
<Input
id={`${prefix}-employmentStart`}
name="employmentStart"
type="date"
required
min="2000-01-01"
max="2100-12-31"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`${prefix}-trackingStart`}>
Start der Zeiterfassung
</Label>
<Input
id={`${prefix}-trackingStart`}
name="trackingStart"
type="date"
defaultValue={today}
required
min="2000-01-01"
max="2100-12-31"
/>
</div>
<p className="text-xs leading-5 text-[var(--ink-soft)] sm:col-span-2">
Ab diesem Datum werden Arbeitszeiten im Kita-Planer erfasst.
Arbeitstage, Urlaub und vorhandene Überstunden richtest du
anschließend im Personalprofil ein.
</p>
</div>
)}
</>
);
}
export function ErzieherList({
educators,
today,
}: {
educators: EducatorDto[];
today: string;
}) {
const router = useRouter();
const [search, setSearch] = useState("");
const [formError, setFormError] = useState("");
const filtered = educators.filter((ed) =>
`${ed.firstName} ${ed.lastName} ${ed.email ?? ""}`
.toLocaleLowerCase("de")
.includes(search.toLocaleLowerCase("de")),
);
const [openCreate, setOpenCreate] = useState(false);
const [editingEducator, setEditingEducator] = useState<EducatorDto | null>(
null,
@@ -54,15 +147,24 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
active: formData.get("active") === "on",
email: formData.get("email"),
employmentStart: formData.get("employmentStart"),
trackingStart: formData.get("trackingStart"),
};
setFormError("");
startTransition(async () => {
const res = await createEducator(data);
if (res.error) {
toast.error(res.error);
setFormError(res.error);
} else {
toast.success("ErzieherIn angelegt.");
toast.success(res.message ?? "Fachkraft und Personalprofil angelegt.");
if (res.warning) toast.warning(res.warning, { duration: 12000 });
setOpenCreate(false);
if (res.educatorId)
router.push(
`/dashboard/personal?tab=verwaltung&educator=${res.educatorId}`,
);
}
});
};
@@ -73,19 +175,35 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
firstName: formData.get("firstName") as string,
lastName: formData.get("lastName") as string,
active: formData.get("active") === "on",
email: formData.get("email"),
employmentStart: formData.get("employmentStart"),
trackingStart: formData.get("trackingStart"),
};
setFormError("");
startTransition(async () => {
const res = await updateEducator(editingEducator.id, data);
if (res.error) {
toast.error(res.error);
setFormError(res.error);
} else {
toast.success("Daten aktualisiert.");
toast.success(res.message ?? "Daten aktualisiert.");
if (res.warning) toast.warning(res.warning, { duration: 12000 });
setEditingEducator(null);
router.refresh();
}
});
};
const handleResend = (educator: EducatorDto) => {
startTransition(async () => {
const res = await resendEducatorInvitation(educator.id);
if (res.error) toast.error(res.error);
else if (res.warning) toast.warning(res.warning, { duration: 12000 });
else toast.success(res.message ?? "Einladung erneuert.");
router.refresh();
});
};
const handleDelete = (educator: EducatorDto) => {
startTransition(async () => {
const res = await deleteEducator(educator.id);
@@ -103,24 +221,44 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="relative min-w-72 max-[760px]:w-full max-[760px]:min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Name suchen" className="pl-9" />
<Input
placeholder="Name oder E-Mail suchen"
aria-label="Team durchsuchen"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Dialog open={openCreate} onOpenChange={setOpenCreate}>
<Dialog
open={openCreate}
onOpenChange={(open) => {
if (!isPending) {
setOpenCreate(open);
setFormError("");
}
}}
>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4 [stroke-width:1.7]" />
ErzieherIn anlegen
</Button>
</DialogTrigger>
<DialogContent>
<form action={handleCreate}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<form
onSubmit={(event) => {
event.preventDefault();
handleCreate(new FormData(event.currentTarget));
}}
>
<DialogHeader>
<DialogTitle>Neue(n) ErzieherIn anlegen</DialogTitle>
<DialogDescription>
Erfasse die Stammdaten des Kita-Personals.
Lege Stammdaten, Personalprofil und App-Zugang gemeinsam an.
Die Fachkraft erhält ihren Aktivierungslink per E-Mail.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<fieldset disabled={isPending} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="firstName">Vorname</Label>
<Input id="firstName" name="firstName" required />
@@ -129,21 +267,30 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
<Label htmlFor="lastName">Nachname</Label>
<Input id="lastName" name="lastName" required />
</div>
<AccessFields prefix="create" today={today} />
<div className="flex items-center gap-2 mt-2">
<Switch id="active" name="active" defaultChecked />
<Label htmlFor="active">Aktiv (Arbeitet aktuell hier)</Label>
</div>
</div>
</fieldset>
{formError && (
<p role="alert" className="mb-4 text-sm text-[var(--danger)]">
{formError}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => setOpenCreate(false)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
Speichern
{isPending
? "Wird angelegt …"
: "Fachkraft anlegen und einladen"}
</Button>
</DialogFooter>
</form>
@@ -169,14 +316,15 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
<TableRow>
<TableHead className="pl-6">Vorname</TableHead>
<TableHead>Nachname</TableHead>
<TableHead>E-Mail / App-Zugang</TableHead>
<TableHead>Status</TableHead>
<TableHead className="pr-6 text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{educators.length === 0 ? (
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center">
<TableCell colSpan={5} className="h-32 text-center">
<div className="flex max-h-[240px] min-h-[200px] flex-col items-center justify-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Plus className="h-6 w-6 [stroke-width:1.7]" />
@@ -191,12 +339,38 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
</TableCell>
</TableRow>
) : (
educators.map((ed) => (
filtered.map((ed) => (
<TableRow key={ed.id}>
<TableCell className="pl-6 font-medium">
{ed.firstName}
</TableCell>
<TableCell>{ed.lastName}</TableCell>
<TableCell>
<p className="mb-1 text-sm">
{ed.email ?? "Noch keine E-Mail hinterlegt"}
</p>
<Badge variant={ed.verified ? "success" : "secondary"}>
{ed.verified
? "Account aktiviert"
: ed.inviteFailed
? "Mailversand fehlgeschlagen"
: ed.email
? "Einladung ausstehend"
: "Zugang fehlt"}
</Badge>
{ed.email && !ed.verified && (
<Button
variant="ghost"
size="sm"
disabled={isPending}
onClick={() => handleResend(ed)}
aria-label={`Einladung für ${ed.firstName} ${ed.lastName} erneut senden`}
className="mt-1 block"
>
Einladung erneut senden
</Button>
)}
</TableCell>
<TableCell>
<Badge variant={ed.active ? "success" : "secondary"}>
{ed.active ? "Aktiv" : "Inaktiv"}
@@ -218,7 +392,12 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
<Button
variant="ghost"
size="icon"
onClick={() => setEditingEducator(ed)}
onClick={() => {
setFormError("");
setEditingEducator(ed);
}}
disabled={isPending}
aria-label={`${ed.firstName} ${ed.lastName} bearbeiten`}
>
<Pencil className="h-4 w-4 [stroke-width:1.7]" />
</Button>
@@ -244,15 +423,22 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
{/* Edit Dialog */}
<Dialog
open={!!editingEducator}
onOpenChange={(open) => !open && setEditingEducator(null)}
onOpenChange={(open) => {
if (!open && !isPending) setEditingEducator(null);
}}
>
<DialogContent>
<form action={handleUpdate}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<form
onSubmit={(event) => {
event.preventDefault();
handleUpdate(new FormData(event.currentTarget));
}}
>
<DialogHeader>
<DialogTitle>ErzieherIn bearbeiten</DialogTitle>
</DialogHeader>
{editingEducator && (
<div className="grid gap-4 py-4">
<fieldset disabled={isPending} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-firstName">Vorname</Label>
<Input
@@ -271,6 +457,11 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
required
/>
</div>
<AccessFields
prefix="edit"
today={today}
educator={editingEducator}
/>
<div className="flex items-center gap-2 mt-2">
<Switch
id="edit-active"
@@ -279,18 +470,28 @@ export function ErzieherList({ educators }: { educators: EducatorDto[] }) {
/>
<Label htmlFor="edit-active">Aktiv</Label>
</div>
</div>
</fieldset>
)}
{formError && (
<p role="alert" className="mb-4 text-sm text-[var(--danger)]">
{formError}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => setEditingEducator(null)}
>
Abbrechen
</Button>
<Button type="submit" disabled={isPending}>
Aktualisieren
{isPending
? "Wird gespeichert …"
: editingEducator?.email
? "Aktualisieren"
: "Speichern und einladen"}
</Button>
</DialogFooter>
</form>
+11 -62
View File
@@ -6,74 +6,23 @@ import { z } from "zod";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { staffAction } from "../personal/actions";
const educatorSchema = z.object({
firstName: z.string().min(1, "Vorname ist erforderlich").max(100).trim(),
lastName: z.string().min(1, "Nachname ist erforderlich").max(100).trim(),
active: z.boolean().default(true),
});
// The staff service creates the person, profile, account and mail job in one transaction.
export async function createEducator(rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.create({
data: {
kitaId,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Anlegen:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
return staffAction("educatorCreate", parsed.data);
}
export async function updateEducator(id: string, rawPayload: unknown) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const parsed = z.record(z.string(), z.unknown()).safeParse(rawPayload);
if (!parsed.success) return { error: "Ungültige Eingabedaten." };
return staffAction("educatorUpdate", { ...parsed.data, educatorId: id });
}
const parsed = educatorSchema.safeParse(rawPayload);
if (!parsed.success) {
return { error: "Ungültige Eingabedaten." };
}
try {
await prisma.educator.update({
where: {
id,
kitaId,
},
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
active: parsed.data.active,
},
});
revalidatePath("/dashboard/erzieher");
return { success: true };
} catch (error) {
console.error("Fehler beim Aktualisieren:", error);
return { error: "Ein Fehler ist aufgetreten." };
}
export async function resendEducatorInvitation(educatorId: string) {
return staffAction("educatorInvite", { educatorId });
}
export async function deleteEducator(id: string) {
+23 -1
View File
@@ -3,6 +3,7 @@ import { UserRole } from "@prisma/client";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { todayKey } from "@/lib/staff/calculations";
import { ErzieherList } from "./_components/erzieher-list";
export const metadata = { title: "ErzieherInnen · Kita-Planer" };
@@ -17,6 +18,18 @@ export default async function ErzieherPage() {
firstName: true,
lastName: true,
active: true,
staffProfile: {
select: {
id: true,
user: { select: { email: true, emailVerifiedAt: true } },
emails: {
where: { inviteToken: { not: null }, skippedAt: null },
orderBy: { createdAt: "desc" },
take: 1,
select: { failed: true },
},
},
},
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
});
@@ -51,7 +64,16 @@ export default async function ErzieherPage() {
</div>
</header>
<ErzieherList educators={educators} />
<ErzieherList
today={todayKey()}
educators={educators.map(({ staffProfile, ...educator }) => ({
...educator,
hasProfile: !!staffProfile,
email: staffProfile?.user?.email ?? null,
verified: !!staffProfile?.user?.emailVerifiedAt,
inviteFailed: !!staffProfile?.emails[0]?.failed,
}))}
/>
</div>
);
}
+2
View File
@@ -7,6 +7,7 @@ import { executeStaffCommand } from "@/lib/staff/service";
import { deliverStaffEmails } from "@/lib/staff/email";
export type StaffActionResult = {
educatorId?: string;
error?: string;
success?: boolean;
invitePath?: string;
@@ -50,6 +51,7 @@ export async function staffAction(
// Returned only to the authenticated admin who generated this single-use link.
return {
success: true,
educatorId: result.educatorId,
warning,
message,
...(result.invite
+2 -2
View File
@@ -634,8 +634,8 @@ export function SettingsPanel({
Link kopieren
</Button>
<p className="text-xs">
Bitte persönlich an die Fachkraft weitergeben. Es wird keine
E-Mail versendet.
Der Link wird per E-Mail versendet. Bei Bedarf kannst du ihn
zusätzlich persönlich an die Fachkraft weitergeben.
</p>
</div>
)}
+221 -70
View File
@@ -28,11 +28,123 @@ export type StaffNotice = {
toAdmins: boolean;
};
type CommandResult = {
educatorId?: string;
emailIds?: string[];
notice?: StaffNotice;
invite?: { email: string; name: string; token: string; kitaName: string };
};
const idSchema = z.string().min(1).max(100);
const educatorInput = z.object({
firstName: z.string().trim().min(1, "Vorname ist erforderlich.").max(100),
lastName: z.string().trim().min(1, "Nachname ist erforderlich.").max(100),
active: z.boolean().default(true),
email: z
.string()
.trim()
.max(254)
.email("Bitte eine gültige E-Mail-Adresse angeben.")
.toLowerCase(),
});
const onboardingDates = z
.object({
employmentStart: dateSchema,
trackingStart: dateSchema,
})
.refine((input) => input.trackingStart >= input.employmentStart, {
message:
"Der Start der Zeiterfassung darf nicht vor dem Beschäftigungsbeginn liegen.",
});
async function inviteStaff(
tx: Prisma.TransactionClient,
actor: StaffActor,
profile: {
id: string;
kitaId: string;
userId: string | null;
user: { email: string; emailVerifiedAt: Date | null } | null;
educator: { firstName: string; lastName: string };
},
email: string,
): Promise<CommandResult> {
assertStaffAccess(actor, profile, true);
if (
profile.userId &&
(profile.user?.email !== email || profile.user.emailVerifiedAt)
)
throw new Error(
"Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.",
);
const existingUser = await tx.user.findUnique({ where: { email } });
if (
existingUser &&
(existingUser.kitaId !== actor.kitaId ||
existingUser.role !== UserRole.ERZIEHER ||
(await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profile.id } },
})))
)
throw new Error(
"Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.",
);
const user =
existingUser ??
(await tx.user.create({
data: {
email,
firstName: profile.educator.firstName,
lastName: profile.educator.lastName,
passwordHash: "",
role: UserRole.ERZIEHER,
kitaId: actor.kitaId,
},
}));
await tx.staffProfile.update({
where: { id: profile.id },
data: { userId: user.id },
});
await tx.staffHistory.create({
data: {
profileId: profile.id,
actorId: actor.id,
action: "Account verknüpft",
details: email,
},
});
if (user.emailVerifiedAt) return {};
const token = randomUUID();
await tx.verificationToken.deleteMany({ where: { identifier: user.id } });
await tx.verificationToken.create({
data: {
identifier: user.id,
token,
expires: new Date(Date.now() + 7 * 86400000),
},
});
const kita = await tx.kita.findUniqueOrThrow({
where: { id: actor.kitaId },
select: { name: true },
});
const job = await tx.staffEmail.create({
data: {
profileId: profile.id,
userId: user.id,
subject: `Einladung zu ${kita.name} im Kita-Planer`,
message: "Account aktivieren",
path: `/invite/${token}`,
inviteToken: token,
},
});
return {
emailIds: [job.id],
invite: {
email,
name: `${profile.educator.firstName} ${profile.educator.lastName}`,
token,
kitaName: kita.name,
},
};
}
const reasonSchema = z
.string()
.trim()
@@ -215,6 +327,114 @@ export async function executeStaffCommand(
throw new Error(
"Dein Konto hat keinen Zugriff auf den Personalbereich.",
);
if (
["educatorCreate", "educatorUpdate", "educatorInvite"].includes(
command,
)
) {
requireAdmin(actor);
if (command === "educatorInvite") {
const educator = await tx.educator.findFirst({
where: {
id: idSchema.parse(data.educatorId),
kitaId: actor.kitaId,
},
include: {
staffProfile: { include: { user: true, educator: true } },
},
});
if (!educator?.staffProfile?.user)
throw new Error(
"Bitte zuerst E-Mail-Adresse und Personalprofil ergänzen.",
);
return inviteStaff(
tx,
actor,
educator.staffProfile,
educator.staffProfile.user.email,
);
}
const input = educatorInput.parse(data);
const existing =
command === "educatorUpdate"
? await tx.educator.findFirst({
where: {
id: idSchema.parse(data.educatorId),
kitaId: actor.kitaId,
},
include: { staffProfile: { include: { user: true } } },
})
: null;
if (command === "educatorUpdate" && !existing)
throw new Error("Fachkraft nicht gefunden.");
if (
existing?.staffProfile?.user &&
existing.staffProfile.user.email !== input.email
)
throw new Error(
"Die Adresse eines verknüpften Accounts kann hier nicht geändert werden.",
);
if (existing?.staffProfile)
assertStaffAccess(actor, existing.staffProfile, true);
// Dates are only supplied when creating the profile. Existing balances and dates stay intact.
const dates = !existing?.staffProfile
? onboardingDates.parse(data)
: null;
const values = {
firstName: input.firstName,
lastName: input.lastName,
active: input.active,
};
const educator = existing
? await tx.educator.update({
where: { id: existing.id },
data: values,
})
: await tx.educator.create({
data: { ...values, kitaId: actor.kitaId },
});
const profile =
existing?.staffProfile ??
(await tx.staffProfile.create({
data: {
kitaId: actor.kitaId,
educatorId: educator.id,
employmentStart: dateOnly(dates!.employmentStart),
trackingStart: dateOnly(dates!.trackingStart),
},
include: { user: true },
}));
await tx.staffHistory.create({
data: {
profileId: profile.id,
actorId: actor.id,
action: existing ? "Stammdaten geändert" : "Fachkraft angelegt",
details: `${educator.firstName} ${educator.lastName}${dates ? ` · Beschäftigung ${dates.employmentStart}, Erfassung ${dates.trackingStart}, Anfangssaldo 0 Min.` : ""}`,
},
});
if (
profile.user &&
(profile.user.kitaId !== actor.kitaId ||
profile.user.role !== UserRole.ERZIEHER)
)
throw new Error(
"Der verknüpfte Account gehört nicht zum Fachpersonal dieser Kita.",
);
if (profile.userId) {
await tx.user.update({
where: { id: profile.userId },
data: { firstName: input.firstName, lastName: input.lastName },
});
return { educatorId: educator.id };
}
const invitation = await inviteStaff(
tx,
actor,
{ ...profile, educator },
input.email,
);
return { ...invitation, educatorId: educator.id };
}
if (command === "emailPreference") {
if (actor.role !== UserRole.ERZIEHER)
throw new Error(
@@ -445,76 +665,7 @@ export async function executeStaffCommand(
.email()
.toLowerCase()
.parse(data.email);
if (profile.userId) {
if (profile.user?.email !== email || profile.user.emailVerifiedAt)
throw new Error(
"Dieses Profil besitzt bereits einen Account. Offene Aktivierungslinks können nur für dieselbe Adresse erneuert werden.",
);
}
const existingUser = await tx.user.findUnique({ where: { email } });
if (
existingUser &&
(existingUser.kitaId !== actor.kitaId ||
existingUser.role !== UserRole.ERZIEHER ||
(await tx.staffProfile.count({
where: { userId: existingUser.id, id: { not: profileId } },
})))
)
throw new Error(
"Diese Adresse ist bereits einem anderen Konto oder Profil zugeordnet.",
);
const user =
existingUser ??
(await tx.user.create({
data: {
email,
firstName: profile.educator.firstName,
lastName: profile.educator.lastName,
passwordHash: "",
role: UserRole.ERZIEHER,
kitaId: actor.kitaId,
},
}));
await tx.staffProfile.update({
where: { id: profileId },
data: { userId: user.id },
});
await history("Account verknüpft", email);
if (user.emailVerifiedAt) return {};
const token = randomUUID();
await tx.verificationToken.deleteMany({
where: { identifier: user.id },
});
await tx.verificationToken.create({
data: {
identifier: user.id,
token,
expires: new Date(Date.now() + 7 * 86400000),
},
});
const kita = await tx.kita.findUniqueOrThrow({
where: { id: actor.kitaId },
select: { name: true },
});
const job = await tx.staffEmail.create({
data: {
profileId,
userId: user.id,
subject: `Einladung zu ${kita.name} im Kita-Planer`,
message: "Account aktivieren",
path: `/invite/${token}`,
inviteToken: token,
},
});
return {
emailIds: [job.id],
invite: {
email,
name: `${profile.educator.firstName} ${profile.educator.lastName}`,
token,
kitaName: kita.name,
},
};
return inviteStaff(tx, actor, profile, email);
}
if (command === "pattern") {
requireAdmin(actor);