Files
Epicnabbo-Catalogus-Updated…/src/actions/events.ts
T
SimoandCursor ed7db6e048 feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills.

Co-authored-by: Cursor <[email protected]>
2026-07-21 21:08:33 +02:00

258 lines
7.4 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { adminAction, authAction } from "@/lib/safe-action";
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
import { logAudit } from "@/lib/services/audit";
import {
createEventSchema,
eventPrizeSchema,
eventTypeSchema,
eventWinnerSchema,
registerForEventSchema,
updateEventSchema,
} from "@/lib/validators/event";
// ── Event Types ─────────────────────────────────────────────────────
export const createEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
async (ctx) => {
const eventType = await prisma.websiteEventType.create({
data: ctx.data,
});
logAudit({
userId: ctx.session.user.id,
action: "event_type_create",
target: "WebsiteEventType",
targetId: eventType.id,
after: { name: eventType.name },
});
return actionOk({ id: eventType.id });
},
);
const updateEventTypeInput = eventTypeSchema.partial().extend({
id: z.coerce.number().int().positive(),
});
export const updateEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
async (ctx) => {
const { id, ...data } = ctx.data;
const existing = await prisma.websiteEventType.findUnique({
where: { id },
});
if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.update({ where: { id }, data });
logAudit({
userId: ctx.session.user.id,
action: "event_type_update",
target: "WebsiteEventType",
targetId: id,
before: { name: existing.name },
after: data,
});
return actionOk({ id });
},
);
const deleteEventTypeInput = z.object({
id: z.coerce.number().int().positive(),
});
export const deleteEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
async (ctx) => {
const existing = await prisma.websiteEventType.findUnique({
where: { id: ctx.data.id },
});
if (!existing) throw new ActionError("Event type not found");
await prisma.websiteEventType.delete({ where: { id: ctx.data.id } });
logAudit({
userId: ctx.session.user.id,
action: "event_type_delete",
target: "WebsiteEventType",
targetId: ctx.data.id,
before: { name: existing.name },
});
return actionOk();
},
);
// ── Events ──────────────────────────────────────────────────────────
export const createEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
async (ctx) => {
const event = await prisma.websiteEvent.create({
data: {
...ctx.data,
hostUserId: Number(ctx.session.user.id),
},
});
logAudit({
userId: ctx.session.user.id,
action: "event_create",
target: "WebsiteEvent",
targetId: event.id,
after: { title: event.title },
});
return actionOk({ id: event.id });
},
);
const updateEventInput = updateEventSchema.extend({
id: z.coerce.number().int().positive(),
});
export const updateEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
async (ctx) => {
const { id, ...data } = ctx.data;
const existing = await prisma.websiteEvent.findUnique({ where: { id } });
if (!existing) throw new ActionError("Event not found");
await prisma.websiteEvent.update({ where: { id }, data });
logAudit({
userId: ctx.session.user.id,
action: "event_update",
target: "WebsiteEvent",
targetId: id,
before: { title: existing.title, status: existing.status },
after: data,
});
return actionOk({ id });
},
);
const deleteEventInput = z.object({
id: z.coerce.number().int().positive(),
});
export const deleteEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
async (ctx) => {
const existing = await prisma.websiteEvent.findUnique({
where: { id: ctx.data.id },
});
if (!existing) throw new ActionError("Event not found");
await prisma.websiteEvent.delete({ where: { id: ctx.data.id } });
logAudit({
userId: ctx.session.user.id,
action: "event_delete",
target: "WebsiteEvent",
targetId: ctx.data.id,
before: { title: existing.title },
});
return actionOk();
},
);
// ── Prizes ──────────────────────────────────────────────────────────
export const addEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
async (ctx) => {
const prize = await prisma.websiteEventPrize.create({ data: ctx.data });
return actionOk({ id: prize.id });
},
);
const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
export const deleteEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
async (ctx) => {
await prisma.websiteEventPrize.delete({ where: { id: ctx.data.id } });
return actionOk();
},
);
// ── Winners ─────────────────────────────────────────────────────────
export const addEventWinner = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
async (ctx) => {
const winner = await prisma.websiteEventWinner.create({ data: ctx.data });
logAudit({
userId: ctx.session.user.id,
action: "event_winner_add",
target: "WebsiteEventWinner",
targetId: winner.id,
after: {
eventId: ctx.data.eventId,
userId: ctx.data.userId,
position: ctx.data.position,
},
});
return actionOk({ id: winner.id });
},
);
// ── Public site: register ───────────────────────────────────────────
export const registerForEvent = authAction(
{
schema: registerForEventSchema,
rateLimitKey: "event-register",
rateLimitMax: 10,
rateLimitWindowMs: 60_000,
},
async (ctx) => {
const userId = Number(ctx.session.user.id);
if (!Number.isInteger(userId) || userId <= 0) {
return actionError("Unauthorized");
}
const event = await prisma.websiteEvent.findUnique({
where: { id: ctx.data.eventId },
include: {
type: true,
_count: { select: { registrations: true } },
},
});
if (!event) return actionError("Event not found");
if (event.status !== "published") {
return actionError("This event is not open for registration");
}
if (event.endsAt && event.endsAt.getTime() < Date.now()) {
return actionError("This event has already ended");
}
if (event.type.minRank > 0) {
const rank = Number(ctx.session.user.rank ?? 0);
if (rank < event.type.minRank) {
return actionError("Your rank is too low to join this event");
}
}
if (
event.maxPlayers != null &&
event._count.registrations >= event.maxPlayers
) {
return actionError("This event is full");
}
const existing = await prisma.websiteEventRegistration.findUnique({
where: {
eventId_userId: { eventId: event.id, userId },
},
});
if (existing) return actionError("You are already registered");
await prisma.websiteEventRegistration.create({
data: { eventId: event.id, userId },
});
revalidatePath("/events");
revalidatePath(`/events/${event.id}`);
return actionOk({ eventId: event.id });
},
);