From f422bb4a0b0ff88bae4e7fd2dfcdde581efb022b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 12 Jul 2026 15:27:07 +0200 Subject: [PATCH] feat: add admin content module actions --- src/actions/banners.ts | 70 ++++++++++++++ src/actions/events.ts | 185 ++++++++++++++++++++++++++++++++++++ src/actions/polls.ts | 107 +++++++++++++++++++++ src/actions/prefixes.ts | 142 +++++++++++++++++++++++++++ src/lib/validators/event.ts | 61 ++++++++++++ src/lib/validators/poll.ts | 31 ++++++ 6 files changed, 596 insertions(+) create mode 100644 src/actions/banners.ts create mode 100644 src/actions/events.ts create mode 100644 src/actions/polls.ts create mode 100644 src/actions/prefixes.ts create mode 100644 src/lib/validators/event.ts create mode 100644 src/lib/validators/poll.ts diff --git a/src/actions/banners.ts b/src/actions/banners.ts new file mode 100644 index 0000000000..2e122afb9f --- /dev/null +++ b/src/actions/banners.ts @@ -0,0 +1,70 @@ +'use server' + +import { z } from 'zod' +import { PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' +import { adminAction } from '@/lib/safe-action' +import { ActionError, actionOk } from '@/lib/safe-action-shared' +import { logAudit } from '@/lib/services/audit' + +const bannerSchema = z.object({ + title: z.string().min(1).max(255), + subtitle: z.string().max(500).optional().default(''), + image: z.string().max(500), + link: z.string().max(500).optional().default(''), + color: z.string().max(20).optional().default(''), + isActive: z.coerce.number().int().min(0).max(1).default(1), + sortOrder: z.coerce.number().int().min(0).default(0), + startDate: z.string().max(50).nullable().optional(), + endDate: z.string().max(50).nullable().optional(), +}) + +export const createBanner = adminAction( + { permission: PERMS.BANNERS_EDIT, schema: bannerSchema }, + async (ctx) => { + const banner = await prisma.websiteBanner.create({ data: ctx.data }) + logAudit({ + userId: ctx.session.user.id, + action: 'banner_create', + target: 'WebsiteBanner', + targetId: banner.id, + after: { title: banner.title }, + }) + return actionOk({ id: banner.id }) + }, +) + +const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() }) + +export const updateBanner = adminAction( + { permission: PERMS.BANNERS_EDIT, schema: updateBannerInput }, + async (ctx) => { + const { id, ...data } = ctx.data + const existing = await prisma.websiteBanner.findUnique({ where: { id } }) + if (!existing) throw new ActionError('Banner not found') + await prisma.websiteBanner.update({ where: { id }, data }) + logAudit({ + userId: ctx.session.user.id, + action: 'banner_update', + target: 'WebsiteBanner', + targetId: id, + }) + return actionOk({ id }) + }, +) + +const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() }) + +export const deleteBanner = adminAction( + { permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput }, + async (ctx) => { + await prisma.websiteBanner.delete({ where: { id: ctx.data.id } }) + logAudit({ + userId: ctx.session.user.id, + action: 'banner_delete', + target: 'WebsiteBanner', + targetId: ctx.data.id, + }) + return actionOk() + }, +) diff --git a/src/actions/events.ts b/src/actions/events.ts new file mode 100644 index 0000000000..c6d832ab90 --- /dev/null +++ b/src/actions/events.ts @@ -0,0 +1,185 @@ +'use server' + +import { z } from 'zod' +import { PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' +import { adminAction } from '@/lib/safe-action' +import { ActionError, actionOk } from '@/lib/safe-action-shared' +import { logAudit } from '@/lib/services/audit' +import { + createEventSchema, + eventPrizeSchema, + eventTypeSchema, + eventWinnerSchema, + 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 }) + }, +) diff --git a/src/actions/polls.ts b/src/actions/polls.ts new file mode 100644 index 0000000000..aa16a06ab9 --- /dev/null +++ b/src/actions/polls.ts @@ -0,0 +1,107 @@ +'use server' + +import { z } from 'zod' +import { PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' +import { adminAction } from '@/lib/safe-action' +import { ActionError, actionOk } from '@/lib/safe-action-shared' +import { logAudit } from '@/lib/services/audit' +import { createPollSchema, pollQuestionSchema, updatePollSchema } from '@/lib/validators/poll' + +// ── Polls ─────────────────────────────────────────────────────────── + +export const createPoll = adminAction( + { permission: PERMS.POLLS_EDIT, schema: createPollSchema }, + async (ctx) => { + const poll = await prisma.websitePoll.create({ data: ctx.data }) + logAudit({ + userId: ctx.session.user.id, + action: 'poll_create', + target: 'WebsitePoll', + targetId: poll.id, + after: { title: poll.title }, + }) + return actionOk({ id: poll.id }) + }, +) + +const updatePollInput = updatePollSchema.extend({ + id: z.coerce.number().int().positive(), +}) + +export const updatePoll = adminAction( + { permission: PERMS.POLLS_EDIT, schema: updatePollInput }, + async (ctx) => { + const { id, ...data } = ctx.data + const existing = await prisma.websitePoll.findUnique({ where: { id } }) + if (!existing) throw new ActionError('Poll not found') + + await prisma.websitePoll.update({ where: { id }, data }) + logAudit({ + userId: ctx.session.user.id, + action: 'poll_update', + target: 'WebsitePoll', + targetId: id, + before: { title: existing.title, status: existing.status }, + after: data, + }) + return actionOk({ id }) + }, +) + +const deletePollInput = z.object({ + id: z.coerce.number().int().positive(), +}) + +export const deletePoll = adminAction( + { permission: PERMS.POLLS_EDIT, schema: deletePollInput }, + async (ctx) => { + const existing = await prisma.websitePoll.findUnique({ where: { id: ctx.data.id } }) + if (!existing) throw new ActionError('Poll not found') + + await prisma.websitePoll.delete({ where: { id: ctx.data.id } }) + logAudit({ + userId: ctx.session.user.id, + action: 'poll_delete', + target: 'WebsitePoll', + targetId: ctx.data.id, + before: { title: existing.title }, + }) + return actionOk() + }, +) + +// ── Questions ─────────────────────────────────────────────────────── + +export const addPollQuestion = adminAction( + { permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema }, + async (ctx) => { + const question = await prisma.websitePollQuestion.create({ data: ctx.data }) + return actionOk({ id: question.id }) + }, +) + +const updateQuestionInput = pollQuestionSchema.partial().extend({ + id: z.coerce.number().int().positive(), +}) + +export const updatePollQuestion = adminAction( + { permission: PERMS.POLLS_EDIT, schema: updateQuestionInput }, + async (ctx) => { + const { id, ...data } = ctx.data + await prisma.websitePollQuestion.update({ where: { id }, data }) + return actionOk({ id }) + }, +) + +const deleteQuestionInput = z.object({ + id: z.coerce.number().int().positive(), +}) + +export const deletePollQuestion = adminAction( + { permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput }, + async (ctx) => { + await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } }) + return actionOk() + }, +) diff --git a/src/actions/prefixes.ts b/src/actions/prefixes.ts new file mode 100644 index 0000000000..640eca7d02 --- /dev/null +++ b/src/actions/prefixes.ts @@ -0,0 +1,142 @@ +'use server' + +import { z } from 'zod' +import { PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' +import { adminAction } from '@/lib/safe-action' +import { ActionError, actionOk } from '@/lib/safe-action-shared' + +// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings +// are not represented in prisma/schema.prisma yet — we use raw queries with +// tagged template literals, which parameterize all interpolated values. + +// ── Create prefix ─────────────────────────────────────────────────── + +const createPrefixSchema = z.object({ + username: z.string().min(1), + text: z.string().min(1), + color: z.string().min(1), + icon: z.string().optional(), + effect: z.string().optional(), + active: z.coerce.number().int().min(0).max(1).default(1), +}) + +export const createPrefix = adminAction( + { permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema }, + async (ctx) => { + const { username, text, color, icon, effect, active } = ctx.data + + const users = await prisma.$queryRaw<{ id: number }[]>` + SELECT id FROM users WHERE username = ${username} LIMIT 1 + ` + if (users.length === 0) throw new ActionError('User not found') + + await prisma.$executeRaw` + INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active) + VALUES (${users[0].id}, ${text}, ${color}, ${icon || ''}, ${effect || ''}, ${active}) + ` + + return actionOk() + }, +) + +// ── Update prefix ─────────────────────────────────────────────────── + +const updatePrefixSchema = z.object({ + id: z.coerce.number().int().positive(), + text: z.string().min(1), + color: z.string().min(1), + icon: z.string().optional(), + effect: z.string().optional(), + active: z.coerce.number().int().min(0).max(1).optional(), +}) + +export const updatePrefix = adminAction( + { permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema }, + async (ctx) => { + const { id, text, color, icon, effect, active } = ctx.data + + await prisma.$executeRaw` + UPDATE custom_prefixes + SET text = ${text}, color = ${color}, icon = ${icon || ''}, effect = ${effect || ''}, active = ${active ?? 1} + WHERE id = ${id} + ` + + return actionOk() + }, +) + +// ── Delete prefix ─────────────────────────────────────────────────── + +const deletePrefixSchema = z.object({ + id: z.coerce.number().int().positive(), +}) + +export const deletePrefix = adminAction( + { permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema }, + async (ctx) => { + await prisma.$executeRaw`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}` + return actionOk() + }, +) + +// ── Add blacklist word ────────────────────────────────────────────── + +const addBlacklistWordSchema = z.object({ + word: z.string().min(1).max(100), +}) + +export const addBlacklistWord = adminAction( + { permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema }, + async (ctx) => { + await prisma.$executeRaw` + INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()}) + ` + return actionOk() + }, +) + +// ── Remove blacklist word ─────────────────────────────────────────── + +const removeBlacklistWordSchema = z.object({ + id: z.coerce.number().int().positive(), +}) + +export const removeBlacklistWord = adminAction( + { permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema }, + async (ctx) => { + await prisma.$executeRaw`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}` + return actionOk() + }, +) + +// ── Update prefix settings ────────────────────────────────────────── + +const SETTINGS_WHITELIST = new Set([ + 'enabled', + 'max_length', + 'min_rank', + 'allow_colors', + 'allow_bold', + 'allow_italic', + 'default_color', +]) + +const updatePrefixSettingsSchema = z.object({ + settings: z.record(z.string(), z.string()), +}) + +export const updatePrefixSettings = adminAction( + { permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema }, + async (ctx) => { + for (const [key, value] of Object.entries(ctx.data.settings)) { + if (!SETTINGS_WHITELIST.has(key)) continue + await prisma.$executeRaw` + INSERT INTO custom_prefix_settings (\`key\`, \`value\`) + VALUES (${key}, ${value}) + ON DUPLICATE KEY UPDATE \`value\` = ${value} + ` + } + return actionOk() + }, +) diff --git a/src/lib/validators/event.ts b/src/lib/validators/event.ts new file mode 100644 index 0000000000..8c2ac002eb --- /dev/null +++ b/src/lib/validators/event.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' + +/** Convert empty strings to undefined so optional/nullable fields pass validation */ +const emptyToUndefined = (v: unknown) => (v === '' ? undefined : v) + +export const eventTypeSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + slug: z + .string() + .min(1) + .max(100) + .regex(/^[a-z0-9-]+$/, 'Slug must be lowercase with hyphens'), + description: z.string().max(500).optional().default(''), + color: z.string().max(20).optional().default('#3b82f6'), + icon: z.string().max(50).optional().default('calendar'), + isActive: z.coerce.number().int().min(0).max(1).default(1), + minRank: z.coerce.number().int().min(0).max(7).default(0), +}) + +export const createEventSchema = z.object({ + title: z.string().min(1, 'Title is required').max(255), + description: z.string().min(1, 'Description is required'), + typeId: z.coerce.number().int().positive(), + roomId: z.preprocess(emptyToUndefined, z.coerce.number().int().positive().nullable().optional()), + startsAt: z.coerce.date(), + endsAt: z.preprocess(emptyToUndefined, z.coerce.date().nullable().optional()), + maxPlayers: z.preprocess( + emptyToUndefined, + z.coerce.number().int().positive().nullable().optional(), + ), + isRecurring: z.coerce.number().int().min(0).max(1).default(0), + recurrenceRule: z.preprocess(emptyToUndefined, z.string().max(255).nullable().optional()), + status: z.enum(['draft', 'published', 'cancelled', 'completed']).default('draft'), + image: z.preprocess(emptyToUndefined, z.string().max(500).nullable().optional()), +}) + +export const updateEventSchema = createEventSchema.partial() + +export const eventPrizeSchema = z.object({ + eventId: z.coerce.number().int().positive(), + position: z.coerce.number().int().positive().default(1), + prizeType: z.enum(['badge', 'credits', 'pixels', 'points', 'item']).default('badge'), + badgeCode: z.string().max(50).nullable().optional(), + credits: z.coerce.number().int().min(0).default(0), + pixels: z.coerce.number().int().min(0).default(0), + points: z.coerce.number().int().min(0).default(0), + itemId: z.coerce.number().int().positive().nullable().optional(), + description: z.string().max(255).optional().default(''), +}) + +export const eventWinnerSchema = z.object({ + eventId: z.coerce.number().int().positive(), + userId: z.coerce.number().int().positive(), + position: z.coerce.number().int().positive().default(1), +}) + +export type EventTypeInput = z.infer +export type CreateEventInput = z.infer +export type UpdateEventInput = z.infer +export type EventPrizeInput = z.infer +export type EventWinnerInput = z.infer diff --git a/src/lib/validators/poll.ts b/src/lib/validators/poll.ts new file mode 100644 index 0000000000..8cabeb3ade --- /dev/null +++ b/src/lib/validators/poll.ts @@ -0,0 +1,31 @@ +import { z } from 'zod' + +export const createPollSchema = z.object({ + title: z.string().min(1, 'Title is required').max(255), + description: z.string().max(2000).nullable().optional(), + status: z.enum(['draft', 'active', 'closed']).default('draft'), + showResults: z.coerce.number().int().min(0).max(1).default(1), + multipleChoice: z.coerce.number().int().min(0).max(1).default(0), + startsAt: z.coerce.date().nullable().optional(), + endsAt: z.coerce.date().nullable().optional(), +}) + +export const updatePollSchema = createPollSchema.partial() + +export const pollQuestionSchema = z.object({ + pollId: z.coerce.number().int().positive(), + question: z.string().min(1).max(500), + type: z.enum(['single', 'multiple', 'text']).default('single'), + sortOrder: z.coerce.number().int().min(0).default(0), + options: z.string().min(1, 'Options are required'), +}) + +export const pollVoteSchema = z.object({ + questionId: z.coerce.number().int().positive(), + answer: z.string().min(1).max(500), +}) + +export type CreatePollInput = z.infer +export type UpdatePollInput = z.infer +export type PollQuestionInput = z.infer +export type PollVoteInput = z.infer