Add niche admin + public expansions + self-host Nunito font (batches 3-4)

Built via two more parallel agent workflows (read schema -> return files),
integrated + verified.

Admin: /admin/emulator (emulator_settings + emulator_texts key/value editor),
/admin/badges (give-badge via RCON), /admin/rare-values (CRUD), /admin/housekeeping
(CRUD), /admin/email-templates (CRUD), /admin/photos (moderation).
Public: /rares (+[category]), /leaderboard (credits/diamonds/duckets),
/guilds (+[id] members), /redeem (voucher -> sendCurrency).
Expanded: /u/[username] now shows badges + photos + guestbook (post form);
/news/[slug] now shows reactions + comments (comment form). Reactions tallied
in JS (Prisma groupBy typing avoided).
Self-hosted Nunito via next/font/google wired to --font-nunito (the atom theme
font, no runtime external fetch). Header + admin nav extended.

Verified: tsc exit 0, vitest 48/48, next build exit 0.
This commit is contained in:
Simo committed 2026-06-28 13:44:23 +02:00
1 parent e96b606e1e
commit e8be0461d8
28 files changed
+2347 -5

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
export async function giveBadge(formData: FormData): Promise<void> {
await requireStaff();
const userId = Number(formData.get("userId"));
const code = String(formData.get("code") ?? "").trim().slice(0, 32);
if (!(userId > 0) || code.length === 0) return;
// Fire the emulator command so the badge appears live for online users.
await rcon.giveBadge(userId, code);
// Persist the badge directly so it survives a relog / offline grant.
// users_badges has no unique (user_id, badge_code) constraint, so guard
// against duplicates and compute the next free slot ourselves.
try {
const existing = await prisma.usersBadges.findFirst({
where: { userId, badgeCode: code },
select: { id: true },
});
if (!existing) {
const max = await prisma.usersBadges.aggregate({
where: { userId },
_max: { slotId: true },
});
const slotId = (max._max.slotId ?? 0) + 1;
await prisma.usersBadges.create({
data: { userId, slotId, badgeCode: code },
});
}
} catch {
// Best-effort: the RCON grant already succeeded for online users.
}
revalidatePath("/admin/badges");
}
+68
View File
@@ -0,0 +1,68 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function createEmailTemplate(formData: FormData): Promise<void> {
await requireStaff();
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const subject = String(formData.get("subject") ?? "").trim().slice(0, 255);
const body = String(formData.get("body") ?? "");
const variablesRaw = String(formData.get("variables") ?? "").trim();
const isActive = formData.get("isActive") != null;
if (!name || !subject || !body) return;
await prisma.emailTemplates.create({
data: {
name,
subject,
body,
variables: variablesRaw || null,
isActive,
},
});
revalidatePath("/admin/email-templates");
}
export async function updateEmailTemplate(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
const subject = String(formData.get("subject") ?? "").trim().slice(0, 255);
const body = String(formData.get("body") ?? "");
const variablesRaw = String(formData.get("variables") ?? "").trim();
const isActive = formData.get("isActive") != null;
if (!subject || !body) return;
await prisma.emailTemplates.update({
where: { id },
data: {
subject,
body,
variables: variablesRaw || null,
isActive,
},
});
revalidatePath("/admin/email-templates");
}
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
let id: bigint;
try {
id = BigInt(raw);
} catch {
return;
}
await prisma.emailTemplates.delete({ where: { id } });
revalidatePath("/admin/email-templates");
}
+36
View File
@@ -0,0 +1,36 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
// emulator_settings: PK is the string column `key`, payload is `value` (VarChar 512).
// emulator_texts: PK is the string column `key`, payload is `value` (VarChar 4096).
// Both tables are emulator-owned; we only ever read/update existing rows or add new
// keys via upsert. We never migrate or drop them.
export async function updateEmulatorSetting(formData: FormData): Promise<void> {
await requireStaff();
const key = String(formData.get("key") ?? "").trim().slice(0, 100);
const value = String(formData.get("value") ?? "").slice(0, 512);
if (!key) return;
await prisma.emulatorSettings.upsert({
where: { key },
update: { value },
create: { key, value },
});
revalidatePath("/admin/emulator");
}
export async function updateEmulatorText(formData: FormData): Promise<void> {
await requireStaff();
const key = String(formData.get("key") ?? "").trim().slice(0, 100);
const value = String(formData.get("value") ?? "").slice(0, 4096);
if (!key) return;
await prisma.emulatorTexts.upsert({
where: { key },
update: { value },
create: { key, value },
});
revalidatePath("/admin/emulator");
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
/**
* Create or update a housekeeping permission (keyed by the unique `permission`
* string). Mirrors AtomCMS' housekeeping permission management.
*/
export async function upsertPermission(formData: FormData): Promise<void> {
await requireStaff();
const permission = String(formData.get("permission") ?? "").trim().slice(0, 255);
const minRank = Number(formData.get("minRank"));
const descriptionRaw = String(formData.get("description") ?? "").trim().slice(0, 255);
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
if (!permission || !Number.isFinite(minRank) || minRank < 0) return;
try {
await prisma.websiteHousekeepingPermissions.upsert({
where: { permission },
update: { minRank, description },
create: { permission, minRank, description },
});
} catch {
// Swallow: duplicate/constraint issues shouldn't crash the action.
}
revalidatePath("/admin/housekeeping");
}
export async function deletePermission(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!raw) return;
try {
await prisma.websiteHousekeepingPermissions.delete({ where: { id: BigInt(raw) } });
} catch {
// Already gone / invalid id.
}
revalidatePath("/admin/housekeeping");
}
+19
View File
@@ -0,0 +1,19 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function deletePhoto(formData: FormData): Promise<void> {
await requireStaff();
const id = Number(formData.get("id"));
if (!(id > 0)) return;
try {
await prisma.cameraWeb.delete({ where: { id } });
} catch {
// Record may have already been removed; ignore.
}
revalidatePath("/admin/photos");
}
+88
View File
@@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireStaff } from "@/lib/admin/guard";
import { prisma } from "@/lib/prisma";
export async function createCategory(formData: FormData): Promise<void> {
await requireStaff();
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const badge = String(formData.get("badge") ?? "").trim().slice(0, 255);
const priorityRaw = Number(formData.get("priority"));
const priority = Number.isFinite(priorityRaw) && priorityRaw > 0 ? Math.floor(priorityRaw) : 1;
if (!name || !badge) return;
try {
await prisma.websiteRareValueCategories.create({
data: { name, badge, priority },
});
} catch {
// Unique name collision or DB error — ignore, page will re-render unchanged.
}
revalidatePath("/admin/rare-values");
}
export async function deleteCategory(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
try {
// Remove the category's values first to avoid orphaned rows.
await prisma.websiteRareValues.deleteMany({ where: { categoryId: id } });
await prisma.websiteRareValueCategories.delete({ where: { id } });
} catch {
// Not found or DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
export async function createValue(formData: FormData): Promise<void> {
await requireStaff();
const categoryRaw = String(formData.get("categoryId") ?? "");
if (!/^\d+$/.test(categoryRaw)) return;
const categoryId = BigInt(categoryRaw);
const name = String(formData.get("name") ?? "").trim().slice(0, 255);
const furnitureIcon = String(formData.get("furnitureIcon") ?? "").trim().slice(0, 255);
if (!name || !furnitureIcon) return;
const itemIdRaw = Number(formData.get("itemId"));
const itemId = Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
const creditValueRaw = String(formData.get("creditValue") ?? "").trim().slice(0, 255);
const currencyValueRaw = String(formData.get("currencyValue") ?? "").trim().slice(0, 255);
const currencyType = String(formData.get("currencyType") ?? "diamonds").trim().slice(0, 255) || "diamonds";
try {
await prisma.websiteRareValues.create({
data: {
categoryId,
itemId,
name,
creditValue: creditValueRaw || null,
currencyValue: currencyValueRaw || null,
currencyType,
furnitureIcon,
},
});
} catch {
// DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
export async function deleteValue(formData: FormData): Promise<void> {
await requireStaff();
const raw = String(formData.get("id") ?? "");
if (!/^\d+$/.test(raw)) return;
const id = BigInt(raw);
try {
await prisma.websiteRareValues.delete({ where: { id } });
} catch {
// Not found or DB error — ignore.
}
revalidatePath("/admin/rare-values");
}
+62
View File
@@ -0,0 +1,62 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// website_article_comments.comment is VARCHAR(255); keep the write within bounds.
const COMMENT_MAX = 255;
/**
* Post a comment on a news article as the SIGNED-IN user. The author id is read
* from the session (re-fetched via auth()), never from the submitted FormData,
* so a crafted form cannot post as another account. The articleId comes from the
* form and is validated as a BigInt (website_articles.id is UNSIGNED BIGINT).
*/
export async function postComment(formData: FormData): Promise<void> {
const session = await auth();
if (!session?.user?.id) return;
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) return;
const comment = String(formData.get("comment") ?? "").trim().slice(0, COMMENT_MAX);
if (!comment) return;
const articleIdRaw = String(formData.get("articleId") ?? "").trim();
if (!/^\d+$/.test(articleIdRaw)) return;
let articleId: bigint;
try {
articleId = BigInt(articleIdRaw);
} catch {
return;
}
let slug: string | null = null;
try {
// Confirm the article exists (and grab its slug for revalidation).
const article = await prisma.websiteArticles.findUnique({
where: { id: articleId },
select: { slug: true },
});
if (!article) return;
slug = article.slug;
const now = new Date();
await prisma.websiteArticleComments.create({
data: {
articleId,
userId,
comment,
createdAt: now,
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
if (slug) revalidatePath(`/news/${slug}`);
}
+50
View File
@@ -0,0 +1,50 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Emulator/CMS column message is VARCHAR(255); keep the write within bounds.
const MESSAGE_MAX = 255;
/**
* Post a guestbook entry on a profile.
*
* The AUTHOR (userId) is re-read from the session via auth() and is never
* trusted from the submitted FormData, so a crafted form cannot impersonate
* another account. Only the PROFILE OWNER id (whose guestbook is written) is
* taken from the form, and we resolve a profile username from the form purely
* to revalidate the right page.
*/
export async function postGuestbook(formData: FormData): Promise<void> {
const session = await auth();
const userId = Number(session?.user?.id);
if (!Number.isInteger(userId) || userId <= 0) return;
const profileId = Number(formData.get("profileId"));
if (!Number.isInteger(profileId) || profileId <= 0) return;
const message = String(formData.get("message") ?? "").trim().slice(0, MESSAGE_MAX);
if (!message) return;
// Optional: used only to revalidate the correct profile route.
const username = String(formData.get("username") ?? "").trim();
const now = new Date();
try {
await prisma.websiteUserGuestbooks.create({
data: {
profileId,
userId,
message,
createdAt: now,
updatedAt: now,
},
});
} catch {
// DB unavailable — fail soft; nothing to persist.
return;
}
if (username) revalidatePath(`/u/${username}`);
}
+125
View File
@@ -0,0 +1,125 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { rcon } from "@/lib/services/rcon";
import { sendCurrency } from "@/lib/services/send-currency";
/** Feedback returned to the <RedeemForm/> client component via useActionState. */
export type RedeemState = { ok: boolean; message: string } | null;
/**
* Redeem a shop voucher for the SIGNED-IN user. Faithful to AtomCMS's
* ShopVoucherController:
* - the user id is re-read from the session (auth()), NEVER from FormData,
* so a crafted form cannot redeem on another account;
* - a code that is missing or expired is rejected;
* - each voucher may be redeemed once per user (website_used_shop_vouchers);
* - on success the reward `amount` is granted, the used-row is inserted,
* use_count is incremented, and the voucher is expired once max_uses is hit.
*
* The reward is delivered through sendCurrency({ rcon, db: prisma }); the
* voucher schema carries a single `amount`, granted as the website credits
* wallet currency.
*/
export async function redeem(
_prev: RedeemState,
formData: FormData,
): Promise<RedeemState> {
const session = await auth();
if (!session?.user?.id) {
return { ok: false, message: "You must be signed in to redeem a voucher." };
}
const userId = Number(session.user.id);
if (!Number.isFinite(userId)) {
return { ok: false, message: "Your session is invalid. Please sign in again." };
}
const code = String(formData.get("code") ?? "").trim();
if (!code) {
return { ok: false, message: "Please enter a voucher code." };
}
// Look up the code (website_shop_vouchers.code is unique).
let voucher: {
id: bigint;
amount: number;
maxUses: number;
useCount: number;
expiresAt: Date | null;
} | null = null;
try {
voucher = await prisma.websiteShopVouchers.findUnique({
where: { code },
select: { id: true, amount: true, maxUses: true, useCount: true, expiresAt: true },
});
} catch {
return { ok: false, message: "We couldn't reach the server. Please try again." };
}
// Not found OR already expired -> generic "no active voucher" (matches AtomCMS).
if (!voucher || (voucher.expiresAt && voucher.expiresAt.getTime() <= Date.now())) {
return { ok: false, message: "No active voucher with the given code was found." };
}
// One redemption per user.
try {
const already = await prisma.websiteUsedShopVouchers.findFirst({
where: { userId, voucherId: voucher.id },
select: { id: true },
});
if (already) {
return { ok: false, message: "You can only use each shop voucher once." };
}
} catch {
return { ok: false, message: "We couldn't reach the server. Please try again." };
}
// Record the redemption first so a successful grant can never be double-claimed.
try {
await prisma.websiteUsedShopVouchers.create({
data: { userId, voucherId: voucher.id },
});
} catch {
// Most likely a race (another tab redeemed it) — treat as already used.
return { ok: false, message: "You can only use each shop voucher once." };
}
// Grant the reward. The voucher carries a single amount, delivered as credits.
try {
await sendCurrency({ rcon, db: prisma }, userId, "credits", voucher.amount);
} catch {
// sendCurrency already falls back to a direct DB write; if it still throws,
// the used-row stands and the balance simply wasn't credited — surface that.
return {
ok: false,
message: "Your voucher was accepted but the reward could not be delivered. Contact staff.",
};
}
// Bump use_count and expire the voucher once the cap is reached.
try {
const updated = await prisma.websiteShopVouchers.update({
where: { id: voucher.id },
data: { useCount: { increment: 1 } },
select: { maxUses: true, useCount: true },
});
if (updated.maxUses && updated.useCount >= updated.maxUses) {
await prisma.websiteShopVouchers.update({
where: { id: voucher.id },
data: { expiresAt: new Date() },
});
}
} catch {
// Reward already delivered; the counter bump is best-effort.
}
revalidatePath("/redeem");
return {
ok: true,
message: `Success! Your balance has been increased by ${voucher.amount.toLocaleString()} credits.`,
};
}