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>
123 lines
6.3 KiB
TypeScript
123 lines
6.3 KiB
TypeScript
"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.");
|
|
});
|
|
}
|