feat: Betreuung, Kinderprofile und Aufnahmeverwaltung implementieren

Dashboard mit Abholslots und Filter-Scrolling, Anwesenheit mit Audit-Verlauf sowie Elternanmeldung, Warteliste und bestätigte Aufnahme ergänzen.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul SchoeneckandCopilot committed 2026-11-03 08:01:25 +01:00
1 parent 10af6159b4
commit c1513c040b
36 files changed
+2593

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import type { PoolClient } from "pg";
import { db } from "@/lib/db";
import { requireStaff } from "@/lib/auth";
import { applicationSchema, idSchema, roundSchema, slotSchema, type ActionResult } from "@/lib/validation";
import { InputError } from "@/lib/corrections";
import { acceptApplication, drawRound, invitationHash, newInvitation, submitApplication } from "@/lib/admissions";
import { localClock } from "@/lib/time";
async function transaction(work: (client: PoolClient) => Promise<void>): Promise<ActionResult> {
let client: PoolClient | undefined;
try {
client = await db.connect();
await client.query("BEGIN");
await work(client);
await client.query("COMMIT");
} catch (error) {
if (client) await client.query("ROLLBACK");
if (error instanceof InputError) return { error: error.message };
console.error("Aufnahme / Slot speichern fehlgeschlagen", error);
return { error: "Speichern fehlgeschlagen. Bitte erneut versuchen." };
} finally { client?.release(); }
revalidatePath("/admissions");
revalidatePath("/slots");
revalidatePath("/");
return { success: true };
}
export async function createRound(input: unknown): Promise<ActionResult & { code?: string; roundId?: string }> {
const staff = await requireStaff();
const parsed = roundSchema.safeParse(input);
if (!parsed.success) return { error: parsed.error.issues[0].message };
if (parsed.data.expiresOn < localClock().day) return { error: "Die Anmeldefrist darf nicht in der Vergangenheit liegen." };
const code = newInvitation();
let roundId: string | undefined;
const result = await transaction(async (client) => {
const round = await client.query<{ id: string }>("INSERT INTO admission_rounds(name, code_hash, expires_on, created_by) VALUES ($1, $2, $3, $4) RETURNING id",
[parsed.data.name, invitationHash(code), parsed.data.expiresOn, staff.id]);
roundId = round.rows[0].id;
});
return result.error ? result : { ...result, code, roundId };
}
export async function rotateInvitation(roundId: unknown): Promise<ActionResult & { code?: string }> {
await requireStaff();
const parsed = idSchema.safeParse(roundId);
if (!parsed.success) return { error: "Ungültige Aufnahmerunde." };
const code = newInvitation();
const result = await transaction(async (client) => {
const updated = await client.query("UPDATE admission_rounds SET code_hash = $2 WHERE id = $1 AND status = 'open' RETURNING id",
[parsed.data, invitationHash(code)]);
if (!updated.rowCount) throw new InputError("Nur offene Runden können einen neuen Einladungscode erhalten.");
});
return result.error ? result : { ...result, code };
}
export async function closeRound(roundId: unknown): Promise<ActionResult> {
await requireStaff();
const parsed = idSchema.safeParse(roundId);
if (!parsed.success) return { error: "Ungültige Aufnahmerunde." };
return transaction(async (client) => {
const result = await client.query("UPDATE admission_rounds SET status = 'closed' WHERE id = $1 AND status = 'open' RETURNING id", [parsed.data]);
if (!result.rowCount) throw new InputError("Diese Runde ist nicht mehr offen. Bitte neu laden.");
});
}
export async function runDraw(input: unknown): Promise<ActionResult> {
const staff = await requireStaff();
const parsed = z.object({ roundId: idSchema, places: z.number().int().min(1).max(10000) }).safeParse(input);
if (!parsed.success) return { error: "Bitte eine gültige Platzzahl zwischen 1 und 10.000 eingeben." };
return transaction((client) => drawRound(client, parsed.data.roundId, parsed.data.places, staff.id));
}
export async function confirmAdmission(applicationId: unknown): Promise<ActionResult> {
const staff = await requireStaff();
const parsed = idSchema.safeParse(applicationId);
if (!parsed.success) return { error: "Ungültige Anmeldung." };
return transaction((client) => acceptApplication(client, parsed.data, staff.id));
}
export async function registerChild(input: unknown): Promise<ActionResult> {
const parsed = applicationSchema.safeParse(input);
if (!parsed.success) return { error: parsed.error.issues[0].message };
try {
await db.query("DELETE FROM application_limits WHERE window_start < now() - interval '1 day'");
const limit = await db.query<{ attempts: number }>(`INSERT INTO application_limits(key_hash, attempts, window_start)
VALUES ($1, 1, now()) ON CONFLICT(key_hash) DO UPDATE SET
attempts = CASE WHEN application_limits.window_start < now() - interval '1 hour' THEN 1 ELSE application_limits.attempts + 1 END,
window_start = CASE WHEN application_limits.window_start < now() - interval '1 hour' THEN now() ELSE application_limits.window_start END RETURNING attempts`,
[invitationHash(`${parsed.data.code}:${parsed.data.email}`)]);
if (limit.rows[0].attempts > 10) return { error: "Zu viele Anmeldungen. Bitte in einer Stunde erneut versuchen." };
} catch (error) {
console.error("Anmeldelimit prüfen fehlgeschlagen", error);
return { error: "Die Anmeldung ist gerade nicht erreichbar. Bitte später erneut versuchen." };
}
return transaction((client) => submitApplication(client, parsed.data));
}
export async function addSlot(input: unknown): Promise<ActionResult> {
await requireStaff();
const parsed = slotSchema.safeParse(input);
if (!parsed.success) return { error: parsed.error.issues[0].message };
return transaction(async (client) => {
const result = await client.query(`INSERT INTO pickup_slots(starts_at, ends_at) VALUES ($1, $2)
ON CONFLICT(starts_at, ends_at) DO UPDATE SET active = true WHERE NOT pickup_slots.active RETURNING id`,
[parsed.data.start, parsed.data.end]);
if (!result.rowCount) throw new InputError("Dieses Abholfenster existiert bereits.");
});
}
export async function toggleSlot(input: unknown): Promise<ActionResult> {
await requireStaff();
const parsed = z.object({ id: idSchema, active: z.boolean() }).safeParse(input);
if (!parsed.success) return { error: "Ungültiger Abholslot." };
return transaction(async (client) => {
const result = await client.query("UPDATE pickup_slots SET active = $2 WHERE id = $1 RETURNING id", [parsed.data.id, parsed.data.active]);
if (!result.rowCount) throw new InputError("Abholslot nicht gefunden.");
});
}