This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+68
-68
@@ -3,88 +3,88 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
||||
// table; it only stores an image URL rendered in the site layout/widgets.
|
||||
|
||||
export async function createAd(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!image) return;
|
||||
const staff = await requireStaff();
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!image) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const ad = await prisma.websiteAds.create({
|
||||
data: { image, createdAt: now, updatedAt: now },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_create",
|
||||
description: `Created advertisement #${ad.id} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(ad.id),
|
||||
});
|
||||
} catch {
|
||||
// DB error — page re-renders unchanged.
|
||||
revalidatePath("/admin/ads");
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
const now = new Date();
|
||||
try {
|
||||
const ad = await prisma.websiteAds.create({
|
||||
data: { image, createdAt: now, updatedAt: now },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_create",
|
||||
description: `Created advertisement #${ad.id} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(ad.id),
|
||||
});
|
||||
} catch {
|
||||
// DB error — page re-renders unchanged.
|
||||
revalidatePath("/admin/ads");
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
|
||||
export async function updateAd(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!/^\d+$/.test(raw)) return;
|
||||
const id = BigInt(raw);
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!image) return;
|
||||
const staff = await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!/^\d+$/.test(raw)) return;
|
||||
const id = BigInt(raw);
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!image) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteAds.update({
|
||||
where: { id },
|
||||
data: { image, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_update",
|
||||
description: `Updated advertisement #${id} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
revalidatePath(`/admin/ads/${id}`);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
try {
|
||||
await prisma.websiteAds.update({
|
||||
where: { id },
|
||||
data: { image, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_update",
|
||||
description: `Updated advertisement #${id} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
revalidatePath(`/admin/ads/${id}`);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
|
||||
export async function deleteAd(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
const staff = await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_delete",
|
||||
description: `Deleted advertisement #${id}`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
try {
|
||||
await prisma.websiteAds.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_delete",
|
||||
description: `Deleted advertisement #${id}`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
+13
-13
@@ -11,20 +11,20 @@ import { rcon } from "@/lib/services/rcon";
|
||||
* `message` payload. Staff-gated; the message is trimmed/bounded before send.
|
||||
*/
|
||||
export async function sendHotelAlert(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
await requireStaff();
|
||||
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 1000);
|
||||
if (!message) return;
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 1000);
|
||||
if (!message) return;
|
||||
|
||||
try {
|
||||
await rcon.send("hotelalert", { message });
|
||||
} catch {
|
||||
// Best-effort delivery (dead socket / emulator offline) — never 500 the
|
||||
// admin page. The emulator writes its own alert_logs row on receipt.
|
||||
}
|
||||
try {
|
||||
await rcon.send("hotelalert", { message });
|
||||
} catch {
|
||||
// Best-effort delivery (dead socket / emulator offline) — never 500 the
|
||||
// admin page. The emulator writes its own alert_logs row on receipt.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/alerts");
|
||||
revalidatePath("/admin/alerts");
|
||||
}
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteStaffApplications.delete({ where: { id } });
|
||||
} catch {
|
||||
// already gone / no DB — nothing to do
|
||||
}
|
||||
try {
|
||||
await prisma.websiteStaffApplications.delete({ where: { id } });
|
||||
} catch {
|
||||
// already gone / no DB — nothing to do
|
||||
}
|
||||
|
||||
revalidatePath("/admin/applications");
|
||||
revalidatePath("/admin/applications");
|
||||
}
|
||||
@@ -2,101 +2,107 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
async function uniqueSlug(title: string): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
while (await prisma.websiteArticles.findUnique({ where: { slug }, select: { id: true } })) {
|
||||
slug = `${base}-${n++}`;
|
||||
}
|
||||
return slug;
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
while (
|
||||
await prisma.websiteArticles.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
})
|
||||
) {
|
||||
slug = `${base}-${n++}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export async function createArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const shortStory = String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const fullStory = String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!title) return;
|
||||
const staff = await requireStaff();
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const shortStory = String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const fullStory = String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
await prisma.websiteArticles.create({
|
||||
data: {
|
||||
slug: await uniqueSlug(title),
|
||||
title: title.slice(0, 255),
|
||||
shortStory: shortStory.slice(0, 255),
|
||||
fullStory,
|
||||
image: image.slice(0, 255),
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
redirect("/admin/articles/new?error=Database error while creating article. Please try again.");
|
||||
}
|
||||
redirect("/admin/articles");
|
||||
try {
|
||||
const now = new Date();
|
||||
await prisma.websiteArticles.create({
|
||||
data: {
|
||||
slug: await uniqueSlug(title),
|
||||
title: title.slice(0, 255),
|
||||
shortStory: shortStory.slice(0, 255),
|
||||
fullStory,
|
||||
image: image.slice(0, 255),
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
redirect(
|
||||
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
||||
);
|
||||
}
|
||||
redirect("/admin/articles");
|
||||
}
|
||||
|
||||
export async function updateArticle(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
try {
|
||||
await prisma.websiteArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
shortStory: String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
fullStory: String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
image: String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Update failed");
|
||||
}
|
||||
revalidatePath(`/admin/articles/${id}`);
|
||||
redirect("/admin/articles");
|
||||
await requireStaff();
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
try {
|
||||
await prisma.websiteArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
shortStory: String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
fullStory: String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
image: String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Update failed");
|
||||
}
|
||||
revalidatePath(`/admin/articles/${id}`);
|
||||
redirect("/admin/articles");
|
||||
}
|
||||
|
||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }),
|
||||
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }),
|
||||
prisma.websiteArticles.delete({ where: { id } }),
|
||||
]);
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Delete failed");
|
||||
}
|
||||
redirect("/admin/articles");
|
||||
await requireStaff();
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.websiteArticleReactions.deleteMany({ where: { articleId: id } }),
|
||||
prisma.websiteArticleComments.deleteMany({ where: { articleId: id } }),
|
||||
prisma.websiteArticles.delete({ where: { id } }),
|
||||
]);
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Delete failed");
|
||||
}
|
||||
redirect("/admin/articles");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import path from "node:path";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
@@ -16,58 +16,58 @@ const MAX_BYTES = 1024 * 1024; // 1MB
|
||||
const ALLOWED_TYPES = new Set(["image/gif", "image/png"]);
|
||||
|
||||
function back(param: string, value: string): never {
|
||||
redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`);
|
||||
redirect(`/admin/badges?${param}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
|
||||
export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const dir = process.env.BADGE_UPLOAD_DIR;
|
||||
if (!dir) {
|
||||
back("error", "Badge upload directory not configured");
|
||||
}
|
||||
const dir = process.env.BADGE_UPLOAD_DIR;
|
||||
if (!dir) {
|
||||
back("error", "Badge upload directory not configured");
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!CODE_RE.test(code)) {
|
||||
back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)");
|
||||
}
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!CODE_RE.test(code)) {
|
||||
back("error", "Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)");
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
back("error", "No file uploaded");
|
||||
}
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
back("error", "No file uploaded");
|
||||
}
|
||||
|
||||
if (file.size === 0) {
|
||||
back("error", "Uploaded file is empty");
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
back("error", "File too large (max 1MB)");
|
||||
}
|
||||
if (!ALLOWED_TYPES.has(file.type)) {
|
||||
back("error", "File must be a GIF or PNG image");
|
||||
}
|
||||
if (file.size === 0) {
|
||||
back("error", "Uploaded file is empty");
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
back("error", "File too large (max 1MB)");
|
||||
}
|
||||
if (!ALLOWED_TYPES.has(file.type)) {
|
||||
back("error", "File must be a GIF or PNG image");
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const baseDir = path.resolve(dir);
|
||||
const target = path.resolve(baseDir, `${code}.gif`);
|
||||
if (!target.startsWith(baseDir + path.sep)) {
|
||||
back("error", "Invalid path");
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(target, buffer);
|
||||
} catch {
|
||||
back("error", "Could not write the badge file to disk");
|
||||
}
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const baseDir = path.resolve(dir);
|
||||
const target = path.resolve(baseDir, `${code}.gif`);
|
||||
if (!target.startsWith(baseDir + path.sep)) {
|
||||
back("error", "Invalid path");
|
||||
}
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(target, buffer);
|
||||
} catch {
|
||||
back("error", "Could not write the badge file to disk");
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "badge_upload",
|
||||
description: `Uploaded badge image "${code}.gif"`,
|
||||
targetType: "badge",
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "badge_upload",
|
||||
description: `Uploaded badge image "${code}.gif"`,
|
||||
targetType: "badge",
|
||||
});
|
||||
|
||||
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
|
||||
redirect(`/admin/badges?uploaded=${encodeURIComponent(code)}`);
|
||||
}
|
||||
+31
-31
@@ -6,39 +6,39 @@ import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export async function giveBadge(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
await requireStaff();
|
||||
|
||||
const userId = Number(formData.get("userId"));
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 32);
|
||||
if (!(userId > 0) || code.length === 0) return;
|
||||
const userId = Number(formData.get("userId"));
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.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);
|
||||
// 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.
|
||||
}
|
||||
// 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");
|
||||
revalidatePath("/admin/badges");
|
||||
}
|
||||
+57
-51
@@ -1,71 +1,77 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { $Enums } from "@/generated/prisma/client";
|
||||
import { requireStaffRateLimited as requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import type { $Enums } from "@/generated/prisma/client";
|
||||
|
||||
type BanType = $Enums.bans_type;
|
||||
const BAN_TYPES: ReadonlySet<string> = new Set(["account", "ip", "machine", "super"]);
|
||||
const BAN_TYPES: ReadonlySet<string> = new Set([
|
||||
"account",
|
||||
"ip",
|
||||
"machine",
|
||||
"super",
|
||||
]);
|
||||
const PERMANENT_SECONDS = 100 * 365 * 24 * 3600;
|
||||
|
||||
export async function createBan(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const reason =
|
||||
String(formData.get("reason") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 200) || "Banned";
|
||||
const hours = Number(formData.get("hours"));
|
||||
const type = String(formData.get("type"));
|
||||
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
||||
const staff = await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const reason =
|
||||
String(formData.get("reason") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 200) || "Banned";
|
||||
const hours = Number(formData.get("hours"));
|
||||
const type = String(formData.get("type"));
|
||||
if (!(userId > 0) || !BAN_TYPES.has(type)) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const banExpire = hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const banExpire =
|
||||
hours > 0 ? now + Math.floor(hours) * 3600 : now + PERMANENT_SECONDS;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true },
|
||||
});
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { username: true },
|
||||
});
|
||||
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type as BanType,
|
||||
cfhTopic: -1,
|
||||
},
|
||||
});
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type as BanType,
|
||||
cfhTopic: -1,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) await rcon.disconnectUser(userId, user.username);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "user_ban",
|
||||
description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
revalidatePath("/admin/bans");
|
||||
if (user) await rcon.disconnectUser(userId, user.username);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "user_ban",
|
||||
description: `Banned user #${userId} (${type}, ${hours > 0 ? `${hours}h` : "permanent"}): ${reason}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
revalidatePath("/admin/bans");
|
||||
}
|
||||
|
||||
export async function liftBan(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = Number(formData.get("id"));
|
||||
if (id > 0) {
|
||||
await prisma.ban.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ban_lift",
|
||||
description: `Lifted ban #${id}`,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/bans");
|
||||
const staff = await requireStaff();
|
||||
const id = Number(formData.get("id"));
|
||||
if (id > 0) {
|
||||
await prisma.ban.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ban_lift",
|
||||
description: `Lifted ban #${id}`,
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/bans");
|
||||
}
|
||||
@@ -2,75 +2,75 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const body = String(formData.get("body") ?? "").normalize("NFC");
|
||||
const variablesRaw = String(formData.get("variables") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const isActive = formData.get("isActive") != null;
|
||||
if (!name || !subject || !body) return;
|
||||
await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const body = String(formData.get("body") ?? "").normalize("NFC");
|
||||
const variablesRaw = String(formData.get("variables") ?? "")
|
||||
.normalize("NFC")
|
||||
.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");
|
||||
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") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
let id: bigint;
|
||||
try {
|
||||
id = BigInt(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const body = String(formData.get("body") ?? "").normalize("NFC");
|
||||
const variablesRaw = String(formData.get("variables") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const isActive = formData.get("isActive") != null;
|
||||
if (!subject || !body) return;
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
let id: bigint;
|
||||
try {
|
||||
id = BigInt(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const body = String(formData.get("body") ?? "").normalize("NFC");
|
||||
const variablesRaw = String(formData.get("variables") ?? "")
|
||||
.normalize("NFC")
|
||||
.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");
|
||||
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 id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
await prisma.emailTemplates.delete({ where: { id } });
|
||||
revalidatePath("/admin/email-templates");
|
||||
await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
await prisma.emailTemplates.delete({ where: { id } });
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
@@ -10,37 +10,37 @@ import { prisma } from "@/lib/prisma";
|
||||
// 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") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
const value = String(formData.get("value") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 512);
|
||||
if (!key) return;
|
||||
await prisma.emulatorSettings.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
revalidatePath("/admin/emulator");
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
const value = String(formData.get("value") ?? "")
|
||||
.normalize("NFC")
|
||||
.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") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
const value = String(formData.get("value") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 4096);
|
||||
if (!key) return;
|
||||
await prisma.emulatorTexts.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
revalidatePath("/admin/emulator");
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 100);
|
||||
const value = String(formData.get("value") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 4096);
|
||||
if (!key) return;
|
||||
await prisma.emulatorTexts.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
revalidatePath("/admin/emulator");
|
||||
}
|
||||
+141
-139
@@ -3,165 +3,167 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
|
||||
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
|
||||
// is a titled content block with an optional image and call-to-action button.
|
||||
|
||||
function parsePosition(value: FormDataEntryValue | null): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
|
||||
}
|
||||
|
||||
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!name || !content) return;
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!name || !content) return;
|
||||
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonColor =
|
||||
String(formData.get("buttonColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
String(formData.get("buttonBorderColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#facc15";
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonColor =
|
||||
String(formData.get("buttonColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
String(formData.get("buttonBorderColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#facc15";
|
||||
|
||||
try {
|
||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
||||
data: {
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
imageUrl: imageUrl || null,
|
||||
buttonText: buttonText || null,
|
||||
buttonUrl: buttonUrl || null,
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_create",
|
||||
description: `Created help-center entry #${entry.id} (${name})`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(entry.id),
|
||||
});
|
||||
} catch {
|
||||
// Unique name collision or DB error — re-render unchanged with error.
|
||||
revalidatePath("/admin/help-questions");
|
||||
redirect("/admin/help-questions/new?error=Unique name collision or database error. Please try again.");
|
||||
}
|
||||
revalidatePath("/admin/help-questions");
|
||||
redirect("/admin/help-questions");
|
||||
try {
|
||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
||||
data: {
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
imageUrl: imageUrl || null,
|
||||
buttonText: buttonText || null,
|
||||
buttonUrl: buttonUrl || null,
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_create",
|
||||
description: `Created help-center entry #${entry.id} (${name})`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(entry.id),
|
||||
});
|
||||
} catch {
|
||||
// Unique name collision or DB error — re-render unchanged with error.
|
||||
revalidatePath("/admin/help-questions");
|
||||
redirect(
|
||||
"/admin/help-questions/new?error=Unique name collision or database error. Please try again.",
|
||||
);
|
||||
}
|
||||
revalidatePath("/admin/help-questions");
|
||||
redirect("/admin/help-questions");
|
||||
}
|
||||
|
||||
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
const staff = await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!name || !content) return;
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!name || !content) return;
|
||||
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonColor =
|
||||
String(formData.get("buttonColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
String(formData.get("buttonBorderColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#facc15";
|
||||
const imageUrl = String(formData.get("imageUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonText = String(formData.get("buttonText") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonUrl = String(formData.get("buttonUrl") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const buttonColor =
|
||||
String(formData.get("buttonColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
String(formData.get("buttonBorderColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 16) || "#facc15";
|
||||
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
imageUrl: imageUrl || null,
|
||||
buttonText: buttonText || null,
|
||||
buttonUrl: buttonUrl || null,
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_update",
|
||||
description: `Updated help-center entry #${id} (${name})`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found, unique collision, or DB error — ignore.
|
||||
revalidatePath(`/admin/help-questions/${id}`);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/help-questions");
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
imageUrl: imageUrl || null,
|
||||
buttonText: buttonText || null,
|
||||
buttonUrl: buttonUrl || null,
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_update",
|
||||
description: `Updated help-center entry #${id} (${name})`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found, unique collision, or DB error — ignore.
|
||||
revalidatePath(`/admin/help-questions/${id}`);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/help-questions");
|
||||
}
|
||||
|
||||
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
const staff = await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_delete",
|
||||
description: `Deleted help-center entry #${id}`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
redirect("/admin/help-questions");
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_delete",
|
||||
description: `Deleted help-center entry #${id}`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
redirect("/admin/help-questions");
|
||||
}
|
||||
@@ -9,45 +9,47 @@ import { prisma } from "@/lib/prisma";
|
||||
* string). Mirrors AtomCMS' housekeeping permission management.
|
||||
*/
|
||||
export async function upsertPermission(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
await requireStaff();
|
||||
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = Number(formData.get("minRank"));
|
||||
const descriptionRaw = String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = Number(formData.get("minRank"));
|
||||
const descriptionRaw = String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const description = descriptionRaw.length > 0 ? descriptionRaw : null;
|
||||
|
||||
if (!permission || !Number.isFinite(minRank) || minRank < 0) return;
|
||||
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.
|
||||
}
|
||||
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");
|
||||
revalidatePath("/admin/housekeeping");
|
||||
}
|
||||
|
||||
export async function deletePermission(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
await requireStaff();
|
||||
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteHousekeepingPermissions.delete({ where: { id: BigInt(raw) } });
|
||||
} catch {
|
||||
// Already gone / invalid id.
|
||||
}
|
||||
try {
|
||||
await prisma.websiteHousekeepingPermissions.delete({
|
||||
where: { id: BigInt(raw) },
|
||||
});
|
||||
} catch {
|
||||
// Already gone / invalid id.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/housekeeping");
|
||||
revalidatePath("/admin/housekeeping");
|
||||
}
|
||||
+39
-39
@@ -5,58 +5,58 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
function parseIp(formData: FormData): string {
|
||||
return String(formData.get("ipAddress") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return String(formData.get("ipAddress") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
}
|
||||
|
||||
function parseAsn(formData: FormData): string | null {
|
||||
const asn = String(formData.get("asn") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return asn || null;
|
||||
const asn = String(formData.get("asn") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return asn || null;
|
||||
}
|
||||
|
||||
export async function addWhitelist(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await prisma.websiteIpWhitelist.create({
|
||||
data: { ipAddress, asn, whitelistAsn: asn != null },
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
await requireStaff();
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await prisma.websiteIpWhitelist.create({
|
||||
data: { ipAddress, asn, whitelistAsn: asn != null },
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
|
||||
export async function deleteWhitelist(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
|
||||
revalidatePath("/admin/ip");
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpWhitelist.delete({ where: { id: BigInt(raw) } });
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
|
||||
export async function addBlacklist(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await prisma.websiteIpBlacklist.create({
|
||||
data: { ipAddress, asn, blacklistAsn: asn != null },
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
await requireStaff();
|
||||
const ipAddress = parseIp(formData);
|
||||
if (!ipAddress) return;
|
||||
const asn = parseAsn(formData);
|
||||
await prisma.websiteIpBlacklist.create({
|
||||
data: { ipAddress, asn, blacklistAsn: asn != null },
|
||||
});
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
|
||||
export async function deleteBlacklist(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
|
||||
revalidatePath("/admin/ip");
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return;
|
||||
await prisma.websiteIpBlacklist.delete({ where: { id: BigInt(raw) } });
|
||||
revalidatePath("/admin/ip");
|
||||
}
|
||||
@@ -18,41 +18,44 @@ const KEY_MESSAGE = "maintenance_message";
|
||||
const KEY_MIN_RANK = "min_maintenance_login_rank";
|
||||
|
||||
const COMMENTS: Record<string, string> = {
|
||||
[KEY_ENABLED]: "Determines whether maintenance is enabled or not",
|
||||
[KEY_MESSAGE]: "The maintenance message displayed to users while maintenance is activated",
|
||||
[KEY_MIN_RANK]: "The minimum rank required to login to the hotel during maintenance",
|
||||
[KEY_ENABLED]: "Determines whether maintenance is enabled or not",
|
||||
[KEY_MESSAGE]:
|
||||
"The maintenance message displayed to users while maintenance is activated",
|
||||
[KEY_MIN_RANK]:
|
||||
"The minimum rank required to login to the hotel during maintenance",
|
||||
};
|
||||
|
||||
async function upsertSetting(key: string, value: string): Promise<void> {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
||||
create: { key, value, comment: COMMENTS[key] ?? null },
|
||||
});
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
// eslint-disable-next-line security/detect-object-injection -- key is one of 3 known const values
|
||||
create: { key, value, comment: COMMENTS[key] ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveMaintenance(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
await requireStaff();
|
||||
|
||||
// Checkbox: present only when ticked. Normalise to the '1'/'0' string the
|
||||
// emulator/Laravel side expects.
|
||||
const enabled = formData.get("enabled") != null ? "1" : "0";
|
||||
// Checkbox: present only when ticked. Normalise to the '1'/'0' string the
|
||||
// emulator/Laravel side expects.
|
||||
const enabled = formData.get("enabled") != null ? "1" : "0";
|
||||
|
||||
const message = String(formData.get("message") ?? "").normalize("NFC");
|
||||
const message = String(formData.get("message") ?? "").normalize("NFC");
|
||||
|
||||
// Coerce the rank to a non-negative integer; fall back to AtomCMS's default
|
||||
// of 5 when the field is blank or garbage.
|
||||
const rawRank = String(formData.get("min_rank") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const parsedRank = Number.parseInt(rawRank, 10);
|
||||
const minRank = Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
|
||||
// Coerce the rank to a non-negative integer; fall back to AtomCMS's default
|
||||
// of 5 when the field is blank or garbage.
|
||||
const rawRank = String(formData.get("min_rank") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const parsedRank = Number.parseInt(rawRank, 10);
|
||||
const minRank =
|
||||
Number.isFinite(parsedRank) && parsedRank >= 0 ? parsedRank : 5;
|
||||
|
||||
await upsertSetting(KEY_ENABLED, enabled);
|
||||
await upsertSetting(KEY_MESSAGE, message);
|
||||
await upsertSetting(KEY_MIN_RANK, String(minRank));
|
||||
await upsertSetting(KEY_ENABLED, enabled);
|
||||
await upsertSetting(KEY_MESSAGE, message);
|
||||
await upsertSetting(KEY_MIN_RANK, String(minRank));
|
||||
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/maintenance");
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/maintenance");
|
||||
}
|
||||
+52
-50
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
|
||||
const MEDIA_DIR = "public/assets/images/media";
|
||||
@@ -10,63 +10,65 @@ const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
export async function uploadMedia(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return;
|
||||
if (file.size > MAX_SIZE) return;
|
||||
if (!ALLOWED.includes(file.type)) return;
|
||||
await requireStaff();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return;
|
||||
if (file.size > MAX_SIZE) return;
|
||||
if (!ALLOWED.includes(file.type)) return;
|
||||
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const bytes = await file.arrayBuffer();
|
||||
const filePath = path.resolve(baseDir, name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path");
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const bytes = await file.arrayBuffer();
|
||||
const filePath = path.resolve(baseDir, name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path");
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
}
|
||||
|
||||
export async function deleteMedia(name: string): Promise<void> {
|
||||
await requireStaff();
|
||||
const { unlink } = await import("fs/promises");
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) return;
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
await requireStaff();
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) return;
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
}
|
||||
|
||||
export async function uploadMediaAndReturn(formData: FormData): Promise<string> {
|
||||
await requireStaff();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return "";
|
||||
if (file.size > MAX_SIZE) return "";
|
||||
if (!ALLOWED.includes(file.type)) return "";
|
||||
export async function uploadMediaAndReturn(
|
||||
formData: FormData,
|
||||
): Promise<string> {
|
||||
await requireStaff();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return "";
|
||||
if (file.size > MAX_SIZE) return "";
|
||||
if (!ALLOWED.includes(file.type)) return "";
|
||||
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const bytes = await file.arrayBuffer();
|
||||
const filePath = path.resolve(baseDir, name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) return "";
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const bytes = await file.arrayBuffer();
|
||||
const filePath = path.resolve(baseDir, name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) return "";
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
return `/api/media/${name}`;
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
return `/api/media/${name}`;
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// website_permissions (model WebsitePermissions) is the CMS-owned permission ->
|
||||
// minimum-rank mapping. Editable columns are exactly: permission (unique name),
|
||||
@@ -14,112 +14,121 @@ import { logServerError } from "@/lib/server-log";
|
||||
// created_at/updated_at are managed here.
|
||||
|
||||
function parseMinRank(formData: FormData): number {
|
||||
const n = Number(
|
||||
String(formData.get("minRank") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
);
|
||||
return Number.isInteger(n) && n >= 0 ? n : 1;
|
||||
const n = Number(
|
||||
String(formData.get("minRank") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
);
|
||||
return Number.isInteger(n) && n >= 0 ? n : 1;
|
||||
}
|
||||
|
||||
export async function createPermission(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = parseMinRank(formData);
|
||||
const description =
|
||||
String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || null;
|
||||
if (!permission) return;
|
||||
const staff = await requireStaff();
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = parseMinRank(formData);
|
||||
const description =
|
||||
String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || null;
|
||||
if (!permission) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.websitePermissions.upsert({
|
||||
where: { permission },
|
||||
update: { minRank, description, updatedAt: now },
|
||||
create: { permission, minRank, description, createdAt: now, updatedAt: now },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_create",
|
||||
description: `Saved permission "${permission}" (min rank ${minRank})`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.permission_create_failed", error, { staffId: staff.id, permission });
|
||||
redirect("/admin/permissions?error=save");
|
||||
// ignore (e.g. constraint failure) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
redirect("/admin/permissions?saved=1");
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.websitePermissions.upsert({
|
||||
where: { permission },
|
||||
update: { minRank, description, updatedAt: now },
|
||||
create: {
|
||||
permission,
|
||||
minRank,
|
||||
description,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_create",
|
||||
description: `Saved permission "${permission}" (min rank ${minRank})`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.permission_create_failed", error, {
|
||||
staffId: staff.id,
|
||||
permission,
|
||||
});
|
||||
redirect("/admin/permissions?error=save");
|
||||
// ignore (e.g. constraint failure) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
redirect("/admin/permissions?saved=1");
|
||||
}
|
||||
|
||||
export async function updatePermission(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
const id = BigInt(raw);
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = parseMinRank(formData);
|
||||
const description =
|
||||
String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || null;
|
||||
if (!permission) return;
|
||||
const staff = await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
const id = BigInt(raw);
|
||||
const permission = String(formData.get("permission") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const minRank = parseMinRank(formData);
|
||||
const description =
|
||||
String(formData.get("description") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || null;
|
||||
if (!permission) return;
|
||||
|
||||
try {
|
||||
await prisma.websitePermissions.update({
|
||||
where: { id },
|
||||
data: { permission, minRank, description, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_update",
|
||||
description: `Updated permission #${id} ("${permission}" min rank ${minRank})`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.permission_update_failed", error, {
|
||||
staffId: staff.id,
|
||||
permissionId: String(id),
|
||||
});
|
||||
redirect("/admin/permissions?error=save");
|
||||
// ignore (e.g. duplicate) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
redirect("/admin/permissions?saved=1");
|
||||
try {
|
||||
await prisma.websitePermissions.update({
|
||||
where: { id },
|
||||
data: { permission, minRank, description, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_update",
|
||||
description: `Updated permission #${id} ("${permission}" min rank ${minRank})`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.permission_update_failed", error, {
|
||||
staffId: staff.id,
|
||||
permissionId: String(id),
|
||||
});
|
||||
redirect("/admin/permissions?error=save");
|
||||
// ignore (e.g. duplicate) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
redirect("/admin/permissions?saved=1");
|
||||
}
|
||||
|
||||
export async function deletePermission(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
const staff = await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const deleted = await prisma.websitePermissions.delete({
|
||||
where: { id },
|
||||
select: { permission: true },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_delete",
|
||||
description: `Deleted permission #${id} ("${deleted.permission}")`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.permission_delete_failed", error, {
|
||||
staffId: staff.id,
|
||||
permissionId: String(id),
|
||||
});
|
||||
redirect("/admin/permissions?error=delete");
|
||||
// ignore (e.g. already removed)
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
redirect("/admin/permissions?saved=1");
|
||||
try {
|
||||
const deleted = await prisma.websitePermissions.delete({
|
||||
where: { id },
|
||||
select: { permission: true },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "permission_delete",
|
||||
description: `Deleted permission #${id} ("${deleted.permission}")`,
|
||||
targetType: "permission",
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.permission_delete_failed", error, {
|
||||
staffId: staff.id,
|
||||
permissionId: String(id),
|
||||
});
|
||||
redirect("/admin/permissions?error=delete");
|
||||
// ignore (e.g. already removed)
|
||||
}
|
||||
revalidatePath("/admin/permissions");
|
||||
redirect("/admin/permissions?saved=1");
|
||||
}
|
||||
@@ -5,15 +5,15 @@ 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;
|
||||
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.
|
||||
}
|
||||
try {
|
||||
await prisma.cameraWeb.delete({ where: { id } });
|
||||
} catch {
|
||||
// Record may have already been removed; ignore.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/photos");
|
||||
revalidatePath("/admin/photos");
|
||||
}
|
||||
@@ -13,118 +13,119 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
// `permissions` JSON column is intentionally left untouched by this CMS slice.
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
/** Parse a BigInt id from a form value, or null when blank/invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
const s = str(raw).trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
return BigInt(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const s = str(raw).trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
return BigInt(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamp a form value to a non-negative integer (defaulting to `fallback`). */
|
||||
function intOr(raw: FormDataEntryValue | null, fallback: number): number {
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||
return Math.floor(n);
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
export async function createApiKey(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const rateLimit = intOr(formData.get("rateLimit"), 300);
|
||||
const allowedIps = str(formData.get("allowedIps")).trim().slice(0, 255) || null;
|
||||
const rateLimit = intOr(formData.get("rateLimit"), 300);
|
||||
const allowedIps =
|
||||
str(formData.get("allowedIps")).trim().slice(0, 255) || null;
|
||||
|
||||
// Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)).
|
||||
const key = randomBytes(24).toString("hex");
|
||||
// Server-side key generation — 24 random bytes → 48 hex chars (fits VarChar(64)).
|
||||
const key = randomBytes(24).toString("hex");
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.radioApiKeys.create({
|
||||
data: {
|
||||
name,
|
||||
key,
|
||||
allowedIps,
|
||||
rateLimit,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_create",
|
||||
description: `Created radio API key "${name}" (#${created.id}, rate limit ${rateLimit})`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.radioApiKeys.create({
|
||||
data: {
|
||||
name,
|
||||
key,
|
||||
allowedIps,
|
||||
rateLimit,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_create",
|
||||
description: `Created radio API key "${name}" (#${created.id}, rate limit ${rateLimit})`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// Unique-key collision (astronomically unlikely) or DB down — fail soft.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
redirect("/admin/radio/api-keys?created=1");
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
redirect("/admin/radio/api-keys?created=1");
|
||||
}
|
||||
|
||||
export async function toggleApiKey(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id == null) return;
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
const existing = await prisma.radioApiKeys.findUnique({
|
||||
where: { id },
|
||||
select: { name: true, isActive: true },
|
||||
});
|
||||
if (!existing) return;
|
||||
try {
|
||||
const existing = await prisma.radioApiKeys.findUnique({
|
||||
where: { id },
|
||||
select: { name: true, isActive: true },
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
const next = !existing.isActive;
|
||||
await prisma.radioApiKeys.update({
|
||||
where: { id },
|
||||
data: { isActive: next, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_toggle",
|
||||
description: `${next ? "Activated" : "Deactivated"} radio API key "${existing.name}" (#${id})`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const next = !existing.isActive;
|
||||
await prisma.radioApiKeys.update({
|
||||
where: { id },
|
||||
data: { isActive: next, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_toggle",
|
||||
description: `${next ? "Activated" : "Deactivated"} radio API key "${existing.name}" (#${id})`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
}
|
||||
|
||||
export async function deleteApiKey(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id == null) return;
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioApiKeys.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_delete",
|
||||
description: `Deleted radio API key #${id}`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.radioApiKeys.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_api_key_delete",
|
||||
description: `Deleted radio API key #${id}`,
|
||||
targetType: "radio_api_key",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
revalidatePath("/admin/radio/api-keys");
|
||||
}
|
||||
@@ -13,125 +13,125 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
|
||||
function bool(raw: FormDataEntryValue | null): boolean {
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "on";
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "on";
|
||||
}
|
||||
|
||||
/** Parse a non-negative UnsignedInt, falling back to 0. */
|
||||
function reqUInt(raw: FormDataEntryValue | null): number {
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.trunc(n);
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
/** Parse an optional non-negative UnsignedInt; blank/invalid/negative → null. */
|
||||
function optUInt(raw: FormDataEntryValue | null): number | null {
|
||||
const s = str(raw).trim();
|
||||
if (s === "") return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return Math.trunc(n);
|
||||
const s = str(raw).trim();
|
||||
if (s === "") return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return Math.trunc(n);
|
||||
}
|
||||
|
||||
// ── AutoDJ playlist CRUD (radio_auto_dj_playlist) ────────────────────────
|
||||
|
||||
export async function createTrack(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||
if (!title) return;
|
||||
const staff = await requireStaff();
|
||||
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
const artist = str(formData.get("artist")).trim().slice(0, 255);
|
||||
const album = str(formData.get("album")).trim().slice(0, 255);
|
||||
const artworkUrl = str(formData.get("artworkUrl")).trim().slice(0, 255);
|
||||
const duration = optUInt(formData.get("duration"));
|
||||
const sortOrder = reqUInt(formData.get("sortOrder"));
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
const now = new Date();
|
||||
const artist = str(formData.get("artist")).trim().slice(0, 255);
|
||||
const album = str(formData.get("album")).trim().slice(0, 255);
|
||||
const artworkUrl = str(formData.get("artworkUrl")).trim().slice(0, 255);
|
||||
const duration = optUInt(formData.get("duration"));
|
||||
const sortOrder = reqUInt(formData.get("sortOrder"));
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const created = await prisma.radioAutoDjPlaylist.create({
|
||||
data: {
|
||||
title,
|
||||
artist: artist || null,
|
||||
album: album || null,
|
||||
artworkUrl: artworkUrl || null,
|
||||
duration,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_create",
|
||||
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
|
||||
targetType: "radio_auto_dj_track",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// Fail soft — DB unavailable; re-render without throwing.
|
||||
}
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
try {
|
||||
const created = await prisma.radioAutoDjPlaylist.create({
|
||||
data: {
|
||||
title,
|
||||
artist: artist || null,
|
||||
album: album || null,
|
||||
artworkUrl: artworkUrl || null,
|
||||
duration,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_create",
|
||||
description: `Created AutoDJ track "${title}"${artist ? ` by ${artist}` : ""}`,
|
||||
targetType: "radio_auto_dj_track",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// Fail soft — DB unavailable; re-render without throwing.
|
||||
}
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
}
|
||||
|
||||
export async function toggleTrack(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
// The form posts the desired next state so the toggle is idempotent.
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
// The form posts the desired next state so the toggle is idempotent.
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
|
||||
try {
|
||||
await prisma.radioAutoDjPlaylist.update({
|
||||
where: { id },
|
||||
data: { isActive, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_toggle",
|
||||
description: `${isActive ? "Activated" : "Deactivated"} AutoDJ track #${id}`,
|
||||
targetType: "radio_auto_dj_track",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
try {
|
||||
await prisma.radioAutoDjPlaylist.update({
|
||||
where: { id },
|
||||
data: { isActive, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_toggle",
|
||||
description: `${isActive ? "Activated" : "Deactivated"} AutoDJ track #${id}`,
|
||||
targetType: "radio_auto_dj_track",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
}
|
||||
|
||||
export async function deleteTrack(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioAutoDjPlaylist.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_delete",
|
||||
description: `Deleted AutoDJ track #${id}`,
|
||||
targetType: "radio_auto_dj_track",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
try {
|
||||
await prisma.radioAutoDjPlaylist.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_autodj_delete",
|
||||
description: `Deleted AutoDJ track #${id}`,
|
||||
targetType: "radio_auto_dj_track",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/autodj");
|
||||
}
|
||||
+168
-164
@@ -9,23 +9,23 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
/** Checkbox/select truthiness: '1', 'true', 'on' → true. */
|
||||
function bool(raw: FormDataEntryValue | null): boolean {
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "on";
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "on";
|
||||
}
|
||||
|
||||
// ── Radio settings (website_settings radio_* keys) ─────────────────────────
|
||||
@@ -36,23 +36,23 @@ function bool(raw: FormDataEntryValue | null): boolean {
|
||||
* siteSettings cache so the public radio pages pick the change up immediately.
|
||||
*/
|
||||
export async function saveRadioSetting(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = str(formData.get("key")).trim().slice(0, 255);
|
||||
const value = str(formData.get("value"));
|
||||
const comment = str(formData.get("comment")).trim().slice(0, 255);
|
||||
if (!key) return;
|
||||
await requireStaff();
|
||||
const key = str(formData.get("key")).trim().slice(0, 255);
|
||||
const value = str(formData.get("value"));
|
||||
const comment = str(formData.get("comment")).trim().slice(0, 255);
|
||||
if (!key) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw.
|
||||
}
|
||||
revalidatePath("/admin/radio/settings");
|
||||
try {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw.
|
||||
}
|
||||
revalidatePath("/admin/radio/settings");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,173 +61,177 @@ export async function saveRadioSetting(formData: FormData): Promise<void> {
|
||||
* only touch those (and never wipe unrelated settings).
|
||||
*/
|
||||
export async function saveRadioSettings(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const keysRaw = str(formData.get("__keys"));
|
||||
const keys = keysRaw
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.startsWith("radio_") || k.startsWith("auto_dj_"));
|
||||
if (keys.length === 0) return;
|
||||
await requireStaff();
|
||||
const keysRaw = str(formData.get("__keys"));
|
||||
const keys = keysRaw
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k.startsWith("radio_") || k.startsWith("auto_dj_"));
|
||||
if (keys.length === 0) return;
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
keys.map((key) => {
|
||||
const value = str(formData.get(key));
|
||||
return prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: null },
|
||||
});
|
||||
}),
|
||||
);
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
// Fail soft.
|
||||
}
|
||||
revalidatePath("/admin/radio/settings");
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
keys.map((key) => {
|
||||
const value = str(formData.get(key));
|
||||
return prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: null },
|
||||
});
|
||||
}),
|
||||
);
|
||||
siteSettings.reload();
|
||||
} catch {
|
||||
// Fail soft.
|
||||
}
|
||||
revalidatePath("/admin/radio/settings");
|
||||
}
|
||||
|
||||
// ── Radio banners CRUD (radio_banners) ─────────────────────────────────────
|
||||
|
||||
export async function createRadioBanner(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
|
||||
if (!imagePath) return;
|
||||
const staff = await requireStaff();
|
||||
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
|
||||
if (!imagePath) return;
|
||||
|
||||
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||
const description = str(formData.get("description")).trim();
|
||||
const sortOrderNum = Number(str(formData.get("sortOrder")));
|
||||
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0;
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
const now = new Date();
|
||||
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||
const description = str(formData.get("description")).trim();
|
||||
const sortOrderNum = Number(str(formData.get("sortOrder")));
|
||||
const sortOrder = Number.isFinite(sortOrderNum)
|
||||
? Math.trunc(sortOrderNum)
|
||||
: 0;
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.radioBanners.create({
|
||||
data: {
|
||||
userId: BigInt(staff.id),
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Fail soft.
|
||||
}
|
||||
revalidatePath("/admin/radio/banners");
|
||||
try {
|
||||
await prisma.radioBanners.create({
|
||||
data: {
|
||||
userId: BigInt(staff.id),
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Fail soft.
|
||||
}
|
||||
revalidatePath("/admin/radio/banners");
|
||||
}
|
||||
|
||||
export async function updateRadioBanner(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
|
||||
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||
const description = str(formData.get("description")).trim();
|
||||
const sortOrderNum = Number(str(formData.get("sortOrder")));
|
||||
const sortOrder = Number.isFinite(sortOrderNum) ? Math.trunc(sortOrderNum) : 0;
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
if (!imagePath) return;
|
||||
const imagePath = str(formData.get("imagePath")).trim().slice(0, 255);
|
||||
const title = str(formData.get("title")).trim().slice(0, 255);
|
||||
const description = str(formData.get("description")).trim();
|
||||
const sortOrderNum = Number(str(formData.get("sortOrder")));
|
||||
const sortOrder = Number.isFinite(sortOrderNum)
|
||||
? Math.trunc(sortOrderNum)
|
||||
: 0;
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
if (!imagePath) return;
|
||||
|
||||
try {
|
||||
await prisma.radioBanners.update({
|
||||
where: { id },
|
||||
data: {
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/banners");
|
||||
try {
|
||||
await prisma.radioBanners.update({
|
||||
where: { id },
|
||||
data: {
|
||||
imagePath,
|
||||
title: title || null,
|
||||
description: description || null,
|
||||
sortOrder,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/banners");
|
||||
}
|
||||
|
||||
export async function deleteRadioBanner(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await prisma.radioBanners.delete({ where: { id } });
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/banners");
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await prisma.radioBanners.delete({ where: { id } });
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/banners");
|
||||
}
|
||||
|
||||
// ── Radio ranks CRUD (radio_ranks) ─────────────────────────────────────────
|
||||
|
||||
export async function createRadioRank(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
await requireStaff();
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const description = str(formData.get("description")).trim().slice(0, 255);
|
||||
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
const now = new Date();
|
||||
const description = str(formData.get("description")).trim().slice(0, 255);
|
||||
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.radioRanks.create({
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Fail soft.
|
||||
}
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
try {
|
||||
await prisma.radioRanks.create({
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Fail soft.
|
||||
}
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
}
|
||||
|
||||
export async function updateRadioRank(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
const description = str(formData.get("description")).trim().slice(0, 255);
|
||||
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
if (!name) return;
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
const description = str(formData.get("description")).trim().slice(0, 255);
|
||||
const badgeCode = str(formData.get("badgeCode")).trim().slice(0, 255);
|
||||
const isActive = bool(formData.get("isActive"));
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.radioRanks.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
try {
|
||||
await prisma.radioRanks.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
badgeCode: badgeCode || null,
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
}
|
||||
|
||||
export async function deleteRadioRank(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await prisma.radioRanks.delete({ where: { id } });
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
try {
|
||||
await prisma.radioRanks.delete({ where: { id } });
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath("/admin/radio/ranks");
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,22 +22,22 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
* moderation route. Fails soft if the row is already gone.
|
||||
*/
|
||||
export async function deleteShout(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
await prisma.radioShouts.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio.shout.delete",
|
||||
description: `Deleted radio shout #${id}`,
|
||||
targetType: "radio_shout",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may already be gone; ignore so the action does not throw.
|
||||
}
|
||||
try {
|
||||
await prisma.radioShouts.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio.shout.delete",
|
||||
description: `Deleted radio shout #${id}`,
|
||||
targetType: "radio_shout",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may already be gone; ignore so the action does not throw.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/moderation");
|
||||
revalidatePath("/admin/radio/moderation");
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Radio listener-points settings (website_settings radio_points_* keys).
|
||||
// Mirrors AtomCMS's RadioPoints Filament page: key/value rows in
|
||||
@@ -14,68 +14,79 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
// pages pick the change up immediately.
|
||||
|
||||
const POINTS_KEYS = [
|
||||
"radio_points_enabled",
|
||||
"radio_points_per_minute",
|
||||
"radio_points_currency",
|
||||
"radio_points_max_per_day",
|
||||
"radio_points_min_listeners",
|
||||
"radio_points_enabled",
|
||||
"radio_points_per_minute",
|
||||
"radio_points_currency",
|
||||
"radio_points_max_per_day",
|
||||
"radio_points_min_listeners",
|
||||
] as const;
|
||||
|
||||
const ALLOWED_CURRENCIES = new Set(["credits", "duckets", "diamonds", "points"]);
|
||||
const ALLOWED_CURRENCIES = new Set([
|
||||
"credits",
|
||||
"duckets",
|
||||
"diamonds",
|
||||
"points",
|
||||
]);
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
/** Checkbox/select truthiness → '1' / '0'. */
|
||||
function boolStr(raw: FormDataEntryValue | null): "0" | "1" {
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "on" ? "1" : "0";
|
||||
const v = str(raw).trim().toLowerCase();
|
||||
return v === "1" || v === "true" || v === "on" ? "1" : "0";
|
||||
}
|
||||
|
||||
/** Clamp a form value to a non-negative integer string (defaulting to 0). */
|
||||
function intStr(raw: FormDataEntryValue | null): string {
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return "0";
|
||||
return String(Math.floor(n));
|
||||
const n = Number(str(raw).trim());
|
||||
if (!Number.isFinite(n) || n < 0) return "0";
|
||||
return String(Math.floor(n));
|
||||
}
|
||||
|
||||
export async function savePoints(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const currencyRaw = str(formData.get("radio_points_currency")).trim().toLowerCase();
|
||||
const currency = ALLOWED_CURRENCIES.has(currencyRaw) ? currencyRaw : "credits";
|
||||
const currencyRaw = str(formData.get("radio_points_currency"))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const currency = ALLOWED_CURRENCIES.has(currencyRaw)
|
||||
? currencyRaw
|
||||
: "credits";
|
||||
|
||||
const values: Record<(typeof POINTS_KEYS)[number], string> = {
|
||||
radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
|
||||
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
|
||||
radio_points_currency: currency,
|
||||
radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")),
|
||||
radio_points_min_listeners: intStr(formData.get("radio_points_min_listeners")),
|
||||
};
|
||||
const values: Record<(typeof POINTS_KEYS)[number], string> = {
|
||||
radio_points_enabled: boolStr(formData.get("radio_points_enabled")),
|
||||
radio_points_per_minute: intStr(formData.get("radio_points_per_minute")),
|
||||
radio_points_currency: currency,
|
||||
radio_points_max_per_day: intStr(formData.get("radio_points_max_per_day")),
|
||||
radio_points_min_listeners: intStr(
|
||||
formData.get("radio_points_min_listeners"),
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
POINTS_KEYS.map((key) =>
|
||||
prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
update: { value: values[key] },
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
create: { key, value: values[key], comment: "Radio points" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_points_update",
|
||||
description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`,
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw.
|
||||
}
|
||||
try {
|
||||
await prisma.$transaction(
|
||||
POINTS_KEYS.map((key) =>
|
||||
prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
update: { value: values[key] },
|
||||
// eslint-disable-next-line security/detect-object-injection -- key from POINTS_KEYS const
|
||||
create: { key, value: values[key], comment: "Radio points" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "radio_points_update",
|
||||
description: `Updated radio listener-points settings (enabled=${values.radio_points_enabled}, ${values.radio_points_per_minute}/min ${currency})`,
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw.
|
||||
}
|
||||
|
||||
revalidatePath("/admin/radio/points");
|
||||
redirect("/admin/radio/points?saved=1");
|
||||
revalidatePath("/admin/radio/points");
|
||||
redirect("/admin/radio/points?saved=1");
|
||||
}
|
||||
@@ -2,106 +2,110 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createCategory(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.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;
|
||||
await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.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");
|
||||
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 id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
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");
|
||||
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 categoryId = formPositiveBigInt(formData, "categoryId");
|
||||
if (!categoryId) return;
|
||||
await requireStaff();
|
||||
const categoryId = formPositiveBigInt(formData, "categoryId");
|
||||
if (!categoryId) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name || !furnitureIcon) return;
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const furnitureIcon = String(formData.get("furnitureIcon") ?? "")
|
||||
.normalize("NFC")
|
||||
.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 itemIdRaw = Number(formData.get("itemId"));
|
||||
const itemId =
|
||||
Number.isFinite(itemIdRaw) && itemIdRaw > 0 ? Math.floor(itemIdRaw) : null;
|
||||
|
||||
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyType =
|
||||
String(formData.get("currencyType") ?? "diamonds")
|
||||
.trim()
|
||||
.slice(0, 255) || "diamonds";
|
||||
const creditValueRaw = String(formData.get("creditValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const currencyValueRaw = String(formData.get("currencyValue") ?? "")
|
||||
.normalize("NFC")
|
||||
.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");
|
||||
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 id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
await requireStaff();
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteRareValues.delete({ where: { id } });
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
try {
|
||||
await prisma.websiteRareValues.delete({ where: { id } });
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
revalidatePath("/admin/rare-values");
|
||||
}
|
||||
@@ -6,45 +6,45 @@ import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export async function updateSetting(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const value = String(formData.get("value") ?? "").normalize("NFC");
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.update({ where: { key }, data: { value } });
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/settings");
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const value = String(formData.get("value") ?? "").normalize("NFC");
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.update({ where: { key }, data: { value } });
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
|
||||
export async function createSetting(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const value = String(formData.get("value") ?? "").normalize("NFC");
|
||||
const comment = String(formData.get("comment") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/settings");
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const value = String(formData.get("value") ?? "").normalize("NFC");
|
||||
const comment = String(formData.get("comment") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: comment || null },
|
||||
});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
|
||||
export async function deleteSetting(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.delete({ where: { key } });
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/settings");
|
||||
await requireStaff();
|
||||
const key = String(formData.get("key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!key) return;
|
||||
await prisma.websiteSetting.delete({ where: { key } });
|
||||
siteSettings.reload();
|
||||
revalidatePath("/admin/settings");
|
||||
}
|
||||
+140
-137
@@ -3,10 +3,10 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Website store packages (website_shop_articles). This CMS-owned table backs
|
||||
// the public store; rows here are the buyable packages, not orders. The closest
|
||||
@@ -14,162 +14,165 @@ import { logServerError } from "@/lib/server-log";
|
||||
|
||||
/** Parse an UnsignedInt form value, returning null when blank/invalid/negative. */
|
||||
function optUInt(formData: FormData, key: string): number | null {
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return Math.floor(n);
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/** Parse a required non-negative UnsignedInt, falling back to 0. */
|
||||
function reqUInt(formData: FormData, key: string): number {
|
||||
const n = optUInt(formData, key);
|
||||
return n ?? 0;
|
||||
const n = optUInt(formData, key);
|
||||
return n ?? 0;
|
||||
}
|
||||
|
||||
export async function createShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.websiteShopArticles.create({
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_create",
|
||||
description: `Created shop package "${name}" (${created.costs} costs)`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_create_failed", error, { staffId: staff.id, name });
|
||||
// Unique constraint on `name` (or DB unavailable) — swallow and re-render.
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.websiteShopArticles.create({
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_create",
|
||||
description: `Created shop package "${name}" (${created.costs} costs)`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_create_failed", error, {
|
||||
staffId: staff.id,
|
||||
name,
|
||||
});
|
||||
// Unique constraint on `name` (or DB unavailable) — swallow and re-render.
|
||||
return;
|
||||
}
|
||||
|
||||
redirect("/admin/shop");
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
|
||||
export async function updateShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteShopArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_update",
|
||||
description: `Updated shop package #${id} ("${name}")`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_update_failed", error, {
|
||||
staffId: staff.id,
|
||||
articleId: String(id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.websiteShopArticles.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
info: String(formData.get("info") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
iconUrl: String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
color: String(formData.get("color") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
costs: reqUInt(formData, "costs"),
|
||||
giveRank: optUInt(formData, "giveRank"),
|
||||
credits: optUInt(formData, "credits"),
|
||||
duckets: optUInt(formData, "duckets"),
|
||||
diamonds: optUInt(formData, "diamonds"),
|
||||
badges:
|
||||
String(formData.get("badges") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
position: reqUInt(formData, "position"),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_update",
|
||||
description: `Updated shop package #${id} ("${name}")`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_update_failed", error, {
|
||||
staffId: staff.id,
|
||||
articleId: String(id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath(`/admin/shop/${id}`);
|
||||
redirect("/admin/shop");
|
||||
revalidatePath(`/admin/shop/${id}`);
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
|
||||
export async function deleteShopArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteShopArticles.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_delete",
|
||||
description: `Deleted shop package #${id}`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_delete_failed", error, {
|
||||
staffId: staff.id,
|
||||
articleId: String(id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.websiteShopArticles.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "shop_delete",
|
||||
description: `Deleted shop package #${id}`,
|
||||
targetType: "shop_article",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.shop_delete_failed", error, {
|
||||
staffId: staff.id,
|
||||
articleId: String(id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
redirect("/admin/shop");
|
||||
redirect("/admin/shop");
|
||||
}
|
||||
+72
-72
@@ -9,99 +9,99 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
/** Normalise a hex-ish colour into the 10-char background_color column. */
|
||||
function normaliseColor(raw: string): string {
|
||||
const v = raw.trim().slice(0, 10);
|
||||
return v || "#888888";
|
||||
const v = raw.trim().slice(0, 10);
|
||||
return v || "#888888";
|
||||
}
|
||||
|
||||
// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ──────────
|
||||
|
||||
export async function createTag(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
const staff = await requireStaff();
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
|
||||
const now = new Date();
|
||||
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const created = await prisma.tags.create({
|
||||
data: { name, backgroundColor, createdAt: now, updatedAt: now },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_create",
|
||||
description: `Created tag "${name}" (#${created.id})`,
|
||||
targetType: "tag",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// Fail soft — DB unavailable or duplicate.
|
||||
}
|
||||
revalidatePath("/admin/tags");
|
||||
try {
|
||||
const created = await prisma.tags.create({
|
||||
data: { name, backgroundColor, createdAt: now, updatedAt: now },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_create",
|
||||
description: `Created tag "${name}" (#${created.id})`,
|
||||
targetType: "tag",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// Fail soft — DB unavailable or duplicate.
|
||||
}
|
||||
revalidatePath("/admin/tags");
|
||||
}
|
||||
|
||||
export async function updateTag(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
|
||||
if (!name) return;
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await prisma.tags.update({
|
||||
where: { id },
|
||||
data: { name, backgroundColor, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_update",
|
||||
description: `Updated tag #${id} → "${name}"`,
|
||||
targetType: "tag",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath("/admin/tags");
|
||||
try {
|
||||
await prisma.tags.update({
|
||||
where: { id },
|
||||
data: { name, backgroundColor, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_update",
|
||||
description: `Updated tag #${id} → "${name}"`,
|
||||
targetType: "tag",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
revalidatePath("/admin/tags");
|
||||
}
|
||||
|
||||
export async function deleteTag(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
const staff = await requireStaff();
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
// Remove the tag and any taggable links pointing at it.
|
||||
await prisma.$transaction([
|
||||
prisma.taggables.deleteMany({ where: { tagId: id } }),
|
||||
prisma.tags.delete({ where: { id } }),
|
||||
]);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_delete",
|
||||
description: `Deleted tag #${id}`,
|
||||
targetType: "tag",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath("/admin/tags");
|
||||
try {
|
||||
// Remove the tag and any taggable links pointing at it.
|
||||
await prisma.$transaction([
|
||||
prisma.taggables.deleteMany({ where: { tagId: id } }),
|
||||
prisma.tags.delete({ where: { id } }),
|
||||
]);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_delete",
|
||||
description: `Deleted tag #${id}`,
|
||||
targetType: "tag",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
revalidatePath("/admin/tags");
|
||||
}
|
||||
+33
-33
@@ -5,46 +5,46 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createTeam(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
await requireStaff();
|
||||
|
||||
const rankName = String(formData.get("rankName") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!rankName) return;
|
||||
const rankName = String(formData.get("rankName") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!rankName) return;
|
||||
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const jobDescription = String(formData.get("jobDescription") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const staffColor =
|
||||
String(formData.get("staffColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || "#327fa8";
|
||||
const hiddenRank = formData.get("hiddenRank") === "on";
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const jobDescription = String(formData.get("jobDescription") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const staffColor =
|
||||
String(formData.get("staffColor") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || "#327fa8";
|
||||
const hiddenRank = formData.get("hiddenRank") === "on";
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteTeams.create({
|
||||
data: {
|
||||
rankName: rankName.slice(0, 255),
|
||||
badge: badge ? badge.slice(0, 255) : null,
|
||||
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
|
||||
staffColor: staffColor.slice(0, 255),
|
||||
hiddenRank,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
const now = new Date();
|
||||
await prisma.websiteTeams.create({
|
||||
data: {
|
||||
rankName: rankName.slice(0, 255),
|
||||
badge: badge ? badge.slice(0, 255) : null,
|
||||
jobDescription: jobDescription ? jobDescription.slice(0, 255) : null,
|
||||
staffColor: staffColor.slice(0, 255),
|
||||
hiddenRank,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/admin/teams");
|
||||
revalidatePath("/admin/teams");
|
||||
}
|
||||
|
||||
export async function deleteTeam(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
await requireStaff();
|
||||
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
await prisma.websiteTeams.delete({ where: { id } });
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
await prisma.websiteTeams.delete({ where: { id } });
|
||||
|
||||
revalidatePath("/admin/teams");
|
||||
revalidatePath("/admin/teams");
|
||||
}
|
||||
+158
-157
@@ -6,14 +6,14 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import {
|
||||
deleteCustomThemeStore,
|
||||
getCustomTheme,
|
||||
snapshotCurrentTheme,
|
||||
upsertCustomTheme,
|
||||
} from "@/lib/theme-custom-store";
|
||||
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
||||
import {
|
||||
deleteCustomThemeStore,
|
||||
getCustomTheme,
|
||||
snapshotCurrentTheme,
|
||||
upsertCustomTheme,
|
||||
} from "@/lib/theme-custom-store";
|
||||
|
||||
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||
@@ -22,179 +22,180 @@ const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"];
|
||||
const CUSTOM_CSS_MAX = 20000;
|
||||
|
||||
async function writeSetting(key: string, value: string): Promise<void> {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: "Theme (housekeeping)" },
|
||||
});
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment: "Theme (housekeeping)" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveTheme(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
try {
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
for (const key of THEME_COLOR_KEYS) {
|
||||
const dbKey = settingKey(key, mode);
|
||||
const raw = String(formData.get(dbKey) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw);
|
||||
}
|
||||
}
|
||||
const ADMIN_KEYS = [
|
||||
"admin_canvas",
|
||||
"admin_surface",
|
||||
"admin_text",
|
||||
"admin_text_muted",
|
||||
"admin_border",
|
||||
"admin_sidebar_bg",
|
||||
];
|
||||
for (const key of ADMIN_KEYS) {
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw);
|
||||
}
|
||||
try {
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
for (const key of THEME_COLOR_KEYS) {
|
||||
const dbKey = settingKey(key, mode);
|
||||
const raw = String(formData.get(dbKey) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw);
|
||||
}
|
||||
}
|
||||
const ADMIN_KEYS = [
|
||||
"admin_canvas",
|
||||
"admin_surface",
|
||||
"admin_text",
|
||||
"admin_text_muted",
|
||||
"admin_border",
|
||||
"admin_sidebar_bg",
|
||||
];
|
||||
for (const key of ADMIN_KEYS) {
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw);
|
||||
}
|
||||
|
||||
const radius = String(formData.get("border_radius") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
|
||||
const radius = String(formData.get("border_radius") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
|
||||
|
||||
// Typography
|
||||
const font = String(formData.get("font_family") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (font in FONTS) await writeSetting("font_family", font);
|
||||
for (const key of HEADING_KEYS) {
|
||||
const v = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (/^\d{1,3}$/.test(v)) await writeSetting(key, v);
|
||||
}
|
||||
// Typography
|
||||
const font = String(formData.get("font_family") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (font in FONTS) await writeSetting("font_family", font);
|
||||
for (const key of HEADING_KEYS) {
|
||||
const v = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (/^\d{1,3}$/.test(v)) await writeSetting(key, v);
|
||||
}
|
||||
|
||||
// Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is).
|
||||
if (formData.has("custom_css")) {
|
||||
const cssRaw = String(formData.get("custom_css") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, CUSTOM_CSS_MAX);
|
||||
await writeSetting("custom_css", cssRaw);
|
||||
}
|
||||
// Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is).
|
||||
if (formData.has("custom_css")) {
|
||||
const cssRaw = String(formData.get("custom_css") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, CUSTOM_CSS_MAX);
|
||||
await writeSetting("custom_css", cssRaw);
|
||||
}
|
||||
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_update",
|
||||
description: "Updated theme settings",
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
// ignore — page re-renders current state
|
||||
}
|
||||
redirect("/admin/theme?saved=1");
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_update",
|
||||
description: "Updated theme settings",
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
// ignore — page re-renders current state
|
||||
}
|
||||
redirect("/admin/theme?saved=1");
|
||||
}
|
||||
|
||||
export async function applyPreset(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) redirect("/admin/theme");
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) redirect("/admin/theme");
|
||||
|
||||
try {
|
||||
for (const [key, value] of presetSettings(preset)) await writeSetting(key, value);
|
||||
await writeSetting("theme_preset", name);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_preset",
|
||||
description: `Applied theme preset "${name}"`,
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect(`/admin/theme?preset=${encodeURIComponent(name)}`);
|
||||
try {
|
||||
for (const [key, value] of presetSettings(preset))
|
||||
await writeSetting(key, value);
|
||||
await writeSetting("theme_preset", name);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_preset",
|
||||
description: `Applied theme preset "${name}"`,
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect(`/admin/theme?preset=${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export async function saveCustomTheme(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!name) redirect("/admin/theme");
|
||||
const snapshot = await snapshotCurrentTheme();
|
||||
try {
|
||||
await upsertCustomTheme(name, snapshot);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_preset",
|
||||
description: `Saved custom theme "${name}"`,
|
||||
});
|
||||
revalidatePath("/admin/theme");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect("/admin/theme?savedTheme=1");
|
||||
const staff = await requireStaff();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!name) redirect("/admin/theme");
|
||||
const snapshot = await snapshotCurrentTheme();
|
||||
try {
|
||||
await upsertCustomTheme(name, snapshot);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_preset",
|
||||
description: `Saved custom theme "${name}"`,
|
||||
});
|
||||
revalidatePath("/admin/theme");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect("/admin/theme?savedTheme=1");
|
||||
}
|
||||
|
||||
export async function applyCustomTheme(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id) redirect("/admin/theme");
|
||||
const theme = await getCustomTheme(id);
|
||||
if (!theme) redirect("/admin/theme");
|
||||
try {
|
||||
for (const [key, value] of Object.entries(theme.settings)) {
|
||||
if (value) await writeSetting(key, value);
|
||||
}
|
||||
await writeSetting("theme_preset", theme.name);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_preset",
|
||||
description: `Applied custom theme "${theme.name}"`,
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`);
|
||||
const staff = await requireStaff();
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id) redirect("/admin/theme");
|
||||
const theme = await getCustomTheme(id);
|
||||
if (!theme) redirect("/admin/theme");
|
||||
try {
|
||||
for (const [key, value] of Object.entries(theme.settings)) {
|
||||
if (value) await writeSetting(key, value);
|
||||
}
|
||||
await writeSetting("theme_preset", theme.name);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_preset",
|
||||
description: `Applied custom theme "${theme.name}"`,
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`);
|
||||
}
|
||||
|
||||
export async function renameCustomTheme(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id || !name) redirect("/admin/theme");
|
||||
const snapshot = await snapshotCurrentTheme();
|
||||
try {
|
||||
await upsertCustomTheme(name, snapshot, id);
|
||||
revalidatePath("/admin/theme");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect("/admin/theme?renamed=1");
|
||||
await requireStaff();
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id || !name) redirect("/admin/theme");
|
||||
const snapshot = await snapshotCurrentTheme();
|
||||
try {
|
||||
await upsertCustomTheme(name, snapshot, id);
|
||||
revalidatePath("/admin/theme");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect("/admin/theme?renamed=1");
|
||||
}
|
||||
|
||||
export async function deleteCustomTheme(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id) redirect("/admin/theme");
|
||||
try {
|
||||
await deleteCustomThemeStore(id);
|
||||
revalidatePath("/admin/theme");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect("/admin/theme?deletedTheme=1");
|
||||
await requireStaff();
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!id) redirect("/admin/theme");
|
||||
try {
|
||||
await deleteCustomThemeStore(id);
|
||||
revalidatePath("/admin/theme");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect("/admin/theme?deletedTheme=1");
|
||||
}
|
||||
@@ -13,13 +13,13 @@ const DUCKETS_TYPE = 0;
|
||||
const DIAMONDS_TYPE = 5;
|
||||
|
||||
function toInt(value: FormDataEntryValue | null, min = 0): number | null {
|
||||
if (value == null) return null;
|
||||
const raw = String(value).trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
const i = Math.trunc(n);
|
||||
return i < min ? min : i;
|
||||
if (value == null) return null;
|
||||
const raw = String(value).trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
const i = Math.trunc(n);
|
||||
return i < min ? min : i;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,73 +28,73 @@ function toInt(value: FormDataEntryValue | null, min = 0): number | null {
|
||||
* user from the session and logs the action. emulator-owned users.id is Int.
|
||||
*/
|
||||
export async function updateUser(formData: FormData): Promise<void> {
|
||||
// Never trust the client: re-check staff inside the action.
|
||||
const staff = await requireStaff();
|
||||
// Never trust the client: re-check staff inside the action.
|
||||
const staff = await requireStaff();
|
||||
|
||||
const userId = Number(formData.get("id"));
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
const userId = Number(formData.get("id"));
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) return;
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) return;
|
||||
|
||||
// users row — only existing, safe columns.
|
||||
const mailRaw = String(formData.get("mail") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 127);
|
||||
const look = String(formData.get("look") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 256);
|
||||
const rank = toInt(formData.get("rank"), 1);
|
||||
const credits = toInt(formData.get("credits"), 0);
|
||||
const pixels = toInt(formData.get("pixels"), 0);
|
||||
const points = toInt(formData.get("points"), 0);
|
||||
// users row — only existing, safe columns.
|
||||
const mailRaw = String(formData.get("mail") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 127);
|
||||
const look = String(formData.get("look") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 256);
|
||||
const rank = toInt(formData.get("rank"), 1);
|
||||
const credits = toInt(formData.get("credits"), 0);
|
||||
const pixels = toInt(formData.get("pixels"), 0);
|
||||
const points = toInt(formData.get("points"), 0);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mail: mailRaw === "" ? null : mailRaw.slice(0, 500),
|
||||
motto,
|
||||
look,
|
||||
...(rank != null ? { rank } : {}),
|
||||
...(credits != null ? { credits } : {}),
|
||||
...(pixels != null ? { pixels } : {}),
|
||||
...(points != null ? { points } : {}),
|
||||
},
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mail: mailRaw === "" ? null : mailRaw.slice(0, 500),
|
||||
motto,
|
||||
look,
|
||||
...(rank != null ? { rank } : {}),
|
||||
...(credits != null ? { credits } : {}),
|
||||
...(pixels != null ? { pixels } : {}),
|
||||
...(points != null ? { points } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// users_currency — set exact balances for duckets / diamonds.
|
||||
const duckets = toInt(formData.get("duckets"), 0);
|
||||
const diamonds = toInt(formData.get("diamonds"), 0);
|
||||
if (duckets != null) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: DUCKETS_TYPE } },
|
||||
update: { amount: duckets },
|
||||
create: { userId, type: DUCKETS_TYPE, amount: duckets },
|
||||
});
|
||||
}
|
||||
if (diamonds != null) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: DIAMONDS_TYPE } },
|
||||
update: { amount: diamonds },
|
||||
create: { userId, type: DIAMONDS_TYPE, amount: diamonds },
|
||||
});
|
||||
}
|
||||
// users_currency — set exact balances for duckets / diamonds.
|
||||
const duckets = toInt(formData.get("duckets"), 0);
|
||||
const diamonds = toInt(formData.get("diamonds"), 0);
|
||||
if (duckets != null) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: DUCKETS_TYPE } },
|
||||
update: { amount: duckets },
|
||||
create: { userId, type: DUCKETS_TYPE, amount: duckets },
|
||||
});
|
||||
}
|
||||
if (diamonds != null) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: DIAMONDS_TYPE } },
|
||||
update: { amount: diamonds },
|
||||
create: { userId, type: DIAMONDS_TYPE, amount: diamonds },
|
||||
});
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "user_edit",
|
||||
description: `Edited account fields of user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "user_edit",
|
||||
description: `Edited account fields of user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
revalidatePath(`/admin/users/${userId}/edit`);
|
||||
redirect(`/admin/users/${userId}`);
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
revalidatePath(`/admin/users/${userId}/edit`);
|
||||
redirect(`/admin/users/${userId}`);
|
||||
}
|
||||
+61
-51
@@ -7,69 +7,79 @@ import { rcon } from "@/lib/services/rcon";
|
||||
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const CURRENCIES: ReadonlySet<string> = new Set(["credits", "duckets", "diamonds", "points"]);
|
||||
const CURRENCIES: ReadonlySet<string> = new Set([
|
||||
"credits",
|
||||
"duckets",
|
||||
"diamonds",
|
||||
"points",
|
||||
]);
|
||||
|
||||
export async function giveCurrency(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaffRateLimited();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const type = String(formData.get("type"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (userId > 0 && amount > 0 && CURRENCIES.has(type)) {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, type as CurrencyName, amount);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "give_currency",
|
||||
description: `Gave ${amount} ${type} to user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
const staff = await requireStaffRateLimited();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const type = String(formData.get("type"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (userId > 0 && amount > 0 && CURRENCIES.has(type)) {
|
||||
await sendCurrency(
|
||||
{ rcon, db: prisma },
|
||||
userId,
|
||||
type as CurrencyName,
|
||||
amount,
|
||||
);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "give_currency",
|
||||
description: `Gave ${amount} ${type} to user #${userId}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function setMotto(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 127);
|
||||
if (userId > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: { motto } });
|
||||
await rcon.setMotto(userId, motto);
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, 127);
|
||||
if (userId > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: { motto } });
|
||||
await rcon.setMotto(userId, motto);
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function setRank(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaffRateLimited();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const rank = Number(formData.get("rank"));
|
||||
if (userId > 0 && rank > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
||||
await rcon.setRank(userId, rank);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "rank_change",
|
||||
description: `Set rank of user #${userId} to ${rank}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
const staff = await requireStaffRateLimited();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const rank = Number(formData.get("rank"));
|
||||
if (userId > 0 && rank > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: { rank } });
|
||||
await rcon.setRank(userId, rank);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "rank_change",
|
||||
description: `Set rank of user #${userId} to ${rank}`,
|
||||
targetType: "user",
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
revalidatePath(`/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function alertUser(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (userId > 0 && message) await rcon.alertUser(userId, message);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (userId > 0 && message) await rcon.alertUser(userId, message);
|
||||
}
|
||||
|
||||
export async function disconnectUser(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") ?? "").normalize("NFC");
|
||||
if (userId > 0) await rcon.disconnectUser(userId, username);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") ?? "").normalize("NFC");
|
||||
if (userId > 0) await rcon.disconnectUser(userId, username);
|
||||
}
|
||||
@@ -2,67 +2,70 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
export async function createVoucher(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
await requireStaff();
|
||||
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const amount = Number(formData.get("amount"));
|
||||
const maxUsesRaw = Number(formData.get("maxUses"));
|
||||
const maxUses = Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const amount = Number(formData.get("amount"));
|
||||
const maxUsesRaw = Number(formData.get("maxUses"));
|
||||
const maxUses =
|
||||
Number.isFinite(maxUsesRaw) && maxUsesRaw > 0 ? Math.floor(maxUsesRaw) : 1;
|
||||
|
||||
if (!code || !(amount > 0)) return;
|
||||
if (!code || !(amount > 0)) return;
|
||||
|
||||
const expiresRaw = String(formData.get("expiresAt") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
let expiresAt: Date | null = null;
|
||||
if (expiresRaw) {
|
||||
const parsed = new Date(expiresRaw);
|
||||
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
|
||||
}
|
||||
const expiresRaw = String(formData.get("expiresAt") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
let expiresAt: Date | null = null;
|
||||
if (expiresRaw) {
|
||||
const parsed = new Date(expiresRaw);
|
||||
if (!Number.isNaN(parsed.getTime())) expiresAt = parsed;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
await prisma.websiteShopVouchers.create({
|
||||
data: {
|
||||
code,
|
||||
amount: Math.floor(amount),
|
||||
maxUses,
|
||||
useCount: 0,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_create_failed", error);
|
||||
// Unique constraint on `code` (or DB unavailable) — swallow and re-render.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.websiteShopVouchers.create({
|
||||
data: {
|
||||
code,
|
||||
amount: Math.floor(amount),
|
||||
maxUses,
|
||||
useCount: 0,
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_create_failed", error);
|
||||
// Unique constraint on `code` (or DB unavailable) — swallow and re-render.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/admin/vouchers");
|
||||
revalidatePath("/admin/vouchers");
|
||||
}
|
||||
|
||||
export async function deleteVoucher(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
await requireStaff();
|
||||
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteShopVouchers.delete({ where: { id } });
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_delete_failed", error, { voucherId: String(id) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.websiteShopVouchers.delete({ where: { id } });
|
||||
} catch (error) {
|
||||
logServerError("admin.voucher_delete_failed", error, {
|
||||
voucherId: String(id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/admin/vouchers");
|
||||
revalidatePath("/admin/vouchers");
|
||||
}
|
||||
+62
-54
@@ -15,67 +15,75 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
const ALLOWED_PROVIDERS = new Set(["none", "proxycheck", "ipqualityscore"]);
|
||||
|
||||
/** Upsert one website_settings key with a stable housekeeping comment. */
|
||||
async function writeSetting(key: string, value: string, comment: string): Promise<void> {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment },
|
||||
});
|
||||
async function writeSetting(
|
||||
key: string,
|
||||
value: string,
|
||||
comment: string,
|
||||
): Promise<void> {
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value, comment },
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveVpn(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
|
||||
const enabled =
|
||||
String(formData.get("vpn_block_enabled") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() !== "";
|
||||
// Toggle: an unchecked checkbox submits nothing, so absence === disabled.
|
||||
const enabled =
|
||||
String(formData.get("vpn_block_enabled") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() !== "";
|
||||
|
||||
const providerRaw = String(formData.get("vpn_provider") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
|
||||
const providerRaw = String(formData.get("vpn_provider") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const provider = ALLOWED_PROVIDERS.has(providerRaw) ? providerRaw : "none";
|
||||
|
||||
const apiKey = String(formData.get("vpn_api_key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const blockMessage = String(formData.get("vpn_block_message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const apiKey = String(formData.get("vpn_api_key") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const blockMessage = String(formData.get("vpn_block_message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
|
||||
try {
|
||||
await writeSetting(
|
||||
"vpn_block_enabled",
|
||||
enabled ? "1" : "0",
|
||||
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
|
||||
);
|
||||
await writeSetting(
|
||||
"vpn_provider",
|
||||
provider,
|
||||
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
|
||||
);
|
||||
await writeSetting("vpn_api_key", apiKey, "API key for the VPN/proxy detection provider");
|
||||
await writeSetting(
|
||||
"vpn_block_message",
|
||||
blockMessage,
|
||||
"Message shown to users blocked for using a VPN/proxy",
|
||||
);
|
||||
try {
|
||||
await writeSetting(
|
||||
"vpn_block_enabled",
|
||||
enabled ? "1" : "0",
|
||||
"Block registrations from detected VPN/proxy IPs (0=no, 1=yes)",
|
||||
);
|
||||
await writeSetting(
|
||||
"vpn_provider",
|
||||
provider,
|
||||
"VPN/proxy detection provider (none/proxycheck/ipqualityscore)",
|
||||
);
|
||||
await writeSetting(
|
||||
"vpn_api_key",
|
||||
apiKey,
|
||||
"API key for the VPN/proxy detection provider",
|
||||
);
|
||||
await writeSetting(
|
||||
"vpn_block_message",
|
||||
blockMessage,
|
||||
"Message shown to users blocked for using a VPN/proxy",
|
||||
);
|
||||
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "vpn_update",
|
||||
description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`,
|
||||
});
|
||||
revalidatePath("/admin/vpn");
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw; the page
|
||||
// re-renders the current (stored) state.
|
||||
}
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "vpn_update",
|
||||
description: `Updated VPN/proxy detection (block=${enabled ? "on" : "off"}, provider=${provider})`,
|
||||
});
|
||||
revalidatePath("/admin/vpn");
|
||||
} catch {
|
||||
// DB unavailable — fail soft so the action does not throw; the page
|
||||
// re-renders the current (stored) state.
|
||||
}
|
||||
|
||||
redirect("/admin/vpn?saved=1");
|
||||
redirect("/admin/vpn?saved=1");
|
||||
}
|
||||
@@ -6,32 +6,32 @@ import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
export async function addWord(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const word = String(formData.get("word") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!word) return;
|
||||
await requireStaff();
|
||||
const word = String(formData.get("word") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!word) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteWordfilter.create({ data: { word } });
|
||||
await rcon.updateWordFilter();
|
||||
} catch {
|
||||
// ignore (e.g. duplicate word) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/wordfilter");
|
||||
try {
|
||||
await prisma.websiteWordfilter.create({ data: { word } });
|
||||
await rcon.updateWordFilter();
|
||||
} catch {
|
||||
// ignore (e.g. duplicate word) — page re-renders current state
|
||||
}
|
||||
revalidatePath("/admin/wordfilter");
|
||||
}
|
||||
|
||||
export async function deleteWord(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
await requireStaff();
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } });
|
||||
await rcon.updateWordFilter();
|
||||
} catch {
|
||||
// ignore (e.g. already removed)
|
||||
}
|
||||
revalidatePath("/admin/wordfilter");
|
||||
try {
|
||||
await prisma.websiteWordfilter.delete({ where: { id: BigInt(raw) } });
|
||||
await rcon.updateWordFilter();
|
||||
} catch {
|
||||
// ignore (e.g. already removed)
|
||||
}
|
||||
revalidatePath("/admin/wordfilter");
|
||||
}
|
||||
@@ -11,163 +11,165 @@ import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
/** Parse a non-negative Int form value, falling back to 0. */
|
||||
function reqInt(formData: FormData, key: string): number {
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw === "") return 0;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.floor(n);
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw === "") return 0;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/** Parse the BigInt `id` form value, returning null when blank/invalid. */
|
||||
function parseId(formData: FormData): bigint | null {
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return BigInt(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return BigInt(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function revalidate(): void {
|
||||
revalidatePath("/admin/writeable-boxes");
|
||||
// Active boxes render on the public home page (root layout).
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/writeable-boxes");
|
||||
// Active boxes render on the public home page (root layout).
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
export async function createBox(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!title) return;
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.websiteWriteableBoxes.create({
|
||||
data: {
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_create",
|
||||
description: `Created writeable box "${title}" (#${created.id})`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — swallow and re-render.
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
try {
|
||||
const created = await prisma.websiteWriteableBoxes.create({
|
||||
data: {
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive:
|
||||
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_create",
|
||||
description: `Created writeable box "${title}" (#${created.id})`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(created.id),
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — swallow and re-render.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
revalidate();
|
||||
}
|
||||
|
||||
export async function updateBox(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!title) return;
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_update",
|
||||
description: `Updated writeable box #${id} ("${title}")`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive:
|
||||
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_update",
|
||||
description: `Updated writeable box #${id} ("${title}")`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
revalidate();
|
||||
}
|
||||
|
||||
export async function deleteBox(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_delete",
|
||||
description: `Deleted writeable box #${id}`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_delete",
|
||||
description: `Deleted writeable box #${id}`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
revalidate();
|
||||
}
|
||||
|
||||
export async function toggleBox(formData: FormData): Promise<void> {
|
||||
const staff = await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
// `next` carries the desired state ("1" to activate, anything else to hide).
|
||||
const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
|
||||
// `next` carries the desired state ("1" to activate, anything else to hide).
|
||||
const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
|
||||
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.update({
|
||||
where: { id },
|
||||
data: { isActive: next, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_toggle",
|
||||
description: `${next ? "Activated" : "Hid"} writeable box #${id}`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await prisma.websiteWriteableBoxes.update({
|
||||
where: { id },
|
||||
data: { isActive: next, updatedAt: new Date() },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_toggle",
|
||||
description: `${next ? "Activated" : "Hid"} writeable box #${id}`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
revalidate();
|
||||
}
|
||||
+53
-53
@@ -20,38 +20,38 @@ const CONTENT_MAX = 5000;
|
||||
* - content must be at least 10 characters.
|
||||
*/
|
||||
export async function applyStaff(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
// rank_id comes from the open position's permission_id (an Int in the schema).
|
||||
const rankId = Number(formData.get("rankId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
// rank_id comes from the open position's permission_id (an Int in the schema).
|
||||
const rankId = Number(formData.get("rankId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
|
||||
try {
|
||||
// Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition).
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
try {
|
||||
// Block duplicate applications for the same rank (AtomCMS hasAppliedForPosition).
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/apply/staff");
|
||||
revalidatePath("/apply/staff");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,35 +64,35 @@ export async function applyStaff(formData: FormData): Promise<void> {
|
||||
* may only apply once per team.
|
||||
*/
|
||||
export async function applyTeam(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
// website_teams.id is a BigInt; rank_id on the application is an Int. The team
|
||||
// id is the application's rank flag.
|
||||
const rankId = Number(formData.get("teamId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
// website_teams.id is a BigInt; rank_id on the application is an Int. The team
|
||||
// id is the application's rank flag.
|
||||
const rankId = Number(formData.get("teamId"));
|
||||
if (!Number.isInteger(rankId) || rankId <= 0) return;
|
||||
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
const content = String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, CONTENT_MAX);
|
||||
if (content.length < CONTENT_MIN) return;
|
||||
|
||||
try {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
try {
|
||||
const existing = await prisma.websiteStaffApplications.findFirst({
|
||||
where: { userId, rankId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return;
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
await prisma.websiteStaffApplications.create({
|
||||
data: { userId, rankId, content, createdAt: now, updatedAt: now },
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/apply/team");
|
||||
revalidatePath("/apply/team");
|
||||
}
|
||||
@@ -15,57 +15,57 @@ const COMMENT_MAX = 255;
|
||||
* 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 session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) return;
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) return;
|
||||
|
||||
const comment = String(formData.get("comment") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, COMMENT_MAX);
|
||||
if (!comment) return;
|
||||
const comment = String(formData.get("comment") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, COMMENT_MAX);
|
||||
if (!comment) return;
|
||||
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
if (!(await isAllowed(comment)).ok) return;
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
if (!(await isAllowed(comment)).ok) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
let articleId: bigint;
|
||||
try {
|
||||
articleId = BigInt(articleIdRaw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
let articleId: bigint;
|
||||
try {
|
||||
articleId = BigInt(articleIdRaw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
let slug: string | 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;
|
||||
let slug: string | 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;
|
||||
}
|
||||
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}`);
|
||||
if (slug) revalidatePath(`/news/${slug}`);
|
||||
}
|
||||
@@ -25,75 +25,75 @@ const ALLOWED_REACTIONS = new Set(["like", "love", "wow"]);
|
||||
* unique key, so we resolve the existing row with findFirst rather than upsert.
|
||||
*/
|
||||
export async function toggleReaction(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) return;
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) return;
|
||||
|
||||
const reaction = String(formData.get("reaction") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
||||
const reaction = String(formData.get("reaction") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!ALLOWED_REACTIONS.has(reaction)) return;
|
||||
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
const articleIdRaw = String(formData.get("articleId") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(articleIdRaw)) return;
|
||||
|
||||
let articleId: bigint;
|
||||
try {
|
||||
articleId = BigInt(articleIdRaw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
let articleId: bigint;
|
||||
try {
|
||||
articleId = BigInt(articleIdRaw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
let slug: string | 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;
|
||||
let slug: string | 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;
|
||||
|
||||
// The user's current row for THIS reaction on THIS article, if any.
|
||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||
where: { userId, articleId, reaction },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
// The user's current row for THIS reaction on THIS article, if any.
|
||||
const existing = await prisma.websiteArticleReactions.findFirst({
|
||||
where: { userId, articleId, reaction },
|
||||
select: { id: true, active: true },
|
||||
});
|
||||
|
||||
if (existing?.active) {
|
||||
// Already reacting with this exact reaction -> un-vote (deactivate it).
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: false },
|
||||
});
|
||||
} else {
|
||||
// Switching to (or first-time picking) this reaction: clear any other
|
||||
// active reaction by this user on this article, then activate this one.
|
||||
await prisma.websiteArticleReactions.updateMany({
|
||||
where: { userId, articleId, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
if (existing?.active) {
|
||||
// Already reacting with this exact reaction -> un-vote (deactivate it).
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: false },
|
||||
});
|
||||
} else {
|
||||
// Switching to (or first-time picking) this reaction: clear any other
|
||||
// active reaction by this user on this article, then activate this one.
|
||||
await prisma.websiteArticleReactions.updateMany({
|
||||
where: { userId, articleId, active: true },
|
||||
data: { active: false },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: true },
|
||||
});
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.create({
|
||||
data: { userId, articleId, reaction, active: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
await prisma.websiteArticleReactions.update({
|
||||
where: { id: existing.id },
|
||||
data: { active: true },
|
||||
});
|
||||
} else {
|
||||
await prisma.websiteArticleReactions.create({
|
||||
data: { userId, articleId, reaction, active: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
if (slug) revalidatePath(`/news/${slug}`);
|
||||
if (slug) revalidatePath(`/news/${slug}`);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { env } from "@/env";
|
||||
|
||||
export type PrecheckResult = "ok" | "invalid" | "twofactor";
|
||||
|
||||
@@ -11,36 +11,44 @@ export type PrecheckResult = "ok" | "invalid" | "twofactor";
|
||||
* Validates username+password WITHOUT creating a session, and reports whether a
|
||||
* TOTP code is still required. Lets the login form do the two-step 2FA flow.
|
||||
*/
|
||||
export async function precheckLogin(username: string, password: string): Promise<PrecheckResult> {
|
||||
const u = String(username ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const p = String(password ?? "");
|
||||
if (!u || !p) return "invalid";
|
||||
export async function precheckLogin(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<PrecheckResult> {
|
||||
const u = String(username ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const p = String(password ?? "");
|
||||
if (!u || !p) return "invalid";
|
||||
|
||||
if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok) return "invalid";
|
||||
if (!(await rateLimit(`precheck:${await clientIp()}`, 10, 5 * 60_000)).ok)
|
||||
return "invalid";
|
||||
|
||||
let user: { password: string; twoFactorConfirmedAt: Date | null } | null;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username: u },
|
||||
select: { password: true, twoFactorConfirmedAt: true },
|
||||
});
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
if (!user) {
|
||||
// Prevent timing-based enumeration: always run a dummy hash check.
|
||||
await checkLogin(p, "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd", {
|
||||
convertPasswords: false,
|
||||
});
|
||||
return "invalid";
|
||||
}
|
||||
let user: { password: string; twoFactorConfirmedAt: Date | null } | null;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { username: u },
|
||||
select: { password: true, twoFactorConfirmedAt: true },
|
||||
});
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
if (!user) {
|
||||
// Prevent timing-based enumeration: always run a dummy hash check.
|
||||
await checkLogin(
|
||||
p,
|
||||
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
|
||||
{
|
||||
convertPasswords: false,
|
||||
},
|
||||
);
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
const res = await checkLogin(p, user.password, {
|
||||
convertPasswords: env.CONVERT_PASSWORDS,
|
||||
});
|
||||
if (!res.valid) return "invalid";
|
||||
const res = await checkLogin(p, user.password, {
|
||||
convertPasswords: env.CONVERT_PASSWORDS,
|
||||
});
|
||||
if (!res.valid) return "invalid";
|
||||
|
||||
return user.twoFactorConfirmedAt ? "twofactor" : "ok";
|
||||
return user.twoFactorConfirmedAt ? "twofactor" : "ok";
|
||||
}
|
||||
+32
-21
@@ -5,27 +5,38 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function getBadgeData({ code }: { code: string }) {
|
||||
await requireStaff();
|
||||
const badge = await prisma.websiteBadges.findUnique({
|
||||
where: { badgeKey: code },
|
||||
select: { badgeName: true, badgeDescription: true },
|
||||
});
|
||||
if (!badge) return { ok: false as const, data: null };
|
||||
return { ok: true as const, data: { name: badge.badgeName, desc: badge.badgeDescription } };
|
||||
await requireStaff();
|
||||
const badge = await prisma.websiteBadges.findUnique({
|
||||
where: { badgeKey: code },
|
||||
select: { badgeName: true, badgeDescription: true },
|
||||
});
|
||||
if (!badge) return { ok: false as const, data: null };
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { name: badge.badgeName, desc: badge.badgeDescription },
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateBadge({ code, name, desc }: { code: string; name: string; desc: string }) {
|
||||
await requireStaff();
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
|
||||
create: {
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: desc,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/import/badges");
|
||||
export async function updateBadge({
|
||||
code,
|
||||
name,
|
||||
desc,
|
||||
}: {
|
||||
code: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
}) {
|
||||
await requireStaff();
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: { badgeName: name, badgeDescription: desc, updatedAt: new Date() },
|
||||
create: {
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: desc,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/import/badges");
|
||||
}
|
||||
+49
-47
@@ -8,63 +8,65 @@ 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(),
|
||||
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 });
|
||||
},
|
||||
{ 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() });
|
||||
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 });
|
||||
},
|
||||
{ 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();
|
||||
},
|
||||
{ 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();
|
||||
},
|
||||
);
|
||||
+148
-125
@@ -1,159 +1,182 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
|
||||
export async function bulkUnban({
|
||||
userIds,
|
||||
userIds,
|
||||
}: {
|
||||
userIds: number[];
|
||||
userIds: number[];
|
||||
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
|
||||
const staff = await requireStaff();
|
||||
const result = await prisma.ban.deleteMany({ where: { userId: { in: userIds } } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_unban",
|
||||
description: `Unbanned ${result.count} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return { ok: true as const, data: { unbanned: result.count, total: userIds.length } };
|
||||
const staff = await requireStaff();
|
||||
const result = await prisma.ban.deleteMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_unban",
|
||||
description: `Unbanned ${result.count} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { unbanned: result.count, total: userIds.length },
|
||||
};
|
||||
}
|
||||
|
||||
export async function bulkBan({
|
||||
userIds,
|
||||
reason,
|
||||
duration,
|
||||
userIds,
|
||||
reason,
|
||||
duration,
|
||||
}: {
|
||||
userIds: number[];
|
||||
reason: string;
|
||||
duration: number;
|
||||
userIds: number[];
|
||||
reason: string;
|
||||
duration: number;
|
||||
}): Promise<ActionResult<{ banned: number }>> {
|
||||
const staff = await requireStaff();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let banned = 0;
|
||||
const staff = await requireStaff();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let banned = 0;
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire: duration > 0 ? now + duration : 0,
|
||||
banReason: reason,
|
||||
type: "account",
|
||||
},
|
||||
});
|
||||
banned++;
|
||||
} catch {
|
||||
// skip duplicates
|
||||
}
|
||||
}
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
ip: "",
|
||||
machineId: "",
|
||||
userStaffId: staff.id,
|
||||
timestamp: now,
|
||||
banExpire: duration > 0 ? now + duration : 0,
|
||||
banReason: reason,
|
||||
type: "account",
|
||||
},
|
||||
});
|
||||
banned++;
|
||||
} catch {
|
||||
// skip duplicates
|
||||
}
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_ban",
|
||||
description: `Banned ${banned} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return { ok: true as const, data: { banned } };
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_ban",
|
||||
description: `Banned ${banned} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return { ok: true as const, data: { banned } };
|
||||
}
|
||||
|
||||
export async function bulkGiveCurrency({
|
||||
userIds,
|
||||
amount,
|
||||
type,
|
||||
userIds,
|
||||
amount,
|
||||
type,
|
||||
}: {
|
||||
userIds: number[];
|
||||
amount: number;
|
||||
type: "credits" | "pixels" | "points";
|
||||
userIds: number[];
|
||||
amount: number;
|
||||
type: "credits" | "pixels" | "points";
|
||||
}): Promise<
|
||||
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>
|
||||
ActionResult<{
|
||||
given: number;
|
||||
total: number;
|
||||
failedIds: Array<{ userId: number; reason: string }>;
|
||||
}>
|
||||
> {
|
||||
const staff = await requireStaff();
|
||||
let given = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
const staff = await requireStaff();
|
||||
let given = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
await prisma.user.update({ where: { id: userId }, data: { credits: { increment: amount } } });
|
||||
await rcon.giveCredits(userId, amount);
|
||||
} else if (type === "pixels") {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: 0 } },
|
||||
update: { amount: { increment: amount } },
|
||||
create: { userId, type: 0, amount },
|
||||
});
|
||||
await rcon.giveDuckets(userId, amount);
|
||||
} else if (type === "points") {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: 101 } },
|
||||
update: { amount: { increment: amount } },
|
||||
create: { userId, type: 101, amount },
|
||||
});
|
||||
await rcon.givePointsGotw(userId, amount);
|
||||
}
|
||||
given++;
|
||||
} catch {
|
||||
failedIds.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
if (type === "credits") {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { increment: amount } },
|
||||
});
|
||||
await rcon.giveCredits(userId, amount);
|
||||
} else if (type === "pixels") {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: 0 } },
|
||||
update: { amount: { increment: amount } },
|
||||
create: { userId, type: 0, amount },
|
||||
});
|
||||
await rcon.giveDuckets(userId, amount);
|
||||
} else if (type === "points") {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId, type: 101 } },
|
||||
update: { amount: { increment: amount } },
|
||||
create: { userId, type: 101, amount },
|
||||
});
|
||||
await rcon.givePointsGotw(userId, amount);
|
||||
}
|
||||
given++;
|
||||
} catch {
|
||||
failedIds.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_give_currency",
|
||||
description: `Gave ${amount} ${type} to ${given} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_give_currency",
|
||||
description: `Gave ${amount} ${type} to ${given} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { given, total: userIds.length, failedIds },
|
||||
};
|
||||
}
|
||||
|
||||
export async function bulkGiveBadge({
|
||||
userIds,
|
||||
badgeCode,
|
||||
userIds,
|
||||
badgeCode,
|
||||
}: {
|
||||
userIds: number[];
|
||||
badgeCode: string;
|
||||
userIds: number[];
|
||||
badgeCode: string;
|
||||
}): Promise<
|
||||
ActionResult<{ given: number; total: number; failedIds: Array<{ userId: number; reason: string }> }>
|
||||
ActionResult<{
|
||||
given: number;
|
||||
total: number;
|
||||
failedIds: Array<{ userId: number; reason: string }>;
|
||||
}>
|
||||
> {
|
||||
const staff = await requireStaff();
|
||||
let given = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
const staff = await requireStaff();
|
||||
let given = 0;
|
||||
const failedIds: Array<{ userId: number; reason: string }> = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
const existing = await prisma.usersBadges.findFirst({
|
||||
where: { userId, badgeCode },
|
||||
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 } });
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
}
|
||||
given++;
|
||||
} catch {
|
||||
failedIds.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
const existing = await prisma.usersBadges.findFirst({
|
||||
where: { userId, badgeCode },
|
||||
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 },
|
||||
});
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
}
|
||||
given++;
|
||||
} catch {
|
||||
failedIds.push({ userId, reason: "Database error" });
|
||||
}
|
||||
}
|
||||
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_give_badge",
|
||||
description: `Gave badge "${badgeCode}" to ${given} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return { ok: true as const, data: { given, total: userIds.length, failedIds } };
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bulk_give_badge",
|
||||
description: `Gave badge "${badgeCode}" to ${given} user(s)`,
|
||||
targetType: "user",
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
data: { given, total: userIds.length, failedIds },
|
||||
};
|
||||
}
|
||||
+108
-95
@@ -6,116 +6,129 @@ import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function updateBcPage({ id, ...fields }: { id: number } & Record<string, unknown>) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogPagesBc.update({ where: { id }, data: fields as any });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_update",
|
||||
description: `Updated BC catalog page #${id}`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
export async function updateBcPage({
|
||||
id,
|
||||
...fields
|
||||
}: { id: number } & Record<string, unknown>) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogPagesBc.update({ where: { id }, data: fields as any });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_update",
|
||||
description: `Updated BC catalog page #${id}`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function deleteBcItem({ id }: { id: number }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItemsBc.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_delete",
|
||||
description: `Deleted BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItemsBc.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_delete",
|
||||
description: `Deleted BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function updateBcItem({
|
||||
id,
|
||||
...data
|
||||
id,
|
||||
...data
|
||||
}: {
|
||||
id: number;
|
||||
itemIds?: string;
|
||||
catalogName?: string;
|
||||
orderNumber?: number;
|
||||
extradata?: string;
|
||||
id: number;
|
||||
itemIds?: string;
|
||||
catalogName?: string;
|
||||
orderNumber?: number;
|
||||
extradata?: string;
|
||||
}) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_update",
|
||||
description: `Updated BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItemsBc.update({ where: { id }, data: data as any });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_update",
|
||||
description: `Updated BC catalog item #${id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function createBcItem({
|
||||
pageId,
|
||||
...data
|
||||
pageId,
|
||||
...data
|
||||
}: {
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
orderNumber: number;
|
||||
extradata: string;
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
orderNumber: number;
|
||||
extradata: string;
|
||||
}) {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogItemsBc.create({
|
||||
data: { pageId, ...data },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_create",
|
||||
description: `Created BC catalog item #${created.id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogItemsBc.create({
|
||||
data: { pageId, ...data },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_item_create",
|
||||
description: `Created BC catalog item #${created.id}`,
|
||||
targetType: "catalog_item_bc",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function toggleBcPage({ id, field }: { id: number; field: "enabled" | "visible" }) {
|
||||
await requireStaff();
|
||||
const page = await prisma.catalogPagesBc.findUnique({
|
||||
where: { id },
|
||||
select: { enabled: true, visible: true },
|
||||
});
|
||||
if (!page) return;
|
||||
await prisma.catalogPagesBc.update({
|
||||
where: { id },
|
||||
data: { [field]: page[field] === "1" ? "0" : "1" },
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
export async function toggleBcPage({
|
||||
id,
|
||||
field,
|
||||
}: {
|
||||
id: number;
|
||||
field: "enabled" | "visible";
|
||||
}) {
|
||||
await requireStaff();
|
||||
const page = await prisma.catalogPagesBc.findUnique({
|
||||
where: { id },
|
||||
select: { enabled: true, visible: true },
|
||||
});
|
||||
if (!page) return;
|
||||
await prisma.catalogPagesBc.update({
|
||||
where: { id },
|
||||
data: { [field]: page[field] === "1" ? "0" : "1" },
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
}
|
||||
|
||||
export async function createBcPage(input: { caption: string; parentId: number; pageLayout: string }) {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogPagesBc.create({
|
||||
data: {
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout,
|
||||
iconColor: 0,
|
||||
iconImage: 0,
|
||||
orderNum: 0,
|
||||
visible: "1",
|
||||
enabled: "1",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_create",
|
||||
description: `Created BC catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
export async function createBcPage(input: {
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout: string;
|
||||
}) {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogPagesBc.create({
|
||||
data: {
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout,
|
||||
iconColor: 0,
|
||||
iconImage: 0,
|
||||
orderNum: 0,
|
||||
visible: "1",
|
||||
enabled: "1",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "bc_page_create",
|
||||
description: `Created BC catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page_bc",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog/builder-club");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
}
|
||||
+136
-124
@@ -7,153 +7,165 @@ import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function createCatalogItem(data: {
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
orderNumber: number;
|
||||
offerId: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
extradata: string;
|
||||
songId: number;
|
||||
haveOffer: "0" | "1";
|
||||
clubOnly: "0" | "1";
|
||||
pageId: number;
|
||||
itemIds: string;
|
||||
catalogName: string;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
amount: number;
|
||||
orderNumber: number;
|
||||
offerId: number;
|
||||
limitedSells: number;
|
||||
limitedStack: number;
|
||||
extradata: string;
|
||||
songId: number;
|
||||
haveOffer: "0" | "1";
|
||||
clubOnly: "0" | "1";
|
||||
}) {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogItems.create({ data });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_create",
|
||||
description: `Created catalog item #${created.id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogItems.create({ data });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_create",
|
||||
description: `Created catalog item #${created.id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
}
|
||||
|
||||
export async function deleteCatalogItems({ ids }: { ids: number[] }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_delete",
|
||||
description: `Deleted catalog items: ${ids.join(", ")}`,
|
||||
targetType: "catalog_item",
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItems.deleteMany({ where: { id: { in: ids } } });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_items_delete",
|
||||
description: `Deleted catalog items: ${ids.join(", ")}`,
|
||||
targetType: "catalog_item",
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function moveCatalogItems({ ids, targetPageId }: { ids: number[]; targetPageId: number }) {
|
||||
await requireStaff();
|
||||
await prisma.catalogItems.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { pageId: targetPageId },
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
export async function moveCatalogItems({
|
||||
ids,
|
||||
targetPageId,
|
||||
}: {
|
||||
ids: number[];
|
||||
targetPageId: number;
|
||||
}) {
|
||||
await requireStaff();
|
||||
await prisma.catalogItems.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { pageId: targetPageId },
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function reorderCatalogItems({
|
||||
orders,
|
||||
orders,
|
||||
}: {
|
||||
orders: Array<{ id: number; orderNumber: number }>;
|
||||
orders: Array<{ id: number; orderNumber: number }>;
|
||||
}) {
|
||||
await requireStaff();
|
||||
for (const { id, orderNumber } of orders) {
|
||||
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
await requireStaff();
|
||||
for (const { id, orderNumber } of orders) {
|
||||
await prisma.catalogItems.update({ where: { id }, data: { orderNumber } });
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function updateCatalogItem({
|
||||
id,
|
||||
catalogFields,
|
||||
baseItem,
|
||||
id,
|
||||
catalogFields,
|
||||
baseItem,
|
||||
}: {
|
||||
id: number;
|
||||
catalogFields: Record<string, unknown>;
|
||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||
id: number;
|
||||
catalogFields: Record<string, unknown>;
|
||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||
}) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItems.update({ where: { id }, data: catalogFields as any });
|
||||
if (baseItem) {
|
||||
await prisma.itemsBase.update({ where: { id: baseItem.id }, data: baseItem.fields as any });
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_update",
|
||||
description: `Updated catalog item #${id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogItems.update({
|
||||
where: { id },
|
||||
data: catalogFields as any,
|
||||
});
|
||||
if (baseItem) {
|
||||
await prisma.itemsBase.update({
|
||||
where: { id: baseItem.id },
|
||||
data: baseItem.fields as any,
|
||||
});
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_item_update",
|
||||
description: `Updated catalog item #${id}`,
|
||||
targetType: "catalog_item",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function translateCatalogItems({
|
||||
items,
|
||||
items,
|
||||
}: {
|
||||
items: Array<{ id: number; publicName: string; description: string }>;
|
||||
items: Array<{ id: number; publicName: string; description: string }>;
|
||||
}) {
|
||||
await requireStaff();
|
||||
let namesUpdated = 0;
|
||||
let descriptionsUpdated = 0;
|
||||
let furniDataUpdated = 0;
|
||||
let furniDataInserted = 0;
|
||||
await requireStaff();
|
||||
let namesUpdated = 0;
|
||||
let descriptionsUpdated = 0;
|
||||
let furniDataUpdated = 0;
|
||||
const _furniDataInserted = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const existing = await prisma.catalogItems.findUnique({
|
||||
where: { id: item.id },
|
||||
select: { catalogName: true },
|
||||
});
|
||||
if (!existing) continue;
|
||||
for (const item of items) {
|
||||
const existing = await prisma.catalogItems.findUnique({
|
||||
where: { id: item.id },
|
||||
select: { catalogName: true },
|
||||
});
|
||||
if (!existing) continue;
|
||||
|
||||
if (item.publicName && item.publicName !== existing.catalogName) {
|
||||
await prisma.catalogItems.update({
|
||||
where: { id: item.id },
|
||||
data: { catalogName: item.publicName },
|
||||
});
|
||||
namesUpdated++;
|
||||
}
|
||||
if (item.publicName && item.publicName !== existing.catalogName) {
|
||||
await prisma.catalogItems.update({
|
||||
where: { id: item.id },
|
||||
data: { catalogName: item.publicName },
|
||||
});
|
||||
namesUpdated++;
|
||||
}
|
||||
|
||||
if (item.description) {
|
||||
const baseItem = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: existing.catalogName },
|
||||
select: { id: true, publicName: true },
|
||||
});
|
||||
if (baseItem) {
|
||||
await prisma.itemsBase.update({
|
||||
where: { id: baseItem.id },
|
||||
data: { publicName: item.publicName || baseItem.publicName },
|
||||
});
|
||||
furniDataUpdated++;
|
||||
}
|
||||
descriptionsUpdated++;
|
||||
}
|
||||
}
|
||||
if (item.description) {
|
||||
const baseItem = await prisma.itemsBase.findFirst({
|
||||
where: { itemName: existing.catalogName },
|
||||
select: { id: true, publicName: true },
|
||||
});
|
||||
if (baseItem) {
|
||||
await prisma.itemsBase.update({
|
||||
where: { id: baseItem.id },
|
||||
data: { publicName: item.publicName || baseItem.publicName },
|
||||
});
|
||||
furniDataUpdated++;
|
||||
}
|
||||
descriptionsUpdated++;
|
||||
}
|
||||
}
|
||||
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
furniDataUpdated,
|
||||
furniDataInserted: 0,
|
||||
updated: items.length,
|
||||
},
|
||||
};
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return {
|
||||
ok: true as const,
|
||||
data: {
|
||||
namesUpdated,
|
||||
descriptionsUpdated,
|
||||
furniDataUpdated,
|
||||
furniDataInserted: 0,
|
||||
updated: items.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
+134
-125
@@ -3,150 +3,159 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import type { ActionResult } from "@/lib/safe-action-shared";
|
||||
|
||||
export async function updateCatalogPage({
|
||||
id,
|
||||
...fields
|
||||
id,
|
||||
...fields
|
||||
}: { id: number } & Record<string, unknown>): Promise<ActionResult> {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogPages.update({ where: { id }, data: fields as any });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_update",
|
||||
description: `Updated catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogPages.update({ where: { id }, data: fields as any });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_update",
|
||||
description: `Updated catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function deleteCatalogPage({ id }: { id: number }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogPages.delete({ where: { id } });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_delete",
|
||||
description: `Deleted catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
const staff = await requireStaff();
|
||||
await prisma.catalogPages.delete({ where: { id } });
|
||||
await rcon.updateCatalog();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_delete",
|
||||
description: `Deleted catalog page #${id}`,
|
||||
targetType: "catalog_page",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function toggleCatalogPage({
|
||||
id,
|
||||
action,
|
||||
id,
|
||||
action,
|
||||
}: {
|
||||
id: number;
|
||||
action: "toggleEnabled" | "toggleVisible";
|
||||
id: number;
|
||||
action: "toggleEnabled" | "toggleVisible";
|
||||
}) {
|
||||
await requireStaff();
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id },
|
||||
select: { enabled: true, visible: true },
|
||||
});
|
||||
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||
await prisma.catalogPages.update({
|
||||
where: { id },
|
||||
data: { [field]: current === "1" ? "0" : "1" },
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
await requireStaff();
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id },
|
||||
select: { enabled: true, visible: true },
|
||||
});
|
||||
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||
const field = action === "toggleEnabled" ? "enabled" : "visible";
|
||||
const current = action === "toggleEnabled" ? page.enabled : page.visible;
|
||||
await prisma.catalogPages.update({
|
||||
where: { id },
|
||||
data: { [field]: current === "1" ? "0" : "1" },
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function createCatalogPage(input: {
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout?: string;
|
||||
iconImage?: number;
|
||||
iconColor?: number;
|
||||
enabled?: "0" | "1";
|
||||
visible?: "0" | "1";
|
||||
minRank?: number;
|
||||
orderNum?: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout?: string;
|
||||
iconImage?: number;
|
||||
iconColor?: number;
|
||||
enabled?: "0" | "1";
|
||||
visible?: "0" | "1";
|
||||
minRank?: number;
|
||||
orderNum?: number;
|
||||
}): Promise<ActionResult<{ id: number }>> {
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogPages.create({
|
||||
data: {
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout ?? "default_3x3",
|
||||
captionSave: input.caption.slice(0, 25),
|
||||
iconColor: input.iconColor ?? 0,
|
||||
iconImage: input.iconImage ?? 0,
|
||||
minRank: input.minRank ?? 1,
|
||||
orderNum: input.orderNum ?? 0,
|
||||
visible: input.visible ?? "1",
|
||||
enabled: input.enabled ?? "1",
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_create",
|
||||
description: `Created catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
const staff = await requireStaff();
|
||||
const created = await prisma.catalogPages.create({
|
||||
data: {
|
||||
caption: input.caption,
|
||||
parentId: input.parentId,
|
||||
pageLayout: input.pageLayout ?? "default_3x3",
|
||||
captionSave: input.caption.slice(0, 25),
|
||||
iconColor: input.iconColor ?? 0,
|
||||
iconImage: input.iconImage ?? 0,
|
||||
minRank: input.minRank ?? 1,
|
||||
orderNum: input.orderNum ?? 0,
|
||||
visible: input.visible ?? "1",
|
||||
enabled: input.enabled ?? "1",
|
||||
clubOnly: "0",
|
||||
vipOnly: "0",
|
||||
pageHeadline: "",
|
||||
pageTeaser: "",
|
||||
includes: "",
|
||||
},
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_create",
|
||||
description: `Created catalog page "${input.caption}"`,
|
||||
targetType: "catalog_page",
|
||||
targetId: created.id,
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { id: created.id } };
|
||||
}
|
||||
|
||||
export async function reorderTreePage(input: { pageId: number; newParentId?: number; newOrderNum: number }) {
|
||||
await requireStaff();
|
||||
await prisma.catalogPages.update({
|
||||
where: { id: input.pageId },
|
||||
data: {
|
||||
orderNum: input.newOrderNum,
|
||||
...(input.newParentId === undefined ? {} : { parentId: input.newParentId }),
|
||||
},
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
export async function reorderTreePage(input: {
|
||||
pageId: number;
|
||||
newParentId?: number;
|
||||
newOrderNum: number;
|
||||
}) {
|
||||
await requireStaff();
|
||||
await prisma.catalogPages.update({
|
||||
where: { id: input.pageId },
|
||||
data: {
|
||||
orderNum: input.newOrderNum,
|
||||
...(input.newParentId === undefined
|
||||
? {}
|
||||
: { parentId: input.newParentId }),
|
||||
},
|
||||
});
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
|
||||
export async function deleteTreePage(input: { pageId: number; mode: "reparent" | "cascade" }) {
|
||||
await requireStaff();
|
||||
if (input.mode === "cascade") {
|
||||
const children = await prisma.catalogPages.findMany({
|
||||
where: { parentId: input.pageId },
|
||||
select: { id: true },
|
||||
});
|
||||
const pageIds = [input.pageId, ...children.map((child) => child.id)];
|
||||
await prisma.$transaction([
|
||||
prisma.catalogItems.deleteMany({ where: { pageId: { in: pageIds } } }),
|
||||
prisma.catalogPages.deleteMany({ where: { id: { in: pageIds } } }),
|
||||
]);
|
||||
} else {
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: input.pageId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||
await prisma.$transaction([
|
||||
prisma.catalogPages.updateMany({
|
||||
where: { parentId: input.pageId },
|
||||
data: { parentId: page.parentId },
|
||||
}),
|
||||
prisma.catalogItems.deleteMany({ where: { pageId: input.pageId } }),
|
||||
prisma.catalogPages.delete({ where: { id: input.pageId } }),
|
||||
]);
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
export async function deleteTreePage(input: {
|
||||
pageId: number;
|
||||
mode: "reparent" | "cascade";
|
||||
}) {
|
||||
await requireStaff();
|
||||
if (input.mode === "cascade") {
|
||||
const children = await prisma.catalogPages.findMany({
|
||||
where: { parentId: input.pageId },
|
||||
select: { id: true },
|
||||
});
|
||||
const pageIds = [input.pageId, ...children.map((child) => child.id)];
|
||||
await prisma.$transaction([
|
||||
prisma.catalogItems.deleteMany({ where: { pageId: { in: pageIds } } }),
|
||||
prisma.catalogPages.deleteMany({ where: { id: { in: pageIds } } }),
|
||||
]);
|
||||
} else {
|
||||
const page = await prisma.catalogPages.findUnique({
|
||||
where: { id: input.pageId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!page) return { ok: false as const, error: "Catalog page not found" };
|
||||
await prisma.$transaction([
|
||||
prisma.catalogPages.updateMany({
|
||||
where: { parentId: input.pageId },
|
||||
data: { parentId: page.parentId },
|
||||
}),
|
||||
prisma.catalogItems.deleteMany({ where: { pageId: input.pageId } }),
|
||||
prisma.catalogPages.delete({ where: { id: input.pageId } }),
|
||||
]);
|
||||
}
|
||||
await rcon.updateCatalog();
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: {} };
|
||||
}
|
||||
+158
-158
@@ -8,218 +8,218 @@ const PATH = "/admin/commandocentrum";
|
||||
|
||||
/** Rebuild the in-memory catalog on the emulator (rcon: updatecatalog). */
|
||||
export async function updateCatalog(): Promise<void> {
|
||||
await requireStaff();
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
// RCON is best-effort; a dead socket must not 500 the admin page.
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
try {
|
||||
await rcon.updateCatalog();
|
||||
} catch {
|
||||
// RCON is best-effort; a dead socket must not 500 the admin page.
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Reload the chat word filter on the emulator (rcon: updatewordfilter). */
|
||||
export async function updateWordFilter(): Promise<void> {
|
||||
await requireStaff();
|
||||
try {
|
||||
await rcon.updateWordFilter();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
try {
|
||||
await rcon.updateWordFilter();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Reload navigator data on the emulator (rcon: updatenavigator, no payload). */
|
||||
export async function updateNavigator(): Promise<void> {
|
||||
await requireStaff();
|
||||
try {
|
||||
await rcon.send("updatenavigator", null);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
try {
|
||||
await rcon.send("updatenavigator", null);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Broadcast a hotel-wide alert to every connected user (rcon: hotelalert). */
|
||||
export async function hotelAlert(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
if (!message) return;
|
||||
try {
|
||||
await rcon.send("hotelalert", { message });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
if (!message) return;
|
||||
try {
|
||||
await rcon.send("hotelalert", { message });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Disconnect/kick a user from the hotel (rcon: disconnect). */
|
||||
export async function disconnectUser(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !username) return;
|
||||
try {
|
||||
await rcon.disconnectUser(userId, username);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !username) return;
|
||||
try {
|
||||
await rcon.disconnectUser(userId, username);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Send an alert to a specific user (rcon: alertuser). */
|
||||
export async function alertUser(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
if (!userId || !message) return;
|
||||
try {
|
||||
await rcon.alertUser(userId, message);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
if (!userId || !message) return;
|
||||
try {
|
||||
await rcon.alertUser(userId, message);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Forward a user to a specific room (rcon: forwarduser). */
|
||||
export async function forwardUser(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const roomId = Number(formData.get("roomId"));
|
||||
if (!userId || !roomId) return;
|
||||
try {
|
||||
await rcon.forwardUser(userId, roomId);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const roomId = Number(formData.get("roomId"));
|
||||
if (!userId || !roomId) return;
|
||||
try {
|
||||
await rcon.forwardUser(userId, roomId);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Give credits to a user (rcon: givecredits). */
|
||||
export async function giveCredits(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const credits = Number(formData.get("credits"));
|
||||
if (!userId || !credits || credits <= 0) return;
|
||||
try {
|
||||
await rcon.giveCredits(userId, credits);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const credits = Number(formData.get("credits"));
|
||||
if (!userId || !credits || credits <= 0) return;
|
||||
try {
|
||||
await rcon.giveCredits(userId, credits);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Give duckets to a user (rcon: givepoints type=duckets). */
|
||||
export async function giveDuckets(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (!userId || !amount || amount <= 0) return;
|
||||
try {
|
||||
await rcon.giveDuckets(userId, amount);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (!userId || !amount || amount <= 0) return;
|
||||
try {
|
||||
await rcon.giveDuckets(userId, amount);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Give diamonds to a user (rcon: givepoints type=diamonds). */
|
||||
export async function giveDiamonds(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (!userId || !amount || amount <= 0) return;
|
||||
try {
|
||||
await rcon.giveDiamonds(userId, amount);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const amount = Number(formData.get("amount"));
|
||||
if (!userId || !amount || amount <= 0) return;
|
||||
try {
|
||||
await rcon.giveDiamonds(userId, amount);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Give a badge to a user (rcon: givebadge). */
|
||||
export async function giveBadge(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !badge) return;
|
||||
try {
|
||||
await rcon.giveBadge(userId, badge);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const badge = String(formData.get("badge") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !badge) return;
|
||||
try {
|
||||
await rcon.giveBadge(userId, badge);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Set a user's motto (rcon: setmotto). */
|
||||
export async function setMotto(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 127);
|
||||
if (!userId || !motto) return;
|
||||
try {
|
||||
await rcon.setMotto(userId, motto);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 127);
|
||||
if (!userId || !motto) return;
|
||||
try {
|
||||
await rcon.setMotto(userId, motto);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Set a user's rank (rcon: setrank). */
|
||||
export async function setRank(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const rank = Number(formData.get("rank"));
|
||||
if (!userId || rank < 0 || rank > 10) return;
|
||||
try {
|
||||
await rcon.setRank(userId, rank);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const rank = Number(formData.get("rank"));
|
||||
if (!userId || rank < 0 || rank > 10) return;
|
||||
try {
|
||||
await rcon.setRank(userId, rank);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Execute a command as a user (rcon: executecommand). */
|
||||
export async function executeCommand(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const command = String(formData.get("command") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !command) return;
|
||||
try {
|
||||
await rcon.executeCommand(userId, command);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const command = String(formData.get("command") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!userId || !command) return;
|
||||
try {
|
||||
await rcon.executeCommand(userId, command);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
|
||||
/** Send a gift to a user (rcon: sendgift). */
|
||||
export async function sendGift(formData: FormData): Promise<void> {
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const itemId = Number(formData.get("itemId"));
|
||||
const message = String(formData.get("message") ?? "Here is a gift.")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!userId || !itemId) return;
|
||||
try {
|
||||
await rcon.sendGift(userId, itemId, message);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
await requireStaff();
|
||||
const userId = Number(formData.get("userId"));
|
||||
const itemId = Number(formData.get("itemId"));
|
||||
const message = String(formData.get("message") ?? "Here is a gift.")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!userId || !itemId) return;
|
||||
try {
|
||||
await rcon.sendGift(userId, itemId, message);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
revalidatePath(PATH);
|
||||
}
|
||||
+80
-80
@@ -2,11 +2,11 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Buy a published community-drawn badge for the SIGNED-IN user. Faithful to
|
||||
@@ -30,100 +30,100 @@ const DEFAULT_PRICE = 50;
|
||||
// The emulator badge code is the badge_path filename without its directory or
|
||||
// extension, restricted to the code charset the client accepts.
|
||||
function badgeCodeFromPath(badgePath: string): string {
|
||||
const base = badgePath.split(/[\\/]/).pop() ?? badgePath;
|
||||
const noExt = base.replace(/\.[^.]+$/, "");
|
||||
return noExt.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32);
|
||||
const base = badgePath.split(/[\\/]/).pop() ?? badgePath;
|
||||
const noExt = base.replace(/\.[^.]+$/, "");
|
||||
return noExt.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32);
|
||||
}
|
||||
|
||||
async function resolvePrice(): Promise<number> {
|
||||
const raw = await siteSettings.get("drawbadge.price", String(DEFAULT_PRICE));
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_PRICE;
|
||||
const raw = await siteSettings.get("drawbadge.price", String(DEFAULT_PRICE));
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_PRICE;
|
||||
}
|
||||
|
||||
export async function buyBadge(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) redirect("/login");
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId)) redirect("/login");
|
||||
|
||||
// The form posts the badge row id; everything else (price, code) is resolved
|
||||
// server-side from trusted data — never from the client.
|
||||
const rawId = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
||||
// The form posts the badge row id; everything else (price, code) is resolved
|
||||
// server-side from trusted data — never from the client.
|
||||
const rawId = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!/^\d+$/.test(rawId)) redirect("/draw-badge?error=invalid");
|
||||
|
||||
let outcome: "bought" | "invalid" | "credits" | "fail";
|
||||
let boughtCode = "";
|
||||
let outcome: "bought" | "invalid" | "credits" | "fail";
|
||||
let boughtCode = "";
|
||||
|
||||
try {
|
||||
const badge = await prisma.websiteDrawbadges.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
select: { id: true, badgePath: true, published: true },
|
||||
});
|
||||
try {
|
||||
const badge = await prisma.websiteDrawbadges.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
select: { id: true, badgePath: true, published: true },
|
||||
});
|
||||
|
||||
if (!badge || !badge.published) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const code = badgeCodeFromPath(badge.badgePath);
|
||||
if (code.length === 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const price = await resolvePrice();
|
||||
if (!badge?.published) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const code = badgeCodeFromPath(badge.badgePath);
|
||||
if (code.length === 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const price = await resolvePrice();
|
||||
|
||||
// Re-read the buyer's live credit balance and verify it covers the cost.
|
||||
const buyer = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true },
|
||||
});
|
||||
if (!buyer || buyer.credits < price) {
|
||||
outcome = "credits";
|
||||
} else {
|
||||
// Atomically deduct credits and persist the badge so a failure
|
||||
// between the two operations cannot orphan the user.
|
||||
if (price > 0) {
|
||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
// Re-read the buyer's live credit balance and verify it covers the cost.
|
||||
const buyer = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true },
|
||||
});
|
||||
if (!buyer || buyer.credits < price) {
|
||||
outcome = "credits";
|
||||
} else {
|
||||
// Atomically deduct credits and persist the badge so a failure
|
||||
// between the two operations cannot orphan the user.
|
||||
if (price > 0) {
|
||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
|
||||
const existing = await tx.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
const max = await tx.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await tx.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
const existing = await tx.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
const max = await tx.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await tx.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Grant the badge live so it appears immediately for online users.
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
// Grant the badge live so it appears immediately for online users.
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
|
||||
outcome = "bought";
|
||||
boughtCode = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
outcome = "fail";
|
||||
}
|
||||
outcome = "bought";
|
||||
boughtCode = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
outcome = "fail";
|
||||
}
|
||||
|
||||
revalidatePath("/draw-badge");
|
||||
revalidatePath("/draw-badge");
|
||||
|
||||
// redirect() throws — it must live OUTSIDE the try/catch.
|
||||
if (outcome === "bought") {
|
||||
redirect(`/draw-badge?bought=${encodeURIComponent(boughtCode)}`);
|
||||
}
|
||||
redirect(`/draw-badge?error=${outcome}`);
|
||||
// redirect() throws — it must live OUTSIDE the try/catch.
|
||||
if (outcome === "bought") {
|
||||
redirect(`/draw-badge?bought=${encodeURIComponent(boughtCode)}`);
|
||||
}
|
||||
redirect(`/draw-badge?error=${outcome}`);
|
||||
}
|
||||
+34
-21
@@ -16,28 +16,36 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
/** Secret mixed into the digest. Requires at least one of APP_KEY or AUTH_SECRET. */
|
||||
function verifySecret(): string {
|
||||
const secret = env.APP_KEY || env.AUTH_SECRET;
|
||||
if (!secret) throw new Error("APP_KEY or AUTH_SECRET must be set for email verification");
|
||||
return secret;
|
||||
const secret = env.APP_KEY || env.AUTH_SECRET;
|
||||
if (!secret)
|
||||
throw new Error(
|
||||
"APP_KEY or AUTH_SECRET must be set for email verification",
|
||||
);
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** Compute the verification token for an email (lowercased + trimmed). */
|
||||
export async function verificationToken(email: string): Promise<string> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
return createHash("sha256").update(`${normalised}|${verifySecret()}`).digest("hex");
|
||||
const normalised = email.trim().toLowerCase();
|
||||
return createHash("sha256")
|
||||
.update(`${normalised}|${verifySecret()}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time check that `token` matches the expected digest for `email`.
|
||||
* Returns false on any length/format mismatch rather than throwing.
|
||||
*/
|
||||
export async function isValidVerificationToken(email: string, token: string): Promise<boolean> {
|
||||
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
|
||||
const expected = await verificationToken(email);
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(token.toLowerCase(), "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
export async function isValidVerificationToken(
|
||||
email: string,
|
||||
token: string,
|
||||
): Promise<boolean> {
|
||||
if (!email || !token || !/^[a-f0-9]{64}$/i.test(token)) return false;
|
||||
const expected = await verificationToken(email);
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(token.toLowerCase(), "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,16 +53,17 @@ export async function isValidVerificationToken(email: string, token: string): Pr
|
||||
* is unconfigured (sendMail returns false).
|
||||
*/
|
||||
export async function sendVerification(email: string): Promise<boolean> {
|
||||
const normalised = email.trim().toLowerCase();
|
||||
if (!normalised) return false;
|
||||
const normalised = email.trim().toLowerCase();
|
||||
if (!normalised) return false;
|
||||
|
||||
const token = await verificationToken(normalised);
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
|
||||
const token = await verificationToken(normalised);
|
||||
const base = env.APP_URL.replace(/\/+$/, "");
|
||||
const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`;
|
||||
|
||||
const hotelName = (await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
|
||||
const hotelName =
|
||||
(await siteSettings.get("hotel_name", env.HOTEL_NAME)) ?? env.HOTEL_NAME;
|
||||
|
||||
const html = `
|
||||
const html = `
|
||||
<div style="font-family:sans-serif;line-height:1.5;color:#0f172a">
|
||||
<h2 style="margin:0 0 0.5rem">Verify your email</h2>
|
||||
<p>Welcome to ${escapeHtml(hotelName)}! Confirm this email address to finish setting up your account.</p>
|
||||
@@ -69,9 +78,13 @@ export async function sendVerification(email: string): Promise<boolean> {
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
||||
return sendMail(normalised, `Verify your email · ${hotelName}`, html);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
+20
-20
@@ -9,31 +9,31 @@ import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
const saveEmulatorSettingsSchema = z.object({
|
||||
settings: z.record(z.string(), z.string()),
|
||||
settings: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export const saveEmulatorSettings = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
||||
async (ctx) => {
|
||||
const entries = Object.entries(ctx.data.settings);
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
||||
async (ctx) => {
|
||||
const entries = Object.entries(ctx.data.settings);
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
await prisma.emulatorSettings.upsert({
|
||||
where: { key },
|
||||
update: { value: String(value) },
|
||||
create: { key, value: String(value) },
|
||||
});
|
||||
}
|
||||
for (const [key, value] of entries) {
|
||||
await prisma.emulatorSettings.upsert({
|
||||
where: { key },
|
||||
update: { value: String(value) },
|
||||
create: { key, value: String(value) },
|
||||
});
|
||||
}
|
||||
|
||||
await rcon.updateConfig();
|
||||
await rcon.updateConfig();
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "emulator_settings_update",
|
||||
target: "EmulatorSettings",
|
||||
after: ctx.data.settings,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "emulator_settings_update",
|
||||
target: "EmulatorSettings",
|
||||
after: ctx.data.settings,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+132
-122
@@ -7,179 +7,189 @@ 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,
|
||||
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 });
|
||||
},
|
||||
{ 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(),
|
||||
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");
|
||||
{ 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 });
|
||||
},
|
||||
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(),
|
||||
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");
|
||||
{ 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();
|
||||
},
|
||||
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 });
|
||||
},
|
||||
{ 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(),
|
||||
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");
|
||||
{ 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 });
|
||||
},
|
||||
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(),
|
||||
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");
|
||||
{ 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();
|
||||
},
|
||||
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 });
|
||||
},
|
||||
{ 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();
|
||||
},
|
||||
{ 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 });
|
||||
},
|
||||
{ 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 });
|
||||
},
|
||||
);
|
||||
+32
-32
@@ -18,42 +18,42 @@ const MESSAGE_MAX = 255;
|
||||
* 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 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 profileId = Number(formData.get("profileId"));
|
||||
if (!Number.isInteger(profileId) || profileId <= 0) return;
|
||||
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!message) return;
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!message) return;
|
||||
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
if (!(await isAllowed(message)).ok) return;
|
||||
// Block filtered/AI-flagged content before it touches the DB (fail-open).
|
||||
if (!(await isAllowed(message)).ok) return;
|
||||
|
||||
// Optional: used only to revalidate the correct profile route.
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
// Optional: used only to revalidate the correct profile route.
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.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;
|
||||
}
|
||||
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}`);
|
||||
if (username) revalidatePath(`/u/${username}`);
|
||||
}
|
||||
+40
-40
@@ -4,57 +4,57 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
|
||||
const ticketSchema = z.object({
|
||||
title: z.string().min(1, "Title is required").max(255),
|
||||
content: z.string().min(1, "Content is required").max(5000),
|
||||
title: z.string().min(1, "Title is required").max(255),
|
||||
content: z.string().min(1, "Content is required").max(5000),
|
||||
});
|
||||
|
||||
export async function createTicket(formData: FormData): Promise<void> {
|
||||
// Re-read the session user id server-side; never trust a form-supplied id.
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
// Re-read the session user id server-side; never trust a form-supplied id.
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) return;
|
||||
|
||||
const raw = {
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
content: String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 5000),
|
||||
};
|
||||
const raw = {
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
content: String(formData.get("content") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 5000),
|
||||
};
|
||||
|
||||
const parsed = ticketSchema.safeParse(raw);
|
||||
if (!parsed.success) return;
|
||||
const parsed = ticketSchema.safeParse(raw);
|
||||
if (!parsed.success) return;
|
||||
|
||||
const { title, content } = parsed.data;
|
||||
const { title, content } = parsed.data;
|
||||
|
||||
// Moderation check
|
||||
try {
|
||||
await moderateOrThrow(`${title} ${content}`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Moderation check
|
||||
try {
|
||||
await moderateOrThrow(`${title} ${content}`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
content,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
const now = new Date();
|
||||
await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
content,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/help/tickets");
|
||||
revalidatePath("/help/tickets");
|
||||
}
|
||||
@@ -4,29 +4,33 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function importBadgeFromRemote({
|
||||
code,
|
||||
name,
|
||||
description,
|
||||
code,
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}) {
|
||||
await requireStaff();
|
||||
try {
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: { badgeName: name, badgeDescription: description, updatedAt: new Date() },
|
||||
create: {
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { ok: true as const };
|
||||
} catch {
|
||||
return { ok: false as const, error: "Failed to import badge" };
|
||||
}
|
||||
await requireStaff();
|
||||
try {
|
||||
await prisma.websiteBadges.upsert({
|
||||
where: { badgeKey: code },
|
||||
update: {
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
badgeKey: code,
|
||||
badgeName: name,
|
||||
badgeDescription: description,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { ok: true as const };
|
||||
} catch {
|
||||
return { ok: false as const, error: "Failed to import badge" };
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,28 @@ import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
import { cleanupConvertedSwfs, deleteImportedItem } from "@/lib/services/furni-import";
|
||||
import {
|
||||
cleanupConvertedSwfs,
|
||||
deleteImportedItem,
|
||||
} from "@/lib/services/furni-import";
|
||||
|
||||
export const cleanSwfFiles = adminAction({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
||||
const result = await cleanupConvertedSwfs();
|
||||
return actionOk(result as unknown as Record<string, unknown>);
|
||||
});
|
||||
export const cleanSwfFiles = adminAction(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async () => {
|
||||
const result = await cleanupConvertedSwfs();
|
||||
return actionOk(result as unknown as Record<string, unknown>);
|
||||
},
|
||||
);
|
||||
|
||||
const deleteSchema = z.object({ classname: z.string().trim().min(1) });
|
||||
|
||||
export const deleteImportedFurni = adminAction(
|
||||
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
|
||||
async (ctx) =>
|
||||
actionOk((await deleteImportedItem(ctx.data.classname)) as unknown as Record<string, unknown>),
|
||||
{ permission: PERMS.ASSETS_IMPORT, schema: deleteSchema },
|
||||
async (ctx) =>
|
||||
actionOk(
|
||||
(await deleteImportedItem(ctx.data.classname)) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
),
|
||||
);
|
||||
+53
-40
@@ -1,53 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function linkDiscordId(discordId: string): Promise<string | null> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return "Not logged in";
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return "Not logged in";
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!discordId || !/^\d{17,20}$/.test(discordId.trim())) {
|
||||
return "Invalid Discord ID format";
|
||||
}
|
||||
const userId = Number(session.user.id);
|
||||
if (!discordId || !/^\d{17,20}$/.test(discordId.trim())) {
|
||||
return "Invalid Discord ID format";
|
||||
}
|
||||
|
||||
const discordIdClean = discordId.trim();
|
||||
const discordIdClean = discordId.trim();
|
||||
|
||||
try {
|
||||
const existing = await prisma.socialAccounts.findUnique({
|
||||
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (existing && Number(existing.userId) !== userId) {
|
||||
return "This Discord ID is already linked to another account";
|
||||
}
|
||||
} catch {
|
||||
return "Failed to check Discord ID";
|
||||
}
|
||||
try {
|
||||
const existing = await prisma.socialAccounts.findUnique({
|
||||
where: {
|
||||
provider_providerId: {
|
||||
provider: "discord",
|
||||
providerId: discordIdClean,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
if (existing && Number(existing.userId) !== userId) {
|
||||
return "This Discord ID is already linked to another account";
|
||||
}
|
||||
} catch {
|
||||
return "Failed to check Discord ID";
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.socialAccounts.upsert({
|
||||
where: { provider_providerId: { provider: "discord", providerId: discordIdClean } },
|
||||
create: {
|
||||
userId: BigInt(userId),
|
||||
provider: "discord",
|
||||
providerId: discordIdClean,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
update: { userId: BigInt(userId), updatedAt: new Date() },
|
||||
});
|
||||
try {
|
||||
await prisma.socialAccounts.upsert({
|
||||
where: {
|
||||
provider_providerId: {
|
||||
provider: "discord",
|
||||
providerId: discordIdClean,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
userId: BigInt(userId),
|
||||
provider: "discord",
|
||||
providerId: discordIdClean,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
update: { userId: BigInt(userId), updatedAt: new Date() },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mailVerified: "1" },
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mailVerified: "1" },
|
||||
});
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
logger.error("Failed to link Discord account", { module: "link-discord", error: (e as Error).message });
|
||||
return "Failed to link Discord account";
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
logger.error("Failed to link Discord account", {
|
||||
module: "link-discord",
|
||||
error: (e as Error).message,
|
||||
});
|
||||
return "Failed to link Discord account";
|
||||
}
|
||||
}
|
||||
+46
-46
@@ -18,58 +18,58 @@ import { prisma } from "@/lib/prisma";
|
||||
* longer shows as pending in the in-game messenger or here.
|
||||
*/
|
||||
export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) return;
|
||||
const session = await auth();
|
||||
const meId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) return;
|
||||
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) return;
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) return;
|
||||
|
||||
try {
|
||||
// The request must exist AND be addressed to the session user.
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userFromId: true, userToId: true },
|
||||
});
|
||||
if (!request || request.userToId !== meId) return;
|
||||
try {
|
||||
// The request must exist AND be addressed to the session user.
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userFromId: true, userToId: true },
|
||||
});
|
||||
if (!request || request.userToId !== meId) return;
|
||||
|
||||
const friendId = request.userFromId;
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
// Malformed/self request — just clear it.
|
||||
await prisma.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
return;
|
||||
}
|
||||
const friendId = request.userFromId;
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
// Malformed/self request — just clear it.
|
||||
await prisma.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
return;
|
||||
}
|
||||
|
||||
const friendsSince = Math.floor(Date.now() / 1000);
|
||||
const friendsSince = Math.floor(Date.now() / 1000);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Don't double-insert if a friendship already exists in either direction.
|
||||
const existing = await tx.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Don't double-insert if a friendship already exists in either direction.
|
||||
const existing = await tx.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await tx.messengerFriendships.createMany({
|
||||
data: [
|
||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (!existing) {
|
||||
await tx.messengerFriendships.createMany({
|
||||
data: [
|
||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await tx.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
await tx.messengerFriendrequests.delete({ where: { id: requestId } });
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/messages");
|
||||
revalidatePath("/friends");
|
||||
revalidatePath("/messages");
|
||||
revalidatePath("/friends");
|
||||
}
|
||||
+134
-130
@@ -1,10 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { NotFoundError } from "@/lib/foundation/errors";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction, actionOk } from "@/lib/foundation/action";
|
||||
import { NotFoundError } from "@/lib/foundation/errors";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
@@ -13,73 +13,77 @@ import { rcon } from "@/lib/services/rcon";
|
||||
const cfhIdSchema = z.object({ ticketId: z.coerce.number().int().positive() });
|
||||
|
||||
export const assignCfhTicket = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
|
||||
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.supportTickets.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { modId: ctx.session.user.id, state: 1 },
|
||||
});
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { modId: ctx.session.user.id, state: 1 },
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_assign",
|
||||
target: "support_tickets",
|
||||
targetId: ctx.data.ticketId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_assign",
|
||||
target: "support_tickets",
|
||||
targetId: ctx.data.ticketId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const cfhStateSchema = z.object({
|
||||
ticketId: z.coerce.number().int().positive(),
|
||||
state: z.coerce.number().int().min(0).max(3),
|
||||
ticketId: z.coerce.number().int().positive(),
|
||||
state: z.coerce.number().int().min(0).max(3),
|
||||
});
|
||||
|
||||
export const updateCfhState = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
|
||||
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.supportTickets.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { state: ctx.data.state, modId: ctx.session.user.id },
|
||||
});
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { state: ctx.data.state, modId: ctx.session.user.id },
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_state_change",
|
||||
target: "support_tickets",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { state: ticket.state },
|
||||
after: { state: ctx.data.state },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_state_change",
|
||||
target: "support_tickets",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { state: ticket.state },
|
||||
after: { state: ctx.data.state },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const closeCfhTicket = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||
async (ctx) => {
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { state: 2, modId: ctx.session.user.id },
|
||||
});
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||
async (ctx) => {
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { state: 2, modId: ctx.session.user.id },
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_close",
|
||||
target: "support_tickets",
|
||||
targetId: ctx.data.ticketId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "cfh_close",
|
||||
target: "support_tickets",
|
||||
targetId: ctx.data.ticketId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Quick Mod Actions ────────────────────────────────────────────────
|
||||
@@ -87,120 +91,120 @@ export const closeCfhTicket = adminAction(
|
||||
const userIdSchema = z.object({ userId: z.coerce.number().int().positive() });
|
||||
|
||||
export const quickKick = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.disconnectUser(ctx.data.userId);
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.disconnectUser(ctx.data.userId);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_kick",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_kick",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const muteSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
duration: z.coerce.number().int().min(0).max(525600),
|
||||
userId: z.coerce.number().int().positive(),
|
||||
duration: z.coerce.number().int().min(0).max(525600),
|
||||
});
|
||||
|
||||
export const quickMute = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_mute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { duration: ctx.data.duration },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_mute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { duration: ctx.data.duration },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const quickUnmute = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.unmuteUser(ctx.data.userId);
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: userIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.unmuteUser(ctx.data.userId);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_unmute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_unmute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const alertSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
message: z.string().min(1).max(500),
|
||||
userId: z.coerce.number().int().positive(),
|
||||
message: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export const quickAlert = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema },
|
||||
async (ctx) => {
|
||||
await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: alertSchema },
|
||||
async (ctx) => {
|
||||
await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_alert",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { message: ctx.data.message },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_alert",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { message: ctx.data.message },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const roomIdSchema = z.object({ roomId: z.coerce.number().int().positive() });
|
||||
|
||||
export const quickRoomKick = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.kickAll(ctx.data.roomId);
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: roomIdSchema },
|
||||
async (ctx) => {
|
||||
await rcon.kickAll(ctx.data.roomId);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_room_kick",
|
||||
target: "Room",
|
||||
targetId: ctx.data.roomId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "mod_room_kick",
|
||||
target: "Room",
|
||||
targetId: ctx.data.roomId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const broadcastSchema = z.object({
|
||||
message: z.string().min(1).max(500),
|
||||
type: z.enum(["hotel", "staff"]),
|
||||
message: z.string().min(1).max(500),
|
||||
type: z.enum(["hotel", "staff"]),
|
||||
});
|
||||
|
||||
export const broadcastAlert = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
|
||||
async (ctx) => {
|
||||
if (ctx.data.type === "hotel") {
|
||||
await rcon.hotelAlert(ctx.data.message);
|
||||
} else {
|
||||
await rcon.staffAlert(ctx.data.message);
|
||||
}
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: broadcastSchema },
|
||||
async (ctx) => {
|
||||
if (ctx.data.type === "hotel") {
|
||||
await rcon.hotelAlert(ctx.data.message);
|
||||
} else {
|
||||
await rcon.staffAlert(ctx.data.message);
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: `mod_broadcast_${ctx.data.type}`,
|
||||
target: "broadcast",
|
||||
after: { message: ctx.data.message },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: `mod_broadcast_${ctx.data.type}`,
|
||||
target: "broadcast",
|
||||
after: { message: ctx.data.message },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -4,38 +4,54 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export interface MultiAccountCluster {
|
||||
key: string;
|
||||
label: string;
|
||||
accountCount: number;
|
||||
accounts: Array<{ id: number; username: string; rank: number; online: string }>;
|
||||
key: string;
|
||||
label: string;
|
||||
accountCount: number;
|
||||
accounts: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
online: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function detectMultiAccounts({ minAccounts, limit }: { minAccounts: number; limit: number }) {
|
||||
await requireStaff();
|
||||
const clusters: MultiAccountCluster[] = [];
|
||||
export async function detectMultiAccounts({
|
||||
minAccounts,
|
||||
limit,
|
||||
}: {
|
||||
minAccounts: number;
|
||||
limit: number;
|
||||
}) {
|
||||
await requireStaff();
|
||||
const clusters: MultiAccountCluster[] = [];
|
||||
|
||||
const ipGroups = await prisma.user.groupBy({
|
||||
by: ["ipCurrent"],
|
||||
where: { ipCurrent: { not: "" } },
|
||||
_count: { id: true },
|
||||
having: { id: { _count: { gte: minAccounts } } },
|
||||
orderBy: { _count: { id: "desc" } },
|
||||
take: limit,
|
||||
});
|
||||
const ipGroups = await prisma.user.groupBy({
|
||||
by: ["ipCurrent"],
|
||||
where: { ipCurrent: { not: "" } },
|
||||
_count: { id: true },
|
||||
having: { id: { _count: { gte: minAccounts } } },
|
||||
orderBy: { _count: { id: "desc" } },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
for (const group of ipGroups) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { ipCurrent: group.ipCurrent },
|
||||
select: { id: true, username: true, rank: true, online: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
clusters.push({
|
||||
key: group.ipCurrent,
|
||||
label: `IP: ${group.ipCurrent}`,
|
||||
accountCount: group._count.id,
|
||||
accounts: users.map((u) => ({ id: u.id, username: u.username, rank: u.rank, online: u.online })),
|
||||
});
|
||||
}
|
||||
for (const group of ipGroups) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { ipCurrent: group.ipCurrent },
|
||||
select: { id: true, username: true, rank: true, online: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
clusters.push({
|
||||
key: group.ipCurrent,
|
||||
label: `IP: ${group.ipCurrent}`,
|
||||
accountCount: group._count.id,
|
||||
accounts: users.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
rank: u.rank,
|
||||
online: u.online,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const, data: { clusters } };
|
||||
return { ok: true as const, data: { clusters } };
|
||||
}
|
||||
@@ -1,85 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockFindFirst, mockUpsert, mockFindUnique, mockUpdate, mockDelete, mockSendMail, mockRedirect } =
|
||||
vi.hoisted(() => ({
|
||||
mockFindFirst: vi.fn(),
|
||||
mockUpsert: vi.fn(),
|
||||
mockFindUnique: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockSendMail: vi.fn(),
|
||||
mockRedirect: vi.fn(),
|
||||
}));
|
||||
const {
|
||||
mockFindFirst,
|
||||
mockUpsert,
|
||||
mockFindUnique,
|
||||
mockUpdate,
|
||||
mockDelete,
|
||||
mockSendMail,
|
||||
mockRedirect,
|
||||
} = vi.hoisted(() => ({
|
||||
mockFindFirst: vi.fn(),
|
||||
mockUpsert: vi.fn(),
|
||||
mockFindUnique: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockSendMail: vi.fn(),
|
||||
mockRedirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (...args: unknown[]) => {
|
||||
mockRedirect(...args);
|
||||
throw new Error("redirect");
|
||||
},
|
||||
redirect: (...args: unknown[]) => {
|
||||
mockRedirect(...args);
|
||||
throw new Error("redirect");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
user: { findFirst: mockFindFirst, update: mockUpdate },
|
||||
passwordReset: { upsert: mockUpsert, findUnique: mockFindUnique, delete: mockDelete },
|
||||
},
|
||||
prisma: {
|
||||
user: { findFirst: mockFindFirst, update: mockUpdate },
|
||||
passwordReset: {
|
||||
upsert: mockUpsert,
|
||||
findUnique: mockFindUnique,
|
||||
delete: mockDelete,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/email", () => ({
|
||||
sendMail: mockSendMail,
|
||||
sendMail: mockSendMail,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
|
||||
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
|
||||
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||
}));
|
||||
|
||||
vi.mock("@/env", () => ({
|
||||
env: { APP_URL: "http://localhost:3000", HOTEL_NAME: "TestHotel" },
|
||||
env: { APP_URL: "http://localhost:3000", HOTEL_NAME: "TestHotel" },
|
||||
}));
|
||||
|
||||
import { requestReset } from "./password-reset";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("requestReset", () => {
|
||||
it("sends a reset email when the user exists", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: 1 });
|
||||
mockUpsert.mockResolvedValue({});
|
||||
it("sends a reset email when the user exists", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: 1 });
|
||||
mockUpsert.mockResolvedValue({});
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set("email", "[email protected]");
|
||||
const fd = new FormData();
|
||||
fd.set("email", "[email protected]");
|
||||
|
||||
await expect(requestReset(fd)).rejects.toThrow("redirect");
|
||||
await expect(requestReset(fd)).rejects.toThrow("redirect");
|
||||
|
||||
expect(mockFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { mail: "[email protected]" } }),
|
||||
);
|
||||
expect(mockUpsert).toHaveBeenCalled();
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
"[email protected]",
|
||||
expect.stringContaining("password reset"),
|
||||
expect.stringContaining("http://localhost:3000/reset"),
|
||||
);
|
||||
});
|
||||
expect(mockFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { mail: "[email protected]" } }),
|
||||
);
|
||||
expect(mockUpsert).toHaveBeenCalled();
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
"[email protected]",
|
||||
expect.stringContaining("password reset"),
|
||||
expect.stringContaining("http://localhost:3000/reset"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send email when user is not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
it("does not send email when user is not found", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
|
||||
const fd = new FormData();
|
||||
fd.set("email", "[email protected]");
|
||||
const fd = new FormData();
|
||||
fd.set("email", "[email protected]");
|
||||
|
||||
await expect(requestReset(fd)).rejects.toThrow("redirect");
|
||||
expect(mockSendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
await expect(requestReset(fd)).rejects.toThrow("redirect");
|
||||
expect(mockSendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rate limits and does not throw on email without @", async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("email", "not-an-email");
|
||||
it("rate limits and does not throw on email without @", async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("email", "not-an-email");
|
||||
|
||||
await expect(requestReset(fd)).rejects.toThrow("redirect");
|
||||
expect(mockFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
await expect(requestReset(fd)).rejects.toThrow("redirect");
|
||||
expect(mockFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,104 +2,116 @@
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { redirect } from "next/navigation";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
import { env } from "@/env";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { sendMail } from "@/lib/services/email";
|
||||
|
||||
const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash("sha256").update(s).digest("hex");
|
||||
return createHash("sha256").update(s).digest("hex");
|
||||
}
|
||||
|
||||
export async function requestReset(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const email = String(formData.get("email") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
|
||||
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000)).ok;
|
||||
// Throttle reset requests per IP (3 per 15 min) to curb email-bomb abuse.
|
||||
const allowed = (await rateLimit(`reset:${await clientIp()}`, 3, 15 * 60_000))
|
||||
.ok;
|
||||
|
||||
// Always respond the same way so we don't reveal which emails exist.
|
||||
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
try {
|
||||
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } });
|
||||
if (user) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
await prisma.passwordReset.upsert({
|
||||
where: { email },
|
||||
update: { token: sha256(token), createdAt: new Date() },
|
||||
create: { email, token: sha256(token), createdAt: new Date() },
|
||||
});
|
||||
const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`;
|
||||
await sendMail(
|
||||
email,
|
||||
`${env.HOTEL_NAME} — password reset`,
|
||||
`<p>Click to reset your password (valid 1 hour):</p><p><a href="${link}">${link}</a></p>`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// swallow — generic response below
|
||||
}
|
||||
}
|
||||
// Always respond the same way so we don't reveal which emails exist.
|
||||
if (allowed && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
||||
try {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { mail: email },
|
||||
select: { id: true },
|
||||
});
|
||||
if (user) {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
await prisma.passwordReset.upsert({
|
||||
where: { email },
|
||||
update: { token: sha256(token), createdAt: new Date() },
|
||||
create: { email, token: sha256(token), createdAt: new Date() },
|
||||
});
|
||||
const link = `${env.APP_URL}/reset?email=${encodeURIComponent(email)}&token=${token}`;
|
||||
await sendMail(
|
||||
email,
|
||||
`${env.HOTEL_NAME} — password reset`,
|
||||
`<p>Click to reset your password (valid 1 hour):</p><p><a href="${link}">${link}</a></p>`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// swallow — generic response below
|
||||
}
|
||||
}
|
||||
|
||||
redirect("/forgot?sent=1");
|
||||
redirect("/forgot?sent=1");
|
||||
}
|
||||
|
||||
export async function resetPassword(formData: FormData): Promise<void> {
|
||||
const email = String(formData.get("email") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const token = String(formData.get("token") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const password = String(formData.get("password") ?? "").normalize("NFC");
|
||||
const email = String(formData.get("email") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const token = String(formData.get("token") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const password = String(formData.get("password") ?? "").normalize("NFC");
|
||||
|
||||
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
|
||||
if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
|
||||
redirect(
|
||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`,
|
||||
);
|
||||
}
|
||||
// Throttle reset attempts per IP (5 per 15 min) to prevent token brute-force.
|
||||
if (!(await rateLimit(`resetpwd:${await clientIp()}`, 5, 15 * 60_000)).ok) {
|
||||
redirect(
|
||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent("Too many attempts — try again later")}`,
|
||||
);
|
||||
}
|
||||
|
||||
let error: string | null = null;
|
||||
if (password.length < 6) error = "Password must be at least 6 characters";
|
||||
let error: string | null = null;
|
||||
if (password.length < 6) error = "Password must be at least 6 characters";
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const row = await prisma.passwordReset.findUnique({ where: { email } });
|
||||
const fresh = row?.createdAt ? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS : false;
|
||||
const a = Buffer.from(sha256(token), "hex");
|
||||
const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length);
|
||||
const match = row != null && a.length === b.length && timingSafeEqual(a, b);
|
||||
if (!error) {
|
||||
try {
|
||||
const row = await prisma.passwordReset.findUnique({ where: { email } });
|
||||
const fresh = row?.createdAt
|
||||
? Date.now() - row.createdAt.getTime() < TOKEN_TTL_MS
|
||||
: false;
|
||||
const a = Buffer.from(sha256(token), "hex");
|
||||
const b = row ? Buffer.from(row.token, "hex") : Buffer.alloc(a.length);
|
||||
const match =
|
||||
row != null && a.length === b.length && timingSafeEqual(a, b);
|
||||
|
||||
if (!row || !fresh || !match) {
|
||||
error = "This reset link is invalid or has expired";
|
||||
} else {
|
||||
const user = await prisma.user.findFirst({ where: { mail: email }, select: { id: true } });
|
||||
if (!user) {
|
||||
error = "Account not found";
|
||||
} else {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: await hashPassword(password) },
|
||||
});
|
||||
await prisma.passwordReset.delete({ where: { email } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error = "Could not reset the password — try again";
|
||||
}
|
||||
}
|
||||
if (!row || !fresh || !match) {
|
||||
error = "This reset link is invalid or has expired";
|
||||
} else {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { mail: email },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) {
|
||||
error = "Account not found";
|
||||
} else {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: await hashPassword(password) },
|
||||
});
|
||||
await prisma.passwordReset
|
||||
.delete({ where: { email } })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error = "Could not reset the password — try again";
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
redirect(
|
||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
|
||||
);
|
||||
}
|
||||
redirect("/login?reset=1");
|
||||
if (error) {
|
||||
redirect(
|
||||
`/reset?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}&error=${encodeURIComponent(error)}`,
|
||||
);
|
||||
}
|
||||
redirect("/login?reset=1");
|
||||
}
|
||||
+118
-107
@@ -6,135 +6,146 @@ import { PERMS } from "@/lib/permission-slugs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { createEmulatorRank, deleteEmulatorRank, updateEmulatorRank } from "@/lib/services/permission-ranks";
|
||||
import {
|
||||
createEmulatorRank,
|
||||
deleteEmulatorRank,
|
||||
updateEmulatorRank,
|
||||
} from "@/lib/services/permission-ranks";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
const createRankSchema = z.object({
|
||||
rank_name: z.string().trim().min(1).max(25),
|
||||
level: z.coerce.number().int().min(1),
|
||||
rank_name: z.string().trim().min(1).max(25),
|
||||
level: z.coerce.number().int().min(1),
|
||||
});
|
||||
|
||||
export const createRank = adminAction(
|
||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const id = await createEmulatorRank(prisma, ctx.data);
|
||||
await prisma.aclRole.upsert({
|
||||
where: { slug: `rank_${id}` },
|
||||
create: {
|
||||
slug: `rank_${id}`,
|
||||
title: ctx.data.rank_name,
|
||||
description: "CMS role synchronized from permission_ranks",
|
||||
},
|
||||
update: { title: ctx.data.rank_name },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_create",
|
||||
description: `Created rank #${id}`,
|
||||
targetType: "rank",
|
||||
targetId: id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk({ id });
|
||||
},
|
||||
{ schema: createRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const id = await createEmulatorRank(prisma, ctx.data);
|
||||
await prisma.aclRole.upsert({
|
||||
where: { slug: `rank_${id}` },
|
||||
create: {
|
||||
slug: `rank_${id}`,
|
||||
title: ctx.data.rank_name,
|
||||
description: "CMS role synchronized from permission_ranks",
|
||||
},
|
||||
update: { title: ctx.data.rank_name },
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_create",
|
||||
description: `Created rank #${id}`,
|
||||
targetType: "rank",
|
||||
targetId: id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk({ id });
|
||||
},
|
||||
);
|
||||
|
||||
const deleteRankSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export const deleteRank = adminAction(
|
||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const users = await prisma.user.count({ where: { rank: ctx.data.id } });
|
||||
if (users > 0) throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||
const role = await prisma.aclRole.findFirst({ where: { slug: `rank_${ctx.data.id}` } });
|
||||
await deleteEmulatorRank(prisma, ctx.data.id);
|
||||
if (role) {
|
||||
await prisma.$transaction([
|
||||
prisma.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } }),
|
||||
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
|
||||
prisma.aclRole.delete({ where: { id: role.id } }),
|
||||
]);
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_delete",
|
||||
description: `Deleted rank #${ctx.data.id}`,
|
||||
targetType: "rank",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
},
|
||||
{ schema: deleteRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const users = await prisma.user.count({ where: { rank: ctx.data.id } });
|
||||
if (users > 0)
|
||||
throw new ActionError(`Cannot delete: ${users} users have this rank`);
|
||||
const role = await prisma.aclRole.findFirst({
|
||||
where: { slug: `rank_${ctx.data.id}` },
|
||||
});
|
||||
await deleteEmulatorRank(prisma, ctx.data.id);
|
||||
if (role) {
|
||||
await prisma.$transaction([
|
||||
prisma.aclModelPermission.deleteMany({
|
||||
where: { modelId: role.id, modelType: "Role" },
|
||||
}),
|
||||
prisma.aclModelRole.deleteMany({ where: { roleId: role.id } }),
|
||||
prisma.aclRole.delete({ where: { id: role.id } }),
|
||||
]);
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_delete",
|
||||
description: `Deleted rank #${ctx.data.id}`,
|
||||
targetType: "rank",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const saveRankSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
fields: z.record(z.string(), z.union([z.string(), z.number()])),
|
||||
id: z.coerce.number().int().positive(),
|
||||
fields: z.record(z.string(), z.union([z.string(), z.number()])),
|
||||
});
|
||||
|
||||
export const saveRank = adminAction(
|
||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields);
|
||||
if (typeof ctx.data.fields.rank_name === "string") {
|
||||
await prisma.aclRole.updateMany({
|
||||
where: { slug: `rank_${ctx.data.id}` },
|
||||
data: { title: ctx.data.fields.rank_name },
|
||||
});
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_update",
|
||||
description: `Updated rank #${ctx.data.id}`,
|
||||
targetType: "rank",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
},
|
||||
{ schema: saveRankSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
await updateEmulatorRank(prisma, ctx.data.id, ctx.data.fields);
|
||||
if (typeof ctx.data.fields.rank_name === "string") {
|
||||
await prisma.aclRole.updateMany({
|
||||
where: { slug: `rank_${ctx.data.id}` },
|
||||
data: { title: ctx.data.fields.rank_name },
|
||||
});
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "rank_update",
|
||||
description: `Updated rank #${ctx.data.id}`,
|
||||
targetType: "rank",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
await rcon.send("updatepermissions");
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const setCmsPermsSchema = z.object({
|
||||
roleId: z.coerce.number().int().positive(),
|
||||
permissionSlugs: z.array(z.string().trim().min(1)).max(500),
|
||||
roleId: z.coerce.number().int().positive(),
|
||||
permissionSlugs: z.array(z.string().trim().min(1)).max(500),
|
||||
});
|
||||
|
||||
export const setCmsPermissions = adminAction(
|
||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const role = await prisma.aclRole.findUnique({
|
||||
where: { id: ctx.data.roleId },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
if (!role) throw new ActionError("Role not found");
|
||||
const permissions = await prisma.aclPermission.findMany({
|
||||
where: { slug: { in: ctx.data.permissionSlugs } },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.aclModelPermission.deleteMany({ where: { modelId: role.id, modelType: "Role" } });
|
||||
if (permissions.length) {
|
||||
await tx.aclModelPermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
modelId: role.id,
|
||||
modelType: "Role",
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "acl_role_permissions_update",
|
||||
description: `Updated ${permissions.length} permissions for ${role.slug}`,
|
||||
targetType: "acl_role",
|
||||
targetId: role.id,
|
||||
});
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
},
|
||||
{ schema: setCmsPermsSchema, permission: PERMS.PERMISSIONS_MANAGE },
|
||||
async (ctx) => {
|
||||
const role = await prisma.aclRole.findUnique({
|
||||
where: { id: ctx.data.roleId },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
if (!role) throw new ActionError("Role not found");
|
||||
const permissions = await prisma.aclPermission.findMany({
|
||||
where: { slug: { in: ctx.data.permissionSlugs } },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.aclModelPermission.deleteMany({
|
||||
where: { modelId: role.id, modelType: "Role" },
|
||||
});
|
||||
if (permissions.length) {
|
||||
await tx.aclModelPermission.createMany({
|
||||
data: permissions.map((permission) => ({
|
||||
modelId: role.id,
|
||||
modelType: "Role",
|
||||
permissionId: permission.id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: ctx.session.user.id,
|
||||
action: "acl_role_permissions_update",
|
||||
description: `Updated ${permissions.length} permissions for ${role.slug}`,
|
||||
targetType: "acl_role",
|
||||
targetId: role.id,
|
||||
});
|
||||
revalidateTag("permissions", { expire: 0 });
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+71
-63
@@ -6,102 +6,110 @@ 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";
|
||||
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 });
|
||||
},
|
||||
{ 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(),
|
||||
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");
|
||||
{ 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 });
|
||||
},
|
||||
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(),
|
||||
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");
|
||||
{ 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();
|
||||
},
|
||||
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 });
|
||||
},
|
||||
{ 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(),
|
||||
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 });
|
||||
},
|
||||
{ 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(),
|
||||
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();
|
||||
},
|
||||
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
|
||||
async (ctx) => {
|
||||
await prisma.websitePollQuestion.delete({ where: { id: ctx.data.id } });
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+60
-60
@@ -13,130 +13,130 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
// ── 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),
|
||||
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;
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
|
||||
async (ctx) => {
|
||||
const { username, text, color, icon, effect, active } = ctx.data;
|
||||
|
||||
const users = await prisma.$queryRaw<{ id: number }[]>`
|
||||
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");
|
||||
if (users.length === 0) throw new ActionError("User not found");
|
||||
|
||||
await prisma.$executeRaw`
|
||||
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();
|
||||
},
|
||||
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(),
|
||||
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;
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
|
||||
async (ctx) => {
|
||||
const { id, text, color, icon, effect, active } = ctx.data;
|
||||
|
||||
await prisma.$executeRaw`
|
||||
await prisma.$executeRaw`
|
||||
UPDATE custom_prefixes
|
||||
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Delete prefix ───────────────────────────────────────────────────
|
||||
|
||||
const deletePrefixSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
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();
|
||||
},
|
||||
{ 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),
|
||||
word: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export const addBlacklistWord = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
|
||||
async (ctx) => {
|
||||
await prisma.$executeRaw`
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
|
||||
async (ctx) => {
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
|
||||
`;
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Remove blacklist word ───────────────────────────────────────────
|
||||
|
||||
const removeBlacklistWordSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
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();
|
||||
},
|
||||
{ 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",
|
||||
"enabled",
|
||||
"max_length",
|
||||
"min_rank",
|
||||
"allow_colors",
|
||||
"allow_bold",
|
||||
"allow_italic",
|
||||
"default_color",
|
||||
]);
|
||||
|
||||
const updatePrefixSettingsSchema = z.object({
|
||||
settings: z.record(z.string(), z.string()),
|
||||
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`
|
||||
{ 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();
|
||||
},
|
||||
}
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+38
-38
@@ -11,10 +11,10 @@ const TEXT_MAX = 5000;
|
||||
const STYLE_MAX = 5000;
|
||||
|
||||
function str(form: FormData, key: string, max: number): string {
|
||||
return String(form.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, max);
|
||||
return String(form.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,43 +26,43 @@ function str(form: FormData, key: string, max: number): string {
|
||||
* UnsignedBigInt, hence the BigInt() coercion.
|
||||
*/
|
||||
export async function applyDj(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const realName = str(formData, "realName", NAME_MAX);
|
||||
const availability = str(formData, "availability", TEXT_MAX);
|
||||
const motivation = str(formData, "motivation", TEXT_MAX);
|
||||
const experience = str(formData, "experience", TEXT_MAX);
|
||||
const musicStyle = str(formData, "musicStyle", STYLE_MAX);
|
||||
const realName = str(formData, "realName", NAME_MAX);
|
||||
const availability = str(formData, "availability", TEXT_MAX);
|
||||
const motivation = str(formData, "motivation", TEXT_MAX);
|
||||
const experience = str(formData, "experience", TEXT_MAX);
|
||||
const musicStyle = str(formData, "musicStyle", STYLE_MAX);
|
||||
|
||||
const ageRaw = Number(formData.get("age"));
|
||||
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
|
||||
const ageRaw = Number(formData.get("age"));
|
||||
const age = Number.isInteger(ageRaw) ? ageRaw : 0;
|
||||
|
||||
// Required fields per the schema (NOT NULL): real_name, age, availability,
|
||||
// motivation. experience + music_style are nullable.
|
||||
if (!realName || !availability || !motivation || age <= 0) return;
|
||||
// Required fields per the schema (NOT NULL): real_name, age, availability,
|
||||
// motivation. experience + music_style are nullable.
|
||||
if (!realName || !availability || !motivation || age <= 0) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioApplications.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
realName,
|
||||
age,
|
||||
availability,
|
||||
motivation,
|
||||
experience: experience || null,
|
||||
musicStyle: musicStyle || null,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable or duplicate — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioApplications.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
realName,
|
||||
age,
|
||||
availability,
|
||||
motivation,
|
||||
experience: experience || null,
|
||||
musicStyle: musicStyle || null,
|
||||
status: "pending",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable or duplicate — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/radio/apply");
|
||||
revalidatePath("/radio/apply");
|
||||
}
|
||||
@@ -8,35 +8,35 @@ const SONG_MAX = 255;
|
||||
const ARTIST_MAX = 255;
|
||||
|
||||
export async function submitRequest(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
const songTitle = String(formData.get("songTitle") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SONG_MAX);
|
||||
const artist = String(formData.get("artist") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, ARTIST_MAX);
|
||||
if (!songTitle && !artist) return;
|
||||
const songTitle = String(formData.get("songTitle") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SONG_MAX);
|
||||
const artist = String(formData.get("artist") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, ARTIST_MAX);
|
||||
if (!songTitle && !artist) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioSongRequests.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
songTitle: songTitle || null,
|
||||
artist: artist || null,
|
||||
submittedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioSongRequests.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
songTitle: songTitle || null,
|
||||
artist: artist || null,
|
||||
submittedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/radio/requests");
|
||||
revalidatePath("/radio/requests");
|
||||
}
|
||||
+37
-37
@@ -4,11 +4,11 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
|
||||
const shoutSchema = z.object({
|
||||
message: z.string().min(1, "Message is required").max(255),
|
||||
message: z.string().min(1, "Message is required").max(255),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -20,46 +20,46 @@ const shoutSchema = z.object({
|
||||
* session id is widened to BigInt for the insert.
|
||||
*/
|
||||
export async function postShout(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
|
||||
await clientIp();
|
||||
if (!(await rateLimit(`shout:${userId}`, 5, 30_000)).ok) return;
|
||||
|
||||
const raw = {
|
||||
message: String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
};
|
||||
const raw = {
|
||||
message: String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
};
|
||||
|
||||
const parsed = shoutSchema.safeParse(raw);
|
||||
if (!parsed.success) return;
|
||||
const parsed = shoutSchema.safeParse(raw);
|
||||
if (!parsed.success) return;
|
||||
|
||||
const { message } = parsed.data;
|
||||
const { message } = parsed.data;
|
||||
|
||||
// Moderation check
|
||||
try {
|
||||
await moderateOrThrow(message);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Moderation check
|
||||
try {
|
||||
await moderateOrThrow(message);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioShouts.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
try {
|
||||
await prisma.radioShouts.create({
|
||||
data: {
|
||||
userId: BigInt(userId),
|
||||
message,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath("/radio/shouts");
|
||||
revalidatePath("/radio/shouts");
|
||||
}
|
||||
+125
-103
@@ -27,121 +27,143 @@ import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||
* redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its
|
||||
* internal control-flow throw is never swallowed.
|
||||
*/
|
||||
const VALID_CURRENCIES = new Set<CurrencyName>(["credits", "duckets", "diamonds", "points"]);
|
||||
const VALID_CURRENCIES = new Set<CurrencyName>([
|
||||
"credits",
|
||||
"duckets",
|
||||
"diamonds",
|
||||
"points",
|
||||
]);
|
||||
|
||||
export async function claimReferral(_formData: FormData): Promise<void> {
|
||||
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" = "error";
|
||||
let outcome:
|
||||
| "claimed"
|
||||
| "not_enough"
|
||||
| "no_referrals"
|
||||
| "bad_config"
|
||||
| "error" = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
|
||||
// 5 referrals needed, 30 diamonds reward.
|
||||
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||
prisma.websiteSetting
|
||||
.findUnique({ where: { key: "referrals_needed" }, select: { value: true } })
|
||||
.catch(() => null),
|
||||
prisma.websiteSetting
|
||||
.findUnique({ where: { key: "referral_reward_amount" }, select: { value: true } })
|
||||
.catch(() => null),
|
||||
// The seeded key is referral_reward_currency_type; fall back to the
|
||||
// shorter referral_reward_currency name if that is what is configured.
|
||||
prisma.websiteSetting
|
||||
.findFirst({
|
||||
where: { key: { in: ["referral_reward_currency_type", "referral_reward_currency"] } },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
|
||||
// 5 referrals needed, 30 diamonds reward.
|
||||
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||
prisma.websiteSetting
|
||||
.findUnique({
|
||||
where: { key: "referrals_needed" },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
prisma.websiteSetting
|
||||
.findUnique({
|
||||
where: { key: "referral_reward_amount" },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
// The seeded key is referral_reward_currency_type; fall back to the
|
||||
// shorter referral_reward_currency name if that is what is configured.
|
||||
prisma.websiteSetting
|
||||
.findFirst({
|
||||
where: {
|
||||
key: {
|
||||
in: ["referral_reward_currency_type", "referral_reward_currency"],
|
||||
},
|
||||
},
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||
const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
|
||||
const currency = (currencyRaw?.value ?? "diamonds").trim().toLowerCase() as CurrencyName;
|
||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||
const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
|
||||
const currency = (currencyRaw?.value ?? "diamonds")
|
||||
.trim()
|
||||
.toLowerCase() as CurrencyName;
|
||||
|
||||
// The user's referral tally lives in user_referrals (one row per user).
|
||||
const referrals = await prisma.userReferrals
|
||||
.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, referralsTotal: true },
|
||||
orderBy: { id: "desc" },
|
||||
})
|
||||
.catch(() => null);
|
||||
// The user's referral tally lives in user_referrals (one row per user).
|
||||
const referrals = await prisma.userReferrals
|
||||
.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, referralsTotal: true },
|
||||
orderBy: { id: "desc" },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
const total = referrals ? Number(referrals.referralsTotal) : 0;
|
||||
const total = referrals ? Number(referrals.referralsTotal) : 0;
|
||||
|
||||
if (!referrals || total <= 0) {
|
||||
outcome = "no_referrals";
|
||||
} else if (total < needed) {
|
||||
outcome = "not_enough";
|
||||
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
|
||||
// Misconfigured reward — keep it conservative and grant nothing.
|
||||
outcome = "bad_config";
|
||||
} else {
|
||||
// Spend the threshold first so a concurrent double-submit can't claim
|
||||
// twice off the same balance, then deliver the reward and log it.
|
||||
await prisma.userReferrals.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { decrement: needed } },
|
||||
});
|
||||
if (!referrals || total <= 0) {
|
||||
outcome = "no_referrals";
|
||||
} else if (total < needed) {
|
||||
outcome = "not_enough";
|
||||
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
|
||||
// Misconfigured reward — keep it conservative and grant nothing.
|
||||
outcome = "bad_config";
|
||||
} else {
|
||||
// Spend the threshold first so a concurrent double-submit can't claim
|
||||
// twice off the same balance, then deliver the reward and log it.
|
||||
await prisma.userReferrals.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { decrement: needed } },
|
||||
});
|
||||
|
||||
try {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
|
||||
} catch {
|
||||
// sendCurrency already falls back to a direct DB write; if it still
|
||||
// throws the spend stands. Roll the threshold back so the user isn't
|
||||
// charged for an undelivered reward.
|
||||
await prisma.userReferrals
|
||||
.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { increment: needed } },
|
||||
})
|
||||
.catch(() => {});
|
||||
outcome = "error";
|
||||
throw new Error("currency-delivery-failed");
|
||||
}
|
||||
try {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
|
||||
} catch {
|
||||
// sendCurrency already falls back to a direct DB write; if it still
|
||||
// throws the spend stands. Roll the threshold back so the user isn't
|
||||
// charged for an undelivered reward.
|
||||
await prisma.userReferrals
|
||||
.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { increment: needed } },
|
||||
})
|
||||
.catch(() => {});
|
||||
outcome = "error";
|
||||
throw new Error("currency-delivery-failed");
|
||||
}
|
||||
|
||||
await prisma.claimedReferralLogs
|
||||
.create({
|
||||
data: {
|
||||
userId,
|
||||
ipAddress: await clientIp(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort audit log; the reward already landed.
|
||||
});
|
||||
await prisma.claimedReferralLogs
|
||||
.create({
|
||||
data: {
|
||||
userId,
|
||||
ipAddress: await clientIp(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort audit log; the reward already landed.
|
||||
});
|
||||
|
||||
outcome = "claimed";
|
||||
}
|
||||
} catch (err) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
|
||||
// navigation actually happens instead of being treated as a failure.
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"digest" in err &&
|
||||
typeof (err as { digest?: unknown }).digest === "string" &&
|
||||
(err as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
if (outcome === "claimed") outcome = "error";
|
||||
}
|
||||
outcome = "claimed";
|
||||
}
|
||||
} catch (err) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
|
||||
// navigation actually happens instead of being treated as a failure.
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"digest" in err &&
|
||||
typeof (err as { digest?: unknown }).digest === "string" &&
|
||||
(err as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
if (outcome === "claimed") outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/me");
|
||||
revalidatePath("/me");
|
||||
|
||||
if (outcome === "claimed") {
|
||||
redirect("/me?claimed=1");
|
||||
}
|
||||
redirect(`/me?error=${outcome}`);
|
||||
if (outcome === "claimed") {
|
||||
redirect("/me?claimed=1");
|
||||
}
|
||||
redirect(`/me?error=${outcome}`);
|
||||
}
|
||||
+110
-99
@@ -11,118 +11,129 @@ import { checkVpn } from "@/lib/services/ip-lookup";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(25, "Username must be at most 25 characters")
|
||||
.regex(/^[A-Za-z0-9_\-=?!@:.,]+$/, "Username contains invalid characters"),
|
||||
mail: z.string().email("Enter a valid email address").optional().or(z.literal("")),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one digit"),
|
||||
look: z.string().optional(),
|
||||
username: z
|
||||
.string()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(25, "Username must be at most 25 characters")
|
||||
.regex(/^[A-Za-z0-9_\-=?!@:.,]+$/, "Username contains invalid characters"),
|
||||
mail: z
|
||||
.string()
|
||||
.email("Enter a valid email address")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one digit"),
|
||||
look: z.string().optional(),
|
||||
});
|
||||
|
||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||
|
||||
export async function register(prevState: string | null, formData: FormData): Promise<string | null> {
|
||||
const raw = {
|
||||
username: String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
mail: String(formData.get("mail") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
password: String(formData.get("password") ?? "").normalize("NFC"),
|
||||
look:
|
||||
String(formData.get("look") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || DEFAULT_LOOK,
|
||||
};
|
||||
export async function register(
|
||||
_prevState: string | null,
|
||||
formData: FormData,
|
||||
): Promise<string | null> {
|
||||
const raw = {
|
||||
username: String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
mail: String(formData.get("mail") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
password: String(formData.get("password") ?? "").normalize("NFC"),
|
||||
look:
|
||||
String(formData.get("look") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim() || DEFAULT_LOOK,
|
||||
};
|
||||
|
||||
const parsed = registerSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return parsed.error.errors[0]?.message ?? "Invalid input";
|
||||
}
|
||||
const parsed = registerSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return parsed.error.errors[0]?.message ?? "Invalid input";
|
||||
}
|
||||
|
||||
const { username, mail, password, look } = parsed.data;
|
||||
const hasEmail = !!mail;
|
||||
const ip = await clientIp();
|
||||
const { username, mail, password, look } = parsed.data;
|
||||
const hasEmail = !!mail;
|
||||
const ip = await clientIp();
|
||||
|
||||
// Throttle sign-ups per IP (5 per 10 minutes) to curb account spam.
|
||||
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
|
||||
return "Too many sign-up attempts. Please wait a few minutes and try again.";
|
||||
}
|
||||
// Throttle sign-ups per IP (5 per 10 minutes) to curb account spam.
|
||||
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
|
||||
return "Too many sign-up attempts. Please wait a few minutes and try again.";
|
||||
}
|
||||
|
||||
// CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings.
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider !== "none") {
|
||||
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
|
||||
if (!(await verifyCaptcha(token, ip))) return "Captcha verification failed. Please try again.";
|
||||
}
|
||||
// CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings.
|
||||
const cfg = await captchaConfig();
|
||||
if (cfg.provider !== "none") {
|
||||
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
|
||||
if (!(await verifyCaptcha(token, ip)))
|
||||
return "Captcha verification failed. Please try again.";
|
||||
}
|
||||
|
||||
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||
if ((await checkVpn(ip)).blocked) {
|
||||
return (
|
||||
(await siteSettings.get("vpn_block_message", "")) ||
|
||||
"Registrations from VPN/proxy connections are not allowed."
|
||||
);
|
||||
}
|
||||
// VPN/proxy block (only when enabled in /admin/vpn).
|
||||
if ((await checkVpn(ip)).blocked) {
|
||||
return (
|
||||
(await siteSettings.get("vpn_block_message", "")) ||
|
||||
"Registrations from VPN/proxy connections are not allowed."
|
||||
);
|
||||
}
|
||||
|
||||
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
|
||||
const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0;
|
||||
if (max > 0) {
|
||||
const count = await prisma.user.count({ where: { ipRegister: ip } }).catch(() => 0);
|
||||
if (count >= max) return "You have reached the maximum number of accounts for your connection.";
|
||||
}
|
||||
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
|
||||
const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0;
|
||||
if (max > 0) {
|
||||
const count = await prisma.user
|
||||
.count({ where: { ipRegister: ip } })
|
||||
.catch(() => 0);
|
||||
if (count >= max)
|
||||
return "You have reached the maximum number of accounts for your connection.";
|
||||
}
|
||||
|
||||
// Uniqueness check.
|
||||
try {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return "That username is already taken";
|
||||
} catch {
|
||||
return "Registration is temporarily unavailable";
|
||||
}
|
||||
// Uniqueness check.
|
||||
try {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return "That username is already taken";
|
||||
} catch {
|
||||
return "Registration is temporarily unavailable";
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
mail: hasEmail ? mail : null,
|
||||
accountCreated: now,
|
||||
ipRegister: ip,
|
||||
ipCurrent: ip,
|
||||
look,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
mail: hasEmail ? mail : null,
|
||||
accountCreated: now,
|
||||
ipRegister: ip,
|
||||
ipCurrent: ip,
|
||||
look,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (hasEmail) {
|
||||
try {
|
||||
await sendVerification(mail);
|
||||
} catch {
|
||||
// No-op: account is created; user can request a new link later.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return "Could not create the account (is the username unique?)";
|
||||
}
|
||||
if (hasEmail) {
|
||||
try {
|
||||
await sendVerification(mail);
|
||||
} catch {
|
||||
// No-op: account is created; user can request a new link later.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return "Could not create the account (is the username unique?)";
|
||||
}
|
||||
|
||||
if (hasEmail) {
|
||||
redirect("/login?registered=1");
|
||||
} else {
|
||||
const { signIn } = await import("@/lib/auth");
|
||||
await signIn("credentials", { username, password, redirect: false });
|
||||
redirect("/verify?method=discord");
|
||||
}
|
||||
if (hasEmail) {
|
||||
redirect("/login?registered=1");
|
||||
} else {
|
||||
const { signIn } = await import("@/lib/auth");
|
||||
await signIn("credentials", { username, password, redirect: false });
|
||||
redirect("/verify?method=discord");
|
||||
}
|
||||
}
|
||||
+92
-70
@@ -7,88 +7,110 @@ import { rcon } from "@/lib/services/rcon";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function updateRoomItem(payload: Record<string, unknown>) {
|
||||
const staff = await requireStaff();
|
||||
const { roomId, itemId, ...data } = payload as { roomId: number; itemId: number; [key: string]: unknown };
|
||||
await prisma.items.update({ where: { id: itemId }, data: data as any });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_item_update",
|
||||
description: `Updated item #${itemId} in room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
targetId: itemId,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
const staff = await requireStaff();
|
||||
const { roomId, itemId, ...data } = payload as {
|
||||
roomId: number;
|
||||
itemId: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
await prisma.items.update({ where: { id: itemId }, data: data as any });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_item_update",
|
||||
description: `Updated item #${itemId} in room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
targetId: itemId,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function bulkDeleteRoomItems({ roomId, itemIds }: { roomId: number; itemIds: number[] }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.items.deleteMany({ where: { id: { in: itemIds }, roomId } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_items_bulk_delete",
|
||||
description: `Deleted ${itemIds.length} item(s) from room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
export async function bulkDeleteRoomItems({
|
||||
roomId,
|
||||
itemIds,
|
||||
}: {
|
||||
roomId: number;
|
||||
itemIds: number[];
|
||||
}) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.items.deleteMany({ where: { id: { in: itemIds }, roomId } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_items_bulk_delete",
|
||||
description: `Deleted ${itemIds.length} item(s) from room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function deleteRoomItem({ roomId, itemId }: { roomId: number; itemId: number }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.items.delete({ where: { id: itemId } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_item_delete",
|
||||
description: `Deleted item #${itemId} from room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
targetId: itemId,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
export async function deleteRoomItem({
|
||||
roomId,
|
||||
itemId,
|
||||
}: {
|
||||
roomId: number;
|
||||
itemId: number;
|
||||
}) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.items.delete({ where: { id: itemId } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_item_delete",
|
||||
description: `Deleted item #${itemId} from room #${roomId}`,
|
||||
targetType: "room_item",
|
||||
targetId: itemId,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${roomId}/furni`);
|
||||
}
|
||||
|
||||
export async function roomRconAction({ roomId, action }: { roomId: number; action: string }) {
|
||||
await requireStaff();
|
||||
if (action === "reload") {
|
||||
await rcon.send("reloadroom", { room_id: roomId });
|
||||
} else if (action === "kick") {
|
||||
await rcon.send("kickall", { room_id: roomId });
|
||||
} else if (action === "lock") {
|
||||
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
|
||||
} else if (action === "unlock") {
|
||||
await rcon.send("updateroom", { room_id: roomId, state: "open" });
|
||||
}
|
||||
export async function roomRconAction({
|
||||
roomId,
|
||||
action,
|
||||
}: {
|
||||
roomId: number;
|
||||
action: string;
|
||||
}) {
|
||||
await requireStaff();
|
||||
if (action === "reload") {
|
||||
await rcon.send("reloadroom", { room_id: roomId });
|
||||
} else if (action === "kick") {
|
||||
await rcon.send("kickall", { room_id: roomId });
|
||||
} else if (action === "lock") {
|
||||
await rcon.send("updateroom", { room_id: roomId, state: "locked" });
|
||||
} else if (action === "unlock") {
|
||||
await rcon.send("updateroom", { room_id: roomId, state: "open" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRoom({ id }: { id: number }) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.rooms.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_delete",
|
||||
description: `Deleted room #${id}`,
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/rooms");
|
||||
const staff = await requireStaff();
|
||||
await prisma.rooms.delete({ where: { id } });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_delete",
|
||||
description: `Deleted room #${id}`,
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath("/admin/rooms");
|
||||
}
|
||||
|
||||
export async function updateRoom({
|
||||
id,
|
||||
...data
|
||||
id,
|
||||
...data
|
||||
}: {
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
state?: string;
|
||||
usersMax?: number;
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
state?: string;
|
||||
usersMax?: number;
|
||||
}) {
|
||||
const staff = await requireStaff();
|
||||
await prisma.rooms.update({ where: { id }, data: data as any });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_update",
|
||||
description: `Updated room #${id}`,
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${id}`);
|
||||
const staff = await requireStaff();
|
||||
await prisma.rooms.update({ where: { id }, data: data as any });
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "room_update",
|
||||
description: `Updated room #${id}`,
|
||||
targetType: "room",
|
||||
targetId: id,
|
||||
});
|
||||
revalidatePath(`/admin/rooms/${id}`);
|
||||
}
|
||||
+108
-85
@@ -1,108 +1,131 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { mkdir, writeFile, unlink } from "fs/promises";
|
||||
import path from "path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const FAVICON_DIR = "public/assets/images/media/favicon";
|
||||
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"];
|
||||
const ALLOWED = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
];
|
||||
|
||||
export async function saveFavicon(
|
||||
formData: FormData,
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return { success: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" };
|
||||
if (!ALLOWED.includes(file.type))
|
||||
return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0)
|
||||
return { success: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE)
|
||||
return { success: false, error: "File too large (max 2MB)" };
|
||||
if (!ALLOWED.includes(file.type))
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
|
||||
};
|
||||
|
||||
const mimeExt: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/x-icon": "ico",
|
||||
"image/svg+xml": "svg",
|
||||
};
|
||||
const ext = mimeExt[file.type] ?? "png";
|
||||
const filename = `favicon-${Date.now()}.${ext}`;
|
||||
const baseDir = path.resolve(process.cwd(), FAVICON_DIR);
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return { success: false, error: "Invalid path" };
|
||||
}
|
||||
const mimeExt: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/x-icon": "ico",
|
||||
"image/svg+xml": "svg",
|
||||
};
|
||||
const ext = mimeExt[file.type] ?? "png";
|
||||
const filename = `favicon-${Date.now()}.${ext}`;
|
||||
const baseDir = path.resolve(process.cwd(), FAVICON_DIR);
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return { success: false, error: "Invalid path" };
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/api/media/favicon/${filename}`;
|
||||
const url = `/api/media/favicon/${filename}`;
|
||||
|
||||
// Remove old favicon file if it exists
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) {
|
||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore if file doesn't exist */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove old favicon file if it exists
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
if (oldUrl?.startsWith("/api/media/favicon/")) {
|
||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore if file doesn't exist */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key: "cms_favicon" },
|
||||
update: { value: url },
|
||||
create: { key: "cms_favicon", value: url, comment: "Favicon URL" },
|
||||
});
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key: "cms_favicon" },
|
||||
update: { value: url },
|
||||
create: { key: "cms_favicon", value: url, comment: "Favicon URL" },
|
||||
});
|
||||
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFavicon(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
if (oldUrl && oldUrl.startsWith("/api/media/favicon/")) {
|
||||
const baseDir = path.resolve(process.cwd(), FAVICON_DIR);
|
||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function deleteFavicon(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
if (oldUrl?.startsWith("/api/media/favicon/")) {
|
||||
const baseDir = path.resolve(process.cwd(), FAVICON_DIR);
|
||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.websiteSetting.delete({ where: { key: "cms_favicon" } }).catch(() => {});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
await prisma.websiteSetting
|
||||
.delete({ where: { key: "cms_favicon" } })
|
||||
.catch(() => {});
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
+84
-77
@@ -1,105 +1,112 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const MEDIA_DIR = "public/assets/images/media/logo";
|
||||
|
||||
export async function saveLogo(
|
||||
formData: FormData,
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
|
||||
const ext =
|
||||
file.type === "image/png"
|
||||
? "png"
|
||||
: file.type === "image/gif"
|
||||
? "gif"
|
||||
: file.type === "image/jpeg"
|
||||
? "jpg"
|
||||
: file.type === "image/webp"
|
||||
? "webp"
|
||||
: "png";
|
||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return { success: false, error: "Invalid path" };
|
||||
}
|
||||
const ext =
|
||||
file.type === "image/png"
|
||||
? "png"
|
||||
: file.type === "image/gif"
|
||||
? "gif"
|
||||
: file.type === "image/jpeg"
|
||||
? "jpg"
|
||||
: file.type === "image/webp"
|
||||
? "webp"
|
||||
: "png";
|
||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return { success: false, error: "Invalid path" };
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/api/media/logo/${filename}`;
|
||||
const url = `/api/media/logo/${filename}`;
|
||||
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key: "cms_logo" },
|
||||
update: { value: url },
|
||||
create: { key: "cms_logo", value: url, comment: "Logo (generator)" },
|
||||
});
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key: "cms_logo" },
|
||||
update: { value: url },
|
||||
create: { key: "cms_logo", value: url, comment: "Logo (generator)" },
|
||||
});
|
||||
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLogoFromUrl(
|
||||
gifUrl: string,
|
||||
gifUrl: string,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(gifUrl);
|
||||
if (!res.ok) return { success: false, error: `Failed to fetch GIF: ${res.status}` };
|
||||
try {
|
||||
const res = await fetch(gifUrl);
|
||||
if (!res.ok)
|
||||
return { success: false, error: `Failed to fetch GIF: ${res.status}` };
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "image/gif";
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const contentType = res.headers.get("content-type") ?? "image/gif";
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
const ext =
|
||||
contentType === "image/png"
|
||||
? "png"
|
||||
: contentType === "image/gif"
|
||||
? "gif"
|
||||
: contentType === "image/jpeg"
|
||||
? "jpg"
|
||||
: contentType === "image/webp"
|
||||
? "webp"
|
||||
: "gif";
|
||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return { success: false, error: "Invalid path" };
|
||||
}
|
||||
const ext =
|
||||
contentType === "image/png"
|
||||
? "png"
|
||||
: contentType === "image/gif"
|
||||
? "gif"
|
||||
: contentType === "image/jpeg"
|
||||
? "jpg"
|
||||
: contentType === "image/webp"
|
||||
? "webp"
|
||||
: "gif";
|
||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const baseDir = path.resolve(process.cwd(), MEDIA_DIR);
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return { success: false, error: "Invalid path" };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/api/media/logo/${filename}`;
|
||||
const url = `/api/media/logo/${filename}`;
|
||||
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key: "cms_logo" },
|
||||
update: { value: url },
|
||||
create: { key: "cms_logo", value: url, comment: "Logo (generator)" },
|
||||
});
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key: "cms_logo" },
|
||||
update: { value: url },
|
||||
create: { key: "cms_logo", value: url, comment: "Logo (generator)" },
|
||||
});
|
||||
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
+92
-92
@@ -21,50 +21,50 @@ const MESSAGE_MAX = 10000;
|
||||
* target). The emulator surfaces the pending request in the in-game messenger.
|
||||
*/
|
||||
export async function sendFriendRequest(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const fromId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(fromId) || fromId <= 0) return;
|
||||
const session = await auth();
|
||||
const fromId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(fromId) || fromId <= 0) return;
|
||||
|
||||
const toId = Number(formData.get("userId"));
|
||||
if (!Number.isInteger(toId) || toId <= 0) return;
|
||||
const toId = Number(formData.get("userId"));
|
||||
if (!Number.isInteger(toId) || toId <= 0) return;
|
||||
|
||||
// Can't befriend yourself.
|
||||
if (toId === fromId) return;
|
||||
// Can't befriend yourself.
|
||||
if (toId === fromId) return;
|
||||
|
||||
try {
|
||||
// Guard against duplicate pending requests and already-existing friendships.
|
||||
const [existingRequest, existingFriendship] = await Promise.all([
|
||||
prisma.messengerFriendrequests.findFirst({
|
||||
where: { userFromId: fromId, userToId: toId },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: fromId, userTwoId: toId },
|
||||
{ userOneId: toId, userTwoId: fromId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
try {
|
||||
// Guard against duplicate pending requests and already-existing friendships.
|
||||
const [existingRequest, existingFriendship] = await Promise.all([
|
||||
prisma.messengerFriendrequests.findFirst({
|
||||
where: { userFromId: fromId, userToId: toId },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: fromId, userTwoId: toId },
|
||||
{ userOneId: toId, userTwoId: fromId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (existingRequest || existingFriendship) return;
|
||||
if (existingRequest || existingFriendship) return;
|
||||
|
||||
await prisma.messengerFriendrequests.create({
|
||||
data: { userFromId: fromId, userToId: toId },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
await prisma.messengerFriendrequests.create({
|
||||
data: { userFromId: fromId, userToId: toId },
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional: revalidate the target profile if a username was supplied, purely
|
||||
// to refresh any request-state UI rendered there.
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (username) revalidatePath(`/u/${username}`);
|
||||
// Optional: revalidate the target profile if a username was supplied, purely
|
||||
// to refresh any request-state UI rendered there.
|
||||
const username = String(formData.get("username") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (username) revalidatePath(`/u/${username}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,65 +80,65 @@ export async function sendFriendRequest(formData: FormData): Promise<void> {
|
||||
* stamp posts_count = 1 to match the emulator's bookkeeping.
|
||||
*/
|
||||
export async function postThread(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
const openerId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(openerId) || openerId <= 0) return;
|
||||
const session = await auth();
|
||||
const openerId = Number(session?.user?.id);
|
||||
if (!Number.isInteger(openerId) || openerId <= 0) return;
|
||||
|
||||
const guildId = Number(formData.get("guildId"));
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) return;
|
||||
const guildId = Number(formData.get("guildId"));
|
||||
if (!Number.isInteger(guildId) || guildId <= 0) return;
|
||||
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SUBJECT_MAX);
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!subject || !message) return;
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, SUBJECT_MAX);
|
||||
const message = String(formData.get("message") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, MESSAGE_MAX);
|
||||
if (!subject || !message) return;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
// Confirm the guild exists (and has a forum) before opening a thread.
|
||||
const guild = await prisma.guilds.findUnique({
|
||||
where: { id: guildId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guild) return;
|
||||
try {
|
||||
// Confirm the guild exists (and has a forum) before opening a thread.
|
||||
const guild = await prisma.guilds.findUnique({
|
||||
where: { id: guildId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!guild) return;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const thread = await tx.guildsForumsThreads.create({
|
||||
data: {
|
||||
guildId,
|
||||
openerId,
|
||||
subject,
|
||||
postsCount: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
state: 0,
|
||||
pinned: 0,
|
||||
locked: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const thread = await tx.guildsForumsThreads.create({
|
||||
data: {
|
||||
guildId,
|
||||
openerId,
|
||||
subject,
|
||||
postsCount: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
state: 0,
|
||||
pinned: 0,
|
||||
locked: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.guildsForumsComments.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
userId: openerId,
|
||||
message,
|
||||
createdAt: now,
|
||||
state: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
await tx.guildsForumsComments.create({
|
||||
data: {
|
||||
threadId: thread.id,
|
||||
userId: openerId,
|
||||
message,
|
||||
createdAt: now,
|
||||
state: 0,
|
||||
adminId: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidatePath(`/guilds/${guildId}/forum`);
|
||||
revalidatePath(`/guilds/${guildId}/forum`);
|
||||
}
|
||||
+19
-19
@@ -5,28 +5,28 @@ import { requireStaff } from "@/lib/admin/guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function deleteSoundtrack({ id }: { id: number }) {
|
||||
await requireStaff();
|
||||
await prisma.soundtracks.delete({ where: { id } });
|
||||
revalidatePath("/admin/sounds");
|
||||
await requireStaff();
|
||||
await prisma.soundtracks.delete({ where: { id } });
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
|
||||
export async function updateSoundtrack({
|
||||
id,
|
||||
name,
|
||||
author,
|
||||
track,
|
||||
length,
|
||||
id,
|
||||
name,
|
||||
author,
|
||||
track,
|
||||
length,
|
||||
}: {
|
||||
id: number;
|
||||
name: string;
|
||||
author: string;
|
||||
track: string;
|
||||
length: number;
|
||||
id: number;
|
||||
name: string;
|
||||
author: string;
|
||||
track: string;
|
||||
length: number;
|
||||
}) {
|
||||
await requireStaff();
|
||||
await prisma.soundtracks.update({
|
||||
where: { id },
|
||||
data: { name, author, track, length },
|
||||
});
|
||||
revalidatePath("/admin/sounds");
|
||||
await requireStaff();
|
||||
await prisma.soundtracks.update({
|
||||
where: { id },
|
||||
data: { name, author, track, length },
|
||||
});
|
||||
revalidatePath("/admin/sounds");
|
||||
}
|
||||
@@ -7,39 +7,45 @@ import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const templateSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
content: z.string().min(1),
|
||||
category: z.string().max(50).optional().default("general"),
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
title: z.string().min(1).max(255),
|
||||
content: z.string().min(1),
|
||||
category: z.string().max(50).optional().default("general"),
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const createTemplate = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
|
||||
async (ctx) => {
|
||||
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data });
|
||||
return actionOk({ id: tpl.id });
|
||||
},
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: templateSchema },
|
||||
async (ctx) => {
|
||||
const tpl = await prisma.websiteTicketTemplate.create({ data: ctx.data });
|
||||
return actionOk({ id: tpl.id });
|
||||
},
|
||||
);
|
||||
|
||||
const updateTemplateInput = templateSchema.partial().extend({ id: z.coerce.number().int().positive() });
|
||||
const updateTemplateInput = templateSchema
|
||||
.partial()
|
||||
.extend({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export const updateTemplate = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const existing = await prisma.websiteTicketTemplate.findUnique({ where: { id } });
|
||||
if (!existing) throw new ActionError("Template not found");
|
||||
await prisma.websiteTicketTemplate.update({ where: { id }, data });
|
||||
return actionOk({ id });
|
||||
},
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTemplateInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const existing = await prisma.websiteTicketTemplate.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing) throw new ActionError("Template not found");
|
||||
await prisma.websiteTicketTemplate.update({ where: { id }, data });
|
||||
return actionOk({ id });
|
||||
},
|
||||
);
|
||||
|
||||
const deleteTemplateInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
const deleteTemplateInput = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const deleteTemplate = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
|
||||
async (ctx) => {
|
||||
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } });
|
||||
return actionOk();
|
||||
},
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: deleteTemplateInput },
|
||||
async (ctx) => {
|
||||
await prisma.websiteTicketTemplate.delete({ where: { id: ctx.data.id } });
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+178
-169
@@ -7,223 +7,232 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import {
|
||||
assignTicketSchema,
|
||||
createTicketSchema,
|
||||
replyTicketSchema,
|
||||
updateTicketPrioritySchema,
|
||||
updateTicketStatusSchema,
|
||||
assignTicketSchema,
|
||||
createTicketSchema,
|
||||
replyTicketSchema,
|
||||
updateTicketPrioritySchema,
|
||||
updateTicketStatusSchema,
|
||||
} from "@/lib/validators/ticket";
|
||||
|
||||
// ── User actions (authenticated, no admin perms needed) ──────────────
|
||||
|
||||
export const createTicket = authAction({ schema: createTicketSchema }, async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.create({
|
||||
data: {
|
||||
subject: ctx.data.subject,
|
||||
category: ctx.data.category,
|
||||
creatorId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
export const createTicket = authAction(
|
||||
{ schema: createTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.create({
|
||||
data: {
|
||||
subject: ctx.data.subject,
|
||||
category: ctx.data.category,
|
||||
creatorId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the first message
|
||||
await prisma.websiteTicketMessage.create({
|
||||
data: {
|
||||
ticketId: ticket.id,
|
||||
userId: ctx.session.user.id,
|
||||
message: ctx.data.message,
|
||||
isStaff: 0,
|
||||
},
|
||||
});
|
||||
// Create the first message
|
||||
await prisma.websiteTicketMessage.create({
|
||||
data: {
|
||||
ticketId: ticket.id,
|
||||
userId: ctx.session.user.id,
|
||||
message: ctx.data.message,
|
||||
isStaff: 0,
|
||||
},
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "ticket_create",
|
||||
actor: ctx.session.user.username,
|
||||
target: `#${ticket.id} - ${ticket.subject}`,
|
||||
details: `Category: ${ticket.category}`,
|
||||
});
|
||||
notify({
|
||||
action: "ticket_create",
|
||||
actor: ctx.session.user.username,
|
||||
target: `#${ticket.id} - ${ticket.subject}`,
|
||||
details: `Category: ${ticket.category}`,
|
||||
});
|
||||
|
||||
return actionOk({ id: ticket.id });
|
||||
});
|
||||
return actionOk({ id: ticket.id });
|
||||
},
|
||||
);
|
||||
|
||||
export const userReplyTicket = authAction({ schema: replyTicketSchema }, async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
export const userReplyTicket = authAction(
|
||||
{ schema: replyTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
|
||||
if (ticket.status === "closed") throw new ActionError("Ticket is closed");
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.creatorId !== ctx.session.user.id)
|
||||
throw new ActionError("Unauthorized");
|
||||
if (ticket.status === "closed") throw new ActionError("Ticket is closed");
|
||||
|
||||
await prisma.websiteTicketMessage.create({
|
||||
data: {
|
||||
ticketId: ctx.data.ticketId,
|
||||
userId: ctx.session.user.id,
|
||||
message: ctx.data.message,
|
||||
isStaff: 0,
|
||||
},
|
||||
});
|
||||
await prisma.websiteTicketMessage.create({
|
||||
data: {
|
||||
ticketId: ctx.data.ticketId,
|
||||
userId: ctx.session.user.id,
|
||||
message: ctx.data.message,
|
||||
isStaff: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// If ticket was in "waiting" (waiting for user), move back to open
|
||||
if (ticket.status === "waiting") {
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { status: "open" },
|
||||
});
|
||||
}
|
||||
// If ticket was in "waiting" (waiting for user), move back to open
|
||||
if (ticket.status === "waiting") {
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { status: "open" },
|
||||
});
|
||||
}
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const closeTicketByUser = authAction(
|
||||
{ schema: replyTicketSchema.pick({ ticketId: true }) },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
{ schema: replyTicketSchema.pick({ ticketId: true }) },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.creatorId !== ctx.session.user.id) throw new ActionError("Unauthorized");
|
||||
if (ticket.status === "closed") throw new ActionError("Ticket is already closed");
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (ticket.creatorId !== ctx.session.user.id)
|
||||
throw new ActionError("Unauthorized");
|
||||
if (ticket.status === "closed")
|
||||
throw new ActionError("Ticket is already closed");
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { status: "closed", closedAt: new Date() },
|
||||
});
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { status: "closed", closedAt: new Date() },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Admin actions ────────────────────────────────────────────────────
|
||||
|
||||
export const adminReplyTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: replyTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
await prisma.websiteTicketMessage.create({
|
||||
data: {
|
||||
ticketId: ctx.data.ticketId,
|
||||
userId: ctx.session.user.id,
|
||||
message: ctx.data.message,
|
||||
isStaff: 1,
|
||||
},
|
||||
});
|
||||
await prisma.websiteTicketMessage.create({
|
||||
data: {
|
||||
ticketId: ctx.data.ticketId,
|
||||
userId: ctx.session.user.id,
|
||||
message: ctx.data.message,
|
||||
isStaff: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-assign if not assigned yet
|
||||
const updates: Record<string, unknown> = { status: "waiting" };
|
||||
if (!ticket.assigneeId) {
|
||||
updates.assigneeId = ctx.session.user.id;
|
||||
}
|
||||
// Auto-assign if not assigned yet
|
||||
const updates: Record<string, unknown> = { status: "waiting" };
|
||||
if (!ticket.assigneeId) {
|
||||
updates.assigneeId = ctx.session.user.id;
|
||||
}
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: updates,
|
||||
});
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: updates,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_reply",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_reply",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const updateTicketStatus = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketStatusSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
const data: Record<string, unknown> = { status: ctx.data.status };
|
||||
if (ctx.data.status === "closed") {
|
||||
data.closedAt = new Date();
|
||||
}
|
||||
if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
|
||||
data.assigneeId = ctx.session.user.id;
|
||||
}
|
||||
const data: Record<string, unknown> = { status: ctx.data.status };
|
||||
if (ctx.data.status === "closed") {
|
||||
data.closedAt = new Date();
|
||||
}
|
||||
if (ctx.data.status === "in_progress" && !ticket.assigneeId) {
|
||||
data.assigneeId = ctx.session.user.id;
|
||||
}
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data,
|
||||
});
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data,
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_status_change",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { status: ticket.status },
|
||||
after: { status: ctx.data.status },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_status_change",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { status: ticket.status },
|
||||
after: { status: ctx.data.status },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const assignTicket = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: assignTicketSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: {
|
||||
assigneeId: ctx.data.assigneeId,
|
||||
status: ctx.data.assigneeId ? "in_progress" : "open",
|
||||
},
|
||||
});
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: {
|
||||
assigneeId: ctx.data.assigneeId,
|
||||
status: ctx.data.assigneeId ? "in_progress" : "open",
|
||||
},
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_assign",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { assigneeId: ticket.assigneeId },
|
||||
after: { assigneeId: ctx.data.assigneeId },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_assign",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { assigneeId: ticket.assigneeId },
|
||||
after: { assigneeId: ctx.data.assigneeId },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const updateTicketPriority = adminAction(
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
{ permission: PERMS.TICKETS_EDIT, schema: updateTicketPrioritySchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ctx.data.ticketId },
|
||||
});
|
||||
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { priority: ctx.data.priority },
|
||||
});
|
||||
await prisma.websiteTicket.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
data: { priority: ctx.data.priority },
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_priority_change",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { priority: ticket.priority },
|
||||
after: { priority: ctx.data.priority },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ticket_priority_change",
|
||||
target: "WebsiteTicket",
|
||||
targetId: ctx.data.ticketId,
|
||||
before: { priority: ticket.priority },
|
||||
after: { priority: ctx.data.priority },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+100
-70
@@ -4,93 +4,123 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { z } from "zod";
|
||||
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files";
|
||||
import {
|
||||
CLIENT_TRANSLATION_FILES,
|
||||
getClientTranslationFile,
|
||||
} from "@/lib/client-translation-files";
|
||||
import { patchJson5 } from "@/lib/json5-patch";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const saveTranslationsSchema = z.object({
|
||||
locale: z.enum([
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
locale: z.enum([
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export const saveTranslations = adminAction({ schema: saveTranslationsSchema }, async (ctx) => {
|
||||
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
||||
export const saveTranslations = adminAction(
|
||||
{ schema: saveTranslationsSchema },
|
||||
async (ctx) => {
|
||||
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
||||
|
||||
const filePath = path.join(process.cwd(), "messages", `${ctx.data.locale}.json`);
|
||||
await fs.writeFile(filePath, JSON.stringify(ctx.data.data, null, 2), "utf-8");
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"messages",
|
||||
`${ctx.data.locale}.json`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(ctx.data.data, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const saveClientTranslationsSchema = z.object({
|
||||
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]]),
|
||||
data: z.record(z.string(), z.string()),
|
||||
fileId: z.enum(
|
||||
CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]],
|
||||
),
|
||||
data: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export const saveClientTranslations = adminAction({ schema: saveClientTranslationsSchema }, async (ctx) => {
|
||||
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
||||
export const saveClientTranslations = adminAction(
|
||||
{ schema: saveClientTranslationsSchema },
|
||||
async (ctx) => {
|
||||
if (ctx.session.user.rank < 7) throw new ActionError("Forbidden");
|
||||
|
||||
const file = getClientTranslationFile(ctx.data.fileId);
|
||||
if (!file) throw new ActionError("Unknown file");
|
||||
if (file.readOnly) throw new ActionError("File is read-only");
|
||||
const file = getClientTranslationFile(ctx.data.fileId);
|
||||
if (!file) throw new ActionError("Unknown file");
|
||||
if (file.readOnly) throw new ActionError("File is read-only");
|
||||
|
||||
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
|
||||
// Turbopack's static tracer can't prove that — without the hint it
|
||||
// pulls the entire project into the NFT list.
|
||||
const absPath = path.join(/*turbopackIgnore: true*/ process.cwd(), file.relPath);
|
||||
const raw = await fs.readFile(absPath, "utf-8");
|
||||
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
|
||||
// Turbopack's static tracer can't prove that — without the hint it
|
||||
// pulls the entire project into the NFT list.
|
||||
const absPath = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
file.relPath,
|
||||
);
|
||||
const raw = await fs.readFile(absPath, "utf-8");
|
||||
|
||||
if (file.format === "json") {
|
||||
// Plain JSON — no comments to preserve, just round-trip.
|
||||
await fs.writeFile(absPath, JSON.stringify(ctx.data.data, null, 4), "utf-8");
|
||||
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
|
||||
}
|
||||
if (file.format === "json") {
|
||||
// Plain JSON — no comments to preserve, just round-trip.
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
JSON.stringify(ctx.data.data, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
|
||||
}
|
||||
|
||||
// JSON5: surgical line-level patch keeps headers and section comments
|
||||
// intact. Falls back to a full re-serialization (which DOES drop comments)
|
||||
// only when an edited key cannot be located via the patch contract.
|
||||
const original: Record<string, string> = {};
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
original[k] = v == null ? "" : String(v);
|
||||
}
|
||||
}
|
||||
// JSON5: surgical line-level patch keeps headers and section comments
|
||||
// intact. Falls back to a full re-serialization (which DOES drop comments)
|
||||
// only when an edited key cannot be located via the patch contract.
|
||||
const original: Record<string, string> = {};
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
original[k] = v == null ? "" : String(v);
|
||||
}
|
||||
}
|
||||
|
||||
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
|
||||
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
|
||||
|
||||
if (unpatchedKeys.length === 0) {
|
||||
await fs.writeFile(absPath, content, "utf-8");
|
||||
return actionOk({ commentsLost: false, unpatchedKeys });
|
||||
}
|
||||
if (unpatchedKeys.length === 0) {
|
||||
await fs.writeFile(absPath, content, "utf-8");
|
||||
return actionOk({ commentsLost: false, unpatchedKeys });
|
||||
}
|
||||
|
||||
// At least one key could not be patched surgically (e.g. unusual
|
||||
// formatting or a brand-new key). Fall back to a full re-serialization
|
||||
// and warn the caller that comments were lost.
|
||||
await fs.writeFile(absPath, JSON5.stringify(ctx.data.data, null, 4), "utf-8");
|
||||
return actionOk({ commentsLost: true, unpatchedKeys });
|
||||
});
|
||||
// At least one key could not be patched surgically (e.g. unusual
|
||||
// formatting or a brand-new key). Fall back to a full re-serialization
|
||||
// and warn the caller that comments were lost.
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
JSON5.stringify(ctx.data.data, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
return actionOk({ commentsLost: true, unpatchedKeys });
|
||||
},
|
||||
);
|
||||
+94
-87
@@ -1,128 +1,135 @@
|
||||
"use server";
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { env } from "@/env";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
|
||||
import { generateTotpSecret, verifyTotp } from "@/lib/auth/totp";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { env } from "@/env";
|
||||
|
||||
async function sessionUserId(): Promise<number> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
return Number(session.user.id);
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
return Number(session.user.id);
|
||||
}
|
||||
|
||||
function generateRecoveryCodes(): string[] {
|
||||
const codes: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
codes.push(
|
||||
randomBytes(4)
|
||||
.toString("hex")
|
||||
.toUpperCase()
|
||||
.replace(/(.{4})/, "$1-"),
|
||||
);
|
||||
}
|
||||
return codes;
|
||||
const codes: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
codes.push(
|
||||
randomBytes(4)
|
||||
.toString("hex")
|
||||
.toUpperCase()
|
||||
.replace(/(.{4})/, "$1-"),
|
||||
);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
/** Verify a TOTP code OR a recovery code. Returns the updated recovery codes (minus used one) if a recovery code was used, or null on failure. */
|
||||
async function verifyTwoFactorCode(
|
||||
userId: number,
|
||||
code: string,
|
||||
userId: number,
|
||||
code: string,
|
||||
): Promise<{ ok: boolean; updatedRecoveryCodes?: string | null }> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { twoFactorSecret: true, twoFactorRecoveryCodes: true },
|
||||
});
|
||||
if (!user?.twoFactorSecret) return { ok: false };
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { twoFactorSecret: true, twoFactorRecoveryCodes: true },
|
||||
});
|
||||
if (!user?.twoFactorSecret) return { ok: false };
|
||||
|
||||
// Try TOTP first
|
||||
try {
|
||||
const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(user.twoFactorSecret);
|
||||
if (verifyTotp(code, secret)) return { ok: true };
|
||||
} catch {
|
||||
/* fall through to recovery */
|
||||
}
|
||||
// Try TOTP first
|
||||
try {
|
||||
const secret = new LaravelEncrypter(env.APP_KEY as string).decrypt(
|
||||
user.twoFactorSecret,
|
||||
);
|
||||
if (verifyTotp(code, secret)) return { ok: true };
|
||||
} catch {
|
||||
/* fall through to recovery */
|
||||
}
|
||||
|
||||
// Try recovery codes
|
||||
if (user.twoFactorRecoveryCodes) {
|
||||
let codes: string[];
|
||||
try {
|
||||
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
|
||||
} catch {
|
||||
codes = [];
|
||||
}
|
||||
const idx = codes.indexOf(code);
|
||||
if (idx !== -1) {
|
||||
codes.splice(idx, 1);
|
||||
const remaining = codes.length > 0 ? JSON.stringify(codes) : null;
|
||||
return { ok: true, updatedRecoveryCodes: remaining };
|
||||
}
|
||||
}
|
||||
// Try recovery codes
|
||||
if (user.twoFactorRecoveryCodes) {
|
||||
let codes: string[];
|
||||
try {
|
||||
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
|
||||
} catch {
|
||||
codes = [];
|
||||
}
|
||||
const idx = codes.indexOf(code);
|
||||
if (idx !== -1) {
|
||||
codes.splice(idx, 1);
|
||||
const remaining = codes.length > 0 ? JSON.stringify(codes) : null;
|
||||
return { ok: true, updatedRecoveryCodes: remaining };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false };
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
/** Step 1: generate a secret and recovery codes, store encrypted but UNconfirmed. */
|
||||
export async function beginTwoFactor(): Promise<void> {
|
||||
const id = await sessionUserId();
|
||||
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
||||
const secret = generateTotpSecret();
|
||||
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
|
||||
const codes = generateRecoveryCodes();
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
twoFactorSecret: encrypted,
|
||||
twoFactorConfirmedAt: null,
|
||||
twoFactorRecoveryCodes: JSON.stringify(codes),
|
||||
},
|
||||
});
|
||||
revalidatePath("/settings/2fa");
|
||||
const id = await sessionUserId();
|
||||
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
||||
const secret = generateTotpSecret();
|
||||
const encrypted = new LaravelEncrypter(env.APP_KEY).encrypt(secret);
|
||||
const codes = generateRecoveryCodes();
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
twoFactorSecret: encrypted,
|
||||
twoFactorConfirmedAt: null,
|
||||
twoFactorRecoveryCodes: JSON.stringify(codes),
|
||||
},
|
||||
});
|
||||
revalidatePath("/settings/2fa");
|
||||
}
|
||||
|
||||
/** Step 2: verify a code against the pending secret, then confirm and show recovery codes. */
|
||||
export async function confirmTwoFactor(formData: FormData): Promise<void> {
|
||||
const id = await sessionUserId();
|
||||
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
||||
const id = await sessionUserId();
|
||||
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
||||
|
||||
if (!(await rateLimit(`2fa-confirm:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit");
|
||||
if (!(await rateLimit(`2fa-confirm:${id}`, 5, 30_000)).ok)
|
||||
redirect("/settings/2fa?error=ratelimit");
|
||||
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
|
||||
const { ok } = await verifyTwoFactorCode(id, code);
|
||||
if (!ok) redirect("/settings/2fa?error=badcode");
|
||||
const { ok } = await verifyTwoFactorCode(id, code);
|
||||
if (!ok) redirect("/settings/2fa?error=badcode");
|
||||
|
||||
await prisma.user.update({ where: { id }, data: { twoFactorConfirmedAt: new Date() } });
|
||||
redirect("/settings/2fa?enabled=1");
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { twoFactorConfirmedAt: new Date() },
|
||||
});
|
||||
redirect("/settings/2fa?enabled=1");
|
||||
}
|
||||
|
||||
export async function disableTwoFactor(formData: FormData): Promise<void> {
|
||||
const id = await sessionUserId();
|
||||
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
||||
const id = await sessionUserId();
|
||||
if (!env.APP_KEY) redirect("/settings/2fa?error=noappkey");
|
||||
|
||||
if (!(await rateLimit(`2fa-disable:${id}`, 5, 30_000)).ok) redirect("/settings/2fa?error=ratelimit");
|
||||
if (!(await rateLimit(`2fa-disable:${id}`, 5, 30_000)).ok)
|
||||
redirect("/settings/2fa?error=ratelimit");
|
||||
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
|
||||
const { ok } = await verifyTwoFactorCode(id, code);
|
||||
if (!ok) redirect("/settings/2fa?error=badcode");
|
||||
const { ok } = await verifyTwoFactorCode(id, code);
|
||||
if (!ok) redirect("/settings/2fa?error=badcode");
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
twoFactorSecret: null,
|
||||
twoFactorRecoveryCodes: null,
|
||||
twoFactorConfirmedAt: null,
|
||||
},
|
||||
});
|
||||
redirect("/settings/2fa?disabled=1");
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
twoFactorSecret: null,
|
||||
twoFactorRecoveryCodes: null,
|
||||
twoFactorConfirmedAt: null,
|
||||
},
|
||||
});
|
||||
redirect("/settings/2fa?disabled=1");
|
||||
}
|
||||
@@ -1,40 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { actionOk, authAction } from "@/lib/foundation/action";
|
||||
import { DatabaseError } from "@/lib/foundation/errors";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { authAction, actionOk } from "@/lib/foundation/action";
|
||||
import { DatabaseError } from "@/lib/foundation/errors";
|
||||
|
||||
const MOTTO_MAX = 127;
|
||||
|
||||
const mottoSchema = z.object({
|
||||
motto: z.string().max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`),
|
||||
motto: z
|
||||
.string()
|
||||
.max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`),
|
||||
});
|
||||
|
||||
const updateMottoAction = authAction({ schema: mottoSchema }, async (ctx) => {
|
||||
try {
|
||||
await prisma.user.update({ where: { id: ctx.session.user.id }, data: { motto: ctx.data.motto } });
|
||||
} catch {
|
||||
throw new DatabaseError("Failed to update motto");
|
||||
}
|
||||
try {
|
||||
await prisma.user.update({
|
||||
where: { id: ctx.session.user.id },
|
||||
data: { motto: ctx.data.motto },
|
||||
});
|
||||
} catch {
|
||||
throw new DatabaseError("Failed to update motto");
|
||||
}
|
||||
|
||||
try {
|
||||
await rcon.setMotto(ctx.session.user.id, ctx.data.motto);
|
||||
} catch {
|
||||
// RCON is best-effort; the change is already persisted.
|
||||
}
|
||||
try {
|
||||
await rcon.setMotto(ctx.session.user.id, ctx.data.motto);
|
||||
} catch {
|
||||
// RCON is best-effort; the change is already persisted.
|
||||
}
|
||||
|
||||
revalidatePath("/settings");
|
||||
return actionOk();
|
||||
revalidatePath("/settings");
|
||||
return actionOk();
|
||||
});
|
||||
|
||||
export async function updateMotto(formData: FormData): Promise<void> {
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, MOTTO_MAX);
|
||||
await updateMottoAction({ motto });
|
||||
const motto = String(formData.get("motto") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, MOTTO_MAX);
|
||||
await updateMottoAction({ motto });
|
||||
}
|
||||
|
||||
export { updateMottoAction };
|
||||
+319
-276
@@ -11,404 +11,447 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { notify } from "@/lib/services/webhook";
|
||||
import { banUserSchema, createUserSchema, giveBadgeSchema, updateUserSchema } from "@/lib/validators/user";
|
||||
import {
|
||||
banUserSchema,
|
||||
createUserSchema,
|
||||
giveBadgeSchema,
|
||||
updateUserSchema,
|
||||
} from "@/lib/validators/user";
|
||||
|
||||
const DEFAULT_LOOK = "hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64";
|
||||
const DEFAULT_LOOK =
|
||||
"hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64";
|
||||
|
||||
export const createUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
|
||||
async (ctx) => {
|
||||
const { username, mail, password, rank, motto } = ctx.data;
|
||||
{ permission: PERMS.USERS_EDIT, schema: createUserSchema },
|
||||
async (ctx) => {
|
||||
const { username, mail, password, rank, motto } = ctx.data;
|
||||
|
||||
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
}
|
||||
if (rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(password, 12);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const hashedPassword = await hash(password, 12);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
try {
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
username,
|
||||
mail,
|
||||
password: hashedPassword,
|
||||
rank,
|
||||
motto: motto || "I'm new here!",
|
||||
look: DEFAULT_LOOK,
|
||||
credits: 5000,
|
||||
pixels: 5000,
|
||||
accountCreated: now,
|
||||
ipRegister: "0.0.0.0",
|
||||
ipCurrent: "0.0.0.0",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
username,
|
||||
mail,
|
||||
password: hashedPassword,
|
||||
rank,
|
||||
motto: motto || "I'm new here!",
|
||||
look: DEFAULT_LOOK,
|
||||
credits: 5000,
|
||||
pixels: 5000,
|
||||
accountCreated: now,
|
||||
ipRegister: "0.0.0.0",
|
||||
ipCurrent: "0.0.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
await tx.usersSettings.create({ data: { userId: created.id } });
|
||||
await tx.usersCurrency.createMany({
|
||||
data: [
|
||||
{ userId: created.id, type: 0, amount: 5000 },
|
||||
{ userId: created.id, type: 5, amount: 5000 },
|
||||
],
|
||||
});
|
||||
await tx.usersSettings.create({ data: { userId: created.id } });
|
||||
await tx.usersCurrency.createMany({
|
||||
data: [
|
||||
{ userId: created.id, type: 0, amount: 5000 },
|
||||
{ userId: created.id, type: 5, amount: 5000 },
|
||||
],
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_create",
|
||||
target: "User",
|
||||
targetId: user.id,
|
||||
after: { username, mail, rank },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_create",
|
||||
target: "User",
|
||||
targetId: user.id,
|
||||
after: { username, mail, rank },
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
actor: ctx.session.user.username,
|
||||
target: username,
|
||||
targetId: user.id,
|
||||
details: "Account created by admin",
|
||||
});
|
||||
notify({
|
||||
action: "user_edit",
|
||||
actor: ctx.session.user.username,
|
||||
target: username,
|
||||
targetId: user.id,
|
||||
details: "Account created by admin",
|
||||
});
|
||||
|
||||
return actionOk({ id: user.id, username: user.username });
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||
const target = (err.meta?.target as string[]) ?? [];
|
||||
if (target.includes("username")) throw new ActionError("Username already taken");
|
||||
if (target.includes("mail")) throw new ActionError("Email already registered");
|
||||
throw new ActionError("Username or email already in use");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
return actionOk({ id: user.id, username: user.username });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
const target = (err.meta?.target as string[]) ?? [];
|
||||
if (target.includes("username"))
|
||||
throw new ActionError("Username already taken");
|
||||
if (target.includes("mail"))
|
||||
throw new ActionError("Email already registered");
|
||||
throw new ActionError("Username or email already in use");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const updateUserInput = updateUserSchema.extend({
|
||||
id: z.coerce.number().int().positive(),
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const updateUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
||||
async (ctx) => {
|
||||
const { id, diamonds, duckets, ...userData } = ctx.data;
|
||||
{ permission: PERMS.USERS_EDIT, schema: updateUserInput },
|
||||
async (ctx) => {
|
||||
const { id, diamonds, duckets, ...userData } = ctx.data;
|
||||
|
||||
const targetUser = await guardRank(id, ctx.session.user.rank);
|
||||
const targetUser = await guardRank(id, ctx.session.user.rank);
|
||||
|
||||
if (userData.rank !== undefined && userData.rank >= ctx.session.user.rank && ctx.session.user.rank < 7) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
}
|
||||
if (
|
||||
userData.rank !== undefined &&
|
||||
userData.rank >= ctx.session.user.rank &&
|
||||
ctx.session.user.rank < 7
|
||||
) {
|
||||
throw new ActionError("Cannot assign rank equal or higher than your own");
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id }, data: userData });
|
||||
await prisma.user.update({ where: { id }, data: userData });
|
||||
|
||||
if (diamonds !== undefined) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId: id, type: 5 } },
|
||||
update: { amount: diamonds },
|
||||
create: { userId: id, type: 5, amount: diamonds },
|
||||
});
|
||||
}
|
||||
if (duckets !== undefined) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId: id, type: 0 } },
|
||||
update: { amount: duckets },
|
||||
create: { userId: id, type: 0, amount: duckets },
|
||||
});
|
||||
}
|
||||
if (diamonds !== undefined) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId: id, type: 5 } },
|
||||
update: { amount: diamonds },
|
||||
create: { userId: id, type: 5, amount: diamonds },
|
||||
});
|
||||
}
|
||||
if (duckets !== undefined) {
|
||||
await prisma.usersCurrency.upsert({
|
||||
where: { userId_type: { userId: id, type: 0 } },
|
||||
update: { amount: duckets },
|
||||
create: { userId: id, type: 0, amount: duckets },
|
||||
});
|
||||
}
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_edit",
|
||||
target: "User",
|
||||
targetId: id,
|
||||
before: { username: targetUser.username, mail: targetUser.mail, rank: targetUser.rank },
|
||||
after: userData,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_edit",
|
||||
target: "User",
|
||||
targetId: id,
|
||||
before: {
|
||||
username: targetUser.username,
|
||||
mail: targetUser.mail,
|
||||
rank: targetUser.rank,
|
||||
},
|
||||
after: userData,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
targetId: id,
|
||||
});
|
||||
notify({
|
||||
action: "user_edit",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
targetId: id,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const banInput = banUserSchema.extend({});
|
||||
|
||||
export const banUser = adminAction({ permission: PERMS.USERS_BAN, schema: banInput }, async (ctx) => {
|
||||
const { userId, reason, duration, type, ip } = ctx.data;
|
||||
export const banUser = adminAction(
|
||||
{ permission: PERMS.USERS_BAN, schema: banInput },
|
||||
async (ctx) => {
|
||||
const { userId, reason, duration, type, ip } = ctx.data;
|
||||
|
||||
const targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||
const targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const banExpire = duration > 0 ? now + duration * 3600 : 0;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const banExpire = duration > 0 ? now + duration * 3600 : 0;
|
||||
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
userStaffId: ctx.session.user.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type || "account",
|
||||
ip: ip || "",
|
||||
machineId: "",
|
||||
},
|
||||
});
|
||||
await prisma.ban.create({
|
||||
data: {
|
||||
userId,
|
||||
userStaffId: ctx.session.user.id,
|
||||
timestamp: now,
|
||||
banExpire,
|
||||
banReason: reason,
|
||||
type: type || "account",
|
||||
ip: ip || "",
|
||||
machineId: "",
|
||||
},
|
||||
});
|
||||
|
||||
await rcon.disconnectUser(userId);
|
||||
await rcon.disconnectUser(userId);
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ban",
|
||||
target: "User",
|
||||
targetId: userId,
|
||||
after: { reason, type, duration },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "ban",
|
||||
target: "User",
|
||||
targetId: userId,
|
||||
after: { reason, type, duration },
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "ban",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
details: reason,
|
||||
});
|
||||
notify({
|
||||
action: "ban",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
details: reason,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const unbanInput = z.object({ userId: z.coerce.number().int().positive() });
|
||||
|
||||
export const unbanUser = adminAction({ permission: PERMS.USERS_BAN, schema: unbanInput }, async (ctx) => {
|
||||
const { userId } = ctx.data;
|
||||
export const unbanUser = adminAction(
|
||||
{ permission: PERMS.USERS_BAN, schema: unbanInput },
|
||||
async (ctx) => {
|
||||
const { userId } = ctx.data;
|
||||
|
||||
const targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||
const targetUser = await guardRank(userId, ctx.session.user.rank);
|
||||
|
||||
await prisma.ban.deleteMany({ where: { userId } });
|
||||
await prisma.ban.deleteMany({ where: { userId } });
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "unban",
|
||||
target: "User",
|
||||
targetId: userId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "unban",
|
||||
target: "User",
|
||||
targetId: userId,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "unban",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
});
|
||||
notify({
|
||||
action: "unban",
|
||||
actor: ctx.session.user.username,
|
||||
target: targetUser.username,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export const giveBadge = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
|
||||
async (ctx) => {
|
||||
const { userId, badgeCode } = ctx.data;
|
||||
{ permission: PERMS.USERS_EDIT, schema: giveBadgeSchema },
|
||||
async (ctx) => {
|
||||
const { userId, badgeCode } = ctx.data;
|
||||
|
||||
await guardRank(userId, ctx.session.user.rank);
|
||||
await guardRank(userId, ctx.session.user.rank);
|
||||
|
||||
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } });
|
||||
if (existing) throw new ActionError("Badge already assigned");
|
||||
const existing = await prisma.usersBadges.findFirst({
|
||||
where: { userId, badgeCode },
|
||||
});
|
||||
if (existing) throw new ActionError("Badge already assigned");
|
||||
|
||||
await prisma.usersBadges.create({ data: { userId, badgeCode } });
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
await prisma.usersBadges.create({ data: { userId, badgeCode } });
|
||||
await rcon.giveBadge(userId, badgeCode);
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Remove Badge ────────────────────────────────────────────────────
|
||||
|
||||
const removeBadgeSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
badgeCode: z.string().min(1),
|
||||
userId: z.coerce.number().int().positive(),
|
||||
badgeCode: z.string().min(1),
|
||||
});
|
||||
|
||||
export const removeBadge = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
|
||||
async (ctx) => {
|
||||
const { userId, badgeCode } = ctx.data;
|
||||
{ permission: PERMS.USERS_EDIT, schema: removeBadgeSchema },
|
||||
async (ctx) => {
|
||||
const { userId, badgeCode } = ctx.data;
|
||||
|
||||
await guardRank(userId, ctx.session.user.rank);
|
||||
await guardRank(userId, ctx.session.user.rank);
|
||||
|
||||
const existing = await prisma.usersBadges.findFirst({ where: { userId, badgeCode } });
|
||||
if (!existing) throw new ActionError("Badge not found");
|
||||
const existing = await prisma.usersBadges.findFirst({
|
||||
where: { userId, badgeCode },
|
||||
});
|
||||
if (!existing) throw new ActionError("Badge not found");
|
||||
|
||||
await prisma.usersBadges.delete({ where: { id: existing.id } });
|
||||
await rcon.removeBadge(userId, badgeCode);
|
||||
await prisma.usersBadges.delete({ where: { id: existing.id } });
|
||||
await rcon.removeBadge(userId, badgeCode);
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Rank guard helper ───────────────────────────────────────────────
|
||||
|
||||
async function guardRank(targetUserId: number, sessionRank: number) {
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { username: true, rank: true, mail: true },
|
||||
});
|
||||
if (!target) throw new ActionError("User not found");
|
||||
if (target.rank >= sessionRank && sessionRank < 7) {
|
||||
throw new ActionError("Cannot modify user with equal or higher rank");
|
||||
}
|
||||
return target;
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { username: true, rank: true, mail: true },
|
||||
});
|
||||
if (!target) throw new ActionError("User not found");
|
||||
if (target.rank >= sessionRank && sessionRank < 7) {
|
||||
throw new ActionError("Cannot modify user with equal or higher rank");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
// ── Reset Password ──────────────────────────────────────────────────
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
userId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const resetPassword = adminAction(
|
||||
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
|
||||
async (ctx) => {
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
{ permission: PERMS.USERS_RESET_PASSWORD, schema: resetPasswordSchema },
|
||||
async (ctx) => {
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
|
||||
const newPassword = crypto.randomBytes(12).toString("base64url").slice(0, 16);
|
||||
const hashed = await hash(newPassword, 10);
|
||||
const newPassword = crypto
|
||||
.randomBytes(12)
|
||||
.toString("base64url")
|
||||
.slice(0, 16);
|
||||
const hashed = await hash(newPassword, 10);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: ctx.data.userId },
|
||||
data: { password: hashed },
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: ctx.data.userId },
|
||||
data: { password: hashed },
|
||||
});
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "reset_password",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "reset_password",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "user_edit",
|
||||
actor: ctx.session.user.username,
|
||||
target: target.username,
|
||||
details: "Password reset",
|
||||
});
|
||||
notify({
|
||||
action: "user_edit",
|
||||
actor: ctx.session.user.username,
|
||||
target: target.username,
|
||||
details: "Password reset",
|
||||
});
|
||||
|
||||
return actionOk({ newPassword });
|
||||
},
|
||||
return actionOk({ newPassword });
|
||||
},
|
||||
);
|
||||
|
||||
// ── Disconnect User ─────────────────────────────────────────────────
|
||||
|
||||
const disconnectSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
userId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const disconnectUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
|
||||
async (ctx) => {
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.disconnectUser(ctx.data.userId);
|
||||
if (!success) throw new ActionError("Failed to disconnect. Is the emulator running?");
|
||||
{ permission: PERMS.USERS_EDIT, schema: disconnectSchema },
|
||||
async (ctx) => {
|
||||
const target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.disconnectUser(ctx.data.userId);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to disconnect. Is the emulator running?");
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_disconnect",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_disconnect",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
|
||||
notify({
|
||||
action: "disconnect",
|
||||
actor: ctx.session.user.username,
|
||||
target: target.username,
|
||||
});
|
||||
notify({
|
||||
action: "disconnect",
|
||||
actor: ctx.session.user.username,
|
||||
target: target.username,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Alert User (in-game message) ────────────────────────────────────
|
||||
|
||||
const alertUserSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
message: z.string().min(1).max(500),
|
||||
userId: z.coerce.number().int().positive(),
|
||||
message: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export const alertUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||
if (!success) throw new ActionError("Failed to send alert. Is the emulator running?");
|
||||
return actionOk();
|
||||
},
|
||||
{ permission: PERMS.USERS_EDIT, schema: alertUserSchema },
|
||||
async (ctx) => {
|
||||
const success = await rcon.alertUser(ctx.data.userId, ctx.data.message);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to send alert. Is the emulator running?");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Mute User ───────────────────────────────────────────────────────
|
||||
|
||||
const muteSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
duration: z.coerce.number().int().min(0).default(0),
|
||||
userId: z.coerce.number().int().positive(),
|
||||
duration: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
export const muteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: muteSchema }, async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||
if (!success) throw new ActionError("Failed to mute. Is the emulator running?");
|
||||
export const muteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to mute. Is the emulator running?");
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_mute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { duration: ctx.data.duration },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_mute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { duration: ctx.data.duration },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Unmute User ─────────────────────────────────────────────────────
|
||||
|
||||
const unmuteSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
userId: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const unmuteUser = adminAction({ permission: PERMS.USERS_EDIT, schema: unmuteSchema }, async (ctx) => {
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.unmuteUser(ctx.data.userId);
|
||||
if (!success) throw new ActionError("Failed to unmute. Is the emulator running?");
|
||||
export const unmuteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: unmuteSchema },
|
||||
async (ctx) => {
|
||||
await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.unmuteUser(ctx.data.userId);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to unmute. Is the emulator running?");
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_unmute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_unmute",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
});
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Send Credits via RCON ───────────────────────────────────────────
|
||||
|
||||
const sendCreditsSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
amount: z.coerce.number().int().min(1).max(1000000),
|
||||
userId: z.coerce.number().int().positive(),
|
||||
amount: z.coerce.number().int().min(1).max(1000000),
|
||||
});
|
||||
|
||||
export const sendCredits = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
||||
async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
|
||||
if (!success) throw new ActionError("Failed to send credits. Is the emulator running?");
|
||||
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
||||
async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to send credits. Is the emulator running?");
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_send_credits",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { amount: ctx.data.amount },
|
||||
});
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "user_send_credits",
|
||||
target: "User",
|
||||
targetId: ctx.data.userId,
|
||||
after: { amount: ctx.data.amount },
|
||||
});
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+113
-88
@@ -23,102 +23,127 @@ export type RedeemState = { ok: boolean; message: string } | null;
|
||||
* 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." };
|
||||
}
|
||||
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 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") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!code) {
|
||||
return { ok: false, message: "Please enter a voucher code." };
|
||||
}
|
||||
const code = String(formData.get("code") ?? "")
|
||||
.normalize("NFC")
|
||||
.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;
|
||||
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." };
|
||||
}
|
||||
// Look up the code (website_shop_vouchers.code is unique).
|
||||
let voucher: {
|
||||
id: bigint;
|
||||
amount: number;
|
||||
maxUses: number;
|
||||
useCount: number;
|
||||
expiresAt: Date | 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." };
|
||||
}
|
||||
// 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." };
|
||||
}
|
||||
// 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." };
|
||||
}
|
||||
// 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.",
|
||||
};
|
||||
}
|
||||
// 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.
|
||||
}
|
||||
// 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");
|
||||
revalidatePath("/redeem");
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: `Success! Your balance has been increased by ${voucher.amount.toLocaleString()} credits.`,
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
message: `Success! Your balance has been increased by ${voucher.amount.toLocaleString()} credits.`,
|
||||
};
|
||||
}
|
||||
+20
-20
@@ -8,8 +8,8 @@ import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const toggleWatchSchema = z.object({
|
||||
targetUserId: z.coerce.number().int().positive(),
|
||||
reason: z.string().max(255).optional(),
|
||||
targetUserId: z.coerce.number().int().positive(),
|
||||
reason: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -19,25 +19,25 @@ const toggleWatchSchema = z.object({
|
||||
* the UI can flip the badge without re-fetching.
|
||||
*/
|
||||
export const toggleUserWatch = adminAction(
|
||||
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
|
||||
async (ctx) => {
|
||||
const staffId = ctx.session.user.id;
|
||||
const { targetUserId, reason } = ctx.data;
|
||||
{ permission: PERMS.USERS_VIEW, schema: toggleWatchSchema },
|
||||
async (ctx) => {
|
||||
const staffId = ctx.session.user.id;
|
||||
const { targetUserId, reason } = ctx.data;
|
||||
|
||||
const existing = await prisma.userWatch.findUnique({
|
||||
where: { staffId_targetUserId: { staffId, targetUserId } },
|
||||
});
|
||||
const existing = await prisma.userWatch.findUnique({
|
||||
where: { staffId_targetUserId: { staffId, targetUserId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.userWatch.delete({ where: { id: existing.id } });
|
||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
|
||||
return actionOk({ watching: false });
|
||||
}
|
||||
if (existing) {
|
||||
await prisma.userWatch.delete({ where: { id: existing.id } });
|
||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
|
||||
return actionOk({ watching: false });
|
||||
}
|
||||
|
||||
await prisma.userWatch.create({
|
||||
data: { staffId, targetUserId, reason: reason ?? "" },
|
||||
});
|
||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
|
||||
return actionOk({ watching: true });
|
||||
},
|
||||
await prisma.userWatch.create({
|
||||
data: { staffId, targetUserId, reason: reason ?? "" },
|
||||
});
|
||||
revalidateTag(`user-watch:${staffId}`, { expire: 0 });
|
||||
return actionOk({ watching: true });
|
||||
},
|
||||
);
|
||||
@@ -6,107 +6,120 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Achievement = {
|
||||
name: string;
|
||||
category: string;
|
||||
level: number;
|
||||
rewardAmount: number;
|
||||
rewardType: number;
|
||||
points: number | null;
|
||||
progressNeeded: number;
|
||||
name: string;
|
||||
category: string;
|
||||
level: number;
|
||||
rewardAmount: number;
|
||||
rewardType: number;
|
||||
points: number | null;
|
||||
progressNeeded: number;
|
||||
};
|
||||
|
||||
export default async function AdminAchievements() {
|
||||
const t = await getTranslations("pages.admin.achievements");
|
||||
const t = await getTranslations("pages.admin.achievements");
|
||||
|
||||
let achievements: Achievement[];
|
||||
try {
|
||||
achievements = await prisma.achievements.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
category: true,
|
||||
level: true,
|
||||
rewardAmount: true,
|
||||
rewardType: true,
|
||||
points: true,
|
||||
progressNeeded: true,
|
||||
},
|
||||
orderBy: [{ category: "asc" }, { name: "asc" }, { level: "asc" }],
|
||||
});
|
||||
} catch {
|
||||
achievements = [];
|
||||
}
|
||||
let achievements: Achievement[];
|
||||
try {
|
||||
achievements = await prisma.achievements.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
category: true,
|
||||
level: true,
|
||||
rewardAmount: true,
|
||||
rewardType: true,
|
||||
points: true,
|
||||
progressNeeded: true,
|
||||
},
|
||||
orderBy: [{ category: "asc" }, { name: "asc" }, { level: "asc" }],
|
||||
});
|
||||
} catch {
|
||||
achievements = [];
|
||||
}
|
||||
|
||||
const groups = new Map<string, Achievement[]>();
|
||||
for (const a of achievements) {
|
||||
const list = groups.get(a.category) ?? [];
|
||||
list.push(a);
|
||||
groups.set(a.category, list);
|
||||
}
|
||||
const groups = new Map<string, Achievement[]>();
|
||||
for (const a of achievements) {
|
||||
const list = groups.get(a.category) ?? [];
|
||||
list.push(a);
|
||||
groups.set(a.category, list);
|
||||
}
|
||||
|
||||
const distinctNames = new Set(achievements.map((a) => a.name)).size;
|
||||
const distinctNames = new Set(achievements.map((a) => a.name)).size;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Award size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle", { rows: achievements.length, categories: groups.size })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Award size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle", {
|
||||
rows: achievements.length,
|
||||
categories: groups.size,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("achievementRows")} value={achievements.length} icon="🏆" />
|
||||
<StatusCard label={t("distinctAchievements")} value={distinctNames} icon="🎖️" />
|
||||
<StatusCard label={t("categories")} value={groups.size} icon="🗂️" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("achievementRows")}
|
||||
value={achievements.length}
|
||||
icon="🏆"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("distinctAchievements")}
|
||||
value={distinctNames}
|
||||
icon="🎖️"
|
||||
/>
|
||||
<StatusCard label={t("categories")} value={groups.size} icon="🗂️" />
|
||||
</div>
|
||||
|
||||
{groups.size === 0 ? (
|
||||
<div className="admin-empty">{t("noAchievements")}</div>
|
||||
) : (
|
||||
[...groups.entries()].map(([category, rows]) => (
|
||||
<section key={category} className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{category}{" "}
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{rows.length}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="admin-card p-0 overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colName")}</th>
|
||||
<th>{t("colLevel")}</th>
|
||||
<th>{t("colProgress")}</th>
|
||||
<th>{t("colRewardType")}</th>
|
||||
<th>{t("colRewardAmount")}</th>
|
||||
<th>{t("colPoints")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((a) => (
|
||||
<tr key={`${a.name}-${a.level}`}>
|
||||
<td>
|
||||
<strong>{a.name}</strong>
|
||||
</td>
|
||||
<td>{a.level}</td>
|
||||
<td>{a.progressNeeded}</td>
|
||||
<td>{a.rewardType}</td>
|
||||
<td>{a.rewardAmount}</td>
|
||||
<td>{a.points ?? 0}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
{groups.size === 0 ? (
|
||||
<div className="admin-empty">{t("noAchievements")}</div>
|
||||
) : (
|
||||
[...groups.entries()].map(([category, rows]) => (
|
||||
<section key={category} className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{category}{" "}
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{rows.length}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="admin-card p-0 overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colName")}</th>
|
||||
<th>{t("colLevel")}</th>
|
||||
<th>{t("colProgress")}</th>
|
||||
<th>{t("colRewardType")}</th>
|
||||
<th>{t("colRewardAmount")}</th>
|
||||
<th>{t("colPoints")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((a) => (
|
||||
<tr key={`${a.name}-${a.level}`}>
|
||||
<td>
|
||||
<strong>{a.name}</strong>
|
||||
</td>
|
||||
<td>{a.level}</td>
|
||||
<td>{a.progressNeeded}</td>
|
||||
<td>{a.rewardType}</td>
|
||||
<td>{a.rewardAmount}</td>
|
||||
<td>{a.points ?? 0}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,59 @@
|
||||
import Link from "next/link";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { deleteAd, updateAd } from "@/actions/admin-ads";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditAd({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
|
||||
try {
|
||||
ad = await prisma.websiteAds.findUnique({ where: { id: BigInt(id) } });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!ad) notFound();
|
||||
export default async function EditAd({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
let ad: Awaited<ReturnType<typeof prisma.websiteAds.findUnique>> = null;
|
||||
try {
|
||||
ad = await prisma.websiteAds.findUnique({ where: { id: BigInt(id) } });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!ad) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">Edit advertisement</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={updateAd} className="admin-card">
|
||||
<input type="hidden" name="id" value={String(ad.id)} />
|
||||
<input name="image" defaultValue={ad.image} placeholder="Image URL" required maxLength={255} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteAd} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(ad.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete advertisement
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
Edit advertisement
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={updateAd} className="admin-card">
|
||||
<input type="hidden" name="id" value={String(ad.id)} />
|
||||
<input
|
||||
name="image"
|
||||
defaultValue={ad.image}
|
||||
placeholder="Image URL"
|
||||
required
|
||||
maxLength={255}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
<form action={deleteAd} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(ad.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
Delete advertisement
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { createAd } from "@/actions/admin-ads";
|
||||
|
||||
export default function NewAd() {
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">New advertisement</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={createAd} className="admin-card">
|
||||
<input name="image" placeholder="Image URL" required maxLength={255} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
New advertisement
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/ads">← Advertisements</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action={createAd} className="admin-card">
|
||||
<input name="image" placeholder="Image URL" required maxLength={255} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+85
-68
@@ -1,81 +1,98 @@
|
||||
import { Megaphone } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminAds() {
|
||||
const t = await getTranslations("pages.admin.ads");
|
||||
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>>;
|
||||
try {
|
||||
ads = await prisma.websiteAds.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
ads = [];
|
||||
}
|
||||
const t = await getTranslations("pages.admin.ads");
|
||||
let ads: Awaited<ReturnType<typeof prisma.websiteAds.findMany>>;
|
||||
try {
|
||||
ads = await prisma.websiteAds.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
ads = [];
|
||||
}
|
||||
|
||||
const latest = ads[0]?.createdAt ? ads[0].createdAt.toISOString().slice(0, 10) : "—";
|
||||
const latest = ads[0]?.createdAt
|
||||
? ads[0].createdAt.toISOString().slice(0, 10)
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Link href="/admin/ads/new" className="btn btn-primary">
|
||||
+ {t("newAd")}
|
||||
</Link>
|
||||
</div>
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Megaphone size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/ads/new" className="btn btn-primary">
|
||||
+ {t("newAd")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("adsCount")} value={ads.length} icon="🖼️" />
|
||||
<StatusCard label={t("latest")} value={latest} hint={t("mostRecentAd")} icon="🕒" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("adsCount")} value={ads.length} icon="🖼️" />
|
||||
<StatusCard
|
||||
label={t("latest")}
|
||||
value={latest}
|
||||
hint={t("mostRecentAd")}
|
||||
icon="🕒"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("allAds")}</h2>
|
||||
{ads.length === 0 ? (
|
||||
<div className="admin-empty">{t("noAds")}</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colPreview")}</th>
|
||||
<th>{t("colImage")}</th>
|
||||
<th>{t("colCreated")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.map((ad) => (
|
||||
<tr key={String(ad.id)}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={ad.image} alt="" style={{ maxHeight: 40, maxWidth: 120 }} />
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{ad.createdAt ? ad.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("allAds")}</h2>
|
||||
{ads.length === 0 ? (
|
||||
<div className="admin-empty">{t("noAds")}</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colPreview")}</th>
|
||||
<th>{t("colImage")}</th>
|
||||
<th>{t("colCreated")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.map((ad) => (
|
||||
<tr key={String(ad.id)}>
|
||||
<td>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={ad.image}
|
||||
alt=""
|
||||
style={{ maxHeight: 40, maxWidth: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
<code>{ad.image}</code>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{ad.createdAt
|
||||
? ad.createdAt.toISOString().slice(0, 10)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/ads/${ad.id}`}>{t("edit")}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+153
-129
@@ -1,5 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Bell } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { sendHotelAlert } from "@/actions/admin-alerts";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -10,147 +10,171 @@ const CRITICAL = new Set(["critical", "error", "danger"]);
|
||||
const WARNING = new Set(["warning", "warn"]);
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
function severityColor(severity: string): string {
|
||||
switch (severity.toLowerCase()) {
|
||||
case "critical":
|
||||
case "error":
|
||||
case "danger":
|
||||
return "var(--color-danger)";
|
||||
case "warning":
|
||||
case "warn":
|
||||
return "var(--admin-accent)";
|
||||
case "success":
|
||||
return "var(--color-secondary)";
|
||||
case "info":
|
||||
case "notice":
|
||||
return "var(--color-accent)";
|
||||
default:
|
||||
return "var(--admin-text-muted)";
|
||||
}
|
||||
switch (severity.toLowerCase()) {
|
||||
case "critical":
|
||||
case "error":
|
||||
case "danger":
|
||||
return "var(--color-danger)";
|
||||
case "warning":
|
||||
case "warn":
|
||||
return "var(--admin-accent)";
|
||||
case "success":
|
||||
return "var(--color-secondary)";
|
||||
case "info":
|
||||
case "notice":
|
||||
return "var(--color-accent)";
|
||||
default:
|
||||
return "var(--admin-text-muted)";
|
||||
}
|
||||
}
|
||||
|
||||
function Badge({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "0.1rem 0.55rem",
|
||||
borderRadius: "999px",
|
||||
background: color,
|
||||
color: "#fff",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.02em",
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "0.1rem 0.55rem",
|
||||
borderRadius: "999px",
|
||||
background: color,
|
||||
color: "#fff",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.02em",
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminAlerts() {
|
||||
const t = await getTranslations("pages.admin.alerts");
|
||||
const t = await getTranslations("pages.admin.alerts");
|
||||
|
||||
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>>;
|
||||
try {
|
||||
alerts = await prisma.alertLogs.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
} catch {
|
||||
alerts = [];
|
||||
}
|
||||
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>>;
|
||||
try {
|
||||
alerts = await prisma.alertLogs.findMany({
|
||||
orderBy: { id: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
} catch {
|
||||
alerts = [];
|
||||
}
|
||||
|
||||
const critical = alerts.filter((a) => CRITICAL.has((a.severity || "").toLowerCase())).length;
|
||||
const warnings = alerts.filter((a) => WARNING.has((a.severity || "").toLowerCase())).length;
|
||||
const unread = alerts.filter((a) => !a.isRead).length;
|
||||
const critical = alerts.filter((a) =>
|
||||
CRITICAL.has((a.severity || "").toLowerCase()),
|
||||
).length;
|
||||
const warnings = alerts.filter((a) =>
|
||||
WARNING.has((a.severity || "").toLowerCase()),
|
||||
).length;
|
||||
const unread = alerts.filter((a) => !a.isRead).length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Bell size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Bell size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
|
||||
<StatusCard label={t("critical")} value={critical} state={critical > 0 ? "danger" : "ok"} icon="🚨" />
|
||||
<StatusCard
|
||||
label={t("warnings")}
|
||||
value={warnings}
|
||||
state={warnings > 0 ? "warn" : "neutral"}
|
||||
icon="⚠️"
|
||||
/>
|
||||
<StatusCard label={t("unread")} value={unread} state={unread > 0 ? "warn" : "ok"} icon="📨" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
|
||||
<StatusCard
|
||||
label={t("critical")}
|
||||
value={critical}
|
||||
state={critical > 0 ? "danger" : "ok"}
|
||||
icon="🚨"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("warnings")}
|
||||
value={warnings}
|
||||
state={warnings > 0 ? "warn" : "neutral"}
|
||||
icon="⚠️"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("unread")}
|
||||
value={unread}
|
||||
state={unread > 0 ? "warn" : "ok"}
|
||||
icon="📨"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form action={sendHotelAlert} className="admin-card mb-6">
|
||||
<h3 className="mt-0">{t("form.send")}</h3>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted">
|
||||
{t.rich("form.description", {
|
||||
code: (chunks) => <code>{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap items-start">
|
||||
<textarea
|
||||
name="message"
|
||||
required
|
||||
maxLength={1000}
|
||||
rows={2}
|
||||
placeholder={t("form.messagePlaceholder")}
|
||||
className="flex-1 min-w-[240px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("form.send")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form action={sendHotelAlert} className="admin-card mb-6">
|
||||
<h3 className="mt-0">{t("form.send")}</h3>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted">
|
||||
{t.rich("form.description", {
|
||||
code: (chunks) => <code>{chunks}</code>,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap items-start">
|
||||
<textarea
|
||||
name="message"
|
||||
required
|
||||
maxLength={1000}
|
||||
rows={2}
|
||||
placeholder={t("form.messagePlaceholder")}
|
||||
className="flex-1 min-w-[240px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("form.send")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="admin-card">
|
||||
<h3 className="mt-0">{t("recentAlerts", { count: alerts.length })}</h3>
|
||||
{alerts.length === 0 ? (
|
||||
<div className="admin-empty">{t("noAlerts")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colWhen")}</th>
|
||||
<th>{t("colSeverity")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colMessage")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{alerts.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{fromDate(a.createdAt)}
|
||||
</td>
|
||||
<td>
|
||||
<Badge label={a.severity || "—"} color={severityColor(a.severity)} />
|
||||
</td>
|
||||
<td>
|
||||
<Badge label={a.type || "—"} color="var(--admin-text-muted)" />
|
||||
</td>
|
||||
<td>{a.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
<section className="admin-card">
|
||||
<h3 className="mt-0">{t("recentAlerts", { count: alerts.length })}</h3>
|
||||
{alerts.length === 0 ? (
|
||||
<div className="admin-empty">{t("noAlerts")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colWhen")}</th>
|
||||
<th>{t("colSeverity")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colMessage")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{alerts.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{fromDate(a.createdAt)}
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
label={a.severity || "—"}
|
||||
color={severityColor(a.severity)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
label={a.type || "—"}
|
||||
color="var(--admin-text-muted)"
|
||||
/>
|
||||
</td>
|
||||
<td>{a.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -6,193 +6,213 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
async function getActivityData() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const todayStart = now - (now % 86400);
|
||||
const weekAgo = now - 7 * 86400;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const todayStart = now - (now % 86400);
|
||||
const weekAgo = now - 7 * 86400;
|
||||
|
||||
// Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries
|
||||
const rangeStart = todayStart - 6 * 86400;
|
||||
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
|
||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
// Daily activity breakdown — 4 GROUP BY queries instead of 28 sequential queries
|
||||
const rangeStart = todayStart - 6 * 86400;
|
||||
const [chatRows, cmdRows, banRows, regRows] = await Promise.all([
|
||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
|
||||
FROM chatlogs_room WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`,
|
||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
|
||||
FROM commandlogs WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`,
|
||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
SELECT FLOOR(timestamp / 86400) AS day_bucket, COUNT(*) AS count
|
||||
FROM bans WHERE timestamp >= ${rangeStart} GROUP BY day_bucket`,
|
||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
|
||||
FROM users WHERE account_created >= ${rangeStart} GROUP BY day_bucket`,
|
||||
]);
|
||||
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
|
||||
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]));
|
||||
const chatMap = toMap(chatRows);
|
||||
const cmdMap = toMap(cmdRows);
|
||||
const banMap = toMap(banRows);
|
||||
const regMap = toMap(regRows);
|
||||
]);
|
||||
const toMap = (rows: { day_bucket: bigint; count: bigint }[]) =>
|
||||
new Map(rows.map((r) => [Number(r.day_bucket), Number(r.count)]));
|
||||
const chatMap = toMap(chatRows);
|
||||
const cmdMap = toMap(cmdRows);
|
||||
const banMap = toMap(banRows);
|
||||
const regMap = toMap(regRows);
|
||||
|
||||
const dailyData = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const dayStart = todayStart - i * 86400;
|
||||
const bucket = Math.floor(dayStart / 86400);
|
||||
const date = new Date(dayStart * 1000);
|
||||
dailyData.push({
|
||||
label: date.toLocaleDateString("en", { weekday: "short", month: "short", day: "numeric" }),
|
||||
chats: chatMap.get(bucket) ?? 0,
|
||||
commands: cmdMap.get(bucket) ?? 0,
|
||||
bans: banMap.get(bucket) ?? 0,
|
||||
registrations: regMap.get(bucket) ?? 0,
|
||||
});
|
||||
}
|
||||
const dailyData = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const dayStart = todayStart - i * 86400;
|
||||
const bucket = Math.floor(dayStart / 86400);
|
||||
const date = new Date(dayStart * 1000);
|
||||
dailyData.push({
|
||||
label: date.toLocaleDateString("en", {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
chats: chatMap.get(bucket) ?? 0,
|
||||
commands: cmdMap.get(bucket) ?? 0,
|
||||
bans: banMap.get(bucket) ?? 0,
|
||||
registrations: regMap.get(bucket) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Totals
|
||||
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] = await Promise.all([
|
||||
prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
|
||||
]);
|
||||
const weekChats = Number(weekChatRows[0]?.count ?? 0);
|
||||
const weekCommands = Number(weekCommandRows[0]?.count ?? 0);
|
||||
// Totals
|
||||
const [weekChatRows, weekCommandRows, weekBans, weekRegistrations] =
|
||||
await Promise.all([
|
||||
prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) AS count FROM commandlogs WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
|
||||
]);
|
||||
const weekChats = Number(weekChatRows[0]?.count ?? 0);
|
||||
const weekCommands = Number(weekCommandRows[0]?.count ?? 0);
|
||||
|
||||
// Most active users (by chat messages)
|
||||
const topChatters = await prisma.$queryRaw<{ userId: number; messages: bigint }[]>`
|
||||
// Most active users (by chat messages)
|
||||
const topChatters = await prisma.$queryRaw<
|
||||
{ userId: number; messages: bigint }[]
|
||||
>`
|
||||
SELECT user_from_id AS userId, COUNT(*) AS messages
|
||||
FROM chatlogs_room WHERE timestamp >= ${weekAgo}
|
||||
GROUP BY user_from_id ORDER BY messages DESC LIMIT 10
|
||||
`;
|
||||
|
||||
const userIds = topChatters.map((c) => c.userId);
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||
const userIds = topChatters.map((c) => c.userId);
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
const userMap = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const topChattersWithNames = topChatters.map((c) => ({
|
||||
userId: c.userId,
|
||||
username: userMap.get(c.userId) ?? `User #${c.userId}`,
|
||||
messages: Number(c.messages),
|
||||
}));
|
||||
const topChattersWithNames = topChatters.map((c) => ({
|
||||
userId: c.userId,
|
||||
username: userMap.get(c.userId) ?? `User #${c.userId}`,
|
||||
messages: Number(c.messages),
|
||||
}));
|
||||
|
||||
return {
|
||||
dailyData,
|
||||
weekChats,
|
||||
weekCommands,
|
||||
weekBans,
|
||||
weekRegistrations,
|
||||
topChatters: topChattersWithNames,
|
||||
};
|
||||
return {
|
||||
dailyData,
|
||||
weekChats,
|
||||
weekCommands,
|
||||
weekBans,
|
||||
weekRegistrations,
|
||||
topChatters: topChattersWithNames,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ActivityPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const data = await getActivityData();
|
||||
const data = await getActivityData();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Activity</h1>
|
||||
<p className="text-muted-foreground">User activity breakdown and engagement metrics</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Activity</h1>
|
||||
<p className="text-muted-foreground">
|
||||
User activity breakdown and engagement metrics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Chat Messages"
|
||||
value={data.weekChats.toLocaleString()}
|
||||
description="Last 7 days"
|
||||
icon={<MessageSquare className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Commands"
|
||||
value={data.weekCommands.toLocaleString()}
|
||||
description="Last 7 days"
|
||||
icon={<Terminal className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Bans"
|
||||
value={data.weekBans.toLocaleString()}
|
||||
description="Last 7 days"
|
||||
icon={<Shield className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Registrations"
|
||||
value={data.weekRegistrations.toLocaleString()}
|
||||
description="Last 7 days"
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Chat Messages"
|
||||
value={data.weekChats.toLocaleString()}
|
||||
description="Last 7 days"
|
||||
icon={<MessageSquare className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Commands"
|
||||
value={data.weekCommands.toLocaleString()}
|
||||
description="Last 7 days"
|
||||
icon={<Terminal className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Bans"
|
||||
value={data.weekBans.toLocaleString()}
|
||||
description="Last 7 days"
|
||||
icon={<Shield className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Registrations"
|
||||
value={data.weekRegistrations.toLocaleString()}
|
||||
description="Last 7 days"
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Daily breakdown table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Breakdown (7 days)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<th className="text-left py-2 font-medium">Day</th>
|
||||
<th className="text-right py-2 font-medium">Chats</th>
|
||||
<th className="text-right py-2 font-medium">Cmds</th>
|
||||
<th className="text-right py-2 font-medium">Bans</th>
|
||||
<th className="text-right py-2 font-medium">Regs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.dailyData.map((day) => (
|
||||
<tr key={day.label} className="border-b last:border-0">
|
||||
<td className="py-2">{day.label}</td>
|
||||
<td className="text-right tabular-nums">{day.chats.toLocaleString()}</td>
|
||||
<td className="text-right tabular-nums">{day.commands.toLocaleString()}</td>
|
||||
<td className="text-right tabular-nums">{day.bans}</td>
|
||||
<td className="text-right tabular-nums">{day.registrations}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Daily breakdown table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Breakdown (7 days)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<th className="text-left py-2 font-medium">Day</th>
|
||||
<th className="text-right py-2 font-medium">Chats</th>
|
||||
<th className="text-right py-2 font-medium">Cmds</th>
|
||||
<th className="text-right py-2 font-medium">Bans</th>
|
||||
<th className="text-right py-2 font-medium">Regs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.dailyData.map((day) => (
|
||||
<tr key={day.label} className="border-b last:border-0">
|
||||
<td className="py-2">{day.label}</td>
|
||||
<td className="text-right tabular-nums">
|
||||
{day.chats.toLocaleString()}
|
||||
</td>
|
||||
<td className="text-right tabular-nums">
|
||||
{day.commands.toLocaleString()}
|
||||
</td>
|
||||
<td className="text-right tabular-nums">{day.bans}</td>
|
||||
<td className="text-right tabular-nums">
|
||||
{day.registrations}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top chatters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Most Active Users (7 days)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.topChatters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No chat activity</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{data.topChatters.map((user, i) => (
|
||||
<div key={user.userId} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 text-sm font-medium">{user.username}</span>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{user.messages.toLocaleString()} msgs
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Top chatters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Most Active Users (7 days)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.topChatters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No chat activity</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{data.topChatters.map((user, i) => (
|
||||
<div
|
||||
key={user.userId}
|
||||
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
|
||||
>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-bold">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="flex-1 text-sm font-medium">
|
||||
{user.username}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{user.messages.toLocaleString()} msgs
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,41 +8,48 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const RevenueChart = dynamic(
|
||||
() => import("@/components/admin/revenue-chart").then((m) => ({ default: m.RevenueChart })),
|
||||
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
|
||||
() =>
|
||||
import("@/components/admin/revenue-chart").then((m) => ({
|
||||
default: m.RevenueChart,
|
||||
})),
|
||||
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
|
||||
);
|
||||
|
||||
async function getEconomyData() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const todayStart = now - (now % 86400);
|
||||
const weekAgo = now - 7 * 86400;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const todayStart = now - (now % 86400);
|
||||
const weekAgo = now - 7 * 86400;
|
||||
|
||||
// Total currency in circulation — parallel instead of sequential
|
||||
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
|
||||
prisma.user.aggregate({ _sum: { credits: true } }),
|
||||
prisma.user.aggregate({ _sum: { pixels: true } }),
|
||||
prisma.user.aggregate({ _sum: { points: true } }),
|
||||
]);
|
||||
const totalCredits = creditsAgg._sum.credits ?? 0;
|
||||
const totalPixels = pixelsAgg._sum.pixels ?? 0;
|
||||
const totalPoints = pointsAgg._sum.points ?? 0;
|
||||
// Total currency in circulation — parallel instead of sequential
|
||||
const [creditsAgg, pixelsAgg, pointsAgg] = await Promise.all([
|
||||
prisma.user.aggregate({ _sum: { credits: true } }),
|
||||
prisma.user.aggregate({ _sum: { pixels: true } }),
|
||||
prisma.user.aggregate({ _sum: { points: true } }),
|
||||
]);
|
||||
const totalCredits = creditsAgg._sum.credits ?? 0;
|
||||
const totalPixels = pixelsAgg._sum.pixels ?? 0;
|
||||
const totalPoints = pointsAgg._sum.points ?? 0;
|
||||
|
||||
// Shop revenue (7 days)
|
||||
const shopRevenue = await prisma.logsShopPurchases.aggregate({
|
||||
where: { timestamp: { gte: weekAgo } },
|
||||
_sum: { costCredits: true, costPoints: true },
|
||||
_count: true,
|
||||
});
|
||||
// Shop revenue (7 days)
|
||||
const shopRevenue = await prisma.logsShopPurchases.aggregate({
|
||||
where: { timestamp: { gte: weekAgo } },
|
||||
_sum: { costCredits: true, costPoints: true },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
// Marketplace stats
|
||||
const marketplaceActive = await prisma.marketplaceItems.count({ where: { state: 1 } });
|
||||
const marketplaceSold = await prisma.marketplaceItems.count({
|
||||
where: { state: 2, soldTimestamp: { gte: weekAgo } },
|
||||
});
|
||||
// Marketplace stats
|
||||
const marketplaceActive = await prisma.marketplaceItems.count({
|
||||
where: { state: 1 },
|
||||
});
|
||||
const marketplaceSold = await prisma.marketplaceItems.count({
|
||||
where: { state: 2, soldTimestamp: { gte: weekAgo } },
|
||||
});
|
||||
|
||||
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
|
||||
const rangeStart = todayStart - 6 * 86400;
|
||||
const revRows = await prisma.$queryRaw<{ day_bucket: bigint; credits: number; points: number }[]>`
|
||||
// Revenue chart (7 days) — single GROUP BY query instead of 7 sequential queries
|
||||
const rangeStart = todayStart - 6 * 86400;
|
||||
const revRows = await prisma.$queryRaw<
|
||||
{ day_bucket: bigint; credits: number; points: number }[]
|
||||
>`
|
||||
SELECT FLOOR(timestamp / 86400) AS day_bucket,
|
||||
COALESCE(SUM(cost_credits), 0) AS credits,
|
||||
COALESCE(SUM(cost_points), 0) AS points
|
||||
@@ -50,146 +57,162 @@ async function getEconomyData() {
|
||||
WHERE timestamp >= ${rangeStart}
|
||||
GROUP BY day_bucket ORDER BY day_bucket
|
||||
`;
|
||||
const revMap = new Map(
|
||||
revRows.map((r) => [Number(r.day_bucket), { credits: Number(r.credits), points: Number(r.points) }]),
|
||||
);
|
||||
const revenueData = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const dayStart = todayStart - i * 86400;
|
||||
const bucket = Math.floor(dayStart / 86400);
|
||||
const date = new Date(dayStart * 1000);
|
||||
const entry = revMap.get(bucket);
|
||||
revenueData.push({
|
||||
label: date.toLocaleDateString("en", { weekday: "short" }),
|
||||
credits: entry?.credits ?? 0,
|
||||
points: entry?.points ?? 0,
|
||||
});
|
||||
}
|
||||
const revMap = new Map(
|
||||
revRows.map((r) => [
|
||||
Number(r.day_bucket),
|
||||
{ credits: Number(r.credits), points: Number(r.points) },
|
||||
]),
|
||||
);
|
||||
const revenueData = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const dayStart = todayStart - i * 86400;
|
||||
const bucket = Math.floor(dayStart / 86400);
|
||||
const date = new Date(dayStart * 1000);
|
||||
const entry = revMap.get(bucket);
|
||||
revenueData.push({
|
||||
label: date.toLocaleDateString("en", { weekday: "short" }),
|
||||
credits: entry?.credits ?? 0,
|
||||
points: entry?.points ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Trades (7 days)
|
||||
const weekTrades = await prisma.roomTradeLog.count({
|
||||
where: { timestamp: { gte: weekAgo } },
|
||||
});
|
||||
// Trades (7 days)
|
||||
const weekTrades = await prisma.roomTradeLog.count({
|
||||
where: { timestamp: { gte: weekAgo } },
|
||||
});
|
||||
|
||||
return {
|
||||
totalCredits,
|
||||
totalPixels,
|
||||
totalPoints,
|
||||
shopPurchases: typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
|
||||
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
|
||||
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
|
||||
marketplaceActive,
|
||||
marketplaceSold,
|
||||
weekTrades,
|
||||
revenueData,
|
||||
};
|
||||
return {
|
||||
totalCredits,
|
||||
totalPixels,
|
||||
totalPoints,
|
||||
shopPurchases:
|
||||
typeof shopRevenue._count === "number" ? shopRevenue._count : 0,
|
||||
shopCreditsSpent: shopRevenue._sum?.costCredits ?? 0,
|
||||
shopPointsSpent: shopRevenue._sum?.costPoints ?? 0,
|
||||
marketplaceActive,
|
||||
marketplaceSold,
|
||||
weekTrades,
|
||||
revenueData,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EconomyPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const data = await getEconomyData();
|
||||
const data = await getEconomyData();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Economy</h1>
|
||||
<p className="text-muted-foreground">Currency circulation, shop revenue and marketplace activity</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Economy</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Currency circulation, shop revenue and marketplace activity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Credits in Circulation"
|
||||
value={data.totalCredits.toLocaleString()}
|
||||
description="Total credits across all users"
|
||||
icon={<Coins className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Pixels in Circulation"
|
||||
value={data.totalPixels.toLocaleString()}
|
||||
description="Total pixels across all users"
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Points in Circulation"
|
||||
value={data.totalPoints.toLocaleString()}
|
||||
description="Total points across all users"
|
||||
icon={<TrendingDown className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Weekly Trades"
|
||||
value={data.weekTrades.toLocaleString()}
|
||||
description={`${data.marketplaceActive} marketplace listings`}
|
||||
icon={<ArrowLeftRight className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Credits in Circulation"
|
||||
value={data.totalCredits.toLocaleString()}
|
||||
description="Total credits across all users"
|
||||
icon={<Coins className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Pixels in Circulation"
|
||||
value={data.totalPixels.toLocaleString()}
|
||||
description="Total pixels across all users"
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Points in Circulation"
|
||||
value={data.totalPoints.toLocaleString()}
|
||||
description="Total points across all users"
|
||||
icon={<TrendingDown className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Weekly Trades"
|
||||
value={data.weekTrades.toLocaleString()}
|
||||
description={`${data.marketplaceActive} marketplace listings`}
|
||||
icon={<ArrowLeftRight className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RevenueChart data={data.revenueData} />
|
||||
<RevenueChart data={data.revenueData} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Shop Revenue (7d)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Purchases</span>
|
||||
<span className="font-medium">{data.shopPurchases}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Credits spent</span>
|
||||
<span className="font-medium">{data.shopCreditsSpent.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Points spent</span>
|
||||
<span className="font-medium">{data.shopPointsSpent.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Marketplace</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Active listings</span>
|
||||
<span className="font-medium">{data.marketplaceActive}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Sold (7d)</span>
|
||||
<span className="font-medium">{data.marketplaceSold}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Currency Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Credits</span>
|
||||
<span className="font-medium">{data.totalCredits.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Pixels</span>
|
||||
<span className="font-medium">{data.totalPixels.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Points</span>
|
||||
<span className="font-medium">{data.totalPoints.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Shop Revenue (7d)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Purchases</span>
|
||||
<span className="font-medium">{data.shopPurchases}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Credits spent</span>
|
||||
<span className="font-medium">
|
||||
{data.shopCreditsSpent.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Points spent</span>
|
||||
<span className="font-medium">
|
||||
{data.shopPointsSpent.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Marketplace</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Active listings</span>
|
||||
<span className="font-medium">{data.marketplaceActive}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Sold (7d)</span>
|
||||
<span className="font-medium">{data.marketplaceSold}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Currency Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Credits</span>
|
||||
<span className="font-medium">
|
||||
{data.totalCredits.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Pixels</span>
|
||||
<span className="font-medium">
|
||||
{data.totalPixels.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Points</span>
|
||||
<span className="font-medium">
|
||||
{data.totalPoints.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+188
-152
@@ -1,4 +1,11 @@
|
||||
import { ArrowLeftRight, Clock, DoorOpen, MessageSquare, TrendingUp, Users } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Clock,
|
||||
DoorOpen,
|
||||
MessageSquare,
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { redirect } from "next/navigation";
|
||||
import { StatsCard } from "@/components/admin/stats-card";
|
||||
@@ -8,66 +15,78 @@ import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const DashboardChart = dynamic(
|
||||
() => import("@/components/admin/dashboard-chart").then((m) => ({ default: m.DashboardChart })),
|
||||
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
|
||||
() =>
|
||||
import("@/components/admin/dashboard-chart").then((m) => ({
|
||||
default: m.DashboardChart,
|
||||
})),
|
||||
{ loading: () => <Skeleton className="h-[300px] w-full rounded-xl" /> },
|
||||
);
|
||||
|
||||
const PeakHoursHeatmap = dynamic(
|
||||
() => import("@/components/admin/peak-hours-heatmap").then((m) => ({ default: m.PeakHoursHeatmap })),
|
||||
{ loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> },
|
||||
() =>
|
||||
import("@/components/admin/peak-hours-heatmap").then((m) => ({
|
||||
default: m.PeakHoursHeatmap,
|
||||
})),
|
||||
{ loading: () => <Skeleton className="h-[250px] w-full rounded-xl" /> },
|
||||
);
|
||||
|
||||
async function getAnalyticsData() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const todayStart = now - (now % 86400);
|
||||
const weekAgo = now - 7 * 86400;
|
||||
const monthAgo = now - 30 * 86400;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const todayStart = now - (now % 86400);
|
||||
const weekAgo = now - 7 * 86400;
|
||||
const monthAgo = now - 30 * 86400;
|
||||
|
||||
const [
|
||||
totalUsers,
|
||||
onlineUsers,
|
||||
weekRegistrations,
|
||||
monthRegistrations,
|
||||
totalRooms,
|
||||
activeRooms,
|
||||
weekChatMessages,
|
||||
weekTrades,
|
||||
weekBans,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { online: "1" } }),
|
||||
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
|
||||
prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }),
|
||||
prisma.rooms.count(),
|
||||
prisma.rooms.count({ where: { users: { gt: 0 } } }),
|
||||
prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||
]);
|
||||
const [
|
||||
totalUsers,
|
||||
onlineUsers,
|
||||
weekRegistrations,
|
||||
monthRegistrations,
|
||||
totalRooms,
|
||||
activeRooms,
|
||||
weekChatMessages,
|
||||
weekTrades,
|
||||
weekBans,
|
||||
] = await Promise.all([
|
||||
prisma.user.count(),
|
||||
prisma.user.count({ where: { online: "1" } }),
|
||||
prisma.user.count({ where: { accountCreated: { gte: weekAgo } } }),
|
||||
prisma.user.count({ where: { accountCreated: { gte: monthAgo } } }),
|
||||
prisma.rooms.count(),
|
||||
prisma.rooms.count({ where: { users: { gt: 0 } } }),
|
||||
prisma.$queryRaw<
|
||||
{ count: bigint }[]
|
||||
>`SELECT COUNT(*) AS count FROM chatlogs_room WHERE timestamp >= ${weekAgo}`,
|
||||
prisma.roomTradeLog.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||
prisma.ban.count({ where: { timestamp: { gte: weekAgo } } }),
|
||||
]);
|
||||
|
||||
// Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
|
||||
const regRows = await prisma.$queryRaw<{ day_bucket: bigint; count: bigint }[]>`
|
||||
// Registration chart (30 days) — single GROUP BY query instead of 30 sequential queries
|
||||
const regRows = await prisma.$queryRaw<
|
||||
{ day_bucket: bigint; count: bigint }[]
|
||||
>`
|
||||
SELECT FLOOR(account_created / 86400) AS day_bucket, COUNT(*) AS count
|
||||
FROM users
|
||||
WHERE account_created >= ${monthAgo}
|
||||
GROUP BY day_bucket ORDER BY day_bucket
|
||||
`;
|
||||
const regCountMap = new Map(regRows.map((r) => [Number(r.day_bucket), Number(r.count)]));
|
||||
const registrationChart = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const dayStart = todayStart - i * 86400;
|
||||
const bucket = Math.floor(dayStart / 86400);
|
||||
const date = new Date(dayStart * 1000);
|
||||
registrationChart.push({
|
||||
label: date.toLocaleDateString("en", { month: "short", day: "numeric" }),
|
||||
value: regCountMap.get(bucket) ?? 0,
|
||||
});
|
||||
}
|
||||
const regCountMap = new Map(
|
||||
regRows.map((r) => [Number(r.day_bucket), Number(r.count)]),
|
||||
);
|
||||
const registrationChart = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const dayStart = todayStart - i * 86400;
|
||||
const bucket = Math.floor(dayStart / 86400);
|
||||
const date = new Date(dayStart * 1000);
|
||||
registrationChart.push({
|
||||
label: date.toLocaleDateString("en", { month: "short", day: "numeric" }),
|
||||
value: regCountMap.get(bucket) ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Peak hours — single GROUP BY query instead of fetching all user rows
|
||||
const peakRows = await prisma.$queryRaw<{ d: number; h: number; c: bigint }[]>`
|
||||
// Peak hours — single GROUP BY query instead of fetching all user rows
|
||||
const peakRows = await prisma.$queryRaw<
|
||||
{ d: number; h: number; c: bigint }[]
|
||||
>`
|
||||
SELECT ((DAYOFWEEK(FROM_UNIXTIME(last_online)) + 5) % 7) AS d,
|
||||
HOUR(FROM_UNIXTIME(last_online)) AS h,
|
||||
COUNT(*) AS c
|
||||
@@ -75,118 +94,135 @@ async function getAnalyticsData() {
|
||||
WHERE last_online >= ${weekAgo}
|
||||
GROUP BY d, h
|
||||
`;
|
||||
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
|
||||
let max = 0;
|
||||
for (const r of peakRows) {
|
||||
const count = Number(r.c);
|
||||
grid[r.d][r.h] = count;
|
||||
if (count > max) max = count;
|
||||
}
|
||||
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
|
||||
let max = 0;
|
||||
for (const r of peakRows) {
|
||||
const count = Number(r.c);
|
||||
grid[r.d][r.h] = count;
|
||||
if (count > max) max = count;
|
||||
}
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
onlineUsers,
|
||||
weekRegistrations,
|
||||
monthRegistrations,
|
||||
totalRooms,
|
||||
activeRooms,
|
||||
weekChatMessages: Array.isArray(weekChatMessages) ? Number(weekChatMessages[0]?.count ?? 0) : 0,
|
||||
weekTrades,
|
||||
weekBans,
|
||||
registrationChart,
|
||||
peakHours: { grid, max },
|
||||
};
|
||||
return {
|
||||
totalUsers,
|
||||
onlineUsers,
|
||||
weekRegistrations,
|
||||
monthRegistrations,
|
||||
totalRooms,
|
||||
activeRooms,
|
||||
weekChatMessages: Array.isArray(weekChatMessages)
|
||||
? Number(weekChatMessages[0]?.count ?? 0)
|
||||
: 0,
|
||||
weekTrades,
|
||||
weekBans,
|
||||
registrationChart,
|
||||
peakHours: { grid, max },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AnalyticsPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ANALYTICS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const data = await getAnalyticsData();
|
||||
const data = await getAnalyticsData();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
|
||||
<p className="text-muted-foreground">Hotel performance overview and trends</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Hotel performance overview and trends
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Total Users"
|
||||
value={data.totalUsers.toLocaleString()}
|
||||
description={`${data.onlineUsers} online now`}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Week Registrations"
|
||||
value={data.weekRegistrations.toLocaleString()}
|
||||
description={`${data.monthRegistrations} this month`}
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Rooms"
|
||||
value={data.activeRooms.toLocaleString()}
|
||||
description={`${data.totalRooms} total rooms`}
|
||||
icon={<DoorOpen className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Weekly Activity"
|
||||
value={data.weekChatMessages.toLocaleString()}
|
||||
description={`${data.weekTrades} trades, ${data.weekBans} bans`}
|
||||
icon={<MessageSquare className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Total Users"
|
||||
value={data.totalUsers.toLocaleString()}
|
||||
description={`${data.onlineUsers} online now`}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Week Registrations"
|
||||
value={data.weekRegistrations.toLocaleString()}
|
||||
description={`${data.monthRegistrations} this month`}
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Rooms"
|
||||
value={data.activeRooms.toLocaleString()}
|
||||
description={`${data.totalRooms} total rooms`}
|
||||
icon={<DoorOpen className="h-4 w-4" />}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Weekly Activity"
|
||||
value={data.weekChatMessages.toLocaleString()}
|
||||
description={`${data.weekTrades} trades, ${data.weekBans} bans`}
|
||||
icon={<MessageSquare className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<DashboardChart
|
||||
title="Registrations (30 days)"
|
||||
data={data.registrationChart}
|
||||
color="hsl(262, 83%, 58%)"
|
||||
description="New user registrations over the last 30 days"
|
||||
/>
|
||||
<PeakHoursHeatmap data={data.peakHours} />
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<DashboardChart
|
||||
title="Registrations (30 days)"
|
||||
data={data.registrationChart}
|
||||
color="hsl(262, 83%, 58%)"
|
||||
description="New user registrations over the last 30 days"
|
||||
/>
|
||||
<PeakHoursHeatmap data={data.peakHours} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" /> Chat Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{data.weekChatMessages.toLocaleString()}</div>
|
||||
<p className="text-sm text-muted-foreground">Messages in the last 7 days</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ArrowLeftRight className="h-4 w-4" /> Trades
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">{data.weekTrades.toLocaleString()}</div>
|
||||
<p className="text-sm text-muted-foreground">Trades in the last 7 days</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" /> Retention
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{data.totalUsers > 0 ? Math.round((data.onlineUsers / data.totalUsers) * 100) : 0}%
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Users online / total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" /> Chat Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{data.weekChatMessages.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Messages in the last 7 days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ArrowLeftRight className="h-4 w-4" /> Trades
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{data.weekTrades.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Trades in the last 7 days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" /> Retention
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold">
|
||||
{data.totalUsers > 0
|
||||
? Math.round((data.onlineUsers / data.totalUsers) * 100)
|
||||
: 0}
|
||||
%
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Users online / total
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +1,120 @@
|
||||
import Link from "next/link";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { dismissApplication } from "@/actions/admin-applications";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type ApplicationRow = {
|
||||
id: bigint;
|
||||
userId: number;
|
||||
rankId: number;
|
||||
content: string;
|
||||
createdAt: Date | null;
|
||||
id: bigint;
|
||||
userId: number;
|
||||
rankId: number;
|
||||
content: string;
|
||||
createdAt: Date | null;
|
||||
};
|
||||
|
||||
function formatDate(d: Date | null): string {
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
|
||||
}
|
||||
|
||||
export default async function AdminApplications() {
|
||||
const t = await getTranslations("pages.admin.applications");
|
||||
let applications: ApplicationRow[];
|
||||
try {
|
||||
applications = await prisma.websiteStaffApplications.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
} catch {
|
||||
applications = [];
|
||||
}
|
||||
const t = await getTranslations("pages.admin.applications");
|
||||
let applications: ApplicationRow[];
|
||||
try {
|
||||
applications = await prisma.websiteStaffApplications.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
} catch {
|
||||
applications = [];
|
||||
}
|
||||
|
||||
// Resolve applicant usernames (no relation defined on the model).
|
||||
const userMap = new Map<number, string>();
|
||||
if (applications.length > 0) {
|
||||
try {
|
||||
const ids = Array.from(new Set(applications.map((a) => a.userId)));
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
for (const u of users) userMap.set(u.id, u.username);
|
||||
} catch {
|
||||
// no DB — fall back to raw ids
|
||||
}
|
||||
}
|
||||
// Resolve applicant usernames (no relation defined on the model).
|
||||
const userMap = new Map<number, string>();
|
||||
if (applications.length > 0) {
|
||||
try {
|
||||
const ids = Array.from(new Set(applications.map((a) => a.userId)));
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
for (const u of users) userMap.set(u.id, u.username);
|
||||
} catch {
|
||||
// no DB — fall back to raw ids
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<ClipboardList size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<ClipboardList size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("pending")}
|
||||
value={applications.length}
|
||||
state={applications.length > 0 ? "warn" : "ok"}
|
||||
icon="📨"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("pending")}
|
||||
value={applications.length}
|
||||
state={applications.length > 0 ? "warn" : "ok"}
|
||||
icon="📨"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("applicationsTitle", { count: applications.length })}</h2>
|
||||
{applications.length === 0 ? (
|
||||
<div className="admin-empty">{t("noApplications")}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{applications.map((a) => {
|
||||
const username = userMap.get(a.userId);
|
||||
return (
|
||||
<article key={String(a.id)} className="admin-card p-3">
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<h3 className="m-0 text-sm font-semibold text-[var(--admin-text)]">
|
||||
{username ? (
|
||||
<Link href={`/admin/users/${a.userId}`}>{username}</Link>
|
||||
) : (
|
||||
<>{t("userNumber", { id: a.userId })}</>
|
||||
)}
|
||||
</h3>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{t("rankLabel", { rank: a.rankId })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted m-0 mb-2">
|
||||
{formatDate(a.createdAt)}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">{a.content}</p>
|
||||
<form action={dismissApplication}>
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("dismiss")}
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("applicationsTitle", { count: applications.length })}
|
||||
</h2>
|
||||
{applications.length === 0 ? (
|
||||
<div className="admin-empty">{t("noApplications")}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{applications.map((a) => {
|
||||
const username = userMap.get(a.userId);
|
||||
return (
|
||||
<article key={String(a.id)} className="admin-card p-3">
|
||||
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||
<h3 className="m-0 text-sm font-semibold text-[var(--admin-text)]">
|
||||
{username ? (
|
||||
<Link href={`/admin/users/${a.userId}`}>
|
||||
{username}
|
||||
</Link>
|
||||
) : (
|
||||
t("userNumber", { id: a.userId })
|
||||
)}
|
||||
</h3>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{t("rankLabel", { rank: a.rankId })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted m-0 mb-2">
|
||||
{formatDate(a.createdAt)}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--admin-text)] whitespace-pre-wrap m-0 mb-3">
|
||||
{a.content}
|
||||
</p>
|
||||
<form action={dismissApplication}>
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("dismiss")}
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Newspaper } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
@@ -9,60 +9,69 @@ import { prisma } from "@/lib/prisma";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EditArticle({
|
||||
params,
|
||||
searchParams,
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { error } = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
const article = await prisma.websiteArticles
|
||||
.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
select: { id: true, slug: true, title: true, shortStory: true, fullStory: true, image: true },
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!article) notFound();
|
||||
const { id } = await params;
|
||||
const { error } = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
const article = await prisma.websiteArticles
|
||||
.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
shortStory: true,
|
||||
fullStory: true,
|
||||
image: true,
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
if (!article) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("form.title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/articles">← {t("title")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
fd.set("id", String(article.id));
|
||||
await updateArticle(fd);
|
||||
}}
|
||||
defaultValues={{
|
||||
title: article.title,
|
||||
image: article.image,
|
||||
shortStory: article.shortStory,
|
||||
fullStory: article.fullStory,
|
||||
}}
|
||||
/>
|
||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(article.id)} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("form.deleteArticle")}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("form.title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/articles">← {t("title")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
fd.set("id", String(article.id));
|
||||
await updateArticle(fd);
|
||||
}}
|
||||
defaultValues={{
|
||||
title: article.title,
|
||||
image: article.image,
|
||||
shortStory: article.shortStory,
|
||||
fullStory: article.fullStory,
|
||||
}}
|
||||
/>
|
||||
<form action={deleteArticle} style={{ marginTop: "1rem" }}>
|
||||
<input type="hidden" name="id" value={String(article.id)} />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("form.deleteArticle")}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,38 @@
|
||||
import { Newspaper } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Newspaper } from "lucide-react";
|
||||
import { createArticle } from "@/actions/admin-articles";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
|
||||
export default async function NewArticle({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||
const { error } = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
export default async function NewArticle({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { error } = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("form.title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/articles">← {t("title")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm action={createArticle} />
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("form.title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
<Link href="/admin/articles">← {t("title")}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm action={createArticle} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+105
-83
@@ -1,96 +1,118 @@
|
||||
import { Newspaper } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Newspaper } from "lucide-react";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { deleteArticle } from "@/actions/admin-articles";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminArticles({ searchParams }: { searchParams: Promise<{ error?: string }> }) {
|
||||
const { error } = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
const articles = await prisma.websiteArticles
|
||||
.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, title: true, slug: true, createdAt: true },
|
||||
take: 50,
|
||||
})
|
||||
.catch(() => []);
|
||||
export default async function AdminArticles({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { error } = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
const articles = await prisma.websiteArticles
|
||||
.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, title: true, slug: true, createdAt: true },
|
||||
take: 50,
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
const latest = articles[0]?.createdAt ? articles[0].createdAt.toISOString().slice(0, 10) : "—";
|
||||
const latest = articles[0]?.createdAt
|
||||
? articles[0].createdAt.toISOString().slice(0, 10)
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Link href="/admin/articles/new" className="btn btn-primary">
|
||||
+ {t("newArticle")}
|
||||
</Link>
|
||||
</div>
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Newspaper size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin/articles/new" className="btn btn-primary">
|
||||
+ {t("newArticle")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] dark:text-[var(--admin-error)] mb-4">
|
||||
Error: {error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("articlesShown")} value={articles.length} icon="📰" />
|
||||
<StatusCard label={t("latest")} value={latest} hint={t("mostRecentPost")} icon="🕒" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("articlesShown")}
|
||||
value={articles.length}
|
||||
icon="📰"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("latest")}
|
||||
value={latest}
|
||||
hint={t("mostRecentPost")}
|
||||
icon="🕒"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
||||
{articles.length === 0 ? (
|
||||
<div className="admin-empty">{t("noArticles")}</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colTitle")}</th>
|
||||
<th>{t("colSlug")}</th>
|
||||
<th>{t("colCreated")}</th>
|
||||
<th>{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{articles.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td>
|
||||
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
<code>{a.slug}</code>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/admin/articles/${a.id}`} className="btn btn-primary">
|
||||
{t("edit")}
|
||||
</Link>
|
||||
<form action={deleteArticle} className="m-0">
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("delete")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
||||
{articles.length === 0 ? (
|
||||
<div className="admin-empty">{t("noArticles")}</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colTitle")}</th>
|
||||
<th>{t("colSlug")}</th>
|
||||
<th>{t("colCreated")}</th>
|
||||
<th>{t("colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{articles.map((a) => (
|
||||
<tr key={String(a.id)}>
|
||||
<td>
|
||||
<Link href={`/admin/articles/${a.id}`}>{a.title}</Link>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
<code>{a.slug}</code>
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{a.createdAt ? a.createdAt.toISOString().slice(0, 10) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={`/admin/articles/${a.id}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t("edit")}
|
||||
</Link>
|
||||
<form action={deleteArticle} className="m-0">
|
||||
<input type="hidden" name="id" value={String(a.id)} />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("delete")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+151
-134
@@ -1,150 +1,167 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { giveBadge } from "@/actions/admin-badges";
|
||||
import { uploadBadge } from "@/actions/admin-badge-upload";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Award } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { uploadBadge } from "@/actions/admin-badge-upload";
|
||||
import { giveBadge } from "@/actions/admin-badges";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata = { title: "Badges" };
|
||||
|
||||
export default async function AdminBadges({
|
||||
searchParams,
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ uploaded?: string; error?: string }>;
|
||||
searchParams: Promise<{ uploaded?: string; error?: string }>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.badges");
|
||||
const { uploaded, error } = await searchParams;
|
||||
const t = await getTranslations("pages.admin.badges");
|
||||
const { uploaded, error } = await searchParams;
|
||||
|
||||
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>>;
|
||||
try {
|
||||
badges = await prisma.websiteBadges.findMany({
|
||||
orderBy: { badgeName: "asc" },
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
badges = [];
|
||||
}
|
||||
let badges: Awaited<ReturnType<typeof prisma.websiteBadges.findMany>>;
|
||||
try {
|
||||
badges = await prisma.websiteBadges.findMany({
|
||||
orderBy: { badgeName: "asc" },
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
badges = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Award size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Award size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploaded && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)] text-xs">
|
||||
{t("uploadedMessage", { code: uploaded })}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--admin-error-subtle)] text-[var(--admin-error)] dark:text-[var(--admin-error)] text-xs">
|
||||
{t("errorMessage", { error })}
|
||||
</div>
|
||||
)}
|
||||
{uploaded && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--admin-success-subtle)] text-[var(--admin-success)] dark:text-[var(--admin-success)] text-xs">
|
||||
{t("uploadedMessage", { code: uploaded })}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--admin-error-subtle)] text-[var(--admin-error)] dark:text-[var(--admin-error)] text-xs">
|
||||
{t("errorMessage", { error })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("uploadTitle")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">{t("uploadSubtitle")}</p>
|
||||
<form action={uploadBadge} className="admin-card">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<input type="file" name="file" accept=".gif,.png" required />
|
||||
<input
|
||||
name="code"
|
||||
placeholder={t("badgeCodePlaceholder")}
|
||||
required
|
||||
maxLength={64}
|
||||
pattern="[A-Za-z0-9_-]{1,64}"
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("uploadButton")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("uploadTitle")}</h2>
|
||||
<p className="text-xs text-[var(--admin-text-muted)] -mt-2 mb-3">
|
||||
{t("uploadSubtitle")}
|
||||
</p>
|
||||
<form action={uploadBadge} className="admin-card">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<input type="file" name="file" accept=".gif,.png" required />
|
||||
<input
|
||||
name="code"
|
||||
placeholder={t("badgeCodePlaceholder")}
|
||||
required
|
||||
maxLength={64}
|
||||
pattern="[A-Za-z0-9_-]{1,64}"
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("uploadButton")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("giveTitle")}</h2>
|
||||
<form action={giveBadge} className="admin-card">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<input name="userId" type="number" min={1} placeholder={t("userIdPlaceholder")} required />
|
||||
<input
|
||||
name="code"
|
||||
placeholder={t("badgeCodePlaceholder")}
|
||||
required
|
||||
maxLength={32}
|
||||
className="flex-1 min-w-[160px]"
|
||||
list="badge-codes"
|
||||
/>
|
||||
<datalist id="badge-codes">
|
||||
{badges.map((b) => (
|
||||
<option key={String(b.id)} value={b.badgeKey}>
|
||||
{b.badgeName}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("giveButton")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">{t("giveTitle")}</h2>
|
||||
<form action={giveBadge} className="admin-card">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userIdPlaceholder")}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="code"
|
||||
placeholder={t("badgeCodePlaceholder")}
|
||||
required
|
||||
maxLength={32}
|
||||
className="flex-1 min-w-[160px]"
|
||||
list="badge-codes"
|
||||
/>
|
||||
<datalist id="badge-codes">
|
||||
{badges.map((b) => (
|
||||
<option key={String(b.id)} value={b.badgeKey}>
|
||||
{b.badgeName}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("giveButton")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("catalogueTitle")} ({badges.length})
|
||||
</h2>
|
||||
{badges.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBadges")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colCode")}</th>
|
||||
<th>{t("colName")}</th>
|
||||
<th>{t("colDescription")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{badges.map((b) => (
|
||||
<tr key={String(b.id)}>
|
||||
<td>
|
||||
<code>{b.badgeKey}</code>
|
||||
</td>
|
||||
<td>{b.badgeName}</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted">{b.badgeDescription}</td>
|
||||
<td>
|
||||
<form action={giveBadge} className="flex gap-2 items-center">
|
||||
<input type="hidden" name="code" value={b.badgeKey} />
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userIdPlaceholder")}
|
||||
required
|
||||
className="w-[90px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-outline">
|
||||
{t("giveButton")}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("catalogueTitle")} ({badges.length})
|
||||
</h2>
|
||||
{badges.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBadges")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colCode")}</th>
|
||||
<th>{t("colName")}</th>
|
||||
<th>{t("colDescription")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{badges.map((b) => (
|
||||
<tr key={String(b.id)}>
|
||||
<td>
|
||||
<code>{b.badgeKey}</code>
|
||||
</td>
|
||||
<td>{b.badgeName}</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
{b.badgeDescription}
|
||||
</td>
|
||||
<td>
|
||||
<form
|
||||
action={giveBadge}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<input type="hidden" name="code" value={b.badgeKey} />
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userIdPlaceholder")}
|
||||
required
|
||||
className="w-[90px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-outline">
|
||||
{t("giveButton")}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -13,230 +13,276 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
interface Banner {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
image: string;
|
||||
link: string;
|
||||
color: string;
|
||||
isActive: number;
|
||||
sortOrder: number;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
image: string;
|
||||
link: string;
|
||||
color: string;
|
||||
isActive: number;
|
||||
sortOrder: number;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
banners: Banner[];
|
||||
banners: Banner[];
|
||||
}
|
||||
|
||||
export function BannersManager({ banners }: Props) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
subtitle: "",
|
||||
image: "",
|
||||
link: "",
|
||||
color: "",
|
||||
isActive: 1,
|
||||
sortOrder: 0,
|
||||
startDate: "" as string | null,
|
||||
endDate: "" as string | null,
|
||||
});
|
||||
const { run, isPending } = useServerAction();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
subtitle: "",
|
||||
image: "",
|
||||
link: "",
|
||||
color: "",
|
||||
isActive: 1,
|
||||
sortOrder: 0,
|
||||
startDate: "" as string | null,
|
||||
endDate: "" as string | null,
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
setForm({
|
||||
title: "",
|
||||
subtitle: "",
|
||||
image: "",
|
||||
link: "",
|
||||
color: "",
|
||||
isActive: 1,
|
||||
sortOrder: 0,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
});
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
}
|
||||
function resetForm() {
|
||||
setForm({
|
||||
title: "",
|
||||
subtitle: "",
|
||||
image: "",
|
||||
link: "",
|
||||
color: "",
|
||||
isActive: 1,
|
||||
sortOrder: 0,
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
});
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
}
|
||||
|
||||
function handleEdit(b: Banner) {
|
||||
setForm({
|
||||
title: b.title,
|
||||
subtitle: b.subtitle,
|
||||
image: b.image,
|
||||
link: b.link,
|
||||
color: b.color,
|
||||
isActive: b.isActive,
|
||||
sortOrder: b.sortOrder,
|
||||
startDate: b.startDate,
|
||||
endDate: b.endDate,
|
||||
});
|
||||
setEditingId(b.id);
|
||||
setShowForm(true);
|
||||
}
|
||||
function handleEdit(b: Banner) {
|
||||
setForm({
|
||||
title: b.title,
|
||||
subtitle: b.subtitle,
|
||||
image: b.image,
|
||||
link: b.link,
|
||||
color: b.color,
|
||||
isActive: b.isActive,
|
||||
sortOrder: b.sortOrder,
|
||||
startDate: b.startDate,
|
||||
endDate: b.endDate,
|
||||
});
|
||||
setEditingId(b.id);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null };
|
||||
if (editingId) {
|
||||
run(() => updateBanner({ id: editingId, ...data }), { successMessage: "Banner updated!" });
|
||||
} else {
|
||||
run(() => createBanner(data), { successMessage: "Banner created!" });
|
||||
}
|
||||
resetForm();
|
||||
}
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
...form,
|
||||
startDate: form.startDate || null,
|
||||
endDate: form.endDate || null,
|
||||
};
|
||||
if (editingId) {
|
||||
run(() => updateBanner({ id: editingId, ...data }), {
|
||||
successMessage: "Banner updated!",
|
||||
});
|
||||
} else {
|
||||
run(() => createBanner(data), { successMessage: "Banner created!" });
|
||||
}
|
||||
resetForm();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowForm(!showForm);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" /> New Banner
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowForm(!showForm);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" /> New Banner
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{editingId ? "Edit Banner" : "New Banner"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subtitle</Label>
|
||||
<Input
|
||||
value={form.subtitle}
|
||||
onChange={(e) => setForm({ ...form, subtitle: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Image URL</Label>
|
||||
<Input
|
||||
value={form.image}
|
||||
onChange={(e) => setForm({ ...form, image: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Link URL</Label>
|
||||
<Input value={form.link} onChange={(e) => setForm({ ...form, link: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={form.color || "#000000"}
|
||||
onChange={(e) => setForm({ ...form, color: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Start Date</Label>
|
||||
<DateTimePicker
|
||||
value={form.startDate}
|
||||
onChange={(d) => setForm({ ...form, startDate: d ? d.toISOString() : null })}
|
||||
placeholder="Select start date"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>End Date</Label>
|
||||
<DateTimePicker
|
||||
value={form.endDate}
|
||||
onChange={(d) => setForm({ ...form, endDate: d ? d.toISOString() : null })}
|
||||
placeholder="Select end date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={form.isActive === 1}
|
||||
onCheckedChange={(v) => setForm({ ...form, isActive: v ? 1 : 0 })}
|
||||
/>
|
||||
<Label>Active</Label>
|
||||
</div>
|
||||
<div className="space-y-2 flex items-center gap-2">
|
||||
<Label>Order</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-20"
|
||||
value={form.sortOrder}
|
||||
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{editingId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{editingId ? "Edit Banner" : "New Banner"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, title: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subtitle</Label>
|
||||
<Input
|
||||
value={form.subtitle}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, subtitle: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Image URL</Label>
|
||||
<Input
|
||||
value={form.image}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, image: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Link URL</Label>
|
||||
<Input
|
||||
value={form.link}
|
||||
onChange={(e) => setForm({ ...form, link: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={form.color || "#000000"}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, color: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Start Date</Label>
|
||||
<DateTimePicker
|
||||
value={form.startDate}
|
||||
onChange={(d) =>
|
||||
setForm({
|
||||
...form,
|
||||
startDate: d ? d.toISOString() : null,
|
||||
})
|
||||
}
|
||||
placeholder="Select start date"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>End Date</Label>
|
||||
<DateTimePicker
|
||||
value={form.endDate}
|
||||
onChange={(d) =>
|
||||
setForm({ ...form, endDate: d ? d.toISOString() : null })
|
||||
}
|
||||
placeholder="Select end date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={form.isActive === 1}
|
||||
onCheckedChange={(v) =>
|
||||
setForm({ ...form, isActive: v ? 1 : 0 })
|
||||
}
|
||||
/>
|
||||
<Label>Active</Label>
|
||||
</div>
|
||||
<div className="space-y-2 flex items-center gap-2">
|
||||
<Label>Order</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-20"
|
||||
value={form.sortOrder}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, sortOrder: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={resetForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{editingId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{banners.length === 0 && (
|
||||
<p className="text-muted-foreground col-span-full text-center py-8">No banners yet.</p>
|
||||
)}
|
||||
{banners.map((b) => (
|
||||
<Card key={b.id} className="overflow-hidden">
|
||||
{b.image && (
|
||||
<div className="h-32 bg-cover bg-center" style={{ backgroundImage: `url(${b.image})` }} />
|
||||
)}
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">{b.title}</h3>
|
||||
{b.subtitle && <p className="text-xs text-muted-foreground">{b.subtitle}</p>}
|
||||
<div className="flex gap-1 mt-2">
|
||||
<Badge variant={b.isActive ? "default" : "outline"}>
|
||||
{b.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
{b.startDate && <Badge variant="secondary">Scheduled</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEdit(b)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() =>
|
||||
run(() => deleteBanner({ id: b.id }), { successMessage: "Banner deleted" })
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{banners.length === 0 && (
|
||||
<p className="text-muted-foreground col-span-full text-center py-8">
|
||||
No banners yet.
|
||||
</p>
|
||||
)}
|
||||
{banners.map((b) => (
|
||||
<Card key={b.id} className="overflow-hidden">
|
||||
{b.image && (
|
||||
<div
|
||||
className="h-32 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${b.image})` }}
|
||||
/>
|
||||
)}
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">{b.title}</h3>
|
||||
{b.subtitle && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.subtitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1 mt-2">
|
||||
<Badge variant={b.isActive ? "default" : "outline"}>
|
||||
{b.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
{b.startDate && (
|
||||
<Badge variant="secondary">Scheduled</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(b)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() =>
|
||||
run(() => deleteBanner({ id: b.id }), {
|
||||
successMessage: "Banner deleted",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,22 +4,24 @@ import { prisma } from "@/lib/prisma";
|
||||
import { BannersManager } from "./banners-manager";
|
||||
|
||||
export default async function BannersPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const banners = await prisma.websiteBanner.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
const banners = await prisma.websiteBanner.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Banners</h1>
|
||||
<p className="text-muted-foreground">Manage homepage banners and promotional slides</p>
|
||||
</div>
|
||||
<BannersManager banners={banners} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Banners</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage homepage banners and promotional slides
|
||||
</p>
|
||||
</div>
|
||||
<BannersManager banners={banners} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+138
-117
@@ -1,133 +1,154 @@
|
||||
import { Ban } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { createBan, liftBan } from "@/actions/admin-bans";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Ban } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fromUnix(ts: number): string {
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
return new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
export default async function AdminBans() {
|
||||
const t = await getTranslations("pages.admin.bans");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
|
||||
try {
|
||||
bans = await prisma.ban.findMany({
|
||||
where: { banExpire: { gt: now } },
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
} catch {
|
||||
bans = [];
|
||||
}
|
||||
const t = await getTranslations("pages.admin.bans");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let bans: Awaited<ReturnType<typeof prisma.ban.findMany>> = [];
|
||||
try {
|
||||
bans = await prisma.ban.findMany({
|
||||
where: { banExpire: { gt: now } },
|
||||
orderBy: { timestamp: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
} catch {
|
||||
bans = [];
|
||||
}
|
||||
|
||||
// banExpire of 0 means permanent in the emulator schema.
|
||||
const permanent = bans.filter((b) => b.banExpire === 0).length;
|
||||
const accountBans = bans.filter((b) => b.type === "account").length;
|
||||
// banExpire of 0 means permanent in the emulator schema.
|
||||
const permanent = bans.filter((b) => b.banExpire === 0).length;
|
||||
const accountBans = bans.filter((b) => b.type === "account").length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Ban size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Ban size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("activeBans")}
|
||||
value={bans.length}
|
||||
state={bans.length > 0 ? "warn" : "ok"}
|
||||
icon="🔨"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("permanent")}
|
||||
value={permanent}
|
||||
state={permanent > 0 ? "danger" : "neutral"}
|
||||
icon="⛔"
|
||||
/>
|
||||
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("activeBans")}
|
||||
value={bans.length}
|
||||
state={bans.length > 0 ? "warn" : "ok"}
|
||||
icon="🔨"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("permanent")}
|
||||
value={permanent}
|
||||
state={permanent > 0 ? "danger" : "neutral"}
|
||||
icon="⛔"
|
||||
/>
|
||||
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
|
||||
</div>
|
||||
|
||||
<form action={createBan} className="admin-card mb-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">{t("newBan")}</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input name="userId" type="number" min={1} placeholder={t("userIdPlaceholder")} required />
|
||||
<select name="type">
|
||||
<option value="account">{t("typeAccount")}</option>
|
||||
<option value="ip">{t("typeIp")}</option>
|
||||
<option value="machine">{t("typeMachine")}</option>
|
||||
<option value="super">{t("typeSuper")}</option>
|
||||
</select>
|
||||
<input name="hours" type="number" min={0} placeholder={t("hoursPlaceholder")} />
|
||||
<input name="reason" placeholder={t("reasonPlaceholder")} className="flex-1 min-w-[160px]" />
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("ban")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form action={createBan} className="admin-card mb-6">
|
||||
<h3 className="text-sm font-bold text-[var(--admin-text)] m-0 mb-3">
|
||||
{t("newBan")}
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
name="userId"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder={t("userIdPlaceholder")}
|
||||
required
|
||||
/>
|
||||
<select name="type">
|
||||
<option value="account">{t("typeAccount")}</option>
|
||||
<option value="ip">{t("typeIp")}</option>
|
||||
<option value="machine">{t("typeMachine")}</option>
|
||||
<option value="super">{t("typeSuper")}</option>
|
||||
</select>
|
||||
<input
|
||||
name="hours"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("hoursPlaceholder")}
|
||||
/>
|
||||
<input
|
||||
name="reason"
|
||||
placeholder={t("reasonPlaceholder")}
|
||||
className="flex-1 min-w-[160px]"
|
||||
/>
|
||||
<button type="submit" className="btn btn-danger">
|
||||
{t("ban")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("activeBans")} ({bans.length})
|
||||
</h2>
|
||||
{bans.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBans")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUserId")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colReason")}</th>
|
||||
<th>{t("colExpires")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bans.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>{b.userId}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{b.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>{b.banReason || "—"}</td>
|
||||
<td>
|
||||
{b.banExpire === 0 ? (
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
|
||||
{t("permanent")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{fromUnix(b.banExpire)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<form action={liftBan}>
|
||||
<input type="hidden" name="id" value={b.id} />
|
||||
<button type="submit" className="btn btn-outline">
|
||||
{t("lift")}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
<section className="mt-6">
|
||||
<h2 className="admin-section-title">
|
||||
{t("activeBans")} ({bans.length})
|
||||
</h2>
|
||||
{bans.length === 0 ? (
|
||||
<div className="admin-empty">{t("noBans")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colUserId")}</th>
|
||||
<th>{t("colType")}</th>
|
||||
<th>{t("colReason")}</th>
|
||||
<th>{t("colExpires")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bans.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>{b.userId}</td>
|
||||
<td>
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
|
||||
{b.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>{b.banReason || "—"}</td>
|
||||
<td>
|
||||
{b.banExpire === 0 ? (
|
||||
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
|
||||
{t("permanent")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{fromUnix(b.banExpire)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<form action={liftBan}>
|
||||
<input type="hidden" name="id" value={b.id} />
|
||||
<button type="submit" className="btn btn-outline">
|
||||
{t("lift")}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,181 +1,205 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Campaign = {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
startTimestamp: number;
|
||||
totalDays: number;
|
||||
lockExpired: string;
|
||||
enabled: string;
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
startTimestamp: number;
|
||||
totalDays: number;
|
||||
lockExpired: string;
|
||||
enabled: string;
|
||||
};
|
||||
|
||||
type Reward = {
|
||||
id: number;
|
||||
campaignId: number;
|
||||
productName: string;
|
||||
customImage: string;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
points: number;
|
||||
pointsType: number;
|
||||
badge: string;
|
||||
itemId: number;
|
||||
subscriptionType: string | null;
|
||||
subscriptionDays: number;
|
||||
id: number;
|
||||
campaignId: number;
|
||||
productName: string;
|
||||
customImage: string;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
points: number;
|
||||
pointsType: number;
|
||||
badge: string;
|
||||
itemId: number;
|
||||
subscriptionType: string | null;
|
||||
subscriptionDays: number;
|
||||
};
|
||||
|
||||
function fmtDate(ts: number): string {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function AdminCalendarCampaign({ params }: { params: Promise<{ id: string }> }) {
|
||||
const t = await getTranslations("pages.admin.calendar");
|
||||
export default async function AdminCalendarCampaign({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const t = await getTranslations("pages.admin.calendar");
|
||||
|
||||
const { id } = await params;
|
||||
const campaignId = Number(id);
|
||||
if (!Number.isInteger(campaignId)) notFound();
|
||||
const { id } = await params;
|
||||
const campaignId = Number(id);
|
||||
if (!Number.isInteger(campaignId)) notFound();
|
||||
|
||||
let campaign: Campaign | null = null;
|
||||
try {
|
||||
campaign = await prisma.calendarCampaigns.findUnique({
|
||||
where: { id: campaignId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
startTimestamp: true,
|
||||
totalDays: true,
|
||||
lockExpired: true,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
campaign = null;
|
||||
}
|
||||
if (!campaign) notFound();
|
||||
let campaign: Campaign | null = null;
|
||||
try {
|
||||
campaign = await prisma.calendarCampaigns.findUnique({
|
||||
where: { id: campaignId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
startTimestamp: true,
|
||||
totalDays: true,
|
||||
lockExpired: true,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
campaign = null;
|
||||
}
|
||||
if (!campaign) notFound();
|
||||
|
||||
let rewards: Reward[] = [];
|
||||
try {
|
||||
rewards = await prisma.calendarRewards.findMany({
|
||||
where: { campaignId },
|
||||
select: {
|
||||
id: true,
|
||||
campaignId: true,
|
||||
productName: true,
|
||||
customImage: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
points: true,
|
||||
pointsType: true,
|
||||
badge: true,
|
||||
itemId: true,
|
||||
subscriptionType: true,
|
||||
subscriptionDays: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
rewards = [];
|
||||
}
|
||||
let rewards: Reward[] = [];
|
||||
try {
|
||||
rewards = await prisma.calendarRewards.findMany({
|
||||
where: { campaignId },
|
||||
select: {
|
||||
id: true,
|
||||
campaignId: true,
|
||||
productName: true,
|
||||
customImage: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
points: true,
|
||||
pointsType: true,
|
||||
badge: true,
|
||||
itemId: true,
|
||||
subscriptionType: true,
|
||||
subscriptionDays: true,
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
rewards = [];
|
||||
}
|
||||
|
||||
const disabled = campaign.enabled !== "1";
|
||||
const disabled = campaign.enabled !== "1";
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted mb-4">
|
||||
<Link href="/admin/calendar">← {t("title")}</Link>
|
||||
</p>
|
||||
return (
|
||||
<main>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted mb-4">
|
||||
<Link href="/admin/calendar">← {t("title")}</Link>
|
||||
</p>
|
||||
|
||||
<div className="admin-card flex items-center gap-5 mb-6">
|
||||
{campaign.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="w-24 h-24 object-cover" src={campaign.image} alt={campaign.name} />
|
||||
) : null}
|
||||
<div>
|
||||
<h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
|
||||
{t("detail.campaignMeta", {
|
||||
id: campaign.id,
|
||||
days: campaign.totalDays,
|
||||
date: fmtDate(campaign.startTimestamp),
|
||||
})}
|
||||
{disabled ? ` · ${t("detail.disabled")}` : ` · ${t("detail.enabled")}`}
|
||||
{campaign.lockExpired === "1" ? ` · ${t("detail.locksExpired")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card flex items-center gap-5 mb-6">
|
||||
{campaign.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="w-24 h-24 object-cover"
|
||||
src={campaign.image}
|
||||
alt={campaign.name}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h1 className="m-0 mb-1">{campaign.name || `#${campaign.id}`}</h1>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
|
||||
{t("detail.campaignMeta", {
|
||||
id: campaign.id,
|
||||
days: campaign.totalDays,
|
||||
date: fmtDate(campaign.startTimestamp),
|
||||
})}
|
||||
{disabled
|
||||
? ` · ${t("detail.disabled")}`
|
||||
: ` · ${t("detail.enabled")}`}
|
||||
{campaign.lockExpired === "1"
|
||||
? ` · ${t("detail.locksExpired")}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="admin-section-title">{t("detail.rewards")}</h2>
|
||||
{rewards.length === 0 ? (
|
||||
<div className="admin-empty">{t("detail.noRewards")}</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--muted)]">
|
||||
<th>{t("detail.colId")}</th>
|
||||
<th>{t("detail.colProduct")}</th>
|
||||
<th>{t("detail.colItem")}</th>
|
||||
<th>{t("detail.colCredits")}</th>
|
||||
<th>{t("detail.colPixels")}</th>
|
||||
<th>{t("detail.colPoints")}</th>
|
||||
<th>{t("detail.colBadge")}</th>
|
||||
<th>{t("detail.colSubscription")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rewards.map((r) => (
|
||||
<tr key={r.id} className="border-t border-[var(--admin-border)]">
|
||||
<td>{r.id}</td>
|
||||
<td>
|
||||
{r.productName || <span className="text-sm theme-text-muted dark:theme-text-muted">—</span>}
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{r.itemId > 0 ? r.itemId : "—"}
|
||||
</td>
|
||||
<td>
|
||||
{r.credits > 0 ? (
|
||||
r.credits
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{r.pixels > 0 ? (
|
||||
r.pixels
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{r.points > 0 ? (
|
||||
`${r.points} (${t("detail.typeLabel", { type: r.pointsType })})`
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{r.badge || "—"}
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{r.subscriptionType ? `${r.subscriptionType} · ${r.subscriptionDays}d` : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<h2 className="admin-section-title">{t("detail.rewards")}</h2>
|
||||
{rewards.length === 0 ? (
|
||||
<div className="admin-empty">{t("detail.noRewards")}</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-[var(--muted)]">
|
||||
<th>{t("detail.colId")}</th>
|
||||
<th>{t("detail.colProduct")}</th>
|
||||
<th>{t("detail.colItem")}</th>
|
||||
<th>{t("detail.colCredits")}</th>
|
||||
<th>{t("detail.colPixels")}</th>
|
||||
<th>{t("detail.colPoints")}</th>
|
||||
<th>{t("detail.colBadge")}</th>
|
||||
<th>{t("detail.colSubscription")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rewards.map((r) => (
|
||||
<tr key={r.id} className="border-t border-[var(--admin-border)]">
|
||||
<td>{r.id}</td>
|
||||
<td>
|
||||
{r.productName || (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{r.itemId > 0 ? r.itemId : "—"}
|
||||
</td>
|
||||
<td>
|
||||
{r.credits > 0 ? (
|
||||
r.credits
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{r.pixels > 0 ? (
|
||||
r.pixels
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{r.points > 0 ? (
|
||||
`${r.points} (${t("detail.typeLabel", { type: r.pointsType })})`
|
||||
) : (
|
||||
<span className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{r.badge || "—"}
|
||||
</td>
|
||||
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
|
||||
{r.subscriptionType
|
||||
? `${r.subscriptionType} · ${r.subscriptionDays}d`
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted mt-4">
|
||||
{t("detail.rewardCount", { count: rewards.length })}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted mt-4">
|
||||
{t("detail.rewardCount", { count: rewards.length })}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+112
-102
@@ -1,123 +1,133 @@
|
||||
import { Calendar } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Calendar } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Campaign = {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
startTimestamp: number;
|
||||
totalDays: number;
|
||||
lockExpired: string;
|
||||
enabled: string;
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
startTimestamp: number;
|
||||
totalDays: number;
|
||||
lockExpired: string;
|
||||
enabled: string;
|
||||
};
|
||||
|
||||
function fmtDate(ts: number): string {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function AdminCalendar() {
|
||||
const t = await getTranslations("pages.admin.calendar");
|
||||
const t = await getTranslations("pages.admin.calendar");
|
||||
|
||||
let campaigns: Campaign[] = [];
|
||||
try {
|
||||
campaigns = await prisma.calendarCampaigns.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
startTimestamp: true,
|
||||
totalDays: true,
|
||||
lockExpired: true,
|
||||
enabled: true,
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
} catch {
|
||||
campaigns = [];
|
||||
}
|
||||
let campaigns: Campaign[] = [];
|
||||
try {
|
||||
campaigns = await prisma.calendarCampaigns.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
startTimestamp: true,
|
||||
totalDays: true,
|
||||
lockExpired: true,
|
||||
enabled: true,
|
||||
},
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
} catch {
|
||||
campaigns = [];
|
||||
}
|
||||
|
||||
const rewardCounts = new Map<number, number>();
|
||||
let totalRewards = 0;
|
||||
try {
|
||||
const rewards = await prisma.calendarRewards.findMany({
|
||||
select: { campaignId: true },
|
||||
});
|
||||
totalRewards = rewards.length;
|
||||
for (const r of rewards) {
|
||||
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
|
||||
}
|
||||
} catch {}
|
||||
const rewardCounts = new Map<number, number>();
|
||||
let totalRewards = 0;
|
||||
try {
|
||||
const rewards = await prisma.calendarRewards.findMany({
|
||||
select: { campaignId: true },
|
||||
});
|
||||
totalRewards = rewards.length;
|
||||
for (const r of rewards) {
|
||||
rewardCounts.set(r.campaignId, (rewardCounts.get(r.campaignId) ?? 0) + 1);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
|
||||
const enabledCount = campaigns.filter((c) => c.enabled === "1").length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Calendar size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle", { count: campaigns.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<Calendar size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("subtitle", { count: campaigns.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("campaigns")} value={campaigns.length} icon="📅" />
|
||||
<StatusCard
|
||||
label={t("enabled")}
|
||||
value={enabledCount}
|
||||
state={enabledCount > 0 ? "ok" : "neutral"}
|
||||
icon="✅"
|
||||
/>
|
||||
<StatusCard label={t("totalRewards")} value={totalRewards} icon="🎁" />
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("campaigns")} value={campaigns.length} icon="📅" />
|
||||
<StatusCard
|
||||
label={t("enabled")}
|
||||
value={enabledCount}
|
||||
state={enabledCount > 0 ? "ok" : "neutral"}
|
||||
icon="✅"
|
||||
/>
|
||||
<StatusCard label={t("totalRewards")} value={totalRewards} icon="🎁" />
|
||||
</div>
|
||||
|
||||
{campaigns.length === 0 ? (
|
||||
<div className="admin-empty">{t("noCampaigns")}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{campaigns.map((c) => {
|
||||
const enabled = c.enabled === "1";
|
||||
const count = rewardCounts.get(c.id) ?? 0;
|
||||
return (
|
||||
<Link key={c.id} href={`/admin/calendar/${c.id}`} className="admin-card hover article">
|
||||
{c.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="article-img" src={c.image} alt={c.name} />
|
||||
) : (
|
||||
<div className="article-img" />
|
||||
)}
|
||||
<div className="body">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<h3 className="m-0">{c.name || `#${c.id}`}</h3>
|
||||
<span
|
||||
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-[var(--admin-success-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]"}`}
|
||||
>
|
||||
{enabled ? t("statusEnabled") : t("statusDisabled")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
|
||||
{t("campaignMeta", { id: c.id, days: c.totalDays, rewards: count })}
|
||||
</p>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1">
|
||||
{t("startsAt", { date: fmtDate(c.startTimestamp) })}
|
||||
{c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
{campaigns.length === 0 ? (
|
||||
<div className="admin-empty">{t("noCampaigns")}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{campaigns.map((c) => {
|
||||
const enabled = c.enabled === "1";
|
||||
const count = rewardCounts.get(c.id) ?? 0;
|
||||
return (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/admin/calendar/${c.id}`}
|
||||
className="admin-card hover article"
|
||||
>
|
||||
{c.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="article-img" src={c.image} alt={c.name} />
|
||||
) : (
|
||||
<div className="article-img" />
|
||||
)}
|
||||
<div className="body">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<h3 className="m-0">{c.name || `#${c.id}`}</h3>
|
||||
<span
|
||||
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${enabled ? "bg-[var(--admin-success-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]"}`}
|
||||
>
|
||||
{enabled ? t("statusEnabled") : t("statusDisabled")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
|
||||
{t("campaignMeta", {
|
||||
id: c.id,
|
||||
days: c.totalDays,
|
||||
rewards: count,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted mt-1">
|
||||
{t("startsAt", { date: fmtDate(c.startTimestamp) })}
|
||||
{c.lockExpired === "1" ? ` · ${t("locksExpired")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -8,120 +8,119 @@ import { CatalogPageForm } from "./catalog-page-form";
|
||||
import { CatalogTranslateTab } from "./catalog-translate-tab";
|
||||
|
||||
interface CatalogPageData {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout: string;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
clubOnly: string;
|
||||
orderNum: number;
|
||||
iconImage: number;
|
||||
iconColor: number;
|
||||
pageHeadline: string;
|
||||
pageTeaser: string;
|
||||
pageSpecial: string | null;
|
||||
pageText1: string | null;
|
||||
pageText2: string | null;
|
||||
pageTextDetails: string | null;
|
||||
pageTextTeaser: string | null;
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout: string;
|
||||
enabled: string;
|
||||
visible: string;
|
||||
minRank: number;
|
||||
clubOnly: string;
|
||||
orderNum: number;
|
||||
iconImage: number;
|
||||
iconColor: number;
|
||||
pageHeadline: string;
|
||||
pageTeaser: string;
|
||||
pageSpecial: string | null;
|
||||
pageText1: string | null;
|
||||
pageText2: string | null;
|
||||
pageTextDetails: string | null;
|
||||
pageTextTeaser: string | null;
|
||||
}
|
||||
|
||||
interface BaseItemData {
|
||||
id: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
spriteId: number;
|
||||
type: string;
|
||||
id: number;
|
||||
publicName: string;
|
||||
itemName: string;
|
||||
spriteId: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface CatalogDetailTabsProps {
|
||||
catalogPage: CatalogPageData;
|
||||
items: CatalogItemData[];
|
||||
baseItems: BaseItemData[];
|
||||
catalogNameMap: Record<number, string>;
|
||||
childPages: { id: number; caption: string; enabled: string }[];
|
||||
pageId: number;
|
||||
canEdit: boolean;
|
||||
furniDataIdList: number[];
|
||||
furniDescriptionMap: Record<number, string>;
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
||||
allPages: { id: number; caption: string }[];
|
||||
interactionTypes: string[];
|
||||
catalogPage: CatalogPageData;
|
||||
items: CatalogItemData[];
|
||||
baseItems: BaseItemData[];
|
||||
catalogNameMap: Record<number, string>;
|
||||
childPages: { id: number; caption: string; enabled: string }[];
|
||||
pageId: number;
|
||||
canEdit: boolean;
|
||||
furniDataIdList: number[];
|
||||
furniDescriptionMap: Record<number, string>;
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
||||
allPages: { id: number; caption: string }[];
|
||||
interactionTypes: string[];
|
||||
}
|
||||
|
||||
export function CatalogDetailTabs({
|
||||
catalogPage,
|
||||
items,
|
||||
baseItems,
|
||||
catalogNameMap,
|
||||
childPages,
|
||||
pageId,
|
||||
canEdit,
|
||||
furniDataIdList,
|
||||
furniDescriptionMap,
|
||||
furniRevisionMap,
|
||||
allPages,
|
||||
interactionTypes,
|
||||
catalogPage,
|
||||
items,
|
||||
baseItems,
|
||||
catalogNameMap,
|
||||
childPages,
|
||||
pageId,
|
||||
canEdit,
|
||||
furniDataIdList,
|
||||
furniDescriptionMap,
|
||||
furniRevisionMap,
|
||||
allPages,
|
||||
interactionTypes,
|
||||
}: CatalogDetailTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue="settings">
|
||||
<TabsList>
|
||||
<TabsTrigger value="settings" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="items" className="gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Items ({items.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="translate" className="gap-2">
|
||||
<Languages className="h-4 w-4" />
|
||||
Translate ({baseItems.length})
|
||||
{(() => {
|
||||
const furniSet = new Set(furniDataIdList);
|
||||
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
|
||||
return missing > 0 ? (
|
||||
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
|
||||
{missing}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
return (
|
||||
<Tabs defaultValue="settings">
|
||||
<TabsList>
|
||||
<TabsTrigger value="settings" className="gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="items" className="gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Items ({items.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="translate" className="gap-2">
|
||||
<Languages className="h-4 w-4" />
|
||||
Translate ({baseItems.length}){(() => {
|
||||
const furniSet = new Set(furniDataIdList);
|
||||
const missing = baseItems.filter((b) => !furniSet.has(b.id)).length;
|
||||
return missing > 0 ? (
|
||||
<span className="ml-1 rounded-full bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] text-[10px] px-1.5 py-0.5 leading-none font-medium">
|
||||
{missing}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
<CatalogPageForm
|
||||
catalogPage={catalogPage}
|
||||
childPages={childPages}
|
||||
canEdit={canEdit}
|
||||
allPages={allPages}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
<CatalogPageForm
|
||||
catalogPage={catalogPage}
|
||||
childPages={childPages}
|
||||
canEdit={canEdit}
|
||||
allPages={allPages}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="items" className="mt-4">
|
||||
<CatalogItemsTable
|
||||
items={items}
|
||||
pageId={pageId}
|
||||
pageLayout={catalogPage.pageLayout}
|
||||
canEdit={canEdit}
|
||||
interactionTypes={interactionTypes}
|
||||
furniRevisionMap={furniRevisionMap}
|
||||
allPages={allPages}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="items" className="mt-4">
|
||||
<CatalogItemsTable
|
||||
items={items}
|
||||
pageId={pageId}
|
||||
pageLayout={catalogPage.pageLayout}
|
||||
canEdit={canEdit}
|
||||
interactionTypes={interactionTypes}
|
||||
furniRevisionMap={furniRevisionMap}
|
||||
allPages={allPages}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="translate" className="mt-4">
|
||||
<CatalogTranslateTab
|
||||
baseItems={baseItems}
|
||||
catalogNameMap={catalogNameMap}
|
||||
canEdit={canEdit}
|
||||
furniDataIdList={furniDataIdList}
|
||||
furniDescriptionMap={furniDescriptionMap}
|
||||
furniRevisionMap={furniRevisionMap}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
<TabsContent value="translate" className="mt-4">
|
||||
<CatalogTranslateTab
|
||||
baseItems={baseItems}
|
||||
catalogNameMap={catalogNameMap}
|
||||
canEdit={canEdit}
|
||||
furniDataIdList={furniDataIdList}
|
||||
furniDescriptionMap={furniDescriptionMap}
|
||||
furniRevisionMap={furniRevisionMap}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -4,4 +4,7 @@
|
||||
*/
|
||||
export { CatalogItemsTable } from "./catalog-items-table/catalog-items-table";
|
||||
export { FurniIcon } from "./catalog-items-table/furni-icon";
|
||||
export type { BaseItemData, CatalogItemData } from "./catalog-items-table/types";
|
||||
export type {
|
||||
BaseItemData,
|
||||
CatalogItemData,
|
||||
} from "./catalog-items-table/types";
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 735 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user