"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): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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."); }); }